afs-core 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,41 @@
1
+ # --- OS / editor ---
2
+ .DS_Store
3
+ Thumbs.db
4
+ *.swp
5
+ .idea/
6
+ .vscode/
7
+
8
+ # --- Python (packages land in M0+) ---
9
+ __pycache__/
10
+ *.py[cod]
11
+ .venv/
12
+ venv/
13
+ .uv/
14
+ *.egg-info/
15
+ .ruff_cache/
16
+ .pytest_cache/
17
+ .mypy_cache/
18
+ .ty_cache/
19
+ dist/
20
+ build/
21
+
22
+ # --- Node (workers/mcp-edge lands later) ---
23
+ node_modules/
24
+ npm-debug.log*
25
+
26
+ # --- Secrets / local env ---
27
+ .env
28
+ .env.*
29
+ !.env.example
30
+ *.secret.*
31
+
32
+ # --- Terraform ---
33
+ # Detailed Terraform ignores live in terraform/.gitignore; these catch any
34
+ # stray state/plan artifacts produced outside that tree.
35
+ *.tfstate
36
+ *.tfstate.*
37
+ *.tfplan
38
+ .terraform/
39
+
40
+ # Agent worktrees (isolated background-agent checkouts)
41
+ .claude/worktrees/
@@ -0,0 +1,49 @@
1
+ Metadata-Version: 2.4
2
+ Name: afs-core
3
+ Version: 0.1.0
4
+ Summary: agentic-fs core contracts, DTOs, key scheme, and conformance kits. Depends on pydantic only.
5
+ Project-URL: Homepage, https://github.com/vivekkhimani/agentic-fs
6
+ Project-URL: Repository, https://github.com/vivekkhimani/agentic-fs
7
+ Project-URL: Issues, https://github.com/vivekkhimani/agentic-fs/issues
8
+ Author-email: Vivek Khimani <vivekkhimani07@gmail.com>
9
+ License-Expression: Apache-2.0
10
+ Keywords: agentic-fs,agents,conformance,contracts,filesystem,mcp
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: License :: OSI Approved :: Apache Software License
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Typing :: Typed
15
+ Requires-Python: >=3.12
16
+ Requires-Dist: pydantic>=2.7
17
+ Provides-Extra: testing
18
+ Requires-Dist: pytest-asyncio>=0.24; extra == 'testing'
19
+ Requires-Dist: pytest>=8; extra == 'testing'
20
+ Description-Content-Type: text/markdown
21
+
22
+ # afs-core
23
+
24
+ The **contracts** package for agentic-fs: `typing.Protocol` interfaces, pydantic
25
+ DTOs, the single key scheme, the closed error vocabulary, versioned event
26
+ contracts, and the conformance test kits. Depends on **pydantic only** — it
27
+ imports without the server, so connectors and adopters can build against it
28
+ without pulling in `afs-server`.
29
+
30
+ ## Status
31
+
32
+ Foundations slice (M0, in progress):
33
+
34
+ - `afs_core.keys` — the **single** definition of the S3 key scheme: build, parse,
35
+ validate, and `is_indexable()`. Nothing else concatenates a key.
36
+ - `afs_core.errors` — the closed `ErrorCode` vocabulary + the `AfsError`
37
+ hierarchy (RFC 9457 `problem+json` shape).
38
+ - `afs_core.models` — core DTOs (`Page[T]`, `CatalogEntry`, `ExtractionState`, …)
39
+ and control records (`TenantRecord`, `NamespaceRecord`, `PrincipalRecord`).
40
+
41
+ Coming next: `afs_core.contracts` (the async Protocols), `afs_core.testing`
42
+ (conformance kits + in-memory fakes), `afs_core.events`.
43
+
44
+ ## Develop
45
+
46
+ ```bash
47
+ uv sync
48
+ uv run pytest packages/afs-core
49
+ ```
@@ -0,0 +1,28 @@
1
+ # afs-core
2
+
3
+ The **contracts** package for agentic-fs: `typing.Protocol` interfaces, pydantic
4
+ DTOs, the single key scheme, the closed error vocabulary, versioned event
5
+ contracts, and the conformance test kits. Depends on **pydantic only** — it
6
+ imports without the server, so connectors and adopters can build against it
7
+ without pulling in `afs-server`.
8
+
9
+ ## Status
10
+
11
+ Foundations slice (M0, in progress):
12
+
13
+ - `afs_core.keys` — the **single** definition of the S3 key scheme: build, parse,
14
+ validate, and `is_indexable()`. Nothing else concatenates a key.
15
+ - `afs_core.errors` — the closed `ErrorCode` vocabulary + the `AfsError`
16
+ hierarchy (RFC 9457 `problem+json` shape).
17
+ - `afs_core.models` — core DTOs (`Page[T]`, `CatalogEntry`, `ExtractionState`, …)
18
+ and control records (`TenantRecord`, `NamespaceRecord`, `PrincipalRecord`).
19
+
20
+ Coming next: `afs_core.contracts` (the async Protocols), `afs_core.testing`
21
+ (conformance kits + in-memory fakes), `afs_core.events`.
22
+
23
+ ## Develop
24
+
25
+ ```bash
26
+ uv sync
27
+ uv run pytest packages/afs-core
28
+ ```
@@ -0,0 +1,46 @@
1
+ [project]
2
+ name = "afs-core"
3
+ version = "0.1.0"
4
+ description = "agentic-fs core contracts, DTOs, key scheme, and conformance kits. Depends on pydantic only."
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ license = "Apache-2.0"
8
+ authors = [{ name = "Vivek Khimani", email = "vivekkhimani07@gmail.com" }]
9
+ keywords = [
10
+ "agentic-fs",
11
+ "agents",
12
+ "contracts",
13
+ "conformance",
14
+ "mcp",
15
+ "filesystem",
16
+ ]
17
+ classifiers = [
18
+ "Development Status :: 3 - Alpha",
19
+ "License :: OSI Approved :: Apache Software License",
20
+ "Programming Language :: Python :: 3.12",
21
+ "Typing :: Typed",
22
+ ]
23
+ dependencies = [
24
+ "pydantic>=2.7",
25
+ ]
26
+
27
+ [project.optional-dependencies]
28
+ # The conformance kits + fakes in ``afs_core.testing`` import pytest at module
29
+ # level. Production adopters get just the contracts (``pip install afs-core``);
30
+ # anyone certifying a store/connector adds ``afs-core[testing]``.
31
+ testing = [
32
+ "pytest>=8",
33
+ "pytest-asyncio>=0.24",
34
+ ]
35
+
36
+ [project.urls]
37
+ Homepage = "https://github.com/vivekkhimani/agentic-fs"
38
+ Repository = "https://github.com/vivekkhimani/agentic-fs"
39
+ Issues = "https://github.com/vivekkhimani/agentic-fs/issues"
40
+
41
+ [build-system]
42
+ requires = ["hatchling"]
43
+ build-backend = "hatchling.build"
44
+
45
+ [tool.hatch.build.targets.wheel]
46
+ packages = ["src/afs_core"]
@@ -0,0 +1,9 @@
1
+ """agentic-fs core: contracts, DTOs, the key scheme, and the error vocabulary.
2
+
3
+ Depends on pydantic only — importable without the server. (``afs_core.testing``
4
+ is intentionally not imported here: it depends on pytest and is opt-in.)
5
+ """
6
+
7
+ from afs_core import contracts, errors, keys, models
8
+
9
+ __all__ = ["contracts", "errors", "keys", "models"]
@@ -0,0 +1,11 @@
1
+ """The agentic-fs contracts: async ``typing.Protocol`` interfaces (plan §5).
2
+
3
+ Structural — adopters implement without importing our hierarchy. Each is proven
4
+ by a conformance kit in :mod:`afs_core.testing`.
5
+ """
6
+
7
+ from afs_core.contracts.catalog import CatalogStore
8
+ from afs_core.contracts.normalize import NormalizationError, Normalizer
9
+ from afs_core.contracts.objects import ObjectStore
10
+
11
+ __all__ = ["CatalogStore", "NormalizationError", "Normalizer", "ObjectStore"]
@@ -0,0 +1,90 @@
1
+ """The ``CatalogStore`` contract (plan §5.1).
2
+
3
+ One contract covers entries + control records + checkpoints + scratch quota, so a
4
+ self-hoster swaps **one** stateful dependency. Structural ``Protocol``, async.
5
+ Certify an impl with ``CatalogStoreConformance`` (afs_core.testing); DynamoDB and
6
+ Postgres are the two reference implementations.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Protocol, runtime_checkable
12
+
13
+ from afs_core.models import (
14
+ CatalogEntry,
15
+ ExtractionState,
16
+ NamespaceRecord,
17
+ Page,
18
+ PrincipalRecord,
19
+ ScratchUsage,
20
+ SyncCheckpoint,
21
+ TenantRecord,
22
+ )
23
+
24
+
25
+ @runtime_checkable
26
+ class CatalogStore(Protocol):
27
+ # -- entries (derived index of S3; healable FROM S3) --
28
+ async def put_entry(self, entry: CatalogEntry) -> None: ...
29
+
30
+ async def get_entry(self, tenant_id: str, namespace: str, path: str) -> CatalogEntry | None: ...
31
+
32
+ async def delete_entry(
33
+ self, tenant_id: str, namespace: str, path: str, *, hard: bool = False
34
+ ) -> None:
35
+ """Tombstone (soft) by default; ``hard=True`` removes the row entirely."""
36
+ ...
37
+
38
+ async def list_entries(
39
+ self,
40
+ tenant_id: str,
41
+ namespace: str,
42
+ *,
43
+ prefix: str = "",
44
+ include_deleted: bool = False,
45
+ cursor: str | None = None,
46
+ limit: int = 1000,
47
+ ) -> Page[CatalogEntry]: ...
48
+
49
+ async def find_by_checksum(self, tenant_id: str, checksum: str) -> list[CatalogEntry]: ...
50
+
51
+ async def set_extraction(
52
+ self, tenant_id: str, namespace: str, path: str, state: ExtractionState
53
+ ) -> None: ...
54
+
55
+ async def list_by_extraction_status(
56
+ self, status: str, *, cursor: str | None = None, limit: int = 100
57
+ ) -> Page[CatalogEntry]: ...
58
+
59
+ async def tree_version(self, tenant_id: str, namespace: str) -> str:
60
+ """A token bumped on any write to the namespace — the tree-cache key."""
61
+ ...
62
+
63
+ # -- control records (tenants / namespaces / principals) --
64
+ async def put_tenant(self, tenant: TenantRecord) -> None: ...
65
+ async def get_tenant(self, tenant_id: str) -> TenantRecord | None: ...
66
+ async def list_tenants(
67
+ self, *, cursor: str | None = None, limit: int = 100
68
+ ) -> Page[TenantRecord]: ...
69
+
70
+ async def put_namespace(self, ns: NamespaceRecord) -> None: ...
71
+ async def get_namespace(self, tenant_id: str, name: str) -> NamespaceRecord | None: ...
72
+ async def list_namespaces(self, tenant_id: str) -> list[NamespaceRecord]: ...
73
+ async def delete_namespace(self, tenant_id: str, name: str) -> None: ...
74
+
75
+ async def put_principal(self, p: PrincipalRecord) -> None: ...
76
+ async def get_principal(self, tenant_id: str, principal_id: str) -> PrincipalRecord | None: ...
77
+ async def list_principals(self, tenant_id: str) -> list[PrincipalRecord]: ...
78
+
79
+ # -- connector checkpoints --
80
+ async def get_checkpoint(self, tenant_id: str, connector_id: str) -> SyncCheckpoint | None: ...
81
+ async def put_checkpoint(
82
+ self, tenant_id: str, connector_id: str, cp: SyncCheckpoint
83
+ ) -> None: ...
84
+
85
+ # -- scratch quota (atomic; raises QuotaExceededError) --
86
+ async def adjust_scratch_usage(
87
+ self, tenant_id: str, principal_id: str, *, delta_bytes: int, delta_objects: int
88
+ ) -> ScratchUsage: ...
89
+
90
+ async def get_scratch_usage(self, tenant_id: str, principal_id: str) -> ScratchUsage: ...
@@ -0,0 +1,40 @@
1
+ """The ``Normalizer`` contract (plan §5.4) — the extractor/parser seam.
2
+
3
+ A normalizer turns one raw document into normalized per-page markdown. It is the
4
+ *only* place document parsing lives: text_native, docling, llamaparse, and any
5
+ custom parser are all just `Normalizer`s. The `ExtractionPipeline` (in
6
+ afs-server) orders them into a ladder, applies a quality gate, and degrades to
7
+ `catalog_only` — none of which a normalizer needs to know about.
8
+
9
+ Adding your own: implement this Protocol, certify it against
10
+ `afs_core.testing.NormalizerConformance`, register it via the `afs.normalizers`
11
+ entry-point group, and name it in the extraction ladder. No core changes.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import Protocol, runtime_checkable
17
+
18
+ from afs_core.models import NormalizedDocument, SourceDocument
19
+
20
+
21
+ class NormalizationError(Exception):
22
+ """A normalizer couldn't parse the document. Carries a closed-vocabulary
23
+ ``reason`` (events v1) so the pipeline can record why and try the next rung."""
24
+
25
+ def __init__(self, reason: str, message: str | None = None) -> None:
26
+ self.reason = reason
27
+ super().__init__(message or reason)
28
+
29
+
30
+ @runtime_checkable
31
+ class Normalizer(Protocol):
32
+ name: str
33
+
34
+ def accepts(self, doc: SourceDocument) -> bool:
35
+ """Whether this normalizer claims the document (by MIME/extension)."""
36
+ ...
37
+
38
+ async def normalize(self, doc: SourceDocument) -> NormalizedDocument:
39
+ """Parse ``doc`` into per-page markdown, or raise ``NormalizationError``."""
40
+ ...
@@ -0,0 +1,44 @@
1
+ """The ``ObjectStore`` contract (plan §5.2).
2
+
3
+ Structural ``Protocol`` — adopters implement it without importing our hierarchy
4
+ or depending on ``afs-server``. S3 is the only production impl; the protocol
5
+ exists so MinIO/LocalStack back local dev and an in-memory fake backs tests.
6
+ Certify any impl with ``ObjectStoreConformance`` (afs_core.testing).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Protocol, runtime_checkable
12
+
13
+ from afs_core.models import ObjectStat, Page, PresignedPut
14
+
15
+
16
+ @runtime_checkable
17
+ class ObjectStore(Protocol):
18
+ async def get(self, key: str, *, start: int | None = None, end: int | None = None) -> bytes:
19
+ """Fetch an object, optionally a byte range ``[start, end]`` (inclusive)."""
20
+ ...
21
+
22
+ async def put(
23
+ self, key: str, body: bytes, *, content_type: str | None = None
24
+ ) -> ObjectStat: ...
25
+
26
+ async def delete(self, key: str) -> None: ...
27
+
28
+ async def delete_prefix(self, prefix: str) -> int:
29
+ """Delete every object under ``prefix``; returns the count removed."""
30
+ ...
31
+
32
+ async def stat(self, key: str) -> ObjectStat | None:
33
+ """Metadata for ``key``, or ``None`` if it does not exist."""
34
+ ...
35
+
36
+ async def list(
37
+ self, prefix: str, *, cursor: str | None = None, limit: int = 1000
38
+ ) -> Page[ObjectStat]: ...
39
+
40
+ async def presigned_put(
41
+ self, key: str, *, content_type: str, max_bytes: int, expires_in: int = 900
42
+ ) -> PresignedPut: ...
43
+
44
+ async def presigned_get(self, key: str, *, expires_in: int = 300) -> str: ...
@@ -0,0 +1,212 @@
1
+ """The closed error vocabulary and the ``AfsError`` hierarchy.
2
+
3
+ Every error agentic-fs raises across the wire carries a code from the closed
4
+ :class:`ErrorCode` enum and serializes to an RFC 9457 ``application/problem+json``
5
+ envelope. The vocabulary is closed on purpose: clients (and the MCP tool layer)
6
+ can branch on a small, stable set of codes instead of parsing prose.
7
+
8
+ Design note — **misses are 404, never 403** (plan §4.1): a caller must not be
9
+ able to tell "exists but forbidden" from "does not exist", or they could
10
+ enumerate tenants/namespaces/documents. So the not-found errors below all map to
11
+ 404 and there is intentionally no 403 for resource access.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from enum import StrEnum
17
+ from typing import Any
18
+
19
+
20
+ class ErrorCode(StrEnum):
21
+ """Closed vocabulary of machine-readable error codes."""
22
+
23
+ # Request / validation
24
+ VALIDATION_ERROR = "validation_error"
25
+ INVALID_KEY = "invalid_key"
26
+ INVALID_NAMESPACE = "invalid_namespace"
27
+
28
+ # Not found (also used to hide forbidden — see module docstring)
29
+ NOT_FOUND = "not_found"
30
+ TENANT_NOT_FOUND = "tenant_not_found"
31
+ NAMESPACE_NOT_FOUND = "namespace_not_found"
32
+ DOCUMENT_NOT_FOUND = "document_not_found"
33
+
34
+ # AuthN / AuthZ
35
+ UNAUTHENTICATED = "unauthenticated"
36
+ INSUFFICIENT_SCOPE = "insufficient_scope"
37
+
38
+ # Read-path / capability
39
+ CATALOG_ONLY = "catalog_only"
40
+ SEARCH_NOT_ENABLED = "search_not_enabled"
41
+ BUDGET_EXCEEDED = "budget_exceeded"
42
+
43
+ # Write-path / quota
44
+ QUOTA_EXCEEDED = "quota_exceeded"
45
+ PAYLOAD_TOO_LARGE = "payload_too_large"
46
+ CONFLICT = "conflict"
47
+
48
+ # Extraction
49
+ EXTRACTION_FAILED = "extraction_failed"
50
+
51
+ # Catch-all
52
+ INTERNAL = "internal"
53
+
54
+
55
+ class AfsError(Exception):
56
+ """Base for every agentic-fs error.
57
+
58
+ Carries a closed :class:`ErrorCode`, an HTTP status, and an optional
59
+ ``detail`` map; serializes to an RFC 9457 problem object via
60
+ :meth:`to_problem`.
61
+ """
62
+
63
+ code: ErrorCode = ErrorCode.INTERNAL
64
+ http_status: int = 500
65
+ title: str = "Internal error"
66
+
67
+ def __init__(
68
+ self,
69
+ message: str | None = None,
70
+ *,
71
+ detail: dict[str, Any] | None = None,
72
+ ) -> None:
73
+ self.message = message or self.title
74
+ self.detail = detail or {}
75
+ super().__init__(self.message)
76
+
77
+ def to_problem(self, *, instance: str | None = None) -> dict[str, Any]:
78
+ """Render as an RFC 9457 ``application/problem+json`` object."""
79
+ problem: dict[str, Any] = {
80
+ "type": f"https://agentic-fs.dev/errors/{self.code.value}",
81
+ "title": self.title,
82
+ "status": self.http_status,
83
+ "code": self.code.value,
84
+ "detail": self.message,
85
+ }
86
+ if instance is not None:
87
+ problem["instance"] = instance
88
+ problem.update(self.detail)
89
+ return problem
90
+
91
+
92
+ # --- 4xx: client ---------------------------------------------------------------
93
+
94
+
95
+ class ValidationError(AfsError):
96
+ code = ErrorCode.VALIDATION_ERROR
97
+ http_status = 400
98
+ title = "Validation error"
99
+
100
+
101
+ class InvalidKeyError(ValidationError):
102
+ code = ErrorCode.INVALID_KEY
103
+ title = "Invalid object key"
104
+
105
+
106
+ class InvalidNamespaceError(ValidationError):
107
+ code = ErrorCode.INVALID_NAMESPACE
108
+ title = "Invalid namespace"
109
+
110
+
111
+ class UnauthenticatedError(AfsError):
112
+ code = ErrorCode.UNAUTHENTICATED
113
+ http_status = 401
114
+ title = "Unauthenticated"
115
+
116
+
117
+ class InsufficientScopeError(AfsError):
118
+ code = ErrorCode.INSUFFICIENT_SCOPE
119
+ http_status = 403
120
+ title = "Insufficient scope"
121
+
122
+
123
+ class NotFoundError(AfsError):
124
+ """Generic 404. Also the disguise for forbidden resource access (§4.1)."""
125
+
126
+ code = ErrorCode.NOT_FOUND
127
+ http_status = 404
128
+ title = "Not found"
129
+
130
+
131
+ class TenantNotFoundError(NotFoundError):
132
+ code = ErrorCode.TENANT_NOT_FOUND
133
+ title = "Tenant not found"
134
+
135
+
136
+ class NamespaceNotFoundError(NotFoundError):
137
+ code = ErrorCode.NAMESPACE_NOT_FOUND
138
+ title = "Namespace not found"
139
+
140
+
141
+ class DocumentNotFoundError(NotFoundError):
142
+ code = ErrorCode.DOCUMENT_NOT_FOUND
143
+ title = "Document not found"
144
+
145
+
146
+ class ConflictError(AfsError):
147
+ code = ErrorCode.CONFLICT
148
+ http_status = 409
149
+ title = "Conflict"
150
+
151
+
152
+ class PayloadTooLargeError(AfsError):
153
+ code = ErrorCode.PAYLOAD_TOO_LARGE
154
+ http_status = 413
155
+ title = "Payload too large"
156
+
157
+
158
+ class QuotaExceededError(AfsError):
159
+ code = ErrorCode.QUOTA_EXCEEDED
160
+ http_status = 429
161
+ title = "Quota exceeded"
162
+
163
+
164
+ class BudgetExceededError(AfsError):
165
+ code = ErrorCode.BUDGET_EXCEEDED
166
+ http_status = 422
167
+ title = "Budget exceeded"
168
+
169
+
170
+ class CatalogOnlyError(AfsError):
171
+ """The document exists and is cite-able, but its contents aren't readable yet."""
172
+
173
+ code = ErrorCode.CATALOG_ONLY
174
+ http_status = 422
175
+ title = "Document is catalog-only"
176
+
177
+
178
+ class SearchNotEnabledError(AfsError):
179
+ code = ErrorCode.SEARCH_NOT_ENABLED
180
+ http_status = 422
181
+ title = "Search is not enabled"
182
+
183
+
184
+ # --- 5xx: server ---------------------------------------------------------------
185
+
186
+
187
+ class ExtractionFailedError(AfsError):
188
+ code = ErrorCode.EXTRACTION_FAILED
189
+ http_status = 500
190
+ title = "Extraction failed"
191
+
192
+
193
+ __all__ = [
194
+ "AfsError",
195
+ "BudgetExceededError",
196
+ "CatalogOnlyError",
197
+ "ConflictError",
198
+ "DocumentNotFoundError",
199
+ "ErrorCode",
200
+ "ExtractionFailedError",
201
+ "InsufficientScopeError",
202
+ "InvalidKeyError",
203
+ "InvalidNamespaceError",
204
+ "NamespaceNotFoundError",
205
+ "NotFoundError",
206
+ "PayloadTooLargeError",
207
+ "QuotaExceededError",
208
+ "SearchNotEnabledError",
209
+ "TenantNotFoundError",
210
+ "UnauthenticatedError",
211
+ "ValidationError",
212
+ ]