Skip to content

Commit eddb974

Browse files
authored
Add custom environment collector (#622)
1 parent 4ae2a78 commit eddb974

File tree

11 files changed

+227
-24
lines changed

11 files changed

+227
-24
lines changed

docs/history/hatch.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
1515

1616
***Added:***
1717

18+
- Add `custom` environment collector
1819
- Increase the timeout for and add retries to the `index` publisher
1920
- Expand home and environment variables in configured cache and data directories
20-
- Retroactively support `License-Expression` core metadata starting at version 2.1
2121
- Update project templates
2222

2323
***Fixed:***

docs/history/hatchling.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
1111
***Added:***
1212

1313
- Add `extra_metadata` build data to the `wheel` target
14+
- Retroactively support `License-Expression` core metadata starting at version 2.1
1415
- Add more type hints
1516
- Store Hatchling's metadata in `pyproject.toml`
1617

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# Custom environment collector
2+
3+
-----
4+
5+
This is a custom class in a given Python file that inherits from the [EnvironmentCollectorInterface](reference.md#hatch.env.collectors.plugin.interface.EnvironmentCollectorInterface).
6+
7+
## Configuration
8+
9+
The environment collector plugin name is `custom`.
10+
11+
=== ":octicons-file-code-16: pyproject.toml"
12+
13+
```toml
14+
[tool.hatch.env.collectors.custom]
15+
```
16+
17+
=== ":octicons-file-code-16: hatch.toml"
18+
19+
```toml
20+
[env.collectors.custom]
21+
```
22+
23+
## Options
24+
25+
| Option | Default | Description |
26+
| --- | --- | --- |
27+
| `path` | `hatch_plugins.py` | The path of the Python file |
28+
29+
## Example
30+
31+
=== ":octicons-file-code-16: hatch_plugins.py"
32+
33+
```python
34+
from hatch.env.collectors.plugin.interface import EnvironmentCollectorInterface
35+
36+
37+
class CustomEnvironmentCollector(EnvironmentCollectorInterface):
38+
...
39+
```
40+
41+
If multiple subclasses are found, you must define a function named `get_environment_collector` that returns the desired environment collector.
42+
43+
!!! note
44+
Any defined [PLUGIN_NAME](reference.md#hatch.env.collectors.plugin.interface.EnvironmentCollectorInterface.PLUGIN_NAME) is ignored and will always be `custom`.

docs/plugins/environment-collector/default.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ This adds the `default` environment with [type](../../config/environment/overvie
66

77
## Configuration
88

9-
The environment plugin name is `default`.
9+
The environment collector plugin name is `default`.
1010

1111
=== ":octicons-file-code-16: pyproject.toml"
1212

mkdocs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ nav:
9090
- Virtual: plugins/environment/virtual.md
9191
- Environment collector:
9292
- Reference: plugins/environment-collector/reference.md
93+
- Custom: plugins/environment-collector/custom.md
9394
- Default: plugins/environment-collector/default.md
9495
- Publisher:
9596
- Reference: plugins/publisher/reference.md

src/hatch/env/collectors/custom.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
from __future__ import annotations
2+
3+
import os
4+
from typing import Any
5+
6+
from hatch.env.collectors.plugin.interface import EnvironmentCollectorInterface
7+
from hatch.plugin.constants import DEFAULT_CUSTOM_SCRIPT
8+
from hatchling.plugin.utils import load_plugin_from_script
9+
10+
11+
class CustomEnvironmentCollector(EnvironmentCollectorInterface):
12+
PLUGIN_NAME = 'custom'
13+
14+
def __new__(cls, root: str, config: dict[str, Any], *args: Any, **kwargs: Any) -> EnvironmentCollectorInterface:
15+
custom_script = config.get('path', DEFAULT_CUSTOM_SCRIPT)
16+
if not isinstance(custom_script, str):
17+
raise TypeError(f'Option `path` for environment collector `{cls.PLUGIN_NAME}` must be a string')
18+
elif not custom_script:
19+
raise ValueError(
20+
f'Option `path` for environment collector `{cls.PLUGIN_NAME}` must not be empty if defined'
21+
)
22+
23+
path = os.path.normpath(os.path.join(root, custom_script))
24+
if not os.path.isfile(path):
25+
raise OSError(f'Plugin script does not exist: {custom_script}')
26+
27+
hook_class = load_plugin_from_script(
28+
path, custom_script, EnvironmentCollectorInterface, 'environment_collector'
29+
)
30+
hook = hook_class(root, config, *args, **kwargs)
31+
32+
# Always keep the name to avoid confusion
33+
hook.PLUGIN_NAME = cls.PLUGIN_NAME
34+
35+
return hook
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1+
from hatch.env.collectors.custom import CustomEnvironmentCollector
12
from hatch.env.collectors.default import DefaultEnvironmentCollector
23
from hatchling.plugin import hookimpl
34

45

56
@hookimpl
67
def hatch_register_environment_collector():
7-
return DefaultEnvironmentCollector
8+
return [CustomEnvironmentCollector, DefaultEnvironmentCollector]

src/hatch/plugin/constants.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
DEFAULT_CUSTOM_SCRIPT = 'hatch_plugins.py'

tests/env/collectors/__init__.py

Whitespace-only changes.

tests/env/collectors/test_custom.py

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import re
2+
3+
import pytest
4+
5+
from hatch.env.collectors.custom import CustomEnvironmentCollector
6+
from hatch.plugin.constants import DEFAULT_CUSTOM_SCRIPT
7+
8+
9+
def test_no_path(isolation):
10+
config = {'path': ''}
11+
12+
with pytest.raises(
13+
ValueError, match='Option `path` for environment collector `custom` must not be empty if defined'
14+
):
15+
CustomEnvironmentCollector(str(isolation), config)
16+
17+
18+
def test_path_not_string(isolation):
19+
config = {'path': 3}
20+
21+
with pytest.raises(TypeError, match='Option `path` for environment collector `custom` must be a string'):
22+
CustomEnvironmentCollector(str(isolation), config)
23+
24+
25+
def test_nonexistent(isolation):
26+
config = {'path': 'test.py'}
27+
28+
with pytest.raises(OSError, match='Plugin script does not exist: test.py'):
29+
CustomEnvironmentCollector(str(isolation), config)
30+
31+
32+
def test_default(temp_dir, helpers):
33+
config = {}
34+
35+
file_path = temp_dir / DEFAULT_CUSTOM_SCRIPT
36+
file_path.write_text(
37+
helpers.dedent(
38+
"""
39+
from hatch.env.collectors.plugin.interface import EnvironmentCollectorInterface
40+
41+
class CustomHook(EnvironmentCollectorInterface):
42+
def foo(self):
43+
return self.PLUGIN_NAME, self.root
44+
"""
45+
)
46+
)
47+
48+
with temp_dir.as_cwd():
49+
hook = CustomEnvironmentCollector(str(temp_dir), config)
50+
51+
assert hook.foo() == ('custom', str(temp_dir))
52+
53+
54+
def test_explicit_path(temp_dir, helpers):
55+
config = {'path': f'foo/{DEFAULT_CUSTOM_SCRIPT}'}
56+
57+
file_path = temp_dir / 'foo' / DEFAULT_CUSTOM_SCRIPT
58+
file_path.ensure_parent_dir_exists()
59+
file_path.write_text(
60+
helpers.dedent(
61+
"""
62+
from hatch.env.collectors.plugin.interface import EnvironmentCollectorInterface
63+
64+
class CustomHook(EnvironmentCollectorInterface):
65+
def foo(self):
66+
return self.PLUGIN_NAME, self.root
67+
"""
68+
)
69+
)
70+
71+
with temp_dir.as_cwd():
72+
hook = CustomEnvironmentCollector(str(temp_dir), config)
73+
74+
assert hook.foo() == ('custom', str(temp_dir))
75+
76+
77+
def test_no_subclass(temp_dir, helpers):
78+
config = {'path': f'foo/{DEFAULT_CUSTOM_SCRIPT}'}
79+
80+
file_path = temp_dir / 'foo' / DEFAULT_CUSTOM_SCRIPT
81+
file_path.ensure_parent_dir_exists()
82+
file_path.write_text(
83+
helpers.dedent(
84+
"""
85+
from hatch.env.collectors.plugin.interface import EnvironmentCollectorInterface
86+
87+
foo = None
88+
bar = 'baz'
89+
90+
class CustomHook:
91+
pass
92+
"""
93+
)
94+
)
95+
96+
with pytest.raises(
97+
ValueError,
98+
match=re.escape(
99+
f'Unable to find a subclass of `EnvironmentCollectorInterface` in `foo/{DEFAULT_CUSTOM_SCRIPT}`: {temp_dir}'
100+
),
101+
):
102+
with temp_dir.as_cwd():
103+
CustomEnvironmentCollector(str(temp_dir), config)

0 commit comments

Comments
 (0)