Adding an extraction tool¶
This guide explains how to add a new metadata extraction tool to the Metadata Extraction Service (MES). Adding a tool never requires changes to the orchestration code (MES-REQ-4-007). It takes three things:
- An adapter class that knows how to invoke the tool.
- An entry in
MES_TOOLS(Django settings) that tells MES the adapter exists and how to run it. - The tool itself installed where the service runs (for the Docker image, a build argument).
All paths below are relative to the service/ directory.
How the pieces fit together¶
settings.MES_TOOLS["mytool"] apps/extraction/adapters/mytool.py
adapter: "...MyToolAdapter" ───────► class MyToolAdapter(SubprocessToolAdapter)
kind, invocation_command, build_command(file_path) -> argv
extra_args, timeout_seconds,
enabled, mandatory
│
▼
tool_config.py validates the entry at startup (ExtractionConfig.ready())
registry.py imports the adapter class from its dotted path
orchestrator.py instantiates it with the config values and calls adapter.run(file)
Key properties to keep in mind:
- Raw output is returned unmodified (MES-REQ-3-004). An adapter only runs the tool and labels its output; it must not parse, reshape or merge it.
- Failures are isolated.
adapter.run()never raises. Non-zero exits, missing binaries and timeouts become afailureortimeoutstatus on that tool's result, and other tools' results are still returned. If the tool ismandatory, its failure additionally produces a top-level error. - Misconfiguration fails at startup, not mid-request: every
MES_TOOLSentry is validated and every adapter path is imported when Django starts.
Step 1: Write the adapter¶
Create apps/extraction/adapters/<tool>.py. For a command-line tool, subclass SubprocessToolAdapter and implement build_command().
Example, for FFprobe:
from .base import SubprocessToolAdapter
class FFprobeAdapter(SubprocessToolAdapter):
tool_name = "ffprobe"
invocation_command = "ffprobe"
raw_output_format = "json"
def build_command(self, file_path) -> list[str]:
return [
self.invocation_command,
"-v", "error",
"-print_format", "json",
"-show_format", "-show_streams",
*self.extra_args,
str(file_path),
]
What each part does:
| Attribute / method | Purpose |
|---|---|
tool_name |
Name reported in results. Must equal the key you use in MES_TOOLS (see Gotchas). |
invocation_command |
Default binary. Used unless MES_TOOLS sets invocation_command, which overrides it. |
raw_output_format |
Label for the output: "json", "xml" or "text". Describes what the tool prints; MES does not convert it. |
build_command(file_path) |
Returns the full argv as a list (no shell). Put *self.extra_args before the file path so settings-supplied flags are applied. |
get_tool_version() (optional) |
Return the tool's version string, reported in the result's tool metadata. Defaults to None. |
Behaviour inherited from SubprocessToolAdapter, which you normally do not override:
- The command runs with
check=Trueand the configured timeout. - Exit code ≠ 0 →
failure(stderr is included in the error message). subprocess.TimeoutExpired→timeout.- Exit code 0 but text on stderr →
successwith that text recorded as a warning (MES-REQ-5-001). - The executed command is recorded in the result for provenance.
Tools that run as an HTTP service¶
For an externally hosted tool (MES-REQ-1-005), such as an Apache Tika server, subclass HTTPToolAdapter and implement build_request() instead. It returns the keyword arguments for requests.request(...):
from .base import HTTPToolAdapter
class TikaAdapter(HTTPToolAdapter):
tool_name = "tika"
raw_output_format = "json"
def build_request(self, file_path) -> dict:
with open(file_path, "rb") as handle:
body = handle.read()
return {
"method": "PUT",
"url": f"{self.base_url}/rmeta/text",
"headers": {"Accept": "application/json"},
"data": body,
}
For HTTP tools the required invocation_command setting holds the base URL (for example http://tika:9998) and is available as self.base_url. extra_args is not passed to HTTP adapters. Timeouts map to timeout; connection errors and non-2xx responses map to failure.
Tools that need something else¶
If a tool is neither a CLI nor an HTTP service, subclass BaseToolAdapter and implement invoke(file_path) -> (raw_output, raw_output_format). You may raise from invoke(); list the exception types in timeout_exceptions and failure_exceptions so run() can map them to a status. Note that the orchestrator only constructs SubprocessToolAdapter and HTTPToolAdapter subclasses with their config arguments (HTTPToolAdapter subclasses get base_url, everything else gets invocation_command, extra_args and timeout_seconds), so a custom base class must accept those keyword arguments.
Step 2: Register the tool in MES_TOOLS¶
Add an entry to MES_TOOLS in web/settings/base.py. Environment-specific overrides can go in dev.py, prod.py or test.py.
MES_TOOLS = {
# ... existing tools ...
"ffprobe": {
"adapter": "apps.extraction.adapters.ffprobe.FFprobeAdapter",
"kind": "subprocess",
# Optional: omit to use the adapter's default ("ffprobe" from PATH).
"invocation_command": os.environ.get("MES_FFPROBE_COMMAND", "ffprobe"),
"extra_args": [],
"timeout_seconds": 30,
"enabled": True,
"mandatory": False,
},
}
Configuration keys¶
| Key | Required | Default | Description |
|---|---|---|---|
adapter |
yes | – | Dotted path to the adapter class. Imported at startup; an import error stops the service from starting. |
kind |
yes | – | "subprocess" (local CLI) or "http" (externally hosted). Any other value is rejected at startup. |
invocation_command |
http: yes; subprocess: no |
adapter's invocation_command |
Binary name or absolute path for subprocess tools; set it only to override the adapter's default (for example a non-PATH install). Base URL for http tools, where it is required because adapters have no default URL. |
extra_args |
no | [] |
Additional arguments inserted by the adapter's build_command() (subprocess tools only). |
timeout_seconds |
no | 30 |
Per-invocation timeout. Increase for slow tools or large files. |
enabled |
no | True |
Disabled tools stay listed by GET /api/v1/tools/ but are never selected. |
mandatory |
no | False |
If True, a failure or timeout of this tool is reported as a top-level error (MES-REQ-3-006). If False, it is reported as a warning and the other tools' results are still returned. |
The dictionary key ("ffprobe" above) is the tool's name everywhere: in the tools listing, in result output, and when selecting the default tool.
Making it the default tool¶
At present, every request runs the default tool (MES-REQ-1-001). Mapping file formats to several tools through the File Format Library is a later build step. To make the new tool the default, set the MES_DEFAULT_TOOL environment variable to its key:
MES_DEFAULT_TOOL=ffprobe
The default tool must exist in MES_TOOLS and be enabled, or requests fail with a "tool not configured" error.
Step 3: Install the tool¶
The service only calls the binary; it does not install it.
- Local development: install the tool with your package manager and make sure it is on
PATH(or set theinvocation_commandto an absolute path). - Docker: the
Dockerfileinstalls tools viaINSTALL_<TOOL>build arguments. FFprobe (packageffmpeg) and ImageMagick already have switches. For a tool without one: - Add
ARG INSTALL_MYTOOL=falseto theDockerfile. - Add a line to the package list, for example
[ "$INSTALL_MYTOOL" = "true" ] && packages="$packages <apt-package>". - Pass the argument through in
docker-compose.ymlunderbuild.args, and add it to.env.example.
Tools that are not in Debian's repositories (or that are Java services like Tika) need their own install steps, or should run as a separate container used through an http adapter.
- Overriding the binary path: if you read the command from an environment variable, as the built-in tools do (MES_EXIFTOOL_COMMAND, MES_MEDIAINFO_COMMAND), add that variable to docker-compose.yml as well if you want to set it there.
Step 4: Add tests¶
Mirror the existing tests in tests/extraction/adapters/. Mock subprocess.run so the tests do not need the tool installed:
import subprocess
from unittest import mock
from django.test import SimpleTestCase
from apps.extraction.adapters.base import ToolExecutionStatus
from apps.extraction.adapters.ffprobe import FFprobeAdapter
class FFprobeAdapterTests(SimpleTestCase):
def test_build_command(self):
command = FFprobeAdapter(timeout_seconds=5).build_command("/tmp/file.mp4")
self.assertEqual(command[0], "ffprobe")
self.assertEqual(command[-1], "/tmp/file.mp4")
@mock.patch("apps.extraction.adapters.base.subprocess.run")
def test_success_returns_raw_output(self, mock_run):
mock_run.return_value = subprocess.CompletedProcess(
args=["ffprobe"], returncode=0, stdout='{"format":{}}', stderr=""
)
result = FFprobeAdapter(timeout_seconds=5).run("/tmp/file.mp4")
self.assertEqual(result.status, ToolExecutionStatus.SUCCESS)
self.assertEqual(result.raw_output, '{"format":{}}')
@mock.patch("apps.extraction.adapters.base.subprocess.run")
def test_non_zero_exit_maps_to_failure(self, mock_run):
mock_run.side_effect = subprocess.CalledProcessError(
returncode=1, cmd=["ffprobe"], stderr="invalid data"
)
result = FFprobeAdapter(timeout_seconds=5).run("/tmp/file.mp4")
self.assertEqual(result.status, ToolExecutionStatus.FAILURE)
@mock.patch("apps.extraction.adapters.base.subprocess.run")
def test_timeout_maps_to_timeout(self, mock_run):
mock_run.side_effect = subprocess.TimeoutExpired(cmd="ffprobe", timeout=5)
result = FFprobeAdapter(timeout_seconds=5).run("/tmp/file.mp4")
self.assertEqual(result.status, ToolExecutionStatus.TIMEOUT)
Run the suite from service/:
python manage.py test
Step 5: Verify¶
- Start the service (
python manage.py runserver, ordocker compose up --build). A bad entry or unimportable adapter stops startup with anImproperlyConfiguredmessage naming the offending key. - Check that the tool is listed:
curl http://localhost:8000/api/v1/tools/
The response shows name, kind, enabled, mandatory, default and timeout. Invocation details are deliberately not exposed. 3. If the tool is the default, run an extraction:
curl -F file=@sample.mp4 http://localhost:8000/api/v1/extract/
The tool's raw output appears in the result together with its name, version, status and the command that was run.
Gotchas¶
- The
MES_TOOLSkey must match the adapter'stool_name. Results are matched back to configured tools by name, so a mismatch makes a successful run look like "no result", and amandatorytool would then be reported as failed. - Do not parse or "fix" output in the adapter. Harmonised metadata belongs in a separate layer; the adapter's output is the tool's raw output.
- Build the argv as a list. Never join it into a shell string, since uploaded file names are untrusted.
- Choose flags that make the tool exit non-zero on failure. Failure detection relies on the exit code; tools that exit 0 on error will be reported as successful.
- Set a realistic
timeout_seconds. The Gunicorn worker timeout in theDockerfileis 120 s; a tool timeout above that will be cut short by the server. - Disabling is safer than deleting. Set
"enabled": Falseto take a tool out of use while keeping it visible inGET /api/v1/tools/.