From 87df7e2fd8a447ff7406b02c248fc0b0290a5aa0 Mon Sep 17 00:00:00 2001 From: Ben Mares Date: Fri, 27 Dec 2024 19:05:28 +0100 Subject: [PATCH 01/13] Fix incorrect type hint on CoreMetadata.version --- backend/src/hatchling/cli/version/__init__.py | 5 +-- backend/src/hatchling/metadata/core.py | 31 +++++++++++-------- 2 files changed, 21 insertions(+), 15 deletions(-) diff --git a/backend/src/hatchling/cli/version/__init__.py b/backend/src/hatchling/cli/version/__init__.py index 20cae309e..b776ebd94 100644 --- a/backend/src/hatchling/cli/version/__init__.py +++ b/backend/src/hatchling/cli/version/__init__.py @@ -1,7 +1,7 @@ from __future__ import annotations import argparse -from typing import Any +from typing import Any, cast def version_impl( @@ -25,7 +25,8 @@ def version_impl( if desired_version: app.abort('Cannot set version when it is statically defined by the `project.version` field') else: - app.display(metadata.core.version) + static_version = cast(str, metadata.core.version) + app.display(static_version) return source = metadata.hatch.version.source diff --git a/backend/src/hatchling/metadata/core.py b/backend/src/hatchling/metadata/core.py index 88ed55775..5745492b6 100644 --- a/backend/src/hatchling/metadata/core.py +++ b/backend/src/hatchling/metadata/core.py @@ -434,9 +434,13 @@ def name(self) -> str: return self._name @property - def version(self) -> str: + def version(self) -> str | None: """ https://peps.python.org/pep-0621/#version + + If the version is dynamic, but not yet computed, return `None`. + Otherwise, return the static version, the version from PKG-INFO, + or the dynamically-computed version. """ version: str @@ -448,20 +452,21 @@ def version(self) -> str: 'if `version` is in field `project.dynamic`' ) raise ValueError(message) - else: - if 'version' in self.dynamic: - message = ( - 'Metadata field `version` cannot be both statically defined and ' - 'listed in field `project.dynamic`' - ) - raise ValueError(message) + # The version is dynamic, but not yet computed. + return None + if 'version' in self.dynamic: + message = ( + 'Metadata field `version` cannot be both statically defined and ' + 'listed in field `project.dynamic`' + ) + raise ValueError(message) - version = self.config['version'] - if not isinstance(version, str): - message = 'Field `project.version` must be a string' - raise TypeError(message) + version = self.config['version'] + if not isinstance(version, str): + message = 'Field `project.version` must be a string' + raise TypeError(message) - self._version = version + self._version = version return cast(str, self._version) From 4104537b1681c29b042415fae66147956e57f48d Mon Sep 17 00:00:00 2001 From: Ben Mares Date: Sat, 14 Dec 2024 13:27:27 +0100 Subject: [PATCH 02/13] Import cached_property Sort imports --- backend/src/hatchling/metadata/core.py | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/src/hatchling/metadata/core.py b/backend/src/hatchling/metadata/core.py index 5745492b6..9e5874600 100644 --- a/backend/src/hatchling/metadata/core.py +++ b/backend/src/hatchling/metadata/core.py @@ -4,6 +4,7 @@ import sys from contextlib import suppress from copy import deepcopy +from functools import cached_property from typing import TYPE_CHECKING, Any, Generic, cast from hatchling.metadata.utils import ( From 0d084fccc98ac55173e32f818dd16c2414405e98 Mon Sep 17 00:00:00 2001 From: Ben Mares Date: Sat, 14 Dec 2024 13:28:08 +0100 Subject: [PATCH 03/13] Convert first group of ProjectMetadata to cached_property --- backend/src/hatchling/metadata/core.py | 139 +++++++++++-------------- 1 file changed, 61 insertions(+), 78 deletions(-) diff --git a/backend/src/hatchling/metadata/core.py b/backend/src/hatchling/metadata/core.py index 9e5874600..a3588da4a 100644 --- a/backend/src/hatchling/metadata/core.py +++ b/backend/src/hatchling/metadata/core.py @@ -48,11 +48,6 @@ def __init__( self.plugin_manager = plugin_manager self._config = config - self._context: Context | None = None - self._build: BuildMetadata | None = None - self._core: CoreMetadata | None = None - self._hatch: HatchMetadata | None = None - self._core_raw_metadata: dict[str, Any] | None = None self._dynamic: list[str] | None = None self._name: str | None = None @@ -69,14 +64,11 @@ def has_project_file(self) -> bool: return False return os.path.isfile(self._project_file) - @property + @cached_property def context(self) -> Context: - if self._context is None: - from hatchling.utils.context import Context + from hatchling.utils.context import Context - self._context = Context(self.root) - - return self._context + return Context(self.root) @property def core_raw_metadata(self) -> dict[str, Any]: @@ -165,80 +157,71 @@ def config(self) -> dict[str, Any]: return self._config - @property + @cached_property def build(self) -> BuildMetadata: - if self._build is None: - build_metadata = self.config.get('build-system', {}) - if not isinstance(build_metadata, dict): - message = 'The `build-system` configuration must be a table' - raise TypeError(message) - - self._build = BuildMetadata(self.root, build_metadata) + build_metadata = self.config.get('build-system', {}) + if not isinstance(build_metadata, dict): + message = 'The `build-system` configuration must be a table' + raise TypeError(message) - return self._build + return BuildMetadata(self.root, build_metadata) - @property + @cached_property def core(self) -> CoreMetadata: - if self._core is None: - metadata = CoreMetadata(self.root, self.core_raw_metadata, self.hatch.metadata, self.context) - - # Save the fields - _ = self.dynamic - - metadata_hooks = self.hatch.metadata.hooks - if metadata_hooks: - static_fields = set(self.core_raw_metadata) - if 'version' in self.hatch.config: - self._version = self._get_version(metadata) - self.core_raw_metadata['version'] = self.version - - if metadata.dynamic: - for metadata_hook in metadata_hooks.values(): - metadata_hook.update(self.core_raw_metadata) - metadata.add_known_classifiers(metadata_hook.get_known_classifiers()) - - new_fields = set(self.core_raw_metadata) - static_fields - for new_field in new_fields: - if new_field in metadata.dynamic: - metadata.dynamic.remove(new_field) - else: - message = ( - f'The field `{new_field}` was set dynamically and therefore must be ' - f'listed in `project.dynamic`' - ) - raise ValueError(message) - - self._core = metadata + metadata = CoreMetadata(self.root, self.core_raw_metadata, self.hatch.metadata, self.context) + + # Save the fields + _ = self.dynamic + + metadata_hooks = self.hatch.metadata.hooks + if metadata_hooks: + static_fields = set(self.core_raw_metadata) + if 'version' in self.hatch.config: + self._version = self._get_version(metadata) + self.core_raw_metadata['version'] = self.version + + if metadata.dynamic: + for metadata_hook in metadata_hooks.values(): + metadata_hook.update(self.core_raw_metadata) + metadata.add_known_classifiers(metadata_hook.get_known_classifiers()) + + new_fields = set(self.core_raw_metadata) - static_fields + for new_field in new_fields: + if new_field in metadata.dynamic: + metadata.dynamic.remove(new_field) + else: + message = ( + f'The field `{new_field}` was set dynamically and therefore must be ' + f'listed in `project.dynamic`' + ) + raise ValueError(message) - return self._core + return metadata - @property + @cached_property def hatch(self) -> HatchMetadata: - if self._hatch is None: - tool_config = self.config.get('tool', {}) - if not isinstance(tool_config, dict): - message = 'The `tool` configuration must be a table' - raise TypeError(message) - - hatch_config = tool_config.get('hatch', {}) - if not isinstance(hatch_config, dict): - message = 'The `tool.hatch` configuration must be a table' - raise TypeError(message) - - hatch_file = ( - os.path.join(os.path.dirname(self._project_file), DEFAULT_CONFIG_FILE) - if self._project_file is not None - else locate_file(self.root, DEFAULT_CONFIG_FILE) or '' - ) - - if hatch_file and os.path.isfile(hatch_file): - config = load_toml(hatch_file) - hatch_config = hatch_config.copy() - hatch_config.update(config) - - self._hatch = HatchMetadata(self.root, hatch_config, self.plugin_manager) - - return self._hatch + tool_config = self.config.get('tool', {}) + if not isinstance(tool_config, dict): + message = 'The `tool` configuration must be a table' + raise TypeError(message) + + hatch_config = tool_config.get('hatch', {}) + if not isinstance(hatch_config, dict): + message = 'The `tool.hatch` configuration must be a table' + raise TypeError(message) + + hatch_file = ( + os.path.join(os.path.dirname(self._project_file), DEFAULT_CONFIG_FILE) + if self._project_file is not None + else locate_file(self.root, DEFAULT_CONFIG_FILE) or '' + ) + + if hatch_file and os.path.isfile(hatch_file): + config = load_toml(hatch_file) + hatch_config = hatch_config.copy() + hatch_config.update(config) + + return HatchMetadata(self.root, hatch_config, self.plugin_manager) def _get_version(self, core_metadata: CoreMetadata | None = None) -> str: if core_metadata is None: From 6f6f1bdb87deaecbfac2ab32682b8a53395a6f5f Mon Sep 17 00:00:00 2001 From: Ben Mares Date: Sat, 14 Dec 2024 18:00:24 +0100 Subject: [PATCH 04/13] Migrate straightforward remaining ProjectMetadata properties --- backend/src/hatchling/metadata/core.py | 91 ++++++++++++-------------- 1 file changed, 41 insertions(+), 50 deletions(-) diff --git a/backend/src/hatchling/metadata/core.py b/backend/src/hatchling/metadata/core.py index a3588da4a..83ca367b8 100644 --- a/backend/src/hatchling/metadata/core.py +++ b/backend/src/hatchling/metadata/core.py @@ -48,9 +48,6 @@ def __init__( self.plugin_manager = plugin_manager self._config = config - self._core_raw_metadata: dict[str, Any] | None = None - self._dynamic: list[str] | None = None - self._name: str | None = None self._version: str | None = None self._project_file: str | None = None @@ -70,68 +67,62 @@ def context(self) -> Context: return Context(self.root) - @property + @cached_property def core_raw_metadata(self) -> dict[str, Any]: - if self._core_raw_metadata is None: - if 'project' not in self.config: - message = 'Missing `project` metadata table in configuration' - raise ValueError(message) - - core_raw_metadata = self.config['project'] - if not isinstance(core_raw_metadata, dict): - message = 'The `project` configuration must be a table' - raise TypeError(message) + if 'project' not in self.config: + message = 'Missing `project` metadata table in configuration' + raise ValueError(message) - core_raw_metadata = deepcopy(core_raw_metadata) - pkg_info = os.path.join(self.root, 'PKG-INFO') - if os.path.isfile(pkg_info): - from hatchling.metadata.spec import PROJECT_CORE_METADATA_FIELDS, project_metadata_from_core_metadata + core_raw_metadata = self.config['project'] + if not isinstance(core_raw_metadata, dict): + message = 'The `project` configuration must be a table' + raise TypeError(message) - with open(pkg_info, encoding='utf-8') as f: - pkg_info_contents = f.read() + core_raw_metadata = deepcopy(core_raw_metadata) + pkg_info = os.path.join(self.root, 'PKG-INFO') + if os.path.isfile(pkg_info): + from hatchling.metadata.spec import PROJECT_CORE_METADATA_FIELDS, project_metadata_from_core_metadata - base_metadata = project_metadata_from_core_metadata(pkg_info_contents) - defined_dynamic = core_raw_metadata.get('dynamic', []) - for field in list(defined_dynamic): - if field in PROJECT_CORE_METADATA_FIELDS and field in base_metadata: - core_raw_metadata[field] = base_metadata[field] - defined_dynamic.remove(field) + with open(pkg_info, encoding='utf-8') as f: + pkg_info_contents = f.read() - self._core_raw_metadata = core_raw_metadata + base_metadata = project_metadata_from_core_metadata(pkg_info_contents) + defined_dynamic = core_raw_metadata.get('dynamic', []) + for field in list(defined_dynamic): + if field in PROJECT_CORE_METADATA_FIELDS and field in base_metadata: + core_raw_metadata[field] = base_metadata[field] + defined_dynamic.remove(field) - return self._core_raw_metadata + return core_raw_metadata - @property + @cached_property def dynamic(self) -> list[str]: - # Keep track of the original dynamic fields before depopulation - if self._dynamic is None: - dynamic = self.core_raw_metadata.get('dynamic', []) - if not isinstance(dynamic, list): - message = 'Field `project.dynamic` must be an array' - raise TypeError(message) - - for i, field in enumerate(dynamic, 1): - if not isinstance(field, str): - message = f'Field #{i} of field `project.dynamic` must be a string' - raise TypeError(message) + # Here we maintain a copy of the dynamic fields from `self.core raw metadata`. + # This property should never be mutated. In contrast, the fields in + # `self.core.dynamic` are depopulated on the first evaulation of `self.core` + # or `self.version` as the actual values are computed. + dynamic = self.core_raw_metadata.get('dynamic', []) + if not isinstance(dynamic, list): + message = 'Field `project.dynamic` must be an array' + raise TypeError(message) - self._dynamic = list(dynamic) + for i, field in enumerate(dynamic, 1): + if not isinstance(field, str): + message = f'Field #{i} of field `project.dynamic` must be a string' + raise TypeError(message) - return self._dynamic + return list(dynamic) - @property + @cached_property def name(self) -> str: # Duplicate the name parsing here for situations where it's # needed but metadata plugins might not be available - if self._name is None: - name = self.core_raw_metadata.get('name', '') - if not name: - message = 'Missing required field `project.name`' - raise ValueError(message) - - self._name = normalize_project_name(name) + name = self.core_raw_metadata.get('name', '') + if not name: + message = 'Missing required field `project.name`' + raise ValueError(message) - return self._name + return normalize_project_name(name) @property def version(self) -> str: From d3730383c3724c35c1a4838f17328800376e643f Mon Sep 17 00:00:00 2001 From: Ben Mares Date: Sat, 14 Dec 2024 18:44:08 +0100 Subject: [PATCH 05/13] Migrate several straightforward cases from CoreMetadata to cached_property --- backend/src/hatchling/metadata/core.py | 299 +++++++++++-------------- 1 file changed, 137 insertions(+), 162 deletions(-) diff --git a/backend/src/hatchling/metadata/core.py b/backend/src/hatchling/metadata/core.py index 83ca367b8..26eab05dc 100644 --- a/backend/src/hatchling/metadata/core.py +++ b/backend/src/hatchling/metadata/core.py @@ -336,19 +336,13 @@ def __init__( self.hatch_metadata = hatch_metadata self.context = context - self._raw_name: str | None = None - self._name: str | None = None self._version: str | None = None - self._description: str | None = None self._readme: str | None = None self._readme_content_type: str | None = None self._readme_path: str | None = None - self._requires_python: str | None = None self._python_constraint: SpecifierSet | None = None self._license: str | None = None self._license_expression: str | None = None - self._license_files: list[str] | None = None - self._authors: list[str] | None = None self._authors_data: dict[str, list[str]] | None = None self._maintainers: list[str] | None = None self._maintainers_data: dict[str, list[str]] | None = None @@ -363,50 +357,43 @@ def __init__( self._dependencies: list[str] | None = None self._optional_dependencies_complex: dict[str, dict[str, Requirement]] | None = None self._optional_dependencies: dict[str, list[str]] | None = None - self._dynamic: list[str] | None = None # Indicates that the version has been successfully set dynamically self._version_set: bool = False - @property + @cached_property def raw_name(self) -> str: """ https://peps.python.org/pep-0621/#name """ - if self._raw_name is None: - if 'name' in self.dynamic: - message = 'Static metadata field `name` cannot be present in field `project.dynamic`' - raise ValueError(message) - - raw_name = self.config.get('name', '') - if not raw_name: - message = 'Missing required field `project.name`' - raise ValueError(message) + if 'name' in self.dynamic: + message = 'Static metadata field `name` cannot be present in field `project.dynamic`' + raise ValueError(message) - if not isinstance(raw_name, str): - message = 'Field `project.name` must be a string' - raise TypeError(message) + raw_name = self.config.get('name', '') + if not raw_name: + message = 'Missing required field `project.name`' + raise ValueError(message) - if not is_valid_project_name(raw_name): - message = ( - 'Required field `project.name` must only contain ASCII letters/digits, underscores, ' - 'hyphens, and periods, and must begin and end with ASCII letters/digits.' - ) - raise ValueError(message) + if not isinstance(raw_name, str): + message = 'Field `project.name` must be a string' + raise TypeError(message) - self._raw_name = raw_name + if not is_valid_project_name(raw_name): + message = ( + 'Required field `project.name` must only contain ASCII letters/digits, underscores, ' + 'hyphens, and periods, and must begin and end with ASCII letters/digits.' + ) + raise ValueError(message) - return self._raw_name + return raw_name - @property + @cached_property def name(self) -> str: """ https://peps.python.org/pep-0621/#name """ - if self._name is None: - self._name = normalize_project_name(self.raw_name) - - return self._name + return normalize_project_name(self.raw_name) @property def version(self) -> str | None: @@ -445,29 +432,26 @@ def version(self) -> str | None: return cast(str, self._version) - @property + @cached_property def description(self) -> str: """ https://peps.python.org/pep-0621/#description """ - if self._description is None: - if 'description' in self.config: - description = self.config['description'] - if 'description' in self.dynamic: - message = ( - 'Metadata field `description` cannot be both statically defined and ' - 'listed in field `project.dynamic`' - ) - raise ValueError(message) - else: - description = '' - - if not isinstance(description, str): - message = 'Field `project.description` must be a string' - raise TypeError(message) - self._description = ' '.join(description.splitlines()) + if 'description' in self.config: + description = self.config['description'] + if 'description' in self.dynamic: + message = ( + 'Metadata field `description` cannot be both statically defined and ' + 'listed in field `project.dynamic`' + ) + raise ValueError(message) + else: + description = '' - return self._description + if not isinstance(description, str): + message = 'Field `project.description` must be a string' + raise TypeError(message) + return ' '.join(description.splitlines()) @property def readme(self) -> str: @@ -591,38 +575,35 @@ def readme_path(self) -> str: return cast(str, self._readme_path) - @property + @cached_property def requires_python(self) -> str: """ https://peps.python.org/pep-0621/#requires-python """ - if self._requires_python is None: - from packaging.specifiers import InvalidSpecifier, SpecifierSet - - if 'requires-python' in self.config: - requires_python = self.config['requires-python'] - if 'requires-python' in self.dynamic: - message = ( - 'Metadata field `requires-python` cannot be both statically defined and ' - 'listed in field `project.dynamic`' - ) - raise ValueError(message) - else: - requires_python = '' + from packaging.specifiers import InvalidSpecifier, SpecifierSet - if not isinstance(requires_python, str): - message = 'Field `project.requires-python` must be a string' - raise TypeError(message) + if 'requires-python' in self.config: + requires_python = self.config['requires-python'] + if 'requires-python' in self.dynamic: + message = ( + 'Metadata field `requires-python` cannot be both statically defined and ' + 'listed in field `project.dynamic`' + ) + raise ValueError(message) + else: + requires_python = '' - try: - self._python_constraint = SpecifierSet(requires_python) - except InvalidSpecifier as e: - message = f'Field `project.requires-python` is invalid: {e}' - raise ValueError(message) from None + if not isinstance(requires_python, str): + message = 'Field `project.requires-python` must be a string' + raise TypeError(message) - self._requires_python = str(self._python_constraint) + try: + self._python_constraint = SpecifierSet(requires_python) + except InvalidSpecifier as e: + message = f'Field `project.requires-python` is invalid: {e}' + raise ValueError(message) from None - return self._requires_python + return str(self._python_constraint) @property def python_constraint(self) -> SpecifierSet: @@ -708,50 +689,47 @@ def license_expression(self) -> str: return cast(str, self._license_expression) - @property + @cached_property def license_files(self) -> list[str]: """ https://peps.python.org/pep-0639/ """ - if self._license_files is None: - if 'license-files' in self.config: - globs = self.config['license-files'] - if 'license-files' in self.dynamic: - message = ( - 'Metadata field `license-files` cannot be both statically defined and ' - 'listed in field `project.dynamic`' - ) - raise ValueError(message) - - if isinstance(globs, dict): - globs = globs.get('globs', globs.get('paths', [])) - else: - globs = ['LICEN[CS]E*', 'COPYING*', 'NOTICE*', 'AUTHORS*'] + if 'license-files' in self.config: + globs = self.config['license-files'] + if 'license-files' in self.dynamic: + message = ( + 'Metadata field `license-files` cannot be both statically defined and ' + 'listed in field `project.dynamic`' + ) + raise ValueError(message) - from glob import glob + if isinstance(globs, dict): + globs = globs.get('globs', globs.get('paths', [])) + else: + globs = ['LICEN[CS]E*', 'COPYING*', 'NOTICE*', 'AUTHORS*'] - license_files: list[str] = [] - if not isinstance(globs, list): - message = 'Field `project.license-files` must be an array' - raise TypeError(message) + from glob import glob - for i, pattern in enumerate(globs, 1): - if not isinstance(pattern, str): - message = f'Entry #{i} of field `project.license-files` must be a string' - raise TypeError(message) + license_files: list[str] = [] + if not isinstance(globs, list): + message = 'Field `project.license-files` must be an array' + raise TypeError(message) - full_pattern = os.path.normpath(os.path.join(self.root, pattern)) - license_files.extend( - os.path.relpath(path, self.root).replace('\\', '/') - for path in glob(full_pattern) - if os.path.isfile(path) - ) + for i, pattern in enumerate(globs, 1): + if not isinstance(pattern, str): + message = f'Entry #{i} of field `project.license-files` must be a string' + raise TypeError(message) - self._license_files = sorted(license_files) + full_pattern = os.path.normpath(os.path.join(self.root, pattern)) + license_files.extend( + os.path.relpath(path, self.root).replace('\\', '/') + for path in glob(full_pattern) + if os.path.isfile(path) + ) - return self._license_files + return sorted(license_files) - @property + @cached_property def authors(self) -> list[str]: """ https://peps.python.org/pep-0621/#authors-maintainers @@ -759,56 +737,53 @@ def authors(self) -> list[str]: authors: list[str] authors_data: dict[str, list[str]] - if self._authors is None: - if 'authors' in self.config: - authors = self.config['authors'] - if 'authors' in self.dynamic: - message = ( - 'Metadata field `authors` cannot be both statically defined and ' - 'listed in field `project.dynamic`' - ) - raise ValueError(message) - else: - authors = [] + if 'authors' in self.config: + authors = self.config['authors'] + if 'authors' in self.dynamic: + message = ( + 'Metadata field `authors` cannot be both statically defined and ' + 'listed in field `project.dynamic`' + ) + raise ValueError(message) + else: + authors = [] - if not isinstance(authors, list): - message = 'Field `project.authors` must be an array' - raise TypeError(message) + if not isinstance(authors, list): + message = 'Field `project.authors` must be an array' + raise TypeError(message) - from email.headerregistry import Address + from email.headerregistry import Address - authors = deepcopy(authors) - authors_data = {'name': [], 'email': []} + authors = deepcopy(authors) + authors_data = {'name': [], 'email': []} - for i, data in enumerate(authors, 1): - if not isinstance(data, dict): - message = f'Author #{i} of field `project.authors` must be an inline table' - raise TypeError(message) - - name = data.get('name', '') - if not isinstance(name, str): - message = f'Name of author #{i} of field `project.authors` must be a string' - raise TypeError(message) + for i, data in enumerate(authors, 1): + if not isinstance(data, dict): + message = f'Author #{i} of field `project.authors` must be an inline table' + raise TypeError(message) - email = data.get('email', '') - if not isinstance(email, str): - message = f'Email of author #{i} of field `project.authors` must be a string' - raise TypeError(message) + name = data.get('name', '') + if not isinstance(name, str): + message = f'Name of author #{i} of field `project.authors` must be a string' + raise TypeError(message) - if name and email: - authors_data['email'].append(str(Address(display_name=name, addr_spec=email))) - elif email: - authors_data['email'].append(str(Address(addr_spec=email))) - elif name: - authors_data['name'].append(name) - else: - message = f'Author #{i} of field `project.authors` must specify either `name` or `email`' - raise ValueError(message) + email = data.get('email', '') + if not isinstance(email, str): + message = f'Email of author #{i} of field `project.authors` must be a string' + raise TypeError(message) - self._authors = authors - self._authors_data = authors_data + if name and email: + authors_data['email'].append(str(Address(display_name=name, addr_spec=email))) + elif email: + authors_data['email'].append(str(Address(addr_spec=email))) + elif name: + authors_data['name'].append(name) + else: + message = f'Author #{i} of field `project.authors` must specify either `name` or `email`' + raise ValueError(message) - return self._authors + self._authors_data = authors_data + return authors @property def authors_data(self) -> dict[str, list[str]]: @@ -1317,25 +1292,25 @@ def optional_dependencies(self) -> dict[str, list[str]]: return self._optional_dependencies - @property + @cached_property def dynamic(self) -> list[str]: """ https://peps.python.org/pep-0621/#dynamic - """ - if self._dynamic is None: - dynamic = self.config.get('dynamic', []) - if not isinstance(dynamic, list): - message = 'Field `project.dynamic` must be an array' - raise TypeError(message) + WARNING: This property is mutable, and dynamic fields will be removed as + they are resolved. + """ + dynamic = self.config.get('dynamic', []) - if not all(isinstance(entry, str) for entry in dynamic): - message = 'Field `project.dynamic` must only contain strings' - raise TypeError(message) + if not isinstance(dynamic, list): + message = 'Field `project.dynamic` must be an array' + raise TypeError(message) - self._dynamic = sorted(dynamic) + if not all(isinstance(entry, str) for entry in dynamic): + message = 'Field `project.dynamic` must only contain strings' + raise TypeError(message) - return self._dynamic + return sorted(dynamic) def add_known_classifiers(self, classifiers: list[str]) -> None: self._extra_classifiers.update(classifiers) From 3edb90c7afa76586bdba9470f1f859fc8006506d Mon Sep 17 00:00:00 2001 From: Ben Mares Date: Sat, 14 Dec 2024 19:03:08 +0100 Subject: [PATCH 06/13] Migrate remaining straightforward cases from CoreMetadata to cached_property --- backend/src/hatchling/metadata/core.py | 684 ++++++++++++------------- 1 file changed, 319 insertions(+), 365 deletions(-) diff --git a/backend/src/hatchling/metadata/core.py b/backend/src/hatchling/metadata/core.py index 26eab05dc..9efb29e48 100644 --- a/backend/src/hatchling/metadata/core.py +++ b/backend/src/hatchling/metadata/core.py @@ -344,19 +344,8 @@ def __init__( self._license: str | None = None self._license_expression: str | None = None self._authors_data: dict[str, list[str]] | None = None - self._maintainers: list[str] | None = None self._maintainers_data: dict[str, list[str]] | None = None - self._keywords: list[str] | None = None - self._classifiers: list[str] | None = None self._extra_classifiers: set[str] = set() - self._urls: dict[str, str] | None = None - self._scripts: dict[str, str] | None = None - self._gui_scripts: dict[str, str] | None = None - self._entry_points: dict[str, dict[str, str]] | None = None - self._dependencies_complex: dict[str, Requirement] | None = None - self._dependencies: list[str] | None = None - self._optional_dependencies_complex: dict[str, dict[str, Requirement]] | None = None - self._optional_dependencies: dict[str, list[str]] | None = None # Indicates that the version has been successfully set dynamically self._version_set: bool = False @@ -795,63 +784,60 @@ def authors_data(self) -> dict[str, list[str]]: return cast(dict, self._authors_data) - @property + @cached_property def maintainers(self) -> list[str]: """ https://peps.python.org/pep-0621/#authors-maintainers """ maintainers: list[str] - if self._maintainers is None: - if 'maintainers' in self.config: - maintainers = self.config['maintainers'] - if 'maintainers' in self.dynamic: - message = ( - 'Metadata field `maintainers` cannot be both statically defined and ' - 'listed in field `project.dynamic`' - ) - raise ValueError(message) - else: - maintainers = [] + if 'maintainers' in self.config: + maintainers = self.config['maintainers'] + if 'maintainers' in self.dynamic: + message = ( + 'Metadata field `maintainers` cannot be both statically defined and ' + 'listed in field `project.dynamic`' + ) + raise ValueError(message) + else: + maintainers = [] - if not isinstance(maintainers, list): - message = 'Field `project.maintainers` must be an array' - raise TypeError(message) + if not isinstance(maintainers, list): + message = 'Field `project.maintainers` must be an array' + raise TypeError(message) - from email.headerregistry import Address + from email.headerregistry import Address - maintainers = deepcopy(maintainers) - maintainers_data: dict[str, list[str]] = {'name': [], 'email': []} + maintainers = deepcopy(maintainers) + maintainers_data: dict[str, list[str]] = {'name': [], 'email': []} - for i, data in enumerate(maintainers, 1): - if not isinstance(data, dict): - message = f'Maintainer #{i} of field `project.maintainers` must be an inline table' - raise TypeError(message) - - name = data.get('name', '') - if not isinstance(name, str): - message = f'Name of maintainer #{i} of field `project.maintainers` must be a string' - raise TypeError(message) + for i, data in enumerate(maintainers, 1): + if not isinstance(data, dict): + message = f'Maintainer #{i} of field `project.maintainers` must be an inline table' + raise TypeError(message) - email = data.get('email', '') - if not isinstance(email, str): - message = f'Email of maintainer #{i} of field `project.maintainers` must be a string' - raise TypeError(message) + name = data.get('name', '') + if not isinstance(name, str): + message = f'Name of maintainer #{i} of field `project.maintainers` must be a string' + raise TypeError(message) - if name and email: - maintainers_data['email'].append(str(Address(display_name=name, addr_spec=email))) - elif email: - maintainers_data['email'].append(str(Address(addr_spec=email))) - elif name: - maintainers_data['name'].append(name) - else: - message = f'Maintainer #{i} of field `project.maintainers` must specify either `name` or `email`' - raise ValueError(message) + email = data.get('email', '') + if not isinstance(email, str): + message = f'Email of maintainer #{i} of field `project.maintainers` must be a string' + raise TypeError(message) - self._maintainers = maintainers - self._maintainers_data = maintainers_data + if name and email: + maintainers_data['email'].append(str(Address(display_name=name, addr_spec=email))) + elif email: + maintainers_data['email'].append(str(Address(addr_spec=email))) + elif name: + maintainers_data['name'].append(name) + else: + message = f'Maintainer #{i} of field `project.maintainers` must specify either `name` or `email`' + raise ValueError(message) - return self._maintainers + self._maintainers_data = maintainers_data + return maintainers @property def maintainers_data(self) -> dict[str, list[str]]: @@ -863,434 +849,402 @@ def maintainers_data(self) -> dict[str, list[str]]: return cast(dict, self._maintainers_data) - @property + @cached_property def keywords(self) -> list[str]: """ https://peps.python.org/pep-0621/#keywords """ - if self._keywords is None: - if 'keywords' in self.config: - keywords = self.config['keywords'] - if 'keywords' in self.dynamic: - message = ( - 'Metadata field `keywords` cannot be both statically defined and ' - 'listed in field `project.dynamic`' - ) - raise ValueError(message) - else: - keywords = [] - - if not isinstance(keywords, list): - message = 'Field `project.keywords` must be an array' - raise TypeError(message) + if 'keywords' in self.config: + keywords = self.config['keywords'] + if 'keywords' in self.dynamic: + message = ( + 'Metadata field `keywords` cannot be both statically defined and ' + 'listed in field `project.dynamic`' + ) + raise ValueError(message) + else: + keywords = [] - unique_keywords = set() + if not isinstance(keywords, list): + message = 'Field `project.keywords` must be an array' + raise TypeError(message) - for i, keyword in enumerate(keywords, 1): - if not isinstance(keyword, str): - message = f'Keyword #{i} of field `project.keywords` must be a string' - raise TypeError(message) + unique_keywords = set() - unique_keywords.add(keyword) + for i, keyword in enumerate(keywords, 1): + if not isinstance(keyword, str): + message = f'Keyword #{i} of field `project.keywords` must be a string' + raise TypeError(message) - self._keywords = sorted(unique_keywords) + unique_keywords.add(keyword) - return self._keywords + return sorted(unique_keywords) - @property + @cached_property def classifiers(self) -> list[str]: """ https://peps.python.org/pep-0621/#classifiers """ - if self._classifiers is None: - import bisect + import bisect - if 'classifiers' in self.config: - classifiers = self.config['classifiers'] - if 'classifiers' in self.dynamic: - message = ( - 'Metadata field `classifiers` cannot be both statically defined and ' - 'listed in field `project.dynamic`' - ) - raise ValueError(message) - else: - classifiers = [] - - if not isinstance(classifiers, list): - message = 'Field `project.classifiers` must be an array' - raise TypeError(message) + if 'classifiers' in self.config: + classifiers = self.config['classifiers'] + if 'classifiers' in self.dynamic: + message = ( + 'Metadata field `classifiers` cannot be both statically defined and ' + 'listed in field `project.dynamic`' + ) + raise ValueError(message) + else: + classifiers = [] - verify_classifiers = not os.environ.get('HATCH_METADATA_CLASSIFIERS_NO_VERIFY') - if verify_classifiers: - import trove_classifiers + if not isinstance(classifiers, list): + message = 'Field `project.classifiers` must be an array' + raise TypeError(message) - known_classifiers = trove_classifiers.classifiers | self._extra_classifiers - sorted_classifiers = list(trove_classifiers.sorted_classifiers) + verify_classifiers = not os.environ.get('HATCH_METADATA_CLASSIFIERS_NO_VERIFY') + if verify_classifiers: + import trove_classifiers - for classifier in sorted(self._extra_classifiers - trove_classifiers.classifiers): - bisect.insort(sorted_classifiers, classifier) + known_classifiers = trove_classifiers.classifiers | self._extra_classifiers + sorted_classifiers = list(trove_classifiers.sorted_classifiers) - unique_classifiers = set() + for classifier in sorted(self._extra_classifiers - trove_classifiers.classifiers): + bisect.insort(sorted_classifiers, classifier) - for i, classifier in enumerate(classifiers, 1): - if not isinstance(classifier, str): - message = f'Classifier #{i} of field `project.classifiers` must be a string' - raise TypeError(message) + unique_classifiers = set() - if ( - not self.__classifier_is_private(classifier) - and verify_classifiers - and classifier not in known_classifiers - ): - message = f'Unknown classifier in field `project.classifiers`: {classifier}' - raise ValueError(message) + for i, classifier in enumerate(classifiers, 1): + if not isinstance(classifier, str): + message = f'Classifier #{i} of field `project.classifiers` must be a string' + raise TypeError(message) - unique_classifiers.add(classifier) + if ( + not self.__classifier_is_private(classifier) + and verify_classifiers + and classifier not in known_classifiers + ): + message = f'Unknown classifier in field `project.classifiers`: {classifier}' + raise ValueError(message) - if not verify_classifiers: - import re + unique_classifiers.add(classifier) - # combined text-numeric sort that ensures that Python versions sort correctly - split_re = re.compile(r'(\D*)(\d*)') - sorted_classifiers = sorted( - classifiers, - key=lambda value: ([(a, int(b) if b else None) for a, b in split_re.findall(value)]), - ) + if not verify_classifiers: + import re - self._classifiers = sorted( - unique_classifiers, key=lambda c: -1 if self.__classifier_is_private(c) else sorted_classifiers.index(c) + # combined text-numeric sort that ensures that Python versions sort correctly + split_re = re.compile(r'(\D*)(\d*)') + sorted_classifiers = sorted( + classifiers, + key=lambda value: ([(a, int(b) if b else None) for a, b in split_re.findall(value)]), ) - return self._classifiers + return sorted( + unique_classifiers, key=lambda c: -1 if self.__classifier_is_private(c) else sorted_classifiers.index(c) + ) - @property + @cached_property def urls(self) -> dict[str, str]: """ https://peps.python.org/pep-0621/#urls """ - if self._urls is None: - if 'urls' in self.config: - urls = self.config['urls'] - if 'urls' in self.dynamic: - message = ( - 'Metadata field `urls` cannot be both statically defined and listed in field `project.dynamic`' - ) - raise ValueError(message) - else: - urls = {} - - if not isinstance(urls, dict): - message = 'Field `project.urls` must be a table' - raise TypeError(message) + if 'urls' in self.config: + urls = self.config['urls'] + if 'urls' in self.dynamic: + message = ( + 'Metadata field `urls` cannot be both statically defined and listed in field `project.dynamic`' + ) + raise ValueError(message) + else: + urls = {} - sorted_urls = {} + if not isinstance(urls, dict): + message = 'Field `project.urls` must be a table' + raise TypeError(message) - for label, url in urls.items(): - if not isinstance(url, str): - message = f'URL `{label}` of field `project.urls` must be a string' - raise TypeError(message) + sorted_urls = {} - sorted_urls[label] = url + for label, url in urls.items(): + if not isinstance(url, str): + message = f'URL `{label}` of field `project.urls` must be a string' + raise TypeError(message) - self._urls = sorted_urls + sorted_urls[label] = url - return self._urls + return sorted_urls - @property + @cached_property def scripts(self) -> dict[str, str]: """ https://peps.python.org/pep-0621/#entry-points """ - if self._scripts is None: - if 'scripts' in self.config: - scripts = self.config['scripts'] - if 'scripts' in self.dynamic: - message = ( - 'Metadata field `scripts` cannot be both statically defined and ' - 'listed in field `project.dynamic`' - ) - raise ValueError(message) - else: - scripts = {} - - if not isinstance(scripts, dict): - message = 'Field `project.scripts` must be a table' - raise TypeError(message) + if 'scripts' in self.config: + scripts = self.config['scripts'] + if 'scripts' in self.dynamic: + message = ( + 'Metadata field `scripts` cannot be both statically defined and ' + 'listed in field `project.dynamic`' + ) + raise ValueError(message) + else: + scripts = {} - sorted_scripts = {} + if not isinstance(scripts, dict): + message = 'Field `project.scripts` must be a table' + raise TypeError(message) - for name, object_ref in sorted(scripts.items()): - if not isinstance(object_ref, str): - message = f'Object reference `{name}` of field `project.scripts` must be a string' - raise TypeError(message) + sorted_scripts = {} - sorted_scripts[name] = object_ref + for name, object_ref in sorted(scripts.items()): + if not isinstance(object_ref, str): + message = f'Object reference `{name}` of field `project.scripts` must be a string' + raise TypeError(message) - self._scripts = sorted_scripts + sorted_scripts[name] = object_ref - return self._scripts + return sorted_scripts - @property + @cached_property def gui_scripts(self) -> dict[str, str]: """ https://peps.python.org/pep-0621/#entry-points """ - if self._gui_scripts is None: - if 'gui-scripts' in self.config: - gui_scripts = self.config['gui-scripts'] - if 'gui-scripts' in self.dynamic: - message = ( - 'Metadata field `gui-scripts` cannot be both statically defined and ' - 'listed in field `project.dynamic`' - ) - raise ValueError(message) - else: - gui_scripts = {} - - if not isinstance(gui_scripts, dict): - message = 'Field `project.gui-scripts` must be a table' - raise TypeError(message) + if 'gui-scripts' in self.config: + gui_scripts = self.config['gui-scripts'] + if 'gui-scripts' in self.dynamic: + message = ( + 'Metadata field `gui-scripts` cannot be both statically defined and ' + 'listed in field `project.dynamic`' + ) + raise ValueError(message) + else: + gui_scripts = {} - sorted_gui_scripts = {} + if not isinstance(gui_scripts, dict): + message = 'Field `project.gui-scripts` must be a table' + raise TypeError(message) - for name, object_ref in sorted(gui_scripts.items()): - if not isinstance(object_ref, str): - message = f'Object reference `{name}` of field `project.gui-scripts` must be a string' - raise TypeError(message) + sorted_gui_scripts = {} - sorted_gui_scripts[name] = object_ref + for name, object_ref in sorted(gui_scripts.items()): + if not isinstance(object_ref, str): + message = f'Object reference `{name}` of field `project.gui-scripts` must be a string' + raise TypeError(message) - self._gui_scripts = sorted_gui_scripts + sorted_gui_scripts[name] = object_ref - return self._gui_scripts + return sorted_gui_scripts - @property + @cached_property def entry_points(self) -> dict[str, dict[str, str]]: """ https://peps.python.org/pep-0621/#entry-points """ - if self._entry_points is None: - if 'entry-points' in self.config: - defined_entry_point_groups = self.config['entry-points'] - if 'entry-points' in self.dynamic: - message = ( - 'Metadata field `entry-points` cannot be both statically defined and ' - 'listed in field `project.dynamic`' - ) - raise ValueError(message) - else: - defined_entry_point_groups = {} - - if not isinstance(defined_entry_point_groups, dict): - message = 'Field `project.entry-points` must be a table' - raise TypeError(message) + if 'entry-points' in self.config: + defined_entry_point_groups = self.config['entry-points'] + if 'entry-points' in self.dynamic: + message = ( + 'Metadata field `entry-points` cannot be both statically defined and ' + 'listed in field `project.dynamic`' + ) + raise ValueError(message) + else: + defined_entry_point_groups = {} - for forbidden_field, expected_field in (('console_scripts', 'scripts'), ('gui-scripts', 'gui-scripts')): - if forbidden_field in defined_entry_point_groups: - message = ( - f'Field `{forbidden_field}` must be defined as `project.{expected_field}` ' - f'instead of in the `project.entry-points` table' - ) - raise ValueError(message) + if not isinstance(defined_entry_point_groups, dict): + message = 'Field `project.entry-points` must be a table' + raise TypeError(message) - entry_point_groups = {} + for forbidden_field, expected_field in (('console_scripts', 'scripts'), ('gui-scripts', 'gui-scripts')): + if forbidden_field in defined_entry_point_groups: + message = ( + f'Field `{forbidden_field}` must be defined as `project.{expected_field}` ' + f'instead of in the `project.entry-points` table' + ) + raise ValueError(message) - for group, entry_point_data in sorted(defined_entry_point_groups.items()): - if not isinstance(entry_point_data, dict): - message = f'Field `project.entry-points.{group}` must be a table' - raise TypeError(message) + entry_point_groups = {} - entry_points = {} + for group, entry_point_data in sorted(defined_entry_point_groups.items()): + if not isinstance(entry_point_data, dict): + message = f'Field `project.entry-points.{group}` must be a table' + raise TypeError(message) - for name, object_ref in sorted(entry_point_data.items()): - if not isinstance(object_ref, str): - message = f'Object reference `{name}` of field `project.entry-points.{group}` must be a string' - raise TypeError(message) + entry_points = {} - entry_points[name] = object_ref + for name, object_ref in sorted(entry_point_data.items()): + if not isinstance(object_ref, str): + message = f'Object reference `{name}` of field `project.entry-points.{group}` must be a string' + raise TypeError(message) - if entry_points: - entry_point_groups[group] = entry_points + entry_points[name] = object_ref - self._entry_points = entry_point_groups + if entry_points: + entry_point_groups[group] = entry_points - return self._entry_points + return entry_point_groups - @property + @cached_property def dependencies_complex(self) -> dict[str, Requirement]: """ https://peps.python.org/pep-0621/#dependencies-optional-dependencies """ - if self._dependencies_complex is None: - from packaging.requirements import InvalidRequirement, Requirement - - if 'dependencies' in self.config: - dependencies = self.config['dependencies'] - if 'dependencies' in self.dynamic: - message = ( - 'Metadata field `dependencies` cannot be both statically defined and ' - 'listed in field `project.dynamic`' - ) - raise ValueError(message) - else: - dependencies = [] + from packaging.requirements import InvalidRequirement, Requirement - if not isinstance(dependencies, list): - message = 'Field `project.dependencies` must be an array' - raise TypeError(message) + if 'dependencies' in self.config: + dependencies = self.config['dependencies'] + if 'dependencies' in self.dynamic: + message = ( + 'Metadata field `dependencies` cannot be both statically defined and ' + 'listed in field `project.dynamic`' + ) + raise ValueError(message) + else: + dependencies = [] - dependencies_complex = {} + if not isinstance(dependencies, list): + message = 'Field `project.dependencies` must be an array' + raise TypeError(message) - for i, entry in enumerate(dependencies, 1): - if not isinstance(entry, str): - message = f'Dependency #{i} of field `project.dependencies` must be a string' - raise TypeError(message) + dependencies_complex = {} - try: - requirement = Requirement(self.context.format(entry)) - except InvalidRequirement as e: - message = f'Dependency #{i} of field `project.dependencies` is invalid: {e}' - raise ValueError(message) from None - else: - if requirement.url and not self.hatch_metadata.allow_direct_references: - message = ( - f'Dependency #{i} of field `project.dependencies` cannot be a direct reference unless ' - f'field `tool.hatch.metadata.allow-direct-references` is set to `true`' - ) - raise ValueError(message) + for i, entry in enumerate(dependencies, 1): + if not isinstance(entry, str): + message = f'Dependency #{i} of field `project.dependencies` must be a string' + raise TypeError(message) - normalize_requirement(requirement) - dependencies_complex[format_dependency(requirement)] = requirement + try: + requirement = Requirement(self.context.format(entry)) + except InvalidRequirement as e: + message = f'Dependency #{i} of field `project.dependencies` is invalid: {e}' + raise ValueError(message) from None + else: + if requirement.url and not self.hatch_metadata.allow_direct_references: + message = ( + f'Dependency #{i} of field `project.dependencies` cannot be a direct reference unless ' + f'field `tool.hatch.metadata.allow-direct-references` is set to `true`' + ) + raise ValueError(message) - self._dependencies_complex = dict(sorted(dependencies_complex.items())) + normalize_requirement(requirement) + dependencies_complex[format_dependency(requirement)] = requirement - return self._dependencies_complex + return dict(sorted(dependencies_complex.items())) - @property + @cached_property def dependencies(self) -> list[str]: """ https://peps.python.org/pep-0621/#dependencies-optional-dependencies """ - if self._dependencies is None: - self._dependencies = list(self.dependencies_complex) - - return self._dependencies + return list(self.dependencies_complex) - @property + @cached_property def optional_dependencies_complex(self) -> dict[str, dict[str, Requirement]]: """ https://peps.python.org/pep-0621/#dependencies-optional-dependencies """ - if self._optional_dependencies_complex is None: - from packaging.requirements import InvalidRequirement, Requirement + from packaging.requirements import InvalidRequirement, Requirement - if 'optional-dependencies' in self.config: - optional_dependencies = self.config['optional-dependencies'] - if 'optional-dependencies' in self.dynamic: - message = ( - 'Metadata field `optional-dependencies` cannot be both statically defined and ' - 'listed in field `project.dynamic`' - ) - raise ValueError(message) - else: - optional_dependencies = {} + if 'optional-dependencies' in self.config: + optional_dependencies = self.config['optional-dependencies'] + if 'optional-dependencies' in self.dynamic: + message = ( + 'Metadata field `optional-dependencies` cannot be both statically defined and ' + 'listed in field `project.dynamic`' + ) + raise ValueError(message) + else: + optional_dependencies = {} - if not isinstance(optional_dependencies, dict): - message = 'Field `project.optional-dependencies` must be a table' - raise TypeError(message) + if not isinstance(optional_dependencies, dict): + message = 'Field `project.optional-dependencies` must be a table' + raise TypeError(message) - normalized_options: dict[str, str] = {} - optional_dependency_entries = {} - inherited_options: dict[str, set[str]] = {} + normalized_options: dict[str, str] = {} + optional_dependency_entries = {} + inherited_options: dict[str, set[str]] = {} - for option, dependencies in optional_dependencies.items(): - if not is_valid_project_name(option): - message = ( - f'Optional dependency group `{option}` of field `project.optional-dependencies` must only ' - f'contain ASCII letters/digits, underscores, hyphens, and periods, and must begin and end with ' - f'ASCII letters/digits.' - ) - raise ValueError(message) + for option, dependencies in optional_dependencies.items(): + if not is_valid_project_name(option): + message = ( + f'Optional dependency group `{option}` of field `project.optional-dependencies` must only ' + f'contain ASCII letters/digits, underscores, hyphens, and periods, and must begin and end with ' + f'ASCII letters/digits.' + ) + raise ValueError(message) - normalized_option = ( - option if self.hatch_metadata.allow_ambiguous_features else normalize_project_name(option) + normalized_option = ( + option if self.hatch_metadata.allow_ambiguous_features else normalize_project_name(option) + ) + if normalized_option in normalized_options: + message = ( + f'Optional dependency groups `{normalized_options[normalized_option]}` and `{option}` of ' + f'field `project.optional-dependencies` both evaluate to `{normalized_option}`.' ) - if normalized_option in normalized_options: - message = ( - f'Optional dependency groups `{normalized_options[normalized_option]}` and `{option}` of ' - f'field `project.optional-dependencies` both evaluate to `{normalized_option}`.' - ) - raise ValueError(message) + raise ValueError(message) - if not isinstance(dependencies, list): + if not isinstance(dependencies, list): + message = ( + f'Dependencies for option `{option}` of field `project.optional-dependencies` must be an array' + ) + raise TypeError(message) + + entries = {} + + for i, entry in enumerate(dependencies, 1): + if not isinstance(entry, str): message = ( - f'Dependencies for option `{option}` of field `project.optional-dependencies` must be an array' + f'Dependency #{i} of option `{option}` of field `project.optional-dependencies` ' + f'must be a string' ) raise TypeError(message) - entries = {} - - for i, entry in enumerate(dependencies, 1): - if not isinstance(entry, str): + try: + requirement = Requirement(self.context.format(entry)) + except InvalidRequirement as e: + message = ( + f'Dependency #{i} of option `{option}` of field `project.optional-dependencies` ' + f'is invalid: {e}' + ) + raise ValueError(message) from None + else: + if requirement.url and not self.hatch_metadata.allow_direct_references: message = ( f'Dependency #{i} of option `{option}` of field `project.optional-dependencies` ' - f'must be a string' + f'cannot be a direct reference unless field ' + f'`tool.hatch.metadata.allow-direct-references` is set to `true`' ) - raise TypeError(message) + raise ValueError(message) - try: - requirement = Requirement(self.context.format(entry)) - except InvalidRequirement as e: - message = ( - f'Dependency #{i} of option `{option}` of field `project.optional-dependencies` ' - f'is invalid: {e}' - ) - raise ValueError(message) from None - else: - if requirement.url and not self.hatch_metadata.allow_direct_references: - message = ( - f'Dependency #{i} of option `{option}` of field `project.optional-dependencies` ' - f'cannot be a direct reference unless field ' - f'`tool.hatch.metadata.allow-direct-references` is set to `true`' - ) - raise ValueError(message) - - normalize_requirement(requirement) - if requirement.name == self.name: - if normalized_option in inherited_options: - inherited_options[normalized_option].update(requirement.extras) - else: - inherited_options[normalized_option] = set(requirement.extras) + normalize_requirement(requirement) + if requirement.name == self.name: + if normalized_option in inherited_options: + inherited_options[normalized_option].update(requirement.extras) else: - entries[format_dependency(requirement)] = requirement - - normalized_options[normalized_option] = option - optional_dependency_entries[normalized_option] = entries + inherited_options[normalized_option] = set(requirement.extras) + else: + entries[format_dependency(requirement)] = requirement - visited: set[str] = set() - resolved: set[str] = set() - for dependent_option in inherited_options: - _resolve_optional_dependencies( - optional_dependency_entries, dependent_option, inherited_options, visited, resolved - ) + normalized_options[normalized_option] = option + optional_dependency_entries[normalized_option] = entries - self._optional_dependencies_complex = { - option: dict(sorted(entries.items())) for option, entries in sorted(optional_dependency_entries.items()) - } + visited: set[str] = set() + resolved: set[str] = set() + for dependent_option in inherited_options: + _resolve_optional_dependencies( + optional_dependency_entries, dependent_option, inherited_options, visited, resolved + ) - return self._optional_dependencies_complex + return { + option: dict(sorted(entries.items())) for option, entries in sorted(optional_dependency_entries.items()) + } - @property + @cached_property def optional_dependencies(self) -> dict[str, list[str]]: """ https://peps.python.org/pep-0621/#dependencies-optional-dependencies """ - if self._optional_dependencies is None: - self._optional_dependencies = { - option: list(entries) for option, entries in self.optional_dependencies_complex.items() - } - - return self._optional_dependencies + return {option: list(entries) for option, entries in self.optional_dependencies_complex.items()} @cached_property def dynamic(self) -> list[str]: From 056ce06ba0a8ca583b9a9ff71be5d5d0ec0ca89f Mon Sep 17 00:00:00 2001 From: Ben Mares Date: Sat, 14 Dec 2024 19:07:01 +0100 Subject: [PATCH 07/13] Improve bisect import --- backend/src/hatchling/metadata/core.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/src/hatchling/metadata/core.py b/backend/src/hatchling/metadata/core.py index 9efb29e48..1eab4eb96 100644 --- a/backend/src/hatchling/metadata/core.py +++ b/backend/src/hatchling/metadata/core.py @@ -885,8 +885,6 @@ def classifiers(self) -> list[str]: """ https://peps.python.org/pep-0621/#classifiers """ - import bisect - if 'classifiers' in self.config: classifiers = self.config['classifiers'] if 'classifiers' in self.dynamic: @@ -904,6 +902,8 @@ def classifiers(self) -> list[str]: verify_classifiers = not os.environ.get('HATCH_METADATA_CLASSIFIERS_NO_VERIFY') if verify_classifiers: + import bisect + import trove_classifiers known_classifiers = trove_classifiers.classifiers | self._extra_classifiers From 76ba9b9b4bc508fd9aecb59df8ee67204280538d Mon Sep 17 00:00:00 2001 From: Ben Mares Date: Sat, 14 Dec 2024 19:55:00 +0100 Subject: [PATCH 08/13] Improve comments --- backend/src/hatchling/metadata/core.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/backend/src/hatchling/metadata/core.py b/backend/src/hatchling/metadata/core.py index 1eab4eb96..dd5460fbf 100644 --- a/backend/src/hatchling/metadata/core.py +++ b/backend/src/hatchling/metadata/core.py @@ -98,7 +98,7 @@ def core_raw_metadata(self) -> dict[str, Any]: @cached_property def dynamic(self) -> list[str]: # Here we maintain a copy of the dynamic fields from `self.core raw metadata`. - # This property should never be mutated. In contrast, the fields in + # This property should never be mutated. In contrast, the fields in # `self.core.dynamic` are depopulated on the first evaulation of `self.core` # or `self.version` as the actual values are computed. dynamic = self.core_raw_metadata.get('dynamic', []) @@ -161,7 +161,11 @@ def build(self) -> BuildMetadata: def core(self) -> CoreMetadata: metadata = CoreMetadata(self.root, self.core_raw_metadata, self.hatch.metadata, self.context) - # Save the fields + # Make a copy of the dynamic fields before they are computed. + # The corresponding `CoreMetadata.dynamic` here in the `metadata` + # variable will be depopulated once the values are computed, either + # directly below by the metadata hooks, or from a version source + # via `ProjectMetadata.version`. _ = self.dynamic metadata_hooks = self.hatch.metadata.hooks @@ -1249,9 +1253,11 @@ def optional_dependencies(self) -> dict[str, list[str]]: @cached_property def dynamic(self) -> list[str]: """ + Dynamic metadata whose values have not yet been resolved. + https://peps.python.org/pep-0621/#dynamic - WARNING: This property is mutable, and dynamic fields will be removed as + WARNING: The returned list is mutable, and dynamic fields will be removed after they are resolved. """ dynamic = self.config.get('dynamic', []) From 2033b126585ad6800cbbb6151a87aa73c5c72f61 Mon Sep 17 00:00:00 2001 From: Ben Mares Date: Sat, 14 Dec 2024 20:43:39 +0100 Subject: [PATCH 09/13] Add docstrings to hatchling.utils.fs --- backend/src/hatchling/utils/fs.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/backend/src/hatchling/utils/fs.py b/backend/src/hatchling/utils/fs.py index b76680467..4534289b6 100644 --- a/backend/src/hatchling/utils/fs.py +++ b/backend/src/hatchling/utils/fs.py @@ -4,6 +4,17 @@ def locate_file(root: str, file_name: str, *, boundary: str | None = None) -> str | None: + """ + Locate a file by searching upward from a root directory until the file is found or a boundary directory is reached. + + Args: + root (str): The starting directory for the search. + file_name (str): The name of the file to locate. + boundary (str | None, optional): The name of a directory that, if reached, stops the search. If None, the search continues up to the filesystem root. + + Returns: + str | None: The full path to the file if found; otherwise, None. + """ while True: file_path = os.path.join(root, file_name) if os.path.isfile(file_path): @@ -20,6 +31,15 @@ def locate_file(root: str, file_name: str, *, boundary: str | None = None) -> st def path_to_uri(path: str) -> str: + """ + Convert a local filesystem path to a file URI, handling spaces and platform-specific path separators. + + Args: + path (str): The local filesystem path to convert. + + Returns: + str: The file URI corresponding to the given path. + """ if os.sep == '/': return f'file://{os.path.abspath(path).replace(" ", "%20")}' From 11f88796e0808481261425dc726985c2ed0d6f01 Mon Sep 17 00:00:00 2001 From: Ben Mares Date: Sat, 14 Dec 2024 20:44:24 +0100 Subject: [PATCH 10/13] =?UTF-8?q?Rename=20base=5Fmetadata=20=E2=86=92=20pk?= =?UTF-8?q?g=5Finfo=5Fmetadata?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/hatchling/metadata/core.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/backend/src/hatchling/metadata/core.py b/backend/src/hatchling/metadata/core.py index dd5460fbf..07709ee7c 100644 --- a/backend/src/hatchling/metadata/core.py +++ b/backend/src/hatchling/metadata/core.py @@ -86,11 +86,12 @@ def core_raw_metadata(self) -> dict[str, Any]: with open(pkg_info, encoding='utf-8') as f: pkg_info_contents = f.read() - base_metadata = project_metadata_from_core_metadata(pkg_info_contents) + # Give `PKG-INFO` first priority to set the values of dynamic metadata + pkg_info_metadata = project_metadata_from_core_metadata(pkg_info_contents) defined_dynamic = core_raw_metadata.get('dynamic', []) for field in list(defined_dynamic): - if field in PROJECT_CORE_METADATA_FIELDS and field in base_metadata: - core_raw_metadata[field] = base_metadata[field] + if field in PROJECT_CORE_METADATA_FIELDS and field in pkg_info_metadata: + core_raw_metadata[field] = pkg_info_metadata[field] defined_dynamic.remove(field) return core_raw_metadata From 11dbc7af1148e772b053d32b7353a7aaa7c172f6 Mon Sep 17 00:00:00 2001 From: Ben Mares Date: Sat, 14 Dec 2024 21:41:35 +0100 Subject: [PATCH 11/13] Update HatchVersionConfig with cached_property --- backend/src/hatchling/metadata/core.py | 105 ++++++++++--------------- 1 file changed, 42 insertions(+), 63 deletions(-) diff --git a/backend/src/hatchling/metadata/core.py b/backend/src/hatchling/metadata/core.py index 07709ee7c..7015c9d01 100644 --- a/backend/src/hatchling/metadata/core.py +++ b/backend/src/hatchling/metadata/core.py @@ -1356,88 +1356,67 @@ def __init__(self, root: str, config: dict[str, Any], plugin_manager: PluginMana self.config = config self.plugin_manager = plugin_manager - self._cached: str | None = None - self._source_name: str | None = None - self._scheme_name: str | None = None - self._source: VersionSourceInterface | None = None - self._scheme: VersionSchemeInterface | None = None - - @property + @cached_property def cached(self) -> str: - if self._cached is None: - try: - self._cached = self.source.get_version_data()['version'] - except Exception as e: # noqa: BLE001 - message = f'Error getting the version from source `{self.source.PLUGIN_NAME}`: {e}' - raise type(e)(message) from None - - return self._cached + try: + return self.source.get_version_data()['version'] + except Exception as e: # noqa: BLE001 + message = f'Error getting the version from source `{self.source.PLUGIN_NAME}`: {e}' + raise type(e)(message) from None - @property + @cached_property def source_name(self) -> str: - if self._source_name is None: - source: str = self.config.get('source', 'regex') - if not source: - message = 'The `source` option under the `tool.hatch.version` table must not be empty if defined' - raise ValueError(message) - - if not isinstance(source, str): - message = 'Field `tool.hatch.version.source` must be a string' - raise TypeError(message) + source: str = self.config.get('source', 'regex') + if not source: + message = 'The `source` option under the `tool.hatch.version` table must not be empty if defined' + raise ValueError(message) - self._source_name = source + if not isinstance(source, str): + message = 'Field `tool.hatch.version.source` must be a string' + raise TypeError(message) - return self._source_name + return source - @property + @cached_property def scheme_name(self) -> str: - if self._scheme_name is None: - scheme: str = self.config.get('scheme', 'standard') - if not scheme: - message = 'The `scheme` option under the `tool.hatch.version` table must not be empty if defined' - raise ValueError(message) - - if not isinstance(scheme, str): - message = 'Field `tool.hatch.version.scheme` must be a string' - raise TypeError(message) + scheme: str = self.config.get('scheme', 'standard') + if not scheme: + message = 'The `scheme` option under the `tool.hatch.version` table must not be empty if defined' + raise ValueError(message) - self._scheme_name = scheme + if not isinstance(scheme, str): + message = 'Field `tool.hatch.version.scheme` must be a string' + raise TypeError(message) - return self._scheme_name + return scheme - @property + @cached_property def source(self) -> VersionSourceInterface: - if self._source is None: - from copy import deepcopy - - source_name = self.source_name - version_source = self.plugin_manager.version_source.get(source_name) - if version_source is None: - from hatchling.plugin.exceptions import UnknownPluginError + from copy import deepcopy - message = f'Unknown version source: {source_name}' - raise UnknownPluginError(message) + source_name = self.source_name + version_source = self.plugin_manager.version_source.get(source_name) + if version_source is None: + from hatchling.plugin.exceptions import UnknownPluginError - self._source = version_source(self.root, deepcopy(self.config)) + message = f'Unknown version source: {source_name}' + raise UnknownPluginError(message) - return self._source + return version_source(self.root, deepcopy(self.config)) - @property + @cached_property def scheme(self) -> VersionSchemeInterface: - if self._scheme is None: - from copy import deepcopy - - scheme_name = self.scheme_name - version_scheme = self.plugin_manager.version_scheme.get(scheme_name) - if version_scheme is None: - from hatchling.plugin.exceptions import UnknownPluginError + from copy import deepcopy - message = f'Unknown version scheme: {scheme_name}' - raise UnknownPluginError(message) + scheme_name = self.scheme_name + version_scheme = self.plugin_manager.version_scheme.get(scheme_name) + if version_scheme is None: + from hatchling.plugin.exceptions import UnknownPluginError - self._scheme = version_scheme(self.root, deepcopy(self.config)) + message = f'Unknown version scheme: {scheme_name}' + raise UnknownPluginError(message) - return self._scheme + return version_scheme(self.root, deepcopy(self.config)) class HatchMetadataSettings(Generic[PluginManagerBound]): From 82131f2626289389b13cca019562df399621afad Mon Sep 17 00:00:00 2001 From: Ben Mares Date: Wed, 25 Dec 2024 12:00:26 +0100 Subject: [PATCH 12/13] Add a few docstrings --- backend/src/hatchling/metadata/core.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/backend/src/hatchling/metadata/core.py b/backend/src/hatchling/metadata/core.py index 7015c9d01..69492d89d 100644 --- a/backend/src/hatchling/metadata/core.py +++ b/backend/src/hatchling/metadata/core.py @@ -69,6 +69,7 @@ def context(self) -> Context: @cached_property def core_raw_metadata(self) -> dict[str, Any]: + """Metadata from `pyproject.toml` or similar, possibly with dynamic fields overwritten by `PKG-INFO`.""" if 'project' not in self.config: message = 'Missing `project` metadata table in configuration' raise ValueError(message) @@ -139,6 +140,7 @@ def version(self) -> str: @property def config(self) -> dict[str, Any]: + """The config dict, directly from `pyproject.toml` or similar.""" if self._config is None: project_file = locate_file(self.root, 'pyproject.toml') if project_file is None: From 4f121fafb3cd50bf060205f61deec666b6da5e1c Mon Sep 17 00:00:00 2001 From: Ben Mares Date: Fri, 27 Dec 2024 23:04:50 +0200 Subject: [PATCH 13/13] Slightly simplify CLI version logic --- backend/src/hatchling/cli/version/__init__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/src/hatchling/cli/version/__init__.py b/backend/src/hatchling/cli/version/__init__.py index b776ebd94..c0bb64204 100644 --- a/backend/src/hatchling/cli/version/__init__.py +++ b/backend/src/hatchling/cli/version/__init__.py @@ -1,7 +1,7 @@ from __future__ import annotations import argparse -from typing import Any, cast +from typing import Any def version_impl( @@ -21,11 +21,11 @@ def version_impl( plugin_manager = PluginManager() metadata = ProjectMetadata(root, plugin_manager) - if 'version' in metadata.config.get('project', {}): + static_version = metadata.core.version + if static_version is not None: if desired_version: app.abort('Cannot set version when it is statically defined by the `project.version` field') else: - static_version = cast(str, metadata.core.version) app.display(static_version) return