codeanalyzer-python 0.3.0__tar.gz → 0.3.2__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.
Files changed (57) hide show
  1. {codeanalyzer_python-0.3.0 → codeanalyzer_python-0.3.2}/.gitignore +3 -0
  2. {codeanalyzer_python-0.3.0 → codeanalyzer_python-0.3.2}/PKG-INFO +23 -25
  3. {codeanalyzer_python-0.3.0 → codeanalyzer_python-0.3.2}/README.md +21 -18
  4. {codeanalyzer_python-0.3.0 → codeanalyzer_python-0.3.2}/codeanalyzer/__main__.py +26 -0
  5. {codeanalyzer_python-0.3.0 → codeanalyzer_python-0.3.2}/codeanalyzer/core.py +34 -3
  6. {codeanalyzer_python-0.3.0 → codeanalyzer_python-0.3.2}/codeanalyzer/neo4j/__init__.py +1 -1
  7. {codeanalyzer_python-0.3.0 → codeanalyzer_python-0.3.2}/codeanalyzer/neo4j/emit.py +2 -2
  8. {codeanalyzer_python-0.3.0 → codeanalyzer_python-0.3.2}/codeanalyzer/neo4j/project.py +84 -18
  9. codeanalyzer_python-0.3.0/codeanalyzer/neo4j/catalog.py → codeanalyzer_python-0.3.2/codeanalyzer/neo4j/schema.py +58 -18
  10. codeanalyzer_python-0.3.2/codeanalyzer/provenance.py +61 -0
  11. {codeanalyzer_python-0.3.0 → codeanalyzer_python-0.3.2}/codeanalyzer/schema/py_schema.py +38 -0
  12. codeanalyzer_python-0.3.2/codeanalyzer/syntactic_analysis/import_resolver.py +67 -0
  13. {codeanalyzer_python-0.3.0 → codeanalyzer_python-0.3.2}/codeanalyzer/syntactic_analysis/symbol_table_builder.py +74 -10
  14. {codeanalyzer_python-0.3.0 → codeanalyzer_python-0.3.2}/pyproject.toml +1 -8
  15. {codeanalyzer_python-0.3.0 → codeanalyzer_python-0.3.2}/schema.neo4j.json +19 -10
  16. codeanalyzer_python-0.3.0/codeanalyzer/neo4j/schema.py +0 -39
  17. {codeanalyzer_python-0.3.0 → codeanalyzer_python-0.3.2}/LICENSE +0 -0
  18. {codeanalyzer_python-0.3.0 → codeanalyzer_python-0.3.2}/NOTICE +0 -0
  19. {codeanalyzer_python-0.3.0 → codeanalyzer_python-0.3.2}/codeanalyzer/__init__.py +0 -0
  20. {codeanalyzer_python-0.3.0 → codeanalyzer_python-0.3.2}/codeanalyzer/config/__init__.py +0 -0
  21. {codeanalyzer_python-0.3.0 → codeanalyzer_python-0.3.2}/codeanalyzer/config/config.py +0 -0
  22. {codeanalyzer_python-0.3.0 → codeanalyzer_python-0.3.2}/codeanalyzer/jedi/__init__.py +0 -0
  23. {codeanalyzer_python-0.3.0 → codeanalyzer_python-0.3.2}/codeanalyzer/jedi/jedi.py +0 -0
  24. {codeanalyzer_python-0.3.0 → codeanalyzer_python-0.3.2}/codeanalyzer/neo4j/bolt.py +0 -0
  25. {codeanalyzer_python-0.3.0 → codeanalyzer_python-0.3.2}/codeanalyzer/neo4j/cypher.py +0 -0
  26. {codeanalyzer_python-0.3.0 → codeanalyzer_python-0.3.2}/codeanalyzer/neo4j/rows.py +0 -0
  27. {codeanalyzer_python-0.3.0 → codeanalyzer_python-0.3.2}/codeanalyzer/options/__init__.py +0 -0
  28. {codeanalyzer_python-0.3.0 → codeanalyzer_python-0.3.2}/codeanalyzer/options/options.py +0 -0
  29. {codeanalyzer_python-0.3.0 → codeanalyzer_python-0.3.2}/codeanalyzer/py.typed +0 -0
  30. {codeanalyzer_python-0.3.0 → codeanalyzer_python-0.3.2}/codeanalyzer/schema/__init__.py +0 -0
  31. {codeanalyzer_python-0.3.0 → codeanalyzer_python-0.3.2}/codeanalyzer/semantic_analysis/__init__.py +0 -0
  32. {codeanalyzer_python-0.3.0 → codeanalyzer_python-0.3.2}/codeanalyzer/semantic_analysis/call_graph.py +0 -0
  33. {codeanalyzer_python-0.3.0 → codeanalyzer_python-0.3.2}/codeanalyzer/semantic_analysis/pycg/__init__.py +0 -0
  34. {codeanalyzer_python-0.3.0 → codeanalyzer_python-0.3.2}/codeanalyzer/semantic_analysis/pycg/pycg_analysis.py +0 -0
  35. {codeanalyzer_python-0.3.0 → codeanalyzer_python-0.3.2}/codeanalyzer/semantic_analysis/pycg/pycg_exceptions.py +0 -0
  36. {codeanalyzer_python-0.3.0 → codeanalyzer_python-0.3.2}/codeanalyzer/semantic_analysis/pycg/shard_planner.py +0 -0
  37. {codeanalyzer_python-0.3.0 → codeanalyzer_python-0.3.2}/codeanalyzer/syntactic_analysis/__init__.py +0 -0
  38. {codeanalyzer_python-0.3.0 → codeanalyzer_python-0.3.2}/codeanalyzer/syntactic_analysis/exceptions.py +0 -0
  39. {codeanalyzer_python-0.3.0 → codeanalyzer_python-0.3.2}/codeanalyzer/utils/__init__.py +0 -0
  40. {codeanalyzer_python-0.3.0 → codeanalyzer_python-0.3.2}/codeanalyzer/utils/logging.py +0 -0
  41. {codeanalyzer_python-0.3.0 → codeanalyzer_python-0.3.2}/codeanalyzer/utils/progress_bar.py +0 -0
  42. {codeanalyzer_python-0.3.0 → codeanalyzer_python-0.3.2}/schema-uml.drawio +0 -0
  43. {codeanalyzer_python-0.3.0 → codeanalyzer_python-0.3.2}/test/fixtures/whole_applications/flask/README.md +0 -0
  44. {codeanalyzer_python-0.3.0 → codeanalyzer_python-0.3.2}/test/fixtures/whole_applications/flask/examples/celery/README.md +0 -0
  45. {codeanalyzer_python-0.3.0 → codeanalyzer_python-0.3.2}/test/fixtures/whole_applications/flask/requirements-skip/README.md +0 -0
  46. {codeanalyzer_python-0.3.0 → codeanalyzer_python-0.3.2}/test/fixtures/whole_applications/flask/src/flask/sansio/README.md +0 -0
  47. {codeanalyzer_python-0.3.0 → codeanalyzer_python-0.3.2}/test/fixtures/whole_applications/requests/LICENSE +0 -0
  48. {codeanalyzer_python-0.3.0 → codeanalyzer_python-0.3.2}/test/fixtures/whole_applications/requests/NOTICE +0 -0
  49. {codeanalyzer_python-0.3.0 → codeanalyzer_python-0.3.2}/test/fixtures/whole_applications/requests/README.md +0 -0
  50. {codeanalyzer_python-0.3.0 → codeanalyzer_python-0.3.2}/test/fixtures/whole_applications/requests/docs/_themes/LICENSE +0 -0
  51. {codeanalyzer_python-0.3.0 → codeanalyzer_python-0.3.2}/test/fixtures/whole_applications/requests/ext/LICENSE +0 -0
  52. {codeanalyzer_python-0.3.0 → codeanalyzer_python-0.3.2}/test/fixtures/whole_applications/xarray/LICENSE +0 -0
  53. {codeanalyzer_python-0.3.0 → codeanalyzer_python-0.3.2}/test/fixtures/whole_applications/xarray/README.md +0 -0
  54. {codeanalyzer_python-0.3.0 → codeanalyzer_python-0.3.2}/test/fixtures/whole_applications/xarray/properties/README.md +0 -0
  55. {codeanalyzer_python-0.3.0 → codeanalyzer_python-0.3.2}/test/fixtures/whole_applications/xarray/xarray/datatree_/LICENSE +0 -0
  56. {codeanalyzer_python-0.3.0 → codeanalyzer_python-0.3.2}/test/fixtures/whole_applications/xarray/xarray/datatree_/README.md +0 -0
  57. {codeanalyzer_python-0.3.0 → codeanalyzer_python-0.3.2}/test/fixtures/whole_applications/xarray/xarray/datatree_/docs/README.md +0 -0
@@ -184,3 +184,6 @@ uv.lock
184
184
  # Node / Astro docs-site build artifacts (never commit these)
185
185
  node_modules/
186
186
  .astro/
187
+
188
+ # Track this repo's CLAUDE.md even though a global gitignore excludes CLAUDE.md
189
+ !CLAUDE.md
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: codeanalyzer-python
3
- Version: 0.3.0
3
+ Version: 0.3.2
4
4
  Summary: Static Analysis on Python source code using Jedi, CodeQL and Treesitter — emits analysis.json or a Neo4j property graph.
5
5
  Author-email: Rahul Krishna <i.m.ralk@gmail.com>
6
6
  License-File: LICENSE
@@ -12,12 +12,7 @@ Requires-Dist: msgpack<1.0.7,>=1.0.0; python_version < '3.11'
12
12
  Requires-Dist: msgpack<2.0.0,>=1.0.7; python_version >= '3.11'
13
13
  Requires-Dist: networkx<3.2.0,>=2.6.0; python_version < '3.11'
14
14
  Requires-Dist: networkx<4.0.0,>=3.0.0; python_version >= '3.11'
15
- Requires-Dist: numpy<1.24.0,>=1.21.0; python_version < '3.11'
16
- Requires-Dist: numpy<2.0.0,>=1.24.0; python_version >= '3.11' and python_version < '3.12'
17
- Requires-Dist: numpy<2.0.0,>=1.26.0; python_version >= '3.12'
18
15
  Requires-Dist: packaging>=25.0
19
- Requires-Dist: pandas<2.0.0,>=1.3.0; python_version < '3.11'
20
- Requires-Dist: pandas<3.0.0,>=2.0.0; python_version >= '3.11'
21
16
  Requires-Dist: pycg>=0.0.6
22
17
  Requires-Dist: pydantic<2.0.0,>=1.8.0; python_version < '3.11'
23
18
  Requires-Dist: pydantic<3.0.0,>=2.0.0; python_version >= '3.11'
@@ -181,19 +176,22 @@ $ canpy --help
181
176
  Static Analysis on Python source code using Jedi, PyCG and Tree sitter.
182
177
 
183
178
  ╭─ Options ────────────────────────────────────────────────────────────────────╮
184
- │ --input -i PATH Path to the
179
+ │ --version Show the canpy
180
+ │ version and │
181
+ │ exit. │
182
+ │ --input -i <path> Path to the │
185
183
  │ project root │
186
184
  │ directory (not │
187
185
  │ required for │
188
186
  │ --emit schema). │
189
- │ --output -o PATH Output directory │
187
+ │ --output -o <path> Output directory │
190
188
  │ for artifacts. │
191
- │ --format -f [json|msgpack] Output format │
189
+ │ --format -f <json|msgpack> Output format │
192
190
  │ for --emit json: │
193
191
  │ json or msgpack. │
194
192
  │ [default: json] │
195
- │ --emit [json|neo4j|sche Output target: │
196
- │ ma] json │
193
+ │ --emit <json|neo4j|sche Output target: │
194
+ │ ma> json │
197
195
  │ (analysis.json, │
198
196
  │ default) | neo4j │
199
197
  │ (graph.cypher or │
@@ -203,13 +201,13 @@ $ canpy --help
203
201
  │ schema.json │
204
202
  │ contract). │
205
203
  │ [default: json] │
206
- │ --app-name TEXT Logical │
204
+ │ --app-name <str> Logical │
207
205
  │ application name │
208
206
  │ for the graph │
209
207
  │ :PyApplication │
210
208
  │ anchor (default: │
211
209
  │ input dir name). │
212
- │ --neo4j-uri TEXT Push the graph │
210
+ │ --neo4j-uri <str> Push the graph │
213
211
  │ to a live Neo4j │
214
212
  │ over Bolt │
215
213
  │ (incremental); │
@@ -217,11 +215,11 @@ $ canpy --help
217
215
  │ graph.cypher. │
218
216
  │ [env var: │
219
217
  │ NEO4J_URI] │
220
- │ --neo4j-user TEXT Neo4j username. │
218
+ │ --neo4j-user <str> Neo4j username. │
221
219
  │ [env var: │
222
220
  │ NEO4J_USERNAME] │
223
221
  │ [default: neo4j] │
224
- │ --neo4j-password TEXT Neo4j password. │
222
+ │ --neo4j-password <str> Neo4j password. │
225
223
  │ Prefer the env │
226
224
  │ var over the │
227
225
  │ flag (the flag │
@@ -231,12 +229,12 @@ $ canpy --help
231
229
  │ [env var: │
232
230
  │ NEO4J_PASSWORD] │
233
231
  │ [default: neo4j] │
234
- │ --neo4j-database TEXT Neo4j database │
232
+ │ --neo4j-database <str> Neo4j database │
235
233
  │ name (default: │
236
234
  │ server default). │
237
235
  │ [env var: │
238
236
  │ NEO4J_DATABASE] │
239
- │ --analysis-level -a INTEGER RANGE Analysis depth: │
237
+ │ --analysis-level -a <int range> Analysis depth: │
240
238
  │ [1<=x<=2] 1=symbol │
241
239
  │ table+Jedi call │
242
240
  │ graph, 2=+PyCG │
@@ -266,12 +264,12 @@ $ canpy --help
266
264
  │ environment │
267
265
  │ instead. │
268
266
  │ [default: venv] │
269
- │ --file-name PATH Analyze only the │
267
+ │ --file-name <path> Analyze only the │
270
268
  │ specified file │
271
269
  │ (relative to │
272
270
  │ input │
273
271
  │ directory). │
274
- │ --cache-dir -c PATH Directory to │
272
+ │ --cache-dir -c <path> Directory to │
275
273
  │ store analysis │
276
274
  │ cache. Defaults │
277
275
  │ to │
@@ -285,7 +283,7 @@ $ canpy --help
285
283
  │ retained. │
286
284
  │ [default: │
287
285
  │ keep-cache] │
288
- │ -v INTEGER Increase │
286
+ │ -v <int> Increase │
289
287
  │ verbosity: -v, │
290
288
  │ -vv, -vvv │
291
289
  │ [default: 0] │
@@ -312,7 +310,7 @@ $ canpy --help
312
310
  │ Jedi-only edges. │
313
311
  │ [default: │
314
312
  │ no-pycg-shard] │
315
- │ --pycg-shard-cei… INTEGER RANGE Maximum files │
313
+ │ --pycg-shard-cei… <int range> Maximum files │
316
314
  │ [x>=1] per shard when │
317
315
  │ --pycg-shard is │
318
316
  │ active (default │
@@ -334,7 +332,7 @@ $ canpy --help
334
332
  │ heavy import │
335
333
  │ graphs. │
336
334
  │ [default: 100] │
337
- │ --pycg-shard-tim… INTEGER RANGE Per-shard │
335
+ │ --pycg-shard-tim… <int range> Per-shard │
338
336
  │ [x>=0] wall-clock │
339
337
  │ timeout in │
340
338
  │ seconds when │
@@ -362,7 +360,7 @@ $ canpy --help
362
360
  │ ignored on │
363
361
  │ Windows. │
364
362
  │ [default: 120] │
365
- │ --pycg-shard-str… [jedi|package] How --pycg-shard │
363
+ │ --pycg-shard-str… <jedi|package> How --pycg-shard │
366
364
  │ groups files │
367
365
  │ (level 2 only). │
368
366
  │ 'jedi' (default) │
@@ -384,7 +382,7 @@ $ canpy --help
384
382
  │ one-shard-per-p… │
385
383
  │ grouping. │
386
384
  │ [default: jedi] │
387
- │ --pycg-max-iter INTEGER RANGE Cap on PyCG's │
385
+ │ --pycg-max-iter <int range> Cap on PyCG's │
388
386
  │ [x>=-1] fixpoint passes │
389
387
  │ per │
390
388
  │ shard/project │
@@ -144,19 +144,22 @@ $ canpy --help
144
144
  Static Analysis on Python source code using Jedi, PyCG and Tree sitter.
145
145
 
146
146
  ╭─ Options ────────────────────────────────────────────────────────────────────╮
147
- │ --input -i PATH Path to the
147
+ │ --version Show the canpy
148
+ │ version and │
149
+ │ exit. │
150
+ │ --input -i <path> Path to the │
148
151
  │ project root │
149
152
  │ directory (not │
150
153
  │ required for │
151
154
  │ --emit schema). │
152
- │ --output -o PATH Output directory │
155
+ │ --output -o <path> Output directory │
153
156
  │ for artifacts. │
154
- │ --format -f [json|msgpack] Output format │
157
+ │ --format -f <json|msgpack> Output format │
155
158
  │ for --emit json: │
156
159
  │ json or msgpack. │
157
160
  │ [default: json] │
158
- │ --emit [json|neo4j|sche Output target: │
159
- │ ma] json │
161
+ │ --emit <json|neo4j|sche Output target: │
162
+ │ ma> json │
160
163
  │ (analysis.json, │
161
164
  │ default) | neo4j │
162
165
  │ (graph.cypher or │
@@ -166,13 +169,13 @@ $ canpy --help
166
169
  │ schema.json │
167
170
  │ contract). │
168
171
  │ [default: json] │
169
- │ --app-name TEXT Logical │
172
+ │ --app-name <str> Logical │
170
173
  │ application name │
171
174
  │ for the graph │
172
175
  │ :PyApplication │
173
176
  │ anchor (default: │
174
177
  │ input dir name). │
175
- │ --neo4j-uri TEXT Push the graph │
178
+ │ --neo4j-uri <str> Push the graph │
176
179
  │ to a live Neo4j │
177
180
  │ over Bolt │
178
181
  │ (incremental); │
@@ -180,11 +183,11 @@ $ canpy --help
180
183
  │ graph.cypher. │
181
184
  │ [env var: │
182
185
  │ NEO4J_URI] │
183
- │ --neo4j-user TEXT Neo4j username. │
186
+ │ --neo4j-user <str> Neo4j username. │
184
187
  │ [env var: │
185
188
  │ NEO4J_USERNAME] │
186
189
  │ [default: neo4j] │
187
- │ --neo4j-password TEXT Neo4j password. │
190
+ │ --neo4j-password <str> Neo4j password. │
188
191
  │ Prefer the env │
189
192
  │ var over the │
190
193
  │ flag (the flag │
@@ -194,12 +197,12 @@ $ canpy --help
194
197
  │ [env var: │
195
198
  │ NEO4J_PASSWORD] │
196
199
  │ [default: neo4j] │
197
- │ --neo4j-database TEXT Neo4j database │
200
+ │ --neo4j-database <str> Neo4j database │
198
201
  │ name (default: │
199
202
  │ server default). │
200
203
  │ [env var: │
201
204
  │ NEO4J_DATABASE] │
202
- │ --analysis-level -a INTEGER RANGE Analysis depth: │
205
+ │ --analysis-level -a <int range> Analysis depth: │
203
206
  │ [1<=x<=2] 1=symbol │
204
207
  │ table+Jedi call │
205
208
  │ graph, 2=+PyCG │
@@ -229,12 +232,12 @@ $ canpy --help
229
232
  │ environment │
230
233
  │ instead. │
231
234
  │ [default: venv] │
232
- │ --file-name PATH Analyze only the │
235
+ │ --file-name <path> Analyze only the │
233
236
  │ specified file │
234
237
  │ (relative to │
235
238
  │ input │
236
239
  │ directory). │
237
- │ --cache-dir -c PATH Directory to │
240
+ │ --cache-dir -c <path> Directory to │
238
241
  │ store analysis │
239
242
  │ cache. Defaults │
240
243
  │ to │
@@ -248,7 +251,7 @@ $ canpy --help
248
251
  │ retained. │
249
252
  │ [default: │
250
253
  │ keep-cache] │
251
- │ -v INTEGER Increase │
254
+ │ -v <int> Increase │
252
255
  │ verbosity: -v, │
253
256
  │ -vv, -vvv │
254
257
  │ [default: 0] │
@@ -275,7 +278,7 @@ $ canpy --help
275
278
  │ Jedi-only edges. │
276
279
  │ [default: │
277
280
  │ no-pycg-shard] │
278
- │ --pycg-shard-cei… INTEGER RANGE Maximum files │
281
+ │ --pycg-shard-cei… <int range> Maximum files │
279
282
  │ [x>=1] per shard when │
280
283
  │ --pycg-shard is │
281
284
  │ active (default │
@@ -297,7 +300,7 @@ $ canpy --help
297
300
  │ heavy import │
298
301
  │ graphs. │
299
302
  │ [default: 100] │
300
- │ --pycg-shard-tim… INTEGER RANGE Per-shard │
303
+ │ --pycg-shard-tim… <int range> Per-shard │
301
304
  │ [x>=0] wall-clock │
302
305
  │ timeout in │
303
306
  │ seconds when │
@@ -325,7 +328,7 @@ $ canpy --help
325
328
  │ ignored on │
326
329
  │ Windows. │
327
330
  │ [default: 120] │
328
- │ --pycg-shard-str… [jedi|package] How --pycg-shard │
331
+ │ --pycg-shard-str… <jedi|package> How --pycg-shard │
329
332
  │ groups files │
330
333
  │ (level 2 only). │
331
334
  │ 'jedi' (default) │
@@ -347,7 +350,7 @@ $ canpy --help
347
350
  │ one-shard-per-p… │
348
351
  │ grouping. │
349
352
  │ [default: jedi] │
350
- │ --pycg-max-iter INTEGER RANGE Cap on PyCG's │
353
+ │ --pycg-max-iter <int range> Cap on PyCG's │
351
354
  │ [x>=-1] fixpoint passes │
352
355
  │ per │
353
356
  │ shard/project │
@@ -1,3 +1,4 @@
1
+ from importlib.metadata import version as _pkg_version, PackageNotFoundError
1
2
  from pathlib import Path
2
3
  from typing import Optional, Annotated
3
4
 
@@ -10,7 +11,32 @@ from codeanalyzer.schema import model_dump_json
10
11
  from codeanalyzer.options import AnalysisOptions, EmitTarget, ShardStrategy
11
12
 
12
13
 
14
+ def _version_callback(value: bool) -> None:
15
+ """Print the installed ``codeanalyzer-python`` version and exit.
16
+
17
+ Eager so it fires before the rest of the CLI callback (no -i/--input
18
+ required). Reads the version from package metadata so it always reflects
19
+ what is actually installed rather than a hardcoded string."""
20
+ if not value:
21
+ return
22
+ try:
23
+ installed = _pkg_version("codeanalyzer-python")
24
+ except PackageNotFoundError:
25
+ installed = "unknown"
26
+ typer.echo(f"canpy {installed}")
27
+ raise typer.Exit()
28
+
29
+
13
30
  def main(
31
+ version: Annotated[
32
+ Optional[bool],
33
+ typer.Option(
34
+ "--version",
35
+ help="Show the canpy version and exit.",
36
+ callback=_version_callback,
37
+ is_eager=True,
38
+ ),
39
+ ] = None,
14
40
  input: Annotated[
15
41
  Optional[Path],
16
42
  typer.Option(
@@ -26,9 +26,11 @@ from codeanalyzer.semantic_analysis.call_graph import (
26
26
  )
27
27
  from codeanalyzer.semantic_analysis.pycg import PyCG, PyCGExceptions
28
28
  from codeanalyzer.syntactic_analysis.exceptions import SymbolTableBuilderRayError
29
+ from codeanalyzer.syntactic_analysis.import_resolver import resolve_imports
29
30
  from codeanalyzer.syntactic_analysis.symbol_table_builder import SymbolTableBuilder
30
31
  from codeanalyzer.utils import ProgressBar
31
32
  from codeanalyzer.options import AnalysisOptions
33
+ from codeanalyzer.provenance import analyzer_info, repository_info
32
34
 
33
35
  @ray.remote
34
36
  def _process_file_with_ray(py_file: Union[Path, str], project_dir: Union[Path, str], virtualenv: Union[Path, str, None]) -> Dict[str, PyModule]:
@@ -411,8 +413,8 @@ class Codeanalyzer:
411
413
  Uses caching to avoid re-analyzing unchanged files.
412
414
  """
413
415
  cache_file = self.cache_dir / "analysis_cache.json"
414
-
415
- # Try to load existing cached analysis
416
+
417
+ # Try to load existing cached analysis
416
418
  cached_pyapplication = None
417
419
  if not self.rebuild_analysis and cache_file.exists():
418
420
  try:
@@ -422,6 +424,12 @@ class Codeanalyzer:
422
424
  logger.warning(f"Failed to load cache: {e}. Rebuilding analysis.")
423
425
  cached_pyapplication = None
424
426
 
427
+ if cached_pyapplication is not None and not self._cache_analyzer_matches(
428
+ cached_pyapplication, analyzer_info(self.analysis_level).version
429
+ ):
430
+ logger.info("Analysis cache written by a different analyzer version; rebuilding.")
431
+ cached_pyapplication = None
432
+
425
433
  # Build symbol table from cached application if available (if no available, the build a new one)
426
434
  symbol_table = self._build_symbol_table(cached_pyapplication.symbol_table if cached_pyapplication else {})
427
435
 
@@ -454,12 +462,35 @@ class Codeanalyzer:
454
462
  .external_symbols(external_symbols)
455
463
  .build()
456
464
  )
457
-
465
+
466
+ # Every run re-resolves import spellings against the analyzed module
467
+ # set -- pure and cheap; cached modules from older caches default to
468
+ # resolved_module=None and get stamped here (issue #82).
469
+ resolve_imports(app, self.project_dir)
470
+
471
+ # Single choke point for provenance: every produced app (fresh symbol
472
+ # table or reused-from-cache) passes through here before being cached
473
+ # or returned, so analyzer/repository always reflect *this* run/checkout
474
+ # even when the symbol table itself came from the on-disk cache.
475
+ app.analyzer = analyzer_info(self.analysis_level)
476
+ app.repository = repository_info(self.project_dir)
477
+
458
478
  # Save to cache
459
479
  self._save_analysis_cache(app, cache_file)
460
480
 
461
481
  return app
462
482
 
483
+ @staticmethod
484
+ def _cache_analyzer_matches(cached_app: Optional[PyApplication], current_version: str) -> bool:
485
+ """A cache written by another analyzer version (or before versions were
486
+ recorded) may lack fields the current models populate — pydantic fills
487
+ silent defaults, which would masquerade as analyzed absence."""
488
+ return (
489
+ cached_app is not None
490
+ and cached_app.analyzer is not None
491
+ and cached_app.analyzer.version == current_version
492
+ )
493
+
463
494
  def _load_pyapplication_from_cache(self, cache_file: Path) -> PyApplication:
464
495
  """Load cached analysis from file.
465
496
 
@@ -19,7 +19,7 @@ plus the two writers (cypher snapshot / bolt incremental). Nothing here runs
19
19
  unless ``--emit neo4j`` (or ``--emit schema``) is selected.
20
20
  """
21
21
  from codeanalyzer.neo4j.bolt import BoltConfig, bolt_writer
22
- from codeanalyzer.neo4j.catalog import (
22
+ from codeanalyzer.neo4j.schema import (
23
23
  MARKER_LABELS,
24
24
  NODE_LABELS,
25
25
  REL_TYPES,
@@ -28,7 +28,7 @@ from pathlib import Path
28
28
  from typing import Optional
29
29
 
30
30
  from codeanalyzer.neo4j.bolt import BoltConfig, bolt_writer
31
- from codeanalyzer.neo4j.catalog import build_schema_document
31
+ from codeanalyzer.neo4j.schema import build_schema_document
32
32
  from codeanalyzer.neo4j.cypher import render_cypher
33
33
  from codeanalyzer.neo4j.project import project
34
34
  from codeanalyzer.options import AnalysisOptions
@@ -38,7 +38,7 @@ from codeanalyzer.utils import logger
38
38
 
39
39
  def emit_schema(output: Optional[Path]) -> None:
40
40
  """Emit the Neo4j schema contract (``schema.json``) — a static artifact derived
41
- from the in-repo catalog, independent of any analyzed project. With no
41
+ from the in-repo schema, independent of any analyzed project. With no
42
42
  ``output`` it prints to stdout."""
43
43
  doc = json.dumps(build_schema_document(), indent=2) + "\n"
44
44
  if output is None:
@@ -36,7 +36,7 @@ import json
36
36
  from pathlib import Path
37
37
  from typing import Any, List, Optional
38
38
 
39
- from codeanalyzer.neo4j.catalog import SCHEMA_VERSION
39
+ from codeanalyzer.neo4j.schema import SCHEMA_VERSION
40
40
  from codeanalyzer.neo4j.rows import GraphRows, NodeRef, Props, RowBuilder, prune
41
41
  from codeanalyzer.schema import (
42
42
  PyApplication,
@@ -53,10 +53,26 @@ from codeanalyzer.schema.py_schema import PyCallsite
53
53
  def project(app: PyApplication, app_name: str) -> GraphRows:
54
54
  b = RowBuilder()
55
55
 
56
- app_ref = b.node(["PyApplication"], "name", app_name, {"schema_version": SCHEMA_VERSION})
56
+ app_ref = b.node(
57
+ ["PyApplication"],
58
+ "name",
59
+ app_name,
60
+ prune(
61
+ {
62
+ "schema_version": SCHEMA_VERSION,
63
+ "analyzer_name": app.analyzer.name if app.analyzer else None,
64
+ "analyzer_version": app.analyzer.version if app.analyzer else None,
65
+ "repo_uri": app.repository.uri if app.repository else None,
66
+ "source_revision": app.repository.revision if app.repository else None,
67
+ "repo_dirty": app.repository.dirty if app.repository else None,
68
+ }
69
+ ),
70
+ )
57
71
 
58
72
  for file_key, mod in app.symbol_table.items():
59
- mod_ref = b.node(["PyModule"], "file_key", file_key, _module_props(mod, file_key))
73
+ mod_ref = b.node(
74
+ ["PyModule"], "file_key", file_key, _module_props(mod, file_key)
75
+ )
60
76
  b.edge("PY_HAS_MODULE", app_ref, mod_ref)
61
77
  _project_module_body(b, file_key, mod_ref, mod)
62
78
 
@@ -66,7 +82,9 @@ def project(app: PyApplication, app_name: str) -> GraphRows:
66
82
  for e in app.call_graph:
67
83
  src = _call_endpoint(b, e.source, externals)
68
84
  tgt = _call_endpoint(b, e.target, externals)
69
- b.edge("PY_CALLS", src, tgt, _call_edge_props(e.weight, list(e.provenance or [])))
85
+ b.edge(
86
+ "PY_CALLS", src, tgt, _call_edge_props(e.weight, list(e.provenance or []))
87
+ )
70
88
 
71
89
  return b.finish()
72
90
 
@@ -86,9 +104,18 @@ def _call_endpoint(b: RowBuilder, signature: str, externals: dict) -> NodeRef:
86
104
  ext = externals.get(signature)
87
105
  if ext is None and b.has_key("PySymbol", signature):
88
106
  return _sym(signature)
89
- name = ext.name if ext is not None else (signature.rsplit(".", 1)[-1] if "." in signature else signature)
107
+ name = (
108
+ ext.name
109
+ if ext is not None
110
+ else (signature.rsplit(".", 1)[-1] if "." in signature else signature)
111
+ )
90
112
  module = ext.module if ext is not None else None
91
- return b.node(["PySymbol", "PyExternal"], "signature", signature, prune({"name": name, "module": module}))
113
+ return b.node(
114
+ ["PySymbol", "PyExternal"],
115
+ "signature",
116
+ signature,
117
+ prune({"name": name, "module": module}),
118
+ )
92
119
 
93
120
 
94
121
  # ----------------------------------------------------------------------------------------------
@@ -96,7 +123,9 @@ def _call_endpoint(b: RowBuilder, signature: str, externals: dict) -> NodeRef:
96
123
  # ----------------------------------------------------------------------------------------------
97
124
 
98
125
 
99
- def _project_module_body(b: RowBuilder, file_key: str, mod_ref: NodeRef, mod: PyModule) -> None:
126
+ def _project_module_body(
127
+ b: RowBuilder, file_key: str, mod_ref: NodeRef, mod: PyModule
128
+ ) -> None:
100
129
  for fn in (mod.functions or {}).values():
101
130
  _project_callable(b, file_key, mod_ref, "PY_DECLARES", fn)
102
131
  for cl in (mod.classes or {}).values():
@@ -107,25 +136,47 @@ def _project_module_body(b: RowBuilder, file_key: str, mod_ref: NodeRef, mod: Py
107
136
 
108
137
 
109
138
  def _project_imports(b: RowBuilder, mod_ref: NodeRef, mod: PyModule) -> None:
110
- # Per-target-module aggregation: collapse all bindings for a given imported
111
- # module into one PY_IMPORTS edge to a shared :PyPackage node.
139
+ # At most one PY_IMPORTS edge per (module, target) pair -- mirrors PY_CALLS,
140
+ # which pre-aggregates for the same reason: both writers MERGE edges on
141
+ # (type, from, to) and SET their props, so a second row for the same pair
142
+ # would silently overwrite the first in Neo4j instead of adding an edge.
143
+ # Buckets key on the edge's target identity (the resolved module, or the
144
+ # spelling itself for unresolved/external imports), so the SAME target
145
+ # imported under different spellings (``from pkg import util``,
146
+ # ``from . import util as u``, ``from .util import helper``) collapses
147
+ # onto one edge; the raw spellings ride along as a ``spellings`` array.
148
+ # Resolved internal imports point at the real :PyModule; externals keep
149
+ # the shared :PyPackage. Unresolved *relative* spellings (".", ".foo")
150
+ # name no package -- they are dropped from the graph (the spelling
151
+ # survives in analysis.json), instead of minting bogus
152
+ # :PyPackage{name: "."} nodes.
112
153
  agg: dict = {}
113
154
  for im in mod.imports or []:
114
155
  if not im.module:
115
- continue # relative `from . import x` — no resolvable package
116
- a = agg.setdefault(im.module, {"names": set(), "aliases": set()})
156
+ continue
157
+ if im.resolved_module is None and im.module.startswith("."):
158
+ continue
159
+ key = im.resolved_module or im.module
160
+ a = agg.setdefault(
161
+ key, {"spellings": set(), "names": set(), "aliases": set(), "resolved": im.resolved_module}
162
+ )
163
+ a["spellings"].add(im.module)
117
164
  if im.name:
118
165
  a["names"].add(im.name)
119
166
  if im.alias:
120
167
  a["aliases"].add(im.alias)
121
- for module_name, a in agg.items():
122
- pkg = b.node(["PyPackage"], "name", module_name, {})
168
+ for key, a in agg.items():
169
+ if a["resolved"] is not None:
170
+ target = NodeRef("PyModule", "file_key", a["resolved"])
171
+ else:
172
+ target = b.node(["PyPackage"], "name", key, {})
123
173
  b.edge(
124
174
  "PY_IMPORTS",
125
175
  mod_ref,
126
- pkg,
176
+ target,
127
177
  prune(
128
178
  {
179
+ "spellings": sorted(a["spellings"]),
129
180
  "imported_names": sorted(a["names"]) or None,
130
181
  "aliases": sorted(a["aliases"]) or None,
131
182
  }
@@ -141,7 +192,9 @@ def _project_imports(b: RowBuilder, mod_ref: NodeRef, mod: PyModule) -> None:
141
192
  def _project_class(
142
193
  b: RowBuilder, file_key: str, parent: NodeRef, parent_rel: str, cl: PyClass
143
194
  ) -> None:
144
- ref = b.node(["PySymbol", "PyClass"], "signature", cl.signature, _class_props(cl, file_key))
195
+ ref = b.node(
196
+ ["PySymbol", "PyClass"], "signature", cl.signature, _class_props(cl, file_key)
197
+ )
145
198
  b.edge(parent_rel, parent, ref)
146
199
 
147
200
  for base in cl.base_classes or []:
@@ -158,7 +211,12 @@ def _project_class(
158
211
  def _project_callable(
159
212
  b: RowBuilder, file_key: str, owner: NodeRef, owner_rel: str, c: PyCallable
160
213
  ) -> None:
161
- ref = b.node(["PySymbol", "PyCallable"], "signature", c.signature, _callable_props(c, file_key))
214
+ ref = b.node(
215
+ ["PySymbol", "PyCallable"],
216
+ "signature",
217
+ c.signature,
218
+ _callable_props(c, file_key),
219
+ )
162
220
  b.edge(owner_rel, owner, ref)
163
221
 
164
222
  for d in c.decorators or []:
@@ -166,7 +224,9 @@ def _project_callable(
166
224
 
167
225
  for s in c.call_sites or []:
168
226
  # Key off the relative file (a call site lives in its callable's file) so ids stay portable.
169
- cs_id = f"{file_key}#{s.start_line}:{s.start_column}-{s.end_line}:{s.end_column}"
227
+ cs_id = (
228
+ f"{file_key}#{s.start_line}:{s.start_column}-{s.end_line}:{s.end_column}"
229
+ )
170
230
  cs = b.node(["PyCallSite"], "id", cs_id, _call_site_props(s, file_key))
171
231
  b.edge("PY_HAS_CALLSITE", ref, cs)
172
232
  if s.callee_signature:
@@ -189,7 +249,11 @@ def _project_attribute(
189
249
 
190
250
 
191
251
  def _project_variable(
192
- b: RowBuilder, file_key: str, owner: NodeRef, owner_id: str, v: PyVariableDeclaration
252
+ b: RowBuilder,
253
+ file_key: str,
254
+ owner: NodeRef,
255
+ owner_id: str,
256
+ v: PyVariableDeclaration,
193
257
  ) -> None:
194
258
  var_id = f"{owner_id}#{v.name}@{v.start_line}"
195
259
  ref = b.node(["PyVariable"], "id", var_id, _variable_props(v, var_id, file_key))
@@ -258,6 +322,7 @@ def _attribute_props(a: PyClassAttribute, attr_id: str, file_key: str) -> Props:
258
322
  "id": attr_id,
259
323
  "name": a.name,
260
324
  "type": a.type,
325
+ "initializer": a.initializer,
261
326
  "docstring": _docstring_of(a.comments),
262
327
  "start_line": a.start_line,
263
328
  "end_line": a.end_line,
@@ -290,6 +355,7 @@ def _call_site_props(s: PyCallsite, file_key: str) -> Props:
290
355
  "receiver_expr": s.receiver_expr,
291
356
  "receiver_type": s.receiver_type,
292
357
  "argument_types": list(s.argument_types or []),
358
+ "arguments_json": _stringify_if(s.arguments),
293
359
  "return_type": s.return_type,
294
360
  "callee_signature": s.callee_signature,
295
361
  "is_constructor_call": s.is_constructor_call,