diff --git a/src/python/pants/base/BUILD b/src/python/pants/base/BUILD index 01ed52c76ad..92e9a3c73ab 100644 --- a/src/python/pants/base/BUILD +++ b/src/python/pants/base/BUILD @@ -105,6 +105,7 @@ python_library( name = 'hash_utils', sources = ['hash_utils.py'], dependencies = [ + '3rdparty/python:dataclasses', '3rdparty/python:typing-extensions', 'src/python/pants/util:strutil', 'src/python/pants/util:ordered_set', diff --git a/src/python/pants/base/hash_utils.py b/src/python/pants/base/hash_utils.py index ed567106572..7764b033113 100644 --- a/src/python/pants/base/hash_utils.py +++ b/src/python/pants/base/hash_utils.py @@ -1,6 +1,7 @@ # Copyright 2014 Pants project contributors (see CONTRIBUTORS.md). # Licensed under the Apache License, Version 2.0 (see LICENSE). +import dataclasses import hashlib import json import logging @@ -140,6 +141,9 @@ def default(self, o): ) # Set order is arbitrary in python 3.6 and 3.7, so we need to keep this sorted() call. return sorted(self.default(i) for i in o) + if dataclasses.is_dataclass(o): + # `@dataclass` objects will fail with a cyclic reference error unless we stringify them here. + return self.default(repr(o)) if isinstance(o, Iterable) and not isinstance(o, (bytes, list, str)): return list(self.default(i) for i in o) logger.debug( diff --git a/src/python/pants/core/project_info/list_targets.py b/src/python/pants/core/project_info/list_targets.py index a1873843f95..265f5e515f3 100644 --- a/src/python/pants/core/project_info/list_targets.py +++ b/src/python/pants/core/project_info/list_targets.py @@ -1,14 +1,17 @@ # Copyright 2020 Pants project contributors (see CONTRIBUTORS.md). # Licensed under the Apache License, Version 2.0 (see LICENSE). -from typing import Dict, cast +import json +from enum import Enum +from typing import Callable, Dict, Optional, Union, cast from pants.engine.addresses import Address, Addresses from pants.engine.console import Console from pants.engine.goal import Goal, GoalSubsystem, LineOriented +from pants.engine.legacy.graph import FingerprintedTargetCollection, TransitiveFingerprintedTarget from pants.engine.rules import goal_rule from pants.engine.selectors import Get -from pants.engine.target import DescriptionField, ProvidesField, Targets +from pants.engine.target import DescriptionField, ProvidesField, Target, Targets class ListOptions(LineOriented, GoalSubsystem): @@ -16,98 +19,184 @@ class ListOptions(LineOriented, GoalSubsystem): name = "list-v2" + class OutputFormat(Enum): + address_specs = "address-specs" + provides = "provides" + documented = "documented" + json = "json" + @classmethod def register_options(cls, register): super().register_options(register) register( "--provides", type=bool, - help=( - "List only targets that provide an artifact, displaying the columns specified by " - "--provides-columns." - ), + removal_version="1.30.0.dev2", + removal_hint="Use --output-format=provides instead!", + help="List only targets that provide an artifact, displaying the columns specified by " + "--provides-columns.", ) register( "--provides-columns", default="address,artifact_id", - help=( - "Display these columns when --provides is specified. Available columns are: " - "address, artifact_id, repo_name, repo_url, push_db_basedir" - ), + help="Display these columns when --output-format=provides is specified. Available " + "columns are: address, artifact_id, repo_name, repo_url, push_db_basedir", ) + register( "--documented", type=bool, + removal_version="1.30.0.dev2", + removal_hint="Use --output-format=documented instead!", help="Print only targets that are documented with a description.", ) - -class List(Goal): - subsystem_cls = ListOptions + register( + "--output-format", + type=cls.OutputFormat, + default=cls.OutputFormat.address_specs, + help="How to format targets when printed to stdout.", + ) -@goal_rule -async def list_targets(addresses: Addresses, options: ListOptions, console: Console) -> List: - if not addresses.dependencies: - console.print_stderr(f"WARNING: No targets were matched in goal `{options.name}`.") - return List(exit_code=0) - - provides_enabled = options.values.provides - documented_enabled = options.values.documented - if provides_enabled and documented_enabled: +PrintFunction = Callable[[Target], Optional[str]] + + +def _make_provides_print_fn(provides_columns: str, targets: Targets) -> PrintFunction: + addresses_with_provide_artifacts = { + tgt.address: tgt[ProvidesField].value + for tgt in targets + if tgt.get(ProvidesField).value is not None + } + extractor_funcs = { + "address": lambda address, _: address.spec, + "artifact_id": lambda _, artifact: str(artifact), + "repo_name": lambda _, artifact: artifact.repo.name, + "repo_url": lambda _, artifact: artifact.repo.url, + "push_db_basedir": lambda _, artifact: artifact.repo.push_db_basedir, + } + try: + column_extractors = [ + extractor_funcs[col] for col in provides_columns.split(",") + ] + except KeyError: raise ValueError( - "Cannot specify both `--list-documented` and `--list-provides` at the same time. " - "Please choose one." + "Invalid columns provided for `--list-provides-columns`: " + f"{provides_columns}. Valid columns are: " + f"{', '.join(sorted(extractor_funcs.keys()))}." + ) + + try: + column_extractors = [extractor_funcs[col] for col in (provides_columns.split(","))] + except KeyError: + raise Exception( + "Invalid columns specified: {0}. Valid columns are: address, artifact_id, " + "repo_name, repo_url, push_db_basedir.".format(provides_columns) ) - if provides_enabled: - targets = await Get[Targets](Addresses, addresses) - addresses_with_provide_artifacts = { - tgt.address: tgt[ProvidesField].value + def print_provides(target: Target) -> Optional[str]: + address = target.address + artifact = addresses_with_provide_artifacts.get(address, None) + if artifact: + return " ".join(extractor(address, artifact) for extractor in column_extractors) + return None + + return print_provides + + +def _make_print_documented_target(targets: Targets) -> PrintFunction: + addresses_with_descriptions = cast( + Dict[Address, str], + { + tgt.address: tgt[DescriptionField].value for tgt in targets - if tgt.get(ProvidesField).value is not None + if tgt.get(DescriptionField).value is not None + }, + ) + def print_documented(target: Target) -> Optional[str]: + address = target.address + description = addresses_with_descriptions.get(address, None) + if description: + formatted_description = "\n ".join(description.strip().split("\n")) + return f"{address.spec}\n {formatted_description}" + return None + return print_documented + + +FingerprintedPrintFunction = Callable[[TransitiveFingerprintedTarget], str] + + +def _print_fingerprinted_target(fingerprinted_target: TransitiveFingerprintedTarget) -> str: + was_root = fingerprinted_target.was_root + address = fingerprinted_target.address.spec + target_type = fingerprinted_target.type_alias + intransitive_fingerprint = fingerprinted_target.intransitive_fingerprint_arg + transitive_fingerprint = fingerprinted_target.transitive_fingerprint_arg + return json.dumps( + { + "was_root": was_root, + "address": address, + "target_type": target_type, + "intransitive_fingerprint": intransitive_fingerprint, + "transitive_fingerprint": transitive_fingerprint, } - extractor_funcs = { - "address": lambda address, _: address.spec, - "artifact_id": lambda _, artifact: str(artifact), - "repo_name": lambda _, artifact: artifact.repo.name, - "repo_url": lambda _, artifact: artifact.repo.url, - "push_db_basedir": lambda _, artifact: artifact.repo.push_db_basedir, - } - try: - extractors = [ - extractor_funcs[col] for col in options.values.provides_columns.split(",") - ] - except KeyError: - raise ValueError( - "Invalid columns provided for `--list-provides-columns`: " - f"{options.values.provides_columns}. Valid columns are: " - f"{', '.join(sorted(extractor_funcs.keys()))}." - ) - with options.line_oriented(console) as print_stdout: - for address, artifact in addresses_with_provide_artifacts.items(): - print_stdout(" ".join(extractor(address, artifact) for extractor in extractors)) - return List(exit_code=0) - - if documented_enabled: - targets = await Get[Targets](Addresses, addresses) - addresses_with_descriptions = cast( - Dict[Address, str], - { - tgt.address: tgt[DescriptionField].value - for tgt in targets - if tgt.get(DescriptionField).value is not None - }, - ) - with options.line_oriented(console) as print_stdout: - for address, description in addresses_with_descriptions.items(): - formatted_description = "\n ".join(description.strip().split("\n")) - print_stdout(f"{address.spec}\n {formatted_description}") - return List(exit_code=0) - - with options.line_oriented(console) as print_stdout: - for address in sorted(addresses): - print_stdout(address) + ) + + +AddressesPrintFunction = Callable[[Address], str] + + +class List(Goal): + subsystem_cls = ListOptions + + +@goal_rule +async def list_targets(console: Console, list_options: ListOptions, addresses: Addresses) -> List: + provides = list_options.values.provides + provides_columns = list_options.values.provides_columns + documented = list_options.values.documented + collection: Union[Targets, Addresses, FingerprintedTargetCollection] + print_fn: Union[PrintFunction, FingerprintedPrintFunction, AddressesPrintFunction] + + output_format = list_options.values.output_format + + # TODO: Remove when these options have completed their deprecation cycle! + if provides: + output_format = ListOptions.OutputFormat.provides + elif documented: + output_format = ListOptions.OutputFormat.documented + + # TODO: a match() method for Enums which allows `await Get()` within it somehow! + if output_format == ListOptions.OutputFormat.provides: + # To get provides clauses, we need hydrated targets. + collection = await Get[Targets](Addresses, addresses) + print_fn = _make_provides_print_fn(provides_columns, collection) + elif output_format == ListOptions.OutputFormat.documented: + # To get documentation, we need hydrated targets. + collection = await Get[Targets](Addresses, addresses) + print_fn = _make_print_documented_target(collection) + elif output_format == ListOptions.OutputFormat.json: + # To get fingerprints of each target and its dependencies, we have to request that information + # specifically. + collection = await Get[FingerprintedTargetCollection](Addresses, addresses) + print_fn = _print_fingerprinted_target + else: + assert output_format == ListOptions.OutputFormat.address_specs + # Otherwise, we can use only addresses. + collection = addresses + print_fn = lambda address: address.spec + + with list_options.line_oriented(console) as print_stdout: + if not collection.dependencies: + console.print_stderr("WARNING: No targets were matched in goal `{}`.".format("list")) + + for item in collection: + # The above waterfall of `if` conditionals using the ListOptions.OutputFormat enum + # should ensure that the types of `collection` and `print_fn` are matched up. + result = print_fn(item) # type: ignore[arg-type] + if result: + print_stdout(result) + return List(exit_code=0) diff --git a/src/python/pants/core/project_info/list_targets_test.py b/src/python/pants/core/project_info/list_targets_test.py index f57ce31e4cc..76ca379d230 100644 --- a/src/python/pants/core/project_info/list_targets_test.py +++ b/src/python/pants/core/project_info/list_targets_test.py @@ -2,12 +2,13 @@ # Licensed under the Apache License, Version 2.0 (see LICENSE). from textwrap import dedent -from typing import List, Optional, Tuple, cast +from typing import Dict, List, Optional, Tuple, cast from pants.backend.jvm.artifact import Artifact from pants.backend.jvm.repository import Repository from pants.core.project_info.list_targets import ListOptions, list_targets from pants.engine.addresses import Address, Addresses +from pants.engine.legacy.graph import FingerprintedTargetCollection, TransitiveFingerprintedTarget from pants.engine.target import DescriptionField, ProvidesField, Target, Targets from pants.testutil.engine.util import MockConsole, MockGet, create_goal_subsystem, run_rule @@ -20,6 +21,7 @@ class MockTarget(Target): def run_goal( targets: List[MockTarget], *, + target_fingerprints: Dict[Address, TransitiveFingerprintedTarget] = {}, show_documented: bool = False, show_provides: bool = False, provides_columns: Optional[str] = None, @@ -39,7 +41,12 @@ def run_goal( ), console, ], - mock_gets=[MockGet(product_type=Targets, subject_type=Addresses, mock=lambda _: targets)], + mock_gets=[ + MockGet(product_type=Targets, subject_type=Addresses, mock=lambda _: targets), + MockGet(product_type=FingerprintedTargetCollection, subject_type=Addresses, + mock=lambda addresses: FingerprintedTargetCollection(target_fingerprints[addr] + for addr in addresses)) + ], ) return cast(str, console.stdout.getvalue()), cast(str, console.stderr.getvalue()) diff --git a/src/python/pants/engine/internals/struct.py b/src/python/pants/engine/internals/struct.py index f3c5a24611c..909466814d5 100644 --- a/src/python/pants/engine/internals/struct.py +++ b/src/python/pants/engine/internals/struct.py @@ -4,6 +4,7 @@ from collections.abc import MutableMapping, MutableSequence from typing import Any, Dict, Iterable, Optional, cast +from pants.base.hash_utils import stable_json_sha1 from pants.build_graph.address import Address from pants.engine.internals.addressable import addressable, addressable_sequence from pants.engine.internals.objects import ( @@ -12,6 +13,7 @@ Validatable, ValidationError, ) +from pants.util.memo import memoized_property from pants.util.objects import SubclassesOf, SuperclassesOf @@ -255,7 +257,11 @@ def __getattr__(self, item): # Without it, AttributeErrors inside @property methods will be misattributed. return object.__getattribute__(self, item) - def _key(self): + @classmethod + def _hash_key_predicate(cls, key): + return key not in cls._INHERITANCE_FIELDS + + def _compute_key(self, key_predicate): def hashable(value): if isinstance(value, dict): return tuple(sorted((k, hashable(v)) for k, v in value.items())) @@ -270,10 +276,13 @@ def hashable(value): sorted( (k, hashable(v)) for k, v in self._kwargs.items() - if k not in self._INHERITANCE_FIELDS + if key_predicate(k) ) ) + def _key(self): + return self._compute_key(self._hash_key_predicate) + def __hash__(self): return hash(self._key()) @@ -315,3 +324,25 @@ def dependencies(self): :rtype: tuple """ + + def _coerce_key_values(self, key, value): + """Convert `value` into an object which doesn't produce a cyclic reference error.""" + if key == "address": + return (key, value.spec) + return (key, value) + + def _intransitive_predicate(self, key): + return self._hash_key_predicate(key) and key != "dependencies" + + @memoized_property + def intransitive_fingerprint(self): + key = tuple( + self._coerce_key_values(k, v) + for k, v in self._compute_key(self._intransitive_predicate) + ) + try: + return stable_json_sha1(key) + except ValueError as e: + raise ValueError( + f"Failed json hash -- object type was: {type(self)}, key was: {key}." + ) from e diff --git a/src/python/pants/engine/legacy/graph.py b/src/python/pants/engine/legacy/graph.py index 646e0790d11..30bcc21712b 100644 --- a/src/python/pants/engine/legacy/graph.py +++ b/src/python/pants/engine/legacy/graph.py @@ -9,6 +9,7 @@ from typing import Any, Dict, Iterable, Iterator, List, Set, Tuple, Type, cast from pants.base.exceptions import TargetDefinitionException +from pants.base.hash_utils import stable_json_sha1 from pants.base.parse_context import ParseContext from pants.base.specs import AddressSpec, AddressSpecs, SingleAddress from pants.build_graph.address import Address, BuildFileAddress @@ -33,6 +34,7 @@ from pants.engine.selectors import Get, MultiGet from pants.option.global_options import GlobMatchErrorBehavior from pants.source.wrapped_globs import EagerFilesetWithSpec, FilesetRelPathWrapper, Filespec +from pants.util.memo import memoized_classmethod from pants.util.meta import frozen_after_init from pants.util.ordered_set import FrozenOrderedSet, OrderedSet @@ -421,6 +423,27 @@ class LegacyTransitiveHydratedTargets: closure: FrozenOrderedSet[LegacyHydratedTarget] +@dataclass(frozen=True) +class TransitiveFingerprintedTarget: + """A dataclass containing memoized fingerprint information for some TransitiveHydratedTarget.""" + + was_root: bool + address: Address + type_alias: str + intransitive_fingerprint_arg: str + transitive_fingerprint_arg: str + + @memoized_classmethod + def calculate_intransitive_fingerprint_for_target_adaptor(cls, adaptor): + sources = getattr(adaptor, "sources", None) + sources_snapshot = sources.snapshot if sources else None + return stable_json_sha1([adaptor.intransitive_fingerprint, sources_snapshot,]) + + +class FingerprintedTargetCollection(Collection[TransitiveFingerprintedTarget]): + """A collection of fingerprint information for a set of `TransitiveHydratedTarget`s.""" + + @rule async def transitive_hydrated_targets(addresses: Addresses) -> TransitiveHydratedTargets: """Given Addresses, kicks off recursion on expansion of TransitiveHydratedTargets. @@ -502,6 +525,44 @@ async def hydrate_target(hydrated_struct: HydratedStruct) -> HydratedTarget: return HydratedTarget(adaptor=type(target_adaptor)(**kwargs)) +@rule +async def transitive_fingerprinted_targets(addresses: Addresses) -> FingerprintedTargetCollection: + """Traverse BuildFileAddresses to obtain fingerprint information for the target set.""" + + root_transitive_hydrated_targets = await MultiGet( + Get[TransitiveHydratedTarget](Address, a) for a in addresses + ) + transitive_hydrated_targets = tuple( + (tht, True) for tht in root_transitive_hydrated_targets + ) + tuple( + (dep, False) + for tht in root_transitive_hydrated_targets + for dep in tht.dependencies + if dep not in root_transitive_hydrated_targets + ) + + def lookup_fingerprint(tht): + return TransitiveFingerprintedTarget.calculate_intransitive_fingerprint_for_target_adaptor( + tht.root.adaptor + ) + + fingerprinted_targets = [ + TransitiveFingerprintedTarget( + was_root=was_root, + address=tht.root.adaptor.address, + type_alias=tht.root.adaptor.type_alias, + intransitive_fingerprint_arg=lookup_fingerprint(tht), + transitive_fingerprint_arg=stable_json_sha1( + (lookup_fingerprint(tht),) + + tuple(lookup_fingerprint(dep) for dep in tht.dependencies) + ), + ) + for tht, was_root in transitive_hydrated_targets + ] + + return FingerprintedTargetCollection(tuple(fingerprinted_targets)) + + @rule async def hydrated_targets(addresses: Addresses) -> HydratedTargets: targets = await MultiGet(Get[HydratedTarget](Address, a) for a in addresses) @@ -595,11 +656,12 @@ async def hydrate_bundles( def create_legacy_graph_tasks(): """Create tasks to recursively parse the legacy graph.""" return [ - transitive_hydrated_target, - transitive_hydrated_targets, - legacy_transitive_hydrated_targets, + hydrate_bundles, + hydrate_sources, hydrate_target, hydrated_targets, - hydrate_sources, - hydrate_bundles, + legacy_transitive_hydrated_targets, + transitive_fingerprinted_targets, + transitive_hydrated_target, + transitive_hydrated_targets, ] diff --git a/src/python/pants/engine/legacy/structs.py b/src/python/pants/engine/legacy/structs.py index 7ec2e21bdeb..630e9bdc12e 100644 --- a/src/python/pants/engine/legacy/structs.py +++ b/src/python/pants/engine/legacy/structs.py @@ -123,6 +123,18 @@ def default_sources_globs(cls): def default_sources_exclude_globs(cls): return None + def _coerce_key_values(self, key, value): + # TODO: This method is overridden from `StructWithDeps` to remove elements which cause + # `stable_json_sha1()` to fail with a cycle detection. Since some python targets are only + # mapped to `TargetAdaptor` (and not `PythonTargetAdaptor`), we check every single target + # for a `requirements` kwarg, which is fine for now. + key, value = super()._coerce_key_values(key, value) + if key == "requirements": + return (key, tuple(str(req) for req in value)) + if key == "provides": + return (key, repr(value)) + return (key, value) + def validate_sources(self, sources): """" Validate that the sources argument is allowed. diff --git a/tests/python/pants_test/backend/graph_info/tasks/test_list_targets.py b/tests/python/pants_test/backend/graph_info/tasks/test_list_targets.py index 2bdfd97142b..cfab930c892 100644 --- a/tests/python/pants_test/backend/graph_info/tasks/test_list_targets.py +++ b/tests/python/pants_test/backend/graph_info/tasks/test_list_targets.py @@ -1,6 +1,7 @@ # Copyright 2014 Pants project contributors (see CONTRIBUTORS.md). # Licensed under the Apache License, Version 2.0 (see LICENSE). +import json import os from textwrap import dedent @@ -9,25 +10,28 @@ from pants.backend.jvm.artifact import Artifact from pants.backend.jvm.repository import Repository from pants.backend.jvm.scala_artifact import ScalaArtifact -from pants.backend.jvm.targets.java_library import JavaLibrary -from pants.backend.python.targets.python_library import PythonLibrary +from pants.backend.jvm.target_types import JavaLibrary +from pants.backend.python.target_types import PythonLibrary from pants.build_graph.build_file_aliases import BuildFileAliases -from pants.build_graph.target import Target -from pants.core.project_info import list_targets_old +from pants.core.project_info import list_targets +from pants.core.target_types import GenericTarget from pants.testutil.goal_rule_test_base import GoalRuleTestBase class ListTargetsTest(GoalRuleTestBase): - goal_cls = list_targets_old.List + goal_cls = list_targets.List + + @classmethod + def target_types(cls): + return [ + GenericTarget, + JavaLibrary, + PythonLibrary, + ] @classmethod def alias_groups(cls): return BuildFileAliases( - targets={ - "target": Target, - "java_library": JavaLibrary, - "python_library": PythonLibrary, - }, objects={ "pants": lambda x: x, "artifact": Artifact, @@ -40,7 +44,7 @@ def alias_groups(cls): @classmethod def rules(cls): - return super().rules() + list_targets_old.rules() + return super().rules() + list_targets.rules() def setUp(self) -> None: super().setUp() @@ -154,7 +158,7 @@ def test_list_all(self): def test_list_provides(self): self.assert_console_output( - "a/b:b com.example#b", "a/b/c:c2 com.example#c2", args=["--provides", "::"] + "a/b:b com.example#b", "a/b/c:c2 com.example#c2", args=["--output-format=provides", "::"] ) def test_list_provides_customcols(self): @@ -162,7 +166,7 @@ def test_list_provides_customcols(self): "/tmp a/b:b http://maven.example.com public com.example#b", "/tmp a/b/c:c2 http://maven.example.com public com.example#c2", args=[ - "--provides", + "--output-format=provides", "--provides-columns=push_db_basedir,address,repo_url,repo_name,artifact_id", "::", ], @@ -174,12 +178,76 @@ def test_list_dedups(self): def test_list_documented(self): self.assert_console_output( # Confirm empty listing - args=["--documented", "a/b"], + args=["--output-format=documented", "a/b"], ) self.assert_console_output_ordered( "f:alias", " Exercises alias resolution.", " Further description.", - args=["--documented", "::"], + args=["--output-format=documented", "::"], ) + + def _list_json(self, targets): + return [ + json.loads(target_info) + for target_info in self.execute_rule( + args=["--output-format=json", *targets], + ).stdout.splitlines() + ] + + def test_list_json(self): + + f_alias, c3, d = tuple(self._list_json(["f:alias"])) + + assert f_alias["address"] == "f:alias" + assert f_alias["target_type"] == "target" + + assert c3["address"] == "a/b/c:c3" + assert c3["target_type"] == "java_library" + + assert d["address"] == "a/b/d:d" + assert d["target_type"] == "java_library" + + def test_list_json_distinct(self): + """Test that modifying sources will change the recorded fingerprints.""" + self.create_file("g/Test.java", contents="") + self.add_to_build_file( + "g", + dedent( + """\ + java_library( + name="a", + sources=["Test.java"], + ) + java_library( + name="b", + sources=["Test.java"], + ) + target( + name="c", + dependencies=[":b"], + ) + """ + ), + ) + + g_a_0, g_b_0, g_c_0 = tuple(self._list_json(["g:a", "g:b", "g:c"])) + + # Modify the source file and see that the fingerprints have changed. + self.create_file("g/Test.java", contents="\n\n\n") + + g_a_1, g_b_1, g_c_1 = tuple(self._list_json(["g:a", "g:b", "g:c"])) + + # Modified, because sources were changed. + assert g_a_0["intransitive_fingerprint"] != g_a_1["intransitive_fingerprint"] + assert g_a_0["transitive_fingerprint"] != g_a_1["transitive_fingerprint"] + + # Modified, because sources were changed. + assert g_b_0["intransitive_fingerprint"] != g_b_1["intransitive_fingerprint"] + assert g_b_0["transitive_fingerprint"] != g_b_1["transitive_fingerprint"] + + # Unchanged. + assert g_c_0["intransitive_fingerprint"] == g_c_1["intransitive_fingerprint"] + # Modified, because sources of the dependency g:b were changed. + assert g_c_0["transitive_fingerprint"] != g_c_1["transitive_fingerprint"]