velophex 0.2.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.
Files changed (43) hide show
  1. velophex/__init__.py +38 -0
  2. velophex/_json.py +297 -0
  3. velophex/agents/__init__.py +130 -0
  4. velophex/agents/_errors.py +75 -0
  5. velophex/agents/_loop.py +436 -0
  6. velophex/agents/_tools.py +168 -0
  7. velophex/agents/_tracing.py +87 -0
  8. velophex/agents/testing.py +249 -0
  9. velophex/cli/__init__.py +5 -0
  10. velophex/cli/main.py +548 -0
  11. velophex/cli/publish.py +152 -0
  12. velophex/cli/sync.py +90 -0
  13. velophex/cli/templates.py +126 -0
  14. velophex/contracts/__init__.py +103 -0
  15. velophex/contracts/_model.py +26 -0
  16. velophex/contracts/agents.py +110 -0
  17. velophex/contracts/diagnostics.py +157 -0
  18. velophex/contracts/executor.py +1921 -0
  19. velophex/contracts/package.py +391 -0
  20. velophex/contracts/project.py +1008 -0
  21. velophex/contracts/runtimes.py +226 -0
  22. velophex/contracts/signin.py +53 -0
  23. velophex/contracts/validation.py +158 -0
  24. velophex/entrypoints.py +329 -0
  25. velophex/errors.py +44 -0
  26. velophex/packaging.py +465 -0
  27. velophex/py.typed +0 -0
  28. velophex/runtime/__init__.py +471 -0
  29. velophex/runtime/_session.py +192 -0
  30. velophex/runtime/client.py +957 -0
  31. velophex/runtime/models.py +326 -0
  32. velophex/signing.py +425 -0
  33. velophex/worker/__init__.py +27 -0
  34. velophex/worker/__main__.py +118 -0
  35. velophex/worker/framing.py +248 -0
  36. velophex/worker/loader.py +105 -0
  37. velophex/worker/outbox.py +147 -0
  38. velophex/worker/session.py +519 -0
  39. velophex-0.2.0.dist-info/METADATA +313 -0
  40. velophex-0.2.0.dist-info/RECORD +43 -0
  41. velophex-0.2.0.dist-info/WHEEL +4 -0
  42. velophex-0.2.0.dist-info/entry_points.txt +2 -0
  43. velophex-0.2.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,391 @@
1
+ """``velophex/package-manifest.json`` (manifest v2) and the package layout (ADR-P003).
2
+
3
+ Mirrors ``VeloPhex.Platform.Contracts.Packages``. The manifest is a snapshot of the project
4
+ plus the SHA-512 of every file under ``content/``; the Orchestrator inspector and the Runner
5
+ re-verify both, so a package the Python CLI builds must agree with one Studio builds down to
6
+ the byte - see :mod:`velophex._json` for why writing goes through an explicit member order.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ from collections.abc import Iterable
13
+ from typing import Any, Final
14
+
15
+ from pydantic import ConfigDict, Field
16
+
17
+ from velophex import _json
18
+ from velophex.contracts._model import ContractModel, omit_none
19
+ from velophex.contracts.diagnostics import Diagnostic
20
+ from velophex.contracts.diagnostics import PackageDiagnosticCodes as Codes
21
+ from velophex.contracts.project import (
22
+ ExpressionPolicy,
23
+ ExpressionPolicyValues,
24
+ ProjectManifest,
25
+ expression_fingerprint,
26
+ expression_policy_problems,
27
+ validate_project_manifest,
28
+ )
29
+ from velophex.contracts.runtimes import RuntimeKind
30
+ from velophex.contracts.validation import is_safe_relative_path, is_sha512_hex
31
+
32
+ __all__ = [
33
+ "CURRENT_MANIFEST_VERSION",
34
+ "FORMAT_NAME",
35
+ "MINIMUM_MANIFEST_VERSION",
36
+ "SCHEMA_URI",
37
+ "PackageFile",
38
+ "PackageLayout",
39
+ "PackageManifest",
40
+ "create_package_manifest",
41
+ "describe_file",
42
+ "read_package_manifest",
43
+ "sha512_hex",
44
+ "validate_package_manifest",
45
+ "write_package_manifest",
46
+ ]
47
+
48
+ FORMAT_NAME: Final = "velophex.package-manifest"
49
+ CURRENT_MANIFEST_VERSION: Final = 2
50
+ #: The oldest manifest version a reader accepts: every version in [minimum, current] is read, a
51
+ #: manifest minor is additive only and unknown members are ignored (ADR-P003, audit VER-01).
52
+ MINIMUM_MANIFEST_VERSION: Final = 2
53
+ SCHEMA_URI: Final = "https://schemas.velophex.com/platform/package-manifest/2/package-manifest.schema.json"
54
+
55
+
56
+ class PackageLayout:
57
+ """Layout of a VeloPhex automation package: a ``.nupkg`` with ``content/`` and ``velophex/``."""
58
+
59
+ FORMAT: Final = "velophex-nupkg"
60
+ FILE_EXTENSION: Final = ".nupkg"
61
+ NUGET_PACKAGE_TYPE: Final = "VeloPhexAutomation"
62
+ CONTENT_ROOT: Final = "content/"
63
+ PROJECT_MANIFEST_PATH: Final = "content/project.json"
64
+ MANIFEST_PATH: Final = "velophex/package-manifest.json"
65
+ SIGNATURE_PATH: Final = "velophex/signature.json"
66
+ DEFAULT_MAXIMUM_PACKAGE_BYTES: Final = 512 * 1024 * 1024
67
+ MAXIMUM_MANIFEST_BYTES: Final = 4 * 1024 * 1024
68
+
69
+ @staticmethod
70
+ def to_package_path(project_relative_path: str) -> str:
71
+ """Convert ``Main.xaml`` to ``content/Main.xaml``."""
72
+ return PackageLayout.CONTENT_ROOT + project_relative_path
73
+
74
+ @staticmethod
75
+ def is_content_path(package_path: str) -> bool:
76
+ """True for a path under ``content/`` that names something."""
77
+ return package_path.startswith(PackageLayout.CONTENT_ROOT) and len(package_path) > len(
78
+ PackageLayout.CONTENT_ROOT
79
+ )
80
+
81
+ @staticmethod
82
+ def is_nuget_metadata_part(package_path: str) -> bool:
83
+ """True for the parts NuGet itself adds, which are ignored when verifying."""
84
+ return (
85
+ package_path in ("[Content_Types].xml", ".signature.p7s")
86
+ or package_path.startswith(("_rels/", "package/"))
87
+ or ("/" not in package_path and package_path.lower().endswith(".nuspec"))
88
+ )
89
+
90
+
91
+ class PackageFile(ContractModel):
92
+ """One file under ``content/``: its package path, lower-case hex SHA-512 and size."""
93
+
94
+ model_config = ConfigDict(populate_by_name=True, extra="forbid")
95
+
96
+ path: str
97
+ sha512: str
98
+ size: int
99
+
100
+ def to_json_value(self) -> dict[str, Any]:
101
+ """Ordered mapping in the member order of the .NET ``PackageFile`` record."""
102
+ return {"path": self.path, "sha512": self.sha512, "size": self.size}
103
+
104
+
105
+ class PackageManifest(ContractModel):
106
+ """A snapshot of the project and a hash of every content file."""
107
+
108
+ schema_: str | None = Field(default=None, alias="$schema")
109
+ format: str
110
+ manifest_version: int = Field(alias="manifestVersion")
111
+ package_id: str = Field(alias="packageId")
112
+ package_version: str = Field(alias="packageVersion")
113
+ runtime: str
114
+ required_runtimes: list[str] = Field(alias="requiredRuntimes")
115
+ created_by: str | None = Field(default=None, alias="createdBy")
116
+ project: ProjectManifest
117
+ files: list[PackageFile]
118
+ expression_policy: ExpressionPolicy | None = Field(default=None, alias="expressionPolicy")
119
+
120
+ def to_json_value(self) -> dict[str, Any]:
121
+ """Ordered mapping in the member order of the .NET ``PackageManifest`` record."""
122
+ return (
123
+ omit_none(
124
+ {
125
+ "$schema": self.schema_,
126
+ "format": self.format,
127
+ "manifestVersion": self.manifest_version,
128
+ "packageId": self.package_id,
129
+ "packageVersion": self.package_version,
130
+ "runtime": self.runtime,
131
+ "requiredRuntimes": list(self.required_runtimes),
132
+ "createdBy": self.created_by,
133
+ "project": self.project.to_json_value(),
134
+ "files": [file.to_json_value() for file in self.files],
135
+ "expressionPolicy": (
136
+ self.expression_policy.to_json_value() if self.expression_policy is not None else None
137
+ ),
138
+ }
139
+ )
140
+ | self.extras()
141
+ )
142
+
143
+
144
+ def sha512_hex(content: bytes) -> str:
145
+ """The platform hash rule for package files: lower-case hex SHA-512 of the raw bytes."""
146
+ return hashlib.sha512(content).hexdigest()
147
+
148
+
149
+ def describe_file(package_path: str, content: bytes) -> PackageFile:
150
+ """Describe one content file for the manifest."""
151
+ return PackageFile(path=package_path, sha512=sha512_hex(content), size=len(content))
152
+
153
+
154
+ def create_package_manifest(
155
+ project: ProjectManifest,
156
+ content_files: Iterable[PackageFile],
157
+ created_by: str | None,
158
+ ) -> PackageManifest:
159
+ """Build a manifest the same way ``PackageManifestFactory.Create`` does.
160
+
161
+ *content_files* must cover every file under ``content/``, including ``project.json``.
162
+ """
163
+ normalized = project.normalized()
164
+
165
+ return PackageManifest(
166
+ **{
167
+ "$schema": SCHEMA_URI,
168
+ "format": FORMAT_NAME,
169
+ "manifestVersion": CURRENT_MANIFEST_VERSION,
170
+ "packageId": normalized.name,
171
+ "packageVersion": normalized.version,
172
+ "runtime": normalized.runtime.kind,
173
+ "requiredRuntimes": normalized.required_runtimes(),
174
+ "createdBy": created_by,
175
+ "project": normalized,
176
+ "files": sorted(content_files, key=lambda file: file.path),
177
+ }
178
+ )
179
+
180
+
181
+ def write_package_manifest(manifest: PackageManifest) -> bytes:
182
+ """Serialize *manifest* exactly as ``PackageManifestSerializer.WriteUtf8`` does."""
183
+ return (_json.dumps_document(manifest.to_json_value()) + "\n").encode("utf-8")
184
+
185
+
186
+ def read_package_manifest(payload: bytes | str) -> tuple[PackageManifest | None, list[Diagnostic]]:
187
+ """Parse and validate ``velophex/package-manifest.json``."""
188
+ raw = payload if isinstance(payload, (bytes, bytearray)) else payload.encode("utf-8")
189
+
190
+ if len(raw) > PackageLayout.MAXIMUM_MANIFEST_BYTES:
191
+ message = f"The document is {len(raw)} bytes; the limit is {PackageLayout.MAXIMUM_MANIFEST_BYTES} bytes."
192
+ return None, [Diagnostic.error(Codes.DOCUMENT_TOO_LARGE, "$", message)]
193
+
194
+ try:
195
+ document = _json.loads(raw)
196
+ except ValueError as error:
197
+ return None, [Diagnostic.error(Codes.INVALID_JSON, "$", str(error))]
198
+
199
+ if not isinstance(document, dict):
200
+ return None, [Diagnostic.error(Codes.INVALID_JSON, "$", "The document is not a JSON object.")]
201
+
202
+ try:
203
+ manifest = PackageManifest.model_validate(document)
204
+ except Exception as error:
205
+ return None, [Diagnostic.error(Codes.INVALID_JSON, "$", str(error))]
206
+
207
+ return manifest, validate_package_manifest(manifest)
208
+
209
+
210
+ def validate_package_manifest(manifest: PackageManifest) -> list[Diagnostic]:
211
+ """Apply the platform rules to *manifest*, mirroring ``PackageManifestValidator``.
212
+
213
+ Comparing the file list with the archive itself is the reader's job, not this function's.
214
+ """
215
+ diagnostics: list[Diagnostic] = []
216
+
217
+ if manifest.format != FORMAT_NAME:
218
+ diagnostics.append(Diagnostic.error(Codes.UNSUPPORTED_FORMAT, "$.format", f"Expected format '{FORMAT_NAME}'."))
219
+
220
+ if not MINIMUM_MANIFEST_VERSION <= manifest.manifest_version <= CURRENT_MANIFEST_VERSION:
221
+ diagnostics.append(
222
+ Diagnostic.error(
223
+ Codes.UNSUPPORTED_MANIFEST_VERSION,
224
+ "$.manifestVersion",
225
+ f"Manifest version {manifest.manifest_version} is not supported; "
226
+ f"this platform reads versions {MINIMUM_MANIFEST_VERSION} to {CURRENT_MANIFEST_VERSION}.",
227
+ )
228
+ )
229
+
230
+ diagnostics.extend(validate_project_manifest(manifest.project, "$.project"))
231
+ _validate_identity(manifest, diagnostics)
232
+ _validate_files(manifest, diagnostics)
233
+ _validate_expression_policy(manifest.expression_policy, diagnostics)
234
+
235
+ return diagnostics
236
+
237
+
238
+ def _validate_expression_policy(policy: ExpressionPolicy | None, diagnostics: list[Diagnostic]) -> None:
239
+ """Mirrors ``PackageManifestValidator.ValidateExpressionPolicy``."""
240
+ if policy is None:
241
+ return
242
+
243
+ for problem in expression_policy_problems(policy.value, policy.extra_types, policy.tier_version):
244
+ diagnostics.append(Diagnostic.error(Codes.INVALID_EXPRESSION_POLICY, "$.expressionPolicy", problem))
245
+
246
+ if policy.tier_version is None:
247
+ diagnostics.append(
248
+ Diagnostic.error(
249
+ Codes.INVALID_EXPRESSION_POLICY,
250
+ "$.expressionPolicy.tierVersion",
251
+ "A recorded expression policy names the tier version it was built under.",
252
+ )
253
+ )
254
+ return
255
+
256
+ if not ExpressionPolicyValues.is_known(policy.value):
257
+ return
258
+
259
+ extras = (
260
+ sorted({item.strip() for item in policy.extra_types if item.strip()})
261
+ if policy.value == ExpressionPolicyValues.DEFAULT_PLUS_TYPES
262
+ else []
263
+ )
264
+ expected = expression_fingerprint(policy.value, policy.tier_version, extras)
265
+
266
+ if list(policy.extra_types) != extras or policy.fingerprint != expected:
267
+ diagnostics.append(
268
+ Diagnostic.error(
269
+ Codes.INVALID_EXPRESSION_POLICY,
270
+ "$.expressionPolicy.fingerprint",
271
+ "The recorded expression policy is not normalized or its fingerprint is not its own "
272
+ f"(expected {expected}).",
273
+ )
274
+ )
275
+
276
+
277
+ def _validate_identity(manifest: PackageManifest, diagnostics: list[Diagnostic]) -> None:
278
+ project = manifest.project
279
+
280
+ if manifest.package_id.casefold() != project.name.casefold():
281
+ diagnostics.append(
282
+ Diagnostic.error(
283
+ Codes.PACKAGE_ID_MISMATCH,
284
+ "$.packageId",
285
+ f"The package id must equal the project name '{project.name}'.",
286
+ )
287
+ )
288
+
289
+ if manifest.package_version != project.version:
290
+ diagnostics.append(
291
+ Diagnostic.error(
292
+ Codes.PACKAGE_VERSION_MISMATCH,
293
+ "$.packageVersion",
294
+ f"The package version must equal the project version '{project.version}'.",
295
+ )
296
+ )
297
+
298
+ if manifest.runtime != project.runtime.kind:
299
+ diagnostics.append(
300
+ Diagnostic.error(
301
+ Codes.RUNTIME_MISMATCH,
302
+ "$.runtime",
303
+ f"The runtime must equal the project runtime '{project.runtime.kind}'.",
304
+ )
305
+ )
306
+
307
+ expected = project.required_runtimes()
308
+
309
+ if sorted(manifest.required_runtimes) != expected:
310
+ diagnostics.append(
311
+ Diagnostic.error(
312
+ Codes.REQUIRED_RUNTIMES_MISMATCH,
313
+ "$.requiredRuntimes",
314
+ "Required runtimes must be the project runtime plus its automation dependencies: "
315
+ f"[{', '.join(expected)}].",
316
+ )
317
+ )
318
+
319
+
320
+ def _validate_files(manifest: PackageManifest, diagnostics: list[Diagnostic]) -> None:
321
+ paths: set[str] = set()
322
+ previous: str | None = None
323
+
324
+ for index, file in enumerate(manifest.files):
325
+ path = f"$.files[{index}]"
326
+
327
+ if (
328
+ not PackageLayout.is_content_path(file.path)
329
+ or not is_safe_relative_path(file.path)
330
+ or not is_sha512_hex(file.sha512)
331
+ or file.size < 0
332
+ ):
333
+ diagnostics.append(
334
+ Diagnostic.error(
335
+ Codes.INVALID_FILE_ENTRY,
336
+ path,
337
+ "Files are listed as a safe 'content/...' path, a lowercase hex SHA-512 and a non-negative size.",
338
+ )
339
+ )
340
+
341
+ key = file.path.casefold()
342
+
343
+ if key in paths:
344
+ diagnostics.append(
345
+ Diagnostic.error(
346
+ Codes.DUPLICATE_FILE,
347
+ f"{path}.path",
348
+ f"'{file.path}' is listed more than once (paths are case-insensitive).",
349
+ )
350
+ )
351
+ else:
352
+ paths.add(key)
353
+
354
+ if previous is not None and previous > file.path:
355
+ diagnostics.append(
356
+ Diagnostic.error(Codes.FILES_NOT_SORTED, f"{path}.path", "Files must be ordinally sorted by path.")
357
+ )
358
+
359
+ previous = file.path
360
+
361
+ if PackageLayout.PROJECT_MANIFEST_PATH.casefold() not in paths:
362
+ diagnostics.append(
363
+ Diagnostic.error(
364
+ Codes.MISSING_PROJECT_MANIFEST, "$.files", f"'{PackageLayout.PROJECT_MANIFEST_PATH}' is not listed."
365
+ )
366
+ )
367
+
368
+ for index, entry in enumerate(manifest.project.entry_points):
369
+ if PackageLayout.to_package_path(entry.path).casefold() not in paths:
370
+ diagnostics.append(
371
+ Diagnostic.error(
372
+ Codes.MISSING_ENTRY_POINT_FILE,
373
+ f"$.project.entryPoints[{index}].path",
374
+ f"Entry point file '{entry.path}' is not in the package.",
375
+ )
376
+ )
377
+
378
+ lock_file = manifest.project.runtime.lock_file
379
+
380
+ if (
381
+ manifest.project.runtime.kind == RuntimeKind.PYTHON
382
+ and lock_file is not None
383
+ and PackageLayout.to_package_path(lock_file).casefold() not in paths
384
+ ):
385
+ diagnostics.append(
386
+ Diagnostic.error(
387
+ Codes.MISSING_PYTHON_LOCK_FILE,
388
+ "$.project.runtime.lockFile",
389
+ f"Lock file '{lock_file}' is not in the package.",
390
+ )
391
+ )