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.
- symref-0.1.0/.claude/settings.local.json +16 -0
- symref-0.1.0/.github/workflows/ci.yml +23 -0
- symref-0.1.0/.gitignore +207 -0
- symref-0.1.0/.pre-commit-config.yaml +15 -0
- symref-0.1.0/.python-version +1 -0
- symref-0.1.0/CONTRIBUTING.md +45 -0
- symref-0.1.0/LICENSE +21 -0
- symref-0.1.0/Makefile +27 -0
- symref-0.1.0/PKG-INFO +107 -0
- symref-0.1.0/README.md +88 -0
- symref-0.1.0/docs/api.md +9 -0
- symref-0.1.0/docs/index.md +64 -0
- symref-0.1.0/mkdocs.yml +25 -0
- symref-0.1.0/pyproject.toml +94 -0
- symref-0.1.0/symref/__init__.py +14 -0
- symref-0.1.0/symref/_ref.py +44 -0
- symref-0.1.0/symref/_source.py +26 -0
- symref-0.1.0/symref/_validate.py +75 -0
- symref-0.1.0/symref/py.typed +0 -0
- symref-0.1.0/tests/__init__.py +0 -0
- symref-0.1.0/tests/conftest.py +10 -0
- symref-0.1.0/tests/test_ref.py +61 -0
- symref-0.1.0/tests/test_validate.py +129 -0
|
@@ -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
|
symref-0.1.0/.gitignore
ADDED
|
@@ -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
|
symref-0.1.0/docs/api.md
ADDED
|
@@ -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
|
symref-0.1.0/mkdocs.yml
ADDED
|
@@ -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,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
|