Skip to content

Commit a885803

Browse files
authored
Add sources to enable other types of local dependencies (#2313)
* Add sources to enable other types of local dependencies that do not impact artifact dependencies. * Fix formatting issues * Wire up sources to environment creation * Fix editable path source subdirectory handling and index-source lock resolution, and add per-environment source overrides, the HATCH_NO_SOURCES kill switch, and a hatch dep show sources command. * Fix type errors * Fix inheritance and address PR comments * address bad anchor
1 parent ab3e000 commit a885803

25 files changed

Lines changed: 1966 additions & 19 deletions

File tree

docs/config/dependency.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,3 +274,18 @@ pkg[feature1,feature2] @ <REFERENCE> ; python_version < "3.7"
274274
```
275275

276276
Note that the space before the semicolon is required.
277+
278+
279+
## Sources
280+
281+
Dependencies may be redirected to a local checkout, a Git branch, a URL, an alternate index, or a workspace member at install time, without changing the metadata your project publishes:
282+
283+
```toml config-example
284+
[project]
285+
dependencies = ["foo"]
286+
287+
[tool.hatch.sources]
288+
foo = "./packages/foo"
289+
```
290+
291+
Sources are environment configuration, so each environment can redirect a dependency differently. See [environment sources](environment/sources.md) for the full reference.

docs/config/environment/overview.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,9 @@ extra-dependencies = [
8686
!!! tip
8787
Hatch uses [pip](https://github.com/pypa/pip) to install dependencies so any [configuration](https://pip.pypa.io/en/stable/topics/configuration/) it supports Hatch does as well. For example, if you wanted to only use a private repository you could set the `PIP_INDEX_URL` [environment variable](#environment-variables).
8888

89+
!!! tip
90+
To redirect a dependency to a local path, Git repository, URL, alternate index, or workspace member without altering your published metadata, see [sources](sources.md). Each environment inherits the sources of its template and may override individual entries, and all sources can be disabled by setting the `HATCH_NO_SOURCES` environment variable.
91+
8992
## Installation
9093

9194
### Features (extras) ### {: #features }

docs/config/environment/sources.md

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
# Environment sources
2+
3+
-----
4+
5+
The `sources` table of an environment redirects dependencies to alternative origins at install time without changing your project's published metadata. This is useful during development when you want to consume a dependency from a local checkout, a Git branch, a private index, or another workspace member, while keeping the released wheel pointing at the version you ship.
6+
7+
A source is matched against your dependencies by name, after [PEP 503 normalization](https://peps.python.org/pep-0503/#normalized-names). Both [project dependencies](../metadata.md#dependencies) and [environment dependencies](overview.md#dependencies) are eligible for redirection.
8+
9+
```toml config-example
10+
[project]
11+
dependencies = ["foo"]
12+
13+
[tool.hatch.envs.default.sources]
14+
foo = "./packages/foo"
15+
```
16+
17+
The top-level `[tool.hatch.sources]` table is an alias for the `default` environment, so the following is equivalent:
18+
19+
```toml config-example
20+
[tool.hatch.sources]
21+
foo = "./packages/foo"
22+
```
23+
24+
!!! note
25+
Sources only affect installs performed by Hatch when it manages an environment. They do not influence the metadata of wheels you build with `hatch build`.
26+
27+
## Inheritance
28+
29+
Sources follow the usual [inheritance](overview.md#inheritance) rules, merging entry by entry so that an environment can redirect a single dependency differently while every other entry it inherits stays in place:
30+
31+
```toml config-example
32+
[tool.hatch.sources]
33+
foo = "./packages/foo"
34+
bar = "./packages/bar"
35+
36+
[tool.hatch.envs.test-upstream.sources]
37+
foo = { git = "https://github.com/example/foo", branch = "main" }
38+
```
39+
40+
Here every environment installs both `foo` and `bar` from the local checkouts, except `test-upstream`, which keeps the local `bar` but tracks the upstream development branch of `foo`.
41+
42+
An environment that does not inherit from `default`, such as a [detached](overview.md#detached-environments) one, receives no sources.
43+
44+
## Path
45+
46+
Use a relative or absolute path to a wheel, source distribution, or project directory:
47+
48+
```toml config-example
49+
[tool.hatch.sources]
50+
foo = "./packages/foo"
51+
```
52+
53+
The shorthand above is equivalent to the full form:
54+
55+
```toml config-example
56+
[tool.hatch.sources]
57+
foo = { path = "./packages/foo", editable = true }
58+
```
59+
60+
`editable` defaults to `true` to match Hatch's existing handling of local installs. Set `editable = false` for a non-editable install. If the package lives below the path root, set `subdirectory`:
61+
62+
```toml config-example
63+
[tool.hatch.sources]
64+
foo = { path = "./monorepo", subdirectory = "packages/foo" }
65+
```
66+
67+
For editable installs the subdirectory is resolved into the path itself (equivalent to `path = "./monorepo/packages/foo"`), since installers expect a bare project directory. For non-editable installs the subdirectory is passed as a URL fragment, which also supports archives.
68+
69+
## Git
70+
71+
Pull from a Git repository:
72+
73+
```toml config-example
74+
[tool.hatch.sources]
75+
foo = { git = "https://github.com/example/foo" }
76+
```
77+
78+
Pin to a specific revision with one of `rev`, `tag`, or `branch` (mutually exclusive):
79+
80+
```toml config-example
81+
[tool.hatch.sources]
82+
foo = { git = "https://github.com/example/foo", rev = "abc1234" }
83+
bar = { git = "https://github.com/example/bar", tag = "v1.0" }
84+
baz = { git = "https://github.com/example/baz", branch = "main" }
85+
```
86+
87+
Use `subdirectory` when the Python package is not at the repository root.
88+
89+
## URL
90+
91+
Install a wheel or source archive from a URL:
92+
93+
```toml config-example
94+
[tool.hatch.sources]
95+
foo = { url = "https://files.example.com/foo-1.0.tar.gz" }
96+
```
97+
98+
`subdirectory` is also supported for archives where the package is not at the root.
99+
100+
## Index
101+
102+
Resolve the dependency from a specific package index:
103+
104+
```toml config-example
105+
[tool.hatch.sources]
106+
foo = { index = "https://pypi.example.com/simple" }
107+
```
108+
109+
The index URL is passed to the installer as `--extra-index-url`, so the default index (PyPI) remains the primary source. Multiple index sources are deduplicated and order-preserving.
110+
111+
## Workspace
112+
113+
Resolve the dependency from a [workspace](../../how-to/environment/workspace.md) member:
114+
115+
```toml config-example
116+
[tool.hatch.sources]
117+
my-pkg = { workspace = true }
118+
```
119+
120+
The actual install path is determined by the matching member in `tool.hatch.envs.<ENV_NAME>.workspace.members`. This lets you declare workspace membership in one place and reference it from many environments.
121+
122+
## Precedence
123+
124+
A dependency that already uses a [PEP 508 direct reference](../dependency.md#direct-references) is left untouched — the explicit URL on the dependency wins over a configured source.
125+
126+
## Disabling sources
127+
128+
Setting the `HATCH_NO_SOURCES` environment variable to any non-empty value disables all sources. This is useful in CI to verify that your published metadata resolves on its own, without local redirections:
129+
130+
```
131+
HATCH_NO_SOURCES=1 hatch env create
132+
```
133+
134+
## Inspecting sources
135+
136+
The [`dep show sources`](../../cli/reference.md#hatch-dep-show) command displays each configured source, its target, and the dependencies of the active environment that it redirects. Sources that match no dependencies are reported with a warning, which helps catch typos in source names since unmatched sources are otherwise silently ignored.
137+
138+
## Installer translation
139+
140+
Sources produce installer-agnostic instructions that Hatch renders into the right flags for the configured installer:
141+
142+
| Source | Per-dependency form | Global flags |
143+
| --- | --- | --- |
144+
| `path` (editable) | `--editable <resolved>` | none |
145+
| `path` (non-editable) | `name @ file://<resolved>` | none |
146+
| `git` | `name @ git+<url>[@<ref>]` | none |
147+
| `url` | `name @ <url>` | none |
148+
| `index` | unchanged | `--extra-index-url <url>` |
149+
| `workspace` | resolved through `workspace.members` | none |
150+
151+
Both [`installer = "pip"`](overview.md#dependencies) and `installer = "uv"` accept the same flag forms, so the same source configuration works for either. When an environment is [`locked`](overview.md#locked), sources also apply during lock resolution: rewritten requirements flow into the lock inputs and index sources are passed to the resolver as `--extra-index-url`.
152+
153+
!!! note
154+
Sources do not apply to [build requirements](../build.md#build-system) (`build-system.requires` or build target dependencies). Build environments always resolve from declared metadata so that builds remain reproducible.

docs/history/hatch.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
1010

1111
***Added:***
1212

13+
- Add the `sources` environment option, which redirects dependencies to a local path, Git repository, URL, alternate index, or workspace member at install time without altering published metadata. The top-level `[tool.hatch.sources]` table is an alias for the `default` environment, environments inherit sources entry by entry, the `HATCH_NO_SOURCES` environment variable disables them, and `hatch dep show sources` reports what each source redirects
14+
1315
- Add the `--all`/`-a` flag to the `build` command to build the workspace root and every workspace member defined by the selected environment, consolidating artifacts in the workspace root's `dist` directory by default. A root that does not define a `project` table is skipped so that a top-level `pyproject.toml` may only contain workspace configuration
1416

1517
***Fixed:***

docs/plugins/environment/reference.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,8 @@ All environment types should [offer support](#hatch.env.plugin.interface.Environ
7474
- platform
7575
- environment_dependencies
7676
- dependencies
77+
- sources
78+
- get_source_install_args
7779
- env_vars
7880
- env_include
7981
- env_exclude

docs/plugins/utilities.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,3 +69,27 @@
6969
- local_path
7070
- env_path
7171
- join
72+
73+
::: hatch.project.sources.Source
74+
options:
75+
show_source: false
76+
77+
::: hatch.project.sources.PathSource
78+
options:
79+
show_source: false
80+
81+
::: hatch.project.sources.GitSource
82+
options:
83+
show_source: false
84+
85+
::: hatch.project.sources.UrlSource
86+
options:
87+
show_source: false
88+
89+
::: hatch.project.sources.IndexSource
90+
options:
91+
show_source: false
92+
93+
::: hatch.project.sources.WorkspaceSource
94+
options:
95+
show_source: false

mkdocs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ nav:
7373
- Build: config/build.md
7474
- Environments:
7575
- Overview: config/environment/overview.md
76+
- Sources: config/environment/sources.md
7677
- Advanced: config/environment/advanced.md
7778
- Internal:
7879
- Testing: config/internal/testing.md

src/hatch/cli/dep/__init__.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,52 @@ def table(app, project_only, env_only, show_lines, force_ascii):
161161
)
162162

163163

164+
@show.command("sources", short_help="Show dependency source redirections for the active environment")
165+
@click.option("--lines", "-l", "show_lines", is_flag=True, help="Whether or not to show lines between table rows")
166+
@click.option("--ascii", "force_ascii", is_flag=True, help="Whether or not to only use ASCII characters")
167+
@click.pass_obj
168+
def show_sources(app, show_lines, force_ascii):
169+
"""
170+
Show each configured dependency source, what it points at, and which
171+
dependencies of the active environment it redirects.
172+
"""
173+
app.ensure_environment_plugin_dependencies()
174+
175+
from hatch.project.sources import describe_source, source_applied
176+
from hatch.utils.metadata import normalize_project_name
177+
178+
environment = app.project.get_environment()
179+
configured = environment.sources
180+
if not configured:
181+
app.display(f"No sources defined for environment `{app.env}`")
182+
return
183+
184+
root = str(environment.root)
185+
workspace_members = environment.source_workspace_members
186+
matched = {name: [] for name in configured}
187+
for dependency in environment.dependencies_complex:
188+
normalized = normalize_project_name(dependency.name)
189+
if normalized in matched and source_applied(dependency, configured[normalized], root, workspace_members):
190+
matched[normalized].append(str(dependency))
191+
192+
columns = {"Source": {}, "Type": {}, "Target": {}, "Dependencies": {}}
193+
for i, (name, source) in enumerate(sorted(configured.items())):
194+
source_type, target = describe_source(source)
195+
columns["Source"][i] = name
196+
columns["Type"][i] = source_type
197+
columns["Target"][i] = target
198+
columns["Dependencies"][i] = "\n".join(matched[name]) if matched[name] else "none"
199+
200+
column_options = {"Source": {"no_wrap": True}, "Type": {"no_wrap": True}}
201+
app.display_table(
202+
f"Sources: {app.env}", columns, show_lines=show_lines, column_options=column_options, force_ascii=force_ascii
203+
)
204+
205+
unused = sorted(name for name, deps in matched.items() if not deps)
206+
if unused:
207+
app.display_warning(f"Sources matched no dependencies of environment `{app.env}`: {', '.join(unused)}")
208+
209+
164210
@show.command(short_help="Enumerate dependencies as a list of requirements")
165211
@click.option("--project-only", "-p", is_flag=True, help="Whether or not to exclude environment dependencies")
166212
@click.option("--env-only", "-e", is_flag=True, help="Whether or not to exclude project dependencies")

src/hatch/config/constants.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ class AppEnvVars:
1010
NO_COLOR = "NO_COLOR"
1111
FORCE_COLOR = "FORCE_COLOR"
1212
KEEP_ENV = "HATCH_KEEP_ENV"
13+
NO_SOURCES = "HATCH_NO_SOURCES"
1314

1415

1516
class ConfigEnvVars:

src/hatch/dep/core.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,5 +35,23 @@ def path(self) -> Path | None:
3535
uri = hyperlink.parse(self.url)
3636
if uri.scheme != "file":
3737
return None
38-
decoded_url = unquote(self.url)
38+
# The fragment (e.g. `#subdirectory=...`) is not part of the filesystem path
39+
decoded_url = unquote(self.url.split("#", 1)[0])
3940
return Path.from_uri(decoded_url)
41+
42+
@cached_property
43+
def subdirectory(self) -> str | None:
44+
"""
45+
The `subdirectory` component of the URL fragment, if any.
46+
"""
47+
from urllib.parse import parse_qs, urlsplit
48+
49+
if self.url is None:
50+
return None
51+
52+
fragment = urlsplit(self.url).fragment
53+
if not fragment:
54+
return None
55+
56+
subdirectories = parse_qs(fragment).get("subdirectory")
57+
return subdirectories[0] if subdirectories else None

0 commit comments

Comments
 (0)