pycodegate 0.1.3__tar.gz → 0.2.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.
Files changed (72) hide show
  1. {pycodegate-0.1.3 → pycodegate-0.2.0}/.github/workflows/ci.yml +2 -2
  2. {pycodegate-0.1.3 → pycodegate-0.2.0}/PKG-INFO +1 -1
  3. {pycodegate-0.1.3 → pycodegate-0.2.0}/pyproject.toml +1 -1
  4. {pycodegate-0.1.3 → pycodegate-0.2.0}/src/pycodegate/__init__.py +1 -1
  5. {pycodegate-0.1.3 → pycodegate-0.2.0}/src/pycodegate/cli.py +14 -2
  6. {pycodegate-0.1.3 → pycodegate-0.2.0}/src/pycodegate/output.py +62 -0
  7. {pycodegate-0.1.3 → pycodegate-0.2.0}/src/pycodegate/rules/security.py +94 -0
  8. pycodegate-0.2.0/tests/rules/test_security_new.py +32 -0
  9. {pycodegate-0.1.3 → pycodegate-0.2.0}/tests/test_cli.py +11 -0
  10. {pycodegate-0.1.3 → pycodegate-0.2.0}/.github/workflows/publish.yml +0 -0
  11. {pycodegate-0.1.3 → pycodegate-0.2.0}/.gitignore +0 -0
  12. {pycodegate-0.1.3 → pycodegate-0.2.0}/AGENTS.md +0 -0
  13. {pycodegate-0.1.3 → pycodegate-0.2.0}/LICENSE +0 -0
  14. {pycodegate-0.1.3 → pycodegate-0.2.0}/README.md +0 -0
  15. {pycodegate-0.1.3 → pycodegate-0.2.0}/docs/superpowers/plans/2026-03-22-python-doctor.md +0 -0
  16. {pycodegate-0.1.3 → pycodegate-0.2.0}/skills/pycodegate/SKILL.md +0 -0
  17. {pycodegate-0.1.3 → pycodegate-0.2.0}/src/pycodegate/_compat.py +0 -0
  18. {pycodegate-0.1.3 → pycodegate-0.2.0}/src/pycodegate/api.py +0 -0
  19. {pycodegate-0.1.3 → pycodegate-0.2.0}/src/pycodegate/config.py +0 -0
  20. {pycodegate-0.1.3 → pycodegate-0.2.0}/src/pycodegate/constants.py +0 -0
  21. {pycodegate-0.1.3 → pycodegate-0.2.0}/src/pycodegate/discover.py +0 -0
  22. {pycodegate-0.1.3 → pycodegate-0.2.0}/src/pycodegate/profile.py +0 -0
  23. {pycodegate-0.1.3 → pycodegate-0.2.0}/src/pycodegate/rules/__init__.py +0 -0
  24. {pycodegate-0.1.3 → pycodegate-0.2.0}/src/pycodegate/rules/architecture.py +0 -0
  25. {pycodegate-0.1.3 → pycodegate-0.2.0}/src/pycodegate/rules/base.py +0 -0
  26. {pycodegate-0.1.3 → pycodegate-0.2.0}/src/pycodegate/rules/complexity.py +0 -0
  27. {pycodegate-0.1.3 → pycodegate-0.2.0}/src/pycodegate/rules/correctness.py +0 -0
  28. {pycodegate-0.1.3 → pycodegate-0.2.0}/src/pycodegate/rules/dead_code.py +0 -0
  29. {pycodegate-0.1.3 → pycodegate-0.2.0}/src/pycodegate/rules/dependencies.py +0 -0
  30. {pycodegate-0.1.3 → pycodegate-0.2.0}/src/pycodegate/rules/django.py +0 -0
  31. {pycodegate-0.1.3 → pycodegate-0.2.0}/src/pycodegate/rules/fastapi.py +0 -0
  32. {pycodegate-0.1.3 → pycodegate-0.2.0}/src/pycodegate/rules/flask.py +0 -0
  33. {pycodegate-0.1.3 → pycodegate-0.2.0}/src/pycodegate/rules/imports.py +0 -0
  34. {pycodegate-0.1.3 → pycodegate-0.2.0}/src/pycodegate/rules/performance.py +0 -0
  35. {pycodegate-0.1.3 → pycodegate-0.2.0}/src/pycodegate/rules/structure.py +0 -0
  36. {pycodegate-0.1.3 → pycodegate-0.2.0}/src/pycodegate/scan.py +0 -0
  37. {pycodegate-0.1.3 → pycodegate-0.2.0}/src/pycodegate/score.py +0 -0
  38. {pycodegate-0.1.3 → pycodegate-0.2.0}/src/pycodegate/types.py +0 -0
  39. {pycodegate-0.1.3 → pycodegate-0.2.0}/src/pycodegate/utils/__init__.py +0 -0
  40. {pycodegate-0.1.3 → pycodegate-0.2.0}/src/pycodegate/utils/ast_helpers.py +0 -0
  41. {pycodegate-0.1.3 → pycodegate-0.2.0}/src/pycodegate/utils/badge.py +0 -0
  42. {pycodegate-0.1.3 → pycodegate-0.2.0}/src/pycodegate/utils/diff.py +0 -0
  43. {pycodegate-0.1.3 → pycodegate-0.2.0}/src/pycodegate/utils/file_discovery.py +0 -0
  44. {pycodegate-0.1.3 → pycodegate-0.2.0}/src/pycodegate/utils/fixer.py +0 -0
  45. {pycodegate-0.1.3 → pycodegate-0.2.0}/src/pycodegate/utils/precommit.py +0 -0
  46. {pycodegate-0.1.3 → pycodegate-0.2.0}/tests/conftest.py +0 -0
  47. {pycodegate-0.1.3 → pycodegate-0.2.0}/tests/fixtures/basic_python/app.py +0 -0
  48. {pycodegate-0.1.3 → pycodegate-0.2.0}/tests/fixtures/clean_project/app.py +0 -0
  49. {pycodegate-0.1.3 → pycodegate-0.2.0}/tests/rules/__init__.py +0 -0
  50. {pycodegate-0.1.3 → pycodegate-0.2.0}/tests/rules/test_architecture.py +0 -0
  51. {pycodegate-0.1.3 → pycodegate-0.2.0}/tests/rules/test_complexity.py +0 -0
  52. {pycodegate-0.1.3 → pycodegate-0.2.0}/tests/rules/test_correctness.py +0 -0
  53. {pycodegate-0.1.3 → pycodegate-0.2.0}/tests/rules/test_dead_code.py +0 -0
  54. {pycodegate-0.1.3 → pycodegate-0.2.0}/tests/rules/test_dependencies.py +0 -0
  55. {pycodegate-0.1.3 → pycodegate-0.2.0}/tests/rules/test_django.py +0 -0
  56. {pycodegate-0.1.3 → pycodegate-0.2.0}/tests/rules/test_fastapi.py +0 -0
  57. {pycodegate-0.1.3 → pycodegate-0.2.0}/tests/rules/test_flask.py +0 -0
  58. {pycodegate-0.1.3 → pycodegate-0.2.0}/tests/rules/test_imports.py +0 -0
  59. {pycodegate-0.1.3 → pycodegate-0.2.0}/tests/rules/test_performance.py +0 -0
  60. {pycodegate-0.1.3 → pycodegate-0.2.0}/tests/rules/test_security.py +0 -0
  61. {pycodegate-0.1.3 → pycodegate-0.2.0}/tests/rules/test_structure.py +0 -0
  62. {pycodegate-0.1.3 → pycodegate-0.2.0}/tests/test_config.py +0 -0
  63. {pycodegate-0.1.3 → pycodegate-0.2.0}/tests/test_discover.py +0 -0
  64. {pycodegate-0.1.3 → pycodegate-0.2.0}/tests/test_fixer.py +0 -0
  65. {pycodegate-0.1.3 → pycodegate-0.2.0}/tests/test_integration.py +0 -0
  66. {pycodegate-0.1.3 → pycodegate-0.2.0}/tests/test_output.py +0 -0
  67. {pycodegate-0.1.3 → pycodegate-0.2.0}/tests/test_profile.py +0 -0
  68. {pycodegate-0.1.3 → pycodegate-0.2.0}/tests/test_scan.py +0 -0
  69. {pycodegate-0.1.3 → pycodegate-0.2.0}/tests/test_score.py +0 -0
  70. {pycodegate-0.1.3 → pycodegate-0.2.0}/tests/test_types.py +0 -0
  71. {pycodegate-0.1.3 → pycodegate-0.2.0}/uv.lock +0 -0
  72. {pycodegate-0.1.3 → pycodegate-0.2.0}/whitelist.py +0 -0
@@ -26,8 +26,8 @@ jobs:
26
26
  - name: Run tests
27
27
  run: uv run pytest --tb=short -q
28
28
 
29
- - name: Run py-gate on itself
30
- run: uv run py-gate . --fail-on error
29
+ - name: Run pycodegate on itself
30
+ run: uv run pycodegate . --fail-on error
31
31
 
32
32
  lint:
33
33
  runs-on: ubuntu-latest
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pycodegate
3
- Version: 0.1.3
3
+ Version: 0.2.0
4
4
  Summary: Trust, but verify. The quality gate for AI-generated Python code.
5
5
  Project-URL: Homepage, https://github.com/themohitkhare/pycodegate
6
6
  Project-URL: Repository, https://github.com/themohitkhare/pycodegate
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pycodegate"
3
- version = "0.1.3"
3
+ version = "0.2.0"
4
4
  description = "Trust, but verify. The quality gate for AI-generated Python code."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -1,3 +1,3 @@
1
1
  """Python Doctor - Diagnose your Python project's health."""
2
2
 
3
- __version__ = "0.1.3"
3
+ __version__ = "0.2.0"
@@ -9,7 +9,7 @@ import click
9
9
 
10
10
  from pycodegate import __version__
11
11
  from pycodegate.config import load_config
12
- from pycodegate.output import output_json, print_scan_result
12
+ from pycodegate.output import output_json, output_sarif, print_scan_result
13
13
  from pycodegate.scan import scan_project
14
14
  from pycodegate.types import Severity
15
15
  from pycodegate.utils.badge import generate_badge, generate_ci_workflow
@@ -35,6 +35,7 @@ class CliOptions:
35
35
  ci_workflow: bool
36
36
  precommit: bool
37
37
  min_score: int | None
38
+ sarif_output: bool
38
39
 
39
40
 
40
41
  def _handle_early_exits(opts: CliOptions) -> bool:
@@ -81,7 +82,9 @@ def _output_result(opts: CliOptions, result) -> None:
81
82
  click.echo(generate_badge(result.score.value, result.score.label))
82
83
  return
83
84
 
84
- if opts.json_output:
85
+ if opts.sarif_output:
86
+ output_sarif(result)
87
+ elif opts.json_output:
85
88
  output_json(result)
86
89
  elif opts.score_only:
87
90
  click.echo(str(result.score.value))
@@ -152,6 +155,13 @@ def _apply_exit_codes(opts: CliOptions, result) -> None:
152
155
  default=None,
153
156
  help="Minimum score threshold (exit 1 if below).",
154
157
  )
158
+ @click.option(
159
+ "--sarif",
160
+ "sarif_output",
161
+ is_flag=True,
162
+ default=False,
163
+ help="Output in SARIF format for GitHub Code Scanning.",
164
+ )
155
165
  def main(
156
166
  directory: str,
157
167
  lint: bool,
@@ -167,6 +177,7 @@ def main(
167
177
  ci_workflow: bool,
168
178
  precommit: bool,
169
179
  min_score: int | None,
180
+ sarif_output: bool,
170
181
  ) -> None:
171
182
  """PyCodeGate — Diagnose your Python project's health."""
172
183
  opts = CliOptions(
@@ -184,6 +195,7 @@ def main(
184
195
  ci_workflow=ci_workflow,
185
196
  precommit=precommit,
186
197
  min_score=min_score,
198
+ sarif_output=sarif_output,
187
199
  )
188
200
 
189
201
  if _handle_early_exits(opts):
@@ -213,6 +213,68 @@ def output_json(result: ScanResult) -> None:
213
213
  sys.stdout.write(json.dumps(payload, indent=2) + "\n")
214
214
 
215
215
 
216
+ def _sarif_rules(diagnostics: list[Diagnostic]) -> list[dict]:
217
+ """Build SARIF rules registry from unique diagnostic rules."""
218
+ seen: dict[str, Diagnostic] = {}
219
+ for d in diagnostics:
220
+ if d.rule not in seen:
221
+ seen[d.rule] = d
222
+ return [
223
+ {
224
+ "id": rule_id,
225
+ "name": rule_id,
226
+ "shortDescription": {"text": diag.message},
227
+ "helpUri": "",
228
+ "help": {"text": diag.help},
229
+ }
230
+ for rule_id, diag in seen.items()
231
+ ]
232
+
233
+
234
+ def _sarif_results(diagnostics: list[Diagnostic]) -> list[dict]:
235
+ """Build SARIF results array from diagnostics."""
236
+ return [
237
+ {
238
+ "ruleId": d.rule,
239
+ "level": "error" if d.severity == Severity.ERROR else "warning",
240
+ "message": {"text": d.message},
241
+ "locations": [
242
+ {
243
+ "physicalLocation": {
244
+ "artifactLocation": {"uri": d.file_path},
245
+ "region": {
246
+ "startLine": d.line,
247
+ "startColumn": (d.column or 0) + 1,
248
+ },
249
+ }
250
+ }
251
+ ],
252
+ }
253
+ for d in diagnostics
254
+ ]
255
+
256
+
257
+ def output_sarif(result: ScanResult) -> None:
258
+ """Output the scan result in SARIF 2.1.0 format for GitHub Code Scanning."""
259
+ sarif = {
260
+ "version": "2.1.0",
261
+ "$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json",
262
+ "runs": [
263
+ {
264
+ "tool": {
265
+ "driver": {
266
+ "name": "PyCodeGate",
267
+ "version": __version__,
268
+ "rules": _sarif_rules(result.diagnostics),
269
+ }
270
+ },
271
+ "results": _sarif_results(result.diagnostics),
272
+ }
273
+ ],
274
+ }
275
+ sys.stdout.write(json.dumps(sarif, indent=2) + "\n")
276
+
277
+
216
278
  def _print_rule_details(
217
279
  console: Console,
218
280
  rule_diags: list[Diagnostic],
@@ -33,6 +33,9 @@ class SecurityRules(BaseRules):
33
33
  diags.extend(self._check_yaml(tree, filename))
34
34
  diags.extend(self._check_hardcoded_secrets(tree, filename))
35
35
  diags.extend(self._check_weak_hash(tree, filename))
36
+ diags.extend(self._check_os_system(tree, filename))
37
+ diags.extend(self._check_subprocess_shell(tree, filename))
38
+ diags.extend(self._check_tempfile_mktemp(tree, filename))
36
39
  return diags
37
40
 
38
41
  def _check_eval_exec(self, tree: ast.Module, filename: str) -> list[Diagnostic]:
@@ -142,6 +145,97 @@ class SecurityRules(BaseRules):
142
145
  )
143
146
  return diags
144
147
 
148
+ def _check_os_system(self, tree: ast.Module, filename: str) -> list[Diagnostic]:
149
+ diags: list[Diagnostic] = []
150
+ for node in ast.walk(tree):
151
+ if not isinstance(node, ast.Call):
152
+ continue
153
+ if not isinstance(node.func, ast.Attribute):
154
+ continue
155
+ if node.func.attr != "system":
156
+ continue
157
+ if not isinstance(node.func.value, ast.Name):
158
+ continue
159
+ if node.func.value.id != "os":
160
+ continue
161
+ diags.append(
162
+ Diagnostic(
163
+ file_path=filename,
164
+ rule="no-os-system",
165
+ severity=Severity.ERROR,
166
+ category=Category.SECURITY,
167
+ message="os.system() is vulnerable to shell injection",
168
+ help="Use subprocess.run() with a list of arguments instead",
169
+ line=node.lineno,
170
+ column=node.col_offset,
171
+ cost=4.0,
172
+ )
173
+ )
174
+ return diags
175
+
176
+ def _check_subprocess_shell(self, tree: ast.Module, filename: str) -> list[Diagnostic]:
177
+ _subprocess_funcs = {"run", "Popen", "call", "check_call", "check_output"}
178
+ diags: list[Diagnostic] = []
179
+ for node in ast.walk(tree):
180
+ if not isinstance(node, ast.Call):
181
+ continue
182
+ if not isinstance(node.func, ast.Attribute):
183
+ continue
184
+ if node.func.attr not in _subprocess_funcs:
185
+ continue
186
+ if not isinstance(node.func.value, ast.Name):
187
+ continue
188
+ if node.func.value.id != "subprocess":
189
+ continue
190
+ shell_true = any(
191
+ kw.arg == "shell" and isinstance(kw.value, ast.Constant) and kw.value.value is True
192
+ for kw in node.keywords
193
+ )
194
+ if not shell_true:
195
+ continue
196
+ diags.append(
197
+ Diagnostic(
198
+ file_path=filename,
199
+ rule="no-subprocess-shell",
200
+ severity=Severity.ERROR,
201
+ category=Category.SECURITY,
202
+ message="subprocess called with shell=True is a security risk",
203
+ help="Pass arguments as a list and remove shell=True",
204
+ line=node.lineno,
205
+ column=node.col_offset,
206
+ cost=4.0,
207
+ )
208
+ )
209
+ return diags
210
+
211
+ def _check_tempfile_mktemp(self, tree: ast.Module, filename: str) -> list[Diagnostic]:
212
+ diags: list[Diagnostic] = []
213
+ for node in ast.walk(tree):
214
+ if not isinstance(node, ast.Call):
215
+ continue
216
+ if not isinstance(node.func, ast.Attribute):
217
+ continue
218
+ if node.func.attr != "mktemp":
219
+ continue
220
+ if not isinstance(node.func.value, ast.Name):
221
+ continue
222
+ if node.func.value.id != "tempfile":
223
+ continue
224
+ diags.append(
225
+ Diagnostic(
226
+ file_path=filename,
227
+ rule="no-tempfile-race",
228
+ severity=Severity.WARNING,
229
+ category=Category.SECURITY,
230
+ message="tempfile.mktemp() is deprecated and vulnerable to race conditions",
231
+ help="Use tempfile.mkstemp() or tempfile.NamedTemporaryFile()",
232
+ line=node.lineno,
233
+ column=node.col_offset,
234
+ cost=2.0,
235
+ )
236
+ )
237
+ return diags
238
+
145
239
  def _check_weak_hash(self, tree: ast.Module, filename: str) -> list[Diagnostic]:
146
240
  diags: list[Diagnostic] = []
147
241
  for node in ast.walk(tree):
@@ -0,0 +1,32 @@
1
+ """Tests for new security rules: os.system, subprocess shell=True, tempfile.mktemp."""
2
+
3
+ from pycodegate.rules.security import SecurityRules
4
+
5
+
6
+ def test_os_system_detected():
7
+ diags = SecurityRules().check("import os\nos.system('ls')\n", "app.py")
8
+ assert any(d.rule == "no-os-system" for d in diags)
9
+
10
+
11
+ def test_subprocess_shell_true():
12
+ source = "import subprocess\nsubprocess.run('ls', shell=True)\n"
13
+ diags = SecurityRules().check(source, "app.py")
14
+ assert any(d.rule == "no-subprocess-shell" for d in diags)
15
+
16
+
17
+ def test_subprocess_shell_false_ok():
18
+ source = "import subprocess\nsubprocess.run(['ls'], shell=False)\n"
19
+ diags = SecurityRules().check(source, "app.py")
20
+ assert not any(d.rule == "no-subprocess-shell" for d in diags)
21
+
22
+
23
+ def test_tempfile_mktemp():
24
+ source = "import tempfile\ntempfile.mktemp()\n"
25
+ diags = SecurityRules().check(source, "app.py")
26
+ assert any(d.rule == "no-tempfile-race" for d in diags)
27
+
28
+
29
+ def test_tempfile_mkstemp_ok():
30
+ source = "import tempfile\ntempfile.mkstemp()\n"
31
+ diags = SecurityRules().check(source, "app.py")
32
+ assert not any(d.rule == "no-tempfile-race" for d in diags)
@@ -124,3 +124,14 @@ def test_ci_workflow(tmp_path):
124
124
  assert result.exit_code == 0
125
125
  assert "name: Py Gate Score" in result.output
126
126
  assert "pycodegate" in result.output
127
+
128
+
129
+ def test_cli_sarif_output(tmp_path):
130
+ (tmp_path / "app.py").write_text("x = 1\n")
131
+ runner = CliRunner()
132
+ result = runner.invoke(main, [str(tmp_path), "--sarif"])
133
+ assert result.exit_code == 0
134
+ data = json.loads(result.output)
135
+ assert data["version"] == "2.1.0"
136
+ assert "runs" in data
137
+ assert data["runs"][0]["tool"]["driver"]["name"] == "PyCodeGate"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes