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,5 @@
1
+ """The shared Scripticus contract: manifest schema, identity, versioning.
2
+
3
+ Code lives in this package only if it defines what a package is or how
4
+ client and server communicate (D29).
5
+ """
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.11.29
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any