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
velophex/packaging.py ADDED
@@ -0,0 +1,465 @@
1
+ """Builds an automation package (``.nupkg``) from a project directory (ADR-P003).
2
+
3
+ This is the Python twin of ``Velophex.Workflow.Packaging.Automation.AutomationPackageBuilder``.
4
+ The two must agree, because the Orchestrator inspects and the Runner verifies both with the
5
+ same readers, and Studio and the CLI may publish the same project.
6
+
7
+ **What "the same package" means.** The uncompressed content is byte-identical: the same entry
8
+ names in the same order, the same fixed timestamp, and the same bytes in every entry -
9
+ ``project.json``, the manifest, the ``.nuspec`` and ``[Content_Types].xml`` down to the last
10
+ escape. The *compressed* bytes are not compared, because the deflate stream a .NET
11
+ ``ZipArchive`` emits is not the one zlib emits and neither is part of the contract; what the
12
+ platform hashes is the manifest and the files it lists, never the archive's compression.
13
+
14
+ The output is deterministic in the sense the contract needs: the same project always produces
15
+ the same package from this builder, so a rebuild does not invalidate a signature.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import fnmatch
21
+ import stat as stat_module
22
+ import zipfile
23
+ from collections.abc import Iterable
24
+ from dataclasses import dataclass, field
25
+ from io import BytesIO
26
+ from pathlib import Path
27
+ from typing import Final
28
+
29
+ from velophex.contracts.diagnostics import Diagnostic, has_errors
30
+ from velophex.contracts.diagnostics import PackageDiagnosticCodes as Codes
31
+ from velophex.contracts.package import (
32
+ PackageLayout,
33
+ PackageManifest,
34
+ create_package_manifest,
35
+ describe_file,
36
+ validate_package_manifest,
37
+ write_package_manifest,
38
+ )
39
+ from velophex.contracts.project import (
40
+ ProjectFiles,
41
+ ProjectManifest,
42
+ maximum_icon_bytes,
43
+ read_project_manifest,
44
+ write_project_manifest,
45
+ )
46
+
47
+ __all__ = [
48
+ "CREDENTIAL_SHAPED_PATHS",
49
+ "DEFAULT_EXCLUDED_PATHS",
50
+ "ENTRY_TIMESTAMP",
51
+ "MAXIMUM_PACKAGE_PATH_LENGTH",
52
+ "PackageBuildOptions",
53
+ "PackageBuildResult",
54
+ "build_package",
55
+ "build_package_to_directory",
56
+ "content_types_xml",
57
+ "nuspec_xml",
58
+ "write_project_file",
59
+ ]
60
+
61
+ #: Every entry is stamped with this instant, so the same project gives the same package.
62
+ #: (The ZIP format's own epoch is 1980; this is the .NET builder's value.)
63
+ ENTRY_TIMESTAMP: Final = (2000, 1, 1, 0, 0, 0)
64
+
65
+ MAXIMUM_PACKAGE_PATH_LENGTH: Final = 240
66
+
67
+ #: Files that hold credentials by convention, never packed, in any directory (PY-05). Mirrors the
68
+ #: engine's ``AutomationPackageBuildOptions.CredentialShapedPaths``: ``appsettings.json`` itself is
69
+ #: packed (base configuration), its environment overlays are where the secrets go.
70
+ CREDENTIAL_SHAPED_PATHS: Final[tuple[str, ...]] = (
71
+ "**/.env",
72
+ "**/.env.*",
73
+ "*.pfx",
74
+ "*.p12",
75
+ "*.pem",
76
+ "*.key",
77
+ "**/secrets.json",
78
+ "**/appsettings.*.json",
79
+ )
80
+
81
+ #: Project paths that are never packed: build output, local tool state, environments and
82
+ #: credential files. Mirrors the engine's ``AutomationPackageBuildOptions.DefaultExcludedPaths``.
83
+ DEFAULT_EXCLUDED_PATHS: Final[tuple[str, ...]] = (
84
+ "bin/",
85
+ "obj/",
86
+ ".git/",
87
+ ".vs/",
88
+ ".vscode/",
89
+ ".idea/",
90
+ ".velophex/",
91
+ ".venv/",
92
+ "__pycache__/",
93
+ "*.nupkg",
94
+ "*.user",
95
+ "*.pyc",
96
+ *CREDENTIAL_SHAPED_PATHS,
97
+ )
98
+
99
+ _RESERVED_DEVICE_NAMES: Final[frozenset[str]] = frozenset(
100
+ {"CON", "PRN", "AUX", "NUL", *(f"COM{n}" for n in range(1, 10)), *(f"LPT{n}" for n in range(1, 10))}
101
+ )
102
+
103
+
104
+ @dataclass(frozen=True, slots=True)
105
+ class PackageBuildOptions:
106
+ """Options for :func:`build_package`."""
107
+
108
+ #: Recorded as ``createdBy`` in the manifest, e.g. ``velophex-cli 0.1.0``.
109
+ created_by: str | None = None
110
+ excluded_paths: tuple[str, ...] = DEFAULT_EXCLUDED_PATHS
111
+ maximum_content_bytes: int = PackageLayout.DEFAULT_MAXIMUM_PACKAGE_BYTES
112
+
113
+
114
+ @dataclass(slots=True)
115
+ class PackageBuildResult:
116
+ """The outcome of building a package."""
117
+
118
+ diagnostics: list[Diagnostic] = field(default_factory=list)
119
+ manifest: PackageManifest | None = None
120
+ package: bytes | None = None
121
+ file_name: str | None = None
122
+
123
+ @property
124
+ def succeeded(self) -> bool:
125
+ """True when the package was produced and nothing was reported as an error."""
126
+ return self.package is not None and not has_errors(self.diagnostics)
127
+
128
+
129
+ def build_package(project_directory: str | Path, options: PackageBuildOptions | None = None) -> PackageBuildResult:
130
+ """Build the package for the project at *project_directory*, in memory."""
131
+ options = options or PackageBuildOptions()
132
+ root = Path(project_directory).resolve()
133
+ project_file = root / ProjectFiles.MANIFEST_FILE_NAME
134
+
135
+ if not project_file.is_file():
136
+ return PackageBuildResult(
137
+ diagnostics=[
138
+ Diagnostic.error(
139
+ Codes.MISSING_PROJECT_MANIFEST,
140
+ "$",
141
+ f"'{project_directory}' has no {ProjectFiles.MANIFEST_FILE_NAME}.",
142
+ )
143
+ ]
144
+ )
145
+
146
+ project, diagnostics = read_project_manifest(project_file.read_bytes(), "$.project")
147
+
148
+ if project is None or has_errors(diagnostics):
149
+ return PackageBuildResult(diagnostics=list(diagnostics))
150
+
151
+ content, read_diagnostics = _read_content(root, options)
152
+ diagnostics.extend(read_diagnostics)
153
+ diagnostics.extend(_check_icon(project, content))
154
+
155
+ if has_errors(diagnostics):
156
+ return PackageBuildResult(diagnostics=diagnostics)
157
+
158
+ manifest = create_package_manifest(
159
+ project,
160
+ (describe_file(path, payload) for path, payload in content.items()),
161
+ options.created_by,
162
+ )
163
+
164
+ diagnostics.extend(validate_package_manifest(manifest))
165
+
166
+ if has_errors(diagnostics):
167
+ return PackageBuildResult(diagnostics=diagnostics)
168
+
169
+ return PackageBuildResult(
170
+ diagnostics=diagnostics,
171
+ manifest=manifest,
172
+ package=_write_archive(manifest, content),
173
+ file_name=f"{manifest.package_id}.{manifest.package_version}{PackageLayout.FILE_EXTENSION}",
174
+ )
175
+
176
+
177
+ def _check_icon(project: ProjectManifest, content: dict[str, bytes]) -> list[Diagnostic]:
178
+ """Mirror of ``AutomationPackageBuilder.CheckIcon``: the icon is packed and within its limit."""
179
+ if project.icon is None:
180
+ return []
181
+
182
+ payload = content.get(PackageLayout.CONTENT_ROOT + project.icon)
183
+
184
+ if payload is None:
185
+ message = f"The package icon '{project.icon}' is not in the project, or the exclusions leave it out."
186
+ return [Diagnostic.error(Codes.MISSING_FILE, "$.project.icon", message)]
187
+
188
+ limit = maximum_icon_bytes(project.icon)
189
+
190
+ if len(payload) > limit:
191
+ message = f"The package icon '{project.icon}' is {len(payload)} bytes; the limit is {limit} bytes."
192
+ return [Diagnostic.error(Codes.DOCUMENT_TOO_LARGE, "$.project.icon", message)]
193
+
194
+ return []
195
+
196
+
197
+ def build_package_to_directory(
198
+ project_directory: str | Path,
199
+ output_directory: str | Path,
200
+ options: PackageBuildOptions | None = None,
201
+ ) -> tuple[PackageBuildResult, Path | None]:
202
+ """Build the package and write it to ``{output}/{id}.{version}.nupkg``."""
203
+ result = build_package(project_directory, options)
204
+
205
+ if not result.succeeded:
206
+ return result, None
207
+
208
+ output = Path(output_directory)
209
+ output.mkdir(parents=True, exist_ok=True)
210
+ path = output / result.file_name # type: ignore[arg-type]
211
+ path.write_bytes(result.package) # type: ignore[arg-type]
212
+
213
+ return result, path
214
+
215
+
216
+ # --------------------------------------------------------------------------- content
217
+
218
+
219
+ def _read_content(root: Path, options: PackageBuildOptions) -> tuple[dict[str, bytes], list[Diagnostic]]:
220
+ diagnostics: list[Diagnostic] = []
221
+ content: dict[str, bytes] = {}
222
+ collisions: set[str] = set()
223
+ total = 0
224
+
225
+ for relative in _enumerate_files(root, root, options, diagnostics):
226
+ package_path = PackageLayout.to_package_path(relative)
227
+ reason = _invalid_package_path(package_path)
228
+
229
+ if reason is not None:
230
+ diagnostics.append(
231
+ Diagnostic.error(
232
+ Codes.INVALID_FILE_ENTRY, "$.files", f"Project file '{relative}' cannot be packed: {reason}."
233
+ )
234
+ )
235
+ continue
236
+
237
+ if package_path.upper() in collisions:
238
+ diagnostics.append(
239
+ Diagnostic.error(
240
+ Codes.DUPLICATE_FILE,
241
+ "$.files",
242
+ f"Project file '{relative}' differs from another file only by case.",
243
+ )
244
+ )
245
+ continue
246
+
247
+ collisions.add(package_path.upper())
248
+
249
+ payload = (root / relative).read_bytes()
250
+ total += len(payload)
251
+
252
+ if total > options.maximum_content_bytes:
253
+ diagnostics.append(
254
+ Diagnostic.error(
255
+ Codes.DOCUMENT_TOO_LARGE,
256
+ "$.files",
257
+ f"The project content exceeds {options.maximum_content_bytes} bytes.",
258
+ )
259
+ )
260
+ break
261
+
262
+ content[package_path] = payload
263
+
264
+ return dict(sorted(content.items())), diagnostics
265
+
266
+
267
+ def _enumerate_files(
268
+ root: Path,
269
+ directory: Path,
270
+ options: PackageBuildOptions,
271
+ diagnostics: list[Diagnostic],
272
+ ) -> list[str]:
273
+ found: list[str] = []
274
+
275
+ for entry in sorted(directory.iterdir(), key=lambda item: item.name):
276
+ relative = entry.relative_to(root).as_posix()
277
+ is_directory = entry.is_dir()
278
+
279
+ if any(_matches(relative + "/" if is_directory else relative, pattern) for pattern in options.excluded_paths):
280
+ continue
281
+
282
+ if _is_link(entry):
283
+ diagnostics.append(
284
+ Diagnostic.error(
285
+ Codes.INVALID_FILE_ENTRY, "$.files", f"'{relative}' is a link; links are never packed."
286
+ )
287
+ )
288
+ continue
289
+
290
+ if is_directory:
291
+ found.extend(_enumerate_files(root, entry, options, diagnostics))
292
+ else:
293
+ found.append(relative)
294
+
295
+ return found
296
+
297
+
298
+ def _is_link(entry: Path) -> bool:
299
+ if entry.is_symlink():
300
+ return True
301
+
302
+ # Windows junctions are reparse points but not symlinks; packing one would follow a path
303
+ # outside the project.
304
+ attributes = getattr(entry.lstat(), "st_file_attributes", 0)
305
+ return bool(attributes & getattr(stat_module, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400))
306
+
307
+
308
+ def _matches(path: str, pattern: str) -> bool:
309
+ """Match the pattern forms the .NET builder uses (``PackagePath.Matches``).
310
+
311
+ ``*.ext``, ``dir/``, ``**/name`` (a file name in any directory, ``*`` standing for any
312
+ characters) or an exact path.
313
+ """
314
+ if pattern.startswith("*."):
315
+ return path.lower().endswith(pattern[1:].lower())
316
+
317
+ if pattern.startswith("**/"):
318
+ return fnmatch.fnmatchcase(path.rsplit("/", 1)[-1].lower(), pattern[3:].lower())
319
+
320
+ if pattern.endswith("/"):
321
+ return path.lower().startswith(pattern.lower())
322
+
323
+ return path.lower() == pattern.lower()
324
+
325
+
326
+ def _invalid_package_path(path: str) -> str | None:
327
+ """Why *path* cannot be an archive entry, or ``None`` when it is fine."""
328
+ if not path:
329
+ return "the path is empty"
330
+
331
+ if len(path) > MAXIMUM_PACKAGE_PATH_LENGTH:
332
+ return f"the path is longer than {MAXIMUM_PACKAGE_PATH_LENGTH} characters"
333
+
334
+ if "\\" in path:
335
+ return "the path contains a backslash"
336
+
337
+ if ":" in path:
338
+ return "the path contains a drive or stream separator ':'"
339
+
340
+ if path.startswith("/"):
341
+ return "the path is rooted"
342
+
343
+ for character in path:
344
+ if ord(character) < 0x20 or ord(character) == 0x7F:
345
+ return "the path contains a control character"
346
+
347
+ if character in '<>"|?*':
348
+ return f"the path contains the reserved character '{character}'"
349
+
350
+ for segment in path.split("/"):
351
+ if not segment:
352
+ return "the path has an empty segment or a trailing slash"
353
+
354
+ if segment in (".", ".."):
355
+ return "the path has a relative navigation segment"
356
+
357
+ if segment.endswith((".", " ")) or segment.startswith(" "):
358
+ return "a path segment starts with a space or ends with a dot or space"
359
+
360
+ if segment.split(".")[0].upper() in _RESERVED_DEVICE_NAMES:
361
+ return f"the path uses the reserved device name '{segment.split('.')[0]}'"
362
+
363
+ return None
364
+
365
+
366
+ # --------------------------------------------------------------------------- archive
367
+
368
+
369
+ def _write_archive(manifest: PackageManifest, content: dict[str, bytes]) -> bytes:
370
+ entries: dict[str, bytes] = dict(content)
371
+ entries["[Content_Types].xml"] = content_types_xml(content.keys()).encode("utf-8")
372
+ entries[f"{manifest.package_id}.nuspec"] = nuspec_xml(manifest).encode("utf-8")
373
+ entries[PackageLayout.MANIFEST_PATH] = write_package_manifest(manifest)
374
+
375
+ buffer = BytesIO()
376
+
377
+ with zipfile.ZipFile(buffer, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=9) as archive:
378
+ for path in sorted(entries):
379
+ info = zipfile.ZipInfo(path, date_time=ENTRY_TIMESTAMP)
380
+ info.compress_type = zipfile.ZIP_DEFLATED
381
+ # 0o600 in the high word; the low word stays 0, as .NET writes for a file entry.
382
+ info.external_attr = 0o600 << 16
383
+ archive.writestr(info, entries[path])
384
+
385
+ return buffer.getvalue()
386
+
387
+
388
+ def nuspec_xml(manifest: PackageManifest) -> str:
389
+ """The ``.nuspec`` the package carries, byte for byte as the .NET builder writes it."""
390
+ project = manifest.project
391
+ authors = ", ".join(project.authors) if project.authors else project.name
392
+ description = project.name if not (project.description or "").strip() else project.description
393
+
394
+ return (
395
+ '<?xml version="1.0" encoding="utf-8"?>\n'
396
+ '<package xmlns="http://schemas.microsoft.com/packaging/2013/05/nuspec.xsd">\n'
397
+ " <metadata>\n"
398
+ f" <id>{_escape_xml(manifest.package_id)}</id>\n"
399
+ f" <version>{_escape_xml(manifest.package_version)}</version>\n"
400
+ f" <authors>{_escape_xml(authors)}</authors>\n"
401
+ f" <description>{_escape_xml(description or project.name)}</description>\n"
402
+ + (f" <icon>{_escape_xml(PackageLayout.CONTENT_ROOT + project.icon)}</icon>\n" if project.icon else "")
403
+ + " <packageTypes>\n"
404
+ f' <packageType name="{PackageLayout.NUGET_PACKAGE_TYPE}" />\n'
405
+ " </packageTypes>\n"
406
+ " </metadata>\n"
407
+ "</package>\n"
408
+ )
409
+
410
+
411
+ def content_types_xml(content_paths: Iterable[str]) -> str:
412
+ """The OPC content-types part, byte for byte as the .NET builder writes it."""
413
+ paths = list(content_paths)
414
+
415
+ extensions: list[str] = []
416
+ seen: set[str] = set()
417
+
418
+ for extension in [_extension(path) for path in paths] + ["json", "nuspec"]:
419
+ if extension and extension.upper() not in seen:
420
+ seen.add(extension.upper())
421
+ extensions.append(extension)
422
+
423
+ lines = [
424
+ '<?xml version="1.0" encoding="utf-8"?>',
425
+ '<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">',
426
+ ]
427
+
428
+ for extension in sorted(extensions, key=str.upper):
429
+ lines.append(f' <Default Extension="{_escape_xml(extension)}" ContentType="application/octet" />')
430
+
431
+ for path in paths:
432
+ if not _extension(path):
433
+ lines.append(f' <Override PartName="/{_escape_xml(path)}" ContentType="application/octet" />')
434
+
435
+ lines.append("</Types>")
436
+ return "\n".join(lines) + "\n"
437
+
438
+
439
+ def _extension(path: str) -> str:
440
+ """``Path.GetExtension`` without the dot: the last dot in the file name, unless it ends it."""
441
+ name = path.rsplit("/", 1)[-1]
442
+ index = name.rfind(".")
443
+
444
+ if index < 0 or index == len(name) - 1:
445
+ return ""
446
+
447
+ return name[index + 1 :]
448
+
449
+
450
+ def _escape_xml(value: str) -> str:
451
+ """Escape as ``SecurityElement.Escape`` does: the five predefined entities."""
452
+ return (
453
+ value.replace("&", "&amp;")
454
+ .replace("<", "&lt;")
455
+ .replace(">", "&gt;")
456
+ .replace('"', "&quot;")
457
+ .replace("'", "&apos;")
458
+ )
459
+
460
+
461
+ def write_project_file(project_directory: str | Path, manifest: ProjectManifest) -> Path:
462
+ """Write ``project.json`` deterministically into *project_directory*."""
463
+ path = Path(project_directory) / ProjectFiles.MANIFEST_FILE_NAME
464
+ path.write_bytes(write_project_manifest(manifest))
465
+ return path
velophex/py.typed ADDED
File without changes