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