From 142ccbc587c27a9ed5c23e3164ed08c600239245 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Fri, 21 Aug 2026 19:26:00 +0200 Subject: [PATCH] feat(core): define managed tabular file contract --- pyproject.toml | 2 +- src/govoplan_core/core/files.py | 119 +++++++++++++++++++++++++++ tests/test_files_tabular_contract.py | 90 ++++++++++++++++++++ webui/package-lock.json | 4 +- webui/package-lock.release.json | 4 +- webui/package.json | 2 +- webui/package.release.json | 2 +- 7 files changed, 216 insertions(+), 7 deletions(-) create mode 100644 tests/test_files_tabular_contract.py diff --git a/pyproject.toml b/pyproject.toml index b040abb..7341e3e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "govoplan-core" -version = "0.1.18" +version = "0.1.19" description = "Reusable GovOPlaN platform core, access, tenancy, and RBAC components." readme = "README.md" requires-python = ">=3.12" diff --git a/src/govoplan_core/core/files.py b/src/govoplan_core/core/files.py index 1dae299..078dc18 100644 --- a/src/govoplan_core/core/files.py +++ b/src/govoplan_core/core/files.py @@ -2,6 +2,7 @@ from __future__ import annotations from collections.abc import Mapping from dataclasses import dataclass, field +from datetime import datetime from typing import Protocol, runtime_checkable from govoplan_core.core.access import ResourceAccessExplanationProvider @@ -10,6 +11,48 @@ from govoplan_core.core.access import ResourceAccessExplanationProvider CAPABILITY_FILES_ACCESS = "files.access" CAPABILITY_FILES_ARTIFACT_STORE = "files.artifact_store" CAPABILITY_FILES_POSTBOX_REFERENCES = "files.postbox_references" +CAPABILITY_FILES_TABULAR_CONTENT = "files.tabular_content" + + +class ManagedTabularFileError(ValueError): + """Stable base error for exact-version managed tabular file access.""" + + +class ManagedTabularFileNotFoundError(ManagedTabularFileError): + pass + + +class ManagedTabularFileAccessError(ManagedTabularFileError): + pass + + +class ManagedTabularFileUnavailableError(ManagedTabularFileError): + pass + + +class ManagedTabularFileValidationError(ManagedTabularFileError): + pass + + +@dataclass(frozen=True, slots=True) +class ManagedTabularFile: + """Authorized metadata for one immutable managed file version.""" + + file_asset_id: str + file_version_id: str + filename: str + display_path: str + content_type: str | None + size_bytes: int + sha256: str + updated_at: datetime | None = None + current_version: bool = True + + +@dataclass(frozen=True, slots=True) +class ManagedTabularFileContent: + file: ManagedTabularFile + payload: bytes @dataclass(frozen=True, slots=True) @@ -91,6 +134,39 @@ class PostboxFileReferenceProvider(Protocol): ) -> tuple[PostboxFileReferenceRef, ...]: ... +@runtime_checkable +class ManagedTabularFileProvider(Protocol): + """List and open authorized CSV/XLSX content without exposing Files internals.""" + + def list_tabular_files( + self, + session: object, + principal: object, + *, + query: str = "", + limit: int = 100, + ) -> tuple[ManagedTabularFile, ...]: ... + + def get_tabular_file( + self, + session: object, + principal: object, + *, + file_asset_id: str, + file_version_id: str | None = None, + ) -> ManagedTabularFile | None: ... + + def read_tabular_file( + self, + session: object, + principal: object, + *, + file_asset_id: str, + file_version_id: str, + max_bytes: int, + ) -> ManagedTabularFileContent: ... + + def postbox_file_reference_provider( registry: object | None, ) -> PostboxFileReferenceProvider | None: @@ -107,3 +183,46 @@ def postbox_file_reference_provider( "PostboxFileReferenceProvider" ) return provider + + +def managed_tabular_file_provider( + registry: object | None, +) -> ManagedTabularFileProvider | None: + if ( + registry is None + or not hasattr(registry, "has_capability") + or not registry.has_capability(CAPABILITY_FILES_TABULAR_CONTENT) + ): + return None + provider = registry.require_capability(CAPABILITY_FILES_TABULAR_CONTENT) + if not isinstance(provider, ManagedTabularFileProvider): + raise TypeError( + "files.tabular_content provider does not implement " + "ManagedTabularFileProvider" + ) + return provider + + +__all__ = [ + "CAPABILITY_FILES_ACCESS", + "CAPABILITY_FILES_ARTIFACT_STORE", + "CAPABILITY_FILES_POSTBOX_REFERENCES", + "CAPABILITY_FILES_TABULAR_CONTENT", + "FileAccessProvider", + "ManagedArtifactRef", + "ManagedArtifactStore", + "ManagedArtifactWriteRequest", + "ManagedTabularFile", + "ManagedTabularFileAccessError", + "ManagedTabularFileContent", + "ManagedTabularFileError", + "ManagedTabularFileNotFoundError", + "ManagedTabularFileProvider", + "ManagedTabularFileUnavailableError", + "ManagedTabularFileValidationError", + "PostboxFileReferenceProvider", + "PostboxFileReferenceRef", + "PostboxFileReferenceRequest", + "managed_tabular_file_provider", + "postbox_file_reference_provider", +] diff --git a/tests/test_files_tabular_contract.py b/tests/test_files_tabular_contract.py new file mode 100644 index 0000000..ee18a8c --- /dev/null +++ b/tests/test_files_tabular_contract.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +import unittest +from datetime import UTC, datetime + +from govoplan_core.core.files import ( + CAPABILITY_FILES_TABULAR_CONTENT, + ManagedTabularFile, + ManagedTabularFileContent, + ManagedTabularFileProvider, + managed_tabular_file_provider, +) + + +class _Provider: + def list_tabular_files(self, session, principal, *, query="", limit=100): + del session, principal, query, limit + return () + + def get_tabular_file( + self, + session, + principal, + *, + file_asset_id, + file_version_id=None, + ): + del session, principal, file_asset_id, file_version_id + return None + + def read_tabular_file( + self, + session, + principal, + *, + file_asset_id, + file_version_id, + max_bytes, + ): + del session, principal, file_asset_id, file_version_id, max_bytes + file = ManagedTabularFile( + file_asset_id="asset-1", + file_version_id="version-1", + filename="source.csv", + display_path="Imports/source.csv", + content_type="text/csv", + size_bytes=8, + sha256="a" * 64, + updated_at=datetime(2026, 8, 21, tzinfo=UTC), + ) + return ManagedTabularFileContent(file=file, payload=b"id\n1\n") + + +class _Registry: + def __init__(self, provider): + self.provider = provider + + def has_capability(self, name): + return name == CAPABILITY_FILES_TABULAR_CONTENT + + def require_capability(self, name): + if not self.has_capability(name): + raise KeyError(name) + return self.provider + + +class ManagedTabularFileContractTests(unittest.TestCase): + def test_runtime_protocol_and_registry_resolution(self) -> None: + provider = _Provider() + + self.assertIsInstance(provider, ManagedTabularFileProvider) + self.assertIs(provider, managed_tabular_file_provider(_Registry(provider))) + self.assertIsNone(managed_tabular_file_provider(None)) + + def test_exact_version_content_retains_safe_metadata(self) -> None: + result = _Provider().read_tabular_file( + object(), + object(), + file_asset_id="asset-1", + file_version_id="version-1", + max_bytes=100, + ) + + self.assertEqual("version-1", result.file.file_version_id) + self.assertEqual("a" * 64, result.file.sha256) + self.assertEqual(b"id\n1\n", result.payload) + + +if __name__ == "__main__": + unittest.main() diff --git a/webui/package-lock.json b/webui/package-lock.json index 8de21f1..84cf4a8 100644 --- a/webui/package-lock.json +++ b/webui/package-lock.json @@ -1,12 +1,12 @@ { "name": "@govoplan/core-webui", - "version": "0.1.18", + "version": "0.1.19", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@govoplan/core-webui", - "version": "0.1.18", + "version": "0.1.19", "dependencies": { "@govoplan/access-webui": "file:../../govoplan-access/webui", "@govoplan/addresses-webui": "file:../../govoplan-addresses/webui", diff --git a/webui/package-lock.release.json b/webui/package-lock.release.json index 724278c..d86c1e4 100644 --- a/webui/package-lock.release.json +++ b/webui/package-lock.release.json @@ -1,12 +1,12 @@ { "name": "@govoplan/core-webui", - "version": "0.1.18", + "version": "0.1.19", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@govoplan/core-webui", - "version": "0.1.18", + "version": "0.1.19", "dependencies": { "@govoplan/access-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-access.git#v0.1.18", "@govoplan/admin-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-admin.git#v0.1.18", diff --git a/webui/package.json b/webui/package.json index 2d96ed5..fa77703 100644 --- a/webui/package.json +++ b/webui/package.json @@ -1,6 +1,6 @@ { "name": "@govoplan/core-webui", - "version": "0.1.18", + "version": "0.1.19", "private": true, "type": "module", "main": "src/index.ts", diff --git a/webui/package.release.json b/webui/package.release.json index ab2e530..5749666 100644 --- a/webui/package.release.json +++ b/webui/package.release.json @@ -1,6 +1,6 @@ { "name": "@govoplan/core-webui", - "version": "0.1.18", + "version": "0.1.19", "private": true, "type": "module", "main": "src/index.ts",