symref 0.1.0__py3-none-any.whl
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.
- symref/__init__.py +14 -0
- symref/_ref.py +44 -0
- symref/_source.py +26 -0
- symref/_validate.py +75 -0
- symref/py.typed +0 -0
- symref-0.1.0.dist-info/METADATA +107 -0
- symref-0.1.0.dist-info/RECORD +9 -0
- symref-0.1.0.dist-info/WHEEL +4 -0
- symref-0.1.0.dist-info/licenses/LICENSE +21 -0
symref/__init__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from symref._ref import ref
|
|
2
|
+
from symref._validate import SymrefError, validate_refs
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def clear_refs() -> None:
|
|
6
|
+
"""
|
|
7
|
+
Clear the global ref registry.
|
|
8
|
+
|
|
9
|
+
Useful for test isolation when tests register their own refs.
|
|
10
|
+
"""
|
|
11
|
+
ref._registry.clear()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
__all__ = ["SymrefError", "clear_refs", "ref", "validate_refs"]
|
symref/_ref.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import ClassVar, Self
|
|
4
|
+
|
|
5
|
+
from symref._source import _capture_source
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ref(str): # noqa: N801
|
|
9
|
+
"""
|
|
10
|
+
A ``str`` subclass that acts as a forward reference to a dotted import path.
|
|
11
|
+
|
|
12
|
+
Every ``ref`` instance is registered in a global registry so that all
|
|
13
|
+
references can later be validated in one shot via
|
|
14
|
+
:func:`~symref.validate_refs`. Because ``ref`` inherits from ``str``,
|
|
15
|
+
frameworks that accept dotted-path strings (Celery, Django, etc.) can
|
|
16
|
+
consume it transparently.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
__slots__ = ("_kind", "_source")
|
|
20
|
+
|
|
21
|
+
_registry: ClassVar[list[ref]] = []
|
|
22
|
+
_kind: str | None
|
|
23
|
+
_source: tuple[str, int]
|
|
24
|
+
|
|
25
|
+
def __new__(cls, value: str, *, kind: str | None = None) -> Self:
|
|
26
|
+
"""
|
|
27
|
+
Create a new forward reference.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
value: Fully-qualified dotted import path (e.g.
|
|
31
|
+
``"myapp.tasks.send_email"``).
|
|
32
|
+
kind: Optional label used to filter validation (e.g.
|
|
33
|
+
``"celery_task"``).
|
|
34
|
+
|
|
35
|
+
"""
|
|
36
|
+
instance = super().__new__(cls, value)
|
|
37
|
+
instance._kind = kind
|
|
38
|
+
instance._source = _capture_source()
|
|
39
|
+
cls._registry.append(instance)
|
|
40
|
+
return instance
|
|
41
|
+
|
|
42
|
+
def __reduce__(self) -> tuple[type, tuple[str]]:
|
|
43
|
+
"""Pickle as a plain ``str`` to avoid re-registering on unpickle."""
|
|
44
|
+
return (str, (str(self),))
|
symref/_source.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from types import FrameType
|
|
9
|
+
|
|
10
|
+
_PACKAGE_DIR = Path(__file__).parent.resolve()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _capture_source() -> tuple[str, int]:
|
|
14
|
+
"""Walk the call stack to find the first frame outside the symref package."""
|
|
15
|
+
frame: FrameType | None = sys._getframe(1) # noqa: SLF001
|
|
16
|
+
while frame is not None:
|
|
17
|
+
filename = frame.f_code.co_filename
|
|
18
|
+
try:
|
|
19
|
+
frame_path = Path(filename).resolve()
|
|
20
|
+
except (OSError, ValueError):
|
|
21
|
+
frame = frame.f_back
|
|
22
|
+
continue
|
|
23
|
+
if not frame_path.is_relative_to(_PACKAGE_DIR):
|
|
24
|
+
return (filename, frame.f_lineno)
|
|
25
|
+
frame = frame.f_back
|
|
26
|
+
return ("<unknown>", 0)
|
symref/_validate.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import importlib
|
|
4
|
+
import importlib.util
|
|
5
|
+
|
|
6
|
+
from symref._ref import ref
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class SymrefError(Exception):
|
|
10
|
+
"""
|
|
11
|
+
Raised when one or more :class:`~symref.ref` paths cannot be resolved.
|
|
12
|
+
|
|
13
|
+
Attributes:
|
|
14
|
+
broken: List of :class:`~symref.ref` instances that failed to resolve.
|
|
15
|
+
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(self, broken: list[ref]) -> None:
|
|
19
|
+
self.broken = broken
|
|
20
|
+
lines = [
|
|
21
|
+
f"{len(broken)} broken reference(s):",
|
|
22
|
+
*[f' - "{r}" (defined in {r._source[0]}:{r._source[1]})' for r in broken],
|
|
23
|
+
]
|
|
24
|
+
super().__init__("\n".join(lines))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _resolve(path: str) -> bool:
|
|
28
|
+
if not path:
|
|
29
|
+
return False
|
|
30
|
+
|
|
31
|
+
# 1. Try as a full module path
|
|
32
|
+
try:
|
|
33
|
+
spec = importlib.util.find_spec(path)
|
|
34
|
+
except (ImportError, ValueError):
|
|
35
|
+
spec = None
|
|
36
|
+
|
|
37
|
+
if spec is not None:
|
|
38
|
+
return True
|
|
39
|
+
|
|
40
|
+
# 2. Split on last dot — try parent as module, last part as attribute
|
|
41
|
+
if "." not in path:
|
|
42
|
+
return False
|
|
43
|
+
|
|
44
|
+
parent, _, attr = path.rpartition(".")
|
|
45
|
+
try:
|
|
46
|
+
parent_spec = importlib.util.find_spec(parent)
|
|
47
|
+
except (ImportError, ValueError):
|
|
48
|
+
parent_spec = None
|
|
49
|
+
|
|
50
|
+
if parent_spec is None:
|
|
51
|
+
return False
|
|
52
|
+
|
|
53
|
+
module = importlib.import_module(parent)
|
|
54
|
+
return hasattr(module, attr)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def validate_refs(kind: str | None = None) -> None:
|
|
58
|
+
"""
|
|
59
|
+
Validate that every registered :class:`~symref.ref` resolves.
|
|
60
|
+
|
|
61
|
+
Iterates over all refs in the global registry (optionally filtered by
|
|
62
|
+
*kind*) and checks that each dotted path points to a real module or
|
|
63
|
+
attribute.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
kind: If given, only refs whose *kind* matches this value are checked.
|
|
67
|
+
|
|
68
|
+
Raises:
|
|
69
|
+
SymrefError: If any refs cannot be resolved.
|
|
70
|
+
|
|
71
|
+
"""
|
|
72
|
+
targets = [r for r in ref._registry if kind is None or r._kind == kind]
|
|
73
|
+
broken = [r for r in targets if not _resolve(r)]
|
|
74
|
+
if broken:
|
|
75
|
+
raise SymrefError(broken)
|
symref/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: symref
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Forward-referencing Python objects by dotted import path with refactor safety
|
|
5
|
+
Project-URL: Repository, https://github.com/jpuglielli/symref
|
|
6
|
+
Author: Josh Puglielli
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Keywords: celery,django,forward-reference,import-path,refactoring
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Typing :: Typed
|
|
17
|
+
Requires-Python: >=3.12
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# symref
|
|
21
|
+
|
|
22
|
+
Refactor-safe forward references for Python dotted import paths.
|
|
23
|
+
|
|
24
|
+
Many frameworks (Celery, Django, etc.) accept dotted string paths as configuration. These strings are invisible to refactoring tools and IDEs -- renaming a module silently breaks them. `symref` wraps these paths in a `str` subclass that registers them for later validation, catching broken references in tests instead of production.
|
|
25
|
+
|
|
26
|
+
## Installation
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pip install symref
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Requires Python 3.12+. No runtime dependencies.
|
|
33
|
+
|
|
34
|
+
## Usage
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from symref import ref
|
|
38
|
+
|
|
39
|
+
app.conf.task_routes = {
|
|
40
|
+
ref("myapp.tasks.send_email", kind="celery_task"): {"queue": "email"},
|
|
41
|
+
ref("myapp.tasks.process_order", kind="celery_task"): {"queue": "orders"},
|
|
42
|
+
}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
`ref()` returns a plain `str` -- frameworks see no difference. But each call is recorded in a global registry with its source location.
|
|
46
|
+
|
|
47
|
+
### Validate in tests
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from symref import validate_refs
|
|
51
|
+
|
|
52
|
+
def test_all_refs_resolve():
|
|
53
|
+
validate_refs()
|
|
54
|
+
|
|
55
|
+
def test_celery_task_refs():
|
|
56
|
+
validate_refs(kind="celery_task")
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
If any path can't be resolved, `validate_refs()` raises `SymrefError` with all broken references and their source locations:
|
|
60
|
+
|
|
61
|
+
```
|
|
62
|
+
symref.SymrefError: 2 broken reference(s):
|
|
63
|
+
- "myapp.tasks.send_email" (defined in config/celery.py:8)
|
|
64
|
+
- "myapp.tasks.process_order" (defined in config/celery.py:9)
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## API
|
|
68
|
+
|
|
69
|
+
### `ref(path, *, kind=None)`
|
|
70
|
+
|
|
71
|
+
Creates a forward reference. Returns a `str` subclass instance.
|
|
72
|
+
|
|
73
|
+
- **`path`** -- fully qualified dotted import path (module or attribute)
|
|
74
|
+
- **`kind`** *(optional)* -- arbitrary label for filtering validation (e.g. `"celery_task"`, `"django_app"`)
|
|
75
|
+
|
|
76
|
+
### `validate_refs(kind=None)`
|
|
77
|
+
|
|
78
|
+
Checks that every registered ref resolves to a real module or attribute. Raises `SymrefError` if any are broken. Pass `kind` to validate only refs with that label.
|
|
79
|
+
|
|
80
|
+
### `SymrefError`
|
|
81
|
+
|
|
82
|
+
Raised when one or more refs can't be resolved. The `.broken` attribute contains the list of broken `ref` instances.
|
|
83
|
+
|
|
84
|
+
## How it works
|
|
85
|
+
|
|
86
|
+
- `ref()` is a `str` subclass -- zero overhead after construction
|
|
87
|
+
- Each `ref()` call appends to `ref._registry` and captures the caller's file/line via `sys._getframe()`
|
|
88
|
+
- `validate_refs()` uses `importlib.util.find_spec()` to check modules. When verifying attributes, it imports the parent module via `importlib.import_module()` -- this may execute module-level code as a side effect
|
|
89
|
+
- No imports happen at `ref()` construction time -- validation is fully deferred
|
|
90
|
+
|
|
91
|
+
## Documentation
|
|
92
|
+
|
|
93
|
+
Build and preview the docs locally:
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
uv run mkdocs serve
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Then open <http://127.0.0.1:8000>.
|
|
100
|
+
|
|
101
|
+
## Contributing
|
|
102
|
+
|
|
103
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, testing, and PR guidelines.
|
|
104
|
+
|
|
105
|
+
## License
|
|
106
|
+
|
|
107
|
+
MIT
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
symref/__init__.py,sha256=hE2N7ZDkicd8zLy4vebUE5y_aQvW4VdsaGw9ZpBhcDA,322
|
|
2
|
+
symref/_ref.py,sha256=SJGPi5sqxE_bd0bgenTiNtvjkX4VDrF_hItPg29jHDI,1391
|
|
3
|
+
symref/_source.py,sha256=uOHIDfluaeC9eD9tA8uMdMe0iYHf02o0grTgOftJejg,780
|
|
4
|
+
symref/_validate.py,sha256=SNw0iAg1LK6eXVec56pNcA4FqFwzOpRaqHihNIcxvWs,1940
|
|
5
|
+
symref/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
symref-0.1.0.dist-info/METADATA,sha256=_Ng4-PHuJE9J0lcDy3fgNm65bDnsn9W1nlvVHbSSfh4,3437
|
|
7
|
+
symref-0.1.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
8
|
+
symref-0.1.0.dist-info/licenses/LICENSE,sha256=5ol71F6e9XFKST5WrGqmNSC2hQZRwDFQw77cWcrH4d4,1071
|
|
9
|
+
symref-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Josh Puglielli
|
|
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.
|