diff --git a/backend/src/hatchling/cli/version/__init__.py b/backend/src/hatchling/cli/version/__init__.py index 20cae309e..c0bb64204 100644 --- a/backend/src/hatchling/cli/version/__init__.py +++ b/backend/src/hatchling/cli/version/__init__.py @@ -21,11 +21,12 @@ 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: - app.display(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..69492d89d 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 ( @@ -47,14 +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 self._version: str | None = None self._project_file: str | None = None @@ -68,77 +61,70 @@ 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 - - self._context = Context(self.root) + from hatchling.utils.context import Context - return 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) - - 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 - - with open(pkg_info, encoding='utf-8') as f: - pkg_info_contents = f.read() - - 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) - - self._core_raw_metadata = core_raw_metadata - - return self._core_raw_metadata - - @property + """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) + + core_raw_metadata = self.config['project'] + if not isinstance(core_raw_metadata, dict): + message = 'The `project` configuration must be a table' + raise TypeError(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 + + with open(pkg_info, encoding='utf-8') as f: + pkg_info_contents = f.read() + + # 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 pkg_info_metadata: + core_raw_metadata[field] = pkg_info_metadata[field] + defined_dynamic.remove(field) + + return core_raw_metadata + + @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' + # 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) + + 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) - 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) - - self._dynamic = list(dynamic) - - 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: @@ -154,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: @@ -164,80 +151,75 @@ 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) + + # 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 + 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) + 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 '' - ) + hatch_config = tool_config.get('hatch', {}) + if not isinstance(hatch_config, dict): + message = 'The `tool.hatch` configuration must be a table' + raise TypeError(message) - if hatch_file and os.path.isfile(hatch_file): - config = load_toml(hatch_file) - hatch_config = hatch_config.copy() - hatch_config.update(config) + 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 '' + ) - self._hatch = HatchMetadata(self.root, hatch_config, self.plugin_manager) + if hatch_file and os.path.isfile(hatch_file): + config = load_toml(hatch_file) + hatch_config = hatch_config.copy() + hatch_config.update(config) - return self._hatch + return HatchMetadata(self.root, hatch_config, self.plugin_manager) def _get_version(self, core_metadata: CoreMetadata | None = None) -> str: if core_metadata is None: @@ -361,82 +343,62 @@ 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 - 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 - 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 not isinstance(raw_name, str): - message = 'Field `project.name` must be a string' - raise TypeError(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) - - self._raw_name = raw_name + 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 not isinstance(raw_name, str): + message = 'Field `project.name` must be a string' + raise TypeError(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) - 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: + 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,46 +410,44 @@ 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) - @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: @@ -611,38 +571,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: @@ -728,50 +685,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 @@ -779,56 +733,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 not isinstance(authors, list): - message = 'Field `project.authors` must be an array' - raise TypeError(message) + 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 = [] - from email.headerregistry import Address + if not isinstance(authors, list): + message = 'Field `project.authors` must be an array' + raise TypeError(message) - authors = deepcopy(authors) - authors_data = {'name': [], 'email': []} + from email.headerregistry import Address - 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) + authors = deepcopy(authors) + authors_data = {'name': [], 'email': []} - 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]]: @@ -840,63 +791,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 not isinstance(maintainers, list): - message = 'Field `project.maintainers` must be an array' - raise TypeError(message) + 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 = [] - from email.headerregistry import Address + if not isinstance(maintainers, list): + message = 'Field `project.maintainers` must be an array' + raise TypeError(message) - maintainers = deepcopy(maintainers) - maintainers_data: dict[str, list[str]] = {'name': [], 'email': []} + from email.headerregistry import Address - 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) + maintainers = deepcopy(maintainers) + maintainers_data: dict[str, list[str]] = {'name': [], 'email': []} - 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]]: @@ -908,454 +856,424 @@ 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 - - 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 '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 not isinstance(classifiers, list): + message = 'Field `project.classifiers` must be an array' + raise TypeError(message) - verify_classifiers = not os.environ.get('HATCH_METADATA_CLASSIFIERS_NO_VERIFY') - if verify_classifiers: - import trove_classifiers + verify_classifiers = not os.environ.get('HATCH_METADATA_CLASSIFIERS_NO_VERIFY') + if verify_classifiers: + import bisect - known_classifiers = trove_classifiers.classifiers | self._extra_classifiers - sorted_classifiers = list(trove_classifiers.sorted_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 '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 not isinstance(urls, dict): + message = 'Field `project.urls` must be a table' + raise TypeError(message) - sorted_urls = {} + sorted_urls = {} - 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[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 '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 not isinstance(gui_scripts, dict): + message = 'Field `project.gui-scripts` must be a table' + raise TypeError(message) - sorted_gui_scripts = {} + sorted_gui_scripts = {} - 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[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 '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 not isinstance(defined_entry_point_groups, dict): + message = 'Field `project.entry-points` must be a table' + raise TypeError(message) - 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 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) - entry_point_groups = {} + entry_point_groups = {} - 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 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_points = {} + entry_points = {} - 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[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 + 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 = [] - - 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 {option: list(entries) for option, entries in self.optional_dependencies_complex.items()} - return self._optional_dependencies - - @property + @cached_property def dynamic(self) -> list[str]: """ + Dynamic metadata whose values have not yet been resolved. + 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: The returned list is mutable, and dynamic fields will be removed after + 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) @@ -1440,88 +1358,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) + 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) + if not isinstance(scheme, str): + message = 'Field `tool.hatch.version.scheme` must be a string' + raise TypeError(message) - self._scheme_name = scheme + return scheme - return self._scheme_name - - @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]): 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")}'