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