python-code-quality 0.1.16__py3-none-any.whl → 0.2.2__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.
py_cq/cli.py CHANGED
@@ -10,26 +10,24 @@ analysis.
10
10
  Helper functions such as `format_as_table` convert the aggregated tool
11
11
  results into a Rich Table for convenient console display.
12
12
  """
13
- import copy
13
+
14
14
  import io
15
15
  import json
16
16
  import logging
17
+ import time
17
18
  import tomllib
18
19
  from enum import Enum
19
- from importlib import import_module
20
20
  from importlib.metadata import requires, version
21
21
  from pathlib import Path
22
22
 
23
+ import tomlkit
23
24
  import typer
24
25
  from rich.console import Console
25
26
  from rich.logging import RichHandler
26
27
  from rich.table import Table
27
28
 
28
- from py_cq.config import load_user_config
29
- from py_cq.execution_engine import _cache as tool_cache
30
- from py_cq.execution_engine import run_tools
29
+ from py_cq.api import CQ, _apply_user_config
31
30
  from py_cq.language_detector import detect_language
32
- from py_cq.localtypes import ToolConfig
33
31
  from py_cq.metric_aggregator import aggregate_metrics
34
32
  from py_cq.table_formatter import format_as_table
35
33
  from py_cq.tool_registry import tool_registry
@@ -45,60 +43,27 @@ app = typer.Typer(
45
43
  epilog=(
46
44
  "Examples:\n\n"
47
45
  " cq check . # full table with all metrics (default)\n\n"
48
- " cq check . -o llm # top defect as markdown (primary LLM workflow)\n\n"
49
- " cq check . -o score # numeric score only\n\n"
50
- " cq check . -o json # parsed metrics as json\n\n"
51
- " cq check . -o raw # unprocessed tool output as json\n\n"
52
- " cq config . # show effective tool configuration"
46
+ " cq check . -o llm # top defect as markdown (primary LLM workflow)\n\n"
47
+ " cq check . -o llm-json # top defect as JSON with fingerprint for automation\n\n"
48
+ " cq check . -o score # numeric score only\n\n"
49
+ " cq check . -o json # parsed metrics as json\n\n"
50
+ " cq check . -o raw # unprocessed tool output as json\n\n"
51
+ " cq config # show effective tool configuration\n"
52
+ " cq config --path . # show configuration for current project\n\n"
53
+ " cq config set radon-hal --warning 0.45 --error 0.25 # set thresholds\n\n"
54
+ " cq config set radon-hal --error 0.25 --path . # set with path"
53
55
  ),
54
56
  )
55
57
 
56
58
 
57
- def _apply_user_config(base: dict[str, ToolConfig], user_cfg: dict) -> dict[str, ToolConfig]:
58
- """Return a modified copy of base with user overrides applied.
59
-
60
- Supports:
61
- - ``disable``: list of tool IDs to remove
62
- - ``thresholds.<tool_id>.warning`` / ``.error``: override per-tool thresholds
63
- - ``tools.<tool_id>``: declare new tools (or override built-ins)
64
- """
65
- registry = {k: copy.copy(v) for k, v in base.items()}
66
- for tool_id in user_cfg.get("disable", []):
67
- registry.pop(tool_id, None)
68
- for tool_id, thresholds in user_cfg.get("thresholds", {}).items():
69
- if tool_id in registry:
70
- if "warning" in thresholds:
71
- registry[tool_id].warning_threshold = float(thresholds["warning"])
72
- if "error" in thresholds:
73
- registry[tool_id].error_threshold = float(thresholds["error"])
74
- for tool_id, tool_data in user_cfg.get("tools", {}).items():
75
- try:
76
- parser_name = tool_data["parser"]
77
- module = import_module(f"py_cq.parsers.{parser_name.lower()}")
78
- parser_class = getattr(module, parser_name)
79
- registry[tool_id] = ToolConfig(
80
- name=tool_id,
81
- command=tool_data["command"],
82
- parser_class=parser_class,
83
- order=tool_data["order"],
84
- warning_threshold=tool_data["warning_threshold"],
85
- error_threshold=tool_data["error_threshold"],
86
- run_in_target_env=tool_data.get("run_in_target_env", False),
87
- extra_deps=tool_data.get("extra_deps", []),
88
- parser_config=tool_data.get("parser_config", {}),
89
- exclude_format=tool_data.get("exclude_format", ""),
90
- )
91
- except KeyError as e:
92
- raise typer.BadParameter(f"[tool.cq.tools.{tool_id}] missing required field {e}")
93
- return registry
94
-
95
-
96
59
  class OutputMode(str, Enum):
97
60
  """Enum of output types."""
61
+
98
62
  TABLE = "table"
99
63
  SCORE = "score"
100
64
  JSON = "json"
101
65
  LLM = "llm"
66
+ LLM_JSON = "llm-json"
102
67
  RAW = "raw"
103
68
 
104
69
 
@@ -106,13 +71,11 @@ def _version_callback(value: bool) -> None:
106
71
  if not value:
107
72
  return
108
73
  import re
109
- import sys
110
- if isinstance(sys.stdout, io.TextIOWrapper): # pragma: no branch
111
- sys.stdout.reconfigure(encoding="utf-8")
74
+
112
75
  pkg = "python-code-quality"
113
76
  pkg_version = version(pkg)
114
77
  dep_versions: list[tuple[str, str]] = []
115
- for req in (requires(pkg) or []):
78
+ for req in requires(pkg) or []:
116
79
  if "; extra ==" in req:
117
80
  continue
118
81
  dep_name = re.split(r"[>=<!;\s\[]", req)[0]
@@ -122,17 +85,28 @@ def _version_callback(value: bool) -> None:
122
85
  pass
123
86
  typer.echo(f"{pkg} v{pkg_version}")
124
87
  for dep_name, dep_ver in sorted(dep_versions):
125
- typer.echo(f"\u251c\u2500\u2500 {dep_name} v{dep_ver}")
88
+ typer.echo(f"+-- {dep_name} v{dep_ver}")
126
89
  raise typer.Exit()
127
90
 
128
91
 
129
92
  @app.callback()
130
93
  def callback(
131
94
  _: bool = typer.Option(
132
- False, "--version", "-V", callback=_version_callback, is_eager=True, help="Show version and dependencies"
95
+ False,
96
+ "--version",
97
+ "-V",
98
+ callback=_version_callback,
99
+ is_eager=True,
100
+ help="Show version and dependencies",
133
101
  ),
134
102
  ) -> None:
135
103
  """Feed the results from 11+ code quality tools to an LLM. Try: cq check . -o llm"""
104
+ import sys
105
+
106
+ if isinstance(sys.stdout, io.TextIOWrapper):
107
+ sys.stdout.reconfigure(encoding="utf-8")
108
+
109
+
136
110
  console = Console()
137
111
 
138
112
 
@@ -140,7 +114,10 @@ console = Console()
140
114
  def check(
141
115
  path: str = typer.Argument(".", help="Path to Python file or project directory"),
142
116
  output: OutputMode = typer.Option(
143
- OutputMode.TABLE, "--output", "-o", help="Output mode: table (default), score, json, llm"
117
+ OutputMode.TABLE,
118
+ "--output",
119
+ "-o",
120
+ help="Output mode: table (default), score, json, llm",
144
121
  ),
145
122
  log_level: str = typer.Option(
146
123
  "CRITICAL",
@@ -151,10 +128,15 @@ def check(
151
128
  False, "--clear-cache", help="Clear cached tool results before running"
152
129
  ),
153
130
  workers: int = typer.Option(
154
- 0, "--workers", help="Max parallel workers (default: one per tool, use 1 for sequential)"
131
+ 0,
132
+ "--workers",
133
+ help="Max parallel workers (default: one per tool, use 1 for sequential)",
155
134
  ),
156
135
  language: str | None = typer.Option(
157
- None, "--language", "-l", help="Override language detection (e.g. python, typescript, rust)"
136
+ None,
137
+ "--language",
138
+ "-l",
139
+ help="Override language detection (e.g. python, typescript, rust)",
158
140
  ),
159
141
  only: str | None = typer.Option(
160
142
  None, "--only", help="Comma-separated tool IDs to run (e.g. ruff,ty,pytest)"
@@ -165,8 +147,20 @@ def check(
165
147
  exclude: str | None = typer.Option(
166
148
  None, "--exclude", help="Comma-separated paths to exclude (e.g. demo,docs)"
167
149
  ),
150
+ hint: bool = typer.Option(
151
+ False, "--hint", help="Append 'run cq again to verify' to -o llm output"
152
+ ),
153
+ limit: int = typer.Option(
154
+ 1, "--limit", help="Number of issues to show with -o llm (default: 1)"
155
+ ),
156
+ silence: list[str] = typer.Option(
157
+ [],
158
+ "--silence",
159
+ "-s",
160
+ help="Silence issues from -o llm output (e.g. -s src/foo.py or -s src/foo.py:42:E501)",
161
+ ),
168
162
  ):
169
- """Feed the results from 11+ code quality tools to an LLM. Try: cq check . -o llm""" # --help
163
+ """Feed the results from 11+ code quality tools to an LLM. Try: cq check . -o llm""" # --help
170
164
  path_obj = Path(path)
171
165
  if not path_obj.exists():
172
166
  raise typer.BadParameter(f"Path does not exist: {path}")
@@ -180,9 +174,6 @@ def check(
180
174
  )
181
175
  raise typer.Exit(0)
182
176
 
183
- # Python path (or unknown — fall through to existing validation).
184
- # Note: --language python still requires pyproject.toml; the flag selects
185
- # the tool set, not the input validation rules.
186
177
  if path_obj.is_file():
187
178
  if path_obj.suffix != ".py":
188
179
  raise typer.BadParameter(f"File must be a Python file (.py): {path}")
@@ -190,39 +181,69 @@ def check(
190
181
  if not (path_obj / "pyproject.toml").exists():
191
182
  raise typer.BadParameter(f"Directory must contain pyproject.toml: {path}")
192
183
  log.setLevel(log_level)
193
- user_cfg = load_user_config(path_obj)
194
- context_lines: int = int(user_cfg.get("context_lines", 15))
195
- effective_registry = _apply_user_config(tool_registry, user_cfg)
196
- if only:
197
- keep = set(only.split(","))
198
- effective_registry = {k: v for k, v in effective_registry.items() if k in keep}
199
- if skip:
200
- drop = set(skip.split(","))
201
- effective_registry = {k: v for k, v in effective_registry.items() if k not in drop}
202
- config_excludes: list[str] = user_cfg.get("exclude", [])
203
- cli_excludes: list[str] = [e.strip() for e in exclude.split(",")] if exclude else []
204
- excludes = list(dict.fromkeys(config_excludes + cli_excludes))
205
- if clear_cache:
206
- tool_cache.clear()
207
- tool_results = run_tools(effective_registry.values(), path, workers, early_exit=(output == OutputMode.LLM), excludes=excludes)
208
- # for tr in tool_results:
209
- # log.debug(json.dumps(tr.to_dict(), indent=2))
210
- combined_metrics = aggregate_metrics(path=path, metrics=tool_results)
184
+
185
+ only_list = [t.strip() for t in only.split(",")] if only else None
186
+ skip_list = [t.strip() for t in skip.split(",")] if skip else None
187
+ exclude_list = [e.strip() for e in exclude.split(",")] if exclude else None
188
+
189
+ try:
190
+ cq = CQ(
191
+ path_obj,
192
+ only=only_list,
193
+ skip=skip_list,
194
+ exclude=exclude_list,
195
+ workers=workers,
196
+ clear_cache=clear_cache,
197
+ )
198
+ except ValueError as e:
199
+ raise typer.BadParameter(str(e))
200
+
201
+ is_llm = output in (OutputMode.LLM, OutputMode.LLM_JSON)
202
+ t0 = time.perf_counter()
203
+ tool_results = cq.raw(early_exit=is_llm)
204
+ total_s = time.perf_counter() - t0
205
+ combined = aggregate_metrics(path, tool_results)
206
+
211
207
  if output == OutputMode.SCORE:
212
- console.print(combined_metrics.score)
208
+ console.print(combined.score)
213
209
  elif output == OutputMode.JSON:
214
210
  print(json.dumps([tr.to_dict() for tr in tool_results], indent=2))
215
211
  elif output == OutputMode.RAW:
216
212
  print(json.dumps([tr.raw.to_dict() for tr in tool_results], indent=2))
217
213
  elif output == OutputMode.LLM:
218
- # log.setLevel("CRITICAL")
219
214
  from py_cq.llm_formatter import format_for_llm
220
- print(format_for_llm(effective_registry, combined_metrics, context_lines=context_lines))
215
+
216
+ print(
217
+ format_for_llm(
218
+ cq._registry,
219
+ combined,
220
+ context_lines=cq._context_lines,
221
+ hint=hint,
222
+ limit=limit,
223
+ silence=silence,
224
+ )
225
+ )
226
+ elif output == OutputMode.LLM_JSON:
227
+ from py_cq.llm_formatter import format_for_llm_json
228
+
229
+ print(
230
+ json.dumps(
231
+ format_for_llm_json(
232
+ cq._registry,
233
+ combined,
234
+ context_lines=cq._context_lines,
235
+ hint=hint,
236
+ limit=limit,
237
+ silence=silence,
238
+ project_root=cq._project_root,
239
+ )
240
+ )
241
+ )
221
242
  else:
222
243
  console.print(f"[bold green]{path_obj.resolve()}[/]")
223
- console.print(format_as_table(combined_metrics, effective_registry))
244
+ console.print(format_as_table(combined, cq._registry, total_s=total_s))
224
245
 
225
- tool_by_name = {tc.name: tc for tc in effective_registry.values()}
246
+ tool_by_name = {tc.name: tc for tc in cq._registry.values()}
226
247
  if any(
227
248
  min(tr.metrics.values()) < tool_by_name[tr.raw.tool_name].error_threshold
228
249
  for tr in tool_results
@@ -231,11 +252,20 @@ def check(
231
252
  raise typer.Exit(code=1)
232
253
 
233
254
 
234
- @app.command()
255
+ config_app = typer.Typer(help="Show or modify tool configuration")
256
+ app.add_typer(config_app, name="config")
257
+
258
+
259
+ @config_app.callback(invoke_without_command=True)
235
260
  def config(
236
- path: str = typer.Argument(".", help="Path to Python file or project directory"),
261
+ ctx: typer.Context,
262
+ path: str = typer.Option(
263
+ ".", "--path", "-p", help="Path to Python file or project directory"
264
+ ),
237
265
  ) -> None:
238
266
  """Show the effective tool configuration for a project."""
267
+ if ctx.invoked_subcommand is not None:
268
+ return
239
269
  path_obj = Path(path).resolve()
240
270
  toml_path = (
241
271
  path_obj.parent / "pyproject.toml"
@@ -259,7 +289,10 @@ def config(
259
289
 
260
290
  console.print(f"Config: [bold]{toml_path}[/bold] ({status_text})\n")
261
291
 
262
- effective_registry = _apply_user_config(tool_registry, user_cfg)
292
+ try:
293
+ effective_registry = _apply_user_config(tool_registry, user_cfg)
294
+ except ValueError as e:
295
+ raise typer.BadParameter(str(e))
263
296
  disabled_ids = set(tool_registry.keys()) - set(effective_registry.keys())
264
297
 
265
298
  table = Table()
@@ -270,7 +303,10 @@ def config(
270
303
  table.add_column("Status", justify="center")
271
304
 
272
305
  all_tool_ids = set(tool_registry) | set(effective_registry)
273
- for tool_id in sorted(all_tool_ids, key=lambda t: (effective_registry.get(t) or tool_registry[t]).order):
306
+ for tool_id in sorted(
307
+ all_tool_ids,
308
+ key=lambda t: (effective_registry.get(t) or tool_registry[t]).order,
309
+ ):
274
310
  tc = effective_registry.get(tool_id) or tool_registry[tool_id]
275
311
  is_disabled = tool_id in disabled_ids
276
312
  status = "[red]disabled[/red]" if is_disabled else "[green]enabled[/green]"
@@ -285,3 +321,93 @@ def config(
285
321
  console.print(table)
286
322
 
287
323
 
324
+ @config_app.command("set")
325
+ def config_set(
326
+ tool_id: str = typer.Argument(..., help="Tool ID (e.g. radon-hal, ruff)"),
327
+ warning: float | None = typer.Option(
328
+ None, "--warning", "-w", help="Warning threshold (0-1)"
329
+ ),
330
+ error: float | None = typer.Option(
331
+ None, "--error", "-e", help="Error threshold (0-1)"
332
+ ),
333
+ path: str = typer.Option(".", "--path", "-p", help="Path to project directory"),
334
+ ) -> None:
335
+ """Set warning/error thresholds for a tool in pyproject.toml."""
336
+ if warning is None and error is None:
337
+ raise typer.BadParameter("At least one of --warning or --error is required")
338
+
339
+ if tool_id not in tool_registry:
340
+ available = ", ".join(sorted(tool_registry))
341
+ raise typer.BadParameter(
342
+ f"Unknown tool: {tool_id!r}. Available tools: {available}"
343
+ )
344
+
345
+ path_obj = Path(path).resolve()
346
+ if not path_obj.is_dir():
347
+ raise typer.BadParameter(f"Path must be a directory: {path}")
348
+
349
+ toml_path = path_obj / "pyproject.toml"
350
+ if not toml_path.exists():
351
+ raise typer.BadParameter(f"No pyproject.toml found at {toml_path}")
352
+
353
+ with toml_path.open("r", encoding="utf-8") as f:
354
+ doc = tomlkit.parse(f.read())
355
+
356
+ if "tool" not in doc:
357
+ doc["tool"] = tomlkit.table()
358
+ tool_tbl = doc["tool"]
359
+ if "cq" not in tool_tbl:
360
+ tool_tbl["cq"] = tomlkit.table()
361
+ cq_tbl = tool_tbl["cq"]
362
+ if "thresholds" not in cq_tbl:
363
+ cq_tbl["thresholds"] = tomlkit.table()
364
+ thresholds = cq_tbl["thresholds"]
365
+
366
+ if tool_id in thresholds:
367
+ entry = thresholds[tool_id]
368
+ else:
369
+ entry = tomlkit.inline_table()
370
+
371
+ if warning is not None:
372
+ entry["warning"] = warning
373
+ if error is not None:
374
+ entry["error"] = error
375
+ thresholds[tool_id] = entry
376
+
377
+ with toml_path.open("w", encoding="utf-8") as f:
378
+ f.write(tomlkit.dumps(doc))
379
+
380
+ parts = []
381
+ if warning is not None:
382
+ parts.append(f"warning={warning}")
383
+ if error is not None:
384
+ parts.append(f"error={error}")
385
+ console.print(
386
+ f"[green]Set {tool_id} thresholds ({', '.join(parts)}) in {toml_path}[/green]"
387
+ )
388
+
389
+ from py_cq.execution_engine import _cache
390
+
391
+ _cache.clear()
392
+ console.print("[dim]Tool cache cleared[/dim]")
393
+
394
+
395
+ @app.command()
396
+ def is_fixed(
397
+ fingerprint: str = typer.Argument(
398
+ ...,
399
+ help="Fingerprint from -o llm-json output (tool::project::path[::line[::code]])",
400
+ ),
401
+ ) -> None:
402
+ """Return True if the fingerprinted issue is no longer present."""
403
+ try:
404
+ cq = CQ(".")
405
+ fixed = cq.is_fixed(fingerprint)
406
+ except ValueError as e:
407
+ raise typer.BadParameter(str(e))
408
+
409
+ if fixed:
410
+ typer.echo("FIXED")
411
+ else:
412
+ typer.echo(f"FAILED: {fingerprint}")
413
+ raise typer.Exit(1)
@@ -0,0 +1,95 @@
1
+ [python.compile]
2
+ command = "{python} -m compileall -r 10 -j 8 \"{context_path}\" -x .*venv|\\.claude"
3
+ parser = "CompileParser"
4
+ order = 1
5
+ warning_threshold = 0.9999
6
+ error_threshold = 0.9999
7
+
8
+ [python.ruff]
9
+ command = "{python} -m ruff check --output-format concise --no-cache \"{context_path}\" --exclude .claude{exclude}"
10
+ exclude_format = " --exclude {path}"
11
+ parser = "RuffParser"
12
+ order = 2
13
+ warning_threshold = 0.9999
14
+ error_threshold = 0.9
15
+
16
+ [python.ty]
17
+ command = "{python} -m ty check --output-format concise --color never \"{context_path}\" --exclude .claude{exclude}"
18
+ exclude_format = " --exclude {path}"
19
+ parser = "TyParser"
20
+ order = 3
21
+ warning_threshold = 0.9999
22
+ error_threshold = 0.8
23
+ run_in_target_env = true
24
+ extra_deps = ["ty"]
25
+
26
+ [python.bandit]
27
+ command = "{python} -m bandit -r {scan_targets} -f json -q -s B101 --severity-level medium{exclude}"
28
+ scan_exclude_names = [".venv", ".claude", "tests"]
29
+ exclude_format = " --exclude {abs_native_path}"
30
+ parser = "BanditParser"
31
+ order = 4
32
+ warning_threshold = 0.9999
33
+ error_threshold = 0.8
34
+
35
+ [python.pytest]
36
+ command = "{python} -m pytest -vv \"{context_path}\" --ignore .claude{exclude}"
37
+ exclude_format = " --ignore {path}"
38
+ parser = "PytestParser"
39
+ order = 5
40
+ warning_threshold = 1.0
41
+ error_threshold = 1.0
42
+ run_in_target_env = true
43
+ extra_deps = ["pytest"]
44
+ skip_for_file = true
45
+
46
+ [python.coverage]
47
+ command = "{python} -m coverage run --omit=*/tests/*,*/test_*.py -m pytest \"{context_path}\" --ignore .claude{exclude} && {python} -m coverage report --show-missing --omit=*/tests/*,*/test_*.py"
48
+ exclude_format = " --ignore {path}"
49
+ parser = "CoverageParser"
50
+ order = 6
51
+ warning_threshold = 0.9
52
+ error_threshold = 0.5
53
+ run_in_target_env = true
54
+ extra_deps = ["coverage", "pytest"]
55
+ skip_for_file = true
56
+
57
+ [python.radon-cc]
58
+ command = "{python} -m radon cc --json --exclude '.claude/**' \"{context_path}\""
59
+ parser = "ComplexityParser"
60
+ order = 7
61
+ warning_threshold = 0.6
62
+ error_threshold = 0.4
63
+
64
+ [python.radon-mi]
65
+ command = "{python} -m radon mi -s --json --exclude '.claude/**' \"{context_path}\""
66
+ parser = "MaintainabilityParser"
67
+ order = 8
68
+ warning_threshold = 0.6
69
+ error_threshold = 0.4
70
+
71
+ [python.radon-hal]
72
+ command = "{python} -m radon hal -f --json --exclude '.claude/**' \"{context_path}\""
73
+ parser = "HalsteadParser"
74
+ order = 9
75
+ warning_threshold = 0.5
76
+ error_threshold = 0.3
77
+
78
+ [python.vulture]
79
+ command = "{python} -m vulture \"{context_path}\" --min-confidence 80 --exclude .venv,dist,.*_cache,docs,.git,.claude{exclude}"
80
+ exclude_format = ",{path}"
81
+ parser = "VultureParser"
82
+ order = 10
83
+ warning_threshold = 0.9999
84
+ error_threshold = 0.8
85
+
86
+ [python.interrogate]
87
+ command = "{python} -m interrogate \"{context_path}\" -e .claude{exclude} -v --fail-under 0"
88
+ exclude_format = " -e {path}"
89
+ parser = "InterrogateParser"
90
+ order = 11
91
+ warning_threshold = 0.8
92
+ error_threshold = 0.3
93
+
94
+ [python.interrogate.parser_config]
95
+ skip_empty_init = true
py_cq/context_hash.py CHANGED
@@ -44,15 +44,22 @@ def get_sigs(path: str):
44
44
  items = []
45
45
  with os.scandir(path) as entries:
46
46
  for entry in entries:
47
- if entry.is_file() and entry.name.endswith(".py"):
48
- stat_info = entry.stat()
47
+ # Use follow_symlinks=False to prevent cache poisoning from
48
+ # symlinks pointing outside the project tree (M-2)
49
+ if entry.is_file(follow_symlinks=False) and entry.name.endswith(".py"):
50
+ stat_info = entry.stat(follow_symlinks=False)
49
51
  items.append(f"{entry.path}:{stat_info.st_size}:{stat_info.st_mtime}")
50
- if entry.is_dir() and entry.name not in [".venv", "venv", "__pycache__"]:
52
+ if entry.is_dir(follow_symlinks=False) and entry.name not in [
53
+ ".venv",
54
+ "venv",
55
+ "__pycache__",
56
+ ".git",
57
+ ]:
51
58
  items.extend(get_sigs(entry.path))
52
59
  return items
53
60
 
54
61
 
55
- def get_context_hash(path: str):
62
+ def get_context_hash(path: str) -> str:
56
63
  """Compute an MD5 hash that uniquely identifies a file or directory.
57
64
 
58
65
  The hash is derived from a signature string. For a file, the signature consists of
@@ -72,10 +79,13 @@ def get_context_hash(path: str):
72
79
  >>> get_context_hash('/tmp/example.txt')
73
80
  '5d41402abc4b2a76b9719d911017c592'
74
81
  """
75
- sig = "empty"
82
+ h = hashlib.md5() # nosec
76
83
  if os.path.isfile(path):
77
84
  s = os.stat(path)
78
- sig = f"{path}:{s.st_size}:{s.st_mtime}"
85
+ h.update(f"{path}:{s.st_size}:{s.st_mtime}".encode())
79
86
  elif os.path.isdir(path):
80
- sig = "".join(get_sigs(path=path))
81
- return f"{hashlib.md5(sig.encode('utf-8')).hexdigest()}" # nosec
87
+ for sig in sorted(get_sigs(path)):
88
+ h.update(sig.encode())
89
+ else:
90
+ h.update(b"empty")
91
+ return h.hexdigest()