pypa-hatch/tests/backend/metadata/test_core.py

1785 lines
70 KiB
Python

import pytest
from hatchling.metadata.core import BuildMetadata, CoreMetadata, HatchMetadata, ProjectMetadata
from hatchling.metadata.spec import (
LATEST_METADATA_VERSION,
get_core_metadata_constructors,
project_metadata_from_core_metadata,
)
from hatchling.plugin.manager import PluginManager
from hatchling.utils.constants import DEFAULT_BUILD_SCRIPT
from hatchling.version.source.regex import RegexSource
@pytest.fixture(scope='module')
def latest_spec():
return get_core_metadata_constructors()[LATEST_METADATA_VERSION]
class TestConfig:
def test_default(self, isolation):
metadata = ProjectMetadata(str(isolation), None)
assert metadata.config == metadata.config == {}
def test_reuse(self, isolation):
config = {}
metadata = ProjectMetadata(str(isolation), None, config)
assert metadata.config is metadata.config is config
def test_read(self, temp_dir):
project_file = temp_dir / 'pyproject.toml'
project_file.write_text('foo = 5')
with temp_dir.as_cwd():
metadata = ProjectMetadata(str(temp_dir), None)
assert metadata.config == metadata.config == {'foo': 5}
class TestInterface:
def test_types(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {'project': {}})
assert isinstance(metadata.core, CoreMetadata)
assert isinstance(metadata.hatch, HatchMetadata)
assert isinstance(metadata.build, BuildMetadata)
def test_missing_core_metadata(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {})
with pytest.raises(ValueError, match='Missing `project` metadata table in configuration'):
_ = metadata.core
def test_core_metadata_not_table(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {'project': 'foo'})
with pytest.raises(TypeError, match='The `project` configuration must be a table'):
_ = metadata.core
def test_tool_metadata_not_table(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {'tool': 'foo'})
with pytest.raises(TypeError, match='The `tool` configuration must be a table'):
_ = metadata.hatch
def test_hatch_metadata_not_table(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {'tool': {'hatch': 'foo'}})
with pytest.raises(TypeError, match='The `tool.hatch` configuration must be a table'):
_ = metadata.hatch
def test_build_metadata_not_table(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {'build-system': 'foo'})
with pytest.raises(TypeError, match='The `build-system` configuration must be a table'):
_ = metadata.build
class TestDynamic:
def test_not_array(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {'project': {'dynamic': 10}})
with pytest.raises(TypeError, match='Field `project.dynamic` must be an array'):
_ = metadata.core.dynamic
def test_entry_not_string(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {'project': {'dynamic': [10]}})
with pytest.raises(TypeError, match='Field #1 of field `project.dynamic` must be a string'):
_ = metadata.core.dynamic
def test_correct(self, isolation):
dynamic = ['version']
metadata = ProjectMetadata(str(isolation), None, {'project': {'dynamic': dynamic}})
assert metadata.core.dynamic == ['version']
def test_cache_not_array(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {'project': {'dynamic': 10}})
with pytest.raises(TypeError, match='Field `project.dynamic` must be an array'):
_ = metadata.dynamic
def test_cache_entry_not_string(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {'project': {'dynamic': [10]}})
with pytest.raises(TypeError, match='Field #1 of field `project.dynamic` must be a string'):
_ = metadata.dynamic
def test_cache_correct(self, temp_dir, helpers):
metadata = ProjectMetadata(
str(temp_dir),
PluginManager(),
{
'project': {'name': 'foo', 'dynamic': ['version', 'description']},
'tool': {'hatch': {'version': {'path': 'a/b'}, 'metadata': {'hooks': {'custom': {}}}}},
},
)
file_path = temp_dir / 'a' / 'b'
file_path.ensure_parent_dir_exists()
file_path.write_text('__version__ = "0.0.1"')
file_path = temp_dir / DEFAULT_BUILD_SCRIPT
file_path.write_text(
helpers.dedent(
"""
from hatchling.metadata.plugin.interface import MetadataHookInterface
class CustomHook(MetadataHookInterface):
def update(self, metadata):
metadata['description'] = metadata['name'] + 'bar'
"""
)
)
# Trigger hooks with `metadata.core` first
assert metadata.core.dynamic == []
assert metadata.dynamic == ['version', 'description']
class TestRawName:
def test_dynamic(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {'project': {'name': 9000, 'dynamic': ['name']}})
with pytest.raises(
ValueError, match='Static metadata field `name` cannot be present in field `project.dynamic`'
):
_ = metadata.core.raw_name
def test_missing(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {'project': {}})
with pytest.raises(ValueError, match='Missing required field `project.name`'):
_ = metadata.core.raw_name
def test_not_string(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {'project': {'name': 9000}})
with pytest.raises(TypeError, match='Field `project.name` must be a string'):
_ = metadata.core.raw_name
def test_invalid(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {'project': {'name': 'my app'}})
with pytest.raises(
ValueError,
match=(
'Required field `project.name` must only contain ASCII letters/digits, underscores, '
'hyphens, and periods, and must begin and end with ASCII letters/digits.'
),
):
_ = metadata.core.raw_name
def test_correct(self, isolation):
name = 'My.App'
metadata = ProjectMetadata(str(isolation), None, {'project': {'name': name}})
assert metadata.core.raw_name is metadata.core.raw_name is name
class TestName:
@pytest.mark.parametrize('name', ['My--App', 'My__App', 'My..App'])
def test_normalization(self, isolation, name):
metadata = ProjectMetadata(str(isolation), None, {'project': {'name': name}})
assert metadata.core.name == metadata.core.name == 'my-app'
class TestVersion:
def test_dynamic(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {'project': {'version': 9000, 'dynamic': ['version']}})
with pytest.raises(
ValueError,
match='Metadata field `version` cannot be both statically defined and listed in field `project.dynamic`',
):
_ = metadata.core.version
def test_static_missing(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {'project': {}})
with pytest.raises(
ValueError,
match='Field `project.version` can only be resolved dynamically if `version` is in field `project.dynamic`',
):
_ = metadata.version
def test_static_not_string(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {'project': {'version': 9000}})
with pytest.raises(TypeError, match='Field `project.version` must be a string'):
_ = metadata.version
def test_static_invalid(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {'project': {'version': '0..0'}})
with pytest.raises(
ValueError,
match='Invalid version `0..0` from field `project.version`, see https://peps.python.org/pep-0440/',
):
_ = metadata.version
def test_static_normalization(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {'project': {'version': '0.1.0.0-rc.1'}})
assert metadata.version == metadata.version == '0.1.0.0rc1'
assert metadata.core.version == metadata.core.version == '0.1.0.0-rc.1'
def test_dynamic_missing(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {'project': {'dynamic': ['version']}, 'tool': {'hatch': {}}})
with pytest.raises(ValueError, match='Missing `tool.hatch.version` configuration'):
_ = metadata.version
def test_dynamic_not_table(self, isolation):
metadata = ProjectMetadata(
str(isolation), None, {'project': {'dynamic': ['version']}, 'tool': {'hatch': {'version': '1.0'}}}
)
with pytest.raises(TypeError, match='Field `tool.hatch.version` must be a table'):
_ = metadata.version
def test_dynamic_source_empty(self, isolation):
metadata = ProjectMetadata(
str(isolation), None, {'project': {'dynamic': ['version']}, 'tool': {'hatch': {'version': {'source': ''}}}}
)
with pytest.raises(
ValueError, match='The `source` option under the `tool.hatch.version` table must not be empty if defined'
):
_ = metadata.version.cached
def test_dynamic_source_not_string(self, isolation):
metadata = ProjectMetadata(
str(isolation), None, {'project': {'dynamic': ['version']}, 'tool': {'hatch': {'version': {'source': 42}}}}
)
with pytest.raises(TypeError, match='Field `tool.hatch.version.source` must be a string'):
_ = metadata.version.cached
def test_dynamic_unknown_source(self, isolation):
metadata = ProjectMetadata(
str(isolation),
PluginManager(),
{'project': {'dynamic': ['version']}, 'tool': {'hatch': {'version': {'source': 'foo'}}}},
)
with pytest.raises(ValueError, match='Unknown version source: foo'):
_ = metadata.version.cached
def test_dynamic_source_regex(self, temp_dir):
metadata = ProjectMetadata(
str(temp_dir),
PluginManager(),
{'project': {'dynamic': ['version']}, 'tool': {'hatch': {'version': {'source': 'regex', 'path': 'a/b'}}}},
)
file_path = temp_dir / 'a' / 'b'
file_path.ensure_parent_dir_exists()
file_path.write_text('__version__ = "0.0.1"')
assert metadata.hatch.version.source is metadata.hatch.version.source
assert isinstance(metadata.hatch.version.source, RegexSource)
assert metadata.hatch.version.cached == metadata.hatch.version.cached == '0.0.1'
def test_dynamic_source_regex_invalid(self, temp_dir):
metadata = ProjectMetadata(
str(temp_dir),
PluginManager(),
{'project': {'dynamic': ['version']}, 'tool': {'hatch': {'version': {'source': 'regex', 'path': 'a/b'}}}},
)
file_path = temp_dir / 'a' / 'b'
file_path.ensure_parent_dir_exists()
file_path.write_text('__version__ = "0..0"')
with pytest.raises(
ValueError, match='Invalid version `0..0` from source `regex`, see https://peps.python.org/pep-0440/'
):
_ = metadata.version
def test_dynamic_error(self, isolation):
metadata = ProjectMetadata(
str(isolation),
PluginManager(),
{'project': {'dynamic': ['version']}, 'tool': {'hatch': {'version': {'source': 'regex'}}}},
)
with pytest.raises(
ValueError, match='Error getting the version from source `regex`: option `path` must be specified'
):
_ = metadata.version.cached
class TestDescription:
def test_dynamic(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {'project': {'description': 9000, 'dynamic': ['description']}})
with pytest.raises(
ValueError,
match=(
'Metadata field `description` cannot be both statically defined and listed in field `project.dynamic`'
),
):
_ = metadata.core.description
def test_not_string(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {'project': {'description': 9000}})
with pytest.raises(TypeError, match='Field `project.description` must be a string'):
_ = metadata.core.description
def test_default(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {'project': {}})
assert metadata.core.description == metadata.core.description == ''
def test_custom(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {'project': {'description': 'foo'}})
assert metadata.core.description == metadata.core.description == 'foo'
def test_normaliza(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {'project': {'description': '\nfirst line.\r\nsecond line'}})
assert metadata.core.description == metadata.core.description == ' first line. second line'
class TestReadme:
def test_dynamic(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {'project': {'readme': 9000, 'dynamic': ['readme']}})
with pytest.raises(
ValueError,
match='Metadata field `readme` cannot be both statically defined and listed in field `project.dynamic`',
):
_ = metadata.core.readme
@pytest.mark.parametrize('attribute', ['readme', 'readme_content_type'])
def test_unknown_type(self, isolation, attribute):
metadata = ProjectMetadata(str(isolation), None, {'project': {'readme': 9000}})
with pytest.raises(TypeError, match='Field `project.readme` must be a string or a table'):
_ = getattr(metadata.core, attribute)
def test_default(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {'project': {}})
assert metadata.core.readme == metadata.core.readme == ''
assert metadata.core.readme_content_type == metadata.core.readme_content_type == 'text/markdown'
assert metadata.core.readme_path == metadata.core.readme_path == ''
def test_string_path_unknown_content_type(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {'project': {'readme': 'foo'}})
with pytest.raises(
TypeError, match='Unable to determine the content-type based on the extension of readme file: foo'
):
_ = metadata.core.readme
def test_string_path_nonexistent(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {'project': {'readme': 'foo/bar.md'}})
with pytest.raises(OSError, match='Readme file does not exist: foo/bar\\.md'):
_ = metadata.core.readme
@pytest.mark.parametrize(
('extension', 'content_type'), [('.md', 'text/markdown'), ('.rst', 'text/x-rst'), ('.txt', 'text/plain')]
)
def test_string_correct(self, extension, content_type, temp_dir):
metadata = ProjectMetadata(str(temp_dir), None, {'project': {'readme': f'foo/bar{extension}'}})
file_path = temp_dir / 'foo' / f'bar{extension}'
file_path.ensure_parent_dir_exists()
file_path.write_text('test content')
assert metadata.core.readme == metadata.core.readme == 'test content'
assert metadata.core.readme_content_type == metadata.core.readme_content_type == content_type
assert metadata.core.readme_path == metadata.core.readme_path == f'foo/bar{extension}'
def test_table_content_type_missing(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {'project': {'readme': {}}})
with pytest.raises(ValueError, match='Field `content-type` is required in the `project.readme` table'):
_ = metadata.core.readme
def test_table_content_type_not_string(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {'project': {'readme': {'content-type': 5}}})
with pytest.raises(TypeError, match='Field `content-type` in the `project.readme` table must be a string'):
_ = metadata.core.readme
def test_table_content_type_not_unknown(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {'project': {'readme': {'content-type': 'foo'}}})
with pytest.raises(
ValueError,
match=(
'Field `content-type` in the `project.readme` table must be one of the following: '
'text/markdown, text/x-rst, text/plain'
),
):
_ = metadata.core.readme
def test_table_multiple_options(self, isolation):
metadata = ProjectMetadata(
str(isolation), None, {'project': {'readme': {'content-type': 'text/markdown', 'file': '', 'text': ''}}}
)
with pytest.raises(ValueError, match='Cannot specify both `file` and `text` in the `project.readme` table'):
_ = metadata.core.readme
def test_table_no_option(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {'project': {'readme': {'content-type': 'text/markdown'}}})
with pytest.raises(ValueError, match='Must specify either `file` or `text` in the `project.readme` table'):
_ = metadata.core.readme
def test_table_file_not_string(self, isolation):
metadata = ProjectMetadata(
str(isolation), None, {'project': {'readme': {'content-type': 'text/markdown', 'file': 4}}}
)
with pytest.raises(TypeError, match='Field `file` in the `project.readme` table must be a string'):
_ = metadata.core.readme
def test_table_file_nonexistent(self, isolation):
metadata = ProjectMetadata(
str(isolation), None, {'project': {'readme': {'content-type': 'text/markdown', 'file': 'foo/bar.md'}}}
)
with pytest.raises(OSError, match='Readme file does not exist: foo/bar\\.md'):
_ = metadata.core.readme
def test_table_file_correct(self, temp_dir):
metadata = ProjectMetadata(
str(temp_dir), None, {'project': {'readme': {'content-type': 'text/markdown', 'file': 'foo/bar.markdown'}}}
)
file_path = temp_dir / 'foo' / 'bar.markdown'
file_path.ensure_parent_dir_exists()
file_path.write_text('test content')
assert metadata.core.readme == metadata.core.readme == 'test content'
assert metadata.core.readme_content_type == metadata.core.readme_content_type == 'text/markdown'
assert metadata.core.readme_path == metadata.core.readme_path == 'foo/bar.markdown'
def test_table_text_not_string(self, isolation):
metadata = ProjectMetadata(
str(isolation), None, {'project': {'readme': {'content-type': 'text/markdown', 'text': 4}}}
)
with pytest.raises(TypeError, match='Field `text` in the `project.readme` table must be a string'):
_ = metadata.core.readme
def test_table_text_correct(self, isolation):
metadata = ProjectMetadata(
str(isolation), None, {'project': {'readme': {'content-type': 'text/markdown', 'text': 'test content'}}}
)
assert metadata.core.readme == metadata.core.readme == 'test content'
assert metadata.core.readme_content_type == metadata.core.readme_content_type == 'text/markdown'
assert metadata.core.readme_path == metadata.core.readme_path == ''
class TestRequiresPython:
def test_dynamic(self, isolation):
metadata = ProjectMetadata(
str(isolation), None, {'project': {'requires-python': 9000, 'dynamic': ['requires-python']}}
)
with pytest.raises(
ValueError,
match=(
'Metadata field `requires-python` cannot be both statically defined and '
'listed in field `project.dynamic`'
),
):
_ = metadata.core.requires_python
@pytest.mark.parametrize('attribute', ['requires_python', 'python_constraint'])
def test_not_string(self, isolation, attribute):
metadata = ProjectMetadata(str(isolation), None, {'project': {'requires-python': 9000}})
with pytest.raises(TypeError, match='Field `project.requires-python` must be a string'):
_ = getattr(metadata.core, attribute)
def test_invalid(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {'project': {'requires-python': '^1'}})
with pytest.raises(ValueError, match='Field `project.requires-python` is invalid: .+'):
_ = metadata.core.requires_python
def test_default(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {'project': {}})
assert metadata.core.requires_python == metadata.core.requires_python == ''
for major_version in map(str, range(10)):
assert metadata.core.python_constraint.contains(major_version)
def test_custom(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {'project': {'requires-python': '>2'}})
assert metadata.core.requires_python == metadata.core.requires_python == '>2'
assert not metadata.core.python_constraint.contains('2')
assert metadata.core.python_constraint.contains('3')
class TestLicense:
def test_dynamic(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {'project': {'license': 9000, 'dynamic': ['license']}})
with pytest.raises(
ValueError,
match='Metadata field `license` cannot be both statically defined and listed in field `project.dynamic`',
):
_ = metadata.core.license
def test_invalid_type(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {'project': {'license': 9000}})
with pytest.raises(TypeError, match='Field `project.license` must be a string or a table'):
_ = metadata.core.license
def test_default(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {'project': {}})
assert metadata.core.license == metadata.core.license == ''
assert metadata.core.license_expression == metadata.core.license_expression == ''
def test_normalization(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {'project': {'license': 'mit or apache-2.0'}})
assert metadata.core.license_expression == 'MIT OR Apache-2.0'
def test_invalid_expression(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {'project': {'license': 'mit or foo'}})
with pytest.raises(ValueError, match="Error parsing field `project.license` - Unknown license: 'foo'"):
_ = metadata.core.license_expression
def test_multiple_options(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {'project': {'license': {'file': '', 'text': ''}}})
with pytest.raises(ValueError, match='Cannot specify both `file` and `text` in the `project.license` table'):
_ = metadata.core.license
def test_no_option(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {'project': {'license': {}}})
with pytest.raises(ValueError, match='Must specify either `file` or `text` in the `project.license` table'):
_ = metadata.core.license
def test_file_not_string(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {'project': {'license': {'file': 4}}})
with pytest.raises(TypeError, match='Field `file` in the `project.license` table must be a string'):
_ = metadata.core.license
def test_file_nonexistent(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {'project': {'license': {'file': 'foo/bar.md'}}})
with pytest.raises(OSError, match='License file does not exist: foo/bar\\.md'):
_ = metadata.core.license
def test_file_correct(self, temp_dir):
metadata = ProjectMetadata(str(temp_dir), None, {'project': {'license': {'file': 'foo/bar.md'}}})
file_path = temp_dir / 'foo' / 'bar.md'
file_path.ensure_parent_dir_exists()
file_path.write_text('test content')
assert metadata.core.license == 'test content'
def test_text_not_string(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {'project': {'license': {'text': 4}}})
with pytest.raises(TypeError, match='Field `text` in the `project.license` table must be a string'):
_ = metadata.core.license
def test_text_correct(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {'project': {'license': {'text': 'test content'}}})
assert metadata.core.license == 'test content'
class TestLicenseFiles:
def test_dynamic(self, isolation):
metadata = ProjectMetadata(
str(isolation), None, {'project': {'license-files': 9000, 'dynamic': ['license-files']}}
)
with pytest.raises(
ValueError,
match=(
'Metadata field `license-files` cannot be both statically defined and '
'listed in field `project.dynamic`'
),
):
_ = metadata.core.license_files
def test_not_array(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {'project': {'license-files': 9000}})
with pytest.raises(TypeError, match='Field `project.license-files` must be an array'):
_ = metadata.core.license_files
def test_entry_not_string(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {'project': {'license-files': [9000]}})
with pytest.raises(TypeError, match='Entry #1 of field `project.license-files` must be a string'):
_ = metadata.core.license_files
def test_default_globs_no_licenses(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {'project': {}})
assert metadata.core.license_files == metadata.core.license_files == []
def test_default_globs_with_licenses(self, temp_dir):
metadata = ProjectMetadata(str(temp_dir), None, {'project': {}})
expected = []
(temp_dir / 'foo').touch()
for name in ('LICENSE', 'LICENCE', 'COPYING', 'NOTICE', 'AUTHORS'):
(temp_dir / name).touch()
expected.append(name)
name_with_extension = f'{name}.txt'
(temp_dir / f'{name}.txt').touch()
expected.append(name_with_extension)
assert metadata.core.license_files == sorted(expected)
def test_globs_with_licenses(self, temp_dir):
metadata = ProjectMetadata(str(temp_dir), None, {'project': {'license-files': ['LICENSES/*']}})
licenses_dir = temp_dir / 'LICENSES'
licenses_dir.mkdir()
(licenses_dir / 'MIT.txt').touch()
(licenses_dir / 'Apache-2.0.txt').touch()
for name in ('LICENSE', 'LICENCE', 'COPYING', 'NOTICE', 'AUTHORS'):
(temp_dir / name).touch()
assert metadata.core.license_files == ['LICENSES/Apache-2.0.txt', 'LICENSES/MIT.txt']
def test_paths_with_licenses(self, temp_dir):
metadata = ProjectMetadata(
str(temp_dir),
None,
{'project': {'license-files': ['LICENSES/Apache-2.0.txt', 'LICENSES/MIT.txt', 'COPYING']}},
)
licenses_dir = temp_dir / 'LICENSES'
licenses_dir.mkdir()
(licenses_dir / 'MIT.txt').touch()
(licenses_dir / 'Apache-2.0.txt').touch()
for name in ('LICENSE', 'LICENCE', 'COPYING', 'NOTICE', 'AUTHORS'):
(temp_dir / name).touch()
assert metadata.core.license_files == ['COPYING', 'LICENSES/Apache-2.0.txt', 'LICENSES/MIT.txt']
class TestAuthors:
def test_dynamic(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {'project': {'authors': 9000, 'dynamic': ['authors']}})
with pytest.raises(
ValueError,
match='Metadata field `authors` cannot be both statically defined and listed in field `project.dynamic`',
):
_ = metadata.core.authors
def test_not_array(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {'project': {'authors': 'foo'}})
with pytest.raises(TypeError, match='Field `project.authors` must be an array'):
_ = metadata.core.authors
def test_default(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {'project': {}})
assert metadata.core.authors == metadata.core.authors == []
def test_not_table(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {'project': {'authors': ['foo']}})
with pytest.raises(TypeError, match='Author #1 of field `project.authors` must be an inline table'):
_ = metadata.core.authors
def test_no_data(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {'project': {'authors': [{}]}})
with pytest.raises(
ValueError, match='Author #1 of field `project.authors` must specify either `name` or `email`'
):
_ = metadata.core.authors
def test_name_not_string(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {'project': {'authors': [{'name': 9}]}})
with pytest.raises(TypeError, match='Name of author #1 of field `project.authors` must be a string'):
_ = metadata.core.authors
def test_name_only(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {'project': {'authors': [{'name': 'foo'}]}})
assert len(metadata.core.authors) == 1
assert metadata.core.authors[0] == {'name': 'foo'}
assert metadata.core.authors_data == metadata.core.authors_data == {'name': ['foo'], 'email': []}
def test_email_not_string(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {'project': {'authors': [{'email': 9}]}})
with pytest.raises(TypeError, match='Email of author #1 of field `project.authors` must be a string'):
_ = metadata.core.authors
def test_email_only(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {'project': {'authors': [{'email': 'foo@bar.baz'}]}})
assert len(metadata.core.authors) == 1
assert metadata.core.authors[0] == {'email': 'foo@bar.baz'}
assert metadata.core.authors_data == {'name': [], 'email': ['foo@bar.baz']}
def test_name_and_email(self, isolation):
metadata = ProjectMetadata(
str(isolation),
None,
{
'project': {
'authors': [{'name': 'foo2', 'email': 'foo2@bar.baz'}, {'name': 'foo1', 'email': 'foo1@bar.baz'}]
}
},
)
assert len(metadata.core.authors) == 2
assert metadata.core.authors[0] == {'name': 'foo2', 'email': 'foo2@bar.baz'}
assert metadata.core.authors[1] == {'name': 'foo1', 'email': 'foo1@bar.baz'}
assert metadata.core.authors_data == {'name': [], 'email': ['foo2 <foo2@bar.baz>', 'foo1 <foo1@bar.baz>']}
class TestMaintainers:
def test_dynamic(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {'project': {'maintainers': 9000, 'dynamic': ['maintainers']}})
with pytest.raises(
ValueError,
match=(
'Metadata field `maintainers` cannot be both statically defined and listed in field `project.dynamic`'
),
):
_ = metadata.core.maintainers
def test_not_array(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {'project': {'maintainers': 'foo'}})
with pytest.raises(TypeError, match='Field `project.maintainers` must be an array'):
_ = metadata.core.maintainers
def test_default(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {'project': {}})
assert metadata.core.maintainers == metadata.core.maintainers == []
def test_not_table(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {'project': {'maintainers': ['foo']}})
with pytest.raises(TypeError, match='Maintainer #1 of field `project.maintainers` must be an inline table'):
_ = metadata.core.maintainers
def test_no_data(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {'project': {'maintainers': [{}]}})
with pytest.raises(
ValueError, match='Maintainer #1 of field `project.maintainers` must specify either `name` or `email`'
):
_ = metadata.core.maintainers
def test_name_not_string(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {'project': {'maintainers': [{'name': 9}]}})
with pytest.raises(TypeError, match='Name of maintainer #1 of field `project.maintainers` must be a string'):
_ = metadata.core.maintainers
def test_name_only(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {'project': {'maintainers': [{'name': 'foo'}]}})
assert len(metadata.core.maintainers) == 1
assert metadata.core.maintainers[0] == {'name': 'foo'}
assert metadata.core.maintainers_data == metadata.core.maintainers_data == {'name': ['foo'], 'email': []}
def test_email_not_string(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {'project': {'maintainers': [{'email': 9}]}})
with pytest.raises(TypeError, match='Email of maintainer #1 of field `project.maintainers` must be a string'):
_ = metadata.core.maintainers
def test_email_only(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {'project': {'maintainers': [{'email': 'foo@bar.baz'}]}})
assert len(metadata.core.maintainers) == 1
assert metadata.core.maintainers[0] == {'email': 'foo@bar.baz'}
assert metadata.core.maintainers_data == {'name': [], 'email': ['foo@bar.baz']}
def test_name_and_email(self, isolation):
metadata = ProjectMetadata(
str(isolation),
None,
{
'project': {
'maintainers': [
{'name': 'foo2', 'email': 'foo2@bar.baz'},
{'name': 'foo1', 'email': 'foo1@bar.baz'},
]
}
},
)
assert len(metadata.core.maintainers) == 2
assert metadata.core.maintainers[0] == {'name': 'foo2', 'email': 'foo2@bar.baz'}
assert metadata.core.maintainers[1] == {'name': 'foo1', 'email': 'foo1@bar.baz'}
assert metadata.core.maintainers_data == {'name': [], 'email': ['foo2 <foo2@bar.baz>', 'foo1 <foo1@bar.baz>']}
class TestKeywords:
def test_dynamic(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {'project': {'keywords': 9000, 'dynamic': ['keywords']}})
with pytest.raises(
ValueError,
match='Metadata field `keywords` cannot be both statically defined and listed in field `project.dynamic`',
):
_ = metadata.core.keywords
def test_not_array(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {'project': {'keywords': 10}})
with pytest.raises(TypeError, match='Field `project.keywords` must be an array'):
_ = metadata.core.keywords
def test_entry_not_string(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {'project': {'keywords': [10]}})
with pytest.raises(TypeError, match='Keyword #1 of field `project.keywords` must be a string'):
_ = metadata.core.keywords
def test_correct(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {'project': {'keywords': ['foo', 'foo', 'bar']}})
assert metadata.core.keywords == metadata.core.keywords == ['bar', 'foo']
class TestClassifiers:
def test_dynamic(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {'project': {'classifiers': 9000, 'dynamic': ['classifiers']}})
with pytest.raises(
ValueError,
match=(
'Metadata field `classifiers` cannot be both statically defined and listed in field `project.dynamic`'
),
):
_ = metadata.core.classifiers
def test_not_array(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {'project': {'classifiers': 10}})
with pytest.raises(TypeError, match='Field `project.classifiers` must be an array'):
_ = metadata.core.classifiers
def test_entry_not_string(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {'project': {'classifiers': [10]}})
with pytest.raises(TypeError, match='Classifier #1 of field `project.classifiers` must be a string'):
_ = metadata.core.classifiers
def test_entry_unknown(self, isolation, monkeypatch):
monkeypatch.delenv('HATCH_METADATA_CLASSIFIERS_NO_VERIFY', False)
metadata = ProjectMetadata(str(isolation), None, {'project': {'classifiers': ['foo']}})
with pytest.raises(ValueError, match='Unknown classifier in field `project.classifiers`: foo'):
_ = metadata.core.classifiers
def test_entry_unknown_no_verify(self, isolation, monkeypatch):
monkeypatch.setenv('HATCH_METADATA_CLASSIFIERS_NO_VERIFY', '1')
classifiers = [
'Programming Language :: Python :: 3.11',
'Programming Language :: Python :: 3.11',
'Programming Language :: Python :: 3.9',
'Development Status :: 4 - Beta',
'Private :: Do Not Upload',
'Foo',
]
metadata = ProjectMetadata(str(isolation), None, {'project': {'classifiers': classifiers}})
assert (
metadata.core.classifiers
== metadata.core.classifiers
== [
'Private :: Do Not Upload',
'Development Status :: 4 - Beta',
'Foo',
'Programming Language :: Python :: 3.9',
'Programming Language :: Python :: 3.11',
]
)
def test_correct(self, isolation):
classifiers = [
'Programming Language :: Python :: 3.11',
'Programming Language :: Python :: 3.11',
'Programming Language :: Python :: 3.9',
'Development Status :: 4 - Beta',
'Private :: Do Not Upload',
]
metadata = ProjectMetadata(str(isolation), None, {'project': {'classifiers': classifiers}})
assert (
metadata.core.classifiers
== metadata.core.classifiers
== [
'Private :: Do Not Upload',
'Development Status :: 4 - Beta',
'Programming Language :: Python :: 3.9',
'Programming Language :: Python :: 3.11',
]
)
class TestURLs:
def test_dynamic(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {'project': {'urls': 9000, 'dynamic': ['urls']}})
with pytest.raises(
ValueError,
match='Metadata field `urls` cannot be both statically defined and listed in field `project.dynamic`',
):
_ = metadata.core.urls
def test_not_table(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {'project': {'urls': 10}})
with pytest.raises(TypeError, match='Field `project.urls` must be a table'):
_ = metadata.core.urls
def test_entry_not_string(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {'project': {'urls': {'foo': 7}}})
with pytest.raises(TypeError, match='URL `foo` of field `project.urls` must be a string'):
_ = metadata.core.urls
def test_correct(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {'project': {'urls': {'foo': 'bar', 'bar': 'baz'}}})
assert metadata.core.urls == metadata.core.urls == {'bar': 'baz', 'foo': 'bar'}
class TestScripts:
def test_dynamic(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {'project': {'scripts': 9000, 'dynamic': ['scripts']}})
with pytest.raises(
ValueError,
match='Metadata field `scripts` cannot be both statically defined and listed in field `project.dynamic`',
):
_ = metadata.core.scripts
def test_not_table(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {'project': {'scripts': 10}})
with pytest.raises(TypeError, match='Field `project.scripts` must be a table'):
_ = metadata.core.scripts
def test_entry_not_string(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {'project': {'scripts': {'foo': 7}}})
with pytest.raises(TypeError, match='Object reference `foo` of field `project.scripts` must be a string'):
_ = metadata.core.scripts
def test_correct(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {'project': {'scripts': {'foo': 'bar', 'bar': 'baz'}}})
assert metadata.core.scripts == metadata.core.scripts == {'bar': 'baz', 'foo': 'bar'}
class TestGUIScripts:
def test_dynamic(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {'project': {'gui-scripts': 9000, 'dynamic': ['gui-scripts']}})
with pytest.raises(
ValueError,
match=(
'Metadata field `gui-scripts` cannot be both statically defined and listed in field `project.dynamic`'
),
):
_ = metadata.core.gui_scripts
def test_not_table(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {'project': {'gui-scripts': 10}})
with pytest.raises(TypeError, match='Field `project.gui-scripts` must be a table'):
_ = metadata.core.gui_scripts
def test_entry_not_string(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {'project': {'gui-scripts': {'foo': 7}}})
with pytest.raises(TypeError, match='Object reference `foo` of field `project.gui-scripts` must be a string'):
_ = metadata.core.gui_scripts
def test_correct(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {'project': {'gui-scripts': {'foo': 'bar', 'bar': 'baz'}}})
assert metadata.core.gui_scripts == metadata.core.gui_scripts == {'bar': 'baz', 'foo': 'bar'}
class TestEntryPoints:
def test_dynamic(self, isolation):
metadata = ProjectMetadata(
str(isolation), None, {'project': {'entry-points': 9000, 'dynamic': ['entry-points']}}
)
with pytest.raises(
ValueError,
match=(
'Metadata field `entry-points` cannot be both statically defined and listed in field `project.dynamic`'
),
):
_ = metadata.core.entry_points
def test_not_table(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {'project': {'entry-points': 10}})
with pytest.raises(TypeError, match='Field `project.entry-points` must be a table'):
_ = metadata.core.entry_points
@pytest.mark.parametrize(('field', 'expected'), [('console_scripts', 'scripts'), ('gui-scripts', 'gui-scripts')])
def test_forbidden_fields(self, isolation, field, expected):
metadata = ProjectMetadata(str(isolation), None, {'project': {'entry-points': {field: 'foo'}}})
with pytest.raises(
ValueError,
match=(
f'Field `{field}` must be defined as `project.{expected}` instead of '
f'in the `project.entry-points` table'
),
):
_ = metadata.core.entry_points
def test_data_not_table(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {'project': {'entry-points': {'foo': 7}}})
with pytest.raises(TypeError, match='Field `project.entry-points.foo` must be a table'):
_ = metadata.core.entry_points
def test_data_entry_not_string(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {'project': {'entry-points': {'foo': {'bar': 4}}}})
with pytest.raises(
TypeError, match='Object reference `bar` of field `project.entry-points.foo` must be a string'
):
_ = metadata.core.entry_points
def test_data_empty(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {'project': {'entry-points': {'foo': {}}}})
assert metadata.core.entry_points == metadata.core.entry_points == {}
def test_default(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {'project': {}})
assert metadata.core.entry_points == metadata.core.entry_points == {}
def test_correct(self, isolation):
metadata = ProjectMetadata(
str(isolation),
None,
{'project': {'entry-points': {'foo': {'bar': 'baz', 'foo': 'baz'}, 'bar': {'foo': 'baz', 'bar': 'baz'}}}},
)
assert (
metadata.core.entry_points
== metadata.core.entry_points
== {'bar': {'bar': 'baz', 'foo': 'baz'}, 'foo': {'bar': 'baz', 'foo': 'baz'}}
)
class TestDependencies:
def test_dynamic(self, isolation):
metadata = ProjectMetadata(
str(isolation), None, {'project': {'dependencies': 9000, 'dynamic': ['dependencies']}}
)
with pytest.raises(
ValueError,
match=(
'Metadata field `dependencies` cannot be both statically defined and listed in field `project.dynamic`'
),
):
_ = metadata.core.dependencies
def test_not_array(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {'project': {'dependencies': 10}})
with pytest.raises(TypeError, match='Field `project.dependencies` must be an array'):
_ = metadata.core.dependencies
def test_entry_not_string(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {'project': {'dependencies': [10]}})
with pytest.raises(TypeError, match='Dependency #1 of field `project.dependencies` must be a string'):
_ = metadata.core.dependencies
def test_invalid(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {'project': {'dependencies': ['foo^1']}})
with pytest.raises(ValueError, match='Dependency #1 of field `project.dependencies` is invalid: .+'):
_ = metadata.core.dependencies
def test_direct_reference(self, isolation):
metadata = ProjectMetadata(
str(isolation), None, {'project': {'dependencies': ['proj @ git+https://github.com/org/proj.git@v1']}}
)
with pytest.raises(
ValueError,
match=(
'Dependency #1 of field `project.dependencies` cannot be a direct reference unless '
'field `tool.hatch.metadata.allow-direct-references` is set to `true`'
),
):
_ = metadata.core.dependencies
def test_direct_reference_allowed(self, isolation):
metadata = ProjectMetadata(
str(isolation),
None,
{
'project': {'dependencies': ['proj @ git+https://github.com/org/proj.git@v1']},
'tool': {'hatch': {'metadata': {'allow-direct-references': True}}},
},
)
assert metadata.core.dependencies == ['proj@ git+https://github.com/org/proj.git@v1']
def test_context_formatting(self, isolation, uri_slash_prefix):
metadata = ProjectMetadata(
str(isolation),
None,
{
'project': {'dependencies': ['proj @ {root:uri}']},
'tool': {'hatch': {'metadata': {'allow-direct-references': True}}},
},
)
normalized_path = str(isolation).replace('\\', '/')
assert metadata.core.dependencies == [f'proj@ file:{uri_slash_prefix}{normalized_path}']
def test_correct(self, isolation):
metadata = ProjectMetadata(
str(isolation),
None,
{
'project': {
'dependencies': [
'python___dateutil;platform_python_implementation=="CPython"',
'bAr.Baz[TLS, Zu.Bat, EdDSA, Zu_Bat] >=1.2RC5 , <9000B1',
'Foo;python_version<"3.8"',
'fOO; python_version< "3.8"',
],
},
},
)
assert (
metadata.core.dependencies
== metadata.core.dependencies
== [
'bar-baz[eddsa,tls,zu-bat]<9000b1,>=1.2rc5',
"foo; python_version < '3.8'",
"python-dateutil; platform_python_implementation == 'CPython'",
]
)
assert metadata.core.dependencies_complex is metadata.core.dependencies_complex
class TestOptionalDependencies:
def test_dynamic(self, isolation):
metadata = ProjectMetadata(
str(isolation), None, {'project': {'optional-dependencies': 9000, 'dynamic': ['optional-dependencies']}}
)
with pytest.raises(
ValueError,
match=(
'Metadata field `optional-dependencies` cannot be both statically defined and '
'listed in field `project.dynamic`'
),
):
_ = metadata.core.optional_dependencies
def test_not_table(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {'project': {'optional-dependencies': 10}})
with pytest.raises(TypeError, match='Field `project.optional-dependencies` must be a table'):
_ = metadata.core.optional_dependencies
def test_invalid_name(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {'project': {'optional-dependencies': {'foo/bar': []}}})
with pytest.raises(
ValueError,
match=(
'Optional dependency group `foo/bar` of field `project.optional-dependencies` must only contain '
'ASCII letters/digits, underscores, hyphens, and periods, and must begin and end with '
'ASCII letters/digits.'
),
):
_ = metadata.core.optional_dependencies
def test_definitions_not_array(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {'project': {'optional-dependencies': {'foo': 5}}})
with pytest.raises(
TypeError, match='Dependencies for option `foo` of field `project.optional-dependencies` must be an array'
):
_ = metadata.core.optional_dependencies
def test_entry_not_string(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {'project': {'optional-dependencies': {'foo': [5]}}})
with pytest.raises(
TypeError, match='Dependency #1 of option `foo` of field `project.optional-dependencies` must be a string'
):
_ = metadata.core.optional_dependencies
def test_invalid(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {'project': {'optional-dependencies': {'foo': ['bar^1']}}})
with pytest.raises(
ValueError, match='Dependency #1 of option `foo` of field `project.optional-dependencies` is invalid: .+'
):
_ = metadata.core.optional_dependencies
def test_conflict(self, isolation):
metadata = ProjectMetadata(
str(isolation), None, {'project': {'optional-dependencies': {'foo_bar': [], 'foo.bar': []}}}
)
with pytest.raises(
ValueError,
match=(
'Optional dependency groups `foo_bar` and `foo.bar` of field `project.optional-dependencies` both '
'evaluate to `foo-bar`.'
),
):
_ = metadata.core.optional_dependencies
def test_recursive_circular(self, isolation):
metadata = ProjectMetadata(
str(isolation),
None,
{'project': {'name': 'my-app', 'optional-dependencies': {'foo': ['my-app[bar]'], 'bar': ['my-app[foo]']}}},
)
with pytest.raises(
ValueError,
match='Field `project.optional-dependencies` defines a circular dependency group: foo',
):
_ = metadata.core.optional_dependencies
def test_recursive_unknown(self, isolation):
metadata = ProjectMetadata(
str(isolation),
None,
{'project': {'name': 'my-app', 'optional-dependencies': {'foo': ['my-app[bar]']}}},
)
with pytest.raises(
ValueError,
match='Unknown recursive dependency group in field `project.optional-dependencies`: bar',
):
_ = metadata.core.optional_dependencies
def test_allow_ambiguity(self, isolation):
metadata = ProjectMetadata(
str(isolation),
None,
{
'project': {'optional-dependencies': {'foo_bar': [], 'foo.bar': []}},
'tool': {'hatch': {'metadata': {'allow-ambiguous-features': True}}},
},
)
assert metadata.core.optional_dependencies == {'foo_bar': [], 'foo.bar': []}
def test_direct_reference(self, isolation):
metadata = ProjectMetadata(
str(isolation),
None,
{'project': {'optional-dependencies': {'foo': ['proj @ git+https://github.com/org/proj.git@v1']}}},
)
with pytest.raises(
ValueError,
match=(
'Dependency #1 of option `foo` of field `project.optional-dependencies` cannot be a direct reference '
'unless field `tool.hatch.metadata.allow-direct-references` is set to `true`'
),
):
_ = metadata.core.optional_dependencies
def test_context_formatting(self, isolation, uri_slash_prefix):
metadata = ProjectMetadata(
str(isolation),
None,
{
'project': {'name': 'my-app', 'optional-dependencies': {'foo': ['proj @ {root:uri}']}},
'tool': {'hatch': {'metadata': {'allow-direct-references': True}}},
},
)
normalized_path = str(isolation).replace('\\', '/')
assert metadata.core.optional_dependencies == {'foo': [f'proj@ file:{uri_slash_prefix}{normalized_path}']}
def test_direct_reference_allowed(self, isolation):
metadata = ProjectMetadata(
str(isolation),
None,
{
'project': {
'name': 'my-app',
'optional-dependencies': {'foo': ['proj @ git+https://github.com/org/proj.git@v1']},
},
'tool': {'hatch': {'metadata': {'allow-direct-references': True}}},
},
)
assert metadata.core.optional_dependencies == {'foo': ['proj@ git+https://github.com/org/proj.git@v1']}
def test_correct(self, isolation):
metadata = ProjectMetadata(
str(isolation),
None,
{
'project': {
'name': 'my-app',
'optional-dependencies': {
'foo': [
'python___dateutil;platform_python_implementation=="CPython"',
'bAr.Baz[TLS, Zu.Bat, EdDSA, Zu_Bat] >=1.2RC5 , <9000B1',
'Foo;python_version<"3.8"',
'fOO; python_version< "3.8"',
'MY-APP[zZz]',
],
'bar': ['foo', 'bar', 'Baz'],
'baz': ['my___app[XYZ]'],
'xyz': ['my...app[Bar]'],
'zzz': ['aaa'],
},
},
},
)
assert (
metadata.core.optional_dependencies
== metadata.core.optional_dependencies
== {
'bar': ['bar', 'baz', 'foo'],
'baz': ['bar', 'baz', 'foo'],
'foo': [
'aaa',
'bar-baz[eddsa,tls,zu-bat]<9000b1,>=1.2rc5',
"foo; python_version < '3.8'",
"python-dateutil; platform_python_implementation == 'CPython'",
],
'xyz': ['bar', 'baz', 'foo'],
'zzz': ['aaa'],
}
)
class TestHook:
def test_unknown(self, isolation):
metadata = ProjectMetadata(
str(isolation),
PluginManager(),
{'project': {'name': 'foo'}, 'tool': {'hatch': {'metadata': {'hooks': {'foo': {}}}}}},
)
with pytest.raises(ValueError, match='Unknown metadata hook: foo'):
_ = metadata.core
def test_custom(self, temp_dir, helpers):
classifiers = [
'Programming Language :: Python :: 3.11',
'Programming Language :: Python :: 3.11',
'Programming Language :: Python :: 3.9',
'Framework :: Foo',
'Development Status :: 4 - Beta',
'Private :: Do Not Upload',
]
metadata = ProjectMetadata(
str(temp_dir),
PluginManager(),
{
'project': {'name': 'foo', 'classifiers': classifiers, 'dynamic': ['version', 'description']},
'tool': {'hatch': {'version': {'path': 'a/b'}, 'metadata': {'hooks': {'custom': {}}}}},
},
)
file_path = temp_dir / 'a' / 'b'
file_path.ensure_parent_dir_exists()
file_path.write_text('__version__ = "0.0.1"')
file_path = temp_dir / DEFAULT_BUILD_SCRIPT
file_path.write_text(
helpers.dedent(
"""
from hatchling.metadata.plugin.interface import MetadataHookInterface
class CustomHook(MetadataHookInterface):
def update(self, metadata):
metadata['description'] = metadata['name'] + 'bar'
metadata['version'] = metadata['version'] + 'rc0'
def get_known_classifiers(self):
return ['Framework :: Foo']
"""
)
)
assert 'custom' in metadata.hatch.metadata.hooks
assert metadata.core.name == 'foo'
assert metadata.core.description == 'foobar'
assert metadata.core.version == '0.0.1rc0'
assert metadata.core.classifiers == [
'Private :: Do Not Upload',
'Development Status :: 4 - Beta',
'Framework :: Foo',
'Programming Language :: Python :: 3.9',
'Programming Language :: Python :: 3.11',
]
def test_custom_missing_dynamic(self, temp_dir, helpers):
metadata = ProjectMetadata(
str(temp_dir),
PluginManager(),
{
'project': {'name': 'foo', 'dynamic': ['version']},
'tool': {'hatch': {'version': {'path': 'a/b'}, 'metadata': {'hooks': {'custom': {}}}}},
},
)
file_path = temp_dir / 'a' / 'b'
file_path.ensure_parent_dir_exists()
file_path.write_text('__version__ = "0.0.1"')
file_path = temp_dir / DEFAULT_BUILD_SCRIPT
file_path.write_text(
helpers.dedent(
"""
from hatchling.metadata.plugin.interface import MetadataHookInterface
class CustomHook(MetadataHookInterface):
def update(self, metadata):
metadata['description'] = metadata['name'] + 'bar'
"""
)
)
with pytest.raises(
ValueError,
match='The field `description` was set dynamically and therefore must be listed in `project.dynamic`',
):
_ = metadata.core
class TestHatchPersonalProjectConfigFile:
def test_correct(self, temp_dir, helpers):
metadata = ProjectMetadata(
str(temp_dir),
PluginManager(),
{
'project': {'name': 'foo', 'dynamic': ['version']},
'tool': {'hatch': {'build': {'reproducible': False}}},
},
)
file_path = temp_dir / 'a' / 'b'
file_path.ensure_parent_dir_exists()
file_path.write_text('__version__ = "0.0.1"')
(temp_dir / 'pyproject.toml').touch()
file_path = temp_dir / 'hatch.toml'
file_path.write_text(
helpers.dedent(
"""
[version]
path = 'a/b'
"""
)
)
assert metadata.version == '0.0.1'
assert metadata.hatch.build_config['reproducible'] is False
def test_precedence(self, temp_dir, helpers):
metadata = ProjectMetadata(
str(temp_dir),
PluginManager(),
{
'project': {'name': 'foo', 'dynamic': ['version']},
'tool': {'hatch': {'version': {'path': 'a/b'}, 'build': {'reproducible': False}}},
},
)
file_path = temp_dir / 'a' / 'b'
file_path.ensure_parent_dir_exists()
file_path.write_text('__version__ = "0.0.1"')
file_path = temp_dir / 'c' / 'd'
file_path.ensure_parent_dir_exists()
file_path.write_text('__version__ = "0.0.2"')
(temp_dir / 'pyproject.toml').touch()
file_path = temp_dir / 'hatch.toml'
file_path.write_text(
helpers.dedent(
"""
[version]
path = 'c/d'
"""
)
)
assert metadata.version == '0.0.2'
assert metadata.hatch.build_config['reproducible'] is False
class TestMetadataConversion:
def test_required_only(self, isolation, latest_spec):
raw_metadata = {'name': 'My.App', 'version': '0.0.1'}
metadata = ProjectMetadata(str(isolation), None, {'project': raw_metadata})
core_metadata = latest_spec(metadata)
assert project_metadata_from_core_metadata(core_metadata) == raw_metadata
def test_dynamic(self, isolation, latest_spec):
raw_metadata = {'name': 'My.App', 'version': '0.0.1', 'dynamic': ['authors', 'classifiers']}
metadata = ProjectMetadata(str(isolation), None, {'project': raw_metadata})
core_metadata = latest_spec(metadata)
assert project_metadata_from_core_metadata(core_metadata) == raw_metadata
def test_description(self, isolation, latest_spec):
raw_metadata = {'name': 'My.App', 'version': '0.0.1', 'description': 'foo bar'}
metadata = ProjectMetadata(str(isolation), None, {'project': raw_metadata})
core_metadata = latest_spec(metadata)
assert project_metadata_from_core_metadata(core_metadata) == raw_metadata
def test_urls(self, isolation, latest_spec):
raw_metadata = {'name': 'My.App', 'version': '0.0.1', 'urls': {'foo': 'bar', 'bar': 'baz'}}
metadata = ProjectMetadata(str(isolation), None, {'project': raw_metadata})
core_metadata = latest_spec(metadata)
assert project_metadata_from_core_metadata(core_metadata) == raw_metadata
def test_authors(self, isolation, latest_spec):
raw_metadata = {
'name': 'My.App',
'version': '0.0.1',
'authors': [{'name': 'foobar'}, {'email': 'bar@domain', 'name': 'foo'}, {'email': 'baz@domain'}],
}
metadata = ProjectMetadata(str(isolation), None, {'project': raw_metadata})
core_metadata = latest_spec(metadata)
assert project_metadata_from_core_metadata(core_metadata) == raw_metadata
def test_maintainers(self, isolation, latest_spec):
raw_metadata = {
'name': 'My.App',
'version': '0.0.1',
'maintainers': [{'name': 'foobar'}, {'email': 'bar@domain', 'name': 'foo'}, {'email': 'baz@domain'}],
}
metadata = ProjectMetadata(str(isolation), None, {'project': raw_metadata})
core_metadata = latest_spec(metadata)
assert project_metadata_from_core_metadata(core_metadata) == raw_metadata
def test_keywords(self, isolation, latest_spec):
raw_metadata = {'name': 'My.App', 'version': '0.0.1', 'keywords': ['bar', 'foo']}
metadata = ProjectMetadata(str(isolation), None, {'project': raw_metadata})
core_metadata = latest_spec(metadata)
assert project_metadata_from_core_metadata(core_metadata) == raw_metadata
def test_classifiers(self, isolation, latest_spec):
raw_metadata = {
'name': 'My.App',
'version': '0.0.1',
'classifiers': ['Programming Language :: Python :: 3.9', 'Programming Language :: Python :: 3.11'],
}
metadata = ProjectMetadata(str(isolation), None, {'project': raw_metadata})
core_metadata = latest_spec(metadata)
assert project_metadata_from_core_metadata(core_metadata) == raw_metadata
def test_license_files(self, temp_dir, latest_spec):
raw_metadata = {
'name': 'My.App',
'version': '0.0.1',
'license-files': ['LICENSES/Apache-2.0.txt', 'LICENSES/MIT.txt'],
}
metadata = ProjectMetadata(str(temp_dir), None, {'project': raw_metadata})
licenses_path = temp_dir / 'LICENSES'
licenses_path.mkdir()
licenses_path.joinpath('Apache-2.0.txt').touch()
licenses_path.joinpath('MIT.txt').touch()
core_metadata = latest_spec(metadata)
assert project_metadata_from_core_metadata(core_metadata) == raw_metadata
def test_license_expression(self, isolation, latest_spec):
raw_metadata = {'name': 'My.App', 'version': '0.0.1', 'license': 'MIT'}
metadata = ProjectMetadata(str(isolation), None, {'project': raw_metadata})
core_metadata = latest_spec(metadata)
assert project_metadata_from_core_metadata(core_metadata) == raw_metadata
def test_license_legacy(self, isolation, latest_spec):
raw_metadata = {'name': 'My.App', 'version': '0.0.1', 'license': {'text': 'foo'}}
metadata = ProjectMetadata(str(isolation), None, {'project': raw_metadata})
core_metadata = latest_spec(metadata)
assert project_metadata_from_core_metadata(core_metadata) == raw_metadata
def test_readme(self, isolation, latest_spec):
raw_metadata = {
'name': 'My.App',
'version': '0.0.1',
'readme': {'content-type': 'text/markdown', 'text': 'test content\n'},
}
metadata = ProjectMetadata(str(isolation), None, {'project': raw_metadata})
core_metadata = latest_spec(metadata)
assert project_metadata_from_core_metadata(core_metadata) == raw_metadata
def test_requires_python(self, isolation, latest_spec):
raw_metadata = {'name': 'My.App', 'version': '0.0.1', 'requires-python': '<2,>=1'}
metadata = ProjectMetadata(str(isolation), None, {'project': raw_metadata})
core_metadata = latest_spec(metadata)
assert project_metadata_from_core_metadata(core_metadata) == raw_metadata
def test_dependencies(self, isolation, latest_spec):
raw_metadata = {
'name': 'My.App',
'version': '0.0.1',
'dependencies': ['bar==5', 'foo==1'],
'optional-dependencies': {
'feature1': ['bar==5; python_version < "3"', 'foo==1'],
'feature2': ['bar==5', 'foo==1; python_version < "3"'],
},
}
metadata = ProjectMetadata(str(isolation), None, {'project': raw_metadata})
core_metadata = latest_spec(metadata)
assert project_metadata_from_core_metadata(core_metadata) == raw_metadata
class TestSourceDistributionMetadata:
def test_basic_persistence(self, temp_dir, helpers):
metadata = ProjectMetadata(
str(temp_dir),
None,
{
'project': {
'name': 'My.App',
'dynamic': ['version', 'keywords'],
'dependencies': ['foo==1'],
'scripts': {'foo': 'bar'},
},
},
)
pkg_info = temp_dir / 'PKG-INFO'
pkg_info.write_text(
helpers.dedent(
f"""
Metadata-Version: {LATEST_METADATA_VERSION}
Name: My.App
Version: 0.0.1
Keywords: foo,bar
Requires-Dist: bar==5
"""
)
)
with temp_dir.as_cwd():
assert metadata.core.config == {
'name': 'My.App',
'version': '0.0.1',
'dependencies': ['foo==1'],
'keywords': ['foo', 'bar'],
'scripts': {'foo': 'bar'},
'dynamic': [],
}
def test_metadata_hooks(self, temp_dir, helpers):
metadata = ProjectMetadata(
str(temp_dir),
PluginManager(),
{
'project': {
'name': 'My.App',
'dynamic': ['version', 'keywords', 'description', 'scripts'],
'dependencies': ['foo==1'],
},
'tool': {'hatch': {'metadata': {'hooks': {'custom': {}}}}},
},
)
build_script = temp_dir / DEFAULT_BUILD_SCRIPT
build_script.write_text(
helpers.dedent(
"""
from hatchling.metadata.plugin.interface import MetadataHookInterface
class CustomHook(MetadataHookInterface):
def update(self, metadata):
metadata['description'] = metadata['name'] + ' bar'
metadata['scripts'] = {'foo': 'bar'}
"""
)
)
pkg_info = temp_dir / 'PKG-INFO'
pkg_info.write_text(
helpers.dedent(
f"""
Metadata-Version: {LATEST_METADATA_VERSION}
Name: My.App
Version: 0.0.1
Summary: My.App bar
Keywords: foo,bar
Requires-Dist: bar==5
"""
)
)
with temp_dir.as_cwd():
assert metadata.core.config == {
'name': 'My.App',
'version': '0.0.1',
'description': 'My.App bar',
'dependencies': ['foo==1'],
'keywords': ['foo', 'bar'],
'scripts': {'foo': 'bar'},
'dynamic': ['scripts'],
}