scripticus-schema 0.1.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,184 @@
|
|
|
1
|
+
"""The package manifest (meta.toml): Pydantic schema and validation (D13).
|
|
2
|
+
|
|
3
|
+
The client validates for UX at pack/install time; the server re-validates
|
|
4
|
+
authoritatively at publish (D8). Both use this module, so there is exactly
|
|
5
|
+
one definition of a valid manifest (D29).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
import tomllib
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from pydantic import BaseModel, Field, ValidationError, field_validator
|
|
14
|
+
|
|
15
|
+
from scripticus_schema.semver import SEMVER_RE
|
|
16
|
+
|
|
17
|
+
# Package names are kebab-case (enforced again at publish; validated
|
|
18
|
+
# client-side so authors find out before they have written any code).
|
|
19
|
+
PACKAGE_NAME_RE = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$")
|
|
20
|
+
|
|
21
|
+
# Lower-case letters, digits, and dashes, starting with a letter. Stricter
|
|
22
|
+
# than Gitea's own username rules (which also allow '.', '_', and upper
|
|
23
|
+
# case): a namespace must satisfy both.
|
|
24
|
+
NAMESPACE_RE = re.compile(r"^[a-z][a-z0-9]*(-[a-z0-9]+)*$")
|
|
25
|
+
|
|
26
|
+
KNOWN_OS = ("linux", "macos", "windows")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class Language:
|
|
31
|
+
extension: str
|
|
32
|
+
interpreter: str
|
|
33
|
+
windows_interpreter: str
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
LANGUAGES: dict[str, Language] = {
|
|
37
|
+
"bash": Language("sh", "bash", "bash"),
|
|
38
|
+
"python": Language("py", "python3", "python"),
|
|
39
|
+
"powershell": Language("ps1", "pwsh", "powershell"),
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ManifestError(Exception):
|
|
44
|
+
"""A package directory does not contain a valid manifest."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class PackageMeta(BaseModel):
|
|
48
|
+
namespace: str
|
|
49
|
+
name: str
|
|
50
|
+
version: str
|
|
51
|
+
language: str
|
|
52
|
+
description: str = ""
|
|
53
|
+
|
|
54
|
+
@field_validator("namespace")
|
|
55
|
+
@classmethod
|
|
56
|
+
def _check_namespace(cls, value: str) -> str:
|
|
57
|
+
if not NAMESPACE_RE.match(value):
|
|
58
|
+
raise ValueError(
|
|
59
|
+
f"'{value}' is not a valid namespace"
|
|
60
|
+
" (lower-case letters, digits, and dashes, starting with a letter)"
|
|
61
|
+
)
|
|
62
|
+
return value
|
|
63
|
+
|
|
64
|
+
@field_validator("name")
|
|
65
|
+
@classmethod
|
|
66
|
+
def _check_name(cls, value: str) -> str:
|
|
67
|
+
if not PACKAGE_NAME_RE.match(value):
|
|
68
|
+
raise ValueError(f"'{value}' is not kebab-case")
|
|
69
|
+
return value
|
|
70
|
+
|
|
71
|
+
@field_validator("version")
|
|
72
|
+
@classmethod
|
|
73
|
+
def _check_version(cls, value: str) -> str:
|
|
74
|
+
if not SEMVER_RE.match(value):
|
|
75
|
+
raise ValueError(f"'{value}' is not strict semver")
|
|
76
|
+
return value
|
|
77
|
+
|
|
78
|
+
@field_validator("language")
|
|
79
|
+
@classmethod
|
|
80
|
+
def _check_language(cls, value: str) -> str:
|
|
81
|
+
if value not in LANGUAGES:
|
|
82
|
+
supported = ", ".join(sorted(LANGUAGES))
|
|
83
|
+
raise ValueError(f"'{value}' is not supported ({supported})")
|
|
84
|
+
return value
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class Platforms(BaseModel):
|
|
88
|
+
os: list[str]
|
|
89
|
+
|
|
90
|
+
@field_validator("os")
|
|
91
|
+
@classmethod
|
|
92
|
+
def _check_os(cls, value: list[str]) -> list[str]:
|
|
93
|
+
if not value or not all(os_name in KNOWN_OS for os_name in value):
|
|
94
|
+
known = ", ".join(KNOWN_OS)
|
|
95
|
+
raise ValueError(f"os must be a non-empty list drawn from: {known}")
|
|
96
|
+
return value
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class ToolDependencies(BaseModel):
|
|
100
|
+
requires: list[str] = Field(default_factory=list)
|
|
101
|
+
optional: list[str] = Field(default_factory=list)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class Dependencies(BaseModel):
|
|
105
|
+
packages: dict[str, str] = Field(default_factory=dict)
|
|
106
|
+
tools: ToolDependencies = Field(default_factory=ToolDependencies)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class Manifest(BaseModel):
|
|
110
|
+
package: PackageMeta
|
|
111
|
+
platforms: Platforms
|
|
112
|
+
commands: dict[str, str] | None = None
|
|
113
|
+
dependencies: Dependencies = Field(default_factory=Dependencies)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _format_error(error: dict) -> str:
|
|
117
|
+
location = ".".join(str(part) for part in error["loc"])
|
|
118
|
+
message = error["msg"]
|
|
119
|
+
message = message.removeprefix("Value error, ")
|
|
120
|
+
return f"[{location}] {message}" if location else message
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def validate_manifest(data: dict) -> Manifest:
|
|
124
|
+
"""Validate parsed manifest data; raise ManifestError listing every problem."""
|
|
125
|
+
try:
|
|
126
|
+
return Manifest.model_validate(data)
|
|
127
|
+
except ValidationError as exc:
|
|
128
|
+
raise ManifestError(
|
|
129
|
+
"\n".join(_format_error(error) for error in exc.errors())
|
|
130
|
+
) from exc
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def check_package_tree(manifest: Manifest, package_dir: Path) -> list[str]:
|
|
134
|
+
"""Checks that need the package tree, not just the manifest: command
|
|
135
|
+
targets must exist inside the package, and without a [commands] table
|
|
136
|
+
the default entrypoint must exist. Used by pack/install client-side and
|
|
137
|
+
by the server at publish, which validates the extracted tree the same
|
|
138
|
+
way.
|
|
139
|
+
"""
|
|
140
|
+
errors = []
|
|
141
|
+
if manifest.commands is None:
|
|
142
|
+
entrypoint = f"src/main.{LANGUAGES[manifest.package.language].extension}"
|
|
143
|
+
if not (package_dir / entrypoint).is_file():
|
|
144
|
+
errors.append(f"no [commands] table and no {entrypoint}")
|
|
145
|
+
else:
|
|
146
|
+
for command, script in manifest.commands.items():
|
|
147
|
+
script_path = package_dir / script
|
|
148
|
+
if not script_path.is_file():
|
|
149
|
+
errors.append(f"[commands] {command} points at missing file '{script}'")
|
|
150
|
+
elif package_dir.resolve() not in script_path.resolve().parents:
|
|
151
|
+
errors.append(f"[commands] {command} points outside the package: '{script}'")
|
|
152
|
+
return errors
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def load_manifest(package_dir: Path) -> Manifest:
|
|
156
|
+
"""Read and validate ``meta.toml``; raise ManifestError listing every problem."""
|
|
157
|
+
manifest_path = package_dir / "meta.toml"
|
|
158
|
+
if not manifest_path.is_file():
|
|
159
|
+
raise ManifestError(f"no meta.toml found in '{package_dir}'")
|
|
160
|
+
|
|
161
|
+
try:
|
|
162
|
+
data = tomllib.loads(manifest_path.read_text())
|
|
163
|
+
except tomllib.TOMLDecodeError as exc:
|
|
164
|
+
raise ManifestError(f"meta.toml is not valid TOML: {exc}") from exc
|
|
165
|
+
|
|
166
|
+
try:
|
|
167
|
+
manifest = Manifest.model_validate(data)
|
|
168
|
+
errors = check_package_tree(manifest, package_dir)
|
|
169
|
+
except ValidationError as exc:
|
|
170
|
+
manifest = None
|
|
171
|
+
errors = [_format_error(error) for error in exc.errors()]
|
|
172
|
+
|
|
173
|
+
if errors:
|
|
174
|
+
problems = "\n".join(f" - {error}" for error in errors)
|
|
175
|
+
raise ManifestError(f"'{package_dir}' is not a valid package:\n{problems}")
|
|
176
|
+
return manifest
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def commands_of(manifest: Manifest) -> dict[str, str]:
|
|
180
|
+
"""The command -> script-path map, applying the default-entrypoint rule."""
|
|
181
|
+
if manifest.commands:
|
|
182
|
+
return dict(manifest.commands)
|
|
183
|
+
extension = LANGUAGES[manifest.package.language].extension
|
|
184
|
+
return {manifest.package.name: f"src/main.{extension}"}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Strict semver: the official pattern and precedence ordering (D16)."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
# The official semver.org regex.
|
|
6
|
+
SEMVER_RE = re.compile(
|
|
7
|
+
r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)"
|
|
8
|
+
r"(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)"
|
|
9
|
+
r"(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?"
|
|
10
|
+
r"(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def semver_key(version: str):
|
|
15
|
+
"""A sort key implementing semver precedence (build metadata ignored).
|
|
16
|
+
|
|
17
|
+
Assumes ``version`` already matches ``SEMVER_RE``.
|
|
18
|
+
"""
|
|
19
|
+
core = version.partition("+")[0]
|
|
20
|
+
numbers, _, prerelease = core.partition("-")
|
|
21
|
+
major, minor, patch = (int(part) for part in numbers.split("."))
|
|
22
|
+
if not prerelease:
|
|
23
|
+
# A release sorts after every prerelease of the same version.
|
|
24
|
+
return (major, minor, patch, 1, ())
|
|
25
|
+
identifiers = tuple(
|
|
26
|
+
(0, int(ident), "") if ident.isdigit() else (1, 0, ident)
|
|
27
|
+
for ident in prerelease.split(".")
|
|
28
|
+
)
|
|
29
|
+
return (major, minor, patch, 0, identifiers)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Content-addressed package identity: a Merkle hash of a directory tree (D3).
|
|
2
|
+
|
|
3
|
+
The canonical identity of a package artifact is the hash of its directory
|
|
4
|
+
tree, git-style: files hash their contents, directories hash their sorted
|
|
5
|
+
entry listing. Entry names are length-prefixed in the listing so the
|
|
6
|
+
encoding is injective — a crafted file name cannot forge record structure,
|
|
7
|
+
so distinct trees cannot hash identically (D27). File modes are deliberately
|
|
8
|
+
excluded — zip extraction drops the executable bit, and the same content
|
|
9
|
+
must hash identically whichever archive container it travelled in (D26).
|
|
10
|
+
|
|
11
|
+
The client verifies this hash after download; the index verifies it at
|
|
12
|
+
publish. Both sides use this module, so the implementations cannot diverge
|
|
13
|
+
(D29).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import hashlib
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _blob_hash(path: Path) -> str:
|
|
21
|
+
return hashlib.sha256(path.read_bytes()).hexdigest()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _tree_digest(directory: Path) -> str:
|
|
25
|
+
records = []
|
|
26
|
+
for child in sorted(directory.iterdir(), key=lambda p: p.name):
|
|
27
|
+
kind = "tree" if child.is_dir() else "blob"
|
|
28
|
+
digest = _tree_digest(child) if child.is_dir() else _blob_hash(child)
|
|
29
|
+
name = child.name.encode()
|
|
30
|
+
records.append(b"%s %s %d:%s\n" % (kind.encode(), digest.encode(), len(name), name))
|
|
31
|
+
return hashlib.sha256(b"".join(records)).hexdigest()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def tree_hash(directory: Path) -> str:
|
|
35
|
+
"""The content hash of a package directory, as ``sha256:<hex>``."""
|
|
36
|
+
return f"sha256:{_tree_digest(directory)}"
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: scripticus-schema
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A package manager and registry for scripts — shared contract: manifest schema, identity, versioning
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Requires-Dist: pydantic>=2.7
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
|
|
10
|
+
# scripticus-schema
|
|
11
|
+
|
|
12
|
+
The shared contract between the [Scripticus](https://pypi.org/project/scripticus/)
|
|
13
|
+
client and [server](https://pypi.org/project/scripticus-server/): the
|
|
14
|
+
package manifest schema and validation, the naming and versioning rules,
|
|
15
|
+
and the content-addressed identity (tree hash) of a package.
|
|
16
|
+
|
|
17
|
+
This package is internal plumbing — install `scripticus` (the CLI) or
|
|
18
|
+
`scripticus-server` (the registry) instead; both depend on this package
|
|
19
|
+
and pull it in automatically.
|
|
20
|
+
|
|
21
|
+
Code lives here only if it defines what a package *is* or how client and
|
|
22
|
+
server communicate. Utilities that merely happen to be useful to both
|
|
23
|
+
sides do not belong here.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
scripticus_schema/__init__.py,sha256=cEYc4YlJSfxGWiV0TT6PSb3vtvyNwUILT_yIEEJ1t70,187
|
|
2
|
+
scripticus_schema/manifest.py,sha256=owhfm559U6PlUQohTG-B4Fr4NvkPYjSOBK9OIyUQ25I,6318
|
|
3
|
+
scripticus_schema/semver.py,sha256=gU3fTK_CXbXXL9mWrehgxRV71Ska7G2KGs_6PoYmqZ4,1007
|
|
4
|
+
scripticus_schema/treehash.py,sha256=UIRL-ZhNI0rQYkWgrrKjIV4b3ShIoxro_JVXdRpWQqo,1496
|
|
5
|
+
scripticus_schema-0.1.0.dist-info/WHEEL,sha256=YR4QUxGCbsyoOnWBVTv-p1I4yc3seKGPev46mvmc8Cg,81
|
|
6
|
+
scripticus_schema-0.1.0.dist-info/METADATA,sha256=3X-z5eJmQ5un63RRcSckdA8aLwOhRSu-YxYP_rd4sxA,943
|
|
7
|
+
scripticus_schema-0.1.0.dist-info/RECORD,,
|