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,1008 @@
1
+ """``project.json`` - the single project manifest (ADR-P002).
2
+
3
+ The model mirrors ``VeloPhex.Platform.Contracts.Projects.ProjectManifest`` and the language
4
+ neutral definition in ``schemas/project.schema.json``. Reading uses pydantic; **writing does
5
+ not**, because the order and the escaping of the output are part of the contract: the
6
+ package manifest hashes ``content/project.json``, so the Python CLI has to emit the same
7
+ bytes Studio would. Every model therefore has an explicit :meth:`to_json_value` that lists
8
+ its members in the order the .NET record declares them.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import hashlib
14
+ import re
15
+ from typing import Any, Final
16
+ from uuid import UUID
17
+
18
+ from pydantic import Field
19
+
20
+ from velophex import _json
21
+ from velophex.contracts._model import ContractModel, omit_none
22
+ from velophex.contracts.diagnostics import Diagnostic
23
+ from velophex.contracts.diagnostics import ProjectDiagnosticCodes as Codes
24
+ from velophex.contracts.runtimes import RuntimeKind
25
+ from velophex.contracts.validation import (
26
+ has_build_metadata,
27
+ is_capability,
28
+ is_entry_point_id,
29
+ is_package_id,
30
+ is_python_handler,
31
+ is_safe_relative_path,
32
+ is_semantic_version,
33
+ is_version_range,
34
+ )
35
+
36
+ __all__ = [
37
+ "AutomationDependency",
38
+ "DesignerSettings",
39
+ "EntryPoint",
40
+ "ExecutionSettings",
41
+ "ExpressionLanguages",
42
+ "ExpressionPolicy",
43
+ "ExpressionPolicyValues",
44
+ "ExpressionSettings",
45
+ "OutputTypes",
46
+ "ProjectConstant",
47
+ "ProjectFiles",
48
+ "ProjectGlobalVariable",
49
+ "ProjectManifest",
50
+ "RuntimeSpec",
51
+ "TestCaseSettings",
52
+ "read_project_manifest",
53
+ "validate_project_manifest",
54
+ "write_project_manifest",
55
+ ]
56
+
57
+ CURRENT_SCHEMA_VERSION: Final = "1.0"
58
+ SCHEMA_URI: Final = "https://schemas.velophex.com/platform/project/1.0/project.schema.json"
59
+
60
+
61
+ class ProjectFiles:
62
+ """Well-known file names of a VeloPhex project. Use these instead of string literals."""
63
+
64
+ MANIFEST_FILE_NAME: Final = "project.json"
65
+ LOCK_FILE_NAME: Final = "project.lock.json"
66
+ PYTHON_LOCK_FILE_NAME: Final = "uv.lock"
67
+ WORKFLOW_EXTENSION: Final = ".xaml"
68
+ DEFAULT_MAIN_WORKFLOW: Final = "Main.xaml"
69
+ MAXIMUM_MANIFEST_BYTES: Final = 1024 * 1024
70
+ MAXIMUM_PNG_ICON_BYTES: Final = 1024 * 1024
71
+ MAXIMUM_SVG_ICON_BYTES: Final = 16 * 1024
72
+
73
+
74
+ class OutputTypes:
75
+ """What a project produces."""
76
+
77
+ PROCESS: Final = "process"
78
+ LIBRARY: Final = "library"
79
+ TESTS: Final = "tests"
80
+
81
+ ALL: Final = (PROCESS, LIBRARY, TESTS)
82
+
83
+ @staticmethod
84
+ def is_known(value: str | None) -> bool:
85
+ """True for a known output type."""
86
+ return value in OutputTypes.ALL
87
+
88
+ @staticmethod
89
+ def requires_entry_points(value: str | None) -> bool:
90
+ """True when a project of this output type must declare at least one entry point."""
91
+ return value in (OutputTypes.PROCESS, OutputTypes.TESTS)
92
+
93
+
94
+ class ExpressionLanguages:
95
+ """Expression languages of VeloPhex workflow projects."""
96
+
97
+ CSHARP: Final = "csharp"
98
+ VISUAL_BASIC: Final = "vb"
99
+
100
+ ALL: Final = (CSHARP, VISUAL_BASIC)
101
+
102
+ @staticmethod
103
+ def is_known(value: str | None) -> bool:
104
+ """Case-sensitive: the token set is the canon shared with the workflow XAML root attribute."""
105
+ return value in ExpressionLanguages.ALL
106
+
107
+
108
+ class ExpressionPolicyValues:
109
+ """The expression policy values (engine ADR 0036), ordered strict < default < default+types."""
110
+
111
+ STRICT: Final = "strict"
112
+ DEFAULT: Final = "default"
113
+ DEFAULT_PLUS_TYPES: Final = "default+types"
114
+
115
+ ALL: Final = (STRICT, DEFAULT, DEFAULT_PLUS_TYPES)
116
+
117
+ @staticmethod
118
+ def is_known(value: str | None) -> bool:
119
+ """Case-sensitive, like every other contract token."""
120
+ return value in ExpressionPolicyValues.ALL
121
+
122
+
123
+ #: What no policy and no extra type lifts; mirrors ``ExpressionPolicy.Floor`` in .NET.
124
+ EXPRESSION_FLOOR: Final = (
125
+ "System.Reflection",
126
+ "System.Runtime.Loader",
127
+ "System.Runtime.CompilerServices",
128
+ "System.Runtime.InteropServices",
129
+ "System.Type",
130
+ "System.Activator",
131
+ "System.AppDomain",
132
+ "System.AppContext",
133
+ "System.GC",
134
+ "System.Delegate",
135
+ "System.MulticastDelegate",
136
+ "System.RuntimeTypeHandle",
137
+ "System.RuntimeMethodHandle",
138
+ "System.RuntimeFieldHandle",
139
+ "System.TypedReference",
140
+ "System.ArgIterator",
141
+ "System.Runtime.ProfileOptimization",
142
+ "System.Runtime.GCSettings",
143
+ "System.Runtime.MemoryFailPoint",
144
+ "System.Runtime.ControlledExecution",
145
+ "System.Diagnostics.Process",
146
+ "System.Diagnostics.ProcessStartInfo",
147
+ "Microsoft.CodeAnalysis",
148
+ "Microsoft.CSharp",
149
+ )
150
+
151
+ _TYPE_SEGMENT = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*\Z")
152
+
153
+
154
+ def expression_floor_entry(type_name: str) -> str | None:
155
+ """The floor entry that covers *type_name*, or ``None``."""
156
+ return next(
157
+ (entry for entry in EXPRESSION_FLOOR if type_name == entry or type_name.startswith(entry + ".")),
158
+ None,
159
+ )
160
+
161
+
162
+ def is_type_name(name: str | None) -> bool:
163
+ """Two or more dot-separated identifiers (ASCII letters, digits, ``_``; no leading digit)."""
164
+ if not name or len(name) > 512:
165
+ return False
166
+
167
+ segments = name.split(".")
168
+ return len(segments) >= 2 and all(_TYPE_SEGMENT.match(segment) for segment in segments)
169
+
170
+
171
+ def expression_fingerprint(value: str, tier_version: int, extra_types: list[str]) -> str:
172
+ """``ExpressionPolicy.ComputeFingerprint``: SHA-256 of the canonical JSON, nothing escaped."""
173
+ extras = ",".join(f'"{item}"' for item in sorted(extra_types))
174
+ canonical = f'{{"extraTypes":[{extras}],"tierVersion":{tier_version},"value":"{value}"}}'
175
+ return "sha256:" + hashlib.sha256(canonical.encode("utf-8")).hexdigest()
176
+
177
+
178
+ def expression_policy_problems(value: str, extra_types: list[str], tier_version: int | None = None) -> list[str]:
179
+ """``ExpressionPolicy.Validate``: one sentence per problem, empty when valid."""
180
+ problems: list[str] = []
181
+
182
+ if not ExpressionPolicyValues.is_known(value):
183
+ problems.append(f"Expression policy '{value}' is not one of: {', '.join(ExpressionPolicyValues.ALL)}.")
184
+
185
+ if tier_version is not None and tier_version < 1:
186
+ problems.append(f"Tier version {tier_version} is not a positive number.")
187
+
188
+ for item in extra_types:
189
+ if not is_type_name(item):
190
+ problems.append(f"Extra type '{item}' is not a full type name such as System.IO.FileInfo.")
191
+ elif (floor := expression_floor_entry(item)) is not None:
192
+ problems.append(
193
+ f"Extra type '{item}' is on the expression floor ({floor}) and cannot be allowed; "
194
+ "use an activity or Invoke Code."
195
+ )
196
+
197
+ return problems
198
+
199
+
200
+ class ExpressionSettings(ContractModel):
201
+ """``project.json`` ``expressions``: the policy the project is built under (engine ADR 0036)."""
202
+
203
+ policy: str = ExpressionPolicyValues.DEFAULT
204
+ extra_types: list[str] = Field(default_factory=list, alias="extraTypes")
205
+
206
+ def to_json_value(self) -> dict[str, Any]:
207
+ """Ordered mapping in the member order of the .NET ``ExpressionSettings`` record."""
208
+ return {"policy": self.policy, "extraTypes": list(self.extra_types)} | self.extras()
209
+
210
+
211
+ class ExpressionPolicy(ContractModel):
212
+ """A package's record of its expression policy (manifest ``expressionPolicy``)."""
213
+
214
+ value: str
215
+ tier_version: int | None = Field(default=None, alias="tierVersion")
216
+ extra_types: list[str] = Field(default_factory=list, alias="extraTypes")
217
+ fingerprint: str | None = None
218
+
219
+ def to_json_value(self) -> dict[str, Any]:
220
+ """Ordered mapping in the member order of the .NET ``ExpressionPolicy`` record."""
221
+ return (
222
+ omit_none(
223
+ {
224
+ "value": self.value,
225
+ "tierVersion": self.tier_version,
226
+ "extraTypes": list(self.extra_types),
227
+ "fingerprint": self.fingerprint,
228
+ }
229
+ )
230
+ | self.extras()
231
+ )
232
+
233
+
234
+ class ProjectGlobalVariable(ContractModel):
235
+ """``project.json`` ``globals``: a project-level global variable (contracts 0.8.11)."""
236
+
237
+ name: str
238
+ type: str
239
+ default: str | None = None
240
+ sensitive: bool = False
241
+
242
+ def to_json_value(self) -> dict[str, Any]:
243
+ """Ordered mapping in the member order of the .NET record; ``sensitive`` only when true."""
244
+ return (
245
+ omit_none(
246
+ {
247
+ "name": self.name,
248
+ "type": self.type,
249
+ "default": self.default,
250
+ "sensitive": True if self.sensitive else None,
251
+ }
252
+ )
253
+ | self.extras()
254
+ )
255
+
256
+
257
+ class ProjectConstant(ContractModel):
258
+ """``project.json`` ``constants``: a project-level constant, fixed at compile time."""
259
+
260
+ name: str
261
+ type: str
262
+ value: str
263
+
264
+ def to_json_value(self) -> dict[str, Any]:
265
+ """Ordered mapping in the member order of the .NET ``ProjectConstant`` record."""
266
+ return {"name": self.name, "type": self.type, "value": self.value} | self.extras()
267
+
268
+
269
+ # A name both C# and Visual Basic accept as an identifier (ProjectSymbolNames in .NET).
270
+ _SYMBOL_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_]{0,127}$")
271
+
272
+
273
+ # ISO 8601 duration, the subset project.schema.json accepts (days, hours, minutes, seconds).
274
+ _DURATION = re.compile(r"^P(?!$)([0-9]+D)?(T(?=[0-9])([0-9]+H)?([0-9]+M)?([0-9]+(\.[0-9]+)?S)?)?$")
275
+
276
+
277
+ class RuntimeSpec(ContractModel):
278
+ """The runtime a project targets and its runtime-specific settings."""
279
+
280
+ kind: str
281
+ engine_version: str | None = Field(default=None, alias="engineVersion")
282
+ target_framework: str | None = Field(default=None, alias="targetFramework")
283
+ expression_language: str | None = Field(default=None, alias="expressionLanguage")
284
+ python_version: str | None = Field(default=None, alias="pythonVersion")
285
+ lock_file: str | None = Field(default=None, alias="lockFile")
286
+
287
+ def to_json_value(self) -> dict[str, Any]:
288
+ """Ordered mapping in the member order of the .NET ``RuntimeSpec`` record."""
289
+ return (
290
+ omit_none(
291
+ {
292
+ "kind": self.kind,
293
+ "engineVersion": self.engine_version,
294
+ "targetFramework": self.target_framework,
295
+ "expressionLanguage": self.expression_language,
296
+ "pythonVersion": self.python_version,
297
+ "lockFile": self.lock_file,
298
+ }
299
+ )
300
+ | self.extras()
301
+ )
302
+
303
+
304
+ def split_data_source(data_source: str) -> tuple[str, str | None]:
305
+ """Split a data source into its file and, for an ``.xlsx``, its sheet (``TestCaseSettings.ParseDataSource``).
306
+
307
+ The text after the first ``.xlsx#`` (case-insensitive) is the sheet, which may itself contain
308
+ ``#``; any other source is a file with no sheet.
309
+ """
310
+ index = data_source.lower().find(".xlsx#")
311
+ return (data_source, None) if index < 0 else (data_source[: index + 5], data_source[index + 6 :])
312
+
313
+
314
+ def _is_data_source_file(source: str) -> bool:
315
+ file, sheet = split_data_source(source)
316
+
317
+ if sheet is not None:
318
+ return 1 <= len(sheet) <= 31 and not any(character in sheet for character in "/\\?*[]:")
319
+
320
+ return file.lower().endswith((".csv", ".json", ".xlsx"))
321
+
322
+
323
+ class TestCaseSettings(ContractModel):
324
+ """The per-entry-point ``testCase`` marker (contracts 0.8.25).
325
+
326
+ An empty object marks a plain test case; ``dataSource`` (a project-relative ``.csv``,
327
+ ``.json`` or, from contracts 0.8.30, ``.xlsx`` file with an optional ``#Sheet``; VeloPhex
328
+ workflow only) makes it data-driven: one run and one result per row.
329
+ """
330
+
331
+ data_source: str | None = Field(default=None, alias="dataSource")
332
+
333
+ def to_json_value(self) -> dict[str, Any]:
334
+ """Ordered mapping in the member order of the .NET ``TestCaseSettings`` record."""
335
+ return omit_none({"dataSource": self.data_source}) | self.extras()
336
+
337
+
338
+ class EntryPoint(ContractModel):
339
+ """A way to start the project, with the JSON Schema of its inputs and outputs."""
340
+
341
+ id: str
342
+ path: str
343
+ handler: str | None = None
344
+ display_name: str | None = Field(default=None, alias="displayName")
345
+ description: str | None = None
346
+ input: Any | None = None
347
+ output: Any | None = None
348
+ test_case: TestCaseSettings | None = Field(default=None, alias="testCase")
349
+
350
+ def to_json_value(self) -> dict[str, Any]:
351
+ """Ordered mapping in the member order of the .NET ``EntryPoint`` record."""
352
+ return (
353
+ omit_none(
354
+ {
355
+ "id": self.id,
356
+ "path": self.path,
357
+ "handler": self.handler,
358
+ "displayName": self.display_name,
359
+ "description": self.description,
360
+ "input": self.input,
361
+ "output": self.output,
362
+ "testCase": None if self.test_case is None else self.test_case.to_json_value(),
363
+ }
364
+ )
365
+ | self.extras()
366
+ )
367
+
368
+
369
+ class AutomationDependency(ContractModel):
370
+ """A published automation this project invokes as a step (hybrid, ADR-P007)."""
371
+
372
+ package_id: str = Field(alias="packageId")
373
+ version: str
374
+ runtime: str
375
+
376
+ def to_json_value(self) -> dict[str, Any]:
377
+ """Ordered mapping in the member order of the .NET ``AutomationDependency`` record."""
378
+ return {"packageId": self.package_id, "version": self.version, "runtime": self.runtime} | self.extras()
379
+
380
+
381
+ class ExecutionSettings(ContractModel):
382
+ """How and where the project may run."""
383
+
384
+ requires_user_interaction: bool = Field(default=False, alias="requiresUserInteraction")
385
+ supports_attended: bool = Field(default=True, alias="supportsAttended")
386
+ supports_unattended: bool = Field(default=True, alias="supportsUnattended")
387
+ supports_persistence: bool = Field(default=False, alias="supportsPersistence")
388
+ timeout: str | None = None
389
+ capabilities: list[str] = Field(default_factory=list)
390
+
391
+ def to_json_value(self) -> dict[str, Any]:
392
+ """Ordered mapping in the member order of the .NET ``ExecutionSettings`` record."""
393
+ return (
394
+ omit_none(
395
+ {
396
+ "requiresUserInteraction": self.requires_user_interaction,
397
+ "supportsAttended": self.supports_attended,
398
+ "supportsUnattended": self.supports_unattended,
399
+ "supportsPersistence": self.supports_persistence,
400
+ "timeout": self.timeout,
401
+ "capabilities": list(self.capabilities),
402
+ }
403
+ )
404
+ | self.extras()
405
+ )
406
+
407
+
408
+ class DesignerSettings(ContractModel):
409
+ """Written by Studio; ignored at run time."""
410
+
411
+ studio_version: str | None = Field(default=None, alias="studioVersion")
412
+ profile: str | None = None
413
+
414
+ def to_json_value(self) -> dict[str, Any]:
415
+ """Ordered mapping in the member order of the .NET ``DesignerSettings`` record."""
416
+ return omit_none({"studioVersion": self.studio_version, "profile": self.profile}) | self.extras()
417
+
418
+
419
+ class ProjectManifest(ContractModel):
420
+ """Identity, runtime, entry points and dependencies of a VeloPhex project."""
421
+
422
+ schema_: str | None = Field(default=None, alias="$schema")
423
+ schema_version: str = Field(alias="schemaVersion")
424
+ project_id: UUID = Field(alias="projectId")
425
+ name: str
426
+ version: str
427
+ description: str | None = None
428
+ authors: list[str] | None = None
429
+ icon: str | None = None
430
+ output_type: str = Field(default=OutputTypes.PROCESS, alias="outputType")
431
+ runtime: RuntimeSpec
432
+ main: str | None = None
433
+ entry_points: list[EntryPoint] = Field(default_factory=list, alias="entryPoints")
434
+ dependencies: dict[str, str] = Field(default_factory=dict)
435
+ automation_dependencies: list[AutomationDependency] = Field(default_factory=list, alias="automationDependencies")
436
+ execution: ExecutionSettings = Field(default_factory=ExecutionSettings)
437
+ designer: DesignerSettings | None = None
438
+ ui_automation: Any | None = Field(default=None, alias="uiAutomation")
439
+ expressions: ExpressionSettings | None = None
440
+ globals_: list[ProjectGlobalVariable] | None = Field(default=None, alias="globals")
441
+ constants: list[ProjectConstant] | None = None
442
+ exception_handler_workflow: str | None = Field(default=None, alias="exceptionHandlerWorkflow")
443
+
444
+ def required_runtimes(self) -> list[str]:
445
+ """Runtimes a machine needs: this project's own plus every automation it invokes.
446
+
447
+ Distinct and ordinally sorted, which is what the Orchestrator matches against a
448
+ runner's reported runtimes when it places a job.
449
+ """
450
+ kinds = {dependency.runtime for dependency in self.automation_dependencies if dependency.runtime}
451
+
452
+ if self.runtime.kind:
453
+ kinds.add(self.runtime.kind)
454
+
455
+ return sorted(kinds)
456
+
457
+ def default_entry_point(self) -> EntryPoint | None:
458
+ """The entry point named by ``main``, else the first one."""
459
+ if self.main is not None:
460
+ for entry in self.entry_points:
461
+ if entry.path == self.main:
462
+ return entry
463
+
464
+ return self.entry_points[0] if self.entry_points else None
465
+
466
+ def is_test_case(self, entry: EntryPoint) -> bool:
467
+ """True when *entry* carries ``testCase`` or the project's output type is ``tests``."""
468
+ return entry.test_case is not None or self.output_type == OutputTypes.TESTS
469
+
470
+ def find_entry_point(self, entry_point_id: str | None) -> EntryPoint | None:
471
+ """The entry point with *entry_point_id*, or the default when it is ``None`` or empty."""
472
+ if not entry_point_id:
473
+ return self.default_entry_point()
474
+
475
+ for entry in self.entry_points:
476
+ if entry.id.casefold() == entry_point_id.casefold():
477
+ return entry
478
+
479
+ return None
480
+
481
+ def normalized(self) -> ProjectManifest:
482
+ """A copy with ``$schema`` set and dependencies sorted, as the .NET writer normalizes."""
483
+ return self.model_copy(
484
+ update={
485
+ "schema_": SCHEMA_URI,
486
+ "dependencies": dict(sorted(self.dependencies.items(), key=lambda pair: pair[0].casefold())),
487
+ }
488
+ )
489
+
490
+ def to_json_value(self) -> dict[str, Any]:
491
+ """Ordered mapping in the member order of the .NET ``ProjectManifest`` record."""
492
+ return (
493
+ omit_none(
494
+ {
495
+ "$schema": self.schema_,
496
+ "schemaVersion": self.schema_version,
497
+ "projectId": str(self.project_id),
498
+ "name": self.name,
499
+ "version": self.version,
500
+ "description": self.description,
501
+ "authors": list(self.authors) if self.authors is not None else None,
502
+ "icon": self.icon,
503
+ "outputType": self.output_type,
504
+ "runtime": self.runtime.to_json_value(),
505
+ "main": self.main,
506
+ "entryPoints": [entry.to_json_value() for entry in self.entry_points],
507
+ "dependencies": dict(self.dependencies),
508
+ "automationDependencies": [item.to_json_value() for item in self.automation_dependencies],
509
+ "execution": self.execution.to_json_value(),
510
+ "designer": self.designer.to_json_value() if self.designer is not None else None,
511
+ "uiAutomation": self.ui_automation,
512
+ "expressions": self.expressions.to_json_value() if self.expressions is not None else None,
513
+ "globals": [item.to_json_value() for item in self.globals_] if self.globals_ is not None else None,
514
+ "constants": [item.to_json_value() for item in self.constants]
515
+ if self.constants is not None
516
+ else None,
517
+ "exceptionHandlerWorkflow": self.exception_handler_workflow,
518
+ }
519
+ )
520
+ | self.extras()
521
+ )
522
+
523
+
524
+ def write_project_manifest(manifest: ProjectManifest) -> bytes:
525
+ """Serialize *manifest* exactly as ``ProjectManifestSerializer.WriteUtf8`` does.
526
+
527
+ Deterministic: ``$schema`` set, dependencies sorted, 2-space indentation, LF endings and
528
+ a trailing newline.
529
+ """
530
+ return (_json.dumps_document(manifest.normalized().to_json_value()) + "\n").encode("utf-8")
531
+
532
+
533
+ def read_project_manifest(
534
+ payload: bytes | str,
535
+ root_path: str = "$",
536
+ ) -> tuple[ProjectManifest | None, list[Diagnostic]]:
537
+ """Parse and validate ``project.json``.
538
+
539
+ Returns the manifest and its diagnostics; the manifest is ``None`` only when the document
540
+ could not be parsed at all.
541
+ """
542
+ raw = payload if isinstance(payload, (bytes, bytearray)) else payload.encode("utf-8")
543
+
544
+ if len(raw) > ProjectFiles.MAXIMUM_MANIFEST_BYTES:
545
+ message = f"The document is {len(raw)} bytes; the limit is {ProjectFiles.MAXIMUM_MANIFEST_BYTES} bytes."
546
+ return None, [Diagnostic.error(Codes.DOCUMENT_TOO_LARGE, root_path, message)]
547
+
548
+ try:
549
+ document = _json.loads(raw)
550
+ except ValueError as error:
551
+ return None, [Diagnostic.error(Codes.INVALID_JSON, root_path, str(error))]
552
+
553
+ if not isinstance(document, dict):
554
+ return None, [Diagnostic.error(Codes.INVALID_JSON, root_path, "The document is not a JSON object.")]
555
+
556
+ try:
557
+ manifest = ProjectManifest.model_validate(document)
558
+ except Exception as error: # pydantic ValidationError, or a bad GUID
559
+ return None, [Diagnostic.error(Codes.INVALID_JSON, root_path, str(error))]
560
+
561
+ return manifest, validate_project_manifest(manifest, root_path)
562
+
563
+
564
+ def validate_project_manifest(manifest: ProjectManifest, root_path: str = "$") -> list[Diagnostic]:
565
+ """Apply the platform rules to *manifest*, mirroring ``ProjectManifestValidator``."""
566
+ diagnostics: list[Diagnostic] = []
567
+
568
+ _validate_identity(manifest, root_path, diagnostics)
569
+ _validate_runtime(manifest.runtime, f"{root_path}.runtime", diagnostics)
570
+ _validate_entry_points(manifest, root_path, diagnostics)
571
+ _validate_dependencies(manifest, root_path, diagnostics)
572
+ _validate_execution(manifest.execution, f"{root_path}.execution", diagnostics)
573
+ _validate_symbols(manifest, root_path, diagnostics)
574
+ _validate_exception_handler(manifest, root_path, diagnostics)
575
+
576
+ if manifest.icon is not None and (not is_safe_relative_path(manifest.icon) or not is_icon_file(manifest.icon)):
577
+ diagnostics.append(
578
+ Diagnostic.error(
579
+ Codes.INVALID_ICON,
580
+ f"{root_path}.icon",
581
+ "The package icon is a '.png' or '.svg' file relative to the project root, "
582
+ "with '/' separators and no '.' or '..' segments.",
583
+ )
584
+ )
585
+
586
+ if manifest.expressions is not None:
587
+ for problem in expression_policy_problems(manifest.expressions.policy, manifest.expressions.extra_types):
588
+ diagnostics.append(Diagnostic.error(Codes.INVALID_EXPRESSION_POLICY, f"{root_path}.expressions", problem))
589
+
590
+ return diagnostics
591
+
592
+
593
+ def is_icon_file(path: str) -> bool:
594
+ """Mirror of ``ProjectFiles.IsIconFile``: a package icon is a ``.png`` or ``.svg`` file."""
595
+ return path.lower().endswith((".png", ".svg"))
596
+
597
+
598
+ def maximum_icon_bytes(path: str) -> int:
599
+ """Mirror of ``ProjectFiles.MaximumIconBytes``: 16 KiB for ``.svg``, 1 MiB (NuGet's limit) for ``.png``."""
600
+ return ProjectFiles.MAXIMUM_SVG_ICON_BYTES if path.lower().endswith(".svg") else ProjectFiles.MAXIMUM_PNG_ICON_BYTES
601
+
602
+
603
+ def _validate_exception_handler(manifest: ProjectManifest, root: str, diagnostics: list[Diagnostic]) -> None:
604
+ """Mirror of ``ProjectManifestValidator.ValidateExceptionHandler`` (VPC1031, contracts 0.8.16)."""
605
+ handler = manifest.exception_handler_workflow
606
+
607
+ if handler is None:
608
+ return
609
+
610
+ path = f"{root}.exceptionHandlerWorkflow"
611
+
612
+ if manifest.runtime.kind != RuntimeKind.VELOPHEX_WORKFLOW:
613
+ diagnostics.append(
614
+ Diagnostic.error(
615
+ Codes.INVALID_EXCEPTION_HANDLER_WORKFLOW,
616
+ path,
617
+ "Only a VeloPhex workflow project has a Global Exception Handler; "
618
+ "a Python automation handles its own exceptions.",
619
+ )
620
+ )
621
+ elif not is_safe_relative_path(handler) or not handler.lower().endswith(ProjectFiles.WORKFLOW_EXTENSION):
622
+ diagnostics.append(
623
+ Diagnostic.error(
624
+ Codes.INVALID_EXCEPTION_HANDLER_WORKFLOW,
625
+ path,
626
+ f"The Global Exception Handler is a '{ProjectFiles.WORKFLOW_EXTENSION}' workflow path relative to "
627
+ "the project root, with '/' separators and no '.' or '..' segments.",
628
+ )
629
+ )
630
+
631
+
632
+ def _validate_symbols(manifest: ProjectManifest, root: str, diagnostics: list[Diagnostic]) -> None:
633
+ """Mirror of ``ProjectManifestValidator.ValidateSymbols``: identifiers, unique ignoring case."""
634
+ names: set[str] = set()
635
+
636
+ def check(path: str, name: str, type_: str, kind: str) -> None:
637
+ if not _SYMBOL_NAME.match(name or ""):
638
+ diagnostics.append(
639
+ Diagnostic.error(
640
+ Codes.INVALID_PROJECT_SYMBOL,
641
+ f"{path}.name",
642
+ f"The {kind} name '{name}' is not an identifier: start with a letter or '_' and use "
643
+ "letters, digits or '_' (max 128).",
644
+ )
645
+ )
646
+ elif name.casefold() in names:
647
+ diagnostics.append(
648
+ Diagnostic.error(
649
+ Codes.INVALID_PROJECT_SYMBOL,
650
+ f"{path}.name",
651
+ f"'{name}' names more than one global or constant; names are compared ignoring case, "
652
+ "as Visual Basic does.",
653
+ )
654
+ )
655
+ else:
656
+ names.add(name.casefold())
657
+
658
+ if not (type_ or "").strip():
659
+ diagnostics.append(
660
+ Diagnostic.error(
661
+ Codes.INVALID_PROJECT_SYMBOL,
662
+ f"{path}.type",
663
+ f"The {kind} '{name}' needs a type, e.g. System.String.",
664
+ )
665
+ )
666
+
667
+ for index, item in enumerate(manifest.globals_ or []):
668
+ check(f"{root}.globals[{index}]", item.name, item.type, "global")
669
+
670
+ for index, constant in enumerate(manifest.constants or []):
671
+ path = f"{root}.constants[{index}]"
672
+ check(path, constant.name, constant.type, "constant")
673
+
674
+ if not (constant.value or "").strip():
675
+ diagnostics.append(
676
+ Diagnostic.error(
677
+ Codes.INVALID_PROJECT_SYMBOL, f"{path}.value", f"The constant '{constant.name}' needs a value."
678
+ )
679
+ )
680
+
681
+
682
+ def _validate_identity(manifest: ProjectManifest, root: str, diagnostics: list[Diagnostic]) -> None:
683
+ if not (manifest.schema_version == CURRENT_SCHEMA_VERSION or manifest.schema_version.startswith("1.")):
684
+ diagnostics.append(
685
+ Diagnostic.error(
686
+ Codes.UNSUPPORTED_SCHEMA_VERSION,
687
+ f"{root}.schemaVersion",
688
+ f"Schema version '{manifest.schema_version}' is not supported; "
689
+ f"this platform reads {CURRENT_SCHEMA_VERSION}.",
690
+ )
691
+ )
692
+
693
+ if manifest.project_id.int == 0:
694
+ diagnostics.append(
695
+ Diagnostic.error(Codes.MISSING_PROJECT_ID, f"{root}.projectId", "The project id must be a non-empty GUID.")
696
+ )
697
+
698
+ if not is_package_id(manifest.name):
699
+ diagnostics.append(
700
+ Diagnostic.error(
701
+ Codes.INVALID_NAME,
702
+ f"{root}.name",
703
+ "The name becomes the package id: use letters, digits, '_', '.' or '-', "
704
+ "starting and ending with a letter or digit (max 100).",
705
+ )
706
+ )
707
+
708
+ if has_build_metadata(manifest.version):
709
+ diagnostics.append(
710
+ Diagnostic.error(
711
+ Codes.VERSION_HAS_BUILD_METADATA,
712
+ f"{root}.version",
713
+ f"'{manifest.version}' carries build metadata after '+'. NuGet drops it from a package's "
714
+ "identity, so the published package would not be the version the manifest names; "
715
+ "use a pre-release label instead (1.0.0-build.7).",
716
+ )
717
+ )
718
+ elif not is_semantic_version(manifest.version):
719
+ diagnostics.append(
720
+ Diagnostic.error(
721
+ Codes.INVALID_VERSION,
722
+ f"{root}.version",
723
+ f"'{manifest.version}' is not a SemVer 2.0 version such as 1.0.0 or 1.0.0-beta.1.",
724
+ )
725
+ )
726
+
727
+ if not OutputTypes.is_known(manifest.output_type):
728
+ diagnostics.append(
729
+ Diagnostic.error(
730
+ Codes.UNKNOWN_OUTPUT_TYPE,
731
+ f"{root}.outputType",
732
+ f"Output type '{manifest.output_type}' is not one of: {', '.join(OutputTypes.ALL)}.",
733
+ )
734
+ )
735
+
736
+
737
+ def _validate_runtime(runtime: RuntimeSpec, path: str, diagnostics: list[Diagnostic]) -> None:
738
+ if runtime.kind == RuntimeKind.VELOPHEX_WORKFLOW:
739
+ if runtime.expression_language is not None and not ExpressionLanguages.is_known(runtime.expression_language):
740
+ diagnostics.append(
741
+ Diagnostic.error(
742
+ Codes.UNSUPPORTED_EXPRESSION_LANGUAGE,
743
+ f"{path}.expressionLanguage",
744
+ f"Expression language '{runtime.expression_language}' is not supported; "
745
+ f"use one of: {', '.join(ExpressionLanguages.ALL)}.",
746
+ )
747
+ )
748
+ elif runtime.kind == RuntimeKind.PYTHON:
749
+ if not (runtime.python_version or "").strip():
750
+ diagnostics.append(
751
+ Diagnostic.error(
752
+ Codes.MISSING_PYTHON_VERSION,
753
+ f"{path}.pythonVersion",
754
+ "Python projects must declare a Python version specifier, e.g. '>=3.12,<3.13'.",
755
+ )
756
+ )
757
+
758
+ if not is_safe_relative_path(runtime.lock_file):
759
+ diagnostics.append(
760
+ Diagnostic.error(
761
+ Codes.MISSING_LOCK_FILE,
762
+ f"{path}.lockFile",
763
+ "Python projects must name their lock file with a relative path, normally "
764
+ f"'{ProjectFiles.PYTHON_LOCK_FILE_NAME}'.",
765
+ )
766
+ )
767
+ else:
768
+ diagnostics.append(
769
+ Diagnostic.error(
770
+ Codes.UNKNOWN_RUNTIME,
771
+ f"{path}.kind",
772
+ f"Runtime '{runtime.kind}' is not supported; "
773
+ f"use '{RuntimeKind.VELOPHEX_WORKFLOW}' or '{RuntimeKind.PYTHON}'.",
774
+ )
775
+ )
776
+
777
+
778
+ def _validate_entry_points(manifest: ProjectManifest, root: str, diagnostics: list[Diagnostic]) -> None:
779
+ if OutputTypes.requires_entry_points(manifest.output_type) and not manifest.entry_points:
780
+ diagnostics.append(
781
+ Diagnostic.error(
782
+ Codes.MISSING_ENTRY_POINTS,
783
+ f"{root}.entryPoints",
784
+ f"A '{manifest.output_type}' project needs at least one entry point.",
785
+ )
786
+ )
787
+
788
+ seen: set[str] = set()
789
+
790
+ for index, entry in enumerate(manifest.entry_points):
791
+ path = f"{root}.entryPoints[{index}]"
792
+
793
+ if not is_entry_point_id(entry.id):
794
+ diagnostics.append(
795
+ Diagnostic.error(
796
+ Codes.INVALID_ENTRY_POINT_ID,
797
+ f"{path}.id",
798
+ "An entry point id starts with a letter and uses letters, digits, '_', '.' or '-' (max 64).",
799
+ )
800
+ )
801
+ elif entry.id.casefold() in seen:
802
+ diagnostics.append(
803
+ Diagnostic.error(
804
+ Codes.DUPLICATE_ENTRY_POINT, f"{path}.id", f"Entry point id '{entry.id}' is used more than once."
805
+ )
806
+ )
807
+ else:
808
+ seen.add(entry.id.casefold())
809
+
810
+ if not is_safe_relative_path(entry.path):
811
+ diagnostics.append(
812
+ Diagnostic.error(
813
+ Codes.UNSAFE_PATH,
814
+ f"{path}.path",
815
+ "Entry point paths are relative to the project root and use '/' separators "
816
+ "without '.' or '..' segments.",
817
+ )
818
+ )
819
+
820
+ _validate_entry_point_for_runtime(manifest.runtime.kind, entry, path, diagnostics)
821
+ _validate_test_case(manifest, entry, path, diagnostics)
822
+ _validate_argument_schema(entry.input, f"{path}.input", diagnostics)
823
+ _validate_argument_schema(entry.output, f"{path}.output", diagnostics)
824
+
825
+ if manifest.main is not None and not any(entry.path == manifest.main for entry in manifest.entry_points):
826
+ diagnostics.append(
827
+ Diagnostic.error(
828
+ Codes.MAIN_NOT_AN_ENTRY_POINT,
829
+ f"{root}.main",
830
+ f"Main '{manifest.main}' must be the path of one of the entry points.",
831
+ )
832
+ )
833
+
834
+
835
+ def _validate_test_case(manifest: ProjectManifest, entry: EntryPoint, path: str, diagnostics: list[Diagnostic]) -> None:
836
+ """Mirror of ``ProjectManifestValidator.ValidateTestCase`` (VPC1032, contracts 0.8.25)."""
837
+ if entry.test_case is None:
838
+ return
839
+
840
+ if manifest.output_type == OutputTypes.LIBRARY:
841
+ diagnostics.append(
842
+ Diagnostic.error(
843
+ Codes.INVALID_TEST_CASE,
844
+ f"{path}.testCase",
845
+ "A library's entry points are the activities it publishes; they cannot be test cases. "
846
+ "Move the test case to a 'tests' project.",
847
+ )
848
+ )
849
+
850
+ source = entry.test_case.data_source
851
+
852
+ if source is None:
853
+ return
854
+
855
+ if manifest.runtime.kind != RuntimeKind.VELOPHEX_WORKFLOW:
856
+ diagnostics.append(
857
+ Diagnostic.error(
858
+ Codes.INVALID_TEST_CASE,
859
+ f"{path}.testCase.dataSource",
860
+ "Data-driven test cases are VeloPhex workflow test cases; remove the data source.",
861
+ )
862
+ )
863
+ elif not is_safe_relative_path(source) or not _is_data_source_file(source):
864
+ diagnostics.append(
865
+ Diagnostic.error(
866
+ Codes.INVALID_TEST_CASE,
867
+ f"{path}.testCase.dataSource",
868
+ "A test case's data source is a '.csv', '.json' or '.xlsx' file (an '.xlsx' may name its sheet as "
869
+ "'file.xlsx#Sheet', 1 to 31 characters, none of / \\ ? * [ ] :), relative to the project root "
870
+ "with '/' separators and no '.' or '..' segments.",
871
+ )
872
+ )
873
+
874
+
875
+ def _validate_entry_point_for_runtime(kind: str, entry: EntryPoint, path: str, diagnostics: list[Diagnostic]) -> None:
876
+ if kind == RuntimeKind.VELOPHEX_WORKFLOW:
877
+ if not entry.path.lower().endswith(ProjectFiles.WORKFLOW_EXTENSION):
878
+ diagnostics.append(
879
+ Diagnostic.error(
880
+ Codes.INVALID_WORKFLOW_ENTRY_PATH,
881
+ f"{path}.path",
882
+ f"VeloPhex workflow entry points must be '{ProjectFiles.WORKFLOW_EXTENSION}' files.",
883
+ )
884
+ )
885
+
886
+ if entry.handler is not None:
887
+ diagnostics.append(
888
+ Diagnostic.error(
889
+ Codes.UNEXPECTED_HANDLER,
890
+ f"{path}.handler",
891
+ "Only Python entry points have a handler.",
892
+ )
893
+ )
894
+ elif kind == RuntimeKind.PYTHON and not is_python_handler(entry.handler):
895
+ diagnostics.append(
896
+ Diagnostic.error(
897
+ Codes.INVALID_PYTHON_HANDLER,
898
+ f"{path}.handler",
899
+ "Python entry points need a handler in the form 'module.path:function'.",
900
+ )
901
+ )
902
+
903
+
904
+ def _validate_argument_schema(schema: Any, path: str, diagnostics: list[Diagnostic]) -> None:
905
+ if schema is not None and not isinstance(schema, dict):
906
+ diagnostics.append(
907
+ Diagnostic.error(
908
+ Codes.INVALID_ARGUMENT_SCHEMA, path, "Inputs and outputs are described by a JSON Schema object."
909
+ )
910
+ )
911
+
912
+
913
+ def _validate_dependencies(manifest: ProjectManifest, root: str, diagnostics: list[Diagnostic]) -> None:
914
+ for package, version_range in manifest.dependencies.items():
915
+ if not is_package_id(package) or not version_range.strip():
916
+ diagnostics.append(
917
+ Diagnostic.error(
918
+ Codes.INVALID_DEPENDENCY,
919
+ f"{root}.dependencies['{package}']",
920
+ 'Dependencies map a package id to a NuGet version range, e.g. "[1.0.0]" or "[1.0.0,2.0.0)".',
921
+ )
922
+ )
923
+ elif not is_version_range(version_range):
924
+ # Parsed, not merely "not empty": restore reads the value as a range, and a consumer
925
+ # that hands "[0.2.0]" to a version parser throws. Saying so here is the difference
926
+ # between a diagnostic at authoring time and a crash in the IDE.
927
+ diagnostics.append(
928
+ Diagnostic.error(
929
+ Codes.INVALID_DEPENDENCY_VERSION_RANGE,
930
+ f"{root}.dependencies['{package}']",
931
+ f"'{version_range}' is not a NuGet version range: use an exact version "
932
+ '"[1.0.0]", an interval "[1.0.0,2.0.0)", or a bare version "1.0.0" for that '
933
+ "version or higher.",
934
+ )
935
+ )
936
+
937
+ seen: set[str] = set()
938
+
939
+ for index, dependency in enumerate(manifest.automation_dependencies):
940
+ path = f"{root}.automationDependencies[{index}]"
941
+ key = dependency.package_id.casefold()
942
+
943
+ well_formed = (
944
+ is_package_id(dependency.package_id)
945
+ and bool(dependency.version.strip())
946
+ and bool(dependency.runtime)
947
+ and key not in seen
948
+ )
949
+ seen.add(key)
950
+
951
+ if not well_formed:
952
+ diagnostics.append(
953
+ Diagnostic.error(
954
+ Codes.INVALID_AUTOMATION_DEPENDENCY,
955
+ path,
956
+ "Each automation dependency needs a unique package id, a version range and the runtime it runs on.",
957
+ )
958
+ )
959
+ elif not is_version_range(dependency.version):
960
+ # The same gap `dependencies` had, and it bites harder here: the Orchestrator
961
+ # resolves this value as a range when a job is created (ADR-P007), so a value that
962
+ # is not one is not caught until a start is refused - long after authoring.
963
+ diagnostics.append(
964
+ Diagnostic.error(
965
+ Codes.INVALID_AUTOMATION_DEPENDENCY_VERSION_RANGE,
966
+ f"{path}.version",
967
+ f"'{dependency.version}' is not a NuGet version range: use an exact version "
968
+ '"[1.0.0]", an interval "[1.0.0,2.0.0)", or a bare version "1.0.0" for that '
969
+ "version or higher.",
970
+ )
971
+ )
972
+
973
+ if key == manifest.name.casefold():
974
+ diagnostics.append(
975
+ Diagnostic.error(
976
+ Codes.SELF_DEPENDENCY, f"{path}.packageId", "A project cannot invoke itself as a dependency."
977
+ )
978
+ )
979
+
980
+
981
+ def _validate_execution(execution: ExecutionSettings, path: str, diagnostics: list[Diagnostic]) -> None:
982
+ if execution.timeout is not None and _DURATION.match(execution.timeout) is None:
983
+ diagnostics.append(
984
+ Diagnostic.error(
985
+ Codes.INVALID_TIMEOUT,
986
+ f"{path}.timeout",
987
+ f"'{execution.timeout}' is not a positive ISO 8601 duration such as PT30M or PT2H.",
988
+ )
989
+ )
990
+
991
+ for index, capability in enumerate(execution.capabilities):
992
+ if not is_capability(capability):
993
+ diagnostics.append(
994
+ Diagnostic.error(
995
+ Codes.INVALID_CAPABILITY,
996
+ f"{path}.capabilities[{index}]",
997
+ "Capabilities are lowercase tokens such as 'network' or 'filesystem'.",
998
+ )
999
+ )
1000
+
1001
+ if not execution.supports_attended and not execution.supports_unattended:
1002
+ diagnostics.append(
1003
+ Diagnostic.warning(
1004
+ Codes.NO_RUN_MODE_ALLOWED,
1005
+ path,
1006
+ "The project allows neither attended nor unattended runs, so it can never be started.",
1007
+ )
1008
+ )