aec-agent-core 1.0.0.dev0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024-2026 Sean O'Connell
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,75 @@
1
+ Metadata-Version: 2.4
2
+ Name: aec-agent-core
3
+ Version: 1.0.0.dev0
4
+ Summary: The shared core of LLM agents that review and produce architecture, engineering and construction documents: model connections, vision calls, guards and records, Word and Excel, and a figure helper. No user interface of its own.
5
+ Author-email: Sean O'Connell <soconnell345@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/soconnell345-geotech/GeotechStaffEngineer
8
+ Project-URL: Repository, https://github.com/soconnell345-geotech/GeotechStaffEngineer
9
+ Classifier: Development Status :: 2 - Pre-Alpha
10
+ Classifier: Intended Audience :: Science/Research
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Programming Language :: Python :: 3.14
17
+ Classifier: Topic :: Scientific/Engineering
18
+ Requires-Python: >=3.10
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Requires-Dist: numpy>=1.26
22
+ Requires-Dist: scipy>=1.13
23
+ Requires-Dist: deepagents<0.7.25,>=0.6.8
24
+ Requires-Dist: langchain<1.4.6,>=1.3
25
+ Requires-Dist: langgraph<1.3,>=1.2
26
+ Requires-Dist: typing_extensions>=4.13
27
+ Requires-Dist: openai<3.29.1
28
+ Requires-Dist: langchain-openai>=1.3
29
+ Requires-Dist: matplotlib>=3.8
30
+ Requires-Dist: plotly>=5
31
+ Requires-Dist: python-docx>=1.1
32
+ Requires-Dist: markdown-it-py>=3
33
+ Requires-Dist: openpyxl>=3.1
34
+ Requires-Dist: planlens>=0.14
35
+ Provides-Extra: anthropic
36
+ Requires-Dist: langchain-anthropic>=1.4; extra == "anthropic"
37
+ Dynamic: license-file
38
+
39
+ # aec-agent-core
40
+
41
+ The shared core of LLM agents that review and produce architecture,
42
+ engineering and construction (AEC) documents. It holds what an agent runs
43
+ below its chat page: the model connections, the vision calls and the file
44
+ tools beside them, the figure helper with its plotting box, the agent's
45
+ guards, limits and kept work, Word and Excel in and out, the run record and
46
+ its checks, and the switches. It has no user interface of its own.
47
+
48
+ Two applications are built on it:
49
+
50
+ - **GeotechStaffEngineer** (`pip install geotech-staff-engineer`), the
51
+ geotechnical engineering agent, which pins this package exactly;
52
+ - a document review application.
53
+
54
+ ## Status
55
+
56
+ **1.0.0.dev0: an empty package.** It exists so that its build, its tests and
57
+ the applications' pins on it can be checked before any code moves in. The
58
+ only thing in it today is `aec_agent_core.install_aliases`, which lets a
59
+ module keep answering to its old import path after it moves here, as the
60
+ same module object (so an import or a test patch by either name reaches the
61
+ one copy of the code).
62
+
63
+ It is not yet published; GeotechStaffEngineer installs it from its own
64
+ repository (`pip install -e packages/aec-agent-core`) until it is.
65
+
66
+ ## Install
67
+
68
+ ```
69
+ pip install aec-agent-core # once published
70
+ pip install "aec-agent-core[anthropic]" # with Claude through LangChain
71
+ ```
72
+
73
+ ## Licence
74
+
75
+ MIT, as GeotechStaffEngineer. See `LICENSE`.
@@ -0,0 +1,37 @@
1
+ # aec-agent-core
2
+
3
+ The shared core of LLM agents that review and produce architecture,
4
+ engineering and construction (AEC) documents. It holds what an agent runs
5
+ below its chat page: the model connections, the vision calls and the file
6
+ tools beside them, the figure helper with its plotting box, the agent's
7
+ guards, limits and kept work, Word and Excel in and out, the run record and
8
+ its checks, and the switches. It has no user interface of its own.
9
+
10
+ Two applications are built on it:
11
+
12
+ - **GeotechStaffEngineer** (`pip install geotech-staff-engineer`), the
13
+ geotechnical engineering agent, which pins this package exactly;
14
+ - a document review application.
15
+
16
+ ## Status
17
+
18
+ **1.0.0.dev0: an empty package.** It exists so that its build, its tests and
19
+ the applications' pins on it can be checked before any code moves in. The
20
+ only thing in it today is `aec_agent_core.install_aliases`, which lets a
21
+ module keep answering to its old import path after it moves here, as the
22
+ same module object (so an import or a test patch by either name reaches the
23
+ one copy of the code).
24
+
25
+ It is not yet published; GeotechStaffEngineer installs it from its own
26
+ repository (`pip install -e packages/aec-agent-core`) until it is.
27
+
28
+ ## Install
29
+
30
+ ```
31
+ pip install aec-agent-core # once published
32
+ pip install "aec-agent-core[anthropic]" # with Claude through LangChain
33
+ ```
34
+
35
+ ## Licence
36
+
37
+ MIT, as GeotechStaffEngineer. See `LICENSE`.
@@ -0,0 +1,166 @@
1
+ """aec-agent-core: what document agents run below their chat pages.
2
+
3
+ 1.0.0.dev0 is the empty package of the app split's step 10 (BACKLOG INF-32;
4
+ ``module_work/APP_SPLIT_PLAN.md``). It holds one thing, the alias helper
5
+ step 11 uses when it moves a module in here, so the module's old import
6
+ path keeps working until step 16 takes the aliases out:
7
+
8
+ # in the old package's __init__.py, e.g. funhouse_agent/__init__.py
9
+ from aec_agent_core import install_aliases
10
+ install_aliases({"funhouse_agent.vision_tools": "aec_agent_core.vision_tools"})
11
+
12
+ From then on ``import funhouse_agent.vision_tools`` (or ``from
13
+ funhouse_agent import vision_tools``) gives the very module object that
14
+ ``aec_agent_core.vision_tools`` is, with no stub file left at the old path.
15
+ So git follows the move as a rename, and a test that patches by the old
16
+ name (``monkeypatch.setattr("funhouse_agent.vision_tools.x", ...)``)
17
+ patches the real module, which a re-exporting stub would silently not do.
18
+
19
+ An alias also covers what lies under the name: with the table above,
20
+ ``funhouse_agent.vision_tools.sub`` is ``aec_agent_core.vision_tools.sub``.
21
+
22
+ The alias is LAZY: nothing is imported until the old name is looked up (an
23
+ import, or ``importlib.util.find_spec``, which imports the target). The module
24
+ keeps its own ``__name__``, ``__spec__`` and ``__file__`` (the new ones).
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import importlib
30
+ import importlib.abc
31
+ import importlib.machinery
32
+ import sys
33
+ import threading
34
+ from typing import Mapping
35
+
36
+ __version__ = "1.0.0.dev0"
37
+
38
+ __all__ = ["install_aliases", "remove_aliases", "aliases", "resolve_alias",
39
+ "__version__"]
40
+
41
+ _LOCK = threading.Lock()
42
+ #: old dotted name -> new dotted name, for every installed alias.
43
+ _TABLE: dict[str, str] = {}
44
+
45
+
46
+ def _valid_name(name: object) -> bool:
47
+ return (isinstance(name, str) and bool(name)
48
+ and all(part.isidentifier() for part in name.split(".")))
49
+
50
+
51
+ def resolve_alias(fullname: str) -> str | None:
52
+ """The new name an old dotted name stands for, or None if none does.
53
+
54
+ The longest matching alias wins, so ``a.b`` -> ``x`` and ``a.b.c`` ->
55
+ ``y`` send ``a.b.c.d`` to ``y.d``.
56
+ """
57
+ with _LOCK:
58
+ table = dict(_TABLE)
59
+ name = fullname
60
+ rest: list[str] = []
61
+ while name:
62
+ new = table.get(name)
63
+ if new is not None:
64
+ return ".".join([new, *reversed(rest)])
65
+ name, _, last = name.rpartition(".")
66
+ rest.append(last)
67
+ return None
68
+
69
+
70
+ class _AliasLoader(importlib.abc.Loader):
71
+ """Hands the import system the already-imported real module."""
72
+
73
+ def __init__(self, target: str):
74
+ self._target = target
75
+
76
+ def create_module(self, spec):
77
+ return importlib.import_module(self._target)
78
+
79
+ def exec_module(self, module):
80
+ # The import system has just set module.__spec__ to the ALIAS spec
81
+ # (it always does, even on an existing module); put the module's own
82
+ # back, so reload, pickle by reference and find_spec see the real
83
+ # module. Nothing else on the module was overwritten: __name__,
84
+ # __loader__, __package__ and __file__ were already set.
85
+ spec = _REAL_SPECS.get(self._target)
86
+ if spec is not None and module is sys.modules.get(self._target):
87
+ module.__spec__ = spec
88
+
89
+
90
+ #: The real spec of every module an alias handed out, kept before the import
91
+ #: system overwrote it (see _AliasLoader.exec_module).
92
+ _REAL_SPECS: dict[str, object] = {}
93
+
94
+
95
+ class _AliasFinder(importlib.abc.MetaPathFinder):
96
+ """Finds an old (aliased) name and answers with the new module."""
97
+
98
+ def find_spec(self, fullname, path=None, target=None):
99
+ new = resolve_alias(fullname)
100
+ if new is None or new == fullname:
101
+ return None
102
+ module = importlib.import_module(new)
103
+ _REAL_SPECS.setdefault(new, module.__spec__)
104
+ is_pkg = hasattr(module, "__path__")
105
+ return importlib.machinery.ModuleSpec(
106
+ fullname, _AliasLoader(new), is_package=is_pkg)
107
+
108
+
109
+ _FINDER = _AliasFinder()
110
+
111
+
112
+ def _ensure_finder() -> None:
113
+ # First in line, so a stale file left at an old path cannot shadow the
114
+ # moved module.
115
+ if _FINDER not in sys.meta_path:
116
+ sys.meta_path.insert(0, _FINDER)
117
+
118
+
119
+ def install_aliases(table: Mapping[str, str]) -> None:
120
+ """Make each old dotted name import as the module of its new name.
121
+
122
+ ``table`` maps old name -> new name, both full dotted module names.
123
+ Installing the same pair twice is harmless; pointing an old name at a
124
+ different new name, aliasing a name to itself, or aliasing a name that
125
+ is already imported as a DIFFERENT module raises ``ValueError``, because
126
+ each would leave two copies of the code or an alias that never applies.
127
+ """
128
+ pairs = dict(table)
129
+ for old, new in pairs.items():
130
+ if not (_valid_name(old) and _valid_name(new)):
131
+ raise ValueError(f"not a dotted module name: {old!r} -> {new!r}")
132
+ if old == new or new.startswith(old + ".") or old.startswith(new + "."):
133
+ raise ValueError(f"an alias cannot point into itself: {old} -> {new}")
134
+ with _LOCK:
135
+ for old, new in pairs.items():
136
+ have = _TABLE.get(old)
137
+ if have is not None and have != new:
138
+ raise ValueError(
139
+ f"{old} is already an alias of {have}, not {new}")
140
+ loaded = sys.modules.get(old)
141
+ if loaded is not None and loaded is not sys.modules.get(new):
142
+ raise ValueError(
143
+ f"{old} is already imported as its own module; an alias "
144
+ f"to {new} would leave two copies")
145
+ _TABLE.update(pairs)
146
+ _ensure_finder()
147
+
148
+
149
+ def remove_aliases(*olds: str) -> None:
150
+ """Take aliases out (all of them when no name is given).
151
+
152
+ A module already imported by an old name stays in ``sys.modules`` under
153
+ it; this only stops new imports from resolving. For tests, and for
154
+ step 16.
155
+ """
156
+ with _LOCK:
157
+ for old in (olds or tuple(_TABLE)):
158
+ _TABLE.pop(old, None)
159
+ if not _TABLE and _FINDER in sys.meta_path:
160
+ sys.meta_path.remove(_FINDER)
161
+
162
+
163
+ def aliases() -> dict[str, str]:
164
+ """A copy of the installed aliases, old name -> new name."""
165
+ with _LOCK:
166
+ return dict(_TABLE)
@@ -0,0 +1,75 @@
1
+ Metadata-Version: 2.4
2
+ Name: aec-agent-core
3
+ Version: 1.0.0.dev0
4
+ Summary: The shared core of LLM agents that review and produce architecture, engineering and construction documents: model connections, vision calls, guards and records, Word and Excel, and a figure helper. No user interface of its own.
5
+ Author-email: Sean O'Connell <soconnell345@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/soconnell345-geotech/GeotechStaffEngineer
8
+ Project-URL: Repository, https://github.com/soconnell345-geotech/GeotechStaffEngineer
9
+ Classifier: Development Status :: 2 - Pre-Alpha
10
+ Classifier: Intended Audience :: Science/Research
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Programming Language :: Python :: 3.14
17
+ Classifier: Topic :: Scientific/Engineering
18
+ Requires-Python: >=3.10
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Requires-Dist: numpy>=1.26
22
+ Requires-Dist: scipy>=1.13
23
+ Requires-Dist: deepagents<0.7.25,>=0.6.8
24
+ Requires-Dist: langchain<1.4.6,>=1.3
25
+ Requires-Dist: langgraph<1.3,>=1.2
26
+ Requires-Dist: typing_extensions>=4.13
27
+ Requires-Dist: openai<3.29.1
28
+ Requires-Dist: langchain-openai>=1.3
29
+ Requires-Dist: matplotlib>=3.8
30
+ Requires-Dist: plotly>=5
31
+ Requires-Dist: python-docx>=1.1
32
+ Requires-Dist: markdown-it-py>=3
33
+ Requires-Dist: openpyxl>=3.1
34
+ Requires-Dist: planlens>=0.14
35
+ Provides-Extra: anthropic
36
+ Requires-Dist: langchain-anthropic>=1.4; extra == "anthropic"
37
+ Dynamic: license-file
38
+
39
+ # aec-agent-core
40
+
41
+ The shared core of LLM agents that review and produce architecture,
42
+ engineering and construction (AEC) documents. It holds what an agent runs
43
+ below its chat page: the model connections, the vision calls and the file
44
+ tools beside them, the figure helper with its plotting box, the agent's
45
+ guards, limits and kept work, Word and Excel in and out, the run record and
46
+ its checks, and the switches. It has no user interface of its own.
47
+
48
+ Two applications are built on it:
49
+
50
+ - **GeotechStaffEngineer** (`pip install geotech-staff-engineer`), the
51
+ geotechnical engineering agent, which pins this package exactly;
52
+ - a document review application.
53
+
54
+ ## Status
55
+
56
+ **1.0.0.dev0: an empty package.** It exists so that its build, its tests and
57
+ the applications' pins on it can be checked before any code moves in. The
58
+ only thing in it today is `aec_agent_core.install_aliases`, which lets a
59
+ module keep answering to its old import path after it moves here, as the
60
+ same module object (so an import or a test patch by either name reaches the
61
+ one copy of the code).
62
+
63
+ It is not yet published; GeotechStaffEngineer installs it from its own
64
+ repository (`pip install -e packages/aec-agent-core`) until it is.
65
+
66
+ ## Install
67
+
68
+ ```
69
+ pip install aec-agent-core # once published
70
+ pip install "aec-agent-core[anthropic]" # with Claude through LangChain
71
+ ```
72
+
73
+ ## Licence
74
+
75
+ MIT, as GeotechStaffEngineer. See `LICENSE`.
@@ -0,0 +1,10 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ aec_agent_core/__init__.py
5
+ aec_agent_core.egg-info/PKG-INFO
6
+ aec_agent_core.egg-info/SOURCES.txt
7
+ aec_agent_core.egg-info/dependency_links.txt
8
+ aec_agent_core.egg-info/requires.txt
9
+ aec_agent_core.egg-info/top_level.txt
10
+ tests/test_core_aliases.py
@@ -0,0 +1,17 @@
1
+ numpy>=1.26
2
+ scipy>=1.13
3
+ deepagents<0.7.25,>=0.6.8
4
+ langchain<1.4.6,>=1.3
5
+ langgraph<1.3,>=1.2
6
+ typing_extensions>=4.13
7
+ openai<3.29.1
8
+ langchain-openai>=1.3
9
+ matplotlib>=3.8
10
+ plotly>=5
11
+ python-docx>=1.1
12
+ markdown-it-py>=3
13
+ openpyxl>=3.1
14
+ planlens>=0.14
15
+
16
+ [anthropic]
17
+ langchain-anthropic>=1.4
@@ -0,0 +1 @@
1
+ aec_agent_core
@@ -0,0 +1,77 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ # The core both agents run below their chat pages (the app split, BACKLOG
6
+ # INF-26 / INF-32; plan module_work/APP_SPLIT_PLAN.md sections 1.1 and 2.3).
7
+ # Step 10 (this file): the package exists, EMPTY apart from the alias helper,
8
+ # with its build, CI and a wheel test. Step 11 moves the code in. Step 12
9
+ # publishes it, on the owner's word; until then nothing publishes it (the
10
+ # publish workflow builds the repository root only).
11
+ [project]
12
+ name = "aec-agent-core"
13
+ # Kept equal to geotech-staff-engineer's exact pin on it (its pyproject) and
14
+ # to aec_agent_core.__version__; webapp/tests/test_wheels.py checks all three.
15
+ version = "1.0.0.dev0"
16
+ description = "The shared core of LLM agents that review and produce architecture, engineering and construction documents: model connections, vision calls, guards and records, Word and Excel, and a figure helper. No user interface of its own."
17
+ readme = "README.md"
18
+ license = "MIT"
19
+ license-files = ["LICENSE"]
20
+ requires-python = ">=3.10"
21
+ authors = [
22
+ {name = "Sean O'Connell", email = "soconnell345@gmail.com"},
23
+ ]
24
+ classifiers = [
25
+ "Development Status :: 2 - Pre-Alpha",
26
+ "Intended Audience :: Science/Research",
27
+ "Programming Language :: Python :: 3",
28
+ "Programming Language :: Python :: 3.10",
29
+ "Programming Language :: Python :: 3.11",
30
+ "Programming Language :: Python :: 3.12",
31
+ "Programming Language :: Python :: 3.13",
32
+ "Programming Language :: Python :: 3.14",
33
+ "Topic :: Scientific/Engineering",
34
+ ]
35
+ # The list of plan section 1.1, at the caps and floors geotech-staff-engineer
36
+ # has today (its pyproject carries the reasons for each; keep the two in
37
+ # step: webapp/tests/test_version_guard.py reads the agent-stack caps in both
38
+ # files). Step 10 installs nothing new: every line here is already a core
39
+ # requirement of geotech-staff-engineer.
40
+ dependencies = [
41
+ # numpy 1.26, not 2.0 (INF-53): Foundry's transforms repos hold 1.26.4.
42
+ "numpy>=1.26",
43
+ # The plotting box lets the model's code import scipy.
44
+ "scipy>=1.13",
45
+ # The agent stack, capped at the versions the offline gate exercised
46
+ # (INF-03, 2026-10-27).
47
+ "deepagents>=0.6.8,<0.7.25",
48
+ "langchain>=1.3,<1.4.6",
49
+ "langgraph>=1.2,<1.3",
50
+ # langchain-protocol needs typing_extensions.TypedDict(extra_items=...)
51
+ # (PEP 728), 4.13 or later; some hosts ship an older one.
52
+ "typing_extensions>=4.13",
53
+ # openai 3.x builds its own client on httpx2; 3.29.0 is the highest tested.
54
+ "openai<3.29.1",
55
+ "langchain-openai>=1.3",
56
+ # The figure helper, the plotting box and the chart stack.
57
+ "matplotlib>=3.8",
58
+ "plotly>=5",
59
+ # Word out (and in), Excel out and in.
60
+ "python-docx>=1.1",
61
+ "markdown-it-py>=3",
62
+ "openpyxl>=3.1",
63
+ # The document layer; the core binds its review tools.
64
+ "planlens>=0.14",
65
+ ]
66
+
67
+ [project.optional-dependencies]
68
+ # Claude through LangChain. GSE asks for it; a GPT-only host need not.
69
+ anthropic = ["langchain-anthropic>=1.4"]
70
+
71
+ [project.urls]
72
+ Homepage = "https://github.com/soconnell345-geotech/GeotechStaffEngineer"
73
+ Repository = "https://github.com/soconnell345-geotech/GeotechStaffEngineer"
74
+
75
+ [tool.setuptools.packages.find]
76
+ include = ["aec_agent_core*"]
77
+ exclude = ["*.tests", "*.tests.*", "tests*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,212 @@
1
+ """The alias helper step 11 uses to keep old import paths working
2
+ (``aec_agent_core.install_aliases``; plan ``module_work/APP_SPLIT_PLAN.md``,
3
+ section 4, "Moves that git can follow").
4
+
5
+ Each test builds a throwaway pair of packages on disk: ``old_<n>`` (the
6
+ package a module moved out of) and ``new_<n>`` (the one it moved into).
7
+
8
+ The alias table is process-wide, and from step 11 the apps install their
9
+ own tables before these tests run. So a test removes only the aliases it
10
+ added and checks only its own entries, never that the table is exactly its
11
+ own.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import importlib
17
+ import importlib.util
18
+ import itertools
19
+ import pickle
20
+ import sys
21
+ import textwrap
22
+
23
+ import pytest
24
+
25
+ import aec_agent_core as core
26
+
27
+ _COUNTER = itertools.count()
28
+
29
+
30
+ @pytest.fixture
31
+ def pair(tmp_path, monkeypatch):
32
+ """(old, new) package names on sys.path; cleaned up afterwards."""
33
+ n = next(_COUNTER)
34
+ old, new = f"old_pkg_{n}", f"new_pkg_{n}"
35
+
36
+ def write(rel, text=""):
37
+ path = tmp_path / rel
38
+ path.parent.mkdir(parents=True, exist_ok=True)
39
+ path.write_text(textwrap.dedent(text), encoding="utf-8")
40
+
41
+ write(f"{old}/__init__.py")
42
+ write(f"{new}/__init__.py")
43
+ write(f"{new}/tool.py", """
44
+ VALUE = 1
45
+
46
+ def answer():
47
+ return VALUE
48
+
49
+ def made_here():
50
+ return 42
51
+ """)
52
+ write(f"{new}/box/__init__.py", "NAME = 'box'\n")
53
+ write(f"{new}/box/child.py", "WHO = 'child'\n")
54
+ monkeypatch.syspath_prepend(str(tmp_path))
55
+ importlib.invalidate_caches()
56
+ before = core.aliases()
57
+ yield old, new, write
58
+ added = [name for name in core.aliases() if name not in before]
59
+ if added: # remove_aliases() with no name removes ALL
60
+ core.remove_aliases(*added)
61
+ for name in list(sys.modules):
62
+ if name.split(".")[0] in (old, new):
63
+ del sys.modules[name]
64
+
65
+
66
+ def test_the_old_name_is_the_same_module_object(pair):
67
+ old, new, _ = pair
68
+ core.install_aliases({f"{old}.tool": f"{new}.tool"})
69
+ moved = importlib.import_module(f"{old}.tool")
70
+ real = importlib.import_module(f"{new}.tool")
71
+ assert moved is real
72
+ assert sys.modules[f"{old}.tool"] is real
73
+
74
+
75
+ def test_from_import_and_the_parent_attribute(pair):
76
+ old, new, _ = pair
77
+ core.install_aliases({f"{old}.tool": f"{new}.tool"})
78
+ exec(f"from {old} import tool as t1\nimport {old}.tool as t2\n"
79
+ f"assert t1 is t2", {})
80
+ parent = sys.modules[old]
81
+ assert parent.tool is sys.modules[f"{new}.tool"]
82
+
83
+
84
+ def test_the_module_keeps_its_own_name_spec_and_file(pair):
85
+ old, new, _ = pair
86
+ core.install_aliases({f"{old}.tool": f"{new}.tool"})
87
+ mod = importlib.import_module(f"{old}.tool")
88
+ assert mod.__name__ == f"{new}.tool"
89
+ assert mod.__spec__.name == f"{new}.tool"
90
+ assert mod.__file__.endswith("tool.py") and new in mod.__file__
91
+ # so reload still works on the real module
92
+ assert importlib.reload(mod) is mod
93
+
94
+
95
+ def test_a_patch_by_the_old_name_reaches_the_real_module(pair, monkeypatch):
96
+ old, new, _ = pair
97
+ core.install_aliases({f"{old}.tool": f"{new}.tool"})
98
+ monkeypatch.setattr(f"{old}.tool.VALUE", 7)
99
+ real = importlib.import_module(f"{new}.tool")
100
+ assert real.answer() == 7
101
+
102
+
103
+ def test_install_is_lazy(pair):
104
+ old, new, _ = pair
105
+ core.install_aliases({f"{old}.tool": f"{new}.tool"})
106
+ assert f"{new}.tool" not in sys.modules
107
+ assert f"{old}.tool" not in sys.modules
108
+
109
+
110
+ def test_a_package_alias_covers_what_lies_under_it(pair):
111
+ old, new, _ = pair
112
+ core.install_aliases({f"{old}.box": f"{new}.box"})
113
+ child = importlib.import_module(f"{old}.box.child")
114
+ assert child is importlib.import_module(f"{new}.box.child")
115
+ assert importlib.import_module(f"{old}.box").NAME == "box"
116
+
117
+
118
+ def test_the_longest_alias_wins(pair):
119
+ old, new, _ = pair
120
+ core.install_aliases({f"{old}.box": f"{new}.box",
121
+ f"{old}.box.other": f"{new}.tool"})
122
+ assert core.resolve_alias(f"{old}.box.other") == f"{new}.tool"
123
+ assert core.resolve_alias(f"{old}.box.child") == f"{new}.box.child"
124
+ assert core.resolve_alias(f"{old}.nothing") is None
125
+
126
+
127
+ def test_find_spec_by_the_old_name(pair):
128
+ old, new, _ = pair
129
+ core.install_aliases({f"{old}.tool": f"{new}.tool"})
130
+ spec = importlib.util.find_spec(f"{old}.tool")
131
+ assert spec is not None and spec.name == f"{old}.tool"
132
+
133
+
134
+ def test_a_stale_file_at_the_old_path_does_not_shadow_the_move(pair):
135
+ old, new, write = pair
136
+ write(f"{old}/tool.py", "STALE = True\n")
137
+ importlib.invalidate_caches()
138
+ core.install_aliases({f"{old}.tool": f"{new}.tool"})
139
+ mod = importlib.import_module(f"{old}.tool")
140
+ assert not hasattr(mod, "STALE")
141
+ assert mod is importlib.import_module(f"{new}.tool")
142
+
143
+
144
+ def test_pickle_by_reference_uses_the_real_name(pair):
145
+ old, new, _ = pair
146
+ core.install_aliases({f"{old}.tool": f"{new}.tool"})
147
+ fn = importlib.import_module(f"{old}.tool").made_here
148
+ assert pickle.loads(pickle.dumps(fn)) is fn
149
+ assert new.encode() in pickle.dumps(fn)
150
+
151
+
152
+ def test_installing_the_same_pair_twice_is_harmless(pair):
153
+ old, new, _ = pair
154
+ core.install_aliases({f"{old}.tool": f"{new}.tool"})
155
+ core.install_aliases({f"{old}.tool": f"{new}.tool"})
156
+ assert core.aliases()[f"{old}.tool"] == f"{new}.tool"
157
+ assert sys.meta_path.count(core._FINDER) == 1
158
+
159
+
160
+ @pytest.mark.parametrize("table", [
161
+ {"a.b": "a.b"},
162
+ {"a.b": "a.b.c"},
163
+ {"a.b.c": "a.b"},
164
+ {"a..b": "x"},
165
+ {"a.b": ""},
166
+ {"a-b.c": "x.y"},
167
+ ])
168
+ def test_aliases_that_could_never_apply_are_refused(table):
169
+ before = core.aliases()
170
+ with pytest.raises(ValueError):
171
+ core.install_aliases(table)
172
+ after = core.aliases()
173
+ assert all(after.get(old) == before.get(old) for old in table)
174
+
175
+
176
+ def test_repointing_an_alias_is_refused(pair):
177
+ old, new, _ = pair
178
+ core.install_aliases({f"{old}.tool": f"{new}.tool"})
179
+ with pytest.raises(ValueError, match="already an alias"):
180
+ core.install_aliases({f"{old}.tool": f"{new}.box"})
181
+
182
+
183
+ def test_an_old_name_already_imported_as_its_own_module_is_refused(pair):
184
+ old, new, write = pair
185
+ write(f"{old}/tool.py", "OWN = True\n")
186
+ importlib.invalidate_caches()
187
+ importlib.import_module(f"{old}.tool")
188
+ with pytest.raises(ValueError, match="two copies"):
189
+ core.install_aliases({f"{old}.tool": f"{new}.tool"})
190
+
191
+
192
+ def test_remove_aliases_stops_new_imports(pair):
193
+ old, new, _ = pair
194
+ core.install_aliases({f"{old}.tool": f"{new}.tool"})
195
+ core.remove_aliases(f"{old}.tool")
196
+ assert f"{old}.tool" not in core.aliases()
197
+ # The finder leaves sys.meta_path with the last alias, and stays while
198
+ # any other alias (an app's table) is still installed.
199
+ if core.aliases():
200
+ assert core._FINDER in sys.meta_path
201
+ else:
202
+ assert core._FINDER not in sys.meta_path
203
+ with pytest.raises(ModuleNotFoundError):
204
+ importlib.import_module(f"{old}.tool")
205
+
206
+
207
+ def test_a_missing_target_is_an_import_error(pair):
208
+ old, new, _ = pair
209
+ core.install_aliases({f"{old}.gone": f"{new}.gone"})
210
+ with pytest.raises(ModuleNotFoundError):
211
+ importlib.import_module(f"{old}.gone")
212
+