envloop-envhub 1.23.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,7 @@
1
+ EnvLoop EnvHub implementation
2
+ Source repository: https://github.com/EnvLoop/EnvHub
3
+
4
+ This distribution preserves the implementation's source and attribution.
5
+ No implementation LICENSE exists in the source baseline; this packaging change
6
+ adds no license grant. Dataset and other content licenses are not repurposed.
7
+ Dataset, Recipe, Runner and environment assets are excluded from this package.
@@ -0,0 +1,34 @@
1
+ Metadata-Version: 2.5
2
+ Name: envloop-envhub
3
+ Version: 1.23.0
4
+ Summary: Dependency-free ANS protocol and verified downloads
5
+ License-File: NOTICE
6
+ Requires-Python: >=3.12
7
+ Provides-Extra: default
8
+ Description-Content-Type: text/markdown
9
+
10
+ # EnvLoop ANS core
11
+
12
+ `envloop-envhub==1.23.0` provides dependency-free ANS references, URI parsing,
13
+ content manifest validation and verified atomic downloads for Python 3.12+.
14
+
15
+ ```python
16
+ from envhub.ans.proto import DatasetRef, parse_uri
17
+ from envhub.ans.client import download_directory
18
+ ```
19
+
20
+ Bare and `[default]` installs have identical files and no runtime dependencies.
21
+ HTTPS uses Python's system CA trust and verifies certificates and hostnames;
22
+ the Python installation must have a working trusted CA store. Download plans
23
+ come from the caller's authorized API; this library does not issue credentials.
24
+
25
+ Only the ANS client/protocol implementation is distributed. Registry services,
26
+ databases, storage drivers, publishing tools, datasets and Runner/Recipe assets
27
+ are excluded. Internal EnvHub applications require a separately provided private
28
+ companion; installing this core does not provide those applications.
29
+
30
+ The import name is `envhub`; the distribution name is `envloop-envhub`.
31
+ Source: https://github.com/EnvLoop/EnvHub
32
+
33
+ The source baseline declares no implementation license. This packaging change
34
+ adds no license grant; excluded content licenses do not apply. See NOTICE.
@@ -0,0 +1,25 @@
1
+ # EnvLoop ANS core
2
+
3
+ `envloop-envhub==1.23.0` provides dependency-free ANS references, URI parsing,
4
+ content manifest validation and verified atomic downloads for Python 3.12+.
5
+
6
+ ```python
7
+ from envhub.ans.proto import DatasetRef, parse_uri
8
+ from envhub.ans.client import download_directory
9
+ ```
10
+
11
+ Bare and `[default]` installs have identical files and no runtime dependencies.
12
+ HTTPS uses Python's system CA trust and verifies certificates and hostnames;
13
+ the Python installation must have a working trusted CA store. Download plans
14
+ come from the caller's authorized API; this library does not issue credentials.
15
+
16
+ Only the ANS client/protocol implementation is distributed. Registry services,
17
+ databases, storage drivers, publishing tools, datasets and Runner/Recipe assets
18
+ are excluded. Internal EnvHub applications require a separately provided private
19
+ companion; installing this core does not provide those applications.
20
+
21
+ The import name is `envhub`; the distribution name is `envloop-envhub`.
22
+ Source: https://github.com/EnvLoop/EnvHub
23
+
24
+ The source baseline declares no implementation license. This packaging change
25
+ adds no license grant; excluded content licenses do not apply. See NOTICE.
@@ -0,0 +1,3 @@
1
+ """Public, dependency-free ANS core."""
2
+ from .ans import * # noqa: F403
3
+ from .ans import __all__ as __all__
@@ -0,0 +1,5 @@
1
+ """Public ANS protocol and download facade."""
2
+ from .client import download_directory, materialize_manifest, validate_download_plan
3
+ from .proto import * # noqa: F403
4
+
5
+ __all__ = ['ArtifactIdRef', 'ArtifactRef', 'AssetReference', 'CheckpointRef', 'DatasetRef', 'JobCheckpointRef', 'ModelRef', 'RecipeRef', 'RevisionRunnerRef', 'RewardSetRef', 'RunnerRef', 'RunnerRevision', 'TemplateRef', 'parse_uri', 'ANSError', 'Conflict', 'InvalidCommand', 'InvalidRecord', 'InvalidURI', 'NotFound', 'RepositoryError', 'InvalidManifest', "download_directory", "validate_download_plan", "materialize_manifest"]
@@ -0,0 +1,5 @@
1
+ """Authorized download and atomic materialization."""
2
+ from .download import download_directory, validate_download_plan
3
+ from .materialize import materialize_manifest
4
+
5
+ __all__ = ["download_directory", "validate_download_plan", "materialize_manifest"]
@@ -0,0 +1,83 @@
1
+ """Materialize one ANS directory from temporary provider URLs."""
2
+
3
+ import re
4
+ from collections.abc import Callable, Iterable, Iterator
5
+ from pathlib import Path
6
+ from typing import Any, override
7
+ from urllib.parse import urlsplit
8
+
9
+ from envhub.ans.client.materialize import materialize_manifest
10
+ from envhub.ans.proto import manifests as artifacts
11
+ from envhub.ans.proto.errors import InvalidRecord
12
+ from envhub.ans.proto.uri import parse_uri
13
+
14
+
15
+ def validate_download_plan(plan: Any, *, uri: str, split: str | None = None,
16
+ content_digest: str | None = None) -> tuple[dict[str, Any], dict[str, str], dict[str, int]]:
17
+ """Validate identity, pins and URLs before issuing any provider request."""
18
+ try:
19
+ reference = parse_uri(uri)
20
+ if (not isinstance(plan, dict) or set(plan) != {'uri', 'content_digest', 'split', 'expires_in', 'files'}
21
+ or reference.uri != uri or plan['uri'] != uri or plan['split'] != split
22
+ or not re.fullmatch('[a-f0-9]{64}', plan['content_digest'])
23
+ or content_digest is not None and plan['content_digest'] != content_digest
24
+ or type(plan['expires_in']) is not int or not 1 <= plan['expires_in'] <= 300):
25
+ raise InvalidRecord('download plan does not match the requested ANS identity')
26
+ entries: list[dict[str, Any]] = []
27
+ urls: dict[str, str] = {}
28
+ modes: dict[str, int] = {}
29
+ for entry in plan['files']:
30
+ if (set(entry) != {'path', 'size', 'sha256', 'mode', 'url'}
31
+ or type(entry['mode']) is not int or entry['mode'] not in (0o100644, 0o100755)):
32
+ raise InvalidRecord('invalid download file metadata')
33
+ url = urlsplit(entry['url'])
34
+ if (url.scheme != 'https' or not url.hostname or url.username is not None
35
+ or url.password is not None or url.fragment):
36
+ raise InvalidRecord('download requires a credential-free HTTPS origin')
37
+ entries.append({key: entry[key] for key in ('path', 'size', 'sha256')})
38
+ urls[entry['path']], modes[entry['path']] = entry['url'], entry['mode']
39
+ manifest = artifacts.validate_manifest({'schema': artifacts.SCHEMA, 'kind': 'directory', 'files': entries})
40
+ except (KeyError, TypeError, ValueError) as error:
41
+ raise InvalidRecord('invalid ANS download plan') from error
42
+
43
+ return manifest, urls, modes
44
+
45
+
46
+ def download_directory(plan: Any, destination: Path, *, uri: str, split: str | None = None,
47
+ content_digest: str | None = None,
48
+ read_url: Callable[[str], Iterable[bytes]] | None = None) -> Path:
49
+ """Verify a complete directory and publish it atomically, without API credentials.
50
+
51
+ The default reader uses one dedicated client, with no ambient authentication,
52
+ cookies or redirect support. Temporary URLs never enter the saved directory.
53
+ """
54
+ manifest, urls, modes = validate_download_plan(plan, uri=uri, split=split, content_digest=content_digest)
55
+ if read_url is not None:
56
+ def read_custom(path: str) -> Iterable[bytes]:
57
+ return read_url(urls[path])
58
+ return materialize_manifest(manifest, destination, read_custom, modes=modes)
59
+ import ssl
60
+ from urllib.error import URLError
61
+ from urllib.request import (
62
+ HTTPRedirectHandler,
63
+ HTTPSHandler,
64
+ ProxyHandler,
65
+ build_opener,
66
+ )
67
+
68
+ class NoRedirect(HTTPRedirectHandler):
69
+ @override
70
+ def redirect_request(self, *args: Any, **kwargs: Any) -> None:
71
+ return None
72
+
73
+ client = build_opener(ProxyHandler({}), NoRedirect(), HTTPSHandler(context=ssl.create_default_context()))
74
+ def read(path: str) -> Iterator[bytes]:
75
+ try:
76
+ with client.open(urls[path], timeout=60) as response:
77
+ if response.status != 200:
78
+ raise InvalidRecord('repository download failed')
79
+ while chunk := response.read(1024 * 1024):
80
+ yield chunk
81
+ except URLError:
82
+ raise InvalidRecord('repository download failed') from None
83
+ return materialize_manifest(manifest, destination, read, modes=modes)
@@ -0,0 +1,64 @@
1
+ """Verify bytes and atomically materialize ANS manifests."""
2
+ import hashlib
3
+ import os
4
+ import shutil
5
+ import tempfile
6
+ from collections.abc import Callable, Iterable
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from envhub.ans.proto.errors import InvalidManifest
11
+ from envhub.ans.proto.manifests import validate_manifest
12
+
13
+
14
+ def materialize_manifest(
15
+ manifest: dict[str, Any],
16
+ destination: Path,
17
+ read: Callable[[str], bytes | Iterable[bytes]],
18
+ *,
19
+ modes: dict[str, int] | None = None,
20
+ ) -> Path:
21
+ """Verify streamed bytes privately, then rename the complete tree into place.
22
+
23
+ ``read(relative_path)`` returns bytes or an iterable of byte chunks. Existing
24
+ destinations and symlink ancestors are rejected; callers own the parent tree.
25
+ No destination becomes visible on download, size or digest failure.
26
+ """
27
+ value = validate_manifest(manifest)
28
+ if modes is not None and (not isinstance(modes, dict)
29
+ or set(modes) != {item['path'] for item in value['files']}
30
+ or any(type(mode) is not int or mode not in (0o100644, 0o100755) for mode in modes.values())):
31
+ raise InvalidManifest('Invalid artifact executable modes')
32
+ target = Path(destination)
33
+ if any(parent.is_symlink() for parent in (target, *target.parents)):
34
+ raise InvalidManifest('Artifact destination cannot contain symlinks')
35
+ if target.exists():
36
+ raise FileExistsError('Artifact destination already exists')
37
+ staging = Path(tempfile.mkdtemp(prefix='.artifact-', dir=target.parent))
38
+ try:
39
+ for item in value['files']:
40
+ path = staging / item['path']
41
+ path.parent.mkdir(parents=True, exist_ok=True)
42
+ content = read(item['path'])
43
+ chunks = (content,) if isinstance(content, bytes) else content
44
+ digest, size = hashlib.sha256(), 0
45
+ with path.open('xb') as output:
46
+ for chunk in chunks:
47
+ if not isinstance(chunk, bytes):
48
+ raise InvalidManifest('Artifact reader must return byte chunks')
49
+ size += len(chunk)
50
+ if size > item['size']:
51
+ raise InvalidManifest('Artifact file size mismatch')
52
+ digest.update(chunk)
53
+ output.write(chunk)
54
+ if size != item['size'] or digest.hexdigest() != item['sha256']:
55
+ raise InvalidManifest('Artifact file size or SHA-256 mismatch')
56
+ if modes is not None:
57
+ path.chmod(modes[item['path']] & 0o777)
58
+ if target.exists() or target.is_symlink():
59
+ raise FileExistsError('Artifact destination already exists')
60
+ os.rename(staging, target)
61
+ return target
62
+ finally:
63
+ if staging.exists():
64
+ shutil.rmtree(staging)
@@ -0,0 +1,29 @@
1
+ """Dependency-free ANS protocol definitions."""
2
+ from .errors import (
3
+ ANSError,
4
+ Conflict,
5
+ InvalidCommand,
6
+ InvalidManifest,
7
+ InvalidRecord,
8
+ InvalidURI,
9
+ NotFound,
10
+ RepositoryError,
11
+ )
12
+ from .uri import (
13
+ ArtifactIdRef,
14
+ ArtifactRef,
15
+ AssetReference,
16
+ CheckpointRef,
17
+ DatasetRef,
18
+ JobCheckpointRef,
19
+ ModelRef,
20
+ RecipeRef,
21
+ RevisionRunnerRef,
22
+ RewardSetRef,
23
+ RunnerRef,
24
+ RunnerRevision,
25
+ TemplateRef,
26
+ parse_uri,
27
+ )
28
+
29
+ __all__ = ['ArtifactIdRef', 'ArtifactRef', 'AssetReference', 'CheckpointRef', 'DatasetRef', 'JobCheckpointRef', 'ModelRef', 'RecipeRef', 'RevisionRunnerRef', 'RewardSetRef', 'RunnerRef', 'RunnerRevision', 'TemplateRef', 'parse_uri', 'ANSError', 'Conflict', 'InvalidCommand', 'InvalidRecord', 'InvalidURI', 'NotFound', 'RepositoryError', 'InvalidManifest']
@@ -0,0 +1,99 @@
1
+ syntax = "proto3";
2
+ package envloop.ans.v1;
3
+
4
+ // Logical identity, never a storage credential or signed download URL.
5
+ message CheckpointRef {
6
+ string session_id = 1;
7
+ string checkpoint_id = 2;
8
+ }
9
+ message DatasetRef {
10
+ string namespace = 1;
11
+ string name = 2;
12
+ string version = 3;
13
+ }
14
+ message RunnerRef {
15
+ string namespace = 1;
16
+ string name = 2;
17
+ string version = 3 [deprecated = true]; // Legacy read compatibility only.
18
+ string revision = 4; // Full 40/64-character Git commit object ID.
19
+ }
20
+ message JobCheckpointRef {
21
+ string job_id = 1;
22
+ string checkpoint_id = 2;
23
+ }
24
+ message TemplateRef {
25
+ string namespace = 1;
26
+ string name = 2;
27
+ string revision = 3; // Full 64-character lowercase canonical manifest SHA-256.
28
+ }
29
+ message ModelRef {
30
+ string namespace = 1;
31
+ string name = 2;
32
+ string revision = 3;
33
+ string checkpoint = 4; // Empty means the model revision itself.
34
+ }
35
+ message ArtifactRef {
36
+ string namespace = 1;
37
+ string name = 2;
38
+ string revision = 3; // Canonical manifest SHA-256.
39
+ }
40
+ message RecipeRef {
41
+ string namespace = 1;
42
+ string name = 2;
43
+ string revision = 3; // Canonical Recipe manifest SHA-256.
44
+ }
45
+ message RewardSetRef {
46
+ string namespace = 1;
47
+ string name = 2;
48
+ string version = 3; // Author version; consumers also pin the manifest SHA-256.
49
+ }
50
+ message AssetReference {
51
+ oneof resource {
52
+ CheckpointRef checkpoint = 1;
53
+ DatasetRef datasets = 2;
54
+ JobCheckpointRef job_checkpoint = 3;
55
+ RunnerRef runners = 4;
56
+ TemplateRef templates = 5;
57
+ ArtifactRef artifacts = 6;
58
+ ModelRef model = 7;
59
+ RecipeRef recipes = 8;
60
+ RewardSetRef reward_sets = 9;
61
+ }
62
+ }
63
+ message StorageLocation {
64
+ string provider_id = 1;
65
+ string object_key = 2;
66
+ }
67
+ message AssetRecord {
68
+ AssetReference reference = 1;
69
+ string manifest_sha256 = 2;
70
+ StorageLocation location = 3;
71
+ }
72
+ message NormalizeRequest { string uri = 1; }
73
+ message NormalizeResponse {
74
+ string uri = 1;
75
+ AssetReference reference = 2;
76
+ }
77
+ message RegisterRequest { AssetRecord record = 1; }
78
+ message ResolveRequest { string uri = 1; }
79
+ message AssetResponse {
80
+ string uri = 1;
81
+ AssetRecord record = 2;
82
+ }
83
+ message ListRequest {
84
+ optional string kind = 1; // checkpoint, datasets, runners, templates, artifacts, recipes, reward-sets or model; omitted means all.
85
+ optional uint32 limit = 2; // 1..100; omitted defaults to 100.
86
+ optional string after = 3; // Previous next_cursor, a canonical URI.
87
+ }
88
+ message ListResponse {
89
+ repeated AssetRecord items = 1;
90
+ optional string next_cursor = 2;
91
+ }
92
+ // Authenticated Scope is injected by the transport, never accepted in requests.
93
+ // These declarations do not imply a deployed gRPC endpoint.
94
+ service AssetNamespaceService {
95
+ rpc Normalize(NormalizeRequest) returns (NormalizeResponse);
96
+ rpc Register(RegisterRequest) returns (AssetResponse);
97
+ rpc Resolve(ResolveRequest) returns (AssetResponse);
98
+ rpc List(ListRequest) returns (ListResponse);
99
+ }
@@ -0,0 +1,33 @@
1
+ """Stable ANS protocol errors without provider or record contents."""
2
+
3
+
4
+ class ANSError(Exception):
5
+ code = 'ANS_ERROR'
6
+
7
+
8
+ class InvalidURI(ANSError, ValueError):
9
+ code = 'INVALID_URI'
10
+
11
+
12
+ class InvalidRecord(ANSError, ValueError):
13
+ code = 'INVALID_RECORD'
14
+
15
+
16
+ class InvalidCommand(ANSError, ValueError):
17
+ code = 'INVALID_COMMAND'
18
+
19
+
20
+ class NotFound(ANSError):
21
+ code = 'NOT_FOUND'
22
+
23
+
24
+ class Conflict(ANSError):
25
+ code = 'CONFLICT'
26
+
27
+
28
+ class RepositoryError(Exception):
29
+ """Base error for local repository operations."""
30
+
31
+
32
+ class InvalidManifest(RepositoryError):
33
+ """The author manifest does not conform to the supported schema."""
@@ -0,0 +1,123 @@
1
+ """Immutable artifact manifests and verified, atomic local materialization.
2
+
3
+ The caller supplies authorized bytes. Names and content pins never grant access,
4
+ and this module neither discovers storage nor changes retention or durability.
5
+ """
6
+
7
+ import hashlib
8
+ import json
9
+ import re
10
+ from typing import Any
11
+
12
+ from envhub.ans.proto.errors import InvalidManifest, InvalidURI
13
+ from envhub.ans.proto.schemas import canonical_schema
14
+ from envhub.ans.proto.uri import ArtifactRef, parse_uri
15
+
16
+ SCHEMA = 'envloop.artifact/v1'
17
+ _HASH = re.compile(r'[0-9a-f]{64}')
18
+ _SEGMENT = re.compile(r'[A-Za-z0-9_][A-Za-z0-9._-]*')
19
+ _STATE_FIELDS = {
20
+ 'acl', 'ttl', 'ttl_seconds', 'expires_at', 'delete_requested', 'deleted',
21
+ 'location', 'storage_location', 'credentials', 'signed_url', 'api_key',
22
+ 'access_token', 'secret', 'ready', 'durable',
23
+ }
24
+ _MAX_INTEGER = 2**53 - 1 # Exact JSON integers in Python and JavaScript.
25
+
26
+
27
+ def _hash(value: Any) -> None:
28
+ if not isinstance(value, str) or not _HASH.fullmatch(value):
29
+ raise InvalidManifest('Invalid artifact SHA-256')
30
+
31
+
32
+ def _json(value: Any) -> None:
33
+ """Strict JSON subset with exact, portable numbers and no implicit coercions."""
34
+ if value is None or type(value) in (str, bool):
35
+ if isinstance(value, str):
36
+ try:
37
+ value.encode('utf-8')
38
+ except UnicodeError as exc:
39
+ raise InvalidManifest('Invalid artifact UTF-8') from exc
40
+ return
41
+ if type(value) is int and abs(value) <= _MAX_INTEGER:
42
+ return
43
+ if isinstance(value, list):
44
+ for item in value:
45
+ _json(item)
46
+ return
47
+ if isinstance(value, dict) and all(isinstance(key, str) for key in value):
48
+ for key, item in value.items():
49
+ if key.lower() in _STATE_FIELDS:
50
+ raise InvalidManifest('Artifact manifests cannot carry access, storage or runtime state')
51
+ _json(key)
52
+ _json(item)
53
+ return
54
+ raise InvalidManifest('Artifact metadata must use portable JSON values and integer numbers')
55
+
56
+
57
+ def validate_manifest(value: dict[str, Any]) -> dict[str, Any]:
58
+ """Return a detached, path-sorted manifest; reject unknown protocol fields."""
59
+ required = {'schema', 'kind', 'files'}
60
+ if (not isinstance(value, dict) or not required <= value.keys()
61
+ or value.keys() - (required | {'metadata', 'producer', 'derived_from'})
62
+ or canonical_schema(value['schema']) != SCHEMA or value['kind'] not in ('checkpoint', 'file', 'directory')):
63
+ raise InvalidManifest('Invalid artifact manifest fields, schema or kind')
64
+ files = value['files']
65
+ if not isinstance(files, list) or not files or (value['kind'] == 'file' and len(files) != 1):
66
+ raise InvalidManifest('Invalid artifact files')
67
+ paths = set()
68
+ for item in files:
69
+ if not isinstance(item, dict) or set(item) != {'path', 'size', 'sha256'}:
70
+ raise InvalidManifest('Invalid artifact file fields')
71
+ path = item['path']
72
+ if (not isinstance(path, str) or len(path) > 1024
73
+ or any(not _SEGMENT.fullmatch(part) for part in path.split('/')) or path in paths):
74
+ raise InvalidManifest('Unsafe or duplicate artifact path')
75
+ paths.add(path)
76
+ if type(item['size']) is not int or not 0 <= item['size'] <= _MAX_INTEGER:
77
+ raise InvalidManifest('Invalid artifact file size')
78
+ _hash(item['sha256'])
79
+ if any('/'.join(path.split('/')[:index]) in paths
80
+ for path in paths for index in range(1, len(path.split('/')))):
81
+ raise InvalidManifest('Conflicting artifact file paths')
82
+ if 'metadata' in value and not isinstance(value['metadata'], dict):
83
+ raise InvalidManifest('Artifact metadata must be an object')
84
+ if 'producer' in value:
85
+ producer = value['producer']
86
+ if (not isinstance(producer, dict) or 'job_id' not in producer
87
+ or producer.keys() - {'job_id', 'trial_id'}
88
+ or any(not isinstance(item, str) or not _SEGMENT.fullmatch(item)
89
+ for item in producer.values())):
90
+ raise InvalidManifest('Invalid artifact producer')
91
+ if 'derived_from' in value:
92
+ parents = value['derived_from']
93
+ if not isinstance(parents, list):
94
+ raise InvalidManifest('Invalid artifact derived_from')
95
+ seen = set()
96
+ for parent in parents:
97
+ if not isinstance(parent, dict) or set(parent) != {'uri', 'manifest_sha256'}:
98
+ raise InvalidManifest('Invalid parent artifact fields')
99
+ _hash(parent['manifest_sha256'])
100
+ try:
101
+ reference = parse_uri(parent['uri'])
102
+ except InvalidURI as exc:
103
+ raise InvalidManifest('Invalid parent artifact URI') from exc
104
+ if (reference.kind not in ('checkpoint', 'model', 'artifacts') or reference.uri != parent['uri']
105
+ or parent['uri'] in seen or (isinstance(reference, ArtifactRef)
106
+ and reference.revision != parent['manifest_sha256'])):
107
+ raise InvalidManifest('Invalid or mismatched parent artifact')
108
+ seen.add(parent['uri'])
109
+ try:
110
+ _json(value)
111
+ normalized = json.loads(json.dumps(value, ensure_ascii=False, allow_nan=False))
112
+ except (RecursionError, ValueError) as exc:
113
+ raise InvalidManifest('Invalid artifact JSON') from exc
114
+ entries: list[dict[str, Any]] = normalized['files']
115
+ entries.sort(key=lambda item: item['path'])
116
+ return normalized
117
+
118
+
119
+ def manifest_digest(value: dict[str, Any]) -> str:
120
+ """SHA-256 of validated, path-sorted canonical UTF-8 JSON (no whitespace)."""
121
+ content = json.dumps(validate_manifest(value), ensure_ascii=False, sort_keys=True,
122
+ separators=(',', ':'), allow_nan=False).encode('utf-8')
123
+ return hashlib.sha256(content).hexdigest()
@@ -0,0 +1,14 @@
1
+ """Schema namespace compatibility for readers; new documents use ``envloop.*``.
2
+
3
+ Only compare this view with an explicit supported schema/version. Never use it
4
+ as a recursive document rewrite: stored bytes and their digest remain immutable.
5
+ The ``envhub`` Python import/package name is independent of the protocol org.
6
+ """
7
+ from typing import Any
8
+
9
+
10
+ def canonical_schema(value: Any) -> Any:
11
+ """Map the historical prefix for dispatch without modifying the document."""
12
+ if isinstance(value, str) and value.startswith('envhub.'):
13
+ return 'envloop.' + value.removeprefix('envhub.')
14
+ return value
@@ -0,0 +1,395 @@
1
+ """Backend-independent checkpoint, dataset, runner, template and reward set references."""
2
+ import re
3
+ from dataclasses import dataclass
4
+ from typing import Any
5
+ from unicodedata import category
6
+ from urllib.parse import quote, unquote
7
+
8
+ from envhub.ans.proto.errors import InvalidURI
9
+
10
+ _CHECKPOINT = re.compile(r'(?:envloop|tinker)://(tts-[0-9a-f]{12})/([A-Za-z0-9_-][A-Za-z0-9_.-]*)')
11
+ _SEGMENT = re.compile(r'[A-Za-z0-9][A-Za-z0-9._-]*')
12
+ _DATASET = re.compile(r'envloop://datasets/([^/]+)/([^/@]+)@([^/]+)')
13
+ _RUNNER = re.compile(r'envloop://runners/([^/]+)/([^/@]+)@([^/]+)')
14
+ _RECIPE = re.compile(r'envloop://recipes/([^/]+)/([^/@]+)@([^/]+)')
15
+ _REWARD_SET = re.compile(r'envloop://reward-sets/([^/]+)/([^/@]+)@([^/]+)')
16
+ _ARTIFACT = re.compile(r'envloop://artifacts/([^/]+)/([^/@]+)@([^/]+)')
17
+ _TEMPLATE = re.compile(r'envloop://templates/([^/]+)/([^/@]+)@([^/]+)')
18
+ _JOB_CHECKPOINT = re.compile(r'(?:envloop|tinker)://(job_[0-7][0-9A-HJKMNP-TV-Z]{25})/([A-Za-z0-9_-][A-Za-z0-9_.-]*)')
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class CheckpointRef:
23
+ session_id: str
24
+ checkpoint_id: str
25
+
26
+ def __post_init__(self):
27
+ if not isinstance(self.session_id, str) or not isinstance(self.checkpoint_id, str):
28
+ raise InvalidURI('invalid checkpoint reference')
29
+ if not _CHECKPOINT.fullmatch(f'envloop://{self.session_id}/{self.checkpoint_id}'):
30
+ raise InvalidURI('invalid checkpoint reference')
31
+
32
+ @property
33
+ def kind(self):
34
+ return 'checkpoint'
35
+
36
+ @property
37
+ def uri(self):
38
+ return f'envloop://{self.session_id}/{self.checkpoint_id}'
39
+
40
+ def to_dict(self):
41
+ return {'checkpoint': {'session_id': self.session_id, 'checkpoint_id': self.checkpoint_id}}
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class ModelRef:
46
+ """Logical model revision and optional single checkpoint segment."""
47
+
48
+ namespace: str
49
+ name: str
50
+ revision: str
51
+ checkpoint: str = ''
52
+
53
+ def __post_init__(self):
54
+ if any(not isinstance(value, str) or not _SEGMENT.fullmatch(value) or '..' in value
55
+ for value in (self.namespace, self.name, self.revision)):
56
+ raise InvalidURI('invalid model identity')
57
+ if (not isinstance(self.checkpoint, str) or (self.checkpoint and
58
+ (not _SEGMENT.fullmatch(self.checkpoint) or '..' in self.checkpoint))):
59
+ raise InvalidURI('invalid model checkpoint')
60
+
61
+ @property
62
+ def kind(self):
63
+ return 'model'
64
+
65
+ @property
66
+ def uri(self):
67
+ suffix = '/' + self.checkpoint if self.checkpoint else ''
68
+ return f'envloop://model/{self.namespace}/{self.name}@{self.revision}{suffix}'
69
+
70
+ def to_dict(self):
71
+ return {'model': {'namespace': self.namespace, 'name': self.name,
72
+ 'revision': self.revision, 'checkpoint': self.checkpoint}}
73
+
74
+
75
+ @dataclass(frozen=True)
76
+ class DatasetRef:
77
+ namespace: str
78
+ name: str
79
+ version: str
80
+
81
+ def __post_init__(self):
82
+ if any(not isinstance(value, str) or not _SEGMENT.fullmatch(value) or '..' in value
83
+ for value in (self.namespace, self.name)):
84
+ raise InvalidURI('invalid dataset name')
85
+ if (not isinstance(self.version, str) or not self.version.strip() or self.version in {'.', '..'}
86
+ or any(category(char) == 'Cc' or char in '/\\' for char in self.version)):
87
+ raise InvalidURI('invalid dataset version')
88
+ try:
89
+ self.version.encode('utf-8')
90
+ except UnicodeError as exc:
91
+ raise InvalidURI('invalid dataset version') from exc
92
+
93
+ @property
94
+ def kind(self):
95
+ return 'datasets'
96
+
97
+ @property
98
+ def uri(self):
99
+ return f'envloop://datasets/{self.namespace}/{self.name}@{quote(self.version, safe="._-")}'
100
+
101
+ def to_dict(self):
102
+ return {'datasets': {'namespace': self.namespace, 'name': self.name, 'version': self.version}}
103
+
104
+
105
+ @dataclass(frozen=True)
106
+ class RunnerRef:
107
+ namespace: str
108
+ name: str
109
+ version: str
110
+
111
+ def __post_init__(self):
112
+ if any(not isinstance(value, str) or not _SEGMENT.fullmatch(value) or '..' in value
113
+ for value in (self.namespace, self.name, self.version)):
114
+ raise InvalidURI('invalid runner identity')
115
+
116
+ @property
117
+ def kind(self):
118
+ return 'runners'
119
+
120
+ @property
121
+ def uri(self):
122
+ return f'envloop://runners/{self.namespace}/{self.name}@{self.version}'
123
+
124
+ def to_dict(self):
125
+ return {'runners': {'namespace': self.namespace, 'name': self.name, 'version': self.version}}
126
+
127
+
128
+ @dataclass(frozen=True)
129
+ class RunnerRevision:
130
+ """ANS logical identity; legacy version identities are a separate protocol."""
131
+
132
+ namespace: str
133
+ name: str
134
+ revision: str
135
+
136
+ def __post_init__(self):
137
+ if any(not isinstance(value, str) or not _SEGMENT.fullmatch(value) or '..' in value
138
+ for value in (self.namespace, self.name)):
139
+ raise InvalidURI('invalid runner name')
140
+ if not isinstance(self.revision, str) or not re.fullmatch(r'[0-9a-f]{40}|[0-9a-f]{64}', self.revision):
141
+ raise InvalidURI('runner revision requires a complete Git commit object ID')
142
+
143
+ @property
144
+ def kind(self):
145
+ return 'runners'
146
+
147
+ @property
148
+ def uri(self):
149
+ return f'envloop://runners/{self.namespace}/{self.name}@{self.revision}'
150
+
151
+ def to_dict(self):
152
+ return {'runners': {'namespace': self.namespace, 'name': self.name, 'revision': self.revision}}
153
+
154
+
155
+ @dataclass(frozen=True)
156
+ class TemplateRef:
157
+ """Immutable template identity pinned to its canonical manifest SHA-256."""
158
+
159
+ namespace: str
160
+ name: str
161
+ revision: str
162
+
163
+ def __post_init__(self):
164
+ if any(not isinstance(value, str) or not _SEGMENT.fullmatch(value) or '..' in value
165
+ for value in (self.namespace, self.name)):
166
+ raise InvalidURI('invalid template name')
167
+ if not isinstance(self.revision, str) or not re.fullmatch(r'[0-9a-f]{64}', self.revision):
168
+ raise InvalidURI('template revision requires a complete manifest SHA-256')
169
+
170
+ @property
171
+ def kind(self):
172
+ return 'templates'
173
+
174
+ @property
175
+ def uri(self):
176
+ return f'envloop://templates/{self.namespace}/{self.name}@{self.revision}'
177
+
178
+ def to_dict(self):
179
+ return {'templates': {'namespace': self.namespace, 'name': self.name, 'revision': self.revision}}
180
+
181
+
182
+ @dataclass(frozen=True)
183
+ class ArtifactRef:
184
+ """Immutable artifact identity pinned to its canonical manifest SHA-256."""
185
+
186
+ namespace: str
187
+ name: str
188
+ revision: str
189
+
190
+ def __post_init__(self):
191
+ if any(not isinstance(value, str) or not _SEGMENT.fullmatch(value) or '..' in value
192
+ for value in (self.namespace, self.name)):
193
+ raise InvalidURI('invalid artifact name')
194
+ if not isinstance(self.revision, str) or not re.fullmatch(r'[0-9a-f]{64}', self.revision):
195
+ raise InvalidURI('artifact revision requires a complete manifest SHA-256')
196
+
197
+ @property
198
+ def kind(self):
199
+ return 'artifacts'
200
+
201
+ @property
202
+ def uri(self):
203
+ return f'envloop://artifacts/{self.namespace}/{self.name}@{self.revision}'
204
+
205
+ def to_dict(self):
206
+ return {'artifacts': {'namespace': self.namespace, 'name': self.name, 'revision': self.revision}}
207
+
208
+
209
+ @dataclass(frozen=True)
210
+ class RecipeRef:
211
+ """Immutable recipe identity pinned to its canonical manifest SHA-256."""
212
+
213
+ namespace: str
214
+ name: str
215
+ revision: str
216
+
217
+ def __post_init__(self):
218
+ if any(not isinstance(value, str) or not _SEGMENT.fullmatch(value) or '..' in value
219
+ for value in (self.namespace, self.name)):
220
+ raise InvalidURI('invalid recipe name')
221
+ if not isinstance(self.revision, str) or not re.fullmatch(r'[0-9a-f]{64}', self.revision):
222
+ raise InvalidURI('recipe revision requires a complete manifest SHA-256')
223
+
224
+ @property
225
+ def kind(self):
226
+ return 'recipes'
227
+
228
+ @property
229
+ def uri(self):
230
+ return f'envloop://recipes/{self.namespace}/{self.name}@{self.revision}'
231
+
232
+ def to_dict(self):
233
+ return {'recipes': {'namespace': self.namespace, 'name': self.name, 'revision': self.revision}}
234
+
235
+
236
+ @dataclass(frozen=True)
237
+ class RewardSetRef:
238
+ """Author-versioned verifier bundle; consumers pin the version's manifest SHA-256.
239
+
240
+ Like a Dataset version, a published version never changes: its first registration binds the
241
+ manifest digest and a different manifest under the same URI is a conflict.
242
+ """
243
+
244
+ namespace: str
245
+ name: str
246
+ version: str
247
+
248
+ def __post_init__(self):
249
+ if any(not isinstance(value, str) or not _SEGMENT.fullmatch(value) or '..' in value
250
+ for value in (self.namespace, self.name, self.version)):
251
+ raise InvalidURI('invalid reward set identity')
252
+
253
+ @property
254
+ def kind(self):
255
+ return 'reward-sets'
256
+
257
+ @property
258
+ def uri(self):
259
+ return f'envloop://reward-sets/{self.namespace}/{self.name}@{self.version}'
260
+
261
+ def to_dict(self):
262
+ return {'reward_sets': {'namespace': self.namespace, 'name': self.name, 'version': self.version}}
263
+
264
+
265
+ @dataclass(frozen=True)
266
+ class RevisionRunnerRef:
267
+ """Frozen Job reference with both immutable content pins and no storage details."""
268
+
269
+ name: str
270
+ revision: str
271
+ sha256: str
272
+ index_sha256: str
273
+
274
+ def __post_init__(self):
275
+ self.identity
276
+ for value in (self.sha256, self.index_sha256):
277
+ if not isinstance(value, str) or not re.fullmatch(r'[0-9a-f]{64}', value):
278
+ raise InvalidURI('invalid Runner content pin')
279
+
280
+ @property
281
+ def identity(self):
282
+ if not isinstance(self.name, str) or len(self.name.split('/')) != 2:
283
+ raise InvalidURI('invalid runner name')
284
+ namespace, name = self.name.split('/')
285
+ return RunnerRevision(namespace, name, self.revision)
286
+
287
+ @property
288
+ def uri(self):
289
+ return self.identity.uri
290
+
291
+ def to_dict(self):
292
+ return {'uri': self.uri, 'name': self.name, 'revision': self.revision,
293
+ 'sha256': self.sha256, 'index_sha256': self.index_sha256}
294
+
295
+ @classmethod
296
+ def from_dict(cls, value: Any):
297
+ if not isinstance(value, dict) or set(value) != {'uri', 'name', 'revision', 'sha256', 'index_sha256'}:
298
+ raise InvalidURI('invalid RunnerRef fields')
299
+ reference = cls(**{key: value[key] for key in ('name', 'revision', 'sha256', 'index_sha256')})
300
+ if value['uri'] != reference.uri:
301
+ raise InvalidURI('RunnerRef URI does not match its identity')
302
+ return reference
303
+
304
+
305
+ @dataclass(frozen=True)
306
+ class JobCheckpointRef:
307
+ job_id: str
308
+ checkpoint_id: str
309
+
310
+ def __post_init__(self):
311
+ if (not isinstance(self.job_id, str) or not isinstance(self.checkpoint_id, str)
312
+ or not _JOB_CHECKPOINT.fullmatch(f'envloop://{self.job_id}/{self.checkpoint_id}')):
313
+ raise InvalidURI('invalid job checkpoint reference')
314
+
315
+ @property
316
+ def kind(self):
317
+ return 'checkpoint'
318
+
319
+ @property
320
+ def uri(self):
321
+ return f'envloop://{self.job_id}/{self.checkpoint_id}'
322
+
323
+ def to_dict(self):
324
+ return {'job_checkpoint': {'job_id': self.job_id, 'checkpoint_id': self.checkpoint_id}}
325
+
326
+
327
+ @dataclass(frozen=True)
328
+ class ArtifactIdRef:
329
+ """Stable opaque platform Artifact ID, never a path or storage locator."""
330
+
331
+ artifact_id: str
332
+
333
+ def __post_init__(self):
334
+ if (not isinstance(self.artifact_id, str) or len(self.artifact_id) > 256
335
+ or not _SEGMENT.fullmatch(self.artifact_id) or '..' in self.artifact_id):
336
+ raise InvalidURI('invalid artifact identity')
337
+
338
+ @property
339
+ def kind(self):
340
+ return 'artifacts'
341
+
342
+ @property
343
+ def uri(self):
344
+ return f'envloop://artifacts/{self.artifact_id}'
345
+
346
+ def to_dict(self):
347
+ return {'artifacts': {'artifact_id': self.artifact_id}}
348
+
349
+
350
+ AssetReference = ArtifactIdRef | CheckpointRef | JobCheckpointRef | DatasetRef | RunnerRef | RunnerRevision | TemplateRef | ModelRef | ArtifactRef | RecipeRef | RewardSetRef
351
+
352
+
353
+ def parse_uri(value: str) -> AssetReference:
354
+ """Strict parsing; legacy checkpoint inputs normalize through ``ref.uri``."""
355
+ if not isinstance(value, str):
356
+ raise InvalidURI('asset URI must be a string')
357
+ if re.fullmatch(r'envloop://artifacts/[^/]+', value):
358
+ return ArtifactIdRef(value.removeprefix('envloop://artifacts/'))
359
+ model = re.fullmatch(r'envloop://model/([^/]+)/([^/@]+)@([^/]+)(?:/([^/]+))?', value)
360
+ if model:
361
+ namespace, name, revision, checkpoint = model.groups()
362
+ return ModelRef(namespace, name, revision, checkpoint or '')
363
+ job_checkpoint = _JOB_CHECKPOINT.fullmatch(value)
364
+ if job_checkpoint:
365
+ return JobCheckpointRef(*job_checkpoint.groups())
366
+ checkpoint = _CHECKPOINT.fullmatch(value)
367
+ if checkpoint:
368
+ return CheckpointRef(*checkpoint.groups())
369
+ recipe = _RECIPE.fullmatch(value)
370
+ if recipe:
371
+ return RecipeRef(*recipe.groups())
372
+ reward_set = _REWARD_SET.fullmatch(value)
373
+ if reward_set:
374
+ return RewardSetRef(*reward_set.groups())
375
+ artifact = _ARTIFACT.fullmatch(value)
376
+ if artifact:
377
+ return ArtifactRef(*artifact.groups())
378
+ template = _TEMPLATE.fullmatch(value)
379
+ if template:
380
+ return TemplateRef(*template.groups())
381
+ runner = _RUNNER.fullmatch(value)
382
+ if runner:
383
+ if re.fullmatch(r'[0-9a-f]{40}|[0-9a-f]{64}', runner.group(3)):
384
+ return RunnerRevision(*runner.groups())
385
+ return RunnerRef(*runner.groups())
386
+ dataset = _DATASET.fullmatch(value)
387
+ if dataset:
388
+ namespace, name, encoded = dataset.groups()
389
+ try:
390
+ reference = DatasetRef(namespace, name, unquote(encoded, errors='strict'))
391
+ except UnicodeError as exc:
392
+ raise InvalidURI('invalid dataset version encoding') from exc
393
+ if reference.uri == value:
394
+ return reference
395
+ raise InvalidURI('unsupported or malformed asset URI')
@@ -0,0 +1,47 @@
1
+ """Fail closed before either implementation-only artifact is assembled."""
2
+ import stat
3
+ import tempfile
4
+ import tomllib
5
+ from pathlib import Path, PurePosixPath
6
+
7
+ from hatchling.builders.hooks.plugin.interface import BuildHookInterface
8
+
9
+
10
+ class CustomBuildHook(BuildHookInterface):
11
+ def initialize(self, version, build_data):
12
+ root = Path(self.root)
13
+ config = tomllib.loads((root / 'pyproject.toml').read_text())
14
+ selected = config['tool']['hatch']['build']['targets'][self.target_name]['only-include']
15
+ # Hatch otherwise force-includes VCS ignore files in sdists, bypassing selection.
16
+ build_data['force_include'] = {source: target for source, target in
17
+ build_data.get('force_include', {}).items() if target in selected}
18
+ metadata = {'pyproject.toml', 'README.pypi.md', 'NOTICE', 'hatch_build.py'}
19
+ for name in selected:
20
+ path = PurePosixPath(name)
21
+ if (str(path) != name or path.is_absolute() or '..' in path.parts
22
+ or '\\' in name or any(part.startswith('.') for part in path.parts)
23
+ or not (name in metadata or (path.parts[0] == 'envhub'
24
+ and (path.suffix == '.py' or name == 'envhub/ans/proto/ans.proto')))):
25
+ raise ValueError('DISTRIBUTION_PATH_INVALID: implementation allowlist only')
26
+ target = root
27
+ for part in path.parts:
28
+ target = target / part
29
+ if target.is_symlink():
30
+ raise ValueError('DISTRIBUTION_LINK_FORBIDDEN: source must be ordinary files')
31
+ mode = target.stat().st_mode
32
+ if not stat.S_ISREG(mode) or mode & 0o7111:
33
+ raise ValueError('DISTRIBUTION_MODE_INVALID: ordinary non-executable files only')
34
+
35
+ if self.target_name == 'sdist':
36
+ # Do not expose private workspace/dependency configuration in the public archive.
37
+ public_config = (root / 'pyproject.toml').read_text().split('# Private development configuration;')[0]
38
+ self._projection = tempfile.TemporaryDirectory(prefix='envhub-public-build-')
39
+ projected = Path(self._projection.name) / 'pyproject.toml'
40
+ projected.write_text(public_config)
41
+ build_data['force_include'] = {source: target for source, target in
42
+ build_data['force_include'].items() if target != 'pyproject.toml'}
43
+ build_data['force_include'][str(projected)] = 'pyproject.toml'
44
+
45
+ def finalize(self, version, build_data, artifact_path):
46
+ if hasattr(self, '_projection'):
47
+ self._projection.cleanup()
@@ -0,0 +1,56 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.26"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "envloop-envhub"
7
+ version = "1.23.0"
8
+ description = "Dependency-free ANS protocol and verified downloads"
9
+ readme = "README.pypi.md"
10
+ license-files = ["NOTICE"]
11
+ requires-python = ">=3.12"
12
+ dependencies = []
13
+
14
+ [project.optional-dependencies]
15
+ default = []
16
+
17
+ [tool.hatch.build]
18
+ ignore-vcs = true
19
+
20
+ [tool.hatch.build.hooks.custom]
21
+ path = "hatch_build.py"
22
+
23
+ [tool.hatch.build.targets.wheel]
24
+ only-include = [
25
+ "envhub/__init__.py",
26
+ "envhub/ans/__init__.py",
27
+ "envhub/ans/client/__init__.py",
28
+ "envhub/ans/client/download.py",
29
+ "envhub/ans/client/materialize.py",
30
+ "envhub/ans/proto/__init__.py",
31
+ "envhub/ans/proto/uri.py",
32
+ "envhub/ans/proto/errors.py",
33
+ "envhub/ans/proto/manifests.py",
34
+ "envhub/ans/proto/schemas.py",
35
+ "envhub/ans/proto/ans.proto"
36
+ ]
37
+
38
+ [tool.hatch.build.targets.sdist]
39
+ only-include = [
40
+ "envhub/__init__.py",
41
+ "envhub/ans/__init__.py",
42
+ "envhub/ans/client/__init__.py",
43
+ "envhub/ans/client/download.py",
44
+ "envhub/ans/client/materialize.py",
45
+ "envhub/ans/proto/__init__.py",
46
+ "envhub/ans/proto/uri.py",
47
+ "envhub/ans/proto/errors.py",
48
+ "envhub/ans/proto/manifests.py",
49
+ "envhub/ans/proto/schemas.py",
50
+ "envhub/ans/proto/ans.proto",
51
+ "pyproject.toml",
52
+ "README.pypi.md",
53
+ "NOTICE",
54
+ "hatch_build.py"
55
+ ]
56
+