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.
- velophex/__init__.py +38 -0
- velophex/_json.py +297 -0
- velophex/agents/__init__.py +130 -0
- velophex/agents/_errors.py +75 -0
- velophex/agents/_loop.py +436 -0
- velophex/agents/_tools.py +168 -0
- velophex/agents/_tracing.py +87 -0
- velophex/agents/testing.py +249 -0
- velophex/cli/__init__.py +5 -0
- velophex/cli/main.py +548 -0
- velophex/cli/publish.py +152 -0
- velophex/cli/sync.py +90 -0
- velophex/cli/templates.py +126 -0
- velophex/contracts/__init__.py +103 -0
- velophex/contracts/_model.py +26 -0
- velophex/contracts/agents.py +110 -0
- velophex/contracts/diagnostics.py +157 -0
- velophex/contracts/executor.py +1921 -0
- velophex/contracts/package.py +391 -0
- velophex/contracts/project.py +1008 -0
- velophex/contracts/runtimes.py +226 -0
- velophex/contracts/signin.py +53 -0
- velophex/contracts/validation.py +158 -0
- velophex/entrypoints.py +329 -0
- velophex/errors.py +44 -0
- velophex/packaging.py +465 -0
- velophex/py.typed +0 -0
- velophex/runtime/__init__.py +471 -0
- velophex/runtime/_session.py +192 -0
- velophex/runtime/client.py +957 -0
- velophex/runtime/models.py +326 -0
- velophex/signing.py +425 -0
- velophex/worker/__init__.py +27 -0
- velophex/worker/__main__.py +118 -0
- velophex/worker/framing.py +248 -0
- velophex/worker/loader.py +105 -0
- velophex/worker/outbox.py +147 -0
- velophex/worker/session.py +519 -0
- velophex-0.2.0.dist-info/METADATA +313 -0
- velophex-0.2.0.dist-info/RECORD +43 -0
- velophex-0.2.0.dist-info/WHEEL +4 -0
- velophex-0.2.0.dist-info/entry_points.txt +2 -0
- 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
|
+
)
|