Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
2f5ab91
feat: add obstore remote file metadata and facade
mplemay Feb 9, 2026
12da96e
feat: refine remote file metadata and model-bound store
mplemay Feb 9, 2026
6eff430
build: updated the project dependencies
mplemay Feb 10, 2026
ba97d04
build: updated the project dependencies
mplemay Feb 10, 2026
d2a8e56
fix: harden remote file cleanup and upload metadata
mplemay Feb 11, 2026
1c696e5
feat: move remote file ops onto model instances
mplemay Feb 11, 2026
dee3ed9
refactor: rename remote file and storage types
mplemay Feb 11, 2026
e5972d1
refactor: consolidate file support into types.file
mplemay Feb 11, 2026
40696ec
refactor: split file module into file storage and helpers
mplemay Feb 11, 2026
761291f
refactor: replace remote file mapper hooks with field handles
mplemay Feb 11, 2026
f00750f
refactor: use walrus operator in file storage guards
mplemay Feb 11, 2026
84cd6df
refactor: split remote metadata from file handle api
mplemay Feb 11, 2026
abeeb2b
refactor: split remote file handle and rename file extra
mplemay Feb 11, 2026
5dd6d7a
chore: deduplicate all dependency group extras
mplemay Feb 11, 2026
81b7730
refactor: require primary key mixin for remote files
mplemay Feb 11, 2026
541fd19
refactor: rename file metadata api and centralize utc helpers
mplemay Feb 11, 2026
9d7e7d8
refactor: freeze remote file handle and rename file modules
mplemay Feb 11, 2026
f9578e7
refactor: move obstore ops onto remotefile
mplemay Feb 11, 2026
90edfdc
refactor: remove unused remote metadata fields
mplemay Feb 11, 2026
93ce003
refactor: remove deleted metadata status
mplemay Feb 11, 2026
5dfaf38
refactor: move file lifecycle listeners to singleton
mplemay Feb 11, 2026
f0e2741
test: reorganize file type tests and add integration coverage
mplemay Feb 11, 2026
e59b30a
test: add async deferred delete lifecycle coverage
mplemay Feb 11, 2026
0113441
fix: restore deferred putresult contract for file uploads
mplemay Feb 11, 2026
3eff019
fix: require sqlalchemy session for remote file mutations
mplemay Feb 11, 2026
96a1321
fix: harden deferred file metadata persistence consistency
mplemay Feb 11, 2026
1ee9d8b
chore: address pr comments
mplemay Feb 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file added examples/__init__.py
Empty file.
63 changes: 63 additions & 0 deletions examples/file.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
from __future__ import annotations

import asyncio

from sqlalchemy import create_engine
from sqlalchemy.orm import Mapped, Session, mapped_column

from brussels.base import DataclassBase
from brussels.mixins import PrimaryKeyMixin

try:
from obstore.store import MemoryStore # ty: ignore[unresolved-import]

from brussels.types.file import RemoteFile, RemoteMetadata, RemoteStorage
except ImportError as exc:
msg = "This example requires optional dependencies. Install with: pip install 'brussels[file]'"
raise SystemExit(msg) from exc


class Document(DataclassBase, PrimaryKeyMixin):
__tablename__ = "documents"

file: Mapped[RemoteMetadata | None] = mapped_column(
RemoteStorage(
store=MemoryStore(),
),
nullable=True,
default=None,
)


async def main() -> None:
engine = create_engine("sqlite:///:memory:")
DataclassBase.metadata.create_all(engine)

with Session(engine) as session:
doc = Document()
session.add(doc)
session.flush() # ensure doc.id exists before upload

remote_file = RemoteFile.from_metadata(doc, Document.file)
await remote_file.put_async(
b"hello world",
content_type="text/plain",
)
if remote_file.metadata is None or remote_file.metadata.key != f"{doc.id}/file":
msg = "Unexpected key generated for uploaded file."
raise RuntimeError(msg)

content = await remote_file.get_async()
if bytes(content.bytes()) != b"hello world":
msg = "Unexpected content returned from remote file download."
raise RuntimeError(msg)

await remote_file.delete_async()
session.commit()
if doc.file is not None:
msg = "File metadata should be cleared after delete."
raise RuntimeError(msg)


if __name__ == "__main__":
asyncio.run(main())
7 changes: 5 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ encrypted = [
alembic = [
"alembic-postgresql-enum",
]
file = [
"obstore",
"pydantic>=2,<3",
]

[build-system]
requires = ["uv_build>=0.10.0,<0.11.0"]
Expand All @@ -38,8 +42,7 @@ dev = [
"ty>=0.0.1a27",
]
all = [
"alembic-postgresql-enum",
"cryptography>=46.0.4",
"brussels[alembic,encrypted,file]",
]

[tool.uv.build-backend]
Expand Down
38 changes: 38 additions & 0 deletions src/brussels/__tests__/test_datetime_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
from __future__ import annotations

from datetime import UTC, datetime, timedelta, timezone

import pytest

from brussels.utils import now, utc


def test_utc_converts_aware_datetime_to_utc() -> None:
value = datetime(2024, 1, 1, 12, 0, tzinfo=timezone(timedelta(hours=5)))

result = utc(value)

assert result.tzinfo is UTC
assert result == datetime(2024, 1, 1, 7, 0, tzinfo=UTC)


def test_utc_raises_for_naive_datetime_by_default() -> None:
value = datetime(2024, 1, 1, 12, 0, tzinfo=UTC).replace(tzinfo=None)

with pytest.raises(ValueError, match="timezone-aware"):
utc(value)


def test_utc_coerces_naive_datetime_when_configured() -> None:
value = datetime(2024, 1, 1, 12, 0, tzinfo=UTC).replace(tzinfo=None)

result = utc(value, raise_on_naive=False)

assert result.tzinfo is UTC
assert result == datetime(2024, 1, 1, 12, 0, tzinfo=UTC)


def test_now_returns_utc_aware_datetime() -> None:
result = now()

assert result.tzinfo is UTC
132 changes: 132 additions & 0 deletions src/brussels/__tests__/types/file/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
from __future__ import annotations

from typing import TYPE_CHECKING

import pytest
from sqlalchemy import Engine, create_engine

from brussels.base import DataclassBase

try:
import brussels.types.file # noqa: F401
except ImportError:
pytest.skip("files optional dependencies not installed", allow_module_level=True)

if TYPE_CHECKING:
from collections.abc import Iterator


class FakeStoreOps:
def __init__(self) -> None:
self.calls: list[tuple[str, tuple[object, ...], dict[str, object]]] = []
self.put_error: Exception | None = None
self.delete_error: Exception | None = None
self.put_response: object = {
"size_bytes": 5,
"content_type": "text/plain",
"etag": "etag-123",
"checksum": "sum-123",
"version": "v1",
}

def _record(self, name: str, args: tuple[object, ...], kwargs: dict[str, object]) -> None:
self.calls.append((name, args, kwargs))

def put(
self,
path: str,
file: object,
*,
attributes: object | None = None,
tags: dict[str, str] | None = None,
mode: object | None = None,
use_multipart: bool | None = None,
chunk_size: int = 5 * 1024 * 1024,
max_concurrency: int = 12,
) -> object:
self._record(
"put",
(path, file),
{
"attributes": attributes,
"tags": tags,
"mode": mode,
"use_multipart": use_multipart,
"chunk_size": chunk_size,
"max_concurrency": max_concurrency,
},
)
if self.put_error is not None:
raise self.put_error
return self.put_response

async def put_async(
self,
path: str,
file: object,
*,
attributes: object | None = None,
tags: dict[str, str] | None = None,
mode: object | None = None,
use_multipart: bool | None = None,
chunk_size: int = 5 * 1024 * 1024,
max_concurrency: int = 12,
) -> object:
self._record(
"put_async",
(path, file),
{
"attributes": attributes,
"tags": tags,
"mode": mode,
"use_multipart": use_multipart,
"chunk_size": chunk_size,
"max_concurrency": max_concurrency,
},
)
if self.put_error is not None:
raise self.put_error
return self.put_response

def get(self, path: str, *, options: object | None = None) -> object:
self._record("get", (path,), {"options": options})
return b"downloaded-sync"

async def get_async(self, path: str, *, options: object | None = None) -> object:
self._record("get_async", (path,), {"options": options})
return b"downloaded-async"

def get_range(self, path: str, *, start: int, end: int | None = None, length: int | None = None) -> object:
self._record("get_range", (path,), {"start": start, "end": end, "length": length})
return b"range-sync"

async def get_range_async(
self,
path: str,
*,
start: int,
end: int | None = None,
length: int | None = None,
) -> object:
self._record("get_range_async", (path,), {"start": start, "end": end, "length": length})
return b"range-async"

def delete(self, paths: str | tuple[str, ...] | list[str]) -> None:
self._record("delete", (paths,), {})
if self.delete_error is not None:
raise self.delete_error

async def delete_async(self, paths: str | tuple[str, ...] | list[str]) -> None:
self._record("delete_async", (paths,), {})
if self.delete_error is not None:
raise self.delete_error


@pytest.fixture
def engine() -> Iterator[Engine]:
engine = create_engine("sqlite:///:memory:")
DataclassBase.metadata.create_all(engine)
try:
yield engine
finally:
engine.dispose()
Loading