def test_pep621_class_bad_config(
    config: str,
    expects: Type[Exception],
    match: str,
    tmp_pathplus: PathPlus,
):
    (tmp_pathplus / "pyproject.toml").write_clean(config)

    with in_directory(tmp_pathplus), pytest.raises(expects, match=match):
        PEP621Parser().parse(
            dom_toml.load(tmp_pathplus / "pyproject.toml")["project"])
def test_parse_config_readme_errors(filename: str, tmp_pathplus: PathPlus):
    config = dedent(f"""
[project]
name = "spam"
version = "2020.0.0"
readme = "{filename}"
""")
    (tmp_pathplus / "pyproject.toml").write_clean(config)
    (tmp_pathplus / filename).write_text("This is the readme.")

    with in_directory(tmp_pathplus), pytest.raises(
            ValueError, match=f"Unsupported extension for '{filename}'"):
        PEP621Parser().parse(
            dom_toml.load(tmp_pathplus / "pyproject.toml")["project"])
def test_pep621_class_valid_config(
    toml_config: str,
    tmp_pathplus: PathPlus,
    advanced_data_regression: AdvancedDataRegressionFixture,
    set_defaults: bool,
):

    (tmp_pathplus / "pyproject.toml").write_clean(toml_config)

    with in_directory(tmp_pathplus):
        config = PEP621Parser().parse(
            dom_toml.load(tmp_pathplus / "pyproject.toml")["project"],
            set_defaults=set_defaults,
        )

    advanced_data_regression.check(config)
def test_pep621_class_bad_config_license(
    license_key: str,
    expected: str,
    tmp_pathplus: PathPlus,
):

    (tmp_pathplus / "pyproject.toml").write_lines([
        f'[project]',
        f'name = "spam"',
        f'version = "2020.0.0"',
        license_key,
    ])

    with in_directory(tmp_pathplus), pytest.raises(BadConfigError,
                                                   match=expected):
        PEP621Parser().parse(
            dom_toml.load(tmp_pathplus / "pyproject.toml")["project"])
def test_pep621_class_valid_config_license_dict(
    tmp_pathplus: PathPlus,
    advanced_data_regression: AdvancedDataRegressionFixture,
):

    (tmp_pathplus / "pyproject.toml").write_lines([
        f'[project]',
        f'name = "spam"',
        f'version = "2020.0.0"',
        f'license = {{text = "This is the MIT License"}}',
    ])

    with in_directory(tmp_pathplus):
        config = PEP621Parser().parse(
            dom_toml.load(tmp_pathplus / "pyproject.toml")["project"])

    advanced_data_regression.check(config)
def test_pep621_class_bad_config_readme(
    readme: str,
    expected: str,
    exception: Type[Exception],
    tmp_pathplus: PathPlus,
):

    (tmp_pathplus / "pyproject.toml").write_lines([
        "[project]",
        'name = "spam"',
        'version = "2020.0.0"',
        readme,
    ])

    with in_directory(tmp_pathplus), pytest.raises(exception, match=expected):
        PEP621Parser().parse(
            dom_toml.load(tmp_pathplus / "pyproject.toml")["project"])
def test_pep621_class_valid_config_readme(
    filename: str,
    tmp_pathplus: PathPlus,
    advanced_data_regression: AdvancedDataRegressionFixture,
):

    (tmp_pathplus / "pyproject.toml").write_lines([
        "[project]",
        'name = "spam"',
        'version = "2020.0.0"',
        f'readme = {filename!r}',
    ])
    (tmp_pathplus / filename).write_text("This is the readme.")

    with in_directory(tmp_pathplus):
        config = PEP621Parser().parse(
            dom_toml.load(tmp_pathplus / "pyproject.toml")["project"])

    advanced_data_regression.check(config)
def test_pep621_class_valid_config_readme_dict(
    readme,
    tmp_pathplus: PathPlus,
    advanced_data_regression: AdvancedDataRegressionFixture,
):

    (tmp_pathplus / "pyproject.toml").write_lines([
        "[project]",
        'name = "spam"',
        'version = "2020.0.0"',
        readme,
    ])
    (tmp_pathplus /
     "README.rst").write_text("This is the reStructuredText README.")
    (tmp_pathplus / "README.md").write_text("This is the markdown README.")
    (tmp_pathplus / "README.txt").write_text("This is the plaintext README.")
    (tmp_pathplus / "README").write_text("This is the README.")

    with in_directory(tmp_pathplus):
        config = PEP621Parser().parse(
            dom_toml.load(tmp_pathplus / "pyproject.toml")["project"])

    advanced_data_regression.check(config)
class PyProject:
    """
	Represents a ``pyproject.toml`` file.

	:param build_system:

	.. autosummary-widths:: 23/64
		:html: 6/16

	.. autoclasssumm:: PyProject
		:autosummary-sections: Attributes

	.. clearpage::

	.. autoclasssumm:: PyProject
		:autosummary-sections: Methods
		:autosummary-exclude-members: __ge__,__gt__,__le__,__lt__,__ne__,__init__

	.. latex:vspace:: 10px
	"""

    #: Represents the :pep:`build-system table <518#build-system-table>` defined in :pep:`517` and :pep:`518`.
    build_system: Optional[BuildSystemDict] = attr.ib(default=None)

    #: Represents the :pep621:`project table <table-name>` defined in :pep:`621`.
    project: Optional[ProjectDict] = attr.ib(default=None)

    #: Represents the :pep:`tool table <518#tool-table>` defined in :pep:`518`.
    tool: Dict[str, Dict[str, Any]] = attr.ib(factory=dict)

    build_system_table_parser: ClassVar[BuildSystemParser] = BuildSystemParser(
    )
    """
	The :class:`dom_toml.parser.AbstractConfigParser`
	to parse the :pep:`build-system table <518#build-system-table>` with.
	"""

    project_table_parser: ClassVar[PEP621Parser] = PEP621Parser()
    """
	The :class:`dom_toml.parser.AbstractConfigParser`
	to parse the :pep621:`project table <table-name>` with.
	"""

    tool_parsers: ClassVar[Mapping[str, AbstractConfigParser]] = {}
    """
	A mapping of subtable names to :class:`dom_toml.parser.AbstractConfigParser`
	to parse the :pep:`tool table <518#tool-table>` with.

	For example, to parse ``[tool.whey]``:

	.. code-block:: python

		class WheyParser(AbstractConfigParser):
			pass

		class CustomPyProject(PyProject):
			tool_parsers = {"whey": WheyParser()}
	"""
    @classmethod
    def load(
        cls: Type[_PP],
        filename: PathLike,
        set_defaults: bool = False,
    ) -> _PP:
        """
		Load the ``pyproject.toml`` configuration mapping from the given file.

		:param filename:
		:param set_defaults: If :py:obj:`True`, passes ``set_defaults=True``
			the :meth:`parse() <dom_toml.parser.AbstractConfigParser.parse>` method on
			:attr:`~.build_system_table_parser` and :attr:`~.project_table_parser`.
		"""

        filename = PathPlus(filename)

        project_dir = filename.parent
        config = dom_toml.load(filename)

        keys = set(config.keys())

        build_system_table: Optional[BuildSystemDict] = None
        project_table: Optional[ProjectDict] = None
        tool_table: Dict[str, Dict[str, Any]] = {}

        with in_directory(project_dir):
            if "build-system" in config:
                build_system_table = cls.build_system_table_parser.parse(
                    config["build-system"], set_defaults=set_defaults)
                keys.remove("build-system")

            if "project" in config:
                project_table = cls.project_table_parser.parse(
                    config["project"], set_defaults=set_defaults)
                keys.remove("project")

            if "tool" in config:
                tool_table = config["tool"]
                keys.remove("tool")

                for tool_name, tool_subtable in tool_table.items():
                    if tool_name in cls.tool_parsers:
                        tool_table[tool_name] = cls.tool_parsers[
                            tool_name].parse(tool_subtable)

        if keys:
            allowed_top_level = ("build-system", "project", "tool")

            for top_level_key in sorted(keys):
                if top_level_key in allowed_top_level:
                    continue

                if normalize(top_level_key) in allowed_top_level:
                    raise BadConfigError(
                        f"Unexpected top-level key {top_level_key!r}. "
                        f"Did you mean {normalize(top_level_key)!r}?", )

                raise BadConfigError(
                    f"Unexpected top-level key {top_level_key!r}. "
                    f"Only {word_join(allowed_top_level, use_repr=True)} are allowed.",
                )

        return cls(
            build_system=build_system_table,
            project=project_table,
            tool=tool_table,
        )

    def dumps(
        self,
        encoder: Union[Type[toml.TomlEncoder],
                       toml.TomlEncoder] = PyProjectTomlEncoder,
    ) -> str:
        """
		Serialise to TOML.

		:param encoder: The :class:`toml.TomlEncoder` to use for constructing the output string.
		"""

        # TODO: filter out default values (lists and dicts)

        toml_dict: _PyProjectAsTomlDict = {
            "build-system": self.build_system,
            "project": self.project,
            "tool": self.tool
        }

        if toml_dict["project"] is not None:
            if "license" in toml_dict["project"] and toml_dict["project"][
                    "license"] is not None:
                toml_dict["project"] = {  # type: ignore
                    **toml_dict["project"],  # type: ignore
                    "license":
                    toml_dict["project"]["license"].to_pep621_dict()
                }

        if toml_dict["project"] is not None:
            if "readme" in toml_dict["project"] and toml_dict["project"][
                    "readme"] is not None:
                readme_dict = toml_dict["project"]["readme"].to_pep621_dict()

                if set(readme_dict.keys()) == {"file"}:
                    toml_dict["project"] = {
                        **toml_dict["project"], "readme": readme_dict["file"]
                    }  # type: ignore
                else:
                    toml_dict["project"] = {
                        **toml_dict["project"], "readme": readme_dict
                    }  # type: ignore

        return dom_toml.dumps(toml_dict, encoder)

    def dump(
        self,
        filename: PathLike,
        encoder: Union[Type[toml.TomlEncoder],
                       toml.TomlEncoder] = PyProjectTomlEncoder,
    ):
        """
		Write as TOML to the given file.

		:param filename: The filename to write to.
		:param encoder: The :class:`toml.TomlEncoder` to use for constructing the output string.

		:returns: A string containing the TOML representation.
		"""

        filename = PathPlus(filename)
        as_toml = self.dumps(encoder=encoder)
        filename.write_clean(as_toml)
        return as_toml

    @classmethod
    def reformat(
        cls: Type[_PP],
        filename: PathLike,
        encoder: Union[Type[toml.TomlEncoder],
                       toml.TomlEncoder] = PyProjectTomlEncoder,
    ) -> str:
        """
		Reformat the given ``pyproject.toml`` file.

		:param filename: The file to reformat.
		:param encoder: The :class:`toml.TomlEncoder` to use for constructing the output string.

		:returns: A string containing the reformatted TOML.

		.. versionchanged:: 0.2.0

			* Added the ``encoder`` argument.
			* The parser configured as :attr:`~.project_table_parser` is now used to parse
			  the :pep621:`project table <table-name>`, rather than always using :class:`~.PEP621Parser`.

		"""

        config = cls.load(filename, set_defaults=False)
        if config.project is not None and isinstance(config.project["name"],
                                                     _NormalisedName):
            config.project["name"] = config.project["name"].unnormalized

        return config.dump(filename, encoder=encoder)

    def resolve_files(self):
        """
		Resolve the ``file`` key in :pep621:`readme` and :pep621:`license`
		(if present) to retrieve the content of the file.

		Calling this method may mean it is no longer possible to recreate
		the original ``TOML`` file from this object.
		"""  # noqa: D400

        if self.project is not None:
            readme = self.project.get("readme", None)

            if readme is not None and isinstance(readme, Readme):
                readme.resolve(inplace=True)

            lic = self.project.get("license", None)

            if lic is not None and isinstance(lic, License):
                lic.resolve(inplace=True)

    @classmethod
    def from_dict(cls, d: Mapping[str, Any]):
        """
		Construct an instance of :class:`~.PyProject` from a dictionary.

		:param d: The dictionary.
		"""

        kwargs = {}

        for key, value in d.items():
            if key == "build-system":
                key = "build_system"

            kwargs[key] = value

        return cls(**kwargs)

    def to_dict(self) -> MutableMapping[str, Any]:
        """
		Returns a dictionary containing the contents of the class.

		.. seealso:: :func:`attr.asdict`
		"""

        return {
            "build_system": self.build_system,
            "project": self.project,
            "tool": self.tool,
        }