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.
- django_probe-0.1.0/.gitignore +12 -0
- django_probe-0.1.0/LICENSE +21 -0
- django_probe-0.1.0/PKG-INFO +60 -0
- django_probe-0.1.0/README.md +36 -0
- django_probe-0.1.0/pyproject.toml +169 -0
- django_probe-0.1.0/src/django_probe/__init__.py +11 -0
- django_probe-0.1.0/src/django_probe/ast_probe.py +122 -0
- django_probe-0.1.0/src/django_probe/collect.py +53 -0
- django_probe-0.1.0/src/django_probe/config.py +40 -0
- django_probe-0.1.0/src/django_probe/main.py +87 -0
- django_probe-0.1.0/src/django_probe/payload.py +32 -0
- django_probe-0.1.0/src/django_probe/probes/__init__.py +107 -0
- django_probe-0.1.0/src/django_probe/probes/auth.py +61 -0
- django_probe-0.1.0/src/django_probe/probes/caching.py +53 -0
- django_probe-0.1.0/src/django_probe/probes/orm.py +91 -0
- django_probe-0.1.0/src/django_probe/probes/signals.py +28 -0
- django_probe-0.1.0/src/django_probe/probes/tasks.py +34 -0
- django_probe-0.1.0/src/django_probe/probes/transactions.py +40 -0
- django_probe-0.1.0/src/django_probe/py.typed +0 -0
- django_probe-0.1.0/src/django_probe/scan.py +83 -0
- django_probe-0.1.0/src/django_probe/submit.py +37 -0
|
@@ -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
|