"""The explicit native version source of a `script` component: one `.properties` file and key, read the same way at packaging and through the forge. UNTESTED: written while test execution was suspended. Source-only: temporary directories, `unittest.mock` for the forge file read, no network, no process. The literal case is the captured Android library: `VERSION_NAME=0.1.1-SNAPSHOT` in `qqq/android/core/gradle.properties` (and the same value in a second module's file). """ import tempfile import unittest import urllib.error from pathlib import Path from types import SimpleNamespace from typing import Any, cast, override from unittest import mock from platform_ci.build_inputs import compute_build_inputs from platform_ci.configuration import ConfigurationError, parse_project_configuration from platform_ci.delivery.selection import declared_version_at from platform_ci.packaging import RunIdentity, collect_outputs from platform_ci.plan import Target, VersionSourceTarget from platform_ci.versions import UNDECLARED, UNVERSIONED, parse_declared_version, parse_properties_version, read_declared_version from platform_ci.workspace import PipelineError from tests.support import PLATFORM_REVISION, SOURCE_REVISION, SOURCE_TREE, event, plan_for CORE = "qqq/android/core/gradle.properties" SOURCE: VersionSourceTarget = {"file": CORE, "property": "VERSION_NAME"} LITERAL = b"## POM_LICENSE_NAME=Apache License, Version 2.0\nVERSION_NAME=0.1.1-SNAPSHOT\n" def script_configuration(version: object = SOURCE, tool: str = "script", directory: str = ".") -> dict[str, object]: """A one-component configuration whose script (or other) tool optionally declares an explicit version source.""" build: dict[str, object] = {"tool": "script", "commands": [["./scripts/ci/verify.sh"]]} if tool == "script" else {"tool": tool, "arguments": ["verify"]} component: dict[str, object] = {"directory": directory, "toolchains": {"java": "21"}, "build": build, "artifacts": {"outputs": {"paths": ["out/*.txt"]}}} if version is not None: component["version"] = version return {"schema": 1, "components": {"android": component}} def absent_file(*_arguments: object) -> None: """Stand in for a forge file read that finds no file.""" def unsupported_file(*_arguments: object) -> bytes: """Stand in for a forge file read that returns a declaration with unsupported syntax.""" return b"VERSION_NAME=1.0.0 \n" def planned(**options: Any) -> Target: """Plan the configuration against the shared test catalog and return its target.""" return plan_for(parse_project_configuration(script_configuration(**options)), event())["targets"][0] class PropertiesReaderTest(unittest.TestCase): """Only plain literal `key=value` is supported; everything else is `undeclared` (absent or unresolved) or an explicit error (unsupported syntax).""" def test_the_captured_android_declaration_reads_as_its_literal(self) -> None: """A commented-out declaration of other keys is ignored and the key's value is returned exactly.""" self.assertEqual(parse_properties_version(LITERAL, "VERSION_NAME"), "0.1.1-SNAPSHOT") def test_the_accepted_separator_forms_agree(self) -> None: """`=`, `:` and whitespace separators, with or without surrounding blanks, are the same plain declaration.""" for line in ("VERSION_NAME=1.2.3", "VERSION_NAME = 1.2.3", "VERSION_NAME: 1.2.3", " VERSION_NAME 1.2.3", "VERSION_NAME\t=\t1.2.3"): with self.subTest(line): self.assertEqual(parse_properties_version(f"{line}\n".encode(), "VERSION_NAME"), "1.2.3") def test_missing_duplicate_empty_or_unresolved_is_undeclared_never_a_fallback(self) -> None: """Nothing is guessed: a version that is not exactly one literal is `undeclared`.""" cases = { "missing": b"OTHER=1.0.0\n", "only a prefix key": b"VERSION_NAME_SUFFIX=1.0.0\n", "commented out": b"# VERSION_NAME=1.0.0\n! VERSION_NAME=1.0.0\n", "duplicate": b"VERSION_NAME=1.0.0\nVERSION_NAME=1.0.0\n", "empty": b"VERSION_NAME=\n", "no value": b"VERSION_NAME\n", "property reference": b"VERSION_NAME=${major}.0.0\n", "dollar": b"VERSION_NAME=$major\n", } for label, content in cases.items(): with self.subTest(label): self.assertEqual(parse_properties_version(content, "VERSION_NAME"), UNDECLARED) def test_unsupported_syntax_is_an_explicit_error(self) -> None: """A continuation, an escape in a key, a backslash or trailing blank in the value, a non-literal value or non-UTF-8 text is refused, not misread.""" cases = { "continuation hides the next line": b"OTHER=a\\\nVERSION_NAME=1.0.0\n", "escaped key": b"VERSION\\_NAME=1.0.0\n", "backslash in the value": b"VERSION_NAME=1.0\\u0030\n", "trailing space": b"VERSION_NAME=1.0.0 \n", "not a version literal": b"VERSION_NAME=one point oh\n", "not utf-8": b"VERSION_NAME=1.0.0\n\xff\n", } for label, content in cases.items(): with self.subTest(label), self.assertRaises(PipelineError): parse_properties_version(content, "VERSION_NAME") def test_an_even_run_of_backslashes_is_not_a_continuation(self) -> None: """A line ending in an escaped backslash does not continue, so the next line keeps its meaning.""" self.assertEqual(parse_properties_version(b"PATH=C:\\\\\nVERSION_NAME=2.0.0\n", "VERSION_NAME"), "2.0.0") def test_the_default_behavior_for_other_tools_is_unchanged(self) -> None: """No explicit source: Maven reads its POM, a tool with no version file is unversioned, and a script component without a source is unversioned.""" pom = b'3.0.0' self.assertEqual(parse_declared_version("maven", pom), "3.0.0") self.assertEqual(parse_declared_version("script", b"VERSION_NAME=1.0.0"), UNVERSIONED) self.assertEqual(parse_declared_version("script", b"VERSION_NAME=1.0.0", (), SOURCE), "1.0.0") with tempfile.TemporaryDirectory() as directory: self.assertEqual(read_declared_version("script", Path(directory)), UNVERSIONED) class LocalReadTest(unittest.TestCase): """The packaging-side read of the explicit source stays inside the component directory.""" @override def setUp(self) -> None: """A component directory holding the captured layout.""" scratch = tempfile.TemporaryDirectory() self.addCleanup(scratch.cleanup) self.root = Path(scratch.name) / "component" (self.root / "qqq" / "android" / "core").mkdir(parents=True) (self.root / CORE).write_bytes(LITERAL) self.scratch = Path(scratch.name) def test_the_file_is_read_relative_to_the_component_directory(self) -> None: """The declared relative path is resolved under the directory and its key read.""" self.assertEqual(read_declared_version("script", self.root, (), SOURCE), "0.1.1-SNAPSHOT") def test_a_missing_or_oversized_file_is_undeclared(self) -> None: """A file that is not there, or is larger than the cap, never falls back to anything else.""" self.assertEqual(read_declared_version("script", self.root, (), {"file": "missing.properties", "property": "VERSION_NAME"}), UNDECLARED) (self.root / "big.properties").write_bytes(b"#" * (4 * 1024 * 1024 + 1)) self.assertEqual(read_declared_version("script", self.root, (), {"file": "big.properties", "property": "VERSION_NAME"}), UNDECLARED) def test_a_link_in_the_path_or_a_link_leaving_the_directory_is_undeclared(self) -> None: """A symlinked directory component or file, even one aimed outside, is never followed.""" outside = self.scratch / "outside" outside.mkdir() (outside / "gradle.properties").write_bytes(b"VERSION_NAME=9.9.9\n") try: (self.root / "linked").symlink_to(outside, target_is_directory=True) (self.root / "file.properties").symlink_to(outside / "gradle.properties") except (OSError, NotImplementedError): self.skipTest("this host cannot create symbolic links") self.assertEqual(read_declared_version("script", self.root, (), {"file": "linked/gradle.properties", "property": "VERSION_NAME"}), UNDECLARED) self.assertEqual(read_declared_version("script", self.root, (), {"file": "file.properties", "property": "VERSION_NAME"}), UNDECLARED) class ConfigurationTest(unittest.TestCase): """The declaration is validated, script-only, and part of the target's identity only when present.""" def test_a_script_component_carries_the_source_into_its_target(self) -> None: """The plan target holds exactly the declared file and property.""" target = planned() self.assertEqual(target.get("version_source"), SOURCE) def test_a_component_without_the_field_has_no_trace_of_it(self) -> None: """No `version_source` key at all, so existing targets and their config digests are untouched.""" target = planned(version=None) self.assertNotIn("version_source", target) self.assertEqual(read_declared_version(target["build_tool"], Path(), target["commands"], target.get("version_source")), UNVERSIONED) def test_the_declaration_is_part_of_the_build_input_identity(self) -> None: """Changing the file or the property, or adding the declaration, changes the config digest, even for the same tree and version.""" base = compute_build_inputs(planned(), SOURCE_TREE, "1.0.0") other_file = compute_build_inputs(planned(version={"file": "qqq/android/mobileapp/gradle.properties", "property": "VERSION_NAME"}), SOURCE_TREE, "1.0.0") other_key = compute_build_inputs(planned(version={"file": CORE, "property": "VERSION"}), SOURCE_TREE, "1.0.0") none = compute_build_inputs(planned(version=None), SOURCE_TREE, "1.0.0") self.assertEqual(len({base["config_digest"], other_file["config_digest"], other_key["config_digest"], none["config_digest"]}), 4) self.assertEqual(compute_build_inputs(planned(), SOURCE_TREE, "1.0.0"), base) def test_invalid_declarations_are_refused_by_name(self) -> None: """Only the script tool, only a literal relative `.properties` path inside the component, and a plain key.""" bad: dict[str, object] = { "not a mapping": "gradle.properties", "no file": {"property": "VERSION_NAME"}, "no property": {"file": CORE}, "unknown field": {"file": CORE, "property": "VERSION_NAME", "expression": "1+1"}, "absolute": {"file": "/etc/gradle.properties", "property": "VERSION_NAME"}, "escapes": {"file": "../gradle.properties", "property": "VERSION_NAME"}, "inner escape": {"file": "a/../../gradle.properties", "property": "VERSION_NAME"}, "backslash": {"file": "qqq\\gradle.properties", "property": "VERSION_NAME"}, "glob": {"file": "qqq/*.properties", "property": "VERSION_NAME"}, "wrong suffix": {"file": "qqq/gradle.txt", "property": "VERSION_NAME"}, "hidden start": {"file": ".gradle.properties", "property": "VERSION_NAME"}, "bad key": {"file": CORE, "property": "VERSION NAME"}, "key with expression": {"file": CORE, "property": "${x}"}, } for label, version in bad.items(): with self.subTest(label), self.assertRaises(ConfigurationError): parse_project_configuration(script_configuration(version=version)) def test_only_the_script_tool_may_declare_one(self) -> None: """Maven and the other tools keep their own version file; an explicit source there is refused rather than ignored.""" with self.assertRaisesRegex(ConfigurationError, "script tool"): parse_project_configuration(script_configuration(tool="maven")) class PackagingAndForgeAgreeTest(unittest.TestCase): """The same declaration yields the same version when packaged locally and when read independently through the forge.""" @override def setUp(self) -> None: """A checkout with the captured file and one declared artifact.""" scratch = tempfile.TemporaryDirectory() self.addCleanup(scratch.cleanup) self.root = Path(scratch.name) self.source = self.root / "checkout" (self.source / "qqq" / "android" / "core").mkdir(parents=True) (self.source / CORE).write_bytes(LITERAL) (self.source / "out").mkdir() (self.source / "out" / "library.txt").write_text("bytes", encoding="utf-8") def test_the_bundle_records_the_declared_version_in_its_build_inputs(self) -> None: """`collect_outputs` reads the explicit source and records its literal, with no inference from any artifact.""" target = planned() manifest = collect_outputs(target, self.source, self.root / "bundle", RunIdentity(SOURCE_REVISION, PLATFORM_REVISION, "7", "1", SOURCE_TREE), build_succeeded=True) self.assertEqual(cast("dict[str, Any]", manifest["build_inputs"])["version"], "0.1.1-SNAPSHOT") def test_the_forge_read_names_the_same_path_and_yields_the_same_version(self) -> None: """`declared_version_at` asks for directory/file at the commit and parses the same bytes with the same reader.""" target = planned() requested: list[tuple[str, str, str]] = [] def read(_forge: object, repository: str, path: str, ref: str) -> bytes | None: requested.append((repository, path, ref)) return (self.source / path).read_bytes() scope = SimpleNamespace(repository="Org/repo") with mock.patch("platform_ci.delivery.selection.read_file", read): version = declared_version_at(cast("Any", object()), cast("Any", scope), target, "a" * 40) self.assertEqual(requested, [("Org/repo", CORE, "a" * 40)]) self.assertEqual(version, read_declared_version(target["build_tool"], self.source, target["commands"], target.get("version_source"))) def test_a_nested_component_directory_prefixes_the_forge_path(self) -> None: """The source path is relative to the component directory on both sides.""" target = planned(directory="android") requested: list[str] = [] def read(_forge: object, _repository: str, path: str, _ref: str) -> bytes | None: requested.append(path) return LITERAL with mock.patch("platform_ci.delivery.selection.read_file", read): declared_version_at(cast("Any", object()), cast("Any", SimpleNamespace(repository="Org/repo")), target, "a" * 40) self.assertEqual(requested, [f"android/{CORE}"]) def test_an_absent_or_unreadable_file_at_the_commit_is_undeclared(self) -> None: """A missing file or an HTTP error never falls back to another version source.""" target = planned() scope = cast("Any", SimpleNamespace(repository="Org/repo")) with mock.patch("platform_ci.delivery.selection.read_file", absent_file): self.assertEqual(declared_version_at(cast("Any", object()), scope, target, "a" * 40), UNDECLARED) def failing(*_arguments: object) -> bytes: raise urllib.error.HTTPError("https://forge.invalid", 500, "error", cast("Any", None), None) with mock.patch("platform_ci.delivery.selection.read_file", failing): self.assertEqual(declared_version_at(cast("Any", object()), scope, target, "a" * 40), UNDECLARED) def test_a_declaration_with_unsupported_syntax_fails_on_both_sides(self) -> None: """The packaging read and the forge read raise the same explicit error, so neither side can disagree by guessing.""" (self.source / CORE).write_bytes(b"VERSION_NAME=1.0.0 \n") target = planned() with self.assertRaises(PipelineError): read_declared_version(target["build_tool"], self.source, target["commands"], target.get("version_source")) with mock.patch("platform_ci.delivery.selection.read_file", unsupported_file), self.assertRaises(PipelineError): declared_version_at(cast("Any", object()), cast("Any", SimpleNamespace(repository="Org/repo")), target, "a" * 40) if __name__ == "__main__": unittest.main()