django-probe 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,12 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ dist/
5
+ build/
6
+ *.egg-info/
7
+ .pytest_cache/
8
+ .ruff_cache/
9
+ .tox/
10
+ db.sqlite3
11
+ .env
12
+ /site/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Django Probe contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,60 @@
1
+ Metadata-Version: 2.5
2
+ Name: django-probe
3
+ Version: 0.1.0
4
+ Summary: Count Django code patterns in a project and report the counts.
5
+ Project-URL: Repository, https://github.com/django-probe/django-probe
6
+ Author: Django Probe contributors
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Keywords: AST,Django,metrics
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Framework :: Django
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Natural Language :: English
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3 :: Only
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Programming Language :: Python :: 3.14
20
+ Classifier: Programming Language :: Python :: Implementation :: CPython
21
+ Classifier: Typing :: Typed
22
+ Requires-Python: >=3.11
23
+ Description-Content-Type: text/markdown
24
+
25
+ # Django Probe
26
+
27
+ It's hard to remove features in open-source software. [Deprecation warnings exist, but people tend to ignore them](https://sethmlarson.dev/deprecations-via-warnings-dont-work-for-python-libraries). What maintainers want to know is how many people are using a feature. That's where Django Probe comes in.
28
+
29
+ Django Probe allows you to share how your project uses Django. This package counts how often specific code patterns appear in your Django project and shares the aggregated information with the community.
30
+
31
+ By sharing what your project uses, you help support the Django community. This allows maintainers to know what features and APIs are actually being used, removing guess work.
32
+
33
+ ## Quickstart
34
+
35
+ ```console
36
+ $ pip install django-probe
37
+ $ django-probe scan . # inspect the payload
38
+ $ django-probe submit . # send it, anonymously
39
+ ```
40
+
41
+ No account is required. See [Getting started](https://docs.djangoprobe.org/getting-started/)
42
+ for project keys, and [Privacy](https://docs.djangoprobe.org/privacy/)
43
+ for exactly what a payload contains.
44
+
45
+ ### Reporting automatically
46
+
47
+ You should avoid reporting this manually. The project key can be set as an environment
48
+ variable (`DJANGO_PROBE_PROJECT_KEY`), so it drops straight into a scheduled GitHub
49
+ Action as a repository secret. See
50
+ [Getting started](https://docs.djangoprobe.org/getting-started/#reporting-on-a-schedule)
51
+ for a workflow you can copy.
52
+
53
+ ## What we're looking to learn
54
+
55
+ This list will grow over time, but for now there are two main usages:
56
+
57
+ - The [`.extra()` ORM API method](https://docs.djangoproject.com/en/6.1/ref/models/querysets/#extra)
58
+ - The [`.extra()` ORM API method](https://docs.djangoproject.com/en/6.1/ref/models/querysets/#extra) has had a note about avoiding its usage for years. Let's determine if this is something that is central to a signficant number of Django projects.
59
+ - The [`@cache_page` decorator](https://docs.djangoproject.com/en/6.1/topics/cache/#the-per-view-cache)
60
+ - The `@cache_page` decorator can easily cause problems for projects by storing and serving sensitive information such as CSRF tokens and CSP nonces. Understanding how widespread the usage is of it can help determine what further changes are needed.
@@ -0,0 +1,36 @@
1
+ # Django Probe
2
+
3
+ It's hard to remove features in open-source software. [Deprecation warnings exist, but people tend to ignore them](https://sethmlarson.dev/deprecations-via-warnings-dont-work-for-python-libraries). What maintainers want to know is how many people are using a feature. That's where Django Probe comes in.
4
+
5
+ Django Probe allows you to share how your project uses Django. This package counts how often specific code patterns appear in your Django project and shares the aggregated information with the community.
6
+
7
+ By sharing what your project uses, you help support the Django community. This allows maintainers to know what features and APIs are actually being used, removing guess work.
8
+
9
+ ## Quickstart
10
+
11
+ ```console
12
+ $ pip install django-probe
13
+ $ django-probe scan . # inspect the payload
14
+ $ django-probe submit . # send it, anonymously
15
+ ```
16
+
17
+ No account is required. See [Getting started](https://docs.djangoprobe.org/getting-started/)
18
+ for project keys, and [Privacy](https://docs.djangoprobe.org/privacy/)
19
+ for exactly what a payload contains.
20
+
21
+ ### Reporting automatically
22
+
23
+ You should avoid reporting this manually. The project key can be set as an environment
24
+ variable (`DJANGO_PROBE_PROJECT_KEY`), so it drops straight into a scheduled GitHub
25
+ Action as a repository secret. See
26
+ [Getting started](https://docs.djangoprobe.org/getting-started/#reporting-on-a-schedule)
27
+ for a workflow you can copy.
28
+
29
+ ## What we're looking to learn
30
+
31
+ This list will grow over time, but for now there are two main usages:
32
+
33
+ - The [`.extra()` ORM API method](https://docs.djangoproject.com/en/6.1/ref/models/querysets/#extra)
34
+ - The [`.extra()` ORM API method](https://docs.djangoproject.com/en/6.1/ref/models/querysets/#extra) has had a note about avoiding its usage for years. Let's determine if this is something that is central to a signficant number of Django projects.
35
+ - The [`@cache_page` decorator](https://docs.djangoproject.com/en/6.1/topics/cache/#the-per-view-cache)
36
+ - The `@cache_page` decorator can easily cause problems for projects by storing and serving sensitive information such as CSRF tokens and CSP nonces. Understanding how widespread the usage is of it can help determine what further changes are needed.
@@ -0,0 +1,169 @@
1
+ [build-system]
2
+ build-backend = "hatchling.build"
3
+ requires = [
4
+ "hatchling",
5
+ ]
6
+
7
+ [project]
8
+ name = "django-probe"
9
+ dynamic = [ "version" ]
10
+ description = "Count Django code patterns in a project and report the counts."
11
+ readme = "README.md"
12
+ keywords = [
13
+ "AST",
14
+ "Django",
15
+ "metrics",
16
+ ]
17
+ license = "MIT"
18
+ authors = [
19
+ { name = "Django Probe contributors" },
20
+ ]
21
+ requires-python = ">=3.11"
22
+ classifiers = [
23
+ "Development Status :: 3 - Alpha",
24
+ "Framework :: Django",
25
+ "Intended Audience :: Developers",
26
+ "Natural Language :: English",
27
+ "Operating System :: OS Independent",
28
+ "Programming Language :: Python :: 3 :: Only",
29
+ "Programming Language :: Python :: 3.11",
30
+ "Programming Language :: Python :: 3.12",
31
+ "Programming Language :: Python :: 3.13",
32
+ "Programming Language :: Python :: 3.14",
33
+ "Programming Language :: Python :: Implementation :: CPython",
34
+ "Typing :: Typed",
35
+ ]
36
+ dependencies = []
37
+ scripts.django-probe = "django_probe.main:main"
38
+ urls.Repository = "https://github.com/django-probe/django-probe"
39
+
40
+ [dependency-groups]
41
+ # Groups rather than extras: they stay out of the published wheel's metadata, so
42
+ # installing django-probe pulls in neither Django nor pytest.
43
+ webapp = [
44
+ "django>=5.0",
45
+ "django-allauth[socialaccount]>=65",
46
+ "django-ratelimit>=4.1",
47
+ "dj-database-url>=2.3",
48
+ "django-redis>=5.4",
49
+ "gunicorn>=23",
50
+ "psycopg[binary]>=3.2",
51
+ "whitenoise>=6.8",
52
+ ]
53
+ test = [
54
+ "pytest>=8",
55
+ "pytest-django>=4.9",
56
+ "pytest-randomly",
57
+ ]
58
+ dev = [
59
+ "mypy>=1.13",
60
+ "pre-commit>=4",
61
+ "ruff>=0.8",
62
+ { include-group = "webapp" },
63
+ { include-group = "test" },
64
+ ]
65
+ docs = [ "zensical==0.0.57" ]
66
+ # Pinned Django versions for the tox test matrix only, kept out of `dev`.
67
+ django50 = [ "django>=5.0,<5.1" ]
68
+ django52 = [ "django>=5.2,<6" ]
69
+ django60 = [ "django>=6.0,<6.1; python_version>='3.12'" ]
70
+ django61 = [ "django>=6.1,<6.2; python_version>='3.12'" ]
71
+ djangomain = [ "django @ git+https://github.com/django/django.git@main ; python_version>='3.12'" ]
72
+
73
+ [tool.uv]
74
+ # django61/djangomain track pre-release and dev Django builds; allow uv to
75
+ # resolve pre-releases project-wide so the lockfile's resolution mode stays
76
+ # consistent across every tox env (a per-env override desyncs `uv sync
77
+ # --locked` from the lockfile's recorded mode).
78
+ prerelease = "allow"
79
+ conflicts = [
80
+ [
81
+ { group = "django50" },
82
+ { group = "django52" },
83
+ { group = "django60" },
84
+ { group = "django61" },
85
+ { group = "djangomain" },
86
+ ],
87
+ ]
88
+
89
+ [tool.hatch.version]
90
+ source = "code"
91
+ path = "src/django_probe/__init__.py"
92
+
93
+ [tool.hatch.build.targets.wheel]
94
+ packages = [ "src/django_probe" ]
95
+
96
+ [tool.hatch.build.targets.sdist]
97
+ include = [
98
+ "/src/django_probe",
99
+ "/README.md",
100
+ ]
101
+
102
+ [tool.ruff]
103
+ extend-exclude = [ "*/migrations/*" ]
104
+ lint.select = [
105
+ # flake8-bugbear
106
+ "B",
107
+ # flake8-comprehensions
108
+ "C4",
109
+ # pycodestyle
110
+ "E",
111
+ # Pyflakes errors
112
+ "F",
113
+ # isort
114
+ "I",
115
+ # flake8-simplify
116
+ "SIM",
117
+ # flake8-tidy-imports
118
+ "TID",
119
+ # pyupgrade
120
+ "UP",
121
+ # Pyflakes warnings
122
+ "W",
123
+ ]
124
+ lint.ignore = [
125
+ # flake8-bugbear opinionated rules
126
+ "B9",
127
+ # line-too-long
128
+ "E501",
129
+ # suppressible-exception
130
+ "SIM105",
131
+ # if-else-block-instead-of-if-exp
132
+ "SIM108",
133
+ ]
134
+ lint.extend-safe-fixes = [
135
+ # non-pep585-annotation
136
+ "UP006",
137
+ ]
138
+ lint.isort.known-first-party = [ "config", "django_probe", "ingest", "tests" ]
139
+ lint.isort.required-imports = [ "from __future__ import annotations" ]
140
+
141
+ [tool.pytest.ini_options]
142
+ addopts = "--strict-markers"
143
+ DJANGO_SETTINGS_MODULE = "config.settings"
144
+ pythonpath = [ ".", "src", "src/webapp" ]
145
+ testpaths = [ "tests", "src/webapp" ]
146
+ markers = [
147
+ "webapp: exercises src/webapp rather than the django_probe library",
148
+ ]
149
+
150
+ [tool.mypy]
151
+ mypy_path = "src/"
152
+ namespace_packages = false
153
+ warn_unreachable = true
154
+ enable_error_code = [
155
+ "ignore-without-code",
156
+ "redundant-expr",
157
+ "truthy-bool",
158
+ ]
159
+ strict = true
160
+ overrides = [
161
+ { module = "tests.*", allow_untyped_defs = true },
162
+ # The webapp is a Django app; annotating views and models fully needs
163
+ # django-stubs, which is more machinery than this POC warrants.
164
+ { module = "config.*", ignore_errors = true },
165
+ { module = "ingest.*", ignore_errors = true },
166
+ ]
167
+
168
+ [tool.django_probe]
169
+ project_key = "1f3d9c62-8a41-4b7e-9d05-6c2e8f0a3b17"
@@ -0,0 +1,11 @@
1
+ """Django Probe. Count Django code patterns and report only the counts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+
7
+ __all__ = ["__version__"]
8
+
9
+ # Suffixed with DJANGO_PROBE_VERSION_DEV so CI can build throwaway dev
10
+ # distributions to test the release process. See .github/workflows/test_release.yml
11
+ __version__ = "0.1.0" + os.environ.get("DJANGO_PROBE_VERSION_DEV", "")
@@ -0,0 +1,122 @@
1
+ """Count probe hits with a small dispatch-by-node-type AST visitor."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ import re
7
+ from collections import Counter, defaultdict
8
+ from collections.abc import Callable, Iterable
9
+ from functools import cached_property
10
+ from typing import Any
11
+
12
+ ProbeFunc = Callable[["State", Any, tuple[ast.AST, ...]], Iterable[Any]]
13
+
14
+ settings_re = re.compile(r"(\b|_)settings(\b|_)")
15
+
16
+
17
+ class State:
18
+ """Per-file state threaded through every probe callback."""
19
+
20
+ #: ``__weakref__`` lets probes key a ``WeakKeyDictionary`` by ``State`` (see
21
+ #: ``probes.orm``'s per-file ``Library`` tracking); ``__dict__`` backs
22
+ #: ``cached_property``.
23
+ __slots__ = ("filename", "from_imports", "__weakref__", "__dict__")
24
+
25
+ def __init__(self, filename: str, from_imports: defaultdict[str, set[str]]) -> None:
26
+ self.filename = filename
27
+ self.from_imports = from_imports
28
+
29
+ @cached_property
30
+ def looks_like_settings_file(self) -> bool:
31
+ return settings_re.search(self.filename) is not None
32
+
33
+
34
+ class _Registration:
35
+ """One probe's AST callbacks and optional file-level gate."""
36
+
37
+ __slots__ = ("key", "condition", "ast_funcs")
38
+
39
+ def __init__(self, key: str, condition: Callable[[State], bool] | None) -> None:
40
+ self.key = key
41
+ self.condition = condition
42
+ self.ast_funcs: dict[type[ast.AST], list[ProbeFunc]] = defaultdict(list)
43
+
44
+
45
+ #: Every probe registered so far, keyed by "namespace:name", populated at import time
46
+ #: by ``probes.Probe``.
47
+ _REGISTRY: dict[str, _Registration] = {}
48
+
49
+
50
+ def register_probe(
51
+ key: str, condition: Callable[[State], bool] | None
52
+ ) -> _Registration:
53
+ if key in _REGISTRY:
54
+ raise RuntimeError(f"probe {key!r} is already registered")
55
+ registration = _Registration(key, condition)
56
+ _REGISTRY[key] = registration
57
+ return registration
58
+
59
+
60
+ def probe_names() -> frozenset[str]:
61
+ return frozenset(_REGISTRY)
62
+
63
+
64
+ def _probe_funcs(state: State) -> dict[type[ast.AST], list[tuple[str, ProbeFunc]]]:
65
+ funcs: dict[type[ast.AST], list[tuple[str, ProbeFunc]]] = defaultdict(list)
66
+ for registration in _REGISTRY.values():
67
+ if registration.condition is not None and not registration.condition(state):
68
+ continue
69
+ for type_, type_funcs in registration.ast_funcs.items():
70
+ funcs[type_].extend((registration.key, f) for f in type_funcs)
71
+ return funcs
72
+
73
+
74
+ def _record_imports(node: ast.AST, state: State) -> None:
75
+ """Track imported names by module.
76
+
77
+ Every module is tracked, not just ``django.*``, since third-party probes need it;
78
+ and plain ``import a.b`` is recorded, which is what lets a probe match the fully
79
+ dotted ``@django.tasks.task`` form.
80
+ """
81
+ if isinstance(node, ast.ImportFrom) and node.level == 0 and node.module:
82
+ state.from_imports[node.module].update(
83
+ alias.name
84
+ for alias in node.names
85
+ if alias.asname is None and alias.name != "*"
86
+ )
87
+ elif isinstance(node, ast.Import):
88
+ for alias in node.names:
89
+ if alias.asname is None:
90
+ root = alias.name.partition(".")[0]
91
+ state.from_imports[root].add(root)
92
+
93
+
94
+ def count_patterns(tree: ast.Module, filename: str) -> Counter[str]:
95
+ """Return ``{"namespace:probe_name": count}`` for one parsed module."""
96
+ state = State(filename=filename, from_imports=defaultdict(set))
97
+ funcs = _probe_funcs(state)
98
+ counts: Counter[str] = Counter()
99
+
100
+ # A stack with reversed fields gives depth-first source order. Probes rely on
101
+ # that, since it guarantees a module's imports are seen before the code using them.
102
+ nodes: list[tuple[ast.AST, tuple[ast.AST, ...]]] = [(tree, ())]
103
+ while nodes:
104
+ node, parents = nodes.pop()
105
+
106
+ for probe_name, func in funcs.get(type(node), ()):
107
+ for _ in func(state, node, parents):
108
+ counts[probe_name] += 1
109
+
110
+ _record_imports(node, state)
111
+
112
+ subparents = (*parents, node)
113
+ for name in reversed(node._fields):
114
+ value = getattr(node, name, None)
115
+ if isinstance(value, ast.AST):
116
+ nodes.append((value, subparents))
117
+ elif isinstance(value, list):
118
+ for subvalue in reversed(value):
119
+ if isinstance(subvalue, ast.AST):
120
+ nodes.append((subvalue, subparents))
121
+
122
+ return counts
@@ -0,0 +1,53 @@
1
+ """Gather the non-pattern half of the payload: versions and installed dependencies."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import platform
6
+ import re
7
+ from importlib import metadata
8
+
9
+ _NORMALIZE_RE = re.compile(r"[-_.]+")
10
+
11
+ #: Recorded in every payload so a zero count can be told apart from "nothing looked".
12
+ PROBE_SOURCE_DISTRIBUTIONS = ("django-probe",)
13
+
14
+
15
+ def normalize(name: str) -> str:
16
+ """PEP 503 name normalization."""
17
+ return _NORMALIZE_RE.sub("-", name).lower()
18
+
19
+
20
+ def dependencies() -> dict[str, str]:
21
+ found: dict[str, str] = {}
22
+ for dist in metadata.distributions():
23
+ name = dist.metadata["Name"]
24
+ if name:
25
+ found[normalize(name)] = dist.version or ""
26
+ return dict(sorted(found.items()))
27
+
28
+
29
+ def _version_of(name: str) -> str | None:
30
+ try:
31
+ return metadata.version(name)
32
+ except metadata.PackageNotFoundError:
33
+ return None
34
+
35
+
36
+ def probe_sources() -> dict[str, str]:
37
+ return {
38
+ name: version
39
+ for name in PROBE_SOURCE_DISTRIBUTIONS
40
+ if (version := _version_of(name)) is not None
41
+ }
42
+
43
+
44
+ def python_version() -> str:
45
+ return platform.python_version()
46
+
47
+
48
+ def django_version() -> str:
49
+ return _version_of("django") or ""
50
+
51
+
52
+ def client_version() -> str:
53
+ return _version_of("django-probe") or "0.0.0"
@@ -0,0 +1,40 @@
1
+ """Read the optional project key from pyproject.toml.
2
+
3
+ The key is a random UUID rather than a hash of the git remote. A hashed remote would
4
+ be zero-config, but public repositories are enumerable, so such a hash is reversible
5
+ by dictionary attack. A UUID has no preimage.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import tomllib
12
+ from pathlib import Path
13
+
14
+ PROJECT_KEY_ENV = "DJANGO_PROBE_PROJECT_KEY"
15
+
16
+
17
+ def pyproject_path(root: Path) -> Path:
18
+ return root / "pyproject.toml"
19
+
20
+
21
+ def read_project_key(root: Path) -> str | None:
22
+ path = pyproject_path(root)
23
+ if not path.is_file():
24
+ return None
25
+ try:
26
+ data = tomllib.loads(path.read_text(encoding="utf-8"))
27
+ except (OSError, tomllib.TOMLDecodeError):
28
+ return None
29
+ key = data.get("tool", {}).get("django_probe", {}).get("project_key")
30
+ return key if isinstance(key, str) and key else None
31
+
32
+
33
+ def resolve_project_key(root: Path) -> str | None:
34
+ """Resolve the project key, preferring `DJANGO_PROBE_PROJECT_KEY` over pyproject.toml.
35
+
36
+ The environment variable lets a private repository report as a stable project
37
+ without committing a project_key to pyproject.toml.
38
+ """
39
+ env_key = os.environ.get(PROJECT_KEY_ENV)
40
+ return env_key or read_project_key(root)
@@ -0,0 +1,87 @@
1
+ """django-probe command line interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import os
8
+ import sys
9
+ import uuid
10
+ from collections.abc import Sequence
11
+ from pathlib import Path
12
+
13
+ from django_probe.payload import build_payload
14
+ from django_probe.submit import SubmitError, submit
15
+
16
+ DEFAULT_SERVER = "https://api.djangoprobe.org"
17
+
18
+
19
+ def build_parser() -> argparse.ArgumentParser:
20
+ parser = argparse.ArgumentParser(
21
+ prog="django-probe",
22
+ description=(
23
+ "Count how often a Django project uses particular APIs. Only counts are "
24
+ "reported. Source code, file paths and repository names are never sent."
25
+ ),
26
+ )
27
+ sub = parser.add_subparsers(dest="command", required=True)
28
+
29
+ def add_common(p: argparse.ArgumentParser) -> None:
30
+ p.add_argument("path", nargs="?", default=".", help="Project root to scan.")
31
+
32
+ scan = sub.add_parser(
33
+ "scan", help="Print the payload as JSON without sending anything."
34
+ )
35
+ add_common(scan)
36
+
37
+ send = sub.add_parser("submit", help="Scan, then send the payload to a server.")
38
+ add_common(send)
39
+ send.add_argument(
40
+ "--server-url", default=os.environ.get("DJANGO_PROBE_SERVER", DEFAULT_SERVER)
41
+ )
42
+ send.add_argument(
43
+ "--dry-run",
44
+ action="store_true",
45
+ help="Print the payload instead of sending it.",
46
+ )
47
+
48
+ sub.add_parser("init", help="Print a random project key.")
49
+
50
+ return parser
51
+
52
+
53
+ def main(argv: Sequence[str] | None = None) -> int:
54
+ args = build_parser().parse_args(argv)
55
+
56
+ if args.command == "init":
57
+ print(uuid.uuid4())
58
+ return 0
59
+
60
+ root = Path(args.path).resolve()
61
+
62
+ if not root.is_dir():
63
+ print(f"not a directory: {root}", file=sys.stderr)
64
+ return 2
65
+
66
+ payload = build_payload(root)
67
+
68
+ if args.command == "scan" or args.dry_run:
69
+ print(json.dumps(payload, indent=2, sort_keys=True))
70
+ return 0
71
+
72
+ try:
73
+ response = submit(payload, args.server_url)
74
+ except SubmitError as exc:
75
+ print(str(exc), file=sys.stderr)
76
+ return 1
77
+
78
+ total = sum(payload["patterns"].values())
79
+ print(
80
+ f"Submitted {total} pattern occurrences across "
81
+ f"{payload['files_scanned']} files. ({response.get('status', 'ok')})"
82
+ )
83
+ return 0
84
+
85
+
86
+ if __name__ == "__main__":
87
+ raise SystemExit(main())
@@ -0,0 +1,32 @@
1
+ """Assemble the submission payload.
2
+
3
+ Everything the server ever receives is built here, in one function, so the privacy
4
+ claim can be checked by reading a single file: integers, package names and version
5
+ strings, and nothing else.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from django_probe import collect
14
+ from django_probe.config import resolve_project_key
15
+ from django_probe.scan import scan_path
16
+
17
+ SCHEMA_VERSION = 1
18
+
19
+
20
+ def build_payload(root: Path) -> dict[str, Any]:
21
+ result = scan_path(root)
22
+ return {
23
+ "schema_version": SCHEMA_VERSION,
24
+ "client_version": collect.client_version(),
25
+ "project_key": resolve_project_key(root),
26
+ "python_version": collect.python_version(),
27
+ "django_version": collect.django_version(),
28
+ "files_scanned": result.files_scanned,
29
+ "probe_sources": collect.probe_sources(),
30
+ "patterns": dict(sorted(result.patterns.items())),
31
+ "dependencies": collect.dependencies(),
32
+ }
@@ -0,0 +1,107 @@
1
+ """django-probe's probes.
2
+
3
+ A probe answers "how often does this codebase do X?" for an X no tool will rewrite
4
+ away: a probe yields once per occurrence and the visitor tallies the yields.
5
+
6
+ Third-party packages will later ship probes the same way, under their own namespace::
7
+
8
+ probe = Probe("periodic_task", namespace="django-celery-beat")
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import ast
14
+ import pkgutil
15
+ from collections.abc import Callable, Iterable
16
+
17
+ from django_probe.ast_probe import ProbeFunc, State, register_probe
18
+
19
+ DEFAULT_NAMESPACE = "probe"
20
+
21
+
22
+ class Probe:
23
+ """A named counter over AST nodes."""
24
+
25
+ def __init__(
26
+ self,
27
+ name: str,
28
+ namespace: str = DEFAULT_NAMESPACE,
29
+ condition: Callable[[State], bool] | None = None,
30
+ ) -> None:
31
+ if any(c in name or c in namespace for c in ":."):
32
+ raise RuntimeError(
33
+ "probe names and namespaces must not contain ':' or '.': "
34
+ "':' separates them in the registry key"
35
+ )
36
+ self._registration = register_probe(f"{namespace}:{name}", condition)
37
+
38
+ def register(self, type_: type[ast.AST]) -> Callable[[ProbeFunc], ProbeFunc]:
39
+ def decorator(func: ProbeFunc) -> ProbeFunc:
40
+ self._registration.ast_funcs[type_].append(func)
41
+ return func
42
+
43
+ return decorator
44
+
45
+
46
+ def hit(node: ast.AST) -> Iterable[None]:
47
+ """Yield a single countable occurrence.
48
+
49
+ ``count_patterns`` only tallies how many times a probe yields, not what it yields;
50
+ ``node`` documents at the call site what was hit.
51
+ """
52
+ yield None
53
+
54
+
55
+ def dotted_name(node: ast.expr) -> str | None:
56
+ """Return the dotted path of a decorator or call target.
57
+
58
+ ``@task`` gives ``"task"`` and ``@django.tasks.task`` gives the full path. None if
59
+ any segment is not a plain name or attribute.
60
+ """
61
+ target = node.func if isinstance(node, ast.Call) else node
62
+ parts: list[str] = []
63
+ while isinstance(target, ast.Attribute):
64
+ parts.append(target.attr)
65
+ target = target.value
66
+ if not isinstance(target, ast.Name):
67
+ return None
68
+ parts.append(target.id)
69
+ return ".".join(reversed(parts))
70
+
71
+
72
+ def resolves_to(state: State, node: ast.expr, module: str, name: str) -> bool:
73
+ """Whether a decorator or call target refers to ``module.name``.
74
+
75
+ Handles the three import forms reaching the same object::
76
+
77
+ from django.tasks import task -> @task
78
+ from django import tasks -> @tasks.task
79
+ import django.tasks -> @django.tasks.task
80
+
81
+ Aliased imports are not resolved: tracking rebindings would need real scope
82
+ analysis.
83
+ """
84
+ dotted = dotted_name(node)
85
+ if dotted is None:
86
+ return False
87
+
88
+ if dotted == name:
89
+ return name in state.from_imports[module]
90
+
91
+ parent, _, leaf = module.rpartition(".")
92
+ if parent and dotted == f"{leaf}.{name}":
93
+ return leaf in state.from_imports[parent]
94
+
95
+ if dotted == f"{module}.{name}":
96
+ root = module.partition(".")[0]
97
+ return root in state.from_imports[root]
98
+
99
+ return False
100
+
101
+
102
+ def _import_probes() -> None:
103
+ for _, name, _ in pkgutil.walk_packages(__path__, f"{__name__}."):
104
+ __import__(name, fromlist=["_trash"])
105
+
106
+
107
+ _import_probes()
@@ -0,0 +1,61 @@
1
+ """Detect a custom user model.
2
+
3
+ Two signals, kept separate because they answer different questions: a project can set
4
+ ``AUTH_USER_MODEL`` to a third-party model without defining one itself.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import ast
10
+ from collections.abc import Iterable
11
+
12
+ from django_probe.ast_probe import State
13
+ from django_probe.probes import Probe, hit
14
+
15
+ BASES = frozenset({"AbstractUser", "AbstractBaseUser"})
16
+ MODULE = "django.contrib.auth.models"
17
+ BASE_MODULE = "django.contrib.auth.base_user"
18
+
19
+ custom_user_model = Probe("custom_user_model")
20
+ auth_user_model_setting = Probe(
21
+ "auth_user_model_setting",
22
+ condition=lambda state: state.looks_like_settings_file,
23
+ )
24
+
25
+
26
+ def _is_user_base(state: State, node: ast.expr) -> bool:
27
+ if isinstance(node, ast.Name):
28
+ name = node.id
29
+ elif isinstance(node, ast.Attribute):
30
+ name = node.attr
31
+ else:
32
+ return False
33
+ if name not in BASES:
34
+ return False
35
+ # AbstractUser lives in auth.models and AbstractBaseUser in auth.base_user, but
36
+ # both are commonly re-exported; accept either path.
37
+ return (
38
+ name in state.from_imports[MODULE]
39
+ or name in state.from_imports[BASE_MODULE]
40
+ or "models" in state.from_imports["django.contrib.auth"]
41
+ )
42
+
43
+
44
+ @custom_user_model.register(ast.ClassDef)
45
+ def visit_ClassDef(
46
+ state: State, node: ast.ClassDef, parents: tuple[ast.AST, ...]
47
+ ) -> Iterable[object]:
48
+ for base in node.bases:
49
+ if _is_user_base(state, base):
50
+ yield from hit(node)
51
+ return
52
+
53
+
54
+ @auth_user_model_setting.register(ast.Assign)
55
+ def visit_Assign(
56
+ state: State, node: ast.Assign, parents: tuple[ast.AST, ...]
57
+ ) -> Iterable[object]:
58
+ for target in node.targets:
59
+ if isinstance(target, ast.Name) and target.id == "AUTH_USER_MODEL":
60
+ yield from hit(node)
61
+ return
@@ -0,0 +1,53 @@
1
+ """Count @cache_page usage.
2
+
3
+ Covers the direct decorator, the ``method_decorator`` wrapping used on class-based
4
+ views, and bare calls in URLconfs.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import ast
10
+ from collections.abc import Iterable
11
+
12
+ from django_probe.ast_probe import State
13
+ from django_probe.probes import Probe, hit, resolves_to
14
+
15
+ MODULE = "django.views.decorators.cache"
16
+
17
+ cache_page = Probe("cache_page")
18
+
19
+
20
+ def _is_cache_page(state: State, node: ast.expr) -> bool:
21
+ return resolves_to(state, node, MODULE, "cache_page")
22
+
23
+
24
+ def _visit_def(
25
+ state: State,
26
+ node: ast.FunctionDef | ast.AsyncFunctionDef,
27
+ parents: tuple[ast.AST, ...],
28
+ ) -> Iterable[object]:
29
+ for decorator in node.decorator_list:
30
+ if _is_cache_page(state, decorator):
31
+ yield from hit(node)
32
+
33
+
34
+ @cache_page.register(ast.Call)
35
+ def visit_Call(
36
+ state: State, node: ast.Call, parents: tuple[ast.AST, ...]
37
+ ) -> Iterable[object]:
38
+ """`method_decorator(cache_page(60))` and `cache_page(60)(view)`.
39
+
40
+ Only counts calls that wrap or are passed to another call. Decorators are handled
41
+ above and would otherwise be counted twice.
42
+ """
43
+ if isinstance(node.func, ast.Call) and _is_cache_page(state, node.func):
44
+ yield from hit(node)
45
+ return
46
+ for arg in node.args:
47
+ if isinstance(arg, ast.Call) and _is_cache_page(state, arg):
48
+ yield from hit(node)
49
+ return
50
+
51
+
52
+ for _node_type in (ast.FunctionDef, ast.AsyncFunctionDef):
53
+ cache_page.register(_node_type)(_visit_def)
@@ -0,0 +1,91 @@
1
+ """Count QuerySet method usage.
2
+
3
+ Matched on method name, since there are no types to consult at parse time. Measured
4
+ against Django, Wagtail, django-oscar and djangopackages, about 98% of matches are
5
+ genuine ORM calls. ``register.filter(...)`` was the only systematic false positive,
6
+ which is why template ``Library`` instances are tracked below.
7
+
8
+ Do not change this to require a recognisable receiver such as ``.objects``. In real
9
+ applications up to a third of ORM calls are made on local variables and custom queryset
10
+ methods such as ``page.get_children().filter(...)``, so precision would gain about a
11
+ point while recall lost a third.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import ast
17
+ from collections.abc import Callable, Iterable
18
+ from weakref import WeakKeyDictionary
19
+
20
+ from django_probe.ast_probe import State
21
+ from django_probe.probes import Probe, dotted_name, hit
22
+
23
+ METHODS = ("extra", "filter", "exclude", "alias", "annotate")
24
+
25
+ LIBRARY_PATHS = frozenset({"Library", "template.Library", "django.template.Library"})
26
+
27
+ #: Only `filter` collides with the template-tag API; `register.exclude` is not a thing.
28
+ LIBRARY_METHODS = frozenset({"filter"})
29
+
30
+ #: Names bound to a template Library, per file.
31
+ _libraries: WeakKeyDictionary[State, set[str]] = WeakKeyDictionary()
32
+
33
+
34
+ def _note_library_assignment(state: State, node: ast.Assign) -> None:
35
+ if not isinstance(node.value, ast.Call):
36
+ return
37
+ if dotted_name(node.value) not in LIBRARY_PATHS:
38
+ return
39
+ names = _libraries.setdefault(state, set())
40
+ for target in node.targets:
41
+ if isinstance(target, ast.Name):
42
+ names.add(target.id)
43
+ elif isinstance(target, ast.Attribute):
44
+ # `self.library = Library()`, then `@self.library.filter`. Recording only
45
+ # the trailing name is coarse, but scoped to a file that builds a Library.
46
+ names.add(target.attr)
47
+
48
+
49
+ def _is_library_call(state: State, node: ast.Call, method: str) -> bool:
50
+ if method not in LIBRARY_METHODS:
51
+ return False
52
+ receiver = node.func.value if isinstance(node.func, ast.Attribute) else None
53
+ known = _libraries.get(state, set())
54
+ if isinstance(receiver, ast.Name):
55
+ return receiver.id in known
56
+ if isinstance(receiver, ast.Attribute):
57
+ return receiver.attr in known
58
+ return False
59
+
60
+
61
+ def _visitor(method: str) -> Callable[..., Iterable[object]]:
62
+ """Build a visitor bound to one method.
63
+
64
+ A single shared visitor would credit every match to all five probes.
65
+ """
66
+
67
+ def visit_Call(
68
+ state: State, node: ast.Call, parents: tuple[ast.AST, ...]
69
+ ) -> Iterable[object]:
70
+ if not isinstance(node.func, ast.Attribute) or node.func.attr != method:
71
+ return
72
+ if _is_library_call(state, node, method):
73
+ return
74
+ yield from hit(node)
75
+
76
+ return visit_Call
77
+
78
+
79
+ def _visit_Assign(
80
+ state: State, node: ast.Assign, parents: tuple[ast.AST, ...]
81
+ ) -> Iterable[object]:
82
+ # Registers the name and counts nothing. Assignments are visited before the code
83
+ # below them, so a later `register.filter` sees it.
84
+ _note_library_assignment(state, node)
85
+ return ()
86
+
87
+
88
+ for _method in METHODS:
89
+ _probe = Probe(f"queryset_{_method}")
90
+ _probe.register(ast.Call)(_visitor(_method))
91
+ _probe.register(ast.Assign)(_visit_Assign)
@@ -0,0 +1,28 @@
1
+ """Count @receiver-decorated signal handlers.
2
+
3
+ Signals are perennially debated and nobody has usage numbers.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import ast
9
+ from collections.abc import Iterable
10
+
11
+ from django_probe.ast_probe import State
12
+ from django_probe.probes import Probe, hit, resolves_to
13
+
14
+ signal_receiver = Probe("signal_receiver")
15
+
16
+
17
+ def _visit(
18
+ state: State,
19
+ node: ast.FunctionDef | ast.AsyncFunctionDef,
20
+ parents: tuple[ast.AST, ...],
21
+ ) -> Iterable[object]:
22
+ for decorator in node.decorator_list:
23
+ if resolves_to(state, decorator, "django.dispatch", "receiver"):
24
+ yield from hit(node)
25
+
26
+
27
+ for _node_type in (ast.FunctionDef, ast.AsyncFunctionDef):
28
+ signal_receiver.register(_node_type)(_visit)
@@ -0,0 +1,34 @@
1
+ """Count Django Tasks framework usage.
2
+
3
+ Django 6.0 shipped a built-in Tasks framework. How quickly it is adopted has no other
4
+ source of data, and unlike a deprecation the number never goes to zero because someone
5
+ ran a codemod.
6
+
7
+ Scoped to ``django.tasks``. Celery's ``@shared_task`` is a different question.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import ast
13
+ from collections.abc import Iterable
14
+
15
+ from django_probe.ast_probe import State
16
+ from django_probe.probes import Probe, hit, resolves_to
17
+
18
+ MODULE = "django.tasks"
19
+
20
+ django_task = Probe("django_task")
21
+
22
+
23
+ def _visit(
24
+ state: State,
25
+ node: ast.FunctionDef | ast.AsyncFunctionDef,
26
+ parents: tuple[ast.AST, ...],
27
+ ) -> Iterable[object]:
28
+ for decorator in node.decorator_list:
29
+ if resolves_to(state, decorator, MODULE, "task"):
30
+ yield from hit(node)
31
+
32
+
33
+ for _node_type in (ast.FunctionDef, ast.AsyncFunctionDef):
34
+ django_task.register(_node_type)(_visit)
@@ -0,0 +1,40 @@
1
+ """Count transaction.atomic usage, as a decorator or context manager."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ from collections.abc import Iterable
7
+
8
+ from django_probe.ast_probe import State
9
+ from django_probe.probes import Probe, hit, resolves_to
10
+
11
+ transaction_atomic = Probe("transaction_atomic")
12
+
13
+
14
+ def _is_atomic(state: State, node: ast.expr) -> bool:
15
+ return resolves_to(state, node, "django.db.transaction", "atomic")
16
+
17
+
18
+ def _visit_with(
19
+ state: State, node: ast.With | ast.AsyncWith, parents: tuple[ast.AST, ...]
20
+ ) -> Iterable[object]:
21
+ for item in node.items:
22
+ if _is_atomic(state, item.context_expr):
23
+ yield from hit(node)
24
+
25
+
26
+ def _visit_def(
27
+ state: State,
28
+ node: ast.FunctionDef | ast.AsyncFunctionDef,
29
+ parents: tuple[ast.AST, ...],
30
+ ) -> Iterable[object]:
31
+ for decorator in node.decorator_list:
32
+ if _is_atomic(state, decorator):
33
+ yield from hit(node)
34
+
35
+
36
+ for _with_type in (ast.With, ast.AsyncWith):
37
+ transaction_atomic.register(_with_type)(_visit_with)
38
+
39
+ for _def_type in (ast.FunctionDef, ast.AsyncFunctionDef):
40
+ transaction_atomic.register(_def_type)(_visit_def)
File without changes
@@ -0,0 +1,83 @@
1
+ """Walk a project directory and tally probe hits across its Python files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ import os
7
+ import warnings
8
+ from collections import Counter
9
+ from collections.abc import Iterator
10
+ from pathlib import Path
11
+
12
+ import django_probe.probes # noqa: F401 -- importing registers the probes
13
+ from django_probe.ast_probe import count_patterns
14
+
15
+ #: `migrations` is skipped deliberately: generated code would swamp the counts with
16
+ #: model classes and `.filter()` calls nobody wrote by hand.
17
+ SKIP_DIRS = frozenset(
18
+ {
19
+ ".git",
20
+ ".hg",
21
+ ".svn",
22
+ ".tox",
23
+ ".nox",
24
+ ".venv",
25
+ "venv",
26
+ ".mypy_cache",
27
+ ".pytest_cache",
28
+ ".ruff_cache",
29
+ "__pycache__",
30
+ "node_modules",
31
+ "site-packages",
32
+ "migrations",
33
+ "build",
34
+ "dist",
35
+ }
36
+ )
37
+
38
+
39
+ def ast_parse(contents_text: str) -> ast.Module:
40
+ # Real projects contain files with syntax warnings (e.g. invalid escape
41
+ # sequences); we can't do anything about them, so don't let them reach the user.
42
+ with warnings.catch_warnings():
43
+ warnings.simplefilter("ignore")
44
+ return ast.parse(contents_text.encode())
45
+
46
+
47
+ class ScanResult:
48
+ def __init__(
49
+ self, patterns: Counter[str], files_scanned: int, files_skipped: int
50
+ ) -> None:
51
+ self.patterns = patterns
52
+ self.files_scanned = files_scanned
53
+ self.files_skipped = files_skipped
54
+
55
+
56
+ def iter_python_files(root: Path) -> Iterator[Path]:
57
+ for dirpath, dirnames, filenames in os.walk(root):
58
+ dirnames[:] = [
59
+ d for d in dirnames if d not in SKIP_DIRS and not d.endswith(".egg-info")
60
+ ]
61
+ for filename in sorted(filenames):
62
+ if filename.endswith(".py"):
63
+ yield Path(dirpath) / filename
64
+
65
+
66
+ def scan_path(root: Path) -> ScanResult:
67
+ patterns: Counter[str] = Counter()
68
+ scanned = skipped = 0
69
+
70
+ for path in iter_python_files(root):
71
+ try:
72
+ tree = ast_parse(path.read_text(encoding="utf-8"))
73
+ except (OSError, UnicodeDecodeError, SyntaxError, ValueError):
74
+ # Real projects contain templates, fixtures and Python 2 leftovers.
75
+ skipped += 1
76
+ continue
77
+
78
+ # Relative path only. Filename heuristics need it, and it never leaves here.
79
+ rel = str(path.relative_to(root)) if path.is_relative_to(root) else path.name
80
+ patterns.update(count_patterns(tree, rel))
81
+ scanned += 1
82
+
83
+ return ScanResult(patterns, scanned, skipped)
@@ -0,0 +1,37 @@
1
+ """POST a payload to a Django Probe server, using only the standard library."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import urllib.error
7
+ import urllib.request
8
+ from typing import Any
9
+
10
+ ENDPOINT = "/api/submissions/"
11
+
12
+
13
+ class SubmitError(Exception):
14
+ pass
15
+
16
+
17
+ def submit(
18
+ payload: dict[str, Any], server_url: str, token: str | None = None
19
+ ) -> dict[str, Any]:
20
+ url = server_url.rstrip("/") + ENDPOINT
21
+ body = json.dumps(payload).encode("utf-8")
22
+
23
+ headers = {"Content-Type": "application/json"}
24
+ if token:
25
+ headers["Authorization"] = f"Token {token}"
26
+
27
+ request = urllib.request.Request(url, data=body, headers=headers, method="POST")
28
+
29
+ try:
30
+ with urllib.request.urlopen(request, timeout=30) as response:
31
+ result: dict[str, Any] = json.loads(response.read().decode("utf-8"))
32
+ return result
33
+ except urllib.error.HTTPError as exc:
34
+ detail = exc.read().decode("utf-8", errors="replace")
35
+ raise SubmitError(f"server returned {exc.code}: {detail}") from exc
36
+ except urllib.error.URLError as exc:
37
+ raise SubmitError(f"could not reach {url}: {exc.reason}") from exc