Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add shared-scripts option for the wheel target #1386

Merged
merged 1 commit into from
Apr 13, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions backend/src/hatchling/builders/wheel.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,10 @@ def write_metadata(self, relative_path: str, contents: str | bytes) -> tuple[str
relative_path = f'{self.metadata_directory}/{normalize_archive_path(relative_path)}'
return self.write_file(relative_path, contents)

def write_shared_script(self, relative_path: str, contents: str | bytes) -> tuple[str, str, str]:
relative_path = f'{self.shared_data_directory}/scripts/{normalize_archive_path(relative_path)}'
return self.write_file(relative_path, contents)

def add_shared_file(self, shared_file: IncludedFile) -> tuple[str, str, str]:
shared_file.distribution_path = f'{self.shared_data_directory}/data/{shared_file.distribution_path}'
return self.add_file(shared_file)
Expand Down Expand Up @@ -163,6 +167,7 @@ def __init__(self, *args: Any, **kwargs: Any) -> None:

self.__core_metadata_constructor: Callable[..., str] | None = None
self.__shared_data: dict[str, str] | None = None
self.__shared_scripts: dict[str, str] | None = None
self.__extra_metadata: dict[str, str] | None = None
self.__strict_naming: bool | None = None
self.__macos_max_compat: bool | None = None
Expand Down Expand Up @@ -289,6 +294,40 @@ def shared_data(self) -> dict[str, str]:

return self.__shared_data

@property
def shared_scripts(self) -> dict[str, str]:
if self.__shared_scripts is None:
shared_scripts = self.target_config.get('shared-scripts', {})
if not isinstance(shared_scripts, dict):
message = f'Field `tool.hatch.build.targets.{self.plugin_name}.shared-scripts` must be a mapping'
raise TypeError(message)

for i, (source, relative_path) in enumerate(shared_scripts.items(), 1):
if not source:
message = (
f'Source #{i} in field `tool.hatch.build.targets.{self.plugin_name}.shared-scripts` '
f'cannot be an empty string'
)
raise ValueError(message)

if not isinstance(relative_path, str):
message = (
f'Path for source `{source}` in field '
f'`tool.hatch.build.targets.{self.plugin_name}.shared-scripts` must be a string'
)
raise TypeError(message)

if not relative_path:
message = (
f'Path for source `{source}` in field '
f'`tool.hatch.build.targets.{self.plugin_name}.shared-scripts` cannot be an empty string'
)
raise ValueError(message)

self.__shared_scripts = normalize_inclusion_map(shared_scripts, self.root)

return self.__shared_scripts

@property
def extra_metadata(self) -> dict[str, str]:
if self.__extra_metadata is None:
Expand Down Expand Up @@ -547,6 +586,7 @@ def write_data(
self, archive: WheelArchive, records: RecordFile, build_data: dict[str, Any], extra_dependencies: Sequence[str]
) -> None:
self.add_shared_data(archive, records)
self.add_shared_scripts(archive, records)

# Ensure metadata is written last, see https://peps.python.org/pep-0427/#recommended-archiver-features
self.write_metadata(archive, records, build_data, extra_dependencies=extra_dependencies)
Expand All @@ -556,6 +596,35 @@ def add_shared_data(self, archive: WheelArchive, records: RecordFile) -> None:
record = archive.add_shared_file(shared_file)
records.write(record)

def add_shared_scripts(self, archive: WheelArchive, records: RecordFile) -> None:
import re
from io import BytesIO

# https://packaging.python.org/en/latest/specifications/binary-distribution-format/#recommended-installer-features
shebang = re.compile(rb'^#!.*(?:pythonw?|pypyw?)[0-9.]*(.*)', flags=re.DOTALL)

for shared_script in self.recurse_explicit_files(self.config.shared_scripts):
with open(shared_script.path, 'rb') as f:
content = BytesIO()
for line in f:
# Ignore leading blank lines
if not line.strip():
continue

match = shebang.match(line)
if match is None:
content.write(line)
else:
content.write(b'#!python')
if remaining := match.group(1):
content.write(remaining)

content.write(f.read())
break

record = archive.write_shared_script(shared_script.distribution_path, content.getvalue())
records.write(record)

def write_metadata(
self,
archive: WheelArchive,
Expand Down
4 changes: 4 additions & 0 deletions docs/history/hatchling.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),

## Unreleased

***Added:***

- Add `shared-scripts` option for the `wheel` target

***Fixed:***

- Set the `packaging` dependency version as `>=23.2` to avoid its URL validation which can conflict with context formatting
Expand Down
3 changes: 2 additions & 1 deletion docs/plugins/builder/wheel.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ The builder plugin name is `wheel`.
| Option | Default | Description |
| --- | --- | --- |
| `core-metadata-version` | `"2.3"` | The version of [core metadata](https://packaging.python.org/specifications/core-metadata/) to use |
| `shared-data` | | A mapping similar to the [forced inclusion](../../config/build.md#forced-inclusion) option corresponding to [data](https://peps.python.org/pep-0427/#the-data-directory) that will be installed globally in a given Python environment, usually under `#!python sys.prefix` |
| `shared-data` | | A mapping similar to the [forced inclusion](../../config/build.md#forced-inclusion) option corresponding to the `data` subdirectory within the standard [data directory](https://packaging.python.org/en/latest/specifications/binary-distribution-format/#the-data-directory) that will be installed globally in a given Python environment, usually under `#!python sys.prefix` |
| `shared-scripts` | | A mapping similar to the [forced inclusion](../../config/build.md#forced-inclusion) option corresponding to the `scripts` subdirectory within the standard [data directory](https://packaging.python.org/en/latest/specifications/binary-distribution-format/#the-data-directory) that will be installed in a given Python environment, usually under `Scripts` on Windows or `bin` otherwise, and would normally be available on PATH |
| `extra-metadata` | | A mapping similar to the [forced inclusion](../../config/build.md#forced-inclusion) option corresponding to extra [metadata](https://peps.python.org/pep-0427/#the-dist-info-directory) that will be shipped in a directory named `extra_metadata` |
| `strict-naming` | `true` | Whether or not file names should contain the normalized version of the project name |
| `macos-max-compat` | `true` | Whether or not on macOS, when build hooks have set the `infer_tag` [build data](#build-data), the wheel name should signal broad support rather than specific versions for newer SDK versions.<br><br>Note: The default will become `false`, and this option eventually removed, sometime after consumers like pip start supporting these newer SDK versions. |
Expand Down
194 changes: 194 additions & 0 deletions tests/backend/builders/test_wheel.py
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,95 @@ def test_order(self, isolation):
}


class TestSharedScripts:
def test_default(self, isolation):
builder = WheelBuilder(str(isolation))

assert builder.config.shared_scripts == builder.config.shared_scripts == {}

def test_invalid_type(self, isolation):
config = {'tool': {'hatch': {'build': {'targets': {'wheel': {'shared-scripts': 42}}}}}}
builder = WheelBuilder(str(isolation), config=config)

with pytest.raises(TypeError, match='Field `tool.hatch.build.targets.wheel.shared-scripts` must be a mapping'):
_ = builder.config.shared_scripts

def test_absolute(self, isolation):
config = {
'tool': {
'hatch': {'build': {'targets': {'wheel': {'shared-scripts': {str(isolation / 'source'): '/target/'}}}}}
}
}
builder = WheelBuilder(str(isolation), config=config)

assert builder.config.shared_scripts == {str(isolation / 'source'): 'target'}

def test_relative(self, isolation):
config = {'tool': {'hatch': {'build': {'targets': {'wheel': {'shared-scripts': {'../source': '/target/'}}}}}}}
builder = WheelBuilder(str(isolation / 'foo'), config=config)

assert builder.config.shared_scripts == {str(isolation / 'source'): 'target'}

def test_source_empty_string(self, isolation):
config = {'tool': {'hatch': {'build': {'targets': {'wheel': {'shared-scripts': {'': '/target/'}}}}}}}
builder = WheelBuilder(str(isolation), config=config)

with pytest.raises(
ValueError,
match='Source #1 in field `tool.hatch.build.targets.wheel.shared-scripts` cannot be an empty string',
):
_ = builder.config.shared_scripts

def test_relative_path_not_string(self, isolation):
config = {'tool': {'hatch': {'build': {'targets': {'wheel': {'shared-scripts': {'source': 0}}}}}}}
builder = WheelBuilder(str(isolation), config=config)

with pytest.raises(
TypeError,
match='Path for source `source` in field `tool.hatch.build.targets.wheel.shared-scripts` must be a string',
):
_ = builder.config.shared_scripts

def test_relative_path_empty_string(self, isolation):
config = {'tool': {'hatch': {'build': {'targets': {'wheel': {'shared-scripts': {'source': ''}}}}}}}
builder = WheelBuilder(str(isolation), config=config)

with pytest.raises(
ValueError,
match=(
'Path for source `source` in field `tool.hatch.build.targets.wheel.shared-scripts` '
'cannot be an empty string'
),
):
_ = builder.config.shared_scripts

def test_order(self, isolation):
config = {
'tool': {
'hatch': {
'build': {
'targets': {
'wheel': {
'shared-scripts': {
'../very-nested': 'target1/embedded',
'../source1': '/target2/',
'../source2': '/target1/',
}
}
}
}
}
}
}
builder = WheelBuilder(str(isolation / 'foo'), config=config)

assert builder.config.shared_scripts == {
str(isolation / 'source2'): 'target1',
str(isolation / 'very-nested'): f'target1{os.sep}embedded',
str(isolation / 'source1'): 'target2',
}


class TestExtraMetadata:
def test_default(self, isolation):
builder = WheelBuilder(str(isolation))
Expand Down Expand Up @@ -1832,6 +1921,111 @@ def test_default_shared_data(self, hatch, helpers, temp_dir, config_file):
)
helpers.assert_files(extraction_directory, expected_files)

def test_default_shared_scripts(self, hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins['default']['src-layout'] = False
config_file.save()

project_name = 'My.App'

with temp_dir.as_cwd():
result = hatch('new', project_name)

assert result.exit_code == 0, result.output

project_path = temp_dir / 'my-app'

shared_data_path = temp_dir / 'data'
shared_data_path.ensure_dir_exists()

binary_contents = os.urandom(1024)
(shared_data_path / 'binary').write_bytes(binary_contents)
(shared_data_path / 'other_script.sh').write_text(
helpers.dedent(
"""

#!/bin/sh arg1 arg2
echo "Hello, World!"
"""
)
)
(shared_data_path / 'python_script.sh').write_text(
helpers.dedent(
"""

#!/usr/bin/env python3.11 arg1 arg2
print("Hello, World!")
"""
)
)
(shared_data_path / 'pythonw_script.sh').write_text(
helpers.dedent(
"""

#!/usr/bin/pythonw3.11 arg1 arg2
print("Hello, World!")
"""
)
)
(shared_data_path / 'pypy_script.sh').write_text(
helpers.dedent(
"""

#!/usr/bin/env pypy
print("Hello, World!")
"""
)
)
(shared_data_path / 'pypyw_script.sh').write_text(
helpers.dedent(
"""

#!pypyw3.11 arg1 arg2
print("Hello, World!")
"""
)
)

config = {
'project': {'name': project_name, 'requires-python': '>3', 'dynamic': ['version']},
'tool': {
'hatch': {
'version': {'path': 'my_app/__about__.py'},
'build': {'targets': {'wheel': {'versions': ['standard'], 'shared-scripts': {'../data': '/'}}}},
},
},
}
builder = WheelBuilder(str(project_path), config=config)

build_path = project_path / 'dist'
build_path.mkdir()

with project_path.as_cwd():
artifacts = list(builder.build(directory=str(build_path)))

assert len(artifacts) == 1
expected_artifact = artifacts[0]

build_artifacts = list(build_path.iterdir())
assert len(build_artifacts) == 1
assert expected_artifact == str(build_artifacts[0])

extraction_directory = temp_dir / '_archive'
extraction_directory.mkdir()

with zipfile.ZipFile(str(expected_artifact), 'r') as zip_archive:
zip_archive.extractall(str(extraction_directory))

metadata_directory = f'{builder.project_id}.dist-info'
shared_data_directory = f'{builder.project_id}.data'
expected_files = helpers.get_template_files(
'wheel.standard_default_shared_scripts',
project_name,
metadata_directory=metadata_directory,
shared_data_directory=shared_data_directory,
binary_contents=binary_contents,
)
helpers.assert_files(extraction_directory, expected_files)

def test_default_extra_metadata(self, hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins['default']['src-layout'] = False
config_file.save()
Expand Down
10 changes: 8 additions & 2 deletions tests/helpers/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,14 @@ def assert_files(directory, expected_files, *, check_contents=True):
seen_relative_file_paths.add(relative_file_path)

if check_contents and relative_file_path in expected_relative_files:
with open(os.path.join(start, relative_file_path), encoding='utf-8') as f:
assert f.read() == expected_relative_files[relative_file_path], relative_file_path
file_path = os.path.join(start, relative_file_path)
expected_contents = expected_relative_files[relative_file_path]
try:
with open(file_path, encoding='utf-8') as f:
assert f.read() == expected_contents, relative_file_path
except UnicodeDecodeError:
with open(file_path, 'rb') as f:
assert f.read() == expected_contents, (relative_file_path, expected_contents)
else: # no cov
pass

Expand Down
Loading