symref 0.1.0__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,16 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(uv run ruff check:*)",
5
+ "Bash(uv run mypy:*)",
6
+ "Bash(uv run pytest:*)",
7
+ "Bash(uv run:*)",
8
+ "WebSearch",
9
+ "WebFetch(domain:mkdocstrings.github.io)",
10
+ "WebFetch(domain:squidfund.github.io)",
11
+ "WebFetch(domain:squidfunk.github.io)",
12
+ "WebFetch(domain:raw.githubusercontent.com)",
13
+ "Bash(uv sync:*)"
14
+ ]
15
+ }
16
+ }
@@ -0,0 +1,23 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+
9
+ jobs:
10
+ check:
11
+ runs-on: ubuntu-latest
12
+ strategy:
13
+ matrix:
14
+ python-version: ["3.12", "3.13"]
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+ - uses: astral-sh/setup-uv@v5
18
+ - run: uv python install ${{ matrix.python-version }}
19
+ - run: uv sync --group dev
20
+ - run: uv run ruff check symref/ tests/
21
+ - run: uv run ruff format --check symref/ tests/
22
+ - run: uv run mypy symref/
23
+ - run: uv run pytest tests/ -v
@@ -0,0 +1,207 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[codz]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ parts/
20
+ sdist/
21
+ var/
22
+ wheels/
23
+ share/python-wheels/
24
+ *.egg-info/
25
+ .installed.cfg
26
+ *.egg
27
+ MANIFEST
28
+
29
+ # PyInstaller
30
+ # Usually these files are written by a python script from a template
31
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
32
+ *.manifest
33
+ *.spec
34
+
35
+ # Installer logs
36
+ pip-log.txt
37
+ pip-delete-this-directory.txt
38
+
39
+ # Unit test / coverage reports
40
+ htmlcov/
41
+ .tox/
42
+ .nox/
43
+ .coverage
44
+ .coverage.*
45
+ .cache
46
+ nosetests.xml
47
+ coverage.xml
48
+ *.cover
49
+ *.py.cover
50
+ .hypothesis/
51
+ .pytest_cache/
52
+ cover/
53
+
54
+ # Translations
55
+ *.mo
56
+ *.pot
57
+
58
+ # Django stuff:
59
+ *.log
60
+ local_settings.py
61
+ db.sqlite3
62
+ db.sqlite3-journal
63
+
64
+ # Flask stuff:
65
+ instance/
66
+ .webassets-cache
67
+
68
+ # Scrapy stuff:
69
+ .scrapy
70
+
71
+ # Sphinx documentation
72
+ docs/_build/
73
+
74
+ # PyBuilder
75
+ .pybuilder/
76
+ target/
77
+
78
+ # Jupyter Notebook
79
+ .ipynb_checkpoints
80
+
81
+ # IPython
82
+ profile_default/
83
+ ipython_config.py
84
+
85
+ # pyenv
86
+ # For a library or package, you might want to ignore these files since the code is
87
+ # intended to run in multiple environments; otherwise, check them in:
88
+ # .python-version
89
+
90
+ # pipenv
91
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
92
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
93
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
94
+ # install all needed dependencies.
95
+ #Pipfile.lock
96
+
97
+ # UV
98
+ # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
99
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
100
+ # commonly ignored for libraries.
101
+ uv.lock
102
+
103
+ # poetry
104
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
105
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
106
+ # commonly ignored for libraries.
107
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
108
+ #poetry.lock
109
+ #poetry.toml
110
+
111
+ # pdm
112
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
113
+ # pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
114
+ # https://pdm-project.org/en/latest/usage/project/#working-with-version-control
115
+ #pdm.lock
116
+ #pdm.toml
117
+ .pdm-python
118
+ .pdm-build/
119
+
120
+ # pixi
121
+ # Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
122
+ #pixi.lock
123
+ # Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
124
+ # in the .venv directory. It is recommended not to include this directory in version control.
125
+ .pixi
126
+
127
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
128
+ __pypackages__/
129
+
130
+ # Celery stuff
131
+ celerybeat-schedule
132
+ celerybeat.pid
133
+
134
+ # SageMath parsed files
135
+ *.sage.py
136
+
137
+ # Environments
138
+ .env
139
+ .envrc
140
+ .venv
141
+ env/
142
+ venv/
143
+ ENV/
144
+ env.bak/
145
+ venv.bak/
146
+
147
+ # Spyder project settings
148
+ .spyderproject
149
+ .spyproject
150
+
151
+ # Rope project settings
152
+ .ropeproject
153
+
154
+ # mkdocs documentation
155
+ /site
156
+
157
+ # mypy
158
+ .mypy_cache/
159
+ .dmypy.json
160
+ dmypy.json
161
+
162
+ # Pyre type checker
163
+ .pyre/
164
+
165
+ # pytype static type analyzer
166
+ .pytype/
167
+
168
+ # Cython debug symbols
169
+ cython_debug/
170
+
171
+ # PyCharm
172
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
173
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
174
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
175
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
176
+ #.idea/
177
+
178
+ # Abstra
179
+ # Abstra is an AI-powered process automation framework.
180
+ # Ignore directories containing user credentials, local state, and settings.
181
+ # Learn more at https://abstra.io/docs
182
+ .abstra/
183
+
184
+ # Visual Studio Code
185
+ # Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
186
+ # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
187
+ # and can be added to the global gitignore or merged into this file. However, if you prefer,
188
+ # you could uncomment the following to ignore the entire vscode folder
189
+ # .vscode/
190
+
191
+ # Ruff stuff:
192
+ .ruff_cache/
193
+
194
+ # PyPI configuration file
195
+ .pypirc
196
+
197
+ # Cursor
198
+ # Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to
199
+ # exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
200
+ # refer to https://docs.cursor.com/context/ignore-files
201
+ .cursorignore
202
+ .cursorindexingignore
203
+
204
+ # Marimo
205
+ marimo/_static/
206
+ marimo/_lsp/
207
+ __marimo__/
@@ -0,0 +1,15 @@
1
+ repos:
2
+ - repo: https://github.com/astral-sh/ruff-pre-commit
3
+ rev: v0.15.1
4
+ hooks:
5
+ - id: ruff
6
+ args: [--fix]
7
+ - id: ruff-format
8
+ - repo: local
9
+ hooks:
10
+ - id: mypy
11
+ name: mypy
12
+ entry: uv run mypy symref/
13
+ language: system
14
+ types: [python]
15
+ pass_filenames: false
@@ -0,0 +1 @@
1
+ 3.12
@@ -0,0 +1,45 @@
1
+ # Contributing to symref
2
+
3
+ ## Prerequisites
4
+
5
+ - Python 3.12+
6
+ - [uv](https://docs.astral.sh/uv/)
7
+
8
+ ## Setup
9
+
10
+ ```bash
11
+ git clone https://github.com/jpuglielli/symref.git
12
+ cd symref
13
+ uv sync --group dev --group docs
14
+ uv run pre-commit install
15
+ ```
16
+
17
+ ## Running tests
18
+
19
+ ```bash
20
+ uv run pytest tests/ -v
21
+ ```
22
+
23
+ ## Linting and type-checking
24
+
25
+ ```bash
26
+ uv run ruff check symref/
27
+ uv run mypy symref/
28
+ ```
29
+
30
+ Pre-commit hooks run these automatically on each commit.
31
+
32
+ ## Building docs
33
+
34
+ ```bash
35
+ uv run mkdocs serve
36
+ ```
37
+
38
+ Then open <http://127.0.0.1:8000>.
39
+
40
+ ## PR workflow
41
+
42
+ 1. Create a feature branch from `main`.
43
+ 2. Make your changes and commit.
44
+ 3. Push the branch and open a pull request.
45
+ 4. Ensure CI passes before requesting review.
symref-0.1.0/LICENSE ADDED
@@ -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.
symref-0.1.0/Makefile ADDED
@@ -0,0 +1,27 @@
1
+ .PHONY: install test lint typecheck format docs hooks
2
+
3
+ install: ## Install dev and docs dependencies
4
+ uv sync --group dev --group docs
5
+
6
+ hooks: ## Install pre-commit hooks
7
+ uv run pre-commit install
8
+
9
+ test: ## Run tests
10
+ uv run pytest tests/ -v
11
+
12
+ lint: ## Run ruff linter
13
+ uv run ruff check symref/
14
+
15
+ typecheck: ## Run mypy
16
+ uv run mypy symref/
17
+
18
+ format: ## Run ruff formatter
19
+ uv run ruff format symref/ tests/
20
+
21
+ docs: ## Serve docs locally
22
+ uv run mkdocs serve
23
+
24
+ check: lint typecheck test ## Run lint, typecheck, and tests
25
+
26
+ help: ## Show this help
27
+ @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-12s\033[0m %s\n", $$1, $$2}'
symref-0.1.0/PKG-INFO ADDED
@@ -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
symref-0.1.0/README.md ADDED
@@ -0,0 +1,88 @@
1
+ # symref
2
+
3
+ Refactor-safe forward references for Python dotted import paths.
4
+
5
+ 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.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ pip install symref
11
+ ```
12
+
13
+ Requires Python 3.12+. No runtime dependencies.
14
+
15
+ ## Usage
16
+
17
+ ```python
18
+ from symref import ref
19
+
20
+ app.conf.task_routes = {
21
+ ref("myapp.tasks.send_email", kind="celery_task"): {"queue": "email"},
22
+ ref("myapp.tasks.process_order", kind="celery_task"): {"queue": "orders"},
23
+ }
24
+ ```
25
+
26
+ `ref()` returns a plain `str` -- frameworks see no difference. But each call is recorded in a global registry with its source location.
27
+
28
+ ### Validate in tests
29
+
30
+ ```python
31
+ from symref import validate_refs
32
+
33
+ def test_all_refs_resolve():
34
+ validate_refs()
35
+
36
+ def test_celery_task_refs():
37
+ validate_refs(kind="celery_task")
38
+ ```
39
+
40
+ If any path can't be resolved, `validate_refs()` raises `SymrefError` with all broken references and their source locations:
41
+
42
+ ```
43
+ symref.SymrefError: 2 broken reference(s):
44
+ - "myapp.tasks.send_email" (defined in config/celery.py:8)
45
+ - "myapp.tasks.process_order" (defined in config/celery.py:9)
46
+ ```
47
+
48
+ ## API
49
+
50
+ ### `ref(path, *, kind=None)`
51
+
52
+ Creates a forward reference. Returns a `str` subclass instance.
53
+
54
+ - **`path`** -- fully qualified dotted import path (module or attribute)
55
+ - **`kind`** *(optional)* -- arbitrary label for filtering validation (e.g. `"celery_task"`, `"django_app"`)
56
+
57
+ ### `validate_refs(kind=None)`
58
+
59
+ 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.
60
+
61
+ ### `SymrefError`
62
+
63
+ Raised when one or more refs can't be resolved. The `.broken` attribute contains the list of broken `ref` instances.
64
+
65
+ ## How it works
66
+
67
+ - `ref()` is a `str` subclass -- zero overhead after construction
68
+ - Each `ref()` call appends to `ref._registry` and captures the caller's file/line via `sys._getframe()`
69
+ - `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
70
+ - No imports happen at `ref()` construction time -- validation is fully deferred
71
+
72
+ ## Documentation
73
+
74
+ Build and preview the docs locally:
75
+
76
+ ```bash
77
+ uv run mkdocs serve
78
+ ```
79
+
80
+ Then open <http://127.0.0.1:8000>.
81
+
82
+ ## Contributing
83
+
84
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, testing, and PR guidelines.
85
+
86
+ ## License
87
+
88
+ MIT
@@ -0,0 +1,9 @@
1
+ # API Reference
2
+
3
+ ::: symref.ref
4
+ options:
5
+ members: false
6
+
7
+ ::: symref.validate_refs
8
+
9
+ ::: symref.SymrefError
@@ -0,0 +1,64 @@
1
+ # symref
2
+
3
+ Refactor-safe forward references for Python dotted import paths.
4
+
5
+ Many frameworks (Celery, Django, etc.) accept dotted string paths as
6
+ configuration. These strings are invisible to refactoring tools and IDEs —
7
+ renaming a module silently breaks them. **symref** wraps these paths in a
8
+ `str` subclass that registers them for later validation, catching broken
9
+ references in tests instead of production.
10
+
11
+ ## Installation
12
+
13
+ ```bash
14
+ pip install symref
15
+ ```
16
+
17
+ Requires Python 3.12+. No runtime dependencies.
18
+
19
+ ## Quick start
20
+
21
+ ### Create refs
22
+
23
+ ```python
24
+ from symref import ref
25
+
26
+ app.conf.task_routes = {
27
+ ref("myapp.tasks.send_email", kind="celery_task"): {"queue": "email"},
28
+ ref("myapp.tasks.process_order", kind="celery_task"): {"queue": "orders"},
29
+ }
30
+ ```
31
+
32
+ `ref()` returns a plain `str` — frameworks see no difference. But each call
33
+ is recorded in a global registry with its source location.
34
+
35
+ ### Validate in tests
36
+
37
+ ```python
38
+ from symref import validate_refs
39
+
40
+ def test_all_refs_resolve():
41
+ validate_refs()
42
+
43
+ def test_celery_task_refs():
44
+ validate_refs(kind="celery_task")
45
+ ```
46
+
47
+ If any path can't be resolved, `validate_refs()` raises `SymrefError` with
48
+ all broken references and their source locations:
49
+
50
+ ```
51
+ symref.SymrefError: 2 broken reference(s):
52
+ - "myapp.tasks.send_email" (defined in config/celery.py:8)
53
+ - "myapp.tasks.process_order" (defined in config/celery.py:9)
54
+ ```
55
+
56
+ ## How it works
57
+
58
+ - `ref()` is a `str` subclass — zero overhead after construction
59
+ - Each `ref()` call appends to `ref._registry` and captures the caller's
60
+ file/line via `sys._getframe()`
61
+ - `validate_refs()` uses `importlib.util.find_spec()` to check modules, and
62
+ imports the parent module only when verifying attributes
63
+ - No imports happen at `ref()` construction time — validation is fully
64
+ deferred
@@ -0,0 +1,25 @@
1
+ site_name: symref
2
+ site_description: Refactor-safe forward references for Python dotted import paths.
3
+
4
+ theme:
5
+ name: material
6
+ palette:
7
+ scheme: default
8
+
9
+ plugins:
10
+ - search
11
+ - mkdocstrings:
12
+ handlers:
13
+ python:
14
+ paths: [.]
15
+ options:
16
+ docstring_style: google
17
+ show_root_heading: true
18
+ show_source: true
19
+ merge_init_into_class: true
20
+ separate_signature: true
21
+ show_signature_annotations: true
22
+
23
+ nav:
24
+ - Home: index.md
25
+ - API Reference: api.md
@@ -0,0 +1,94 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "symref"
7
+ version = "0.1.0"
8
+ description = "Forward-referencing Python objects by dotted import path with refactor safety"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.12"
12
+ authors = [{name = "Josh Puglielli"}]
13
+ keywords = ["forward-reference", "import-path", "refactoring", "celery", "django"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.12",
20
+ "Programming Language :: Python :: 3.13",
21
+ "Typing :: Typed",
22
+ ]
23
+ dependencies = []
24
+
25
+ [project.urls]
26
+ Repository = "https://github.com/jpuglielli/symref"
27
+
28
+ [dependency-groups]
29
+ dev = [
30
+ "mypy>=1.19.1",
31
+ "pre-commit>=4.0",
32
+ "pytest>=8.0",
33
+ "ruff>=0.15.1",
34
+ ]
35
+ docs = [
36
+ "mkdocs-material",
37
+ "mkdocstrings[python]",
38
+ ]
39
+
40
+ [tool.hatch.build.targets.wheel]
41
+ packages = ["symref"]
42
+
43
+ # Ruff 0.15.1
44
+ [tool.ruff]
45
+ target-version = "py312"
46
+ line-length = 88
47
+
48
+ [tool.ruff.lint]
49
+ select = ["ALL"]
50
+ ignore = [
51
+ # Missing docstrings
52
+ "D100", # Missing docstring in public module
53
+ "D101", # Missing docstring in public class
54
+ "D102", # Missing docstring in public method
55
+ "D103", # Missing docstring in public function
56
+ "D104", # Missing docstring in public package
57
+ "D105", # Missing docstring in magic method
58
+ "D106", # Missing docstring in public nested class
59
+ "D107", # Missing docstring in __init__
60
+
61
+ # D203 and D211 conflict — use D211 (no blank line before class docstring)
62
+ "D203",
63
+ # D212 and D213 conflict — use D213 (multi-line summary on second line)
64
+ "D212",
65
+
66
+ # COM812 and ISC001 conflict with the ruff formatter
67
+ "COM812",
68
+ "ISC001",
69
+ ]
70
+
71
+ [tool.ruff.lint.per-file-ignores]
72
+ "tests/**/*.py" = [
73
+ "S101", # assert is fine in tests
74
+ "D", # no docstring enforcement in tests
75
+ "SLF001", # tests access private members (_kind, _source, _registry)
76
+ ]
77
+ "symref/__init__.py" = [
78
+ "SLF001", # clear_refs() accesses ref._registry
79
+ ]
80
+ "symref/_validate.py" = [
81
+ "SLF001", # accesses r._source, r._kind
82
+ ]
83
+
84
+ [tool.ruff.format]
85
+ docstring-code-format = true
86
+
87
+ [tool.mypy]
88
+ python_version = "3.12"
89
+ strict = true
90
+ warn_return_any = true
91
+ warn_unused_configs = true
92
+
93
+ [tool.pytest.ini_options]
94
+ testpaths = ["tests"]
@@ -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"]
@@ -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),))
@@ -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)
@@ -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)
File without changes
File without changes
@@ -0,0 +1,10 @@
1
+ from __future__ import annotations
2
+
3
+ import pytest
4
+
5
+ from symref import clear_refs
6
+
7
+
8
+ @pytest.fixture(autouse=True)
9
+ def _clear_registry() -> None:
10
+ clear_refs()
@@ -0,0 +1,61 @@
1
+ from __future__ import annotations
2
+
3
+ from symref import ref
4
+
5
+
6
+ def test_ref_is_str() -> None:
7
+ r = ref("os.path")
8
+ assert isinstance(r, str)
9
+
10
+
11
+ def test_ref_equals_plain_str() -> None:
12
+ r = ref("os.path")
13
+ assert r == "os.path"
14
+ assert "os.path" == r # noqa: SIM300 -- intentionally testing str.__eq__(ref)
15
+
16
+
17
+ def test_ref_registered_in_registry() -> None:
18
+ r = ref("os.path")
19
+ assert r in ref._registry
20
+ assert len(ref._registry) == 1
21
+
22
+
23
+ def test_kind_defaults_to_none() -> None:
24
+ r = ref("os.path")
25
+ assert r._kind is None
26
+
27
+
28
+ def test_kind_stored_when_provided() -> None:
29
+ r = ref("os.path", kind="test_kind")
30
+ assert r._kind == "test_kind"
31
+
32
+
33
+ def test_source_captured() -> None:
34
+ r = ref("os.path")
35
+ assert r._source[0].endswith("test_ref.py")
36
+ assert isinstance(r._source[1], int)
37
+ assert r._source[1] > 0
38
+
39
+
40
+ def test_usable_as_dict_key() -> None:
41
+ r = ref("os.path")
42
+ d: dict[str, str] = {r: "value"}
43
+ assert d["os.path"] == "value"
44
+ assert d[r] == "value"
45
+
46
+
47
+ def test_repr_matches_str() -> None:
48
+ r = ref("os.path")
49
+ assert repr(r) == repr("os.path")
50
+
51
+
52
+ def test_hash_matches_str() -> None:
53
+ r = ref("os.path")
54
+ assert hash(r) == hash("os.path")
55
+
56
+
57
+ def test_registry_clearing() -> None:
58
+ ref("os.path")
59
+ assert len(ref._registry) == 1
60
+ ref._registry.clear()
61
+ assert len(ref._registry) == 0
@@ -0,0 +1,129 @@
1
+ from __future__ import annotations
2
+
3
+ import pytest
4
+
5
+ from symref import SymrefError, ref, validate_refs
6
+ from symref._validate import _resolve
7
+
8
+ # --- _resolve tests ---
9
+
10
+
11
+ def test_resolve_valid_module() -> None:
12
+ assert _resolve("os") is True
13
+
14
+
15
+ def test_resolve_valid_nested_module() -> None:
16
+ assert _resolve("os.path") is True
17
+
18
+
19
+ def test_resolve_valid_attribute() -> None:
20
+ assert _resolve("os.path.join") is True
21
+
22
+
23
+ def test_resolve_valid_package_module() -> None:
24
+ assert _resolve("symref._ref") is True
25
+
26
+
27
+ def test_resolve_valid_package_attribute() -> None:
28
+ assert _resolve("symref._ref.ref") is True
29
+
30
+
31
+ def test_resolve_valid_package_class_var() -> None:
32
+ assert _resolve("symref.SymrefError") is True
33
+
34
+
35
+ def test_resolve_nonexistent_module() -> None:
36
+ assert _resolve("nonexistent.module.that.does.not.exist") is False
37
+
38
+
39
+ def test_resolve_nonexistent_attribute() -> None:
40
+ assert _resolve("os.path.nonexistent_attr_xyz") is False
41
+
42
+
43
+ def test_resolve_relative_path() -> None:
44
+ assert _resolve(".foo.bar") is False
45
+
46
+
47
+ def test_resolve_double_relative_path() -> None:
48
+ assert _resolve("..foo") is False
49
+
50
+
51
+ def test_resolve_whitespace_path() -> None:
52
+ assert _resolve(" ") is False
53
+
54
+
55
+ def test_resolve_empty_string() -> None:
56
+ assert _resolve("") is False
57
+
58
+
59
+ # --- validate_refs tests ---
60
+
61
+
62
+ def test_validate_no_refs_passes() -> None:
63
+ validate_refs()
64
+
65
+
66
+ def test_validate_valid_ref_passes() -> None:
67
+ ref("os.path")
68
+ validate_refs()
69
+
70
+
71
+ def test_validate_broken_ref_raises() -> None:
72
+ ref("nonexistent.module.xyz")
73
+ with pytest.raises(SymrefError):
74
+ validate_refs()
75
+
76
+
77
+ def test_validate_multiple_broken_refs() -> None:
78
+ ref("nonexistent.module.one")
79
+ ref("nonexistent.module.two")
80
+ with pytest.raises(SymrefError, match="2 broken reference"):
81
+ validate_refs()
82
+
83
+
84
+ def test_validate_kind_filtering_passes() -> None:
85
+ ref("os.path", kind="good")
86
+ ref("nonexistent.module.xyz", kind="bad")
87
+ validate_refs(kind="good")
88
+
89
+
90
+ def test_validate_kind_filtering_raises() -> None:
91
+ ref("os.path", kind="good")
92
+ ref("nonexistent.module.xyz", kind="bad")
93
+ with pytest.raises(SymrefError):
94
+ validate_refs(kind="bad")
95
+
96
+
97
+ def test_error_message_includes_source() -> None:
98
+ ref("nonexistent.module.xyz")
99
+ with pytest.raises(SymrefError, match=r"test_validate\.py"):
100
+ validate_refs()
101
+
102
+
103
+ def test_validate_valid_attribute_ref() -> None:
104
+ ref("symref.SymrefError")
105
+ validate_refs()
106
+
107
+
108
+ def test_validate_invalid_attribute_ref() -> None:
109
+ ref("symref.NonexistentThing")
110
+ with pytest.raises(SymrefError):
111
+ validate_refs()
112
+
113
+
114
+ def test_validate_mixed_valid_and_broken_refs() -> None:
115
+ ref("os.path")
116
+ ref("nonexistent.module.xyz")
117
+ ref("os")
118
+ with pytest.raises(SymrefError) as exc_info:
119
+ validate_refs()
120
+ assert len(exc_info.value.broken) == 1
121
+ assert str(exc_info.value.broken[0]) == "nonexistent.module.xyz"
122
+
123
+
124
+ def test_symref_error_broken_attribute() -> None:
125
+ r = ref("nonexistent.module.xyz")
126
+ with pytest.raises(SymrefError) as exc_info:
127
+ validate_refs()
128
+ assert exc_info.value.broken == [r]
129
+ assert exc_info.value.broken[0] is r