Esempio n. 1
0
class PutativeTarget:
    """A potential target to add, detected by various heuristics."""

    # Note that field order is such that the dataclass order will be by address (path+name).
    path: str
    name: str
    type_alias: str

    # The sources that triggered creating of this putative target.
    # The putative target will own these sources, but may also glob over other sources.
    triggering_sources: Tuple[str, ...]

    # The globs of sources owned by this target.
    # If kwargs contains an explicit sources key, it should be identical to this value.
    # Otherwise, this field should contain the default globs that the target type will apply.
    # TODO: If target_type is a regular target (and not a macro) we can derive the default
    #  source globs for that type from BuildConfiguration.  However that is fiddly and not
    #  a high priority.
    owned_sources: Tuple[str, ...]

    # Note that we generate the BUILD file target entry exclusively from these kwargs (plus the
    # type_alias), not from the fields above, which are broken out for other uses.
    # This allows the creator of instances of this class to control whether the generated
    # target should assume default kwarg values or provide them explicitly.
    kwargs: FrozenDict[str, str | int | bool | Tuple[str, ...]]

    # Any comment lines to add above the BUILD file stanza we generate for this putative target.
    # Should include the `#` prefix, which will not be added.
    comments: Tuple[str, ...]

    # The name of the BUILD file to generate this putative target in. Typically just `BUILD`,
    # but `BUILD.suffix` for any suffix is also valid.
    build_file_name: str

    @classmethod
    def for_target_type(
            cls,
            target_type: Type[Target],
            path: str,
            name: str,
            triggering_sources: Iterable[str],
            kwargs: Mapping[str, str | int | bool | Tuple[str, ...]]
        | None = None,
            comments: Iterable[str] = tuple(),
            build_file_name: str = "BUILD",
    ):
        owned_sources = ((kwargs or {}).get("sources")
                         or default_sources_for_target_type(target_type)
                         or tuple())
        return cls(
            path,
            name,
            target_type.alias,
            triggering_sources,
            owned_sources,  # type: ignore[arg-type]
            kwargs=kwargs,
            comments=comments,
            build_file_name=build_file_name,
        )

    def __init__(
        self,
        path: str,
        name: str,
        type_alias: str,
        triggering_sources: Iterable[str],
        owned_sources: Iterable[str],
        *,
        kwargs: Mapping[str, str | int | bool | Tuple[str, ...]] | None = None,
        comments: Iterable[str] = tuple(),
        build_file_name: str = "BUILD",
    ) -> None:
        self.path = path
        self.name = name
        self.type_alias = type_alias
        self.triggering_sources = tuple(triggering_sources)
        self.owned_sources = tuple(owned_sources)
        self.kwargs = FrozenDict(kwargs or {})
        self.comments = tuple(comments)
        self.build_file_name = build_file_name

    @property
    def build_file_path(self) -> str:
        return os.path.join(self.path, self.build_file_name)

    @property
    def address(self) -> Address:
        return Address(self.path, target_name=self.name)

    def rename(self, new_name: str) -> PutativeTarget:
        """A copy of this object with the name replaced to the given name."""
        # We assume that a rename imposes an explicit "name=" kwarg, overriding any previous
        # explicit "name=" kwarg, even if the rename happens to be to the default name.
        return dataclasses.replace(self,
                                   name=new_name,
                                   kwargs={
                                       **self.kwargs, "name": new_name
                                   })

    def restrict_sources(self) -> PutativeTarget:
        """A copy of this object with the sources explicitly set to just the triggering sources."""
        owned_sources = self.triggering_sources
        return dataclasses.replace(
            self,
            owned_sources=owned_sources,
            kwargs={
                **self.kwargs, "sources": owned_sources
            },
        )

    def add_comments(self, comments: Iterable[str]) -> PutativeTarget:
        return dataclasses.replace(self,
                                   comments=self.comments + tuple(comments))

    def generate_build_file_stanza(self, indent: str) -> str:
        def fmt_val(v) -> str:
            if isinstance(v, str):
                return f'"{v}"'
            if isinstance(v, tuple):
                val_parts = [f"\n{indent*2}{fmt_val(x)}" for x in v]
                val_str = ",".join(val_parts)
                return f"[{val_str},\n{indent}]"
            return repr(v)

        if self.kwargs:
            kwargs_str_parts = [
                f"\n{indent}{k}={fmt_val(v)}" for k, v in self.kwargs.items()
            ]
            kwargs_str = ",".join(kwargs_str_parts) + ",\n"
        else:
            kwargs_str = ""

        comment_str = ("\n".join(self.comments) +
                       "\n") if self.comments else ""
        return f"{comment_str}{self.type_alias}({kwargs_str})\n"
Esempio n. 2
0
class Address(EngineAwareParameter):
    """The unique address for a `Target`.

    Targets explicitly declared in BUILD files use the format `path/to:tgt`, whereas targets
    generated from other targets use the format `path/to:generator#generated`.
    """
    def __init__(
        self,
        spec_path: str,
        *,
        target_name: str | None = None,
        parameters: Mapping[str, str] | None = None,
        generated_name: str | None = None,
        relative_file_path: str | None = None,
    ) -> None:
        """
        :param spec_path: The path from the build root to the directory containing the BUILD file
          for the target. If the target is generated, this is the path to the generator target.
        :param target_name: The name of the target. For generated targets, this is the name of
            its target generator. If the `name` is left off (i.e. the default), set to `None`.
        :param parameters: A series of key-value pairs which are incorporated into the identity of
            the Address.
        :param generated_name: The name of what is generated. You can use a file path if the
            generated target represents an entity from the file system, such as `a/b/c` or
            `subdir/f.ext`.
        :param relative_file_path: The relative path from the spec_path to an addressed file,
          if any. Because files must always be located below targets that apply metadata to
          them, this will always be relative.
        """
        self.spec_path = spec_path
        self.parameters = FrozenDict(
            parameters) if parameters else FrozenDict()
        self.generated_name = generated_name
        self._relative_file_path = relative_file_path
        if generated_name:
            if relative_file_path:
                raise AssertionError(
                    f"Do not use both `generated_name` ({generated_name}) and "
                    f"`relative_file_path` ({relative_file_path}).")
            banned_chars = BANNED_CHARS_IN_GENERATED_NAME & set(generated_name)
            if banned_chars:
                raise InvalidTargetName(
                    f"The generated name `{generated_name}` (defined in directory "
                    f"{self.spec_path}, the part after `#`) contains banned characters "
                    f"(`{'`,`'.join(banned_chars)}`). Please replace "
                    "these characters with another separator character like `_`, `-`, or `/`."
                )

        # If the target_name is the same as the default name would be, we normalize to None.
        self._target_name = None
        if target_name and target_name != os.path.basename(self.spec_path):
            banned_chars = BANNED_CHARS_IN_TARGET_NAME & set(target_name)
            if banned_chars:
                raise InvalidTargetName(
                    f"The target name {target_name} (defined in directory {self.spec_path}) "
                    f"contains banned characters (`{'`,`'.join(banned_chars)}`). Please replace "
                    "these characters with another separator character like `_` or `-`."
                )
            self._target_name = target_name

        self._hash = hash((self.spec_path, self._target_name,
                           self.generated_name, self._relative_file_path))
        if PurePath(spec_path).name.startswith("BUILD"):
            raise InvalidSpecPath(
                f"The address {self.spec} has {PurePath(spec_path).name} as the last part of its "
                f"path, but BUILD is a reserved name. Please make sure that you did not name any "
                f"directories BUILD.")

    @property
    def is_generated_target(self) -> bool:
        return self.generated_name is not None or self.is_file_target

    @property
    def is_file_target(self) -> bool:
        return self._relative_file_path is not None

    @property
    def is_default_target(self) -> bool:
        """True if this is address refers to the "default" target in the spec_path.

        The default target has a target name equal to the directory name.
        """
        return self._target_name is None

    @property
    def is_parametrized(self) -> bool:
        return bool(self.parameters)

    def is_parametrized_subset_of(self, other: Address) -> bool:
        """True if this Address is == to the given Address, but with a subset of its parameters."""
        if not self._equal_without_parameters(other):
            return False
        return self.parameters.items() <= other.parameters.items()

    @property
    def filename(self) -> str:
        if self._relative_file_path is None:
            raise AssertionError(
                f"Only a file Address (`self.is_file_target`) has a filename: {self}"
            )
        return os.path.join(self.spec_path, self._relative_file_path)

    @property
    def target_name(self) -> str:
        if self._target_name is None:
            return os.path.basename(self.spec_path)
        return self._target_name

    @property
    def parameters_repr(self) -> str:
        if not self.parameters:
            return ""
        rhs = ",".join(f"{k}={v}" for k, v in self.parameters.items())
        return f"@{rhs}"

    @property
    def spec(self) -> str:
        """The canonical string representation of the Address.

        Prepends '//' if the target is at the root, to disambiguate build root level targets
        from "relative" spec notation.

        :API: public
        """
        prefix = "//" if not self.spec_path else ""
        if self._relative_file_path is None:
            path = self.spec_path
            target = ("" if self._target_name is None and
                      (self.generated_name or self.parameters) else
                      self.target_name)
        else:
            path = self.filename
            parent_prefix = "../" * self._relative_file_path.count(os.path.sep)
            target = ("" if self._target_name is None and not parent_prefix
                      else f"{parent_prefix}{self.target_name}")
        target_sep = ":" if target else ""
        generated = "" if self.generated_name is None else f"#{self.generated_name}"
        return f"{prefix}{path}{target_sep}{target}{generated}{self.parameters_repr}"

    @property
    def path_safe_spec(self) -> str:
        """
        :API: public
        """
        def sanitize(s: str) -> str:
            return s.replace(os.path.sep, ".")

        if self._relative_file_path:
            parent_count = self._relative_file_path.count(os.path.sep)
            parent_prefix = "@" * parent_count if parent_count else "."
            path = f".{sanitize(self._relative_file_path)}"
        else:
            parent_prefix = "."
            path = ""
        if parent_prefix == ".":
            target = f"{parent_prefix}{self._target_name}" if self._target_name else ""
        else:
            target = f"{parent_prefix}{self.target_name}"
        if self.parameters:
            key_value_strs = ",".join(f"{sanitize(k)}={sanitize(v)}"
                                      for k, v in self.parameters.items())
            params = f"@@{key_value_strs}"
        else:
            params = ""
        generated = f"@{sanitize(self.generated_name)}" if self.generated_name else ""
        prefix = sanitize(self.spec_path)
        return f"{prefix}{path}{target}{generated}{params}"

    def parametrize(self, parameters: Mapping[str, str]) -> Address:
        """Creates a new Address with the given `parameters` merged over self.parameters."""
        merged_parameters = {**self.parameters, **parameters}
        return self.__class__(
            self.spec_path,
            target_name=self._target_name,
            generated_name=self.generated_name,
            relative_file_path=self._relative_file_path,
            parameters=merged_parameters,
        )

    def maybe_convert_to_target_generator(self) -> Address:
        """If this address is generated or parametrized, convert it to its generator target.

        Otherwise, return self unmodified.
        """
        if self.is_generated_target or self.is_parametrized:
            return self.__class__(self.spec_path,
                                  target_name=self._target_name)
        return self

    def create_generated(self, generated_name: str) -> Address:
        if self.is_generated_target:
            raise AssertionError(
                f"Cannot call `create_generated` on `{self}`.")
        return self.__class__(
            self.spec_path,
            target_name=self._target_name,
            parameters=self.parameters,
            generated_name=generated_name,
        )

    def create_file(self, relative_file_path: str) -> Address:
        if self.is_generated_target:
            raise AssertionError(f"Cannot call `create_file` on `{self}`.")
        return self.__class__(
            self.spec_path,
            target_name=self._target_name,
            parameters=self.parameters,
            relative_file_path=relative_file_path,
        )

    def _equal_without_parameters(self, other: Address) -> bool:
        return (self.spec_path == other.spec_path
                and self._target_name == other._target_name
                and self.generated_name == other.generated_name
                and self._relative_file_path == other._relative_file_path)

    def __eq__(self, other):
        if not isinstance(other, Address):
            return False
        return self._equal_without_parameters(
            other) and self.parameters == other.parameters

    def __hash__(self):
        return self._hash

    def __repr__(self) -> str:
        return f"Address({self.spec})"

    def __str__(self) -> str:
        return self.spec

    def __lt__(self, other):
        # NB: This ordering is intentional so that we match the spec format:
        # `{spec_path}{relative_file_path}:{tgt_name}#{generated_name}`.
        return (
            self.spec_path,
            self._relative_file_path or "",
            self._target_name or "",
            self.parameters,
            self.generated_name or "",
        ) < (
            other.spec_path,
            other._relative_file_path or "",
            other._target_name or "",
            self.parameters,
            other.generated_name or "",
        )

    def debug_hint(self) -> str:
        return self.spec

    def metadata(self) -> dict[str, Any]:
        return {"address": self.spec}
Esempio n. 3
0
class PutativeTarget:
    """A potential target to add, detected by various heuristics.

    This class uses the term "target" in the loose sense. It can also represent an invocation of a
    target-generating macro.
    """

    # Note that field order is such that the dataclass order will be by address (path+name).
    path: str
    name: str
    type_alias: str

    # The sources that triggered creating of this putative target.
    # The putative target will own these sources, but may also glob over other sources.
    # If the putative target does not have a `sources` field, then this value must be the
    # empty tuple.
    triggering_sources: Tuple[str, ...]

    # The globs of sources owned by this target.
    # If kwargs contains an explicit sources key, it should be identical to this value.
    # Otherwise, this field should contain the default globs that the target type will apply.
    # If the putative target does not have a `sources` field, then this value must be the
    # empty tuple.
    # TODO: If target_type is a regular target (and not a macro) we can derive the default
    #  source globs for that type from BuildConfiguration.  However that is fiddly and not
    #  a high priority.
    owned_sources: Tuple[str, ...]

    # Whether the pututative target has an address (or, e.g., is a macro with no address).
    addressable: bool

    # Note that we generate the BUILD file target entry exclusively from these kwargs (plus the
    # type_alias), not from the fields above, which are broken out for other uses.
    # This allows the creator of instances of this class to control whether the generated
    # target should assume default kwarg values or provide them explicitly.
    kwargs: FrozenDict[str, str | int | bool | Tuple[str, ...]]

    # Any comment lines to add above the BUILD file stanza we generate for this putative target.
    # Should include the `#` prefix, which will not be added.
    comments: Tuple[str, ...]

    @classmethod
    def for_target_type(
            cls,
            target_type: Type[Target],
            path: str,
            name: str,
            triggering_sources: Iterable[str],
            kwargs: Mapping[str, str | int | bool | Tuple[str, ...]]
        | None = None,
            comments: Iterable[str] = tuple(),
    ):
        explicit_sources = (kwargs or {}).get("sources")
        if explicit_sources is not None and not isinstance(
                explicit_sources, tuple):
            raise TypeError(
                "Explicit sources passed to PutativeTarget.for_target_type must be a Tuple[str]."
            )

        default_sources = default_sources_for_target_type(target_type)
        if (explicit_sources or triggering_sources) and not default_sources:
            raise ValueError(
                f"A target of type {target_type.__name__} was proposed at "
                f"address {path}:{name} with explicit sources {', '.join(explicit_sources or triggering_sources)}, "
                "but this target type does not have a `sources` field.")
        owned_sources = explicit_sources or default_sources or tuple()
        return cls(
            path,
            name,
            target_type.alias,
            triggering_sources,
            owned_sources,
            addressable=True,  # "Real" targets are always addressable.
            kwargs=kwargs,
            comments=comments,
        )

    def __init__(
            self,
            path: str,
            name: str,
            type_alias: str,
            triggering_sources: Iterable[str],
            owned_sources: Iterable[str],
            *,
            addressable: bool = True,
            kwargs: Mapping[str, str | int | bool | Tuple[str, ...]]
        | None = None,
            comments: Iterable[str] = tuple(),
    ) -> None:
        self.path = path
        self.name = name
        self.type_alias = type_alias
        self.triggering_sources = tuple(triggering_sources)
        self.owned_sources = tuple(owned_sources)
        self.addressable = addressable
        self.kwargs = FrozenDict(kwargs or {})
        self.comments = tuple(comments)

    @property
    def address(self) -> Address:
        if not self.addressable:
            raise ValueError(
                f"Cannot compute address for non-addressable putative target of type "
                f"{self.type_alias} at path {self.path}")
        return Address(self.path, target_name=self.name)

    def realias(self, new_alias: str | None) -> PutativeTarget:
        """A copy of this object with the alias replaced to the given alias.

        Returns this object if the alias is None or is identical to this objects existing alias.
        """
        return (self if (new_alias is None or new_alias == self.type_alias)
                else dataclasses.replace(self, type_alias=new_alias))

    def rename(self, new_name: str) -> PutativeTarget:
        """A copy of this object with the name replaced to the given name."""
        # We assume that a rename imposes an explicit "name=" kwarg, overriding any previous
        # explicit "name=" kwarg, even if the rename happens to be to the default name.
        return dataclasses.replace(self,
                                   name=new_name,
                                   kwargs={
                                       **self.kwargs, "name": new_name
                                   })

    def restrict_sources(self) -> PutativeTarget:
        """A copy of this object with the sources explicitly set to just the triggering sources."""
        owned_sources = self.triggering_sources
        return dataclasses.replace(
            self,
            owned_sources=owned_sources,
            kwargs={
                **self.kwargs, "sources": owned_sources
            },
        )

    def add_comments(self, comments: Iterable[str]) -> PutativeTarget:
        return dataclasses.replace(self,
                                   comments=self.comments + tuple(comments))

    def generate_build_file_stanza(self, indent: str) -> str:
        def fmt_val(v) -> str:
            if isinstance(v, str):
                return f'"{v}"'
            if isinstance(v, tuple):
                val_parts = [f"\n{indent*2}{fmt_val(x)}" for x in v]
                val_str = ",".join(val_parts) + ("," if v else "")
                return f"[{val_str}\n{indent}]"
            return repr(v)

        if self.kwargs:
            kwargs_str_parts = [
                f"\n{indent}{k}={fmt_val(v)}" for k, v in self.kwargs.items()
            ]
            kwargs_str = ",".join(kwargs_str_parts) + ",\n"
        else:
            kwargs_str = ""

        comment_str = ("\n".join(self.comments) +
                       "\n") if self.comments else ""
        return f"{comment_str}{self.type_alias}({kwargs_str})\n"
Esempio n. 4
0
class AddressInput:
    """A string that has been parsed and normalized using the Address syntax.

    An AddressInput must be resolved into an Address using the engine (which involves inspecting
    disk to determine the types of its path component).
    """

    path_component: str
    target_component: str | None
    generated_component: str | None
    parameters: FrozenDict[str, str]
    description_of_origin: str

    def __init__(
        self,
        path_component: str,
        target_component: str | None = None,
        *,
        generated_component: str | None = None,
        parameters: Mapping[str, str] = FrozenDict(),
        description_of_origin: str,
    ) -> None:
        self.path_component = path_component
        self.target_component = target_component
        self.generated_component = generated_component
        self.parameters = FrozenDict(parameters)
        self.description_of_origin = description_of_origin

        if not self.target_component:
            if self.target_component is not None:
                raise InvalidTargetName(
                    softwrap(f"""
                        Address `{self.spec}` from {self.description_of_origin} sets
                        the name component to the empty string, which is not legal.
                        """))
            if self.path_component == "":
                raise InvalidTargetName(
                    softwrap(f"""
                        Address `{self.spec}` from {self.description_of_origin} has no name part,
                        but it's necessary because the path is the build root.
                        """))

        if self.path_component != "":
            if os.path.isabs(self.path_component):
                raise InvalidSpecPath(
                    softwrap(f"""
                        Invalid address {self.spec} from {self.description_of_origin}. Cannot use
                        absolute paths.
                        """))

            invalid_component = next(
                (component for component in self.path_component.split(os.sep)
                 if component in (".", "..", "")),
                None,
            )
            if invalid_component is not None:
                raise InvalidSpecPath(
                    softwrap(f"""
                        Invalid address `{self.spec}` from {self.description_of_origin}. It has an
                        un-normalized path part: '{os.sep}{invalid_component}'.
                        """))

        for k, v in self.parameters.items():
            key_banned = set(BANNED_CHARS_IN_PARAMETERS & set(k))
            if key_banned:
                raise InvalidParameters(
                    softwrap(f"""
                        Invalid address `{self.spec}` from {self.description_of_origin}. It has
                        illegal characters in parameter keys: `{key_banned}` in `{k}={v}`.
                        """))
            val_banned = set(BANNED_CHARS_IN_PARAMETERS & set(v))
            if val_banned:
                raise InvalidParameters(
                    softwrap(f"""
                        Invalid address `{self.spec}` from {self.description_of_origin}. It has
                        illegal characters in parameter values: `{val_banned}` in `{k}={v}`.
                        """))

    @classmethod
    def parse(
        cls,
        spec: str,
        *,
        relative_to: str | None = None,
        subproject_roots: Sequence[str] | None = None,
        description_of_origin: str,
    ) -> AddressInput:
        """Parse a string into an AddressInput.

        :param spec: Target address spec.
        :param relative_to: path to use for sibling specs, ie: ':another_in_same_build_family',
          interprets the missing spec_path part as `relative_to`.
        :param subproject_roots: Paths that correspond with embedded build roots under
          the current build root.
        :param description_of_origin: where the AddressInput comes from, e.g. "CLI arguments" or
          "the option `--paths-from`". This is used for better error messages.

        For example:

            some_target(
                name='mytarget',
                dependencies=['path/to/buildfile:targetname'],
            )

        Where `path/to/buildfile:targetname` is the dependent target address spec.

        In there is no target name component, it defaults the default target in the resulting
        Address's spec_path.

        Optionally, specs can be prefixed with '//' to denote an absolute spec path. This is
        normally not significant except when a spec referring to a root level target is needed
        from deeper in the tree. For example, in `path/to/buildfile/BUILD`:

            some_target(
                name='mytarget',
                dependencies=[':targetname'],
            )

        The `targetname` spec refers to a target defined in `path/to/buildfile/BUILD*`. If instead
        you want to reference `targetname` in a root level BUILD file, use the absolute form.
        For example:

            some_target(
                name='mytarget',
                dependencies=['//:targetname'],
            )

        The spec may be for a generated target: `dir:generator#generated`.

        The spec may be a file, such as `a/b/c.txt`. It may include a relative address spec at the
        end, such as `a/b/c.txt:original` or `a/b/c.txt:../original`, to disambiguate which target
        the file comes from; otherwise, it will be assumed to come from the default target in the
        directory, i.e. a target which leaves off `name`.
        """
        subproject = (longest_dir_prefix(relative_to, subproject_roots)
                      if relative_to and subproject_roots else None)

        def prefix_subproject(spec_path: str) -> str:
            if not subproject:
                return spec_path
            if spec_path:
                return os.path.join(subproject, spec_path)
            return os.path.normpath(subproject)

        (
            (
                path_component,
                target_component,
                generated_component,
                parameters,
            ),
            wildcard,
        ) = native_engine.address_spec_parse(spec)

        if wildcard:
            raise UnsupportedWildcard(
                softwrap(f"""
                    The address `{spec}` from {description_of_origin} ended in a wildcard
                    (`{wildcard}`), which is not supported.
                    """))

        normalized_relative_to = None
        if relative_to:
            normalized_relative_to = (fast_relpath(relative_to, subproject)
                                      if subproject else relative_to)
        if path_component.startswith("./") and normalized_relative_to:
            path_component = os.path.join(normalized_relative_to,
                                          path_component[2:])
        if not path_component and normalized_relative_to:
            path_component = normalized_relative_to

        path_component = prefix_subproject(strip_prefix(path_component, "//"))

        return cls(
            path_component,
            target_component,
            generated_component=generated_component,
            parameters=FrozenDict(sorted(parameters)),
            description_of_origin=description_of_origin,
        )

    def file_to_address(self) -> Address:
        """Converts to an Address by assuming that the path_component is a file on disk."""
        if self.target_component is None:
            # Use the default target in the same directory as the file.
            spec_path, relative_file_path = os.path.split(self.path_component)
            # We validate that this is not a top-level file. We couldn't do this earlier in the
            # AddressSpec constructor because we weren't sure if the path_spec referred to a file
            # vs. a directory.
            if not spec_path:
                raise InvalidTargetName(
                    softwrap(f"""
                        Addresses for generated first-party targets in the build root must include
                        which target generator they come from, such as
                        `{self.path_component}:original_target`. However, `{self.spec}`
                        from {self.description_of_origin} did not have a target name.
                        """))
            return Address(
                spec_path=spec_path,
                relative_file_path=relative_file_path,
                parameters=self.parameters,
            )

        # The target component may be "above" (but not below) the file in the filesystem.
        # Determine how many levels above the file it is, and validate that the path is relative.
        parent_count = self.target_component.count(os.path.sep)
        if parent_count == 0:
            spec_path, relative_file_path = os.path.split(self.path_component)
            return Address(
                spec_path=spec_path,
                relative_file_path=relative_file_path,
                target_name=self.target_component,
                parameters=self.parameters,
            )

        expected_prefix = f"..{os.path.sep}" * parent_count
        if self.target_component[:self.target_component.rfind(os.path.sep) +
                                 1] != expected_prefix:
            raise InvalidTargetName(
                softwrap(f"""
                    Invalid address `{self.spec}` from {self.description_of_origin}. The target
                    name portion of the address must refer to a target defined in the same
                    directory or a parent directory of the file path `{self.path_component}`, but
                    the value `{self.target_component}` is a subdirectory.
                    """))

        # Split the path_component into a spec_path and relative_file_path at the appropriate
        # position.
        path_components = self.path_component.split(os.path.sep)
        if len(path_components) <= parent_count:
            raise InvalidTargetName(
                softwrap(f"""
                    Invalid address `{self.spec}` from {self.description_of_origin}. The target
                    name portion of the address `{self.target_component}` has too many `../`, which
                    means it refers to a directory above the file path `{self.path_component}`.
                    Expected no more than {len(path_components) -1 } instances of `../` in
                    `{self.target_component}`, but found {parent_count} instances.
                    """))
        offset = -1 * (parent_count + 1)
        spec_path = os.path.join(
            *path_components[:offset]) if path_components[:offset] else ""
        relative_file_path = os.path.join(*path_components[offset:])
        target_name = os.path.basename(self.target_component)
        return Address(
            spec_path,
            relative_file_path=relative_file_path,
            target_name=target_name,
            parameters=self.parameters,
        )

    def dir_to_address(self) -> Address:
        """Converts to an Address by assuming that the path_component is a directory on disk."""
        return Address(
            spec_path=self.path_component,
            target_name=self.target_component,
            generated_name=self.generated_component,
            parameters=self.parameters,
        )

    @property
    def spec(self) -> str:
        rep = self.path_component or "//"
        if self.generated_component:
            rep += f"#{self.generated_component}"
        if self.target_component:
            rep += f":{self.target_component}"
        if self.parameters:
            params_vals = ",".join(f"{k}={v}"
                                   for k, v in self.parameters.items())
            rep += f"@{params_vals}"
        return rep