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 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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.