3tears-interfaces 0.93.0__py3-none-any.whl

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,85 @@
1
+ Metadata-Version: 2.5
2
+ Name: 3tears-interfaces
3
+ Version: 0.93.0
4
+ Summary: Dependency-free Protocols and shared vocabulary for the 3tears family
5
+ Project-URL: Repository, https://github.com/pacepace/3tears
6
+ Author: pace
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Framework :: AsyncIO
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.14
14
+ Classifier: Topic :: Security
15
+ Classifier: Topic :: Software Development :: Libraries
16
+ Classifier: Typing :: Typed
17
+ Requires-Python: >=3.14
18
+ Description-Content-Type: text/markdown
19
+
20
+ # 3tears-interfaces
21
+
22
+ The 3tears family's shared Protocols and vocabulary, in one package that
23
+ depends on nothing.
24
+
25
+ **The rule.** This package holds Protocols, the value types and enums that
26
+ cross them, and pure helpers over those types. No implementations, no
27
+ third-party imports, no dependencies. A provider can implement a Protocol, and
28
+ a consumer can accept one, without inheriting any feature package's dependency
29
+ closure. The workspace's `tests/enforcement/` fails any module-top import here
30
+ that is not the stdlib or `threetears.interfaces` itself.
31
+
32
+ One module per domain:
33
+
34
+ | Module | What it holds |
35
+ |---|---|
36
+ | `threetears.interfaces.access` | The identity and authorization vocabulary `3tears-iam` and `3tears-agent-acl` both name |
37
+ | `threetears.interfaces.media` | Media capability Protocols, the value types that cross them, and the object-key builder |
38
+
39
+ ## access
40
+
41
+ ```python
42
+ from threetears.interfaces.access import ImpersonationCategory
43
+ ```
44
+
45
+ `ImpersonationCategory` is the fixed set of six sensitive actions: credential,
46
+ passkey and MFA management; account deletion; API-key mint or rotate for the
47
+ target; email change; RBAC grant changes; audit editing. agent-acl deny-lists
48
+ all six under impersonation, and iam's `stepup` requires a recent sign-in for
49
+ each. iam proves who a caller is and agent-acl decides what they may do;
50
+ neither depends on the other, so the values both must agree on live here.
51
+
52
+ ## media
53
+
54
+ ```python
55
+ from threetears.interfaces.media import GeneratedImage, ImageGenerationBackend
56
+ ```
57
+
58
+ The Protocols media providers (`3tears-models`, `3tears-object-store`)
59
+ implement and media consumers (`3tears-agent-tools`) accept: `VisionProvider`,
60
+ `ReferenceVisionProvider`, `TextProvider`, `TranscriptionProvider`,
61
+ `ImageGenerationBackend`, `MediaStorage` and `ObjectStore`. The value types
62
+ that cross them: `MediaInfo`, `GeneratedImage`, `ObjectHandle`,
63
+ `ObjectListing` and `MediaSizeLimitExceeded`. Plus the extraction-status and
64
+ generation-metadata constants, and the object-key builder `build_object_key`.
65
+
66
+ `MediaFacets` carries the additive carrier facets: rights status, pixel
67
+ dimensions, and whether a locator points at the file itself or at the page
68
+ containing it. The vocabulary grows by addition only, and a consumer that
69
+ does not recognise a facet ignores it rather than failing, so a new carrier
70
+ ships without a coordinated release.
71
+
72
+ ```python
73
+ from threetears.interfaces.media import LOCATOR_KIND_DIRECT_FILE, MediaFacets
74
+ ```
75
+
76
+ ## Install
77
+
78
+ ```bash
79
+ pip install 3tears-interfaces
80
+ ```
81
+
82
+ ## Versioning policy
83
+
84
+ `3tears-interfaces` versions in lockstep with the rest of the 3tears
85
+ monorepo: every package shares one version, tracking the framework git tag.
@@ -0,0 +1,11 @@
1
+ threetears/interfaces/__init__.py,sha256=am8iZx27vAMKWaVyXNSLkBAAVfN5usoKZphwKQXD8q8,1421
2
+ threetears/interfaces/access.py,sha256=Zr1Dk8SWoiHLMVX6KOR44K3G2-umQqFkyFow7pNZ2FM,1714
3
+ threetears/interfaces/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ threetears/interfaces/media/__init__.py,sha256=_QXnyVA-jIJUUsr7LnzA3lJMlauQ9-f-77hxubxC978,1956
5
+ threetears/interfaces/media/facets.py,sha256=Kw6I-U-IK6dWzHk5iUFU7AiAM1v-os2uGdc-QCUhoYY,5918
6
+ threetears/interfaces/media/keys.py,sha256=VcAW89tvkTW381LpFeCIdqYvThYpqzAeY2x9BMWxSr4,6336
7
+ threetears/interfaces/media/protocols.py,sha256=8yYDSRdHqO7TC5AxfkOAqFT6boHUN6InO5YiRvUKjKQ,21994
8
+ 3tears_interfaces-0.93.0.dist-info/METADATA,sha256=w0MT4ijoQNqz5NSmHznEPoD7AaZrl46cGgx48Lnpkas,3342
9
+ 3tears_interfaces-0.93.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
10
+ 3tears_interfaces-0.93.0.dist-info/licenses/LICENSE,sha256=7GWEoEOcFJenZLt4LDzqH2K7QLxo_2m8rzG7Vv8VGXo,1066
11
+ 3tears_interfaces-0.93.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mark Pace
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,26 @@
1
+ """The 3tears family's shared Protocols and vocabulary, in one package that depends on nothing.
2
+
3
+ One module per domain:
4
+
5
+ - :mod:`threetears.interfaces.access` -- the identity and authorization vocabulary
6
+ ``3tears-iam`` (who a caller is) and ``3tears-agent-acl`` (what they may do) both name.
7
+ - :mod:`threetears.interfaces.media` -- the media capability Protocols shared by media
8
+ providers (``3tears-models``, ``3tears-object-store``) and consumers (``3tears-agent-tools``).
9
+
10
+ The rule: Protocols, the value types and enums that cross them, and pure helpers over those
11
+ types. No implementations, no third-party imports, no dependencies. A package that speaks
12
+ the vocabulary or implements a Protocol never inherits another feature package's closure by
13
+ doing so. The workspace's ``tests/enforcement/`` enforces it: every module-top import here
14
+ must be the stdlib or ``threetears.interfaces`` itself.
15
+ """
16
+
17
+ # Version derived from package metadata so the metadata is the single source of truth: a
18
+ # release that bumps pyproject cannot drift the runtime ``__version__``. ``importlib.metadata``
19
+ # is stdlib, so the dependency-free floor is untouched.
20
+ from importlib.metadata import PackageNotFoundError as _PackageNotFoundError
21
+ from importlib.metadata import version as _version
22
+
23
+ try:
24
+ __version__ = _version("3tears-interfaces")
25
+ except _PackageNotFoundError: # pragma: no cover - dev fallback
26
+ __version__ = "unknown"
@@ -0,0 +1,37 @@
1
+ """The sensitive-action categories authentication and authorization both name.
2
+
3
+ Two checks guard the same six actions. Authorization (``3tears-agent-acl``'s
4
+ ``authorize_from_claims``) refuses them outright under impersonation. Authentication
5
+ (``3tears-iam``'s ``stepup``) lets them through only on a recent sign-in. Each check is
6
+ only as good as its agreement with the other about what the six are, so the set is
7
+ declared once, here, where neither side pays for the other's dependencies to import it.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from enum import StrEnum
13
+
14
+ __all__ = ["ImpersonationCategory"]
15
+
16
+
17
+ class ImpersonationCategory(StrEnum):
18
+ """The fixed, non-tenant-configurable sensitive-action category set.
19
+
20
+ security-model.md's Impersonation paragraph names these six categories verbatim:
21
+ "credential, passkey, and MFA management; account deletion; API-key mint/rotate for the
22
+ target; email change; RBAC grant changes; audit editing."
23
+
24
+ Two enforcement points read it. Under impersonation every member is deny-listed: there is
25
+ no partial or tenant-configurable subset. Outside impersonation every member requires
26
+ step-up re-authentication: the same taxonomy, enforced by freshness instead of subtraction.
27
+
28
+ The string values are a wire vocabulary as well as a type: a token, an audit record or a
29
+ peer repository may carry them, so a member is added, never renamed.
30
+ """
31
+
32
+ CREDENTIAL_PASSKEY_MFA_MANAGEMENT = "credential_passkey_mfa_management"
33
+ ACCOUNT_DELETION = "account_deletion"
34
+ APIKEY_MINT_ROTATE_FOR_TARGET = "apikey_mint_rotate_for_target"
35
+ EMAIL_CHANGE = "email_change"
36
+ RBAC_GRANT_CHANGE = "rbac_grant_change"
37
+ AUDIT_EDITING = "audit_editing"
@@ -0,0 +1,69 @@
1
+ """Media capability interfaces.
2
+
3
+ Pure interface types -- ``typing.Protocol`` classes and stdlib dataclasses -- shared between
4
+ media providers (``3tears-models``, ``3tears-object-store``) and media consumers
5
+ (``3tears-agent-tools``). Implementing or accepting one never inherits a feature package's
6
+ dependency closure: ``3tears-interfaces`` depends on nothing (see :mod:`threetears.interfaces`).
7
+ """
8
+
9
+ from threetears.interfaces.media.facets import (
10
+ LOCATOR_KIND_CONTAINING_PAGE,
11
+ LOCATOR_KIND_DIRECT_FILE,
12
+ MediaFacets,
13
+ )
14
+ from threetears.interfaces.media.keys import build_object_key
15
+ from threetears.interfaces.media.protocols import (
16
+ EXTRACTION_STATUS_COMPLETE,
17
+ EXTRACTION_STATUS_FAILED,
18
+ EXTRACTION_STATUS_NONE,
19
+ EXTRACTION_STATUS_PENDING,
20
+ EXTRACTION_STATUS_REFUSED,
21
+ EXTRACTION_STATUS_UNCHANGED,
22
+ GENERATION_IGNORED,
23
+ GENERATION_SEED,
24
+ GENERATION_SIZE,
25
+ GENERATION_SOURCE_USED,
26
+ OBJECT_HANDLE_METADATA_KEY,
27
+ GeneratedImage,
28
+ ImageGenerationBackend,
29
+ MediaInfo,
30
+ MediaSizeLimitExceeded,
31
+ MediaStorage,
32
+ ObjectHandle,
33
+ ObjectListing,
34
+ ObjectStore,
35
+ TextProvider,
36
+ TranscriptionProvider,
37
+ ReferenceVisionProvider,
38
+ VisionProvider,
39
+ )
40
+
41
+ __all__ = [
42
+ "EXTRACTION_STATUS_COMPLETE",
43
+ "EXTRACTION_STATUS_FAILED",
44
+ "EXTRACTION_STATUS_NONE",
45
+ "EXTRACTION_STATUS_PENDING",
46
+ "EXTRACTION_STATUS_REFUSED",
47
+ "EXTRACTION_STATUS_UNCHANGED",
48
+ "GENERATION_IGNORED",
49
+ "GENERATION_SEED",
50
+ "GENERATION_SIZE",
51
+ "GENERATION_SOURCE_USED",
52
+ "LOCATOR_KIND_CONTAINING_PAGE",
53
+ "LOCATOR_KIND_DIRECT_FILE",
54
+ "OBJECT_HANDLE_METADATA_KEY",
55
+ "GeneratedImage",
56
+ "ImageGenerationBackend",
57
+ "MediaFacets",
58
+ "MediaInfo",
59
+ "MediaSizeLimitExceeded",
60
+ "MediaStorage",
61
+ "ObjectHandle",
62
+ "ObjectListing",
63
+ "ObjectStore",
64
+ "TextProvider",
65
+ "TranscriptionProvider",
66
+ "ReferenceVisionProvider",
67
+ "VisionProvider",
68
+ "build_object_key",
69
+ ]
@@ -0,0 +1,131 @@
1
+ """Carrier facets -- the additive vocabulary a media result may carry.
2
+
3
+ A facet is the per-carrier detail a *carrier-neutral* result core cannot
4
+ hold: an image's pixel dimensions, the rights it is offered under, whether
5
+ its locator addresses the file itself or the page containing it. The core
6
+ stays carrier-agnostic; facets ride alongside it, keyed by name.
7
+
8
+ Two rules make the vocabulary safe to grow:
9
+
10
+ - **Additive only.** A facet is added, never repurposed or removed within a
11
+ family major. Everything here is optional, and absence means "not known"
12
+ rather than "not so".
13
+ - **Unrecognised facets are ignored, not rejected.** A producer running a
14
+ later family version may carry a facet this reader has never heard of, and
15
+ the reader keeps working -- so :meth:`MediaFacets.from_metadata` drops keys
16
+ it does not know instead of raising. That is what lets a new carrier ship
17
+ without a coordinated release across every consumer.
18
+
19
+ The vocabulary lives here, beside the carrier taxonomy it belongs to
20
+ (:class:`~threetears.interfaces.media.protocols.MediaInfo`'s ``media_category``
21
+ and ``extraction_status``, and the
22
+ :class:`~threetears.interfaces.media.protocols.ObjectHandle` that answers "how
23
+ are the bytes fetchable"), rather than in each retrieval package that produces
24
+ one. A second media vocabulary in a search or discovery leaf is this
25
+ vocabulary, drifting.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ from dataclasses import dataclass
31
+ from typing import Any
32
+
33
+ __all__ = [
34
+ "LOCATOR_KIND_CONTAINING_PAGE",
35
+ "LOCATOR_KIND_DIRECT_FILE",
36
+ "MediaFacets",
37
+ ]
38
+
39
+ #: :attr:`MediaFacets.locator_kind`: the locator addresses the media bytes
40
+ #: themselves -- fetching it yields the file.
41
+ LOCATOR_KIND_DIRECT_FILE = "direct-file"
42
+
43
+ #: :attr:`MediaFacets.locator_kind`: the locator addresses a page the media
44
+ #: appears *on* -- fetching it yields markup, and the bytes need a second step.
45
+ LOCATOR_KIND_CONTAINING_PAGE = "containing-page"
46
+
47
+
48
+ @dataclass
49
+ class MediaFacets:
50
+ """The facets known to this vocabulary, for one media result.
51
+
52
+ Every facet is optional and defaults to ``None`` meaning "not reported":
53
+ a producer fills in what its upstream told it and leaves the rest alone,
54
+ and a consumer reads only the facets it understands.
55
+
56
+ ``rights_status`` is an **open** vocabulary, deliberately: providers
57
+ report usage rights in labels nobody here controls (a licence identifier,
58
+ a Creative Commons label, a provider's own bucket name), and coercing
59
+ those into a closed set would lose the distinction that made a consumer
60
+ ask. Carry the upstream label verbatim; ``None`` means the provider said
61
+ nothing, which is not the same as "unrestricted".
62
+
63
+ ``locator_kind`` is a *closed* distinction by contrast -- it is ours, not
64
+ a provider's -- so its two values ship as constants
65
+ (:data:`LOCATOR_KIND_DIRECT_FILE`, :data:`LOCATOR_KIND_CONTAINING_PAGE`)
66
+ for callers to compare against rather than as loose strings.
67
+
68
+ ``width``/``height`` are pixel dimensions, spelled the way
69
+ :class:`~threetears.interfaces.media.protocols.GeneratedImage` already
70
+ spells them so the two can be read across without a translation step.
71
+ """
72
+
73
+ rights_status: str | None = None
74
+ width: int | None = None
75
+ height: int | None = None
76
+ locator_kind: str | None = None # LOCATOR_KIND_DIRECT_FILE | LOCATOR_KIND_CONTAINING_PAGE
77
+
78
+ def to_metadata(self) -> dict[str, Any]:
79
+ """Project the facets that say something to a JSON-safe dict.
80
+
81
+ Unset facets are omitted rather than carried as explicit nulls, so the
82
+ mapping is exactly the set of facets this result asserts -- a reader
83
+ iterating its keys never has to tell a reported ``None`` apart from an
84
+ absent one.
85
+
86
+ :return: a JSON-safe mapping of facet name to value, holding only the
87
+ facets that are set
88
+ :rtype: dict[str, Any]
89
+ """
90
+ facets: dict[str, Any] = {}
91
+ if self.rights_status is not None:
92
+ facets["rights_status"] = self.rights_status
93
+ if self.width is not None:
94
+ facets["width"] = self.width
95
+ if self.height is not None:
96
+ facets["height"] = self.height
97
+ if self.locator_kind is not None:
98
+ facets["locator_kind"] = self.locator_kind
99
+ return facets
100
+
101
+ @classmethod
102
+ def from_metadata(cls, data: dict[str, Any]) -> MediaFacets:
103
+ """Read the facets this vocabulary knows, ignoring every other key.
104
+
105
+ Deliberately lenient where the rest of this package is strict: an
106
+ unknown key is a facet from a later version of the vocabulary, and
107
+ dropping it is the contract (a consumer that does not recognise a
108
+ facet ignores it rather than failing). The leniency stops there --
109
+ a *known* facet carrying an unusable value still fails, because that
110
+ is a producer bug rather than a version gap.
111
+
112
+ :param data: a facet mapping (as produced by :meth:`to_metadata`),
113
+ possibly carrying facets this reader does not know
114
+ :ptype data: dict[str, Any]
115
+ :return: the facets this vocabulary recognises; absent ones are None
116
+ :rtype: MediaFacets
117
+ :raises ValueError: when ``width`` or ``height`` is present but does
118
+ not read as an integer
119
+ :raises TypeError: when ``width`` or ``height`` is present but is of
120
+ a type no integer can be read from
121
+ """
122
+ width = data.get("width")
123
+ height = data.get("height")
124
+ rights_status = data.get("rights_status")
125
+ locator_kind = data.get("locator_kind")
126
+ return cls(
127
+ rights_status=str(rights_status) if rights_status is not None else None,
128
+ width=int(width) if width is not None else None,
129
+ height=int(height) if height is not None else None,
130
+ locator_kind=str(locator_kind) if locator_kind is not None else None,
131
+ )
@@ -0,0 +1,146 @@
1
+ """Tenant-scoped object-key builder (the platform's scope-first scheme).
2
+
3
+ The locked layout (scope-and-objects-design.md section 8) is, under one
4
+ bucket per environment::
5
+
6
+ <customer_id>/<scope>/<category>/<YYYY>/<MM>/<DD>/<object_id>/<filename>
7
+
8
+ ``customer_id`` is the tenant-isolation prefix; ``scope`` is a
9
+ framework-general owning-context label the producer supplies
10
+ (``engagement-<id>`` / ``conversation-<id>`` / ``agent-<slug>``);
11
+ ``object_id`` is a UUIDv7 (unique + time-ordered) folder so derivatives
12
+ co-locate; ``filename`` keeps the original name + extension for human
13
+ readability and correct download naming.
14
+
15
+ This builder lives in the dependency-free contract (beside
16
+ :class:`~threetears.interfaces.media.protocols.ObjectHandle`) rather than the
17
+ S3 impl package: the layout is a *contract* every producer encodes, and a
18
+ producing tool must be able to build a key without inheriting the aioboto3
19
+ client tree. The impl package re-exports it for back-compat.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import re
25
+ from datetime import datetime
26
+ from uuid import UUID
27
+
28
+ __all__ = ["build_object_key"]
29
+
30
+ #: anything outside the safe key-segment alphabet collapses to a hyphen.
31
+ _UNSAFE = re.compile(r"[^a-z0-9-]+")
32
+
33
+ #: fallback leaf when no usable filename is supplied.
34
+ _DEFAULT_FILENAME = "object"
35
+
36
+ #: isolation prefix for objects owned by the platform rather than by one
37
+ #: tenant. the scheme leads with ``customer_id`` because that segment IS the
38
+ #: isolation boundary bucket policy grants on, so a platform-shared object
39
+ #: still needs *a* leading segment to grant against -- it just cannot be a
40
+ #: customer. see :func:`build_object_key`.
41
+ _SHARED_PREFIX = "shared"
42
+
43
+
44
+ def _sanitize_segment(value: str) -> str:
45
+ """Lower-case and collapse ``value`` to the ``[a-z0-9-]`` key alphabet.
46
+
47
+ :param value: raw segment (scope label, category, filename stem)
48
+ :ptype value: str
49
+ :return: sanitized segment safe as one S3 key path component; falls
50
+ back to ``object`` when nothing usable remains
51
+ :rtype: str
52
+ """
53
+ cleaned = _UNSAFE.sub("-", value.strip().lower()).strip("-")
54
+ return cleaned or _DEFAULT_FILENAME
55
+
56
+
57
+ def _sanitize_filename(filename: str | None) -> str:
58
+ """Sanitize a filename's stem while preserving its extension.
59
+
60
+ :param filename: original filename (may carry an extension); ``None``
61
+ or empty yields the ``object`` fallback
62
+ :ptype filename: str | None
63
+ :return: readable, key-safe ``<stem>.<ext>`` (or ``<stem>``)
64
+ :rtype: str
65
+ """
66
+ stem, dot, ext = (filename or "").rpartition(".")
67
+ if not dot:
68
+ result = _sanitize_segment(filename or "")
69
+ else:
70
+ safe_stem = _sanitize_segment(stem)
71
+ safe_ext = _UNSAFE.sub("", ext.lower())
72
+ result = f"{safe_stem}.{safe_ext}" if safe_ext else safe_stem
73
+ return result
74
+
75
+
76
+ def build_object_key(
77
+ *,
78
+ customer_id: UUID | None,
79
+ scope: str,
80
+ category: str,
81
+ object_id: UUID | None = None,
82
+ created: datetime | None = None,
83
+ filename: str | None = None,
84
+ path: str | None = None,
85
+ ) -> str:
86
+ """Build the scope-first object key (locked design section 8).
87
+
88
+ Two shapes, distinguished by whether the object is tenant-owned and
89
+ whether its address is opaque or meaningful:
90
+
91
+ - **tenant-owned, opaque address** (the original and still the common
92
+ case): ``customer_id`` plus ``object_id`` and ``created`` produce
93
+ ``<customer_id>/<scope>/<category>/<YYYY>/<MM>/<DD>/<object_id>/<filename>``.
94
+ - **deterministic address** (``path`` supplied): the caller's own path
95
+ replaces the date partition, object-id folder and filename. Required
96
+ when a reader must derive the key from a request without a lookup --
97
+ a CDN fetching a map tile cannot consult a database to translate
98
+ ``z/x/y`` into a UUID.
99
+
100
+ ``customer_id=None`` addresses a platform-shared object under
101
+ the ``shared`` prefix instead of a tenant. That is not a loophole in the
102
+ tenant isolation: ``datasources.customer_id`` is itself nullable
103
+ with NULL meaning platform-shared, so objects derived from such a row
104
+ have no tenant to be scoped to, and forcing one would fork a single
105
+ shared artifact into one copy per customer.
106
+
107
+ :param customer_id: verified tenant UUID, or ``None`` for a
108
+ platform-shared object
109
+ :ptype customer_id: UUID | None
110
+ :param scope: owning-context label, e.g. ``engagement-<id>`` (sanitized)
111
+ :ptype scope: str
112
+ :param category: object kind, e.g. ``reports`` / ``evidence`` (sanitized)
113
+ :ptype category: str
114
+ :param object_id: unique object UUID (UUIDv7, time-ordered); required
115
+ unless ``path`` is supplied
116
+ :ptype object_id: UUID | None
117
+ :param created: UTC creation timestamp; drives the ``YYYY/MM/DD``
118
+ partition; required unless ``path`` is supplied
119
+ :ptype created: datetime | None
120
+ :param filename: original filename + extension; ``None`` -> ``object``
121
+ :ptype filename: str | None
122
+ :param path: caller-supplied deterministic tail, replacing the date /
123
+ object-id / filename segments. Each component is sanitized
124
+ individually so separators survive, and the final component keeps
125
+ its extension
126
+ :ptype path: str | None
127
+ :return: the full object key
128
+ :rtype: str
129
+ :raises ValueError: when neither ``path`` nor both of
130
+ ``object_id``/``created`` are supplied
131
+ """
132
+ owner = str(customer_id) if customer_id is not None else _SHARED_PREFIX
133
+ head = f"{owner}/{_sanitize_segment(scope)}/{_sanitize_segment(category)}"
134
+ if path is not None:
135
+ parts = [part for part in path.split("/") if part]
136
+ if not parts:
137
+ raise ValueError("build_object_key: path= must contain at least one component")
138
+ # the last component is a filename: sanitized as a stem plus extension
139
+ # so ``98.mvt`` does not collapse to ``98-mvt`` and lose the type a
140
+ # reader (and a CDN's content negotiation) depends on.
141
+ tail = "/".join([*(_sanitize_segment(part) for part in parts[:-1]), _sanitize_filename(parts[-1])])
142
+ return f"{head}/{tail}"
143
+ if object_id is None or created is None:
144
+ raise ValueError("build_object_key requires either path=, or both object_id= and created=")
145
+ leaf = _sanitize_filename(filename)
146
+ return f"{head}/{created:%Y/%m/%d}/{object_id}/{leaf}"
@@ -0,0 +1,618 @@
1
+ """Protocols for media-related tool capabilities.
2
+
3
+ These define the contracts that host applications implement. Tool
4
+ implementations can depend on these protocols without coupling to
5
+ any specific infrastructure (S3, specific vision APIs, etc.).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import AsyncIterator
11
+ from dataclasses import dataclass
12
+ from datetime import datetime
13
+ from typing import Any, Protocol, runtime_checkable
14
+ from uuid import UUID
15
+
16
+ __all__ = [
17
+ "EXTRACTION_STATUS_COMPLETE",
18
+ "EXTRACTION_STATUS_FAILED",
19
+ "EXTRACTION_STATUS_NONE",
20
+ "EXTRACTION_STATUS_PENDING",
21
+ "EXTRACTION_STATUS_REFUSED",
22
+ "EXTRACTION_STATUS_UNCHANGED",
23
+ "GENERATION_IGNORED",
24
+ "GENERATION_SEED",
25
+ "GENERATION_SIZE",
26
+ "GENERATION_SOURCE_USED",
27
+ "OBJECT_HANDLE_METADATA_KEY",
28
+ "GeneratedImage",
29
+ "ImageGenerationBackend",
30
+ "MediaInfo",
31
+ "MediaSizeLimitExceeded",
32
+ "MediaStorage",
33
+ "ReferenceVisionProvider",
34
+ "ObjectHandle",
35
+ "ObjectListing",
36
+ "ObjectStore",
37
+ "TextProvider",
38
+ "TranscriptionProvider",
39
+ "VisionProvider",
40
+ ]
41
+
42
+ # ``MediaInfo.extraction_status``: the spellings below are the ones already
43
+ # in the database, and that is deliberately where this vocabulary is
44
+ # defined from. ``agent-memory``'s v021 migration declares the column
45
+ # ``TEXT NOT NULL DEFAULT 'none'`` and v022 builds a partial index on
46
+ # ``WHERE extraction_status = 'pending'`` -- a spelling sitting in a column
47
+ # default and an index predicate is changed by a migration and an index
48
+ # rebuild, not by editing a constant. So these constants name what is
49
+ # stored; they do not propose it (docs/search-spec.md §3.5).
50
+ #
51
+ # The field stays ``str | None``. It is not narrowed to a ``Literal`` or a
52
+ # ``StrEnum``: consumers assign and compare bare ``str`` today, and the
53
+ # column carries no CHECK constraint, so a producer may legitimately store
54
+ # a value this list has not caught up with. Read an unrecognised status the
55
+ # way :mod:`threetears.interfaces.media.facets` reads an unrecognised facet
56
+ # -- ignore it, do not reject it.
57
+
58
+ #: No extraction has been attempted. The column's server default, and the
59
+ #: value a row carries until something asks for extraction.
60
+ EXTRACTION_STATUS_NONE = "none"
61
+
62
+ #: Extraction has been requested and has not finished. The value v022's
63
+ #: partial index is built on, which is what makes it a work queue.
64
+ EXTRACTION_STATUS_PENDING = "pending"
65
+
66
+ #: Extraction finished and the content is available.
67
+ EXTRACTION_STATUS_COMPLETE = "complete"
68
+
69
+ #: Extraction was attempted and did not produce content -- the fetch died,
70
+ #: the carrier was unreadable, the extractor gave up. Distinct from
71
+ #: :data:`EXTRACTION_STATUS_REFUSED`: something tried and failed.
72
+ EXTRACTION_STATUS_FAILED = "failed"
73
+
74
+ #: Extraction was declined before it was attempted -- ``robots.txt``
75
+ #: disallowed the fetch, a required extra was absent, a cap refused the
76
+ #: read. Distinct from :data:`EXTRACTION_STATUS_FAILED`: nothing tried, and
77
+ #: retrying under the same rules will decline again.
78
+ EXTRACTION_STATUS_REFUSED = "refused"
79
+
80
+ #: Upstream confirmed the caller's existing copy is still current, so no
81
+ #: content was produced and none was needed -- an HTTP ``304`` answering a
82
+ #: conditional request (D30 / SR-M4). Distinct from every status above it:
83
+ #: it is a **success**, unlike :data:`EXTRACTION_STATUS_FAILED`; something
84
+ #: did try, unlike :data:`EXTRACTION_STATUS_REFUSED`; and it produced no new
85
+ #: content, unlike :data:`EXTRACTION_STATUS_COMPLETE`.
86
+ #:
87
+ #: A reader that treats "no content" as failure will misread this. The
88
+ #: correct reading is that the caller's own copy is the content, and it has
89
+ #: just been re-confirmed.
90
+ EXTRACTION_STATUS_UNCHANGED = "unchanged"
91
+
92
+
93
+ @dataclass
94
+ class GeneratedImage:
95
+ """Result from an image generation backend."""
96
+
97
+ data: bytes
98
+ mime_type: str
99
+ width: int | None = None
100
+ height: int | None = None
101
+ metadata: dict[str, Any] | None = None
102
+
103
+
104
+ @dataclass
105
+ class MediaInfo:
106
+ """Metadata about a media item, returned by :meth:`MediaStorage.get_media`.
107
+
108
+ ``extraction_status`` carries one of the ``EXTRACTION_STATUS_*``
109
+ constants in this module, or ``None``. ``None`` and
110
+ :data:`EXTRACTION_STATUS_NONE` both mean *no extraction attempted*:
111
+ the dataclass defaults to ``None`` while the database column is
112
+ ``NOT NULL DEFAULT 'none'``, so which one a reader sees depends on
113
+ whether the value came from a constructor or a row. That split is
114
+ recorded rather than reconciled -- collapsing it is a migration, not a
115
+ contract edit -- so a consumer testing for "nothing has happened yet"
116
+ must accept both.
117
+
118
+ ``size_bytes`` is the stored object's size as the storage's catalog
119
+ records it, or ``None`` when the storage does not know it. It lets a
120
+ consumer refuse an object that is too large BEFORE downloading it. It is
121
+ what was recorded, not a measurement, so a consumer that must bound its
122
+ memory still bounds the read itself (``download_media(max_bytes=...)``).
123
+ """
124
+
125
+ media_id: UUID
126
+ media_category: str # "image", "audio", "video", "document"
127
+ mime_type: str
128
+ extraction_status: str | None = None
129
+ has_downloadable_data: bool = True
130
+ size_bytes: int | None = None
131
+
132
+
133
+ class MediaSizeLimitExceeded(Exception):
134
+ """A bounded download stopped: the media is larger than the limit its caller set.
135
+
136
+ Raised by :meth:`MediaStorage.download_media` when called with
137
+ ``max_bytes``, either before anything is read (the recorded size is over
138
+ the limit) or as soon as the bytes read pass it. Whatever was read is
139
+ discarded; the caller never holds more than the limit plus one chunk.
140
+
141
+ :param media_id: the media item that was too large
142
+ :ptype media_id: UUID
143
+ :param limit_bytes: the limit the caller set
144
+ :ptype limit_bytes: int
145
+ :param size_bytes: the recorded size when that is what refused it, or
146
+ ``None`` when the read passed the limit and the full size is unknown
147
+ :ptype size_bytes: int | None
148
+ """
149
+
150
+ def __init__(self, media_id: UUID, *, limit_bytes: int, size_bytes: int | None) -> None:
151
+ """Record which media item passed which limit.
152
+
153
+ :param media_id: the media item that was too large
154
+ :ptype media_id: UUID
155
+ :param limit_bytes: the limit the caller set
156
+ :ptype limit_bytes: int
157
+ :param size_bytes: the recorded size, or ``None`` when unknown
158
+ :ptype size_bytes: int | None
159
+ :return: nothing
160
+ :rtype: None
161
+ """
162
+ self.media_id = media_id
163
+ self.limit_bytes = limit_bytes
164
+ self.size_bytes = size_bytes
165
+ detail = f"it is {size_bytes:,} bytes" if size_bytes is not None else "it passed the limit while being read"
166
+ super().__init__(f"media {media_id} is over the {limit_bytes:,}-byte limit: {detail}")
167
+
168
+
169
+ @runtime_checkable
170
+ class ImageGenerationBackend(Protocol):
171
+ """Protocol for image generation backends.
172
+
173
+ What a backend did with what it was given is said in the result's
174
+ ``metadata``, under the keys :data:`GENERATION_SOURCE_USED`,
175
+ :data:`GENERATION_IGNORED`, :data:`GENERATION_SIZE` and
176
+ :data:`GENERATION_SEED`, so the caller can tell the model "the source was not
177
+ used" or "this model takes no seed" instead of reporting what was asked as
178
+ what was done. A backend may also carry a ``takes_source_image`` attribute
179
+ (bool): ``False`` lets a caller refuse an edit before paying for an image
180
+ made from the words alone. It is not part of the protocol's structure, so a
181
+ backend without it still satisfies ``isinstance``.
182
+
183
+ ``size``, ``aspect`` and ``seed`` are passed only when the caller set them,
184
+ so a backend written before they existed still takes every call that does
185
+ not use them.
186
+ """
187
+
188
+ async def generate(
189
+ self,
190
+ prompt: str,
191
+ *,
192
+ style: str | None = None,
193
+ source_image: bytes | None = None,
194
+ source_mime_type: str | None = None,
195
+ size: str | None = None,
196
+ aspect: str | None = None,
197
+ seed: int | None = None,
198
+ ) -> GeneratedImage:
199
+ """Generate an image from a text prompt.
200
+
201
+ :param prompt: text description of the image to generate
202
+ :ptype prompt: str
203
+ :param style: optional style modifier
204
+ :ptype style: str | None
205
+ :param source_image: optional source image for img2img
206
+ :ptype source_image: bytes | None
207
+ :param source_mime_type: MIME type of source image
208
+ :ptype source_mime_type: str | None
209
+ :param size: pixel size as ``"WIDTHxHEIGHT"``, e.g. ``"1024x1536"``
210
+ :ptype size: str | None
211
+ :param aspect: shape when no size is given: ``"square"``, ``"landscape"``,
212
+ ``"portrait"`` or a ratio such as ``"16:9"``
213
+ :ptype aspect: str | None
214
+ :param seed: a number that makes the same prompt give the same image again
215
+ :ptype seed: int | None
216
+ :return: generated image result
217
+ :rtype: GeneratedImage
218
+ """
219
+ ...
220
+
221
+
222
+ #: ``GeneratedImage.metadata`` key: ``True`` when a given source image shaped the
223
+ #: result, ``False`` when the backend made the image from the words alone.
224
+ GENERATION_SOURCE_USED = "source_used"
225
+
226
+ #: ``GeneratedImage.metadata`` key: the names of the options the backend could not
227
+ #: apply (``"size"``, ``"aspect"``, ``"seed"``, ``"style"``), each with why, as
228
+ #: ``{name: reason}``.
229
+ GENERATION_IGNORED = "ignored"
230
+
231
+ #: ``GeneratedImage.metadata`` key: the size the image was made at, ``"WIDTHxHEIGHT"``.
232
+ GENERATION_SIZE = "size"
233
+
234
+ #: ``GeneratedImage.metadata`` key: the seed the image was made with, when the backend has one.
235
+ GENERATION_SEED = "seed"
236
+
237
+
238
+ @runtime_checkable
239
+ class MediaStorage(Protocol):
240
+ """Protocol for media item access and storage.
241
+
242
+ Host applications implement this to bridge their specific storage
243
+ infrastructure (S3, database, etc.) to the generic tool interface.
244
+ """
245
+
246
+ async def get_media(
247
+ self,
248
+ media_id: UUID,
249
+ ) -> MediaInfo | None:
250
+ """Look up media metadata by ID.
251
+
252
+ :param media_id: media item UUID
253
+ :ptype media_id: UUID
254
+ :return: media info or None
255
+ :rtype: MediaInfo | None
256
+ """
257
+ ...
258
+
259
+ async def download_media(
260
+ self,
261
+ media_id: UUID,
262
+ *,
263
+ max_bytes: int | None = None,
264
+ ) -> tuple[bytes, str] | None:
265
+ """Download raw media bytes, bounded when the caller sets a limit.
266
+
267
+ With ``max_bytes`` the implementation refuses an item whose recorded
268
+ size is over it without reading any of it, and stops reading as soon
269
+ as the bytes read pass it -- counting as they arrive, because a
270
+ recorded size can be absent or wrong -- releasing its stream and
271
+ raising :class:`MediaSizeLimitExceeded`. ``None`` reads the whole item.
272
+
273
+ :param media_id: media item UUID
274
+ :ptype media_id: UUID
275
+ :param max_bytes: the most bytes the caller will hold, or ``None`` for no limit
276
+ :ptype max_bytes: int | None
277
+ :return: (data, mime_type) or None if unavailable
278
+ :rtype: tuple[bytes, str] | None
279
+ :raises MediaSizeLimitExceeded: when ``max_bytes`` is set and the item is larger
280
+ """
281
+ ...
282
+
283
+ async def get_content(
284
+ self,
285
+ media_id: UUID,
286
+ content_type: str,
287
+ *,
288
+ model_name: str | None = None,
289
+ ) -> str | None:
290
+ """Retrieve extracted content for a media item.
291
+
292
+ :param media_id: media item UUID
293
+ :ptype media_id: UUID
294
+ :param content_type: type of content to retrieve
295
+ :ptype content_type: str
296
+ :param model_name: optional model name filter
297
+ :ptype model_name: str | None
298
+ :return: extracted content or None
299
+ :rtype: str | None
300
+ """
301
+ ...
302
+
303
+ async def store_content(
304
+ self,
305
+ media_id: UUID,
306
+ user_id: UUID,
307
+ content_type: str,
308
+ content: str,
309
+ metadata: dict[str, Any] | None = None,
310
+ ) -> str:
311
+ """Store extracted content for a media item.
312
+
313
+ :param media_id: media item UUID
314
+ :ptype media_id: UUID
315
+ :param user_id: user who triggered extraction
316
+ :ptype user_id: UUID
317
+ :param content_type: type of content being stored
318
+ :ptype content_type: str
319
+ :param content: extracted content text
320
+ :ptype content: str
321
+ :param metadata: optional metadata
322
+ :ptype metadata: dict[str, Any] | None
323
+ :return: content ID
324
+ :rtype: str
325
+ """
326
+ ...
327
+
328
+
329
+ @runtime_checkable
330
+ class VisionProvider(Protocol):
331
+ """Protocol for image analysis via a vision-capable model."""
332
+
333
+ async def analyze(
334
+ self,
335
+ image_data: bytes,
336
+ mime_type: str,
337
+ prompt: str,
338
+ ) -> str:
339
+ """Analyze an image and return a text description.
340
+
341
+ :param image_data: raw image bytes
342
+ :ptype image_data: bytes
343
+ :param mime_type: image MIME type
344
+ :ptype mime_type: str
345
+ :param prompt: analysis prompt
346
+ :ptype prompt: str
347
+ :return: analysis result text
348
+ :rtype: str
349
+ """
350
+ ...
351
+
352
+
353
+ @runtime_checkable
354
+ class ReferenceVisionProvider(Protocol):
355
+ """Protocol for image analysis by OBJECT REFERENCE, not raw bytes.
356
+
357
+ A vision backend that resolves the image itself -- a gateway-backed
358
+ provider that names the object on the model wire and lets the platform
359
+ stream the bytes at the model boundary -- implements this instead of
360
+ :class:`VisionProvider`. The bytes never reach the calling pod, so a
361
+ provider on this protocol needs no object-store credentials and puts no
362
+ image on the message bus.
363
+
364
+ Additive and separate from :class:`VisionProvider` on purpose: a
365
+ bytes-taking backend and a reference-taking backend are different
366
+ capabilities, and a consumer (:mod:`threetears.agent.tools.builtin.analyze_media`)
367
+ detects which it holds with :func:`isinstance` and routes accordingly. A
368
+ provider may implement either; a reference-only backend does not have to
369
+ pretend it can accept bytes it cannot honour.
370
+
371
+ Several object ids analyse together in ONE turn, so a multi-image question
372
+ reaches a vision model as a single interleaved message rather than N
373
+ separate calls.
374
+ """
375
+
376
+ async def analyze_ref(
377
+ self,
378
+ object_ids: list[UUID],
379
+ prompt: str,
380
+ ) -> str:
381
+ """Analyze one or more referenced images and return a text answer.
382
+
383
+ :param object_ids: catalogued object ids to analyse together, in order
384
+ :ptype object_ids: list[UUID]
385
+ :param prompt: analysis prompt
386
+ :ptype prompt: str
387
+ :return: analysis result text
388
+ :rtype: str
389
+ """
390
+ ...
391
+
392
+
393
+ @runtime_checkable
394
+ class TextProvider(Protocol):
395
+ """Protocol for text-only queries (document QA, summarization).
396
+
397
+ Separate from :class:`VisionProvider` so that apps can use different
398
+ models for image analysis vs. text reasoning. Apps that don't
399
+ handle documents can ignore this entirely.
400
+ """
401
+
402
+ async def answer(
403
+ self,
404
+ prompt: str,
405
+ ) -> str:
406
+ """Answer a text prompt.
407
+
408
+ :param prompt: text prompt
409
+ :ptype prompt: str
410
+ :return: answer text
411
+ :rtype: str
412
+ """
413
+ ...
414
+
415
+
416
+ @runtime_checkable
417
+ class TranscriptionProvider(Protocol):
418
+ """Protocol for audio/video transcription."""
419
+
420
+ async def transcribe(
421
+ self,
422
+ audio_data: bytes,
423
+ mime_type: str,
424
+ ) -> str:
425
+ """Transcribe audio data to text.
426
+
427
+ :param audio_data: raw audio bytes
428
+ :ptype audio_data: bytes
429
+ :param mime_type: audio MIME type
430
+ :ptype mime_type: str
431
+ :return: transcription text
432
+ :rtype: str
433
+ """
434
+ ...
435
+
436
+
437
+ #: the key under which a producing tool places its :class:`ObjectHandle`
438
+ #: (as :meth:`ObjectHandle.to_metadata`) in ``ToolResult.metadata`` so the
439
+ #: agent's catalog seam can recognise + persist it in the object catalog.
440
+ OBJECT_HANDLE_METADATA_KEY = "object_handle"
441
+
442
+
443
+ @dataclass
444
+ class ObjectHandle:
445
+ """Handle to a stored object -- the small descriptor that crosses NATS
446
+ in place of the bytes.
447
+
448
+ The producing tool returns this (in ``ToolResult.metadata`` under
449
+ :data:`OBJECT_HANDLE_METADATA_KEY`); the agent catalogs it into the
450
+ hub-owned ``objects`` catalog; consumers resolve ``object_id`` back to
451
+ ``s3_key`` (customer-safe, pod-side) and stream the bytes down. The bytes
452
+ themselves never travel with the handle.
453
+ """
454
+
455
+ object_id: UUID
456
+ s3_key: str
457
+ mime_type: str
458
+ size_bytes: int
459
+ summary: str | None = None
460
+ category: str | None = None
461
+
462
+ def to_metadata(self) -> dict[str, Any]:
463
+ """Project the handle to a JSON-safe dict for ``ToolResult.metadata``.
464
+
465
+ UUIDs are stringified at this border so the descriptor survives the
466
+ NATS/JSON round-trip to the agent intact.
467
+
468
+ :return: a JSON-safe representation of this handle
469
+ :rtype: dict[str, Any]
470
+ """
471
+ return {
472
+ "object_id": str(self.object_id),
473
+ "s3_key": self.s3_key,
474
+ "mime_type": self.mime_type,
475
+ "size_bytes": self.size_bytes,
476
+ "summary": self.summary,
477
+ "category": self.category,
478
+ }
479
+
480
+ @classmethod
481
+ def from_metadata(cls, data: dict[str, Any]) -> ObjectHandle:
482
+ """Reconstruct a handle from its :meth:`to_metadata` dict.
483
+
484
+ :param data: the JSON-safe handle dict (as produced by
485
+ :meth:`to_metadata`)
486
+ :ptype data: dict[str, Any]
487
+ :return: the reconstructed handle
488
+ :rtype: ObjectHandle
489
+ :raises KeyError: when a required field is absent
490
+ :raises ValueError: when ``object_id`` is not a valid UUID
491
+ """
492
+ return cls(
493
+ object_id=UUID(str(data["object_id"])),
494
+ s3_key=str(data["s3_key"]),
495
+ mime_type=str(data["mime_type"]),
496
+ size_bytes=int(data["size_bytes"]),
497
+ summary=data.get("summary"),
498
+ category=data.get("category"),
499
+ )
500
+
501
+
502
+ @dataclass
503
+ class ObjectListing:
504
+ """One entry from an object-store listing: key plus server metadata.
505
+
506
+ Yielded by :meth:`ObjectStore.list_entries` so a reconciler can decide
507
+ orphan-eligibility by age (``last_modified``) without a per-key HEAD
508
+ round-trip. ``last_modified`` is the store's own last-modified timestamp
509
+ (timezone-aware); ``size_bytes`` is the stored object size.
510
+ """
511
+
512
+ key: str
513
+ last_modified: datetime
514
+ size_bytes: int
515
+
516
+
517
+ @runtime_checkable
518
+ class ObjectStore(Protocol):
519
+ """Streaming S3-compatible store for large binary artifacts.
520
+
521
+ Host apps / the SDK implement this over their object-store
522
+ infrastructure. STREAMING by contract: writes consume an async byte
523
+ stream and reads yield one, so a multi-GB artifact (pcap, db dump,
524
+ rendered report) never has to sit whole in a pod's memory. The key is
525
+ opaque here; the platform's tenant-scoped key scheme is built above
526
+ this contract.
527
+ """
528
+
529
+ async def put(
530
+ self,
531
+ key: str,
532
+ body: AsyncIterator[bytes],
533
+ *,
534
+ content_type: str,
535
+ size: int | None = None,
536
+ ) -> None:
537
+ """Stream ``body`` to ``key`` (multipart for large objects).
538
+
539
+ :param key: tenant-scoped object key (opaque to this contract)
540
+ :ptype key: str
541
+ :param body: async iterator yielding the object's bytes in chunks
542
+ :ptype body: AsyncIterator[bytes]
543
+ :param content_type: MIME type stored on the object
544
+ :ptype content_type: str
545
+ :param size: total byte length when known (lets the impl pick a
546
+ single PUT below the multipart threshold); None streams multipart
547
+ :ptype size: int | None
548
+ :return: nothing
549
+ :rtype: None
550
+ """
551
+ ...
552
+
553
+ def open_read(self, key: str) -> AsyncIterator[bytes]:
554
+ """Open ``key`` for streaming read, yielding bytes in chunks.
555
+
556
+ :param key: object key
557
+ :ptype key: str
558
+ :return: async iterator over the object's bytes
559
+ :rtype: AsyncIterator[bytes]
560
+ """
561
+ ...
562
+
563
+ async def delete(self, key: str) -> None:
564
+ """Delete a single object.
565
+
566
+ :param key: object key
567
+ :ptype key: str
568
+ :return: nothing
569
+ :rtype: None
570
+ """
571
+ ...
572
+
573
+ async def delete_many(self, keys: list[str]) -> None:
574
+ """Delete many objects in one batched request (reconciler sweep).
575
+
576
+ :param keys: object keys to delete
577
+ :ptype keys: list[str]
578
+ :return: nothing
579
+ :rtype: None
580
+ """
581
+ ...
582
+
583
+ def list_keys(self, prefix: str | None = None) -> AsyncIterator[str]:
584
+ """Yield object keys, optionally restricted to ``prefix``.
585
+
586
+ :param prefix: key-prefix filter (e.g. a tenant's ``<customer_id>/``);
587
+ None lists the whole bucket
588
+ :ptype prefix: str | None
589
+ :return: async iterator over object keys
590
+ :rtype: AsyncIterator[str]
591
+ """
592
+ ...
593
+
594
+ def list_entries(self, prefix: str | None = None) -> AsyncIterator[ObjectListing]:
595
+ """Yield object listings (key + last-modified + size), optionally by ``prefix``.
596
+
597
+ Like :meth:`list_keys` but carries each object's server metadata so a
598
+ reconciler can judge orphan age without a per-key HEAD request.
599
+
600
+ :param prefix: key-prefix filter (e.g. a tenant's ``<customer_id>/``);
601
+ None lists the whole bucket
602
+ :ptype prefix: str | None
603
+ :return: async iterator over object listings
604
+ :rtype: AsyncIterator[ObjectListing]
605
+ """
606
+ ...
607
+
608
+ async def presigned_get_url(self, key: str, *, expires_in: int = 300) -> str:
609
+ """Presigned GET URL for delivery -- bytes never cross the agent.
610
+
611
+ :param key: object key
612
+ :ptype key: str
613
+ :param expires_in: URL validity in seconds
614
+ :ptype expires_in: int
615
+ :return: presigned URL
616
+ :rtype: str
617
+ """
618
+ ...
File without changes