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.
- envloop_envhub-1.23.0/NOTICE +7 -0
- envloop_envhub-1.23.0/PKG-INFO +34 -0
- envloop_envhub-1.23.0/README.pypi.md +25 -0
- envloop_envhub-1.23.0/envhub/__init__.py +3 -0
- envloop_envhub-1.23.0/envhub/ans/__init__.py +5 -0
- envloop_envhub-1.23.0/envhub/ans/client/__init__.py +5 -0
- envloop_envhub-1.23.0/envhub/ans/client/download.py +83 -0
- envloop_envhub-1.23.0/envhub/ans/client/materialize.py +64 -0
- envloop_envhub-1.23.0/envhub/ans/proto/__init__.py +29 -0
- envloop_envhub-1.23.0/envhub/ans/proto/ans.proto +99 -0
- envloop_envhub-1.23.0/envhub/ans/proto/errors.py +33 -0
- envloop_envhub-1.23.0/envhub/ans/proto/manifests.py +123 -0
- envloop_envhub-1.23.0/envhub/ans/proto/schemas.py +14 -0
- envloop_envhub-1.23.0/envhub/ans/proto/uri.py +395 -0
- envloop_envhub-1.23.0/hatch_build.py +47 -0
- envloop_envhub-1.23.0/pyproject.toml +56 -0
|
@@ -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,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,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
|
+
|