diff --git a/docs/index.md b/docs/index.md index 2c2c25de7..a51fa788f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -71,6 +71,7 @@ tryhamilton.dev reference/decorators/index reference/drivers/index reference/caching/index +reference/packaging/index reference/graph-adapters/index reference/lifecycle-hooks/index reference/result-builders/index diff --git a/docs/reference/packaging/index.rst b/docs/reference/packaging/index.rst new file mode 100644 index 000000000..ad26e42e3 --- /dev/null +++ b/docs/reference/packaging/index.rst @@ -0,0 +1,81 @@ +============== +Packaging +============== + +Subgraph packaging for Hamilton dataflows. A :class:`hamilton.packaging.NodePackage` +carves a group of nodes out of a driver's built internal graph (``Driver.graph``) and +treats it as a first-class, reusable unit that records the nodes it provides, the +external dependencies it requires, the configuration keys it needs, and the dependency +edges among them. Packages serialize to JSON manifests for export and import, validate +for compatibility against a host dataflow's graph, detect conflicts against other +packages, and compose deterministically into larger packages. Packages supplied with +``Builder.with_packages(...)`` are enforced when the driver is built. + +NodePackage +----------- + +.. autoclass:: hamilton.packaging.NodePackage + :members: + +PackageManifest +--------------- + +.. autoclass:: hamilton.packaging.PackageManifest + :members: + +Validation results +------------------ + +.. autoclass:: hamilton.packaging.ValidationReport + :members: + +.. autoclass:: hamilton.packaging.ValidationProblem + :members: + +Conflicts +--------- + +.. autoclass:: hamilton.packaging.PackageConflict + :members: + +Diffs +----- + +.. autoclass:: hamilton.packaging.ManifestDiff + :members: + +Errors +------ + +.. autoclass:: hamilton.packaging.PackagingError + +.. autoclass:: hamilton.packaging.PackageValidationError + +.. autoclass:: hamilton.packaging.PackageConflictError + +Module-level helpers +-------------------- + +.. autofunction:: hamilton.packaging.validate_package + +.. autofunction:: hamilton.packaging.find_conflicts + +.. autofunction:: hamilton.packaging.compose_packages + +.. autofunction:: hamilton.packaging.diff_manifests + +.. autofunction:: hamilton.packaging.export_package + +.. autofunction:: hamilton.packaging.import_manifest + +Driver integration +------------------ + +Packages integrate with the driver lifecycle: + +* ``Builder.with_packages(*node_packages)`` declares the packages a dataflow must be + compatible with. +* Building a driver validates its graph against every declared package, raising + :class:`hamilton.packaging.PackageValidationError` when one is incompatible. +* ``Driver.list_packages()`` returns the declared packages and + ``Driver.validate_packages()`` returns one report per package. diff --git a/hamilton/async_driver.py b/hamilton/async_driver.py index 2bab61479..492f570a7 100644 --- a/hamilton/async_driver.py +++ b/hamilton/async_driver.py @@ -23,7 +23,7 @@ from typing import Any import hamilton.lifecycle.base as lifecycle_base -from hamilton import base, driver, graph, lifecycle, node +from hamilton import base, driver, graph, lifecycle, node, packaging from hamilton.execution.graph_functions import create_error_message from hamilton.io.materialization import ExtractorFactory, MaterializerFactory @@ -213,6 +213,7 @@ def __init__( result_builder: base.ResultMixin | None = None, adapters: list[lifecycle.LifecycleAdapter] = None, allow_module_overrides: bool = False, + _node_packages: typing.Sequence["packaging.NodePackage"] = None, ): """Instantiates an asynchronous driver. @@ -231,6 +232,7 @@ def __init__( The order of listing the modules is important, since later ones will overwrite the previous ones. This is a global call affecting all imported modules. See https://github.com/apache/hamilton/tree/main/examples/module_overrides for more info. + :param _node_packages: Not public facing, do not use this parameter. This is injected by the builder. """ if adapters is None: adapters = [] @@ -263,6 +265,7 @@ def __init__( *async_adapters, # note async adapters will not be called during synchronous execution -- this is for access later ], allow_module_overrides=allow_module_overrides, + _node_packages=_node_packages, ) self.initialized = False @@ -451,6 +454,7 @@ def build_without_init(self) -> AsyncDriver: adapters=self.adapters, result_builder=specified_result_builder, allow_module_overrides=self._allow_module_overrides, + _node_packages=self.node_packages, ) async def build(self) -> AsyncDriver: diff --git a/hamilton/driver.py b/hamilton/driver.py index dc063c475..8b966abe0 100644 --- a/hamilton/driver.py +++ b/hamilton/driver.py @@ -40,7 +40,7 @@ import pandas as pd from typing_extensions import Self -from hamilton import common, graph_types, htypes +from hamilton import common, graph_types, htypes, packaging from hamilton.caching.adapter import HamiltonCacheAdapter from hamilton.caching.stores.base import MetadataStore, ResultStore from hamilton.dev_utils import deprecation @@ -393,6 +393,22 @@ def _perform_graph_validations( ) raise lifecycle_base.ValidationException(error_str) + @staticmethod + def _enforce_node_packages( + graph: graph.FunctionGraph, + node_packages: typing.Sequence["packaging.NodePackage"], + ): + """Validates the constructed graph against each supplied node package. + + :param graph: Graph to validate against. + :param node_packages: Packages the driver should be compatible with. + :raises PackageValidationError: If a package is incompatible with the graph. + """ + for node_package in node_packages: + report = packaging.validate_package(node_package, graph) + if not report.is_valid: + raise packaging.PackageValidationError(report) + def __init__( self, config: dict[str, Any], @@ -404,6 +420,7 @@ def __init__( _materializers: typing.Sequence[ExtractorFactory | MaterializerFactory] = None, _graph_executor: GraphExecutor = None, _use_legacy_adapter: bool = True, + _node_packages: typing.Sequence["packaging.NodePackage"] = None, ): """Constructor: creates a DAG given the configuration & modules to crawl. @@ -422,6 +439,7 @@ def __init__( :param _use_legacy_adapter: Not public facing, do not use this parameter. This represents whether or not to use the legacy adapter. Defaults to True, as this should be backwards compatible. In Hamilton 2.0.0, this will be removed. + :param _node_packages: Not public facing, do not use this parameter. This is injected by the builder. """ @@ -445,6 +463,8 @@ def __init__( self.graph, materializer_factories, extractor_factories ) Driver._perform_graph_validations(adapter, graph=self.graph, graph_modules=modules) + self._node_packages = list(_node_packages) if _node_packages else [] + Driver._enforce_node_packages(self.graph, self._node_packages) if adapter.does_hook("post_graph_construct", is_async=False): adapter.call_all_lifecycle_hooks_sync( "post_graph_construct", graph=self.graph, modules=modules, config=config @@ -793,6 +813,27 @@ def list_available_variables( results = [Variable.from_node(n) for n in all_nodes] return results + def list_packages(self) -> list["packaging.NodePackage"]: + """Returns the node packages this driver was built with. + + :return: The packages supplied via ``Builder.with_packages`` (empty if none). + """ + return list(self._node_packages) + + def validate_packages(self) -> list["packaging.ValidationReport"]: + """Validates each of this driver's node packages against its built graph. + + Since a driver only builds when its packages are compatible, this returns + problem-free reports for a successfully built driver. It is primarily useful + for inspecting the per-package validation result programmatically. + + :return: One report per package, in the order the packages were supplied. + """ + return [ + packaging.validate_package(node_package, self.graph) + for node_package in self._node_packages + ] + def get_variable(self, name: str) -> Variable: """Returns a variable by name. @@ -1773,6 +1814,7 @@ def __init__(self): self.config = {} self.modules = [] self.materializers = [] + self.node_packages = [] # Allow later modules to override nodes of the same name self._allow_module_overrides = False @@ -1892,6 +1934,29 @@ def with_materializers(self, *materializers: ExtractorFactory | MaterializerFact self.materializers.extend(materializers) return self + def with_packages(self, *node_packages: "packaging.NodePackage") -> Self: + """Declares node packages the built dataflow must be compatible with. + + Each package is validated against the driver's built graph when the driver is + built; building fails with a ``PackageValidationError`` if a package is + incompatible. + + :param node_packages: packages the dataflow should satisfy + :return: self + """ + if any(not isinstance(p, packaging.NodePackage) for p in node_packages): + if len(node_packages) == 1 and isinstance(node_packages[0], Sequence): + raise ValueError( + "`.with_packages()` received a sequence. Unpack it by prepending `*` e.g., `*[package_a, package_b]`" + ) + else: + raise ValueError( + f"`.with_packages()` only accepts packages. Received instead: {node_packages}" + ) + + self.node_packages.extend(node_packages) + return self + def with_cache( self, path: str | pathlib.Path = ".hamilton_cache", @@ -2085,6 +2150,7 @@ def build(self) -> Driver: _graph_executor=graph_executor, _use_legacy_adapter=False, allow_module_overrides=self._allow_module_overrides, + _node_packages=self.node_packages, ) def copy(self) -> "Builder": @@ -2099,6 +2165,7 @@ def copy(self) -> "Builder": new_builder.legacy_graph_adapter = self.legacy_graph_adapter new_builder.adapters = self.adapters.copy() new_builder.materializers = self.materializers.copy() + new_builder.node_packages = self.node_packages.copy() new_builder.execution_manager = self.execution_manager new_builder.local_executor = self.local_executor new_builder.remote_executor = self.remote_executor diff --git a/hamilton/packaging.py b/hamilton/packaging.py new file mode 100644 index 000000000..a5ede9341 --- /dev/null +++ b/hamilton/packaging.py @@ -0,0 +1,1057 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Subgraph packaging for Hamilton dataflows. + +This module lets you carve a group of nodes out of a built dataflow and treat it as a +first-class, reusable unit: a :class:`NodePackage`. A package records the nodes it +provides (with their output types), the external dependencies it requires (with the +types it expects), the configuration keys it needs, and the dependency edges among all +of these. Packages can be serialized to a JSON manifest, exported to and imported from +files, validated for compatibility against a host dataflow's graph, checked for +conflicts against other packages, and composed into larger packages. +""" + +import heapq +import json +import pathlib +import typing +from collections.abc import Iterable, Mapping +from typing import Any + +from hamilton import graph, htypes +from hamilton.node import DependencyType + +VALIDATION_CATEGORIES = frozenset( + { + "version_too_low", + "node_collision", + "dependency_type_mismatch", + "missing_dependency", + "config_requirement_missing", + } +) + +CONFLICT_CATEGORIES = frozenset( + { + "duplicate_node", + "requirement_mismatch", + "requirement_disagreement", + } +) + +MANIFEST_FIELDS = ("name", "version", "provides", "requires", "dependencies", "config_keys") + + +class PackagingError(Exception): + """Base exception for the packaging module. + + Raised when a package, manifest, or helper is constructed or called with + invalid arguments. + """ + + +class PackageValidationError(PackagingError): + """Raised when a package fails validation against a host graph. + + Carries the full :class:`ValidationReport` as the ``report`` attribute; the + exception message contains the message of every problem in the report. + """ + + def __init__(self, report: "ValidationReport"): + """Creates the error from a failed validation report. + + :param report: The report describing why validation failed. + """ + if not isinstance(report, ValidationReport): + raise PackagingError( + f"PackageValidationError expects a ValidationReport, got {type(report)}." + ) + if report.is_valid: + raise PackagingError( + "PackageValidationError requires a report with at least one problem." + ) + self.report = report + message_lines = [ + f"Package '{report.package_name}' failed validation against the host graph:" + ] + for problem in report.problems: + message_lines.append(f" [{problem.category}] {problem.message}") + super().__init__("\n".join(message_lines)) + + +class PackageConflictError(PackagingError): + """Raised when packages being composed conflict with each other. + + Carries every detected :class:`PackageConflict` as the ``conflicts`` attribute; + the exception message contains the message of every conflict. + """ + + def __init__(self, conflicts: Iterable["PackageConflict"]): + """Creates the error from the detected conflicts. + + :param conflicts: The conflicts that prevented composition. + """ + materialized = list(conflicts) + for conflict in materialized: + if not isinstance(conflict, PackageConflict): + raise PackagingError( + f"PackageConflictError expects PackageConflict instances, got {type(conflict)}." + ) + if not materialized: + raise PackagingError("PackageConflictError requires at least one conflict.") + self.conflicts = tuple(materialized) + message_lines = ["Package composition failed due to conflicts:"] + for conflict in self.conflicts: + message_lines.append(f" [{conflict.category}] {conflict.message}") + super().__init__("\n".join(message_lines)) + + +def _validate_string(value: Any, description: str) -> str: + """Validates that a value is a non-empty string. + + :param value: Value to check. + :param description: Human-readable description used in the error message. + :return: The validated string. + """ + if not isinstance(value, str): + raise PackagingError(f"{description} must be a string, got {type(value)}.") + if not value: + raise PackagingError(f"{description} must be a non-empty string.") + return value + + +def _validate_version(value: Any, description: str) -> int: + """Validates that a value is an integer version of at least 1. + + :param value: Value to check. + :param description: Human-readable description used in the error message. + :return: The validated version. + """ + if isinstance(value, bool) or not isinstance(value, int): + raise PackagingError(f"{description} must be an integer, got {type(value)}.") + if value < 1: + raise PackagingError(f"{description} must be at least 1, got {value}.") + return value + + +def _is_type_annotation(value: Any) -> bool: + """Tells whether a value is usable as a type annotation for a package boundary. + + :param value: Value to check. + :return: True if the value is a type, a generic alias, or ``typing.Any``. + """ + return value is Any or isinstance(value, type) or typing.get_origin(value) is not None + + +def _validate_type_mapping(value: Any, description: str) -> dict[str, Any]: + """Validates a name-to-type mapping and returns it sorted by name. + + :param value: Mapping to check. None is treated as empty. + :param description: Human-readable description used in the error messages. + :return: A new dict sorted by key. + """ + if value is None: + return {} + if not isinstance(value, Mapping): + raise PackagingError( + f"{description} must be a mapping of names to types, got {type(value)}." + ) + for key, type_ in value.items(): + _validate_string(key, f"{description} name {key!r}") + if not _is_type_annotation(type_): + raise PackagingError(f"{description} entry '{key}' must map to a type, got {type_!r}.") + return {key: value[key] for key in sorted(value)} + + +def _validate_names(value: Any, description: str) -> tuple[str, ...]: + """Validates an iterable of names and returns them deduplicated and sorted. + + :param value: Iterable of names to check. None is treated as empty. + :param description: Human-readable description used in the error messages. + :return: A sorted tuple of unique names. + """ + if value is None: + return () + if isinstance(value, str) or not isinstance(value, Iterable): + raise PackagingError(f"{description} must be an iterable of strings, got {type(value)}.") + materialized = list(value) + for name in materialized: + _validate_string(name, f"{description} entry {name!r}") + return tuple(sorted(set(materialized))) + + +def _validate_dependency_edges( + owner: str, + provides: Mapping[str, Any], + known_targets: set[str], + dependencies: Any, +) -> dict[str, tuple[str, ...]]: + """Validates dependency edges and returns them normalized. + + :param owner: Label of the owning package or manifest, used in error messages. + :param provides: The provided-name mapping edges may be declared for. + :param known_targets: Every name an edge may point at. + :param dependencies: Mapping of provided name to consumed names. None is treated + as empty. + :return: A dict with one sorted tuple of consumed names per provided name. + """ + if dependencies is None: + dependencies = {} + if not isinstance(dependencies, Mapping): + raise PackagingError(f"{owner} dependencies must be a mapping, got {type(dependencies)}.") + normalized = {} + for key, targets in dependencies.items(): + _validate_string(key, f"{owner} dependencies key {key!r}") + if key not in provides: + raise PackagingError( + f"{owner} declares dependencies for '{key}', which is not a provided node." + ) + resolved = _validate_names(targets, f"{owner} dependencies of '{key}'") + for target in resolved: + if target not in known_targets: + raise PackagingError(f"{owner} node '{key}' depends on unknown name '{target}'.") + normalized[key] = resolved + return {node_name: normalized.get(node_name, ()) for node_name in sorted(provides)} + + +def _topological_order( + owner: str, provided: set[str], dependencies: Mapping[str, tuple[str, ...]] +) -> list[str]: + """Topologically sorts provided names by their internal dependency edges. + + Ties are broken alphabetically so the result is deterministic, and the sort is + iterative so it works however deep the dependency chains are. + + :param owner: Name of the package or manifest, used in the cycle error message. + :param provided: The names to order. + :param dependencies: Mapping of provided name to the names it consumes; targets + outside the provided set are ignored. + :return: Every provided name, dependencies before dependents. + """ + internal_edges = { + node_name: [target for target in targets if target in provided] + for node_name, targets in dependencies.items() + } + remaining_counts = {node_name: len(targets) for node_name, targets in internal_edges.items()} + dependents = {node_name: [] for node_name in provided} + for node_name, targets in internal_edges.items(): + for target in targets: + dependents[target].append(node_name) + ready = [node_name for node_name, count in remaining_counts.items() if count == 0] + heapq.heapify(ready) + ordered = [] + while ready: + current = heapq.heappop(ready) + ordered.append(current) + for dependent in dependents[current]: + remaining_counts[dependent] -= 1 + if remaining_counts[dependent] == 0: + heapq.heappush(ready, dependent) + if len(ordered) != len(provided): + unordered = sorted(provided - set(ordered)) + raise PackagingError(f"'{owner}' has a dependency cycle involving: {unordered}.") + return ordered + + +def _produced_satisfies(expected_type: Any, produced_type: Any) -> bool: + """Checks that a produced type can be consumed where the expected type is declared. + + This follows Hamilton's edge-type semantics: the produced type may be a subclass of + the expected type, so a ``bool`` output can feed an ``int`` input but not the + reverse. + + :param expected_type: The type the consumer declares. + :param produced_type: The type the producer emits. + :return: True if the produced value is acceptable for the consumer. + """ + return htypes.types_match(expected_type, produced_type) + + +def _mutually_compatible(first_type: Any, second_type: Any) -> bool: + """Checks that two producer types are interchangeable in either direction. + + :param first_type: One produced type. + :param second_type: The other produced type. + :return: True if each type satisfies the other. + """ + return _produced_satisfies(first_type, second_type) and _produced_satisfies( + second_type, first_type + ) + + +class NodePackage: + """A reusable, self-describing bundle of dataflow nodes. + + A package declares the nodes it provides with their output types, the external + dependencies it requires with the types it expects, the configuration keys it + needs, and the dependency edges from each provided node to the names it consumes. + """ + + def __init__( + self, + name: str, + *, + version: int = 1, + provides: Mapping[str, Any] = None, + requires: Mapping[str, Any] = None, + dependencies: Mapping[str, Iterable[str]] = None, + config_keys: Iterable[str] = None, + ): + """Creates a package from explicit declarations. + + :param name: Name of the package. Must be a non-empty string. + :param version: Version of the package. Must be an integer of at least 1. + :param provides: Mapping of provided node name to output type. + :param requires: Mapping of external dependency name to the expected type. + :param dependencies: Mapping of provided node name to the iterable of names it + consumes. Every listed name must be a provided node, a required dependency, + or a configuration key. + :param config_keys: Iterable of configuration key names the package needs. + """ + self.name = _validate_string(name, "Package name") + self.version = _validate_version(version, f"Package '{self.name}' version") + self.provides = _validate_type_mapping(provides, f"Package '{self.name}' provides") + self.requires = _validate_type_mapping(requires, f"Package '{self.name}' requires") + overlap = set(self.provides) & set(self.requires) + if overlap: + raise PackagingError( + f"Package '{self.name}' cannot both provide and require: {sorted(overlap)}." + ) + self.config_keys = _validate_names(config_keys, f"Package '{self.name}' config_keys") + known_targets = set(self.provides) | set(self.requires) | set(self.config_keys) + self.dependencies = _validate_dependency_edges( + f"Package '{self.name}'", self.provides, known_targets, dependencies + ) + + @classmethod + def from_graph( + cls, + fn_graph: "graph.FunctionGraph", + node_names: Iterable[str], + name: str, + *, + version: int = 1, + include_upstream: bool = False, + ) -> "NodePackage": + """Builds a package from a subset of a driver's built internal graph. + + The graph is the one a built driver exposes as ``Driver.graph``. The selected + nodes become the package's provided nodes; their required dependencies are + classified as internal edges, external requirements, or configuration keys + depending on whether the dependency is selected, another graph node, or a + configuration entry. Optional (defaulted) parameters are ignored. + + :param fn_graph: The driver's built internal graph, i.e. ``Driver.graph``. + :param node_names: Iterable of node names to package. + :param name: Name for the new package. + :param version: Version for the new package. + :param include_upstream: If True, the selection is expanded to every transitive + non-input, non-configuration dependency of the selected nodes. The + expansion is iterative, so it works however deep the graph is. + :return: The package describing the selected subgraph. + """ + if not isinstance(fn_graph, graph.FunctionGraph): + raise PackagingError( + "from_graph expects the driver's built internal graph (Driver.graph), " + f"a FunctionGraph -- got {type(fn_graph)}." + ) + if not isinstance(include_upstream, bool): + raise PackagingError( + f"include_upstream must be a boolean, got {type(include_upstream)}." + ) + selection = list(_validate_names(node_names, "from_graph node_names")) + if not selection: + raise PackagingError("from_graph requires at least one node name to package.") + for node_name in selection: + if node_name not in fn_graph.nodes: + raise PackagingError( + f"Node '{node_name}' does not exist in the graph, so it cannot be packaged." + ) + if fn_graph.nodes[node_name].user_defined: + raise PackagingError( + f"Node '{node_name}' is an input to the graph, not a computed node, " + "so it cannot be packaged." + ) + selected = set(selection) + if include_upstream: + stack = list(selection) + while stack: + current = stack.pop() + for dep_name, (_, dep_kind) in fn_graph.nodes[current].input_types.items(): + if dep_kind == DependencyType.OPTIONAL: + continue + if dep_name in fn_graph.config or dep_name in selected: + continue + if fn_graph.nodes[dep_name].user_defined: + continue + selected.add(dep_name) + stack.append(dep_name) + provides = {} + requires = {} + dependencies = {} + config_keys = set() + for node_name in sorted(selected): + node_ = fn_graph.nodes[node_name] + provides[node_name] = node_.type + edge_targets = [] + for dep_name, (_, dep_kind) in node_.input_types.items(): + if dep_kind == DependencyType.OPTIONAL: + continue + if dep_name in fn_graph.config: + config_keys.add(dep_name) + elif dep_name not in selected: + requires[dep_name] = fn_graph.nodes[dep_name].type + edge_targets.append(dep_name) + dependencies[node_name] = edge_targets + return cls( + name, + version=version, + provides=provides, + requires=requires, + dependencies=dependencies, + config_keys=config_keys, + ) + + def dependency_order(self) -> list[str]: + """Returns the provided nodes in dependency order. + + The order is a topological sort of the package's internal edges, with ties + broken alphabetically so the result is deterministic. The sort is iterative, + so it works however deep the package's dependency chains are. + + :return: Every provided node name, dependencies before dependents. + """ + return _topological_order(self.name, set(self.provides), self.dependencies) + + def manifest(self) -> "PackageManifest": + """Renders the package as a serializable manifest. + + Types are rendered to strings, and every mapping in the manifest is sorted by + name so the rendering is deterministic. + + :return: The manifest describing this package. + """ + return PackageManifest( + name=self.name, + version=self.version, + provides={ + node_name: htypes.get_type_as_string(type_) + for node_name, type_ in self.provides.items() + }, + requires={ + dep_name: htypes.get_type_as_string(type_) + for dep_name, type_ in self.requires.items() + }, + dependencies=self.dependencies, + config_keys=self.config_keys, + ) + + def __eq__(self, other: Any) -> bool: + return isinstance(other, NodePackage) and self.manifest() == other.manifest() + + def __repr__(self) -> str: + return ( + f"NodePackage(name={self.name!r}, version={self.version}, " + f"provides={sorted(self.provides)})" + ) + + +class PackageManifest: + """The serializable description of a :class:`NodePackage`. + + A manifest holds the same structure as a package, with every type rendered as a + string. It round-trips losslessly through plain dictionaries and JSON. + """ + + def __init__( + self, + name: str, + version: int, + provides: Mapping[str, str], + requires: Mapping[str, str], + dependencies: Mapping[str, Iterable[str]], + config_keys: Iterable[str], + ): + """Creates a manifest from its fields. + + :param name: Name of the packaged subgraph. + :param version: Version of the packaged subgraph. + :param provides: Mapping of provided node name to rendered output type. + :param requires: Mapping of required dependency name to rendered expected type. + :param dependencies: Mapping of provided node name to the names it consumes. + :param config_keys: Iterable of configuration key names. + """ + self.name = _validate_string(name, "Manifest name") + self.version = _validate_version(version, f"Manifest '{self.name}' version") + self.provides = self._validate_string_mapping(provides, "provides") + self.requires = self._validate_string_mapping(requires, "requires") + overlap = set(self.provides) & set(self.requires) + if overlap: + raise PackagingError( + f"Manifest '{self.name}' cannot both provide and require: {sorted(overlap)}." + ) + self.config_keys = _validate_names(config_keys, f"Manifest '{self.name}' config_keys") + known_targets = set(self.provides) | set(self.requires) | set(self.config_keys) + self.dependencies = _validate_dependency_edges( + f"Manifest '{self.name}'", self.provides, known_targets, dependencies + ) + + def _validate_string_mapping(self, value: Any, field: str) -> dict[str, str]: + """Validates a name-to-rendered-type mapping and returns it sorted. + + :param value: Mapping to check. + :param field: Manifest field name used in error messages. + :return: A new dict sorted by key. + """ + if not isinstance(value, Mapping): + raise PackagingError( + f"Manifest '{self.name}' {field} must be a mapping, got {type(value)}." + ) + for key, rendered in value.items(): + _validate_string(key, f"Manifest '{self.name}' {field} name {key!r}") + _validate_string(rendered, f"Manifest '{self.name}' {field} type for '{key}'") + return {key: value[key] for key in sorted(value)} + + def to_dict(self) -> dict[str, Any]: + """Renders the manifest as a plain, JSON-serializable dictionary. + + :return: A dictionary with every mapping sorted by name. + """ + return { + "name": self.name, + "version": self.version, + "provides": dict(self.provides), + "requires": dict(self.requires), + "dependencies": { + node_name: list(targets) for node_name, targets in self.dependencies.items() + }, + "config_keys": list(self.config_keys), + } + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> "PackageManifest": + """Reconstructs a manifest from a dictionary produced by :meth:`to_dict`. + + :param data: The dictionary to reconstruct from. + :return: The reconstructed manifest. + """ + if not isinstance(data, Mapping): + raise PackagingError(f"Manifest data must be a mapping, got {type(data)}.") + unknown = set(data) - set(MANIFEST_FIELDS) + if unknown: + raise PackagingError(f"Manifest data has unknown fields: {sorted(unknown)}.") + missing = set(MANIFEST_FIELDS) - set(data) + if missing: + raise PackagingError(f"Manifest data is missing fields: {sorted(missing)}.") + return cls( + name=data["name"], + version=data["version"], + provides=data["provides"], + requires=data["requires"], + dependencies=data["dependencies"], + config_keys=data["config_keys"], + ) + + def to_json(self) -> str: + """Renders the manifest as a JSON string. + + :return: The JSON rendering of :meth:`to_dict`. + """ + return json.dumps(self.to_dict(), indent=2, sort_keys=True) + + @classmethod + def from_json(cls, serialized: str) -> "PackageManifest": + """Reconstructs a manifest from a JSON string produced by :meth:`to_json`. + + :param serialized: The JSON string to reconstruct from. + :return: The reconstructed manifest. + """ + if not isinstance(serialized, str): + raise PackagingError(f"Manifest JSON must be a string, got {type(serialized)}.") + try: + data = json.loads(serialized) + except json.JSONDecodeError as e: + raise PackagingError(f"Manifest JSON is malformed: {e}.") from e + return cls.from_dict(data) + + def dependency_order(self) -> list[str]: + """Returns the provided nodes in dependency order. + + This mirrors :meth:`NodePackage.dependency_order` for manifests that were + imported rather than built in-process. + + :return: Every provided node name, dependencies before dependents. + """ + return _topological_order(self.name, set(self.provides), self.dependencies) + + def __eq__(self, other: Any) -> bool: + return isinstance(other, PackageManifest) and self.to_dict() == other.to_dict() + + def __repr__(self) -> str: + return f"PackageManifest(name={self.name!r}, version={self.version})" + + +class ValidationProblem: + """A single problem found while validating a package against a host graph.""" + + def __init__(self, category: str, name: str, message: str): + """Creates a problem record. + + :param category: One of the machine-readable validation categories. + :param name: The offending node, dependency, or configuration key name; the + package name for version problems. + :param message: Human-readable explanation of the problem. + """ + _validate_string(category, "Problem category") + if category not in VALIDATION_CATEGORIES: + raise PackagingError( + f"Unknown validation category '{category}'. " + f"Valid categories: {sorted(VALIDATION_CATEGORIES)}." + ) + self.category = category + self.name = _validate_string(name, "Problem name") + self.message = _validate_string(message, "Problem message") + + def __repr__(self) -> str: + return f"ValidationProblem(category={self.category!r}, name={self.name!r})" + + +class ValidationReport: + """The result of validating a package against a host graph.""" + + def __init__(self, package_name: str, problems: Iterable[ValidationProblem]): + """Creates a report from the problems found. + + :param package_name: Name of the validated package. + :param problems: The problems found; empty if the package is compatible. + """ + self.package_name = _validate_string(package_name, "Report package_name") + materialized = list(problems) + for problem in materialized: + if not isinstance(problem, ValidationProblem): + raise PackagingError( + f"ValidationReport expects ValidationProblem instances, got {type(problem)}." + ) + self.problems = tuple(materialized) + + @property + def is_valid(self) -> bool: + """Whether the package is compatible with the host graph.""" + return len(self.problems) == 0 + + def __str__(self) -> str: + if self.is_valid: + return f"Package '{self.package_name}' is compatible with the host graph." + lines = [f"Package '{self.package_name}' has {len(self.problems)} problem(s):"] + for problem in self.problems: + lines.append(f" [{problem.category}] {problem.message}") + return "\n".join(lines) + + def __repr__(self) -> str: + return ( + f"ValidationReport(package_name={self.package_name!r}, problems={len(self.problems)})" + ) + + +class PackageConflict: + """A single conflict detected between two packages.""" + + def __init__( + self, first_package: str, second_package: str, category: str, name: str, message: str + ): + """Creates a conflict record. + + :param first_package: Name of the first package involved. + :param second_package: Name of the second package involved. + :param category: One of the machine-readable conflict categories. + :param name: The node or dependency name the packages disagree about. + :param message: Human-readable explanation of the conflict. + """ + self.first_package = _validate_string(first_package, "Conflict first_package") + self.second_package = _validate_string(second_package, "Conflict second_package") + _validate_string(category, "Conflict category") + if category not in CONFLICT_CATEGORIES: + raise PackagingError( + f"Unknown conflict category '{category}'. " + f"Valid categories: {sorted(CONFLICT_CATEGORIES)}." + ) + self.category = category + self.name = _validate_string(name, "Conflict name") + self.message = _validate_string(message, "Conflict message") + + def __str__(self) -> str: + return f"[{self.category}] {self.message}" + + def __repr__(self) -> str: + return ( + f"PackageConflict(category={self.category!r}, name={self.name!r}, " + f"first_package={self.first_package!r}, second_package={self.second_package!r})" + ) + + +def validate_package( + package: NodePackage, fn_graph: "graph.FunctionGraph", minimum_version: int = 1 +) -> ValidationReport: + """Validates a package for compatibility against a host dataflow's graph. + + The graph is the one a built driver exposes as ``Driver.graph``. A required + dependency is satisfied by a host node whose output type can be consumed where the + package's expected type is declared, or by a configuration entry of the same name. + A provided node may share a name with a host input node if its output type is + acceptable to the host, but may not collide with a computed host node. + + :param package: The package to validate. + :param fn_graph: The driver's built internal graph, i.e. ``Driver.graph``. + :param minimum_version: The minimum package version the host accepts. Must be an + integer of at least 1. + :return: A report with one problem per incompatibility; empty problems if the + package is compatible. + """ + if not isinstance(package, NodePackage): + raise PackagingError(f"validate_package expects a NodePackage, got {type(package)}.") + if not isinstance(fn_graph, graph.FunctionGraph): + raise PackagingError( + "validate_package expects the driver's built internal graph (Driver.graph), " + f"a FunctionGraph -- got {type(fn_graph)}." + ) + _validate_version(minimum_version, "minimum_version") + problems = [] + if package.version < minimum_version: + problems.append( + ValidationProblem( + "version_too_low", + package.name, + f"Package '{package.name}' has version {package.version}, " + f"but the host requires at least {minimum_version}.", + ) + ) + for node_name, provided_type in package.provides.items(): + if node_name not in fn_graph.nodes: + continue + host_node = fn_graph.nodes[node_name] + if host_node.user_defined: + if not _produced_satisfies(host_node.type, provided_type): + problems.append( + ValidationProblem( + "dependency_type_mismatch", + node_name, + f"Package '{package.name}' provides '{node_name}' with type " + f"{htypes.get_type_as_string(provided_type)}, but the host expects " + f"{htypes.get_type_as_string(host_node.type)}.", + ) + ) + else: + problems.append( + ValidationProblem( + "node_collision", + node_name, + f"Package '{package.name}' provides '{node_name}', " + "which the host graph already computes.", + ) + ) + for dep_name, expected_type in package.requires.items(): + if dep_name in fn_graph.config: + continue + if dep_name not in fn_graph.nodes: + problems.append( + ValidationProblem( + "missing_dependency", + dep_name, + f"Package '{package.name}' requires '{dep_name}', which the host graph " + "does not define and the configuration does not supply.", + ) + ) + continue + produced_type = fn_graph.nodes[dep_name].type + if not _produced_satisfies(expected_type, produced_type): + problems.append( + ValidationProblem( + "dependency_type_mismatch", + dep_name, + f"Package '{package.name}' requires '{dep_name}' with type " + f"{htypes.get_type_as_string(expected_type)}, but the host produces " + f"{htypes.get_type_as_string(produced_type)}.", + ) + ) + for config_key in package.config_keys: + if config_key not in fn_graph.config: + problems.append( + ValidationProblem( + "config_requirement_missing", + config_key, + f"Package '{package.name}' needs configuration key '{config_key}', " + "which the host configuration does not supply.", + ) + ) + return ValidationReport(package.name, problems) + + +def _cross_requirement_conflicts( + provider: NodePackage, requirer: NodePackage, first: NodePackage, second: NodePackage +) -> list[PackageConflict]: + """Finds requirement mismatches where one package provides what the other requires. + + :param provider: The package whose provided nodes are checked. + :param requirer: The package whose requirements are checked. + :param first: The first package of the original pair, for attribution. + :param second: The second package of the original pair, for attribution. + :return: One conflict per mismatched provider/requirer edge. + """ + conflicts = [] + for dep_name, expected_type in requirer.requires.items(): + if dep_name not in provider.provides: + continue + produced_type = provider.provides[dep_name] + if not _produced_satisfies(expected_type, produced_type): + conflicts.append( + PackageConflict( + first.name, + second.name, + "requirement_mismatch", + dep_name, + f"Package '{requirer.name}' requires '{dep_name}' with type " + f"{htypes.get_type_as_string(expected_type)}, but package " + f"'{provider.name}' provides it with type " + f"{htypes.get_type_as_string(produced_type)}.", + ) + ) + return conflicts + + +def find_conflicts(first: NodePackage, second: NodePackage) -> list[PackageConflict]: + """Detects conflicts between two packages. + + Two packages may provide identically named nodes only when the output types are + interchangeable in both directions and the recorded dependency names agree. When + one package provides a node another requires, the provided output type must be + consumable where the expected type is declared. When both packages require the + same external dependency, the expected types must be identical. + + :param first: One package. + :param second: The other package. + :return: Every detected conflict; an empty list if the packages are compatible. + """ + if not isinstance(first, NodePackage): + raise PackagingError(f"find_conflicts expects NodePackage instances, got {type(first)}.") + if not isinstance(second, NodePackage): + raise PackagingError(f"find_conflicts expects NodePackage instances, got {type(second)}.") + conflicts = [] + for node_name in sorted(set(first.provides) & set(second.provides)): + first_type = first.provides[node_name] + second_type = second.provides[node_name] + if not _mutually_compatible(first_type, second_type): + conflicts.append( + PackageConflict( + first.name, + second.name, + "duplicate_node", + node_name, + f"Packages '{first.name}' and '{second.name}' both provide '{node_name}' " + f"with incompatible types {htypes.get_type_as_string(first_type)} and " + f"{htypes.get_type_as_string(second_type)}.", + ) + ) + elif first.dependencies[node_name] != second.dependencies[node_name]: + conflicts.append( + PackageConflict( + first.name, + second.name, + "duplicate_node", + node_name, + f"Packages '{first.name}' and '{second.name}' both provide '{node_name}' " + "with disagreeing dependencies.", + ) + ) + conflicts.extend(_cross_requirement_conflicts(first, second, first, second)) + conflicts.extend(_cross_requirement_conflicts(second, first, first, second)) + for dep_name in sorted(set(first.requires) & set(second.requires)): + if first.requires[dep_name] != second.requires[dep_name]: + conflicts.append( + PackageConflict( + first.name, + second.name, + "requirement_disagreement", + dep_name, + f"Packages '{first.name}' and '{second.name}' both require '{dep_name}' " + f"but expect different types " + f"{htypes.get_type_as_string(first.requires[dep_name])} and " + f"{htypes.get_type_as_string(second.requires[dep_name])}.", + ) + ) + return conflicts + + +def _find_all_conflicts(packages: list[NodePackage]) -> list[PackageConflict]: + """Detects conflicts across every pair of the given packages. + + :param packages: The packages to cross-check. + :return: Every conflict from every pair. + """ + all_conflicts = [] + for index, first in enumerate(packages): + for second in packages[index + 1 :]: + all_conflicts.extend(find_conflicts(first, second)) + return all_conflicts + + +def compose_packages( + packages: Iterable[NodePackage], name: str, *, version: int = None +) -> NodePackage: + """Composes multiple packages into a single package. + + Every pair of packages is checked for conflicts before any merging happens, so + composition either fails with the complete set of conflicts or succeeds with a + result that is identical for any ordering of the input packages. Requirements + satisfied by another package's provided nodes become internal edges of the + composed package. + + :param packages: The packages to compose. Must contain at least one package. + :param name: Name for the composed package. + :param version: Version for the composed package. Defaults to the highest version + among the composed packages. + :return: The composed package. + """ + materialized = list(packages) + for package in materialized: + if not isinstance(package, NodePackage): + raise PackagingError( + f"compose_packages expects NodePackage instances, got {type(package)}." + ) + if not materialized: + raise PackagingError("compose_packages requires at least one package.") + _validate_string(name, "Composed package name") + if version is not None: + _validate_version(version, "Composed package version") + all_conflicts = _find_all_conflicts(materialized) + if all_conflicts: + raise PackageConflictError(all_conflicts) + provides = {} + dependencies = {} + requires = {} + config_keys = set() + for package in materialized: + provides.update(package.provides) + dependencies.update(package.dependencies) + requires.update(package.requires) + config_keys.update(package.config_keys) + external_requires = { + dep_name: expected_type + for dep_name, expected_type in requires.items() + if dep_name not in provides + } + return NodePackage( + name, + version=version if version is not None else max(p.version for p in materialized), + provides=provides, + requires=external_requires, + dependencies=dependencies, + config_keys=config_keys, + ) + + +def _mapping_diff( + old: Mapping[str, str], new: Mapping[str, str] +) -> tuple[dict[str, str], dict[str, str], dict[str, tuple[str, str]]]: + """Computes the added, removed, and changed entries between two sorted mappings. + + :param old: The mapping from the older manifest. + :param new: The mapping from the newer manifest. + :return: Added entries, removed entries, and changed entries as name to + (old rendering, new rendering). + """ + added = {name: new[name] for name in sorted(set(new) - set(old))} + removed = {name: old[name] for name in sorted(set(old) - set(new))} + changed = { + name: (old[name], new[name]) + for name in sorted(set(old) & set(new)) + if old[name] != new[name] + } + return added, removed, changed + + +class ManifestDiff: + """The structural differences between two package manifests.""" + + def __init__(self, old: PackageManifest, new: PackageManifest): + """Computes the diff between two manifests. + + :param old: The manifest treated as the older revision. + :param new: The manifest treated as the newer revision. + """ + if not isinstance(old, PackageManifest): + raise PackagingError( + f"ManifestDiff expects PackageManifest instances, got {type(old)}." + ) + if not isinstance(new, PackageManifest): + raise PackagingError( + f"ManifestDiff expects PackageManifest instances, got {type(new)}." + ) + self.added_nodes, self.removed_nodes, self.changed_nodes = _mapping_diff( + old.provides, new.provides + ) + self.added_requirements, self.removed_requirements, self.changed_requirements = ( + _mapping_diff(old.requires, new.requires) + ) + self.added_config_keys = tuple(sorted(set(new.config_keys) - set(old.config_keys))) + self.removed_config_keys = tuple(sorted(set(old.config_keys) - set(new.config_keys))) + + @property + def has_changes(self) -> bool: + """Whether the two manifests differ on any of the compared axes.""" + return any( + [ + self.added_nodes, + self.removed_nodes, + self.changed_nodes, + self.added_requirements, + self.removed_requirements, + self.changed_requirements, + self.added_config_keys, + self.removed_config_keys, + ] + ) + + +def diff_manifests(old: PackageManifest, new: PackageManifest) -> ManifestDiff: + """Computes the structural differences between two package manifests. + + :param old: The manifest treated as the older revision. + :param new: The manifest treated as the newer revision. + :return: The diff between the two manifests. + """ + return ManifestDiff(old, new) + + +def export_package(package: NodePackage, path: str | pathlib.Path) -> None: + """Exports a package's manifest to a JSON file. + + :param package: The package to export. + :param path: The file path to write the manifest to. + """ + if not isinstance(package, NodePackage): + raise PackagingError(f"export_package expects a NodePackage, got {type(package)}.") + pathlib.Path(path).write_text(package.manifest().to_json() + "\n", encoding="utf-8") + + +def import_manifest(path: str | pathlib.Path) -> PackageManifest: + """Imports a package manifest from a JSON file written by :func:`export_package`. + + :param path: The file path to read the manifest from. + :return: The imported manifest. + """ + return PackageManifest.from_json(pathlib.Path(path).read_text(encoding="utf-8")) diff --git a/tests/test_pkg.py b/tests/test_pkg.py new file mode 100644 index 000000000..cfbce1eb4 --- /dev/null +++ b/tests/test_pkg.py @@ -0,0 +1,1593 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +import functools +import itertools +import json +import sys +import typing + +import pytest + +from hamilton import ad_hoc_utils, async_driver, driver, htypes +from hamilton import function_modifiers as fm + +try: + from hamilton import packaging + from hamilton.packaging import ( + CONFLICT_CATEGORIES, + MANIFEST_FIELDS, + VALIDATION_CATEGORIES, + ManifestDiff, + NodePackage, + PackageConflict, + PackageConflictError, + PackageManifest, + PackageValidationError, + PackagingError, + ValidationProblem, + ValidationReport, + compose_packages, + diff_manifests, + export_package, + find_conflicts, + import_manifest, + validate_package, + ) +except ImportError: + packaging = None + CONFLICT_CATEGORIES = None + MANIFEST_FIELDS = None + VALIDATION_CATEGORIES = None + ManifestDiff = None + NodePackage = None + PackageConflict = None + PackageConflictError = None + PackageManifest = None + PackageValidationError = None + PackagingError = None + ValidationProblem = None + ValidationReport = None + compose_packages = None + diff_manifests = None + export_package = None + find_conflicts = None + import_manifest = None + validate_package = None + + +def base_int() -> int: + return 1 + + +def doubled(base_int: int) -> int: + return base_int * 2 + + +def as_float(doubled: int) -> float: + return float(doubled) + + +def scaled(base_int: int, factor: int) -> int: + return base_int * factor + + +def with_default(base_int: int, offset: int = 3) -> int: + return base_int + offset + + +def needs_input(external_num: int) -> int: + return external_num + 1 + + +def uses_flag_input(external_flag: bool) -> int: + return int(external_flag) + + +def optional_source() -> int: + return 9 + + +def optional_consumer(base_int: int, optional_source: int = 5) -> int: + return base_int + optional_source + + +def any_node() -> typing.Any: + return 1 + + +def consumes_any(any_node: int) -> int: + return any_node + + +def listy_node() -> list[int]: + return [1, 2] + + +def flag_node() -> bool: + return True + + +def from_flag(flag_node: int) -> int: + return flag_node + 1 + + +def _build_driver(*fns, config=None): + module = ad_hoc_utils.create_temporary_module(*fns) + return driver.Builder().with_modules(module).with_config(config or {}).build() + + +def _simple_package(name="pkg", **overrides): + kwargs = dict( + version=1, + provides={"doubled": int, "as_float": float}, + requires={"base_int": int}, + dependencies={"doubled": ["base_int"], "as_float": ["doubled"]}, + config_keys=["factor"], + ) + kwargs.update(overrides) + return NodePackage(name, **kwargs) + + +def chain_step(prev: float) -> float: + return prev + 1.0 + + +@functools.lru_cache(maxsize=1) +def _deep_chain_state(): + chain_size = sys.getrecursionlimit() + 200 + parameterization = {} + for i in range(chain_size): + parameterization[f"node_{i}"] = { + "prev": fm.source(f"node_{i - 1}") if i > 0 else fm.value(0.0) + } + decorated = fm.parameterize(**parameterization)(chain_step) + module = ad_hoc_utils.create_temporary_module(decorated, module_name="pkg_large_chain") + dr = driver.Builder().with_modules(module).build() + return chain_size, dr + + +class TestNodePackageConstructor: + def test_defaults_via_empty_manifest(self): + package = NodePackage("empty") + assert package.manifest().to_dict() == { + "name": "empty", + "version": 1, + "provides": {}, + "requires": {}, + "dependencies": {}, + "config_keys": [], + } + + def test_attributes_exposed(self): + package = _simple_package() + assert package.name == "pkg" + assert package.version == 1 + assert set(package.provides) == {"doubled", "as_float"} + assert package.provides["doubled"] is int + assert set(package.requires) == {"base_int"} + assert set(package.config_keys) == {"factor"} + assert set(package.dependencies["doubled"]) == {"base_int"} + + def test_non_string_name(self): + with pytest.raises(PackagingError): + NodePackage(123) + + def test_empty_name(self): + with pytest.raises(PackagingError): + NodePackage("") + + def test_string_version(self): + with pytest.raises(PackagingError): + NodePackage("p", version="2") + + def test_zero_version(self): + with pytest.raises(PackagingError): + NodePackage("p", version=0) + + def test_bool_version(self): + with pytest.raises(PackagingError): + NodePackage("p", version=True) + + def test_float_version(self): + with pytest.raises(PackagingError): + NodePackage("p", version=1.5) + + def test_provides_not_a_mapping(self): + with pytest.raises(PackagingError): + NodePackage("p", provides=["a"]) + + def test_provides_non_string_key(self): + with pytest.raises(PackagingError): + NodePackage("p", provides={1: int}) + + def test_provides_string_value(self): + with pytest.raises(PackagingError): + NodePackage("p", provides={"a": "int"}) + + def test_provides_int_value(self): + with pytest.raises(PackagingError): + NodePackage("p", provides={"a": 5}) + + def test_provides_generic_and_any_values(self): + package = NodePackage("p", provides={"a": list[int], "b": typing.Any}) + assert set(package.provides) == {"a", "b"} + + def test_requires_not_a_mapping(self): + with pytest.raises(PackagingError): + NodePackage("p", requires=[("a", int)]) + + def test_requires_non_string_key(self): + with pytest.raises(PackagingError): + NodePackage("p", requires={2: int}) + + def test_requires_string_value(self): + with pytest.raises(PackagingError): + NodePackage("p", requires={"a": "float"}) + + def test_provides_requires_overlap(self): + with pytest.raises(PackagingError): + NodePackage("p", provides={"a": int}, requires={"a": int}) + + def test_config_keys_plain_string(self): + with pytest.raises(PackagingError): + NodePackage("p", config_keys="factor") + + def test_config_keys_non_string_entry(self): + with pytest.raises(PackagingError): + NodePackage("p", config_keys=["factor", 7]) + + def test_config_keys_generator_single_pass(self): + package = NodePackage("p", config_keys=(key for key in ["b_key", "a_key"])) + assert set(package.config_keys) == {"a_key", "b_key"} + + def test_dependencies_not_a_mapping(self): + with pytest.raises(PackagingError): + NodePackage("p", provides={"a": int}, dependencies=["a"]) + + def test_dependencies_key_not_provided(self): + with pytest.raises(PackagingError): + NodePackage("p", provides={"a": int}, dependencies={"b": []}) + + def test_dependencies_unknown_target(self): + with pytest.raises(PackagingError): + NodePackage("p", provides={"a": int}, dependencies={"a": ["mystery"]}) + + def test_dependencies_non_iterable_value(self): + with pytest.raises(PackagingError): + NodePackage("p", provides={"a": int}, dependencies={"a": 5}) + + def test_dependencies_generator_single_pass(self): + package = NodePackage( + "p", + provides={"x": int}, + requires={"a": int, "b": int}, + dependencies={"x": (name for name in ["b", "a"])}, + ) + assert set(package.dependencies["x"]) == {"a", "b"} + + def test_dependencies_may_reference_all_boundary_kinds(self): + package = NodePackage( + "p", + provides={"a": int, "b": int}, + requires={"ext": int}, + config_keys=["cfg"], + dependencies={"b": ["a", "ext", "cfg"]}, + ) + assert set(package.dependencies["b"]) == {"a", "ext", "cfg"} + assert not package.dependencies.get("a") + + +class TestFromGraph: + def test_rejects_hamilton_graph(self): + dr = _build_driver(base_int, doubled) + with pytest.raises(PackagingError): + NodePackage.from_graph(dr.get_graph(), ["doubled"], "p") + + def test_rejects_plain_dict(self): + with pytest.raises(PackagingError): + NodePackage.from_graph({}, ["doubled"], "p") + + def test_basic_selection(self): + dr = _build_driver(base_int, doubled, as_float) + package = NodePackage.from_graph(dr.graph, ["doubled", "as_float"], "p") + assert set(package.provides) == {"doubled", "as_float"} + assert package.provides["as_float"] is float + assert set(package.requires) == {"base_int"} + assert package.requires["base_int"] is int + assert set(package.dependencies["doubled"]) == {"base_int"} + assert set(package.dependencies["as_float"]) == {"doubled"} + + def test_unknown_node(self): + dr = _build_driver(base_int) + with pytest.raises(PackagingError): + NodePackage.from_graph(dr.graph, ["nonexistent"], "p") + + def test_input_node_rejected(self): + dr = _build_driver(needs_input) + with pytest.raises(PackagingError): + NodePackage.from_graph(dr.graph, ["external_num"], "p") + + def test_empty_selection(self): + dr = _build_driver(base_int) + with pytest.raises(PackagingError): + NodePackage.from_graph(dr.graph, [], "p") + + def test_non_iterable_selection(self): + dr = _build_driver(base_int) + with pytest.raises(PackagingError): + NodePackage.from_graph(dr.graph, 5, "p") + + def test_non_bool_include_upstream(self): + dr = _build_driver(base_int) + with pytest.raises(PackagingError): + NodePackage.from_graph(dr.graph, ["base_int"], "p", include_upstream=1) + + def test_include_upstream_expands_selection(self): + dr = _build_driver(base_int, doubled, as_float) + package = NodePackage.from_graph(dr.graph, ["as_float"], "p", include_upstream=True) + assert set(package.provides) == {"base_int", "doubled", "as_float"} + assert not package.requires + + def test_include_upstream_shared_ancestor_once(self): + dr = _build_driver(base_int, doubled, as_float, with_default) + package = NodePackage.from_graph( + dr.graph, ["as_float", "with_default"], "p", include_upstream=True + ) + assert set(package.provides) == {"base_int", "doubled", "as_float", "with_default"} + assert not package.requires + order = package.dependency_order() + assert order.index("base_int") < order.index("doubled") < order.index("as_float") + + def test_include_upstream_skips_optional_computed_producer(self): + dr = _build_driver(base_int, optional_source, optional_consumer) + package = NodePackage.from_graph( + dr.graph, ["optional_consumer"], "p", include_upstream=True + ) + assert "optional_source" not in package.provides + assert set(package.provides) == {"base_int", "optional_consumer"} + + def test_include_upstream_stops_at_inputs(self): + dr = _build_driver(needs_input, doubled) + package = NodePackage.from_graph(dr.graph, ["needs_input"], "p", include_upstream=True) + assert set(package.provides) == {"needs_input"} + assert set(package.requires) == {"external_num"} + + def test_config_dependency_recorded_as_config_key(self): + dr = _build_driver(base_int, scaled, config={"factor": 2}) + package = NodePackage.from_graph(dr.graph, ["scaled"], "p") + assert set(package.config_keys) == {"factor"} + assert "factor" not in package.requires + assert set(package.requires) == {"base_int"} + assert set(package.dependencies["scaled"]) == {"base_int", "factor"} + + def test_requirement_typed_by_producing_node(self): + dr = _build_driver(flag_node, from_flag) + package = NodePackage.from_graph(dr.graph, ["from_flag"], "p") + assert package.requires["flag_node"] is bool + + def test_config_key_edge_survives_manifest_round_trip(self): + dr = _build_driver(base_int, scaled, config={"factor": 2}) + package = NodePackage.from_graph(dr.graph, ["scaled"], "p") + data = package.manifest().to_dict() + assert set(data["dependencies"]["scaled"]) == {"base_int", "factor"} + assert set(data["config_keys"]) == {"factor"} + assert PackageManifest.from_dict(data) == package.manifest() + + def test_optional_parameter_ignored(self): + dr = _build_driver(base_int, with_default) + package = NodePackage.from_graph(dr.graph, ["with_default"], "p") + assert "offset" not in package.requires + assert set(package.dependencies["with_default"]) == {"base_int"} + + def test_version_passed_through(self): + dr = _build_driver(base_int) + package = NodePackage.from_graph(dr.graph, ["base_int"], "p", version=4) + assert package.version == 4 + + def test_selection_accepts_generator(self): + dr = _build_driver(base_int, doubled) + package = NodePackage.from_graph(dr.graph, (n for n in ["doubled"]), "p") + assert set(package.provides) == {"doubled"} + + def test_selection_names_must_be_strings(self): + dr = _build_driver(base_int) + with pytest.raises(PackagingError): + NodePackage.from_graph(dr.graph, [1], "p") + + def test_deep_chain_no_recursion_error(self): + chain_size, dr = _deep_chain_state() + final_node = f"node_{chain_size - 1}" + package = NodePackage.from_graph(dr.graph, [final_node], "deep", include_upstream=True) + assert len(package.provides) == chain_size + order = package.dependency_order() + assert len(order) == chain_size + assert order[0] == "node_0" + assert order[-1] == final_node + + +class TestDependencyOrder: + def test_dependencies_before_dependents(self): + package = _simple_package() + order = package.dependency_order() + assert set(order) == {"doubled", "as_float"} + assert order.index("doubled") < order.index("as_float") + + def test_alphabetical_tie_break(self): + package = NodePackage("p", provides={"zeta": int, "alpha": int}) + assert package.dependency_order() == ["alpha", "zeta"] + + def test_empty_package(self): + assert NodePackage("p").dependency_order() == [] + + def test_cycle_detected(self): + package = NodePackage( + "p", + provides={"a": int, "b": int}, + dependencies={"a": ["b"], "b": ["a"]}, + ) + with pytest.raises(PackagingError): + package.dependency_order() + + def test_manifest_cycle_detected(self): + manifest = PackageManifest.from_dict( + { + "name": "m", + "version": 1, + "provides": {"a": "int", "b": "int"}, + "requires": {}, + "dependencies": {"a": ["b"], "b": ["a"]}, + "config_keys": [], + } + ) + with pytest.raises(PackagingError): + manifest.dependency_order() + + def test_imported_manifest_dependency_order(self, tmp_path): + package = _simple_package() + path = tmp_path / "ordered.json" + export_package(package, path) + order = import_manifest(path).dependency_order() + assert set(order) == {"doubled", "as_float"} + assert order.index("doubled") < order.index("as_float") + + def test_manifest_dependency_order_matches_package(self): + package = _simple_package() + assert package.manifest().dependency_order() == package.dependency_order() + + +class TestManifest: + def test_manifest_is_package_manifest(self): + assert isinstance(_simple_package().manifest(), PackageManifest) + + def test_type_strings_use_get_type_as_string(self): + package = NodePackage( + "p", + provides={"a": int, "b": list[int]}, + requires={"c": dict[str, float]}, + dependencies={"a": ["c"]}, + ) + data = package.manifest().to_dict() + assert data["provides"]["a"] == htypes.get_type_as_string(int) + assert data["provides"]["b"] == htypes.get_type_as_string(list[int]) + assert data["requires"]["c"] == htypes.get_type_as_string(dict[str, float]) + + def test_mappings_sorted(self): + package = NodePackage( + "p", + provides={"zeta": int, "alpha": int, "mid": int}, + requires={"z_req": int, "a_req": int}, + dependencies={"mid": ["zeta", "a_req"]}, + ) + data = package.manifest().to_dict() + assert list(data["provides"]) == sorted(data["provides"]) + assert list(data["requires"]) == sorted(data["requires"]) + assert list(data["dependencies"]) == sorted(data["dependencies"]) + assert data["dependencies"]["mid"] == sorted(data["dependencies"]["mid"]) + assert data["config_keys"] == sorted(data["config_keys"]) + + def test_json_round_trip(self): + manifest = _simple_package().manifest() + assert json.loads(manifest.to_json()) == manifest.to_dict() + + def test_from_dict_round_trip(self): + manifest = _simple_package().manifest() + assert PackageManifest.from_dict(manifest.to_dict()) == manifest + + def test_from_json_round_trip(self): + manifest = _simple_package().manifest() + reloaded = PackageManifest.from_json(manifest.to_json()) + assert reloaded == manifest + assert reloaded.to_dict() == manifest.to_dict() + + def test_from_dict_unknown_field(self): + data = _simple_package().manifest().to_dict() + data["surprise"] = 1 + with pytest.raises(PackagingError): + PackageManifest.from_dict(data) + + def test_from_dict_missing_field(self): + data = _simple_package().manifest().to_dict() + del data["provides"] + with pytest.raises(PackagingError): + PackageManifest.from_dict(data) + + def test_from_dict_dependency_target_non_string(self): + data = _simple_package().manifest().to_dict() + data["dependencies"]["doubled"] = ["base_int", 7] + with pytest.raises(PackagingError): + PackageManifest.from_dict(data) + + def test_from_dict_config_keys_bare_string(self): + data = _simple_package().manifest().to_dict() + data["config_keys"] = "factor" + with pytest.raises(PackagingError): + PackageManifest.from_dict(data) + + def test_from_json_malformed_inner_payload(self): + data = _simple_package().manifest().to_dict() + data["dependencies"]["doubled"] = ["base_int", 7] + with pytest.raises(PackagingError): + PackageManifest.from_json(json.dumps(data)) + + def test_from_dict_non_mapping(self): + with pytest.raises(PackagingError): + PackageManifest.from_dict([1, 2]) + + def test_from_json_non_string(self): + with pytest.raises(PackagingError): + PackageManifest.from_json(42) + + def test_from_json_malformed(self): + with pytest.raises(PackagingError): + PackageManifest.from_json("{not valid json") + + def test_constructor_non_string_name(self): + with pytest.raises(PackagingError): + PackageManifest(9, 1, {}, {}, {}, []) + + def test_constructor_bad_version(self): + with pytest.raises(PackagingError): + PackageManifest("m", "1", {}, {}, {}, []) + + def test_constructor_provides_non_mapping(self): + with pytest.raises(PackagingError): + PackageManifest("m", 1, ["a"], {}, {}, []) + + def test_constructor_provides_requires_overlap(self): + with pytest.raises(PackagingError): + PackageManifest("m", 1, {"a": "int"}, {"a": "int"}, {}, []) + + def test_constructor_requires_non_string_key(self): + with pytest.raises(PackagingError): + PackageManifest("m", 1, {}, {3: "int"}, {}, []) + + def test_constructor_dependencies_non_mapping(self): + with pytest.raises(PackagingError): + PackageManifest("m", 1, {"a": "int"}, {}, ["a"], []) + + def test_constructor_dependencies_key_not_provided(self): + with pytest.raises(PackagingError): + PackageManifest("m", 1, {"a": "int"}, {}, {"b": []}, []) + + def test_constructor_dependencies_unknown_target(self): + with pytest.raises(PackagingError): + PackageManifest("m", 1, {"a": "int"}, {}, {"a": ["ghost"]}, []) + + def test_constructor_config_keys_plain_string(self): + with pytest.raises(PackagingError): + PackageManifest("m", 1, {}, {}, {}, "cfg") + + def test_equality_negative_on_version(self): + first = NodePackage("p", version=1).manifest() + second = NodePackage("p", version=2).manifest() + assert first != second + + def test_equality_negative_on_other_type(self): + assert NodePackage("p").manifest() != {"name": "p"} + + def test_package_equality(self): + assert _simple_package() == _simple_package() + assert _simple_package() != _simple_package(version=2) + assert _simple_package() != "pkg" + + +class TestExportImport: + def test_round_trip(self, tmp_path): + package = _simple_package() + path = tmp_path / "pkg.json" + export_package(package, path) + imported = import_manifest(path) + assert imported == package.manifest() + + def test_exported_file_is_json(self, tmp_path): + package = _simple_package() + path = tmp_path / "pkg.json" + export_package(package, str(path)) + with open(path, encoding="utf-8") as f: + assert json.load(f) == package.manifest().to_dict() + + def test_round_trip_with_string_path(self, tmp_path): + package = _simple_package() + path = tmp_path / "pkg_str.json" + export_package(package, str(path)) + assert import_manifest(str(path)) == package.manifest() + + def test_export_rejects_non_package(self, tmp_path): + with pytest.raises(PackagingError): + export_package({"name": "p"}, tmp_path / "pkg.json") + + def test_import_unknown_field(self, tmp_path): + data = _simple_package().manifest().to_dict() + data["surprise"] = 1 + path = tmp_path / "unknown.json" + path.write_text(json.dumps(data), encoding="utf-8") + with pytest.raises(PackagingError): + import_manifest(path) + + def test_import_semantically_invalid_dependency(self, tmp_path): + data = _simple_package().manifest().to_dict() + data["dependencies"]["not_provided"] = [] + path = tmp_path / "bad_dep.json" + path.write_text(json.dumps(data), encoding="utf-8") + with pytest.raises(PackagingError): + import_manifest(path) + + def test_import_missing_field(self, tmp_path): + data = _simple_package().manifest().to_dict() + del data["provides"] + path = tmp_path / "missing.json" + path.write_text(json.dumps(data), encoding="utf-8") + with pytest.raises(PackagingError): + import_manifest(path) + + def test_import_malformed_json(self, tmp_path): + path = tmp_path / "broken.json" + path.write_text("{not valid json", encoding="utf-8") + with pytest.raises(PackagingError): + import_manifest(path) + + def test_import_hand_written_manifest(self, tmp_path): + data = { + "name": "hand", + "version": 2, + "provides": {"a": "int"}, + "requires": {"b": "float"}, + "dependencies": {"a": ["b"]}, + "config_keys": ["cfg"], + } + path = tmp_path / "hand.json" + path.write_text(json.dumps(data), encoding="utf-8") + manifest = import_manifest(path) + assert manifest.name == "hand" + assert manifest.version == 2 + assert manifest.to_dict() == data + + +class TestValidatePackage: + def test_rejects_non_package(self): + dr = _build_driver(base_int) + with pytest.raises(PackagingError): + validate_package("pkg", dr.graph) + + def test_rejects_hamilton_graph(self): + dr = _build_driver(base_int) + package = NodePackage("p", requires={"base_int": int}) + with pytest.raises(PackagingError): + validate_package(package, dr.get_graph()) + + def test_rejects_non_int_minimum_version(self): + dr = _build_driver(base_int) + with pytest.raises(PackagingError): + validate_package(NodePackage("p"), dr.graph, minimum_version="1") + + def test_rejects_bool_minimum_version(self): + dr = _build_driver(base_int) + with pytest.raises(PackagingError): + validate_package(NodePackage("p"), dr.graph, minimum_version=True) + + def test_rejects_zero_minimum_version(self): + dr = _build_driver(base_int) + with pytest.raises(PackagingError): + validate_package(NodePackage("p"), dr.graph, minimum_version=0) + + def test_compatible_package(self): + dr = _build_driver(base_int) + package = NodePackage( + "p", + provides={"enrichment": float}, + requires={"base_int": int}, + dependencies={"enrichment": ["base_int"]}, + ) + report = validate_package(package, dr.graph) + assert isinstance(report, ValidationReport) + assert report.package_name == "p" + assert report.is_valid is True + assert not report.problems + + def test_missing_dependency_flagged(self): + dr = _build_driver(base_int) + package = NodePackage("p", requires={"absent_upstream": int}) + report = validate_package(package, dr.graph) + assert report.is_valid is False + by_name = {problem.name: problem for problem in report.problems} + assert by_name["absent_upstream"].category == "missing_dependency" + + def test_missing_dependency_not_flagged_when_present(self): + dr = _build_driver(base_int) + package = NodePackage("p", requires={"base_int": int}) + report = validate_package(package, dr.graph) + assert report.is_valid is True + + def test_conflict_mismatch_message_names_offender(self): + provider = NodePackage("a", provides={"shared": int}) + requirer = NodePackage("b", requires={"shared": bool}) + conflicts = find_conflicts(provider, requirer) + by_name = {c.name: c for c in conflicts} + assert "shared" in str(by_name["shared"]) + + def test_requirement_type_mismatch_flagged(self): + dr = _build_driver(base_int) + package = NodePackage("p", requires={"base_int": bool}) + report = validate_package(package, dr.graph) + by_name = {problem.name: problem for problem in report.problems} + assert by_name["base_int"].category == "dependency_type_mismatch" + + def test_bool_output_feeds_int_requirement(self): + dr = _build_driver(flag_node) + package = NodePackage("p", requires={"flag_node": int}) + report = validate_package(package, dr.graph) + assert report.is_valid is True + + def test_provided_bool_feeds_int_host_input(self): + dr = _build_driver(needs_input) + package = NodePackage("p", provides={"external_num": bool}) + report = validate_package(package, dr.graph) + assert report.is_valid is True + + def test_provided_int_cannot_feed_bool_host_input(self): + dr = _build_driver(uses_flag_input) + package = NodePackage("p", provides={"external_flag": int}) + report = validate_package(package, dr.graph) + by_name = {problem.name: problem for problem in report.problems} + assert by_name["external_flag"].category == "dependency_type_mismatch" + + def test_generic_alias_requirement_matches(self): + dr = _build_driver(listy_node) + assert validate_package( + NodePackage("p", requires={"listy_node": list[int]}), dr.graph + ).is_valid + mismatched = validate_package( + NodePackage("p", requires={"listy_node": dict[str, int]}), dr.graph + ) + by_name = {problem.name: problem for problem in mismatched.problems} + assert by_name["listy_node"].category == "dependency_type_mismatch" + + def test_any_host_producer_satisfies_typed_requirement(self): + dr = _build_driver(any_node, consumes_any) + report = validate_package(NodePackage("p", requires={"any_node": int}), dr.graph) + assert report.is_valid is True + + def test_any_provided_satisfies_typed_host_input(self): + dr = _build_driver(needs_input) + report = validate_package(NodePackage("p", provides={"external_num": typing.Any}), dr.graph) + assert report.is_valid is True + + def test_any_requirement_accepts_any_producer(self): + dr = _build_driver(listy_node) + report = validate_package(NodePackage("p", requires={"listy_node": typing.Any}), dr.graph) + assert report.is_valid is True + + def test_node_collision_flagged(self): + dr = _build_driver(base_int) + package = NodePackage("p", provides={"base_int": int}) + report = validate_package(package, dr.graph) + by_name = {problem.name: problem for problem in report.problems} + assert by_name["base_int"].category == "node_collision" + + def test_provided_type_checked_against_config_host_input(self): + dr = _build_driver(base_int, scaled, config={"factor": 2}) + report = validate_package(NodePackage("p", provides={"factor": str}), dr.graph) + by_name = {problem.name: problem for problem in report.problems} + assert by_name["factor"].category == "dependency_type_mismatch" + + def test_provided_matching_type_over_config_host_input_ok(self): + dr = _build_driver(base_int, scaled, config={"factor": 2}) + report = validate_package(NodePackage("p", provides={"factor": int}), dr.graph) + assert report.is_valid is True + + def test_no_collision_for_fresh_name(self): + dr = _build_driver(base_int) + package = NodePackage("p", provides={"brand_new": int}) + report = validate_package(package, dr.graph) + assert report.is_valid is True + + def test_config_requirement_missing_flagged(self): + dr = _build_driver(base_int) + package = NodePackage("p", config_keys=["absent_key"]) + report = validate_package(package, dr.graph) + by_name = {problem.name: problem for problem in report.problems} + assert by_name["absent_key"].category == "config_requirement_missing" + + def test_config_requirement_present(self): + dr = _build_driver(base_int, scaled, config={"factor": 2}) + package = NodePackage("p", config_keys=["factor"]) + report = validate_package(package, dr.graph) + assert report.is_valid is True + + def test_undeclared_dependency_satisfied_by_config(self): + dr = _build_driver(base_int, scaled, config={"factor": 2}) + package = NodePackage("p", requires={"factor": int}) + report = validate_package(package, dr.graph) + assert report.is_valid is True + + def test_config_satisfies_requirement_regardless_of_type(self): + dr = _build_driver(base_int, scaled, config={"factor": 2}) + package = NodePackage("p", requires={"factor": str}) + report = validate_package(package, dr.graph) + assert report.is_valid is True + + def test_mixed_problems_aggregate(self): + dr = _build_driver(base_int, scaled, config={"factor": 2}) + package = NodePackage( + "p", + provides={"scaled": int}, + requires={"factor": int, "truly_missing": int, "base_int": bool}, + config_keys=["factor", "missing_key"], + ) + report = validate_package(package, dr.graph) + assert report.is_valid is False + assert len(report.problems) >= 3 + by_name = {problem.name: problem for problem in report.problems} + assert by_name["truly_missing"].category == "missing_dependency" + assert by_name["base_int"].category == "dependency_type_mismatch" + assert by_name["missing_key"].category == "config_requirement_missing" + assert by_name["scaled"].category == "node_collision" + assert "factor" not in by_name + + def test_version_too_low(self): + dr = _build_driver(base_int) + package = NodePackage("p", version=2) + report = validate_package(package, dr.graph, minimum_version=3) + by_name = {problem.name: problem for problem in report.problems} + assert by_name["p"].category == "version_too_low" + assert "2" in by_name["p"].message + assert "3" in by_name["p"].message + + def test_problem_attributes_populated_by_validation(self): + dr = _build_driver(base_int) + package = NodePackage("attr_pkg", requires={"absent_one": int}) + report = validate_package(package, dr.graph) + problem = report.problems[0] + assert problem.category in set(VALIDATION_CATEGORIES) + assert problem.name == "absent_one" + assert isinstance(problem.message, str) + + def test_version_boundary_equal_passes(self): + dr = _build_driver(base_int) + package = NodePackage("p", version=3) + report = validate_package(package, dr.graph, minimum_version=3) + assert report.is_valid is True + + def test_optional_parameter_not_flagged(self): + dr = _build_driver(base_int) + host = _build_driver(base_int, with_default) + package = NodePackage.from_graph(host.graph, ["with_default"], "p") + report = validate_package(package, dr.graph) + by_name = {problem.name: problem for problem in report.problems} + assert "offset" not in by_name + + def test_report_str_contains_offenders(self): + dr = _build_driver(base_int) + package = NodePackage("lonely_pkg", requires={"vanished": int}) + report = validate_package(package, dr.graph) + assert "lonely_pkg" in str(report) + + def test_valid_report_str(self): + dr = _build_driver(base_int) + report = validate_package(NodePackage("wholesome"), dr.graph) + assert "wholesome" in str(report) + + def test_categories_constant(self): + assert set(VALIDATION_CATEGORIES) == { + "version_too_low", + "node_collision", + "dependency_type_mismatch", + "missing_dependency", + "config_requirement_missing", + } + + +class TestReportObjects: + def test_problem_attributes(self): + problem = ValidationProblem("missing_dependency", "x", "x is missing") + assert problem.category == "missing_dependency" + assert problem.name == "x" + assert problem.message == "x is missing" + + def test_problem_unknown_category(self): + with pytest.raises(PackagingError): + ValidationProblem("bogus_category", "x", "message") + + def test_problem_non_string_category(self): + with pytest.raises(PackagingError): + ValidationProblem(1, "x", "message") + + def test_problem_non_string_name(self): + with pytest.raises(PackagingError): + ValidationProblem("missing_dependency", None, "message") + + def test_problem_non_string_message(self): + with pytest.raises(PackagingError): + ValidationProblem("missing_dependency", "x", 5) + + def test_report_non_string_package_name(self): + with pytest.raises(PackagingError): + ValidationReport(1, []) + + def test_report_non_problem_entries(self): + with pytest.raises(PackagingError): + ValidationReport("p", ["oops"]) + + def test_report_from_problem_list(self): + problems = [ + ValidationProblem("missing_dependency", "a", "gone"), + ValidationProblem("missing_dependency", "b", "gone"), + ] + report = ValidationReport("p", problems) + assert len(report.problems) >= 2 + assert report.is_valid is False + + def test_is_valid_is_boolean(self): + empty = ValidationReport("p", []) + assert isinstance(empty.is_valid, bool) + full = ValidationReport("p", [ValidationProblem("missing_dependency", "x", "x gone")]) + assert isinstance(full.is_valid, bool) + + def test_report_str_contains_problem_messages(self): + problems = [ + ValidationProblem("missing_dependency", "one", "first thing went missing"), + ValidationProblem("config_requirement_missing", "two", "second thing is not set"), + ] + report = ValidationReport("stringy_pkg", problems) + rendered = str(report) + assert "stringy_pkg" in rendered + assert "first thing went missing" in rendered + assert "second thing is not set" in rendered + + def test_empty_report_valid(self): + report = ValidationReport("p", []) + assert report.is_valid is True + assert not report.problems + + +class TestConflictObjects: + def test_attributes(self): + conflict = PackageConflict("a", "b", "duplicate_node", "x", "x clashes") + assert conflict.first_package == "a" + assert conflict.second_package == "b" + assert conflict.category == "duplicate_node" + assert conflict.name == "x" + assert conflict.message == "x clashes" + + def test_unknown_category(self): + with pytest.raises(PackagingError): + PackageConflict("a", "b", "bogus", "x", "message") + + def test_non_string_category(self): + with pytest.raises(PackagingError): + PackageConflict("a", "b", 3, "x", "message") + + def test_non_string_name(self): + with pytest.raises(PackagingError): + PackageConflict("a", "b", "duplicate_node", 3, "message") + + def test_non_string_package_names(self): + with pytest.raises(PackagingError): + PackageConflict(None, "b", "duplicate_node", "x", "message") + with pytest.raises(PackagingError): + PackageConflict("a", 4, "duplicate_node", "x", "message") + + def test_str_contains_name(self): + conflict = PackageConflict("a", "b", "duplicate_node", "x_marks", "x_marks clashes") + assert "x_marks" in str(conflict) + + def test_categories_constant(self): + assert set(CONFLICT_CATEGORIES) == { + "duplicate_node", + "requirement_mismatch", + "requirement_disagreement", + } + + +class TestFindConflicts: + def test_first_arg_must_be_package(self): + with pytest.raises(PackagingError): + find_conflicts("a", NodePackage("b")) + + def test_second_arg_must_be_package(self): + with pytest.raises(PackagingError): + find_conflicts(NodePackage("a"), None) + + def test_disjoint_packages_no_conflicts(self): + first = NodePackage("a", provides={"x": int}) + second = NodePackage("b", provides={"y": int}) + assert find_conflicts(first, second) == [] + + def test_same_name_same_type_no_conflict(self): + first = NodePackage("a", provides={"x": int}) + second = NodePackage("b", provides={"x": int}) + assert find_conflicts(first, second) == [] + + def test_duplicate_node_type_disagreement_both_orders(self): + first = NodePackage("a", provides={"x": int}) + second = NodePackage("b", provides={"x": str}) + for left, right in [(first, second), (second, first)]: + conflicts = find_conflicts(left, right) + found = {(c.category, c.name) for c in conflicts} + assert ("duplicate_node", "x") in found + + def test_duplicate_node_subtype_still_conflicts(self): + first = NodePackage("a", provides={"x": bool}) + second = NodePackage("b", provides={"x": int}) + for left, right in [(first, second), (second, first)]: + found = {(c.category, c.name) for c in find_conflicts(left, right)} + assert ("duplicate_node", "x") in found + + def test_duplicate_node_dependency_disagreement(self): + first = NodePackage("a", provides={"x": int, "up": int}, dependencies={"x": ["up"]}) + second = NodePackage("b", provides={"x": int, "up": int}, dependencies={"x": []}) + found = {(c.category, c.name) for c in find_conflicts(first, second)} + assert ("duplicate_node", "x") in found + + def test_requirement_mismatch_cross_group_both_orders(self): + provider = NodePackage("a", provides={"x": int}) + requirer = NodePackage("b", requires={"x": bool}) + for left, right in [(provider, requirer), (requirer, provider)]: + found = {(c.category, c.name) for c in find_conflicts(left, right)} + assert ("requirement_mismatch", "x") in found + + def test_requirement_satisfied_by_subtype_no_conflict(self): + provider = NodePackage("a", provides={"x": bool}) + requirer = NodePackage("b", requires={"x": int}) + assert find_conflicts(provider, requirer) == [] + assert find_conflicts(requirer, provider) == [] + + def test_generic_alias_conflicts(self): + provider = NodePackage("a", provides={"x": list[int]}) + matching = NodePackage("b", requires={"x": list[int]}) + assert find_conflicts(provider, matching) == [] + clashing = NodePackage("c", requires={"x": dict[str, int]}) + found = {(c.category, c.name) for c in find_conflicts(provider, clashing)} + assert ("requirement_mismatch", "x") in found + + def test_any_provider_satisfies_typed_requirer(self): + provider = NodePackage("a", provides={"x": typing.Any}) + requirer = NodePackage("b", requires={"x": int}) + assert find_conflicts(provider, requirer) == [] + assert find_conflicts(requirer, provider) == [] + + def test_any_provider_and_requirer_compatible(self): + provider = NodePackage("a", provides={"x": typing.Any}) + requirer = NodePackage("b", requires={"x": typing.Any}) + assert find_conflicts(provider, requirer) == [] + + def test_requirement_disagreement(self): + first = NodePackage("a", requires={"x": int}) + second = NodePackage("b", requires={"x": str}) + for left, right in [(first, second), (second, first)]: + found = {(c.category, c.name) for c in find_conflicts(left, right)} + assert ("requirement_disagreement", "x") in found + + def test_requirement_agreement_no_conflict(self): + first = NodePackage("a", requires={"x": int}) + second = NodePackage("b", requires={"x": int}) + assert find_conflicts(first, second) == [] + + def test_conflict_attribution(self): + first = NodePackage("alpha", provides={"x": int}) + second = NodePackage("beta", provides={"x": str}) + conflicts = find_conflicts(first, second) + for conflict in conflicts: + assert {conflict.first_package, conflict.second_package} == {"alpha", "beta"} + assert conflict.name == "x" + + def test_multiple_conflicts_reported(self): + first = NodePackage("a", provides={"x": int, "y": int}, requires={"z": int}) + second = NodePackage("b", provides={"x": str, "y": str}, requires={"z": str}) + conflicts = find_conflicts(first, second) + assert len(conflicts) >= 3 + names = {c.name for c in conflicts} + assert names == {"x", "y", "z"} + + +class TestComposePackages: + def test_empty_input_rejected(self): + with pytest.raises(PackagingError): + compose_packages([], "combined") + + def test_non_package_entry_rejected(self): + with pytest.raises(PackagingError): + compose_packages([NodePackage("a"), 3], "combined") + + def test_bad_name_rejected(self): + with pytest.raises(PackagingError): + compose_packages([NodePackage("a")], 7) + + def test_bad_version_rejected(self): + with pytest.raises(PackagingError): + compose_packages([NodePackage("a")], "combined", version=0) + + def test_bool_version_rejected(self): + with pytest.raises(PackagingError): + compose_packages([NodePackage("a")], "combined", version=True) + + def test_generator_input(self): + packages = (NodePackage(name, provides={name: int}) for name in ["a", "b"]) + composed = compose_packages(packages, "combined") + assert set(composed.provides) == {"a", "b"} + + def test_basic_merge(self): + first = NodePackage( + "a", + provides={"x": int}, + requires={"raw": int}, + dependencies={"x": ["raw"]}, + config_keys=["cfg_a"], + ) + second = NodePackage( + "b", + provides={"y": float}, + requires={"x": int}, + dependencies={"y": ["x"]}, + config_keys=["cfg_b"], + ) + composed = compose_packages([first, second], "combined") + assert composed.name == "combined" + assert set(composed.provides) == {"x", "y"} + assert set(composed.requires) == {"raw"} + assert set(composed.config_keys) == {"cfg_a", "cfg_b"} + assert set(composed.dependencies["y"]) == {"x"} + assert composed.dependency_order().index("x") < composed.dependency_order().index("y") + + def test_config_keys_merge_from_all_members(self): + first = NodePackage("a", provides={"x": int}, config_keys=["only_a"]) + second = NodePackage("b", provides={"y": int}, config_keys=["only_b", "shared"]) + third = NodePackage("c", provides={"z": int}, config_keys=["shared"]) + composed = compose_packages([first, second, third], "combined") + assert set(composed.config_keys) == {"only_a", "only_b", "shared"} + + def test_internal_edges_survive_merge(self): + first = NodePackage( + "a", provides={"x": int}, requires={"raw": int}, dependencies={"x": ["raw"]} + ) + second = NodePackage( + "b", provides={"y": int}, requires={"x": int}, dependencies={"y": ["x"]} + ) + composed = compose_packages([first, second], "combined") + assert set(composed.dependencies["x"]) == {"raw"} + assert set(composed.dependencies["y"]) == {"x"} + + def test_version_defaults_to_max(self): + first = NodePackage("a", version=2, provides={"x": int}) + second = NodePackage("b", version=5, provides={"y": int}) + assert compose_packages([first, second], "combined").version == 5 + + def test_explicit_version_wins(self): + first = NodePackage("a", version=2) + assert compose_packages([first], "combined", version=9).version == 9 + + def test_same_name_same_type_deduplicated(self): + first = NodePackage("a", provides={"shared": int, "x": int}) + second = NodePackage("b", provides={"shared": int, "y": int}) + composed = compose_packages([first, second], "combined") + assert set(composed.provides) == {"shared", "x", "y"} + + def test_internal_satisfaction_honors_subtype(self): + provider = NodePackage("a", provides={"x": bool}) + requirer = NodePackage( + "b", provides={"y": int}, requires={"x": int}, dependencies={"y": ["x"]} + ) + composed = compose_packages([provider, requirer], "combined") + assert "x" not in composed.requires + assert set(composed.provides) == {"x", "y"} + + def test_conflict_raises_with_details(self): + first = NodePackage("a", provides={"clash_point": int}) + second = NodePackage("b", provides={"clash_point": str}) + with pytest.raises(PackageConflictError) as e: + compose_packages([first, second], "combined") + assert e.value.conflicts + for conflict in e.value.conflicts: + assert isinstance(conflict, PackageConflict) + assert {conflict.name for conflict in e.value.conflicts} == {"clash_point"} + + def test_conflicting_trio_raises_in_every_order(self): + first = NodePackage("a", provides={"x": int}) + second = NodePackage("b", requires={"x": bool}) + third = NodePackage("c", provides={"y": int}) + for permutation in itertools.permutations([first, second, third]): + with pytest.raises(PackageConflictError) as e: + compose_packages(list(permutation), "combined") + names = {conflict.name for conflict in e.value.conflicts} + assert "x" in names + + def test_all_pairs_checked_despite_internal_satisfaction(self): + provider = NodePackage("a", provides={"x": bool}) + int_requirer = NodePackage("b", requires={"x": int}) + bool_requirer = NodePackage("c", requires={"x": bool}) + for permutation in itertools.permutations([provider, int_requirer, bool_requirer]): + with pytest.raises(PackageConflictError) as e: + compose_packages(list(permutation), "combined") + found = {(conflict.category, conflict.name) for conflict in e.value.conflicts} + assert ("requirement_disagreement", "x") in found + + def test_order_independent_composition(self): + first = NodePackage( + "a", + version=2, + provides={"x": int}, + requires={"raw": int}, + dependencies={"x": ["raw"]}, + config_keys=["cfg_a"], + ) + second = NodePackage( + "b", + version=3, + provides={"y": float}, + requires={"x": int}, + dependencies={"y": ["x"]}, + config_keys=["cfg_b"], + ) + third = NodePackage( + "c", + version=1, + provides={"z": str}, + requires={"y": float, "other": str}, + dependencies={"z": ["y", "other"]}, + config_keys=["cfg_c"], + ) + rendered = set() + for permutation in itertools.permutations([first, second, third]): + composed = compose_packages(list(permutation), "combined") + rendered.add(composed.manifest().to_json()) + assert len(rendered) == 1 + composed = compose_packages([first, second, third], "combined") + assert composed.name == "combined" + assert composed.version == 3 + assert set(composed.provides) == {"x", "y", "z"} + assert set(composed.requires) == {"raw", "other"} + assert set(composed.config_keys) == {"cfg_a", "cfg_b", "cfg_c"} + assert composed.manifest().to_dict()["provides"]["y"] == htypes.get_type_as_string(float) + + +class TestManifestDiff: + def _manifest(self, **overrides): + kwargs = dict( + provides={"a": int, "b": float}, + requires={"r": int}, + dependencies={"a": ["r"]}, + config_keys=["cfg"], + ) + kwargs.update(overrides) + return NodePackage("p", **kwargs).manifest() + + def test_identical_manifests(self): + diff = diff_manifests(self._manifest(), self._manifest()) + assert isinstance(diff, ManifestDiff) + assert diff.has_changes is False + assert diff.added_nodes == {} + assert diff.removed_nodes == {} + assert diff.changed_nodes == {} + assert diff.added_requirements == {} + assert diff.removed_requirements == {} + assert diff.changed_requirements == {} + assert not diff.added_config_keys + assert not diff.removed_config_keys + + def test_added_and_removed_nodes(self): + old = self._manifest() + new = self._manifest(provides={"a": int, "c": str}, dependencies={"a": ["r"]}) + diff = diff_manifests(old, new) + assert set(diff.added_nodes) == {"c"} + assert diff.added_nodes["c"] == htypes.get_type_as_string(str) + assert set(diff.removed_nodes) == {"b"} + assert diff.has_changes is True + + def test_changed_node_type(self): + old = self._manifest() + new = self._manifest(provides={"a": str, "b": float}) + diff = diff_manifests(old, new) + assert diff.changed_nodes["a"] == ( + htypes.get_type_as_string(int), + htypes.get_type_as_string(str), + ) + + def test_requirement_changes(self): + old = self._manifest() + new = self._manifest(requires={"r": float, "s": int}, dependencies={"a": ["r"]}) + diff = diff_manifests(old, new) + assert set(diff.added_requirements) == {"s"} + assert diff.changed_requirements["r"] == ( + htypes.get_type_as_string(int), + htypes.get_type_as_string(float), + ) + removed = diff_manifests(new, old) + assert set(removed.removed_requirements) == {"s"} + + def test_config_key_changes(self): + old = self._manifest() + new = self._manifest(config_keys=["other"], dependencies={"a": ["r"]}) + diff = diff_manifests(old, new) + assert set(diff.added_config_keys) == {"other"} + assert set(diff.removed_config_keys) == {"cfg"} + + def test_has_changes_is_boolean(self): + same = diff_manifests(self._manifest(), self._manifest()) + assert isinstance(same.has_changes, bool) + different = diff_manifests(self._manifest(), self._manifest(config_keys=[])) + assert isinstance(different.has_changes, bool) + assert different.has_changes is True + + def test_rejects_non_manifests(self): + with pytest.raises(PackagingError): + diff_manifests({"name": "p"}, self._manifest()) + with pytest.raises(PackagingError): + diff_manifests(self._manifest(), None) + + def test_constructor_matches_function(self): + old = self._manifest() + new = self._manifest(provides={"a": int, "c": str}, dependencies={"a": ["r"]}) + assert ManifestDiff(old, new).added_nodes == diff_manifests(old, new).added_nodes + + +class TestExceptions: + def test_hierarchy(self): + assert issubclass(PackageValidationError, PackagingError) + assert issubclass(PackageConflictError, PackagingError) + assert issubclass(PackagingError, Exception) + + def test_validation_error_requires_report(self): + with pytest.raises(PackagingError): + PackageValidationError("not a report") + + def test_validation_error_rejects_valid_report(self): + with pytest.raises(PackagingError): + PackageValidationError(ValidationReport("p", [])) + + def test_validation_error_carries_report_and_messages(self): + problems = [ + ValidationProblem("missing_dependency", "gone_one", "gone_one is not available"), + ValidationProblem("config_requirement_missing", "gone_two", "gone_two is not set"), + ] + report = ValidationReport("p", problems) + error = PackageValidationError(report) + assert error.report is report + assert "gone_one is not available" in str(error) + assert "gone_two is not set" in str(error) + + def test_conflict_error_requires_conflicts(self): + with pytest.raises(PackagingError): + PackageConflictError([]) + + def test_conflict_error_rejects_non_conflicts(self): + with pytest.raises(PackagingError): + PackageConflictError(["oops"]) + + def test_validation_error_repr_mentions_report(self): + report = ValidationReport( + "repr_err_pkg", + [ValidationProblem("missing_dependency", "gone_dep", "gone_dep missing")], + ) + rendered = repr(PackageValidationError(report)) + assert "repr_err_pkg" in rendered + assert "gone_dep" in rendered + + def test_conflict_error_repr_mentions_conflict(self): + conflict = PackageConflict("a", "b", "duplicate_node", "clash_here", "clash_here twice") + rendered = repr(PackageConflictError([conflict])) + assert "clash_here" in rendered + + def test_conflict_error_carries_conflicts_and_messages(self): + conflicts = [ + PackageConflict("a", "b", "duplicate_node", "x", "x provided twice"), + PackageConflict("a", "b", "requirement_disagreement", "y", "y expected differently"), + ] + error = PackageConflictError(conflicts) + assert list(error.conflicts) == conflicts + assert "x provided twice" in str(error) + assert "y expected differently" in str(error) + + +class TestDriverIntegration: + def test_with_packages_rejects_non_package(self): + with pytest.raises(ValueError): + driver.Builder().with_packages("not a package") + + def test_with_packages_rejects_falsey_non_packages(self): + with pytest.raises(ValueError): + driver.Builder().with_packages(None) + with pytest.raises(ValueError): + driver.Builder().with_packages(0) + + def test_with_packages_rejects_sequence(self): + packages = [NodePackage("a"), NodePackage("b")] + with pytest.raises(ValueError): + driver.Builder().with_packages(packages) + + def test_build_with_compatible_package(self): + module = ad_hoc_utils.create_temporary_module(base_int, doubled) + package = NodePackage("p", requires={"base_int": int}) + dr = driver.Builder().with_modules(module).with_packages(package).build() + assert dr.list_packages() == [package] + reports = dr.validate_packages() + assert len(reports) == 1 + assert reports[0].is_valid is True + assert reports[0].package_name == "p" + + def test_build_failure_carries_report(self): + module = ad_hoc_utils.create_temporary_module(base_int) + package = NodePackage("p", requires={"long_gone": int}) + with pytest.raises(PackageValidationError) as e: + driver.Builder().with_modules(module).with_packages(package).build() + assert isinstance(e.value.report, ValidationReport) + by_name = {problem.name: problem for problem in e.value.report.problems} + assert by_name["long_gone"].category == "missing_dependency" + for problem in e.value.report.problems: + assert problem.message in str(e.value) + + def test_build_failure_multiple_problems_in_message(self): + module = ad_hoc_utils.create_temporary_module(base_int) + package = NodePackage( + "p", requires={"gone_one": int, "gone_two": int}, config_keys=["gone_key"] + ) + with pytest.raises(PackageValidationError) as e: + driver.Builder().with_modules(module).with_packages(package).build() + assert len(e.value.report.problems) >= 3 + assert {problem.name for problem in e.value.report.problems} == { + "gone_one", + "gone_two", + "gone_key", + } + for problem in e.value.report.problems: + assert problem.message in str(e.value) + + def test_async_build_failure_carries_report(self): + module = ad_hoc_utils.create_temporary_module(base_int) + package = NodePackage("p", requires={"long_gone": int}) + builder = async_driver.Builder().with_modules(module).with_packages(package) + with pytest.raises(PackageValidationError) as e: + builder.build_without_init() + assert isinstance(e.value.report, ValidationReport) + assert {problem.name for problem in e.value.report.problems} == {"long_gone"} + + def test_async_build_success_keeps_packages(self): + module = ad_hoc_utils.create_temporary_module(base_int) + package = NodePackage("p", requires={"base_int": int}) + dr = async_driver.Builder().with_modules(module).with_packages(package).build_without_init() + assert dr.list_packages() == [package] + + def test_no_packages_by_default(self): + module = ad_hoc_utils.create_temporary_module(base_int) + dr = driver.Builder().with_modules(module).build() + assert dr.list_packages() == [] + assert dr.validate_packages() == [] + + def test_with_packages_accumulates(self): + first = NodePackage("a", requires={"base_int": int}) + second = NodePackage("b", requires={"doubled": int}) + module = ad_hoc_utils.create_temporary_module(base_int, doubled) + dr = ( + driver.Builder().with_modules(module).with_packages(first).with_packages(second).build() + ) + assert {p.name for p in dr.list_packages()} == {"a", "b"} + assert {r.package_name for r in dr.validate_packages()} == {"a", "b"} + + def test_with_packages_accepts_varargs_in_one_call(self): + first = NodePackage("a", requires={"base_int": int}) + second = NodePackage("b", requires={"doubled": int}) + module = ad_hoc_utils.create_temporary_module(base_int, doubled) + dr = driver.Builder().with_modules(module).with_packages(first, second).build() + assert {p.name for p in dr.list_packages()} == {"a", "b"} + assert all(report.is_valid for report in dr.validate_packages()) + + def test_builder_copy_preserves_packages(self): + module = ad_hoc_utils.create_temporary_module(base_int) + package = NodePackage("p", requires={"base_int": int}) + builder = ( + driver.Builder().with_modules(module).with_config({"someone": 1}).with_packages(package) + ) + copied = builder.copy() + assert copied.config == builder.config + assert copied.modules == builder.modules + builder.with_packages(NodePackage("later")) + builder.with_config({"added_later": 2}) + builder.with_modules(ad_hoc_utils.create_temporary_module(doubled)) + assert "added_later" not in copied.config + assert len(copied.modules) == 1 + dr = copied.build() + assert dr.list_packages() == [package] + + def test_multiple_modules_supply_requirements(self): + first_module = ad_hoc_utils.create_temporary_module(base_int, module_name="pkg_mod_a") + second_module = ad_hoc_utils.create_temporary_module( + doubled, as_float, module_name="pkg_mod_b" + ) + package = NodePackage("p", requires={"base_int": int, "as_float": float}) + dr = ( + driver.Builder() + .with_modules(first_module, second_module) + .with_packages(package) + .build() + ) + assert dr.validate_packages()[0].is_valid is True + + def test_config_satisfied_package_through_builder(self): + module = ad_hoc_utils.create_temporary_module(base_int, scaled) + package = NodePackage("p", requires={"factor": int}, config_keys=["factor"]) + dr = ( + driver.Builder() + .with_modules(module) + .with_config({"factor": 2}) + .with_packages(package) + .build() + ) + assert dr.validate_packages()[0].is_valid is True + + def test_manifest_fields_constant(self): + assert set(MANIFEST_FIELDS) == { + "name", + "version", + "provides", + "requires", + "dependencies", + "config_keys", + } + + def test_packaged_subgraph_round_trip_through_export(self, tmp_path): + dr = _build_driver(base_int, doubled, as_float) + package = NodePackage.from_graph(dr.graph, ["doubled", "as_float"], "p", version=2) + path = tmp_path / "p.json" + export_package(package, path) + manifest = import_manifest(path) + data = manifest.to_dict() + assert data["version"] == 2 + assert set(data["provides"]) == {"doubled", "as_float"} + assert data["provides"]["doubled"] == htypes.get_type_as_string(int) + assert manifest.dependency_order() == package.dependency_order() + + +class TestRemainingSurface: + def test_include_upstream_skips_optional_and_config_dependencies(self): + dr = _build_driver(base_int, scaled, with_default, config={"factor": 2}) + package = NodePackage.from_graph( + dr.graph, ["scaled", "with_default"], "p", include_upstream=True + ) + assert set(package.provides) == {"base_int", "scaled", "with_default"} + assert set(package.config_keys) == {"factor"} + assert not package.requires + + def test_reprs_mention_identifying_fields(self): + package = _simple_package("repr_pkg") + assert "repr_pkg" in repr(package) + assert "repr_pkg" in repr(package.manifest()) + problem = ValidationProblem("missing_dependency", "repr_name", "repr_name gone") + assert "repr_name" in repr(problem) + report = ValidationReport("repr_pkg", [problem]) + assert "repr_pkg" in repr(report) + conflict = PackageConflict("a", "b", "duplicate_node", "repr_name", "repr_name clashes") + assert "repr_name" in repr(conflict)