Skip to content

Commit a6dba03

Browse files
committed
Add python paths option in config
1 parent ad0b367 commit a6dba03

5 files changed

Lines changed: 114 additions & 2 deletions

File tree

mr/data_types.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,13 @@ class ArtifactsConfig(BaseModel):
7575
class RepoConfig(BaseModel):
7676
"""Repo-level config loaded from .makerrepo/config.yaml (or REPO_CONFIG_PATH)."""
7777

78+
pythonpaths: list[str] = Field(
79+
default_factory=list,
80+
description=(
81+
"A list of paths to prepend to sys.path before importing user code. "
82+
"Useful for src/ layouts (e.g. add 'src')."
83+
),
84+
)
7885
artifacts: ArtifactsConfig | None = Field(
7986
default=None, description="Artifacts section"
8087
)

mr/utils.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1+
import contextlib
12
import dataclasses
23
import importlib
34
import os
45
import pathlib
6+
import sys
57
from importlib.machinery import SourceFileLoader
68
from types import ModuleType
79

@@ -13,6 +15,46 @@
1315
from .data_types import RepoConfig
1416

1517

18+
@contextlib.contextmanager
19+
def apply_pythonpaths(
20+
config: RepoConfig, repo_root: str | pathlib.Path | None = None
21+
) -> list[str]:
22+
"""
23+
Temporarily prepend configured python paths to sys.path.
24+
25+
Paths are interpreted relative to ``repo_root`` when provided; otherwise relative
26+
to the current working directory.
27+
"""
28+
if not config.pythonpaths:
29+
yield []
30+
return
31+
32+
root = pathlib.Path(repo_root) if repo_root is not None else pathlib.Path.cwd()
33+
added: list[str] = []
34+
35+
# Insert in reverse so the first entry ends up first in sys.path.
36+
for raw in reversed(config.pythonpaths):
37+
if not raw or not raw.strip():
38+
continue
39+
p = pathlib.Path(raw)
40+
if not p.is_absolute():
41+
p = root / p
42+
value = str(p.resolve())
43+
if value in sys.path:
44+
continue
45+
sys.path.insert(0, value)
46+
added.append(value)
47+
48+
added.reverse()
49+
try:
50+
yield added
51+
finally:
52+
# Remove only what we added (one occurrence each).
53+
for value in added:
54+
if value in sys.path:
55+
sys.path.remove(value)
56+
57+
1658
def load_module(module_spec: str) -> ModuleType:
1759
if module_spec.lower().endswith(".py") and os.path.exists(module_spec):
1860
module_path = pathlib.Path(module_spec)

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "makerrepo"
3-
version = "0.5.5"
3+
version = "0.5.6"
44
description = "Open source library that brings Manufacturing As Code concept into build123d ecosystem"
55
authors = [{ name = "Fang-Pen Lin", email = "fangpen@launchplatform.com" }]
66
readme = "README.md"

tests/test_utils.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from mr.data_types import ArtifactsConfig
88
from mr.data_types import DefaultArtifactConfig
99
from mr.data_types import RepoConfig
10+
from mr.utils import apply_pythonpaths
1011
from mr.utils import apply_repo_config
1112
from mr.utils import find_python_modules
1213
from mr.utils import find_python_packages
@@ -34,13 +35,16 @@ def test_load_repo_config_custom_path_valid_yaml(tmp_path: pathlib.Path):
3435
config_file = tmp_path / "config.yaml"
3536
config_file.write_text(
3637
"""
38+
pythonpaths:
39+
- src
3740
artifacts:
3841
default_config:
3942
export_step: false
4043
export_3mf: true
4144
"""
4245
)
4346
config = load_repo_config(config_file)
47+
assert config.pythonpaths == ["src"]
4448
assert config.artifacts is not None
4549
assert config.artifacts.default_config.export_step is False
4650
assert config.artifacts.default_config.export_3mf is True
@@ -51,9 +55,68 @@ def test_load_repo_config_empty_yaml_returns_default(tmp_path: pathlib.Path):
5155
config_file = tmp_path / "empty.yaml"
5256
config_file.write_text("")
5357
config = load_repo_config(config_file)
58+
assert config.pythonpaths == []
5459
assert config.artifacts is None
5560

5661

62+
def test_apply_pythonpaths_prepends_to_sys_path(
63+
tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
64+
):
65+
(tmp_path / "src").mkdir()
66+
config = RepoConfig(pythonpaths=["src"])
67+
monkeypatch.chdir(tmp_path)
68+
before = list(__import__("sys").path)
69+
with apply_pythonpaths(config) as added:
70+
assert added and added[0].endswith("/src")
71+
assert __import__("sys").path[0] == added[0]
72+
# Ensure we didn't blow away sys.path
73+
assert len(__import__("sys").path) >= len(before)
74+
assert __import__("sys").path == before
75+
76+
77+
def test_apply_pythonpaths_resolves_relative_to_repo_root(
78+
tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
79+
):
80+
repo_root = tmp_path / "repo"
81+
(repo_root / "src").mkdir(parents=True)
82+
config = RepoConfig(pythonpaths=["src"])
83+
monkeypatch.chdir(tmp_path) # different cwd on purpose
84+
before = list(__import__("sys").path)
85+
with apply_pythonpaths(config, repo_root=repo_root) as added:
86+
assert added == [str((repo_root / "src").resolve())]
87+
assert __import__("sys").path[0] == added[0]
88+
assert __import__("sys").path == before
89+
90+
91+
def test_apply_pythonpaths_does_not_remove_external_duplicate(
92+
tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
93+
):
94+
"""
95+
If user code adds the same path again during the context, the context manager
96+
should only remove its own inserted occurrence and leave the external one.
97+
"""
98+
sys = __import__("sys")
99+
(tmp_path / "src").mkdir()
100+
config = RepoConfig(pythonpaths=["src"])
101+
monkeypatch.chdir(tmp_path)
102+
103+
before = list(sys.path)
104+
try:
105+
with apply_pythonpaths(config) as added:
106+
assert len(added) == 1
107+
value = added[0]
108+
assert sys.path[0] == value
109+
# Simulate other code adding the same path during the context.
110+
sys.path.append(value)
111+
assert sys.path.count(value) == 2
112+
# Our inserted occurrence is removed, but the "external" one remains.
113+
assert value in sys.path
114+
assert sys.path.count(value) == 1
115+
finally:
116+
# Restore sys.path so this test doesn't affect others.
117+
sys.path[:] = before
118+
119+
57120
def test_apply_repo_config_fills_none_from_config():
58121
"""apply_repo_config fills export_step/export_3mf when artifact has None."""
59122

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)