codeanalyzer-python 1.1.1__py3-none-any.whl → 1.3.0__py3-none-any.whl

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 (45) hide show
  1. codeanalyzer/__main__.py +119 -118
  2. codeanalyzer/artifacts/__init__.py +20 -0
  3. codeanalyzer/artifacts/config_keys.py +588 -0
  4. codeanalyzer/artifacts/config_use.py +597 -0
  5. codeanalyzer/artifacts/config_use_rules.yml +58 -0
  6. codeanalyzer/artifacts/dependencies.py +237 -0
  7. codeanalyzer/artifacts/discovery.py +167 -0
  8. codeanalyzer/artifacts/parsers.py +248 -0
  9. codeanalyzer/core.py +112 -45
  10. codeanalyzer/dataflow/access_paths.py +26 -4
  11. codeanalyzer/dataflow/builder.py +22 -1
  12. codeanalyzer/dataflow/identity.py +1 -1
  13. codeanalyzer/dataflow/pdg.py +7 -2
  14. codeanalyzer/dataflow/scc.py +1 -1
  15. codeanalyzer/entrypoints/__init__.py +3 -0
  16. codeanalyzer/entrypoints/detect.py +124 -0
  17. codeanalyzer/entrypoints/matching.py +182 -0
  18. codeanalyzer/entrypoints/pipeline.py +131 -0
  19. codeanalyzer/entrypoints/rules.py +159 -0
  20. codeanalyzer/entrypoints/rules.yml +88 -0
  21. codeanalyzer/neo4j/bolt.py +1 -1
  22. codeanalyzer/neo4j/project.py +277 -60
  23. codeanalyzer/neo4j/schema.py +92 -34
  24. codeanalyzer/options/__init__.py +2 -2
  25. codeanalyzer/options/options.py +7 -26
  26. codeanalyzer/schema/__init__.py +48 -0
  27. codeanalyzer/schema/ids.py +21 -0
  28. codeanalyzer/schema/l1_body.py +11 -1
  29. codeanalyzer/schema/l2_callees.py +29 -13
  30. codeanalyzer/schema/py_schema.py +213 -103
  31. codeanalyzer/semantic_analysis/call_graph.py +20 -4
  32. codeanalyzer/semantic_analysis/defuse_linker.py +1499 -0
  33. codeanalyzer/syntactic_analysis/symbol_table_builder.py +99 -3
  34. {codeanalyzer_python-1.1.1.dist-info → codeanalyzer_python-1.3.0.dist-info}/METADATA +143 -164
  35. {codeanalyzer_python-1.1.1.dist-info → codeanalyzer_python-1.3.0.dist-info}/RECORD +39 -31
  36. {codeanalyzer_python-1.1.1.dist-info → codeanalyzer_python-1.3.0.dist-info}/WHEEL +1 -1
  37. codeanalyzer/config/__init__.py +0 -3
  38. codeanalyzer/config/config.py +0 -8
  39. codeanalyzer/semantic_analysis/pycg/__init__.py +0 -20
  40. codeanalyzer/semantic_analysis/pycg/pycg_analysis.py +0 -1115
  41. codeanalyzer/semantic_analysis/pycg/pycg_exceptions.py +0 -23
  42. codeanalyzer/semantic_analysis/pycg/shard_planner.py +0 -401
  43. {codeanalyzer_python-1.1.1.dist-info → codeanalyzer_python-1.3.0.dist-info}/entry_points.txt +0 -0
  44. {codeanalyzer_python-1.1.1.dist-info → codeanalyzer_python-1.3.0.dist-info}/licenses/LICENSE +0 -0
  45. {codeanalyzer_python-1.1.1.dist-info → codeanalyzer_python-1.3.0.dist-info}/licenses/NOTICE +0 -0
codeanalyzer/__main__.py CHANGED
@@ -1,8 +1,9 @@
1
+ import json
1
2
  import os
2
3
  import sys
3
4
  from importlib.metadata import version as _pkg_version, PackageNotFoundError
4
5
  from pathlib import Path
5
- from typing import Optional, Annotated
6
+ from typing import List, Optional, Annotated
6
7
 
7
8
  import typer
8
9
 
@@ -10,7 +11,7 @@ import typer
10
11
  def _pin_hash_seed() -> None:
11
12
  """Re-exec once with ``PYTHONHASHSEED=0`` unless the caller pinned one.
12
13
 
13
- PyCG's capped fixpoint (``--pycg-max-iter``) iterates hash-ordered sets
14
+ Jedi's inference (and any hash-ordered iteration in the pipeline) walks sets
14
15
  keyed on module/access-path strings, so an unpinned per-interpreter hash
15
16
  seed makes the emitted L2+ call graph vary run to run (issue #99). The
16
17
  seed cannot be set after interpreter start, hence the exec. Export
@@ -37,9 +38,8 @@ def _pin_hash_seed() -> None:
37
38
 
38
39
  from codeanalyzer.core import Codeanalyzer
39
40
  from codeanalyzer.utils import _set_log_level, logger
40
- from codeanalyzer.config import OutputFormat
41
- from codeanalyzer.schema import model_dump_json
42
- from codeanalyzer.options import AnalysisOptions, EmitTarget, ShardStrategy
41
+ from codeanalyzer.schema import model_dump, model_dump_json, strip_internal_only
42
+ from codeanalyzer.options import AnalysisOptions, EmitTarget
43
43
 
44
44
 
45
45
  def _version_callback(value: bool) -> None:
@@ -80,15 +80,6 @@ def main(
80
80
  Optional[Path],
81
81
  typer.Option("-o", "--output", help="Output directory for artifacts."),
82
82
  ] = None,
83
- format: Annotated[
84
- OutputFormat,
85
- typer.Option(
86
- "-f",
87
- "--format",
88
- help="Output format for --emit json: json or msgpack.",
89
- case_sensitive=False,
90
- ),
91
- ] = OutputFormat.JSON,
92
83
  emit: Annotated[
93
84
  EmitTarget,
94
85
  typer.Option(
@@ -141,26 +132,31 @@ def main(
141
132
  ),
142
133
  ] = None,
143
134
  analysis_level: Annotated[
144
- int,
135
+ Optional[int],
145
136
  typer.Option(
146
137
  "-a",
147
138
  "--analysis-level",
148
- help="Analysis depth: 1=symbol table+Jedi call graph, 2=+PyCG call "
139
+ help="Analysis depth: 1=symbol table+Jedi call graph, 2=+defuse-linker call "
149
140
  "graph, 3=+native intraprocedural dataflow (CFG/PDG), "
150
- "4=+interprocedural SDG (param/summary edges, alias-aware DDG).",
141
+ "4=+interprocedural SDG (param/summary edges, alias-aware DDG). "
142
+ "[default: 1; incompatible with --emit neo4j, which is always "
143
+ "full-depth]",
151
144
  min=1,
152
145
  max=4,
146
+ show_default="1",
153
147
  ),
154
- ] = 1,
148
+ ] = None,
155
149
  graphs: Annotated[
156
- str,
150
+ Optional[str],
157
151
  typer.Option(
158
152
  "--graphs",
159
153
  help="Level 3+ only: comma-separated program-graph sections to emit "
160
154
  "(cfg, dfg, pdg, sdg). Default: cfg,dfg,pdg. `dfg` emits the PDG's data "
161
- "edges only; `sdg` requires -a 4.",
155
+ "edges only; `sdg` requires -a 4. Incompatible with --emit neo4j "
156
+ "(always full-depth).",
157
+ show_default="cfg,dfg,pdg",
162
158
  ),
163
- ] = "cfg,dfg,pdg",
159
+ ] = None,
164
160
  graph_field_depth: Annotated[
165
161
  int,
166
162
  typer.Option(
@@ -197,6 +193,14 @@ def main(
197
193
  "imports against the ambient Python environment instead.",
198
194
  ),
199
195
  ] = False,
196
+ resolve_installed: Annotated[
197
+ bool,
198
+ typer.Option(
199
+ "--resolve-installed",
200
+ help="Additionally bind imports via the project venv's installed metadata "
201
+ "(*.dist-info); output becomes machine-dependent (prov: installed-metadata).",
202
+ ),
203
+ ] = False,
200
204
  file_name: Annotated[
201
205
  Optional[Path],
202
206
  typer.Option(
@@ -222,87 +226,72 @@ def main(
222
226
  verbosity: Annotated[
223
227
  int, typer.Option("-v", count=True, help="Increase verbosity: -v, -vv, -vvv")
224
228
  ] = 0,
225
- pycg_shard: Annotated[
226
- bool,
227
- typer.Option(
228
- "--pycg-shard/--no-pycg-shard",
229
- help=(
230
- "Shard PyCG call-graph analysis by Python package (level 2 only). "
231
- "When the project exceeds the 500-file ceiling, PyCG is run "
232
- "independently per top-level package with cross-package imports "
233
- "treated as ghost nodes. Without this flag, projects over the "
234
- "ceiling fall back to Jedi-only edges."
235
- ),
236
- ),
237
- ] = False,
238
- pycg_shard_ceiling: Annotated[
239
- int,
240
- typer.Option(
241
- "--pycg-shard-ceiling",
242
- help=(
243
- "Maximum files per shard when --pycg-shard is active (default 100). "
244
- "Shards exceeding this limit are skipped; their call edges are "
245
- "omitted from the call graph (Jedi edges for those packages are "
246
- "still included). Lower values are safer for packages with deep "
247
- "class hierarchies or heavy import graphs."
248
- ),
249
- min=1,
250
- ),
251
- ] = 100,
252
- pycg_shard_timeout: Annotated[
253
- int,
229
+ entrypoint_rules: Annotated[
230
+ Optional[List[Path]],
254
231
  typer.Option(
255
- "--pycg-shard-timeout",
256
- help=(
257
- "Per-shard wall-clock timeout in seconds when --pycg-shard is "
258
- "active (default 120). A shard that exceeds this limit is skipped "
259
- "gracefully. PyCG's fixpoint is bimodal: it either converges "
260
- "quickly or diverges indefinitely, so the timeout acts as a final "
261
- "safety net after the file-count ceiling. Set to 0 to disable. "
262
- "POSIX only (macOS / Linux); ignored on Windows."
263
- ),
264
- min=0,
232
+ "--entrypoint-rules",
233
+ help="Extra entrypoint rules file (YAML). Repeatable; merges with "
234
+ "the shipped rules. A malformed file is an error.",
265
235
  ),
266
- ] = 120,
267
- pycg_shard_strategy: Annotated[
268
- ShardStrategy,
236
+ ] = None,
237
+ artifact_text: Annotated[
238
+ bool,
269
239
  typer.Option(
270
- "--pycg-shard-strategy",
271
- help=(
272
- "How --pycg-shard groups files (level 2 only). 'jedi' (default) "
273
- "partitions the Jedi module-dependency graph (SCC + Louvain) so "
274
- "tightly-coupled modules co-compute and few call edges are "
275
- "severed between shards; import cycles are never split. "
276
- "'package' uses the legacy one-shard-per-package-directory "
277
- "grouping."
278
- ),
240
+ "--artifact-text/--no-artifact-text",
241
+ help="Capture verbatim `source` text on discovered artifacts. "
242
+ "--no-artifact-text empties `source` everywhere (inventory unchanged).",
279
243
  ),
280
- ] = ShardStrategy.JEDI,
281
- pycg_max_iter: Annotated[
244
+ ] = True,
245
+ artifact_text_max_bytes: Annotated[
282
246
  int,
283
247
  typer.Option(
284
- "--pycg-max-iter",
285
- help=(
286
- "Cap on PyCG's fixpoint passes per shard/project (level 2; "
287
- "default 50). PyCG iterates until its points-to state stops "
288
- "changing, but its access-path domain has no convergence bound, "
289
- "so heavy metaclass/mixin code (e.g. an ORM) can loop with each "
290
- "pass costing seconds. The cap returns a sound-but-incomplete "
291
- "call graph instead of looping until the timeout kills it. "
292
- "Set to -1 for PyCG's unbounded run-to-convergence behaviour."
293
- ),
294
- min=-1,
248
+ "--artifact-text-max-bytes",
249
+ help="Per-file byte cap on captured artifact `source`; a decodable "
250
+ "file over the cap is truncated (text_truncated=True). "
251
+ "sha256/size_bytes always reflect the full file.",
252
+ min=1,
295
253
  ),
296
- ] = 50,
254
+ ] = 262144,
297
255
  ):
298
256
  # Determinism: pin the interpreter hash seed before any analysis (no-op
299
257
  # when PYTHONHASHSEED is already set; --version exits before this).
300
258
  _pin_hash_seed()
301
259
 
302
260
  # Flag validation (strict: unrecognized values error out, never fall back).
303
- selected_graphs = [g.strip() for g in graphs.split(",") if g.strip()]
261
+ # -a and --graphs use None sentinels so an explicitly-passed flag is
262
+ # distinguishable from the default (#119).
263
+ explicit_level = analysis_level is not None
264
+ explicit_graphs = graphs is not None
265
+
266
+ # Neo4j is always full-depth (#119): the graph carries every level's
267
+ # facts, so depth/section selectors cannot be combined with it — reject
268
+ # explicitly-passed flags and force level 4 with every graph section.
304
269
  from codeanalyzer.dataflow.builder import VALID_GRAPHS
305
270
 
271
+ if emit == EmitTarget.NEO4J:
272
+ explicit = [
273
+ flag
274
+ for flag, was_explicit in (
275
+ ("-a/--analysis-level", explicit_level),
276
+ ("--graphs", explicit_graphs),
277
+ )
278
+ if was_explicit
279
+ ]
280
+ if explicit:
281
+ logger.error(
282
+ "--emit neo4j is always full-depth (level 4, all graph "
283
+ f"sections); {' and '.join(explicit)} cannot be combined with it."
284
+ )
285
+ raise typer.Exit(code=2)
286
+ analysis_level = 4
287
+ graphs = ",".join(VALID_GRAPHS)
288
+
289
+ if analysis_level is None:
290
+ analysis_level = 1
291
+ if graphs is None:
292
+ graphs = "cfg,dfg,pdg"
293
+ selected_graphs = [g.strip() for g in graphs.split(",") if g.strip()]
294
+
306
295
  unknown_graphs = [g for g in selected_graphs if g not in VALID_GRAPHS]
307
296
  if unknown_graphs:
308
297
  logger.error(
@@ -316,7 +305,7 @@ def main(
316
305
  if "sdg" in selected_graphs and analysis_level < 4:
317
306
  logger.error("--graphs sdg requires -a 4 (interprocedural SDG).")
318
307
  raise typer.Exit(code=2)
319
- if analysis_level < 3 and graphs != "cfg,dfg,pdg":
308
+ if analysis_level < 3 and explicit_graphs:
320
309
  logger.error("--graphs is a level-3 option; pass -a 3 to emit program graphs.")
321
310
  raise typer.Exit(code=2)
322
311
  if analysis_level < 3 and graph_field_depth != 3:
@@ -326,7 +315,7 @@ def main(
326
315
  options = AnalysisOptions(
327
316
  input=input,
328
317
  output=output,
329
- format=format,
318
+
330
319
  emit=emit,
331
320
  app_name=app_name,
332
321
  neo4j_uri=neo4j_uri,
@@ -340,19 +329,32 @@ def main(
340
329
  rebuild_analysis=rebuild_analysis,
341
330
  skip_tests=skip_tests,
342
331
  no_venv=no_venv,
332
+ resolve_installed=resolve_installed,
343
333
  file_name=file_name,
344
334
  cache_dir=cache_dir,
345
335
  clear_cache=clear_cache,
346
336
  verbosity=verbosity,
347
- pycg_shard=pycg_shard,
348
- pycg_shard_ceiling=pycg_shard_ceiling,
349
- pycg_shard_timeout=pycg_shard_timeout,
350
- pycg_shard_strategy=pycg_shard_strategy,
351
- pycg_max_iter=pycg_max_iter,
337
+ entrypoint_rules=tuple(entrypoint_rules or ()),
338
+ artifact_text=artifact_text,
339
+ artifact_text_max_bytes=artifact_text_max_bytes,
352
340
  )
353
341
 
354
342
  _set_log_level(options.verbosity)
355
343
 
344
+ # Entrypoint rules are configuration, validated before any analysis work
345
+ # starts (#122 review) -- a typo must fail in milliseconds, not after the
346
+ # symbol table, venv build, Jedi and the defuse linker have all run. `detect_entrypoints`
347
+ # loads the rules again at its own call site; that second load is cheap
348
+ # and keeps the entrypoints pipeline self-contained.
349
+ if options.entrypoint_rules:
350
+ from codeanalyzer.entrypoints.rules import RulesError, load_rules
351
+
352
+ try:
353
+ load_rules(options.entrypoint_rules)
354
+ except RulesError as exc:
355
+ logger.error(f"Invalid --entrypoint-rules: {exc}")
356
+ raise typer.Exit(code=1)
357
+
356
358
  # The schema contract is a static artifact — no project analysis required.
357
359
  if options.emit == EmitTarget.SCHEMA:
358
360
  from codeanalyzer.neo4j.emit import emit_schema
@@ -392,37 +394,36 @@ def main(
392
394
 
393
395
  emit_neo4j(artifacts, options)
394
396
  elif options.output is None:
395
- print(model_dump_json(artifacts, exclude_none=True))
397
+ print(
398
+ json.dumps(
399
+ strip_internal_only(
400
+ model_dump(artifacts, mode="json", exclude_none=True)
401
+ )
402
+ )
403
+ )
396
404
  else:
397
405
  options.output.mkdir(parents=True, exist_ok=True)
398
- _write_output(artifacts, options.output, options.format)
399
-
400
-
401
- def _write_output(artifacts, output_dir: Path, format: OutputFormat):
402
- """Write artifacts to file in the specified format."""
403
- if format == OutputFormat.JSON:
404
- output_file = output_dir / "analysis.json"
405
- # Use Pydantic's model_dump_json() for compact output
406
- json_str = model_dump_json(artifacts, indent=None, exclude_none=True)
407
- with output_file.open("w") as f:
408
- f.write(json_str)
409
- logger.info(f"Analysis saved to {output_file}")
410
-
411
- elif format == OutputFormat.MSGPACK:
412
- output_file = output_dir / "analysis.msgpack"
413
- msgpack_data = artifacts.to_msgpack_bytes()
414
- with output_file.open("wb") as f:
415
- f.write(msgpack_data)
416
- logger.info(f"Analysis saved to {output_file}")
417
- logger.info(
418
- f"Compression ratio: {artifacts.get_compression_ratio():.1%} of JSON size"
419
- )
406
+ _write_output(artifacts, options.output)
407
+
408
+
409
+ def _write_output(artifacts, output_dir: Path):
410
+ """Write analysis.json (the single wire format since #118)."""
411
+ output_file = output_dir / "analysis.json"
412
+ # Use Pydantic's model_dump_json() for compact output
413
+ # Strip internal-only fields here rather than with a field-level Pydantic
414
+ # `exclude`: the analysis cache shares the serializer and must keep them.
415
+ json_str = json.dumps(
416
+ strip_internal_only(model_dump(artifacts, mode="json", exclude_none=True))
417
+ )
418
+ with output_file.open("w") as f:
419
+ f.write(json_str)
420
+ logger.info(f"Analysis saved to {output_file}")
420
421
 
421
422
 
422
423
  app = typer.Typer(
423
424
  callback=main,
424
425
  name="canpy",
425
- help="Static Analysis on Python source code using Jedi, PyCG and Tree sitter.",
426
+ help="Static Analysis on Python source code using Jedi and Tree sitter.",
426
427
  invoke_without_command=True,
427
428
  no_args_is_help=True,
428
429
  add_completion=False,
@@ -0,0 +1,20 @@
1
+ """Non-code artifact capture and dependency extraction (spec 2026-08-27).
2
+
3
+ Capture never drops a file (every non-`.py` file becomes a
4
+ :class:`~codeanalyzer.schema.py_schema.PyArtifact`, rule-matched or not,
5
+ text or binary -- issue #157 follow-up); extraction is narrow (only
6
+ dependency manifests are parsed for meaning in this unit)."""
7
+
8
+ from codeanalyzer.artifacts.config_keys import extract_config_keys, is_config_eligible
9
+ from codeanalyzer.artifacts.config_use import (
10
+ dataflow_intra_tier, dataflow_interproc_tier, detect_config_reads, resolve_uses,
11
+ )
12
+ from codeanalyzer.artifacts.dependencies import build_dependency_view
13
+ from codeanalyzer.artifacts.discovery import discover_artifacts
14
+
15
+ __all__ = [
16
+ "discover_artifacts", "build_dependency_view",
17
+ "extract_config_keys", "is_config_eligible",
18
+ "detect_config_reads", "resolve_uses",
19
+ "dataflow_intra_tier", "dataflow_interproc_tier",
20
+ ]