diff --git a/packages/dsw-tdk/CHANGELOG.md b/packages/dsw-tdk/CHANGELOG.md index e2b000c4..5f29901f 100644 --- a/packages/dsw-tdk/CHANGELOG.md +++ b/packages/dsw-tdk/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Update to DT metamodel 18.3 - Metamodel version handling is shared with `dsw-models` (now a dependency) +- The `template.json` written into a package is built and validated as the shared `DocumentTemplateBundle` from `dsw-models`, so the package shape has one definition; a template that does not match it is reported as a warning and still packaged, as before ### Fixed diff --git a/packages/dsw-tdk/dsw/tdk/core.py b/packages/dsw-tdk/dsw/tdk/core.py index 28e599cb..f860e71c 100644 --- a/packages/dsw-tdk/dsw/tdk/core.py +++ b/packages/dsw-tdk/dsw/tdk/core.py @@ -10,9 +10,12 @@ import typing import zipfile +import pydantic import watchfiles +from dsw.models.document_template.metadata import DocumentTemplateBundle from dsw.models.errors import MetamodelVersionError +from dsw.models.strictness import load as load_model from dsw.models.versions import MetamodelVersion from . import consts @@ -361,6 +364,24 @@ def create_pot_file(self, output: pathlib.Path, force: bool) -> PotFile: output.write_bytes(pot_file.data) return pot_file + def _package_descriptor(self, descriptor: dict) -> dict: + """Normalize the package descriptor through the shared DocumentTemplateBundle. + + `package` does not run TemplateValidator, so it has always accepted a template.json + that `verify` rejects. Validation failures are therefore reported but not fatal: the + descriptor is written as assembled, exactly as before. + """ + try: + return load_model(DocumentTemplateBundle, descriptor).to_json_data() + except pydantic.ValidationError as e: + problems = ', '.join( + f'{".".join(str(part) for part in error["loc"])}: {error["msg"]}' + for error in e.errors() + ) + self.logger.warning('Template does not match the document template bundle (%s) - ' + 'packaging it anyway, run "dsw-tdk verify" for details', problems) + return descriptor + def create_package(self, output: pathlib.Path, force: bool): if output.exists() and not force: raise RuntimeError(f'File {output} already exists (not forced)') @@ -389,17 +410,27 @@ def create_package(self, output: pathlib.Path, force: bool): file.filename.as_posix()) pkg.writestr(f'template/assets/{file.filename.as_posix()}', file.content) - descriptor['files'] = files - descriptor['assets'] = assets if len(files) == 0 and len(assets) == 0: self.logger.warning('No files or assets found in the template, maybe you forgot ' 'to update _tdk.files patterns in template.json?') timestamp = datetime.datetime.now(tz=datetime.UTC).strftime('%Y-%m-%dT%H:%M:%S.%fZ') - descriptor['createdAt'] = timestamp - descriptor['updatedAt'] = timestamp + # The package descriptor is the shared DocumentTemplateBundle: building it through + # dsw-models validates what we ship and keeps the shape in one place. The local + # template.json stays the TDK's own lenient model, as `verify` reports on it. + metamodel_version = descriptor['metamodelVersion'] + descriptor.update({ + # the local descriptor accepts an int (see TemplateValidator), the bundle does not; + # anything else is left as it is so an invalid value is reported, not rewritten + 'metamodelVersion': (str(metamodel_version) + if isinstance(metamodel_version, int) else metamodel_version), + 'files': files, + 'assets': assets, + 'createdAt': timestamp, + 'updatedAt': timestamp, + }) self.logger.debug('Packaging template.json file') pkg.writestr('template/template.json', - data=json.dumps(descriptor, indent=4)) + data=json.dumps(self._package_descriptor(descriptor), indent=4)) self.logger.debug('ZIP packaging done') def extract_package(self, zip_data: bytes, template_dir: pathlib.Path | None, force: bool): diff --git a/packages/dsw-tdk/pyproject.toml b/packages/dsw-tdk/pyproject.toml index 87d72c48..4737108f 100644 --- a/packages/dsw-tdk/pyproject.toml +++ b/packages/dsw-tdk/pyproject.toml @@ -46,7 +46,7 @@ build-backend = "hatchling.build" source = "uv-dynamic-versioning" [tool.hatch.metadata.hooks.uv-dynamic-versioning] -dependencies = ["aiohttp", "Babel", "click", "colorama", "humanize", "Jinja2", "multidict", "pathspec", "python-dotenv", "python-slugify", "watchfiles", "dsw-models=={{ version }}"] +dependencies = ["aiohttp", "Babel", "click", "colorama", "humanize", "Jinja2", "multidict", "pathspec", "pydantic", "python-dotenv", "python-slugify", "watchfiles", "dsw-models=={{ version }}"] [tool.hatch.build.targets.wheel] packages = ["dsw"] diff --git a/packages/dsw-tdk/tests/test_cmd_package.py b/packages/dsw-tdk/tests/test_cmd_package.py index ce92a900..4e2b23b5 100644 --- a/packages/dsw-tdk/tests/test_cmd_package.py +++ b/packages/dsw-tdk/tests/test_cmd_package.py @@ -1,7 +1,11 @@ +import json import pathlib +import zipfile import click.testing +from dsw.models.document_template.metadata import DocumentTemplateBundle +from dsw.models.strictness import load from dsw.tdk import main @@ -55,3 +59,44 @@ def test_package_faulty01(fixtures_path: pathlib.Path, tmp_path: pathlib.Path): result = runner.invoke(main, args=['package', template_path.as_posix(), '-o', 'my-template.zip']) assert result.exit_code == 1 assert not zip_file.exists() + + +def _packaged_descriptor(fixtures_path: pathlib.Path, tmp_path: pathlib.Path, + fixture: str) -> tuple[int, str, dict]: + runner = click.testing.CliRunner() + template_path = fixtures_path / fixture + with runner.isolated_filesystem(temp_dir=tmp_path) as isolated_dir: + zip_file = pathlib.Path(isolated_dir) / 'my-template.zip' + result = runner.invoke(main, args=['package', template_path.as_posix(), + '-o', 'my-template.zip']) + if not zip_file.exists(): + return result.exit_code, result.output, {} + with zipfile.ZipFile(zip_file) as pkg: + descriptor = json.loads(pkg.read('template/template.json')) + return result.exit_code, result.output, descriptor + + +def test_package_matches_document_template_bundle(fixtures_path, tmp_path): + """What `package` writes is exactly the bundle shape shared via dsw-models.""" + exit_code, _, descriptor = _packaged_descriptor(fixtures_path, tmp_path, 'test_example01') + assert exit_code == 0 + bundle = load(DocumentTemplateBundle, descriptor) + assert bundle.coordinate == descriptor['id'] + assert bundle.metamodel_version == '17.1' + assert [f.file_name for f in bundle.files] == [f['fileName'] for f in descriptor['files']] + + +def test_package_keeps_allowed_package_options(fixtures_path, tmp_path): + """`options` is optional on a package pattern: present when set, absent otherwise.""" + _, _, descriptor = _packaged_descriptor(fixtures_path, tmp_path, 'test_example01') + for pattern in descriptor['allowedPackages']: + assert set(pattern) >= {'orgId', 'kmId', 'minVersion', 'maxVersion'} + assert 'options' not in pattern or isinstance(pattern['options'], dict) + + +def test_package_of_invalid_template_still_succeeds(fixtures_path, tmp_path): + """`package` never ran TemplateValidator, so bundle mismatches warn rather than fail.""" + exit_code, output, descriptor = _packaged_descriptor(fixtures_path, tmp_path, 'test_faulty03') + assert exit_code == 0 + assert 'does not match the document template bundle' in output + assert descriptor['name'] is None # written as assembled, exactly as before diff --git a/uv.lock b/uv.lock index 78a0be8d..27c8c3bc 100644 --- a/uv.lock +++ b/uv.lock @@ -659,6 +659,7 @@ dependencies = [ { name = "jinja2" }, { name = "multidict" }, { name = "pathspec" }, + { name = "pydantic" }, { name = "python-dotenv" }, { name = "python-slugify" }, { name = "watchfiles" }, @@ -682,6 +683,7 @@ requires-dist = [ { name = "jinja2" }, { name = "multidict" }, { name = "pathspec" }, + { name = "pydantic" }, { name = "pytest", marker = "extra == 'test'" }, { name = "pytest-recording", marker = "extra == 'test'" }, { name = "python-dotenv" },