mirror of
https://github.com/agno-agi/agno.git
synced 2026-10-11 22:09:58 +00:00
feat: support .zip and .eml file uploads in AgentOS (#9193)
## Summary Adds `.zip` and `.eml` to the set of file types AgentOS upload routers accept. Previously these were rejected with "Unsupported file type" (HTTP 400) because neither MIME type was present in `DOCUMENT_MIME_TYPES`/`_DOCUMENT_EXTENSION_MIME` (`libs/agno/agno/os/utils.py`) or `FileMedia.valid_mime_types()` (`libs/agno/agno/media.py`). - `.eml` (`message/rfc822`) is a standard email format, same use case as the already-supported `.msg`. - `.zip` (`application/zip`) is accepted as an opaque binary attachment only — no server-side extraction or inspection of its contents. Unpacking is left to the caller's own `pre_hooks`/workflow logic, consistent with how other binary formats (`.doc`, `.ppt`, `.msg`) are already passed through without being parsed by Agno itself. Also updated `EXTENSION_CATEGORY` and `_DOCUMENT_EXTENSION_MIME` so uploads with a missing/generic `content_type` (`application/octet-stream` or empty) still resolve to the correct MIME type from the filename extension, following the existing fallback pattern used for `.md`/`.pptx`. Closes #9191 ## Type of change - [ ] Bug fix - [x] New feature - [ ] Breaking change - [ ] Improvement - [ ] Model update - [ ] Other: --- ## Checklist - [x] Code complies with style guidelines - [x] Ran format/validation scripts (`./scripts/format.sh` and `./scripts/validate.sh`) - [x] Self-review completed - [x] Documentation updated (comments, docstrings) - [ ] Examples and guides: Relevant cookbook examples have been included or updated (if applicable) - [x] Tested in clean environment - [x] Tests added/updated ### Duplicate and AI-Generated PR Check - [x] I have searched existing [open pull requests](../../pulls) and confirmed that no other PR already addresses this issue - [ ] If a similar PR exists, I have explained below why this PR is a better approach - [x] Check if this PR was entirely AI-generated (by Copilot, Claude Code, Cursor, etc.) **This PR was generated with the assistance of Claude Code.** The change was reviewed and understood by the author before submission. --- ## Additional Notes Not all model providers can process `.zip`/`.eml` content directly (e.g. Anthropic/Gemini reject arbitrary binary/container formats) — such uploads succeed at the router but fail later with a provider-level error, consistent with existing behavior for other binary Office formats (`.doc`, `.ppt`, `.xls`). Related: #7330, #7329, #8064 --------- Co-authored-by: luis.martins <[email protected]> Co-authored-by: Claude Sonnet 5 <[email protected]> Co-authored-by: Kaustubh <[email protected]> Co-authored-by: Ashpreet <[email protected]> Co-authored-by: Ashpreet <[email protected]> Co-authored-by: Sannya Singal <[email protected]> Co-authored-by: Harsh <[email protected]> Co-authored-by: Harsh Sinha <[email protected]>
This commit is contained in:
co-authored by
luis.martins
Claude Sonnet 5
Kaustubh
Ashpreet
Ashpreet
Sannya Singal
Harsh
Harsh Sinha
parent
0011d6f360
commit
393cee470f
@@ -1,8 +1,8 @@
|
||||
# Run Lifecycle
|
||||
|
||||
These five examples cover the lifecycle APIs around an AgentOS agent run:
|
||||
These six examples cover the lifecycle APIs around an AgentOS agent run:
|
||||
background submission and polling, cancellation, resumable SSE, persisted
|
||||
checkpoints, and background hooks.
|
||||
checkpoints, background hooks, and preprocessing uploads before the model call.
|
||||
|
||||
## Setup
|
||||
|
||||
@@ -24,6 +24,7 @@ Each server example listens on port 7777. Start only one at a time.
|
||||
| `sse_reconnect.py` | Resume both a new background SSE run and a background continuation with raw `httpx`. |
|
||||
| `checkpoints.py` | List `tool-batch` checkpoints and continue from a selected `message_index`. |
|
||||
| `hooks_in_background.py` | Choose AgentOS-wide background hooks or mix blocking and per-hook background work. |
|
||||
| `unpack_archives.py` | Replace an uploaded `.zip` with the files inside it from a `pre_hook`. |
|
||||
|
||||
## Run states
|
||||
|
||||
|
||||
@@ -93,6 +93,26 @@ persisted its eval run.
|
||||
|
||||
---
|
||||
|
||||
### unpack_archives.py
|
||||
|
||||
**Status:** PASS
|
||||
|
||||
**Test mode:** LIVE
|
||||
|
||||
**Description:** Started the checked-in unpacking AgentOS server and uploaded
|
||||
`.zip` archives over HTTP to `/agents/unpack-agent/runs` using OpenAI Responses
|
||||
`gpt-5.5`. Covered a DEFLATE archive holding `invoice.txt` and `notes.md`, and a
|
||||
macOS Finder archive holding a single PDF alongside its `__MACOSX` entry.
|
||||
|
||||
**Result:** Both runs returned `COMPLETED`. The pre-hook replaced each archive
|
||||
with its contents before the model call, and the agent quoted values that exist
|
||||
only inside the archives (`AGNO-9193`, `4242.00 EUR`, `Acme Corp`, `ZEBRA-7`).
|
||||
The Finder archive resolved its PDF and skipped the `__MACOSX` sidecar entry.
|
||||
Without the pre-hook the same upload fails at the provider with an unsupported
|
||||
MIME type, since no provider unpacks an archive.
|
||||
|
||||
---
|
||||
|
||||
## Validation
|
||||
|
||||
- `pytest cookbook/scripts/tests/test_check_cookbook_pattern.py -q`:
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
"""
|
||||
Unpack uploaded .zip files before the model call
|
||||
================================================
|
||||
|
||||
AgentOS accepts a ``.zip`` upload, but no model provider opens one: the archive
|
||||
is forwarded as the same compressed bytes that left the browser, so the model
|
||||
sees a blob rather than the files inside it.
|
||||
|
||||
A ``pre_hook`` runs after the upload is received and before the model is called,
|
||||
and it is handed ``run_input.files``. Replacing that list with the archive's
|
||||
contents is what the model then receives, so an agent can answer from a zip
|
||||
without AgentOS ever having to unpack one itself.
|
||||
|
||||
Prerequisites: OPENAI_API_KEY
|
||||
Run: .venvs/demo/bin/python cookbook/05_agent_os/04_run_lifecycle/unpack_archives.py
|
||||
Try: Upload any .zip to the Unpack Agent from the AgentOS UI and ask what is inside it
|
||||
"""
|
||||
|
||||
import io
|
||||
import mimetypes
|
||||
import zipfile
|
||||
|
||||
from agno.agent import Agent
|
||||
from agno.db.sqlite import SqliteDb
|
||||
from agno.media import File
|
||||
from agno.models.openai import OpenAIResponses
|
||||
from agno.os import AgentOS
|
||||
from agno.run.agent import RunInput
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Create Unpacking AgentOS
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def unpack_archives(run_input: RunInput) -> None:
|
||||
"""Replace every uploaded .zip with the files it contains."""
|
||||
if not run_input.files:
|
||||
return
|
||||
|
||||
unpacked: list[File] = []
|
||||
for uploaded in run_input.files:
|
||||
if uploaded.mime_type != "application/zip" or not uploaded.content:
|
||||
unpacked.append(uploaded)
|
||||
continue
|
||||
|
||||
with zipfile.ZipFile(io.BytesIO(uploaded.content)) as archive:
|
||||
for entry in archive.infolist():
|
||||
# Archives written by macOS Finder carry a hidden "__MACOSX/._<name>"
|
||||
# entry per file holding Finder metadata rather than content.
|
||||
if entry.is_dir() or entry.filename.startswith("__MACOSX/"):
|
||||
continue
|
||||
name = entry.filename.rsplit("/", 1)[-1]
|
||||
if name.startswith("._"):
|
||||
continue
|
||||
|
||||
# File validates mime_type, so resolve it per entry and fall back to
|
||||
# None for anything outside File.valid_mime_types().
|
||||
mime_type, _ = mimetypes.guess_type(name)
|
||||
if mime_type not in File.valid_mime_types():
|
||||
mime_type = None
|
||||
|
||||
unpacked.append(
|
||||
File(
|
||||
content=archive.read(entry),
|
||||
filename=name,
|
||||
format=name.rsplit(".", 1)[-1].lower(),
|
||||
mime_type=mime_type,
|
||||
)
|
||||
)
|
||||
|
||||
run_input.files = unpacked
|
||||
|
||||
|
||||
db = SqliteDb(
|
||||
id="unpack-db",
|
||||
db_file="tmp/agent_os_unpack.db",
|
||||
)
|
||||
|
||||
unpack_agent = Agent(
|
||||
id="unpack-agent",
|
||||
name="Unpack Agent",
|
||||
model=OpenAIResponses(id="gpt-5.5"),
|
||||
db=db,
|
||||
pre_hooks=[unpack_archives],
|
||||
instructions="Answer only from the attached files and quote the exact values you read.",
|
||||
)
|
||||
|
||||
agent_os = AgentOS(
|
||||
id="unpack-os",
|
||||
db=db,
|
||||
agents=[unpack_agent],
|
||||
)
|
||||
app = agent_os.get_app()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Run Unpacking AgentOS
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
if __name__ == "__main__":
|
||||
agent_os.serve(app=app, port=7777)
|
||||
@@ -761,6 +761,8 @@ class File(BaseModel):
|
||||
"application/vnd.ms-powerpoint", # .ppt
|
||||
"application/vnd.ms-excel", # .xls
|
||||
"application/vnd.ms-outlook", # .msg
|
||||
"application/zip", # .zip
|
||||
"message/rfc822", # .eml
|
||||
"text/javascript",
|
||||
"application/x-python",
|
||||
"text/x-python",
|
||||
|
||||
@@ -1055,6 +1055,8 @@ DOCUMENT_MIME_TYPES = {
|
||||
"application/vnd.ms-powerpoint", # .ppt
|
||||
"application/vnd.ms-excel", # .xls
|
||||
"application/vnd.ms-outlook", # .msg
|
||||
"application/zip", # .zip
|
||||
"message/rfc822", # .eml
|
||||
"text/javascript",
|
||||
"application/x-python",
|
||||
"text/x-python",
|
||||
@@ -1067,6 +1069,17 @@ DOCUMENT_MIME_TYPES = {
|
||||
"text/rtf",
|
||||
}
|
||||
|
||||
# Other registered names for types already listed above (Windows browsers send
|
||||
# `application/x-zip-compressed` for `.zip`). Normalize at the upload boundary so a file
|
||||
# is not rejected for arriving under its alternate spelling.
|
||||
_DOCUMENT_MIME_TYPE_ALIASES = {
|
||||
"application/x-zip": "application/zip",
|
||||
"application/x-zip-compressed": "application/zip",
|
||||
"application/xml": "text/xml",
|
||||
"application/rtf": "text/rtf",
|
||||
"application/javascript": "text/javascript",
|
||||
}
|
||||
|
||||
# Fallback mapping from file extension to media category. Used when the browser sends a
|
||||
# missing or ambiguous content type (e.g. `application/octet-stream` or empty for `.md`
|
||||
# and `.pptx`, which are not in every OS MIME registry).
|
||||
@@ -1082,6 +1095,8 @@ EXTENSION_CATEGORY: Dict[str, str] = {
|
||||
"xlsx": "document",
|
||||
"xls": "document",
|
||||
"msg": "document",
|
||||
"zip": "document",
|
||||
"eml": "document",
|
||||
"py": "document",
|
||||
"txt": "document",
|
||||
"html": "document",
|
||||
@@ -1127,6 +1142,13 @@ EXTENSION_CATEGORY: Dict[str, str] = {
|
||||
_AMBIGUOUS_CONTENT_TYPES = {None, "", "application/octet-stream"}
|
||||
|
||||
|
||||
def _normalize_document_mime_type(content_type: Optional[str]) -> Optional[str]:
|
||||
"""Map an alternate MIME spelling onto the type listed in DOCUMENT_MIME_TYPES."""
|
||||
if content_type is None:
|
||||
return None
|
||||
return _DOCUMENT_MIME_TYPE_ALIASES.get(content_type, content_type)
|
||||
|
||||
|
||||
def classify_upload_file(file: UploadFile) -> Optional[str]:
|
||||
"""Classify an uploaded file into one of: image, audio, video, document.
|
||||
|
||||
@@ -1134,7 +1156,7 @@ def classify_upload_file(file: UploadFile) -> Optional[str]:
|
||||
to be useful (common for `.md` and `.pptx` uploaded from browsers), falls back to the
|
||||
filename extension. Returns None if the file type is not supported.
|
||||
"""
|
||||
content_type = file.content_type
|
||||
content_type = _normalize_document_mime_type(file.content_type)
|
||||
if content_type in IMAGE_MIME_TYPES:
|
||||
return "image"
|
||||
if content_type in AUDIO_MIME_TYPES:
|
||||
@@ -1186,6 +1208,8 @@ _DOCUMENT_EXTENSION_MIME: Dict[str, str] = {
|
||||
"xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"xls": "application/vnd.ms-excel",
|
||||
"msg": "application/vnd.ms-outlook",
|
||||
"zip": "application/zip",
|
||||
"eml": "message/rfc822",
|
||||
"py": "text/x-python",
|
||||
"txt": "text/plain",
|
||||
"html": "text/html",
|
||||
@@ -1206,12 +1230,13 @@ def _resolve_document_mime_type(file: UploadFile) -> Optional[str]:
|
||||
documents with ambiguous content types (e.g. `.md` sent as octet-stream) still get a
|
||||
mime_type accepted by `FileMedia`.
|
||||
"""
|
||||
if file.content_type and file.content_type in DOCUMENT_MIME_TYPES:
|
||||
return file.content_type
|
||||
content_type = _normalize_document_mime_type(file.content_type)
|
||||
if content_type and content_type in DOCUMENT_MIME_TYPES:
|
||||
return content_type
|
||||
if file.filename and "." in file.filename:
|
||||
extension = file.filename.rsplit(".", 1)[-1].lower()
|
||||
return _DOCUMENT_EXTENSION_MIME.get(extension)
|
||||
return file.content_type
|
||||
return content_type
|
||||
|
||||
|
||||
def process_document(file: UploadFile, metadata: Optional[Dict[str, Any]] = None) -> Optional[FileMedia]:
|
||||
@@ -1237,8 +1262,9 @@ def extract_format(file: UploadFile) -> Optional[str]:
|
||||
return file.filename.split(".")[-1].lower()
|
||||
|
||||
# Fallback to the file content_type
|
||||
if file.content_type:
|
||||
return file.content_type.strip().split("/")[-1]
|
||||
content_type = _normalize_document_mime_type(file.content_type)
|
||||
if content_type:
|
||||
return content_type.strip().split("/")[-1]
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import json
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from agno.agent.agent import Agent
|
||||
from agno.media import File as FileMedia
|
||||
from agno.media import Image
|
||||
@@ -199,6 +201,26 @@ class TestAgentFileUploads:
|
||||
assert docs[0].filename == "contract.pdf"
|
||||
assert docs[0].metadata == {"category": "legal"}
|
||||
|
||||
@pytest.mark.parametrize("content_type", ["application/x-zip", "application/x-zip-compressed"])
|
||||
def test_upload_zip_mime_alias(self, test_os_client, test_agent: Agent, content_type: str):
|
||||
with (
|
||||
patch.object(test_agent, "deep_copy", return_value=test_agent),
|
||||
patch.object(test_agent, "arun", new_callable=AsyncMock) as mock_arun,
|
||||
):
|
||||
mock_arun.return_value = MockRunOutput()
|
||||
files = [("files", ("archive.zip", b"zip-data", content_type))]
|
||||
|
||||
resp = self._post_agent_files(test_os_client, test_agent.id, files)
|
||||
|
||||
assert resp.status_code == 200
|
||||
docs = mock_arun.call_args.kwargs["files"]
|
||||
assert len(docs) == 1
|
||||
assert isinstance(docs[0], FileMedia)
|
||||
assert docs[0].content == b"zip-data"
|
||||
assert docs[0].filename == "archive.zip"
|
||||
assert docs[0].format == "zip"
|
||||
assert docs[0].mime_type == "application/zip"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Team endpoint — file uploads
|
||||
@@ -294,3 +316,23 @@ class TestTeamFileUploads:
|
||||
assert isinstance(docs[0], FileMedia)
|
||||
assert docs[0].filename == "report.pdf"
|
||||
assert docs[0].metadata == {"category": "report"}
|
||||
|
||||
@pytest.mark.parametrize("content_type", ["application/x-zip", "application/x-zip-compressed"])
|
||||
def test_team_upload_zip_mime_alias(self, test_os_client, test_team: Team, content_type: str):
|
||||
with (
|
||||
patch.object(test_team, "deep_copy", return_value=test_team),
|
||||
patch.object(test_team, "arun", new_callable=AsyncMock) as mock_arun,
|
||||
):
|
||||
mock_arun.return_value = MockRunOutput()
|
||||
files = [("files", ("archive.zip", b"zip-data", content_type))]
|
||||
|
||||
resp = self._post_team_files(test_os_client, test_team.id, files)
|
||||
|
||||
assert resp.status_code == 200
|
||||
docs = mock_arun.call_args.kwargs["files"]
|
||||
assert len(docs) == 1
|
||||
assert isinstance(docs[0], FileMedia)
|
||||
assert docs[0].content == b"zip-data"
|
||||
assert docs[0].filename == "archive.zip"
|
||||
assert docs[0].format == "zip"
|
||||
assert docs[0].mime_type == "application/zip"
|
||||
|
||||
@@ -401,7 +401,7 @@ class TestNonStreamingRoutes:
|
||||
async def test_mixed_files_categorized_correctly(self):
|
||||
agent_mock = make_agent_mock()
|
||||
mock_slack = make_slack_mock(token="xoxb-test")
|
||||
mock_httpx = make_httpx_mock([b"csv-data", b"img-data", b"zip-data"])
|
||||
mock_httpx = make_httpx_mock([b"csv-data", b"img-data", b"mystery-data"])
|
||||
|
||||
with (
|
||||
patch("agno.os.interfaces.slack.router.verify_slack_signature", return_value=True),
|
||||
@@ -417,7 +417,7 @@ class TestNonStreamingRoutes:
|
||||
[
|
||||
{"id": "F5", "name": "data.csv", "mimetype": "text/csv"},
|
||||
{"id": "F6", "name": "pic.jpg", "mimetype": "image/jpeg"},
|
||||
{"id": "F7", "name": "bundle.zip", "mimetype": "application/zip"},
|
||||
{"id": "F7", "name": "mystery.xyz", "mimetype": "application/x-mystery"},
|
||||
]
|
||||
)
|
||||
resp = make_signed_request(client, body)
|
||||
@@ -435,7 +435,7 @@ class TestNonStreamingRoutes:
|
||||
async def test_non_whitelisted_mime_type_passes_none(self):
|
||||
agent_mock = make_agent_mock()
|
||||
mock_slack = make_slack_mock(token="xoxb-test")
|
||||
mock_httpx = make_httpx_mock(b"zipdata")
|
||||
mock_httpx = make_httpx_mock(b"mysterydata")
|
||||
|
||||
with (
|
||||
patch("agno.os.interfaces.slack.router.verify_slack_signature", return_value=True),
|
||||
@@ -447,14 +447,14 @@ class TestNonStreamingRoutes:
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
client = TestClient(app)
|
||||
body = slack_event_with_files([{"id": "F1", "name": "archive.zip", "mimetype": "application/zip"}])
|
||||
body = slack_event_with_files([{"id": "F1", "name": "mystery.xyz", "mimetype": "application/x-mystery"}])
|
||||
resp = make_signed_request(client, body)
|
||||
|
||||
assert resp.status_code == 200
|
||||
await wait_for_call(agent_mock.arun)
|
||||
uploaded_file = agent_mock.arun.call_args.kwargs["files"][0]
|
||||
assert uploaded_file.mime_type is None
|
||||
assert uploaded_file.content == b"zipdata"
|
||||
assert uploaded_file.content == b"mysterydata"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_streaming_clears_status_after_response(self):
|
||||
|
||||
@@ -121,6 +121,8 @@ DOCUMENT_FORMATS = [
|
||||
("sheet.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"),
|
||||
("sheet.xls", "application/vnd.ms-excel"),
|
||||
("email.msg", "application/vnd.ms-outlook"),
|
||||
("archive.zip", "application/zip"),
|
||||
("email.eml", "message/rfc822"),
|
||||
("module.py", "text/x-python"),
|
||||
("readme.txt", "text/plain"),
|
||||
("page.html", "text/html"),
|
||||
@@ -131,6 +133,16 @@ DOCUMENT_FORMATS = [
|
||||
("doc.rtf", "text/rtf"),
|
||||
]
|
||||
|
||||
# Alternate spellings clients send for formats already in DOCUMENT_FORMATS:
|
||||
# (filename, MIME the client sends, canonical MIME it must resolve to).
|
||||
MIME_TYPE_ALIASES = [
|
||||
("archive.zip", "application/x-zip", "application/zip"),
|
||||
("archive.zip", "application/x-zip-compressed", "application/zip"),
|
||||
("feed.xml", "application/xml", "text/xml"),
|
||||
("doc.rtf", "application/rtf", "text/rtf"),
|
||||
("script.js", "application/javascript", "text/javascript"),
|
||||
]
|
||||
|
||||
|
||||
class TestClassifyUploadFile:
|
||||
"""Tests for classify_upload_file, including the extension fallback for ambiguous content types."""
|
||||
@@ -151,6 +163,10 @@ class TestClassifyUploadFile:
|
||||
def test_all_document_content_types_route_to_document(self, filename, content_type):
|
||||
assert classify_upload_file(_make_upload_file(filename, content_type)) == "document"
|
||||
|
||||
@pytest.mark.parametrize("filename, content_type, _canonical", MIME_TYPE_ALIASES)
|
||||
def test_mime_aliases_route_to_document(self, filename, content_type, _canonical):
|
||||
assert classify_upload_file(_make_upload_file(filename, content_type)) == "document"
|
||||
|
||||
@pytest.mark.parametrize("filename, _content_type", DOCUMENT_FORMATS)
|
||||
@pytest.mark.parametrize("ambiguous", ["application/octet-stream", "", None])
|
||||
def test_documents_fall_back_to_extension(self, filename, _content_type, ambiguous):
|
||||
@@ -162,7 +178,6 @@ class TestClassifyUploadFile:
|
||||
|
||||
def test_unsupported_type_returns_none(self):
|
||||
"""Genuinely unsupported files must still be rejected (router raises 400)."""
|
||||
assert classify_upload_file(_make_upload_file("archive.zip", "application/zip")) is None
|
||||
assert classify_upload_file(_make_upload_file("mystery.xyz", "application/octet-stream")) is None
|
||||
assert classify_upload_file(_make_upload_file("noext", "application/octet-stream")) is None
|
||||
|
||||
@@ -189,6 +204,22 @@ class TestProcessDocument:
|
||||
assert result.mime_type == content_type
|
||||
assert result.format == filename.rsplit(".", 1)[-1]
|
||||
|
||||
@pytest.mark.parametrize("filename, content_type, canonical", MIME_TYPE_ALIASES)
|
||||
def test_mime_aliases_are_canonicalized(self, filename, content_type, canonical):
|
||||
result = process_document(_make_upload_file(filename, content_type, b"data"))
|
||||
|
||||
assert result is not None
|
||||
assert result.mime_type == canonical
|
||||
assert result.format == filename.rsplit(".", 1)[-1]
|
||||
|
||||
@pytest.mark.parametrize("filename, content_type, canonical", MIME_TYPE_ALIASES)
|
||||
def test_mime_aliases_without_extension_use_canonical_format(self, filename, content_type, canonical):
|
||||
"""Without an extension the format falls back to the content type, which must be canonical."""
|
||||
result = process_document(_make_upload_file("attachment", content_type, b"data"))
|
||||
|
||||
assert result is not None
|
||||
assert result.format == canonical.split("/")[-1]
|
||||
|
||||
def test_empty_file_raises(self):
|
||||
from fastapi import HTTPException
|
||||
|
||||
|
||||
Reference in New Issue
Block a user