pygenkit 0.2.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 (95) hide show
  1. pygenkit/__init__.py +2 -0
  2. pygenkit/ai/__init__.py +11 -0
  3. pygenkit/ai/prompts.py +27 -0
  4. pygenkit/ai/provider.py +57 -0
  5. pygenkit/ai/review.py +59 -0
  6. pygenkit/cli/__init__.py +3 -0
  7. pygenkit/cli/app.py +53 -0
  8. pygenkit/cli/commands/__init__.py +21 -0
  9. pygenkit/cli/commands/build.py +41 -0
  10. pygenkit/cli/commands/doctor.py +119 -0
  11. pygenkit/cli/commands/generate.py +52 -0
  12. pygenkit/cli/commands/health.py +64 -0
  13. pygenkit/cli/commands/init.py +23 -0
  14. pygenkit/cli/commands/inspect.py +58 -0
  15. pygenkit/cli/commands/new.py +37 -0
  16. pygenkit/cli/commands/publish.py +30 -0
  17. pygenkit/cli/commands/release.py +33 -0
  18. pygenkit/cli/commands/release_check.py +17 -0
  19. pygenkit/cli/commands/review.py +58 -0
  20. pygenkit/cli/commands/validate.py +29 -0
  21. pygenkit/cli.py +4 -0
  22. pygenkit/config/__init__.py +3 -0
  23. pygenkit/generators/__init__.py +13 -0
  24. pygenkit/generators/base.py +31 -0
  25. pygenkit/generators/deploy.py +48 -0
  26. pygenkit/generators/docker.py +43 -0
  27. pygenkit/generators/github_actions.py +68 -0
  28. pygenkit/generators/orchestrator.py +33 -0
  29. pygenkit/generators/project.py +110 -0
  30. pygenkit/health/__init__.py +3 -0
  31. pygenkit/health/api.py +57 -0
  32. pygenkit/health/checks.py +343 -0
  33. pygenkit/inspector/__init__.py +3 -0
  34. pygenkit/inspector/api.py +154 -0
  35. pygenkit/inspector/debian.py +51 -0
  36. pygenkit/inspector/detect.py +84 -0
  37. pygenkit/inspector/git.py +55 -0
  38. pygenkit/inspector/pyproject.py +74 -0
  39. pygenkit/models/__init__.py +25 -0
  40. pygenkit/models/config.py +266 -0
  41. pygenkit/models/health.py +22 -0
  42. pygenkit/models/inspection.py +56 -0
  43. pygenkit/render/__init__.py +3 -0
  44. pygenkit/render/engine.py +41 -0
  45. pygenkit/services/__init__.py +0 -0
  46. pygenkit/templates/deploy/Procfile.j2 +1 -0
  47. pygenkit/templates/deploy/fly.toml.j2 +14 -0
  48. pygenkit/templates/deploy/railway.json.j2 +12 -0
  49. pygenkit/templates/docker/Dockerfile.j2 +15 -0
  50. pygenkit/templates/docker/docker-compose.yml.j2 +17 -0
  51. pygenkit/templates/github/workflows/ci.yml.j2 +33 -0
  52. pygenkit/templates/github/workflows/publish-launchpad.yml.j2 +37 -0
  53. pygenkit/templates/github/workflows/publish-pypi.yml.j2 +32 -0
  54. pygenkit/templates/github/workflows/release.yml.j2 +28 -0
  55. pygenkit/templates/project/LICENSE.j2 +21 -0
  56. pygenkit/templates/project/README.md.j2 +21 -0
  57. pygenkit/templates/project/pyproject.toml.j2 +51 -0
  58. pygenkit/templates/project/src/__init__.py.j2 +2 -0
  59. pygenkit/templates/project/src/cli.py.j2 +5 -0
  60. pygenkit/templates/project/tests/__init__.py.j2 +0 -0
  61. pygenkit/templates/project/tests/test_cli.py.j2 +5 -0
  62. pygenkit/templates/python-cli/CHANGELOG.md.jinja +7 -0
  63. pygenkit/templates/python-cli/LICENSE.jinja +204 -0
  64. pygenkit/templates/python-cli/Makefile.jinja +31 -0
  65. pygenkit/templates/python-cli/README.md.jinja +86 -0
  66. pygenkit/templates/python-cli/debian/changelog.jinja +5 -0
  67. pygenkit/templates/python-cli/debian/control.jinja +23 -0
  68. pygenkit/templates/python-cli/debian/copyright.jinja +23 -0
  69. pygenkit/templates/python-cli/debian/install.jinja +1 -0
  70. pygenkit/templates/python-cli/debian/links.jinja +1 -0
  71. pygenkit/templates/python-cli/debian/postinst.jinja +15 -0
  72. pygenkit/templates/python-cli/debian/prerm.jinja +14 -0
  73. pygenkit/templates/python-cli/debian/rules.jinja +11 -0
  74. pygenkit/templates/python-cli/debian/source/options.jinja +2 -0
  75. pygenkit/templates/python-cli/debian/{{module_name}}.service.jinja +13 -0
  76. pygenkit/templates/python-cli/pygenkit.yaml.jinja +19 -0
  77. pygenkit/templates/python-cli/pyproject.toml.jinja +53 -0
  78. pygenkit/templates/python-cli/src/{{module_name}}/__init__.py.jinja +4 -0
  79. pygenkit/templates/python-cli/src/{{module_name}}/cli.py.jinja +28 -0
  80. pygenkit/templates/python-cli/tests/__init__.py +0 -0
  81. pygenkit/templates/python-cli/tests/test_cli.py.jinja +21 -0
  82. pygenkit/utils/__init__.py +4 -0
  83. pygenkit/utils/files.py +29 -0
  84. pygenkit/utils/filters.py +43 -0
  85. pygenkit/validators/__init__.py +3 -0
  86. pygenkit/validators/api.py +30 -0
  87. pygenkit/validators/security.py +102 -0
  88. pygenkit/validators/version.py +77 -0
  89. pygenkit/validators/workflow.py +141 -0
  90. pygenkit-0.2.0.dist-info/METADATA +350 -0
  91. pygenkit-0.2.0.dist-info/RECORD +95 -0
  92. pygenkit-0.2.0.dist-info/WHEEL +5 -0
  93. pygenkit-0.2.0.dist-info/entry_points.txt +2 -0
  94. pygenkit-0.2.0.dist-info/licenses/LICENSE +674 -0
  95. pygenkit-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,28 @@
1
+ """CLI entry point for {{ project.name }}."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+
8
+
9
+ def build_parser() -> argparse.ArgumentParser:
10
+ parser = argparse.ArgumentParser(prog="{{ module_name }}")
11
+ parser.add_argument(
12
+ "--version",
13
+ action="version",
14
+ version=f"%(prog)s {{ project.version }}",
15
+ )
16
+ return parser
17
+
18
+
19
+ def main(argv: list[str] | None = None) -> int:
20
+ parser = build_parser()
21
+ args = parser.parse_args(argv)
22
+ _ = args
23
+ print("Hello from {{ project.name }}!")
24
+ return 0
25
+
26
+
27
+ if __name__ == "__main__":
28
+ sys.exit(main())
File without changes
@@ -0,0 +1,21 @@
1
+ """Tests for the CLI."""
2
+
3
+ from pathlib import Path
4
+ import sys
5
+
6
+ ROOT = Path(__file__).resolve().parents[1]
7
+ SRC = ROOT / "src"
8
+ if str(SRC) not in sys.path:
9
+ sys.path.insert(0, str(SRC))
10
+
11
+ from {{ module_name }}.cli import main
12
+
13
+
14
+ def test_help_returns_zero() -> None:
15
+ exit_code = main(["--help"])
16
+ assert exit_code == 0
17
+
18
+
19
+ def test_version_output() -> None:
20
+ exit_code = main(["--version"])
21
+ assert exit_code == 0
@@ -0,0 +1,4 @@
1
+ from pygenkit.utils.files import FileUtils
2
+ from pygenkit.utils.filters import TemplateFilters
3
+
4
+ __all__ = ["FileUtils", "TemplateFilters"]
@@ -0,0 +1,29 @@
1
+ from __future__ import annotations
2
+
3
+ import shutil
4
+ from pathlib import Path
5
+
6
+
7
+ class FileUtils:
8
+ @staticmethod
9
+ def ensure_dir(path: str | Path) -> Path:
10
+ p = Path(path)
11
+ p.mkdir(parents=True, exist_ok=True)
12
+ return p
13
+
14
+ @staticmethod
15
+ def write_file(path: str | Path, content: str) -> Path:
16
+ p = Path(path)
17
+ p.parent.mkdir(parents=True, exist_ok=True)
18
+ p.write_text(content, encoding="utf-8")
19
+ return p
20
+
21
+ @staticmethod
22
+ def remove_tree(path: str | Path) -> None:
23
+ p = Path(path)
24
+ if p.exists():
25
+ shutil.rmtree(p)
26
+
27
+ @staticmethod
28
+ def copy_tree(src: str | Path, dst: str | Path) -> None:
29
+ shutil.copytree(Path(src), Path(dst), dirs_exist_ok=True)
@@ -0,0 +1,43 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+
5
+
6
+ class TemplateFilters:
7
+ @staticmethod
8
+ def snake_case(value: str) -> str:
9
+ s = re.sub(r"[-\s]+", "_", value)
10
+ return s.lower().strip("_")
11
+
12
+ @staticmethod
13
+ def kebab_case(value: str) -> str:
14
+ s = re.sub(r"[_\s]+", "-", value)
15
+ return s.lower().strip("-")
16
+
17
+ @staticmethod
18
+ def pascal_case(value: str) -> str:
19
+ return "".join(word.capitalize() for word in re.split(r"[-_\\s]", value))
20
+
21
+ @staticmethod
22
+ def camel_case(value: str) -> str:
23
+ s = TemplateFilters.pascal_case(value)
24
+ return s[0].lower() + s[1:] if s else s
25
+
26
+ @staticmethod
27
+ def module_name(value: str) -> str:
28
+ return TemplateFilters.snake_case(value)
29
+
30
+ @staticmethod
31
+ def capitalise(value: str) -> str:
32
+ return value.capitalize()
33
+
34
+ @staticmethod
35
+ def get_filters() -> dict[str, object]:
36
+ return {
37
+ "snake_case": TemplateFilters.snake_case,
38
+ "kebab_case": TemplateFilters.kebab_case,
39
+ "pascal_case": TemplateFilters.pascal_case,
40
+ "camel_case": TemplateFilters.camel_case,
41
+ "module_name": TemplateFilters.module_name,
42
+ "capitalise": TemplateFilters.capitalise,
43
+ }
@@ -0,0 +1,3 @@
1
+ from pygenkit.validators.api import has_errors, validate_project
2
+
3
+ __all__ = ["validate_project", "has_errors"]
@@ -0,0 +1,30 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from pygenkit.validators import security as security_validator
6
+ from pygenkit.validators import version as version_validator
7
+ from pygenkit.validators import workflow as workflow_validator
8
+
9
+
10
+ def validate_project(root: str | Path = ".") -> list[dict[str, object]]:
11
+ results: list[dict[str, object]] = []
12
+
13
+ _add_results(results, "version", version_validator.validate_version_consistency(root))
14
+ _add_results(results, "workflow", workflow_validator.validate_workflows(root))
15
+ _add_results(results, "security", security_validator.validate_security(root))
16
+
17
+ return results
18
+
19
+
20
+ def _add_results(
21
+ results: list[dict[str, object]],
22
+ category: str,
23
+ issues: list[str],
24
+ ) -> None:
25
+ for issue in issues:
26
+ results.append({"category": category, "message": issue})
27
+
28
+
29
+ def has_errors(results: list[dict[str, object]]) -> bool:
30
+ return len(results) > 0
@@ -0,0 +1,102 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ import yaml
7
+
8
+
9
+ def validate_security(root: str | Path = ".") -> list[str]:
10
+ issues: list[str] = []
11
+ wf_dir = Path(root) / ".github" / "workflows"
12
+
13
+ if not wf_dir.is_dir():
14
+ return []
15
+
16
+ for yml_file in sorted(wf_dir.glob("*.yml")):
17
+ issues.extend(_check_workflow_security(yml_file))
18
+
19
+ secrets_file = Path(root) / ".github" / "secrets"
20
+ if secrets_file.exists():
21
+ issues.append(".github/secrets file should not be versioned")
22
+
23
+ return issues
24
+
25
+
26
+ def _check_workflow_security(path: Path) -> list[str]:
27
+ issues: list[str] = []
28
+ name = path.name
29
+
30
+ try:
31
+ raw = path.read_text(encoding="utf-8")
32
+ data = yaml.safe_load(raw)
33
+ except yaml.YAMLError:
34
+ return issues
35
+
36
+ if not isinstance(data, dict):
37
+ return issues
38
+
39
+ jobs = data.get("jobs", {})
40
+ if not isinstance(jobs, dict):
41
+ return issues
42
+
43
+ for job_id, job in jobs.items():
44
+ if not isinstance(job, dict):
45
+ continue
46
+ issues.extend(_check_job_permissions(name, job_id, job))
47
+ issues.extend(_check_job_secrets(name, job_id, job))
48
+ issues.extend(_check_job_environment(name, job_id, job))
49
+
50
+ return issues
51
+
52
+
53
+ def _check_job_permissions(wf_name: str, job_id: str, job: dict[str, Any]) -> list[str]:
54
+ issues: list[str] = []
55
+ prefix = f"{wf_name}/{job_id}"
56
+
57
+ if "permissions" not in job:
58
+ issues.append(
59
+ f"{prefix}: no explicit permissions block"
60
+ )
61
+
62
+ steps = job.get("steps", [])
63
+ if isinstance(steps, list):
64
+ for i, step in enumerate(steps):
65
+ if isinstance(step, dict) and step.get("uses", "").startswith("actions/checkout"):
66
+ token = step.get("with", {}).get("token", "")
67
+ if token and "secrets." not in str(token):
68
+ issues.append(f"{prefix}/step{i}: hardcoded token in checkout action")
69
+
70
+ return issues
71
+
72
+
73
+ def _check_job_secrets(wf_name: str, job_id: str, job: dict[str, Any]) -> list[str]:
74
+ issues: list[str] = []
75
+ raw_yml = yaml.dump(job)
76
+
77
+ hardcoded_patterns = ["password=", "api_key=", "secret=", "token="]
78
+ for pattern in hardcoded_patterns:
79
+ if pattern in raw_yml.lower():
80
+ prefix = f"{wf_name}/{job_id}"
81
+ issues.append(f"{prefix}: possible hardcoded secret ('{pattern}') in workflow")
82
+
83
+ return issues
84
+
85
+
86
+ def _check_job_environment(wf_name: str, job_id: str, job: dict[str, Any]) -> list[str]:
87
+ issues: list[str] = []
88
+ env = job.get("environment")
89
+
90
+ if env and isinstance(env, str) and env == "pypi":
91
+ return issues
92
+
93
+ steps = job.get("steps", [])
94
+ if isinstance(steps, list):
95
+ for step in steps:
96
+ if isinstance(step, dict):
97
+ uses = step.get("uses", "")
98
+ if ("pypi-publish" in uses or "pypa/gh-action-pypi-publish" in uses) and not env:
99
+ prefix = f"{wf_name}/{job_id}"
100
+ issues.append(f"{prefix}: PyPI publish should use a protected environment")
101
+
102
+ return issues
@@ -0,0 +1,77 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from pygenkit.inspector import debian as debian_inspector
6
+ from pygenkit.inspector import detect as detect_inspector
7
+ from pygenkit.inspector import git as git_inspector
8
+ from pygenkit.inspector import pyproject as pyproject_inspector
9
+
10
+
11
+ def validate_version_consistency(root: str | Path = ".") -> list[str]:
12
+ issues: list[str] = []
13
+ root = Path(root).resolve()
14
+
15
+ pyproject_ver = _get_pyproject_version(root)
16
+ init_ver = _get_init_version(root)
17
+ changelog_ver = _get_changelog_version(root)
18
+ latest_tag = _get_latest_tag(root)
19
+
20
+ if pyproject_ver and init_ver and pyproject_ver != init_ver:
21
+ issues.append(
22
+ f"pyproject.toml: {pyproject_ver} != src/.../__init__.py: {init_ver}"
23
+ )
24
+
25
+ if pyproject_ver and changelog_ver and pyproject_ver != changelog_ver:
26
+ issues.append(
27
+ f"pyproject.toml: {pyproject_ver} != debian/changelog: {changelog_ver}"
28
+ )
29
+
30
+ if latest_tag and pyproject_ver and latest_tag != pyproject_ver:
31
+ issues.append(
32
+ f"latest git tag v{latest_tag} != pyproject.toml: {pyproject_ver}"
33
+ )
34
+
35
+ return issues
36
+
37
+
38
+ def _get_pyproject_version(root: Path) -> str | None:
39
+ try:
40
+ data = pyproject_inspector.read_pyproject(root)
41
+ return pyproject_inspector.detect_project_version(data)
42
+ except (FileNotFoundError, Exception):
43
+ return None
44
+
45
+
46
+ def _get_init_version(root: Path) -> str | None:
47
+ try:
48
+ data = pyproject_inspector.read_pyproject(root)
49
+ name = pyproject_inspector.detect_project_name(data)
50
+ if name:
51
+ module = detect_inspector.detect_module(root, name)
52
+ else:
53
+ module = detect_inspector.detect_module(root)
54
+ if module:
55
+ return detect_inspector.detect_version_in_init(root, module)
56
+ except (FileNotFoundError, Exception):
57
+ pass
58
+ return None
59
+
60
+
61
+ def _get_changelog_version(root: Path) -> str | None:
62
+ try:
63
+ info = debian_inspector.inspect_debian(root)
64
+ return info.version_in_changelog
65
+ except Exception:
66
+ return None
67
+
68
+
69
+ def _get_latest_tag(root: Path) -> str | None:
70
+ try:
71
+ tags = git_inspector.detect_git_tags(root)
72
+ for t in tags:
73
+ if t.startswith("v"):
74
+ return t.lstrip("v")
75
+ return tags[0] if tags else None
76
+ except Exception:
77
+ return None
@@ -0,0 +1,141 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ import yaml
7
+
8
+
9
+ def validate_workflows(root: str | Path = ".") -> list[str]:
10
+ issues: list[str] = []
11
+ wf_dir = Path(root) / ".github" / "workflows"
12
+
13
+ if not wf_dir.is_dir():
14
+ return []
15
+
16
+ for yml_file in sorted(wf_dir.glob("*.yml")):
17
+ issues.extend(_validate_single_workflow(yml_file))
18
+
19
+ return issues
20
+
21
+
22
+ def _validate_single_workflow(path: Path) -> list[str]:
23
+ issues: list[str] = []
24
+ name = path.name
25
+
26
+ try:
27
+ raw = path.read_text(encoding="utf-8")
28
+ data = yaml.safe_load(raw)
29
+ except yaml.YAMLError as exc:
30
+ issues.append(f"{name}: invalid YAML — {exc}")
31
+ return issues
32
+
33
+ if not isinstance(data, dict):
34
+ issues.append(f"{name}: not a mapping")
35
+ return issues
36
+
37
+ if "name" not in data:
38
+ issues.append(f"{name}: missing 'name' field")
39
+
40
+ triggers = _collect_triggers(data)
41
+ if not triggers:
42
+ issues.append(f"{name}: no triggers defined (on:)")
43
+
44
+ jobs = data.get("jobs", {})
45
+ if not jobs:
46
+ issues.append(f"{name}: no jobs defined")
47
+ return issues
48
+
49
+ for job_id, job in jobs.items():
50
+ if not isinstance(job, dict):
51
+ continue
52
+ issues.extend(_validate_job(name, job_id, job))
53
+
54
+ return issues
55
+
56
+
57
+ def _collect_triggers(data: dict[str, Any]) -> list[str]:
58
+ triggers: list[str] = []
59
+ on = data.get("on", {})
60
+ if isinstance(on, str):
61
+ triggers.append(on)
62
+ elif isinstance(on, dict):
63
+ triggers.extend(on.keys())
64
+ elif isinstance(on, list):
65
+ triggers.extend(str(t) for t in on)
66
+ return triggers
67
+
68
+
69
+ def _validate_job(wf_name: str, job_id: str, job: dict[str, Any]) -> list[str]:
70
+ issues: list[str] = []
71
+ prefix = f"{wf_name}/{job_id}"
72
+
73
+ if "runs-on" not in job:
74
+ issues.append(f"{prefix}: missing runs-on")
75
+
76
+ steps = job.get("steps", [])
77
+ if not steps:
78
+ issues.append(f"{prefix}: no steps")
79
+ return issues
80
+
81
+ for i, step in enumerate(steps):
82
+ if not isinstance(step, dict):
83
+ continue
84
+ if "uses" in step:
85
+ issues.extend(_check_action_version(prefix, i, step["uses"]))
86
+ if "run" in step:
87
+ issues.extend(_check_shell_injection(prefix, i, step["run"]))
88
+
89
+ return issues
90
+
91
+
92
+ def _check_action_version(prefix: str, step_idx: int, uses: str) -> list[str]:
93
+ issues: list[str] = []
94
+ if not isinstance(uses, str):
95
+ return issues
96
+
97
+ if "@" not in uses:
98
+ issues.append(f"{prefix}/step{step_idx}: action '{uses}' has no version pin")
99
+ return issues
100
+
101
+ action, version = uses.rsplit("@", 1)
102
+ known_outdated = {
103
+ "actions/checkout@v4": "use actions/checkout@v6 or newer",
104
+ "actions/checkout@v3": "use actions/checkout@v6 or newer",
105
+ "actions/checkout@v2": "use actions/checkout@v6 or newer",
106
+ "actions/setup-python@v5": "use actions/setup-python@v6 or newer",
107
+ "actions/setup-python@v4": "use actions/setup-python@v6 or newer",
108
+ "actions/setup-python@v3": "use actions/setup-python@v6 or newer",
109
+ "actions/upload-artifact@v3": "use actions/upload-artifact@v4 or newer",
110
+ "actions/download-artifact@v3": "use actions/download-artifact@v4 or newer",
111
+ }
112
+
113
+ full = f"{action}@{version}"
114
+ if full in known_outdated:
115
+ issues.append(f"{prefix}/step{step_idx}: {full} is outdated — {known_outdated[full]}")
116
+
117
+ if not _is_sha_pin(version):
118
+ issues.append(
119
+ f"{prefix}/step{step_idx}: '{full}' uses tag '{version}' instead of SHA pin"
120
+ )
121
+
122
+ return issues
123
+
124
+
125
+ def _is_sha_pin(version: str) -> bool:
126
+ return len(version) == 40 and all(c in "0123456789abcdef" for c in version)
127
+
128
+
129
+ def _check_shell_injection(prefix: str, step_idx: int, run: str) -> list[str]:
130
+ issues: list[str] = []
131
+ if not isinstance(run, str):
132
+ return issues
133
+
134
+ dangerous = ["${{ github.event.issue.title }}", "${{ github.event.pull_request.title }}"]
135
+ for pattern in dangerous:
136
+ if pattern in run:
137
+ issues.append(
138
+ f"{prefix}/step{step_idx}: possible script injection via {pattern}"
139
+ )
140
+
141
+ return issues