karni-cli 0.3.1__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 (145) hide show
  1. karni_cli/__init__.py +3 -0
  2. karni_cli/_agent/__init__.py +82 -0
  3. karni_cli/_agent/__main__.py +44 -0
  4. karni_cli/_agent/check.py +658 -0
  5. karni_cli/_agent/history.py +328 -0
  6. karni_cli/_agent/history_chronicle.py +246 -0
  7. karni_cli/_agent/overrides.py +194 -0
  8. karni_cli/_agent/rules.py +673 -0
  9. karni_cli/_changelog/0.2.0.toml +304 -0
  10. karni_cli/_changelog/0.3.0.toml +25 -0
  11. karni_cli/_changelog/__init__.py +248 -0
  12. karni_cli/_errorcodes/__init__.py +1171 -0
  13. karni_cli/_errorcodes/__main__.py +34 -0
  14. karni_cli/_errorcodes/links.py +217 -0
  15. karni_cli/_templates/project/cookiecutter.json +8 -0
  16. karni_cli/_templates/project/{{cookiecutter.project_slug}}/.dockerignore +7 -0
  17. karni_cli/_templates/project/{{cookiecutter.project_slug}}/.env.example +85 -0
  18. karni_cli/_templates/project/{{cookiecutter.project_slug}}/.gitignore +10 -0
  19. karni_cli/_templates/project/{{cookiecutter.project_slug}}/Caddyfile +22 -0
  20. karni_cli/_templates/project/{{cookiecutter.project_slug}}/Dockerfile +53 -0
  21. karni_cli/_templates/project/{{cookiecutter.project_slug}}/Makefile +52 -0
  22. karni_cli/_templates/project/{{cookiecutter.project_slug}}/README.md +169 -0
  23. karni_cli/_templates/project/{{cookiecutter.project_slug}}/config/__init__.py +16 -0
  24. karni_cli/_templates/project/{{cookiecutter.project_slug}}/config/asgi.py +14 -0
  25. karni_cli/_templates/project/{{cookiecutter.project_slug}}/config/celery.py +17 -0
  26. karni_cli/_templates/project/{{cookiecutter.project_slug}}/config/settings.py +28 -0
  27. karni_cli/_templates/project/{{cookiecutter.project_slug}}/config/urls.py +31 -0
  28. karni_cli/_templates/project/{{cookiecutter.project_slug}}/config/wsgi.py +14 -0
  29. karni_cli/_templates/project/{{cookiecutter.project_slug}}/docker-compose.yml +126 -0
  30. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/.dockerignore +2 -0
  31. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/.jscpd.json +9 -0
  32. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/.npmrc +7 -0
  33. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/Dockerfile.dev +17 -0
  34. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/eslint.config.js +32 -0
  35. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/index.html +14 -0
  36. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/package.json +43 -0
  37. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/postcss.config.js +6 -0
  38. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/public/favicon.svg +6 -0
  39. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/App.tsx +18 -0
  40. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/api/health.ts +21 -0
  41. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/index.css +11 -0
  42. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/layout.tsx +51 -0
  43. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/main.tsx +11 -0
  44. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/pages/home.tsx +28 -0
  45. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/routes.tsx +24 -0
  46. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/vite-env.d.ts +1 -0
  47. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/tailwind.config.ts +13 -0
  48. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/tsconfig.json +19 -0
  49. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/vite.config.ts +18 -0
  50. karni_cli/_templates/project/{{cookiecutter.project_slug}}/manage.py +23 -0
  51. karni_cli/_templates/project/{{cookiecutter.project_slug}}/pyproject.toml +31 -0
  52. karni_cli/_templates/project/{{cookiecutter.project_slug}}/tests/__init__.py +0 -0
  53. karni_cli/_templates/project/{{cookiecutter.project_slug}}/tests/test_api_schema.py +17 -0
  54. karni_cli/_templates/project/{{cookiecutter.project_slug}}/tests/test_smoke.py +11 -0
  55. karni_cli/adapters.py +373 -0
  56. karni_cli/agent_adapters.toml +160 -0
  57. karni_cli/agent_data.py +260 -0
  58. karni_cli/brand.py +55 -0
  59. karni_cli/clidoc.py +254 -0
  60. karni_cli/commands/__init__.py +0 -0
  61. karni_cli/commands/add.py +144 -0
  62. karni_cli/commands/beat.py +36 -0
  63. karni_cli/commands/check.py +101 -0
  64. karni_cli/commands/config.py +81 -0
  65. karni_cli/commands/context.py +171 -0
  66. karni_cli/commands/createsuperuser.py +28 -0
  67. karni_cli/commands/doctor.py +136 -0
  68. karni_cli/commands/explain.py +60 -0
  69. karni_cli/commands/make.py +55 -0
  70. karni_cli/commands/manage_passthrough.py +28 -0
  71. karni_cli/commands/mcp.py +20 -0
  72. karni_cli/commands/migrate.py +23 -0
  73. karni_cli/commands/new.py +106 -0
  74. karni_cli/commands/routes.py +20 -0
  75. karni_cli/commands/rules.py +139 -0
  76. karni_cli/commands/run_tests.py +38 -0
  77. karni_cli/commands/schedule.py +40 -0
  78. karni_cli/commands/seed.py +34 -0
  79. karni_cli/commands/serve.py +50 -0
  80. karni_cli/commands/shell.py +28 -0
  81. karni_cli/commands/sync_roles.py +32 -0
  82. karni_cli/commands/types.py +72 -0
  83. karni_cli/commands/upgrade.py +231 -0
  84. karni_cli/commands/worker.py +63 -0
  85. karni_cli/config.py +142 -0
  86. karni_cli/environment.py +29 -0
  87. karni_cli/errorcodes.py +377 -0
  88. karni_cli/errordoc.py +227 -0
  89. karni_cli/generators/__init__.py +67 -0
  90. karni_cli/generators/apitest.py +206 -0
  91. karni_cli/generators/app.py +103 -0
  92. karni_cli/generators/command.py +98 -0
  93. karni_cli/generators/component.py +115 -0
  94. karni_cli/generators/core.py +319 -0
  95. karni_cli/generators/event.py +57 -0
  96. karni_cli/generators/factory.py +88 -0
  97. karni_cli/generators/fields.py +343 -0
  98. karni_cli/generators/introspect.py +263 -0
  99. karni_cli/generators/listener.py +168 -0
  100. karni_cli/generators/middleware.py +95 -0
  101. karni_cli/generators/migration.py +82 -0
  102. karni_cli/generators/model.py +235 -0
  103. karni_cli/generators/module.py +255 -0
  104. karni_cli/generators/naming.py +100 -0
  105. karni_cli/generators/page.py +373 -0
  106. karni_cli/generators/pysource.py +304 -0
  107. karni_cli/generators/schedule.py +174 -0
  108. karni_cli/generators/seeder.py +86 -0
  109. karni_cli/generators/serializer.py +165 -0
  110. karni_cli/generators/service.py +68 -0
  111. karni_cli/generators/task.py +84 -0
  112. karni_cli/generators/viewset.py +179 -0
  113. karni_cli/history.py +59 -0
  114. karni_cli/local_state.py +73 -0
  115. karni_cli/main.py +104 -0
  116. karni_cli/mcp_server.py +103 -0
  117. karni_cli/project.py +248 -0
  118. karni_cli/rules_render.py +86 -0
  119. karni_cli/scaffold.py +197 -0
  120. karni_cli/settings_edit.py +128 -0
  121. karni_cli/tips.py +53 -0
  122. karni_cli/typegen.py +101 -0
  123. karni_cli/upgrades/__init__.py +28 -0
  124. karni_cli/upgrades/changelog.py +123 -0
  125. karni_cli/upgrades/codemod.py +125 -0
  126. karni_cli/upgrades/engine.py +224 -0
  127. karni_cli/upgrades/legacy_agent_files.py +138 -0
  128. karni_cli/upgrades/python.py +670 -0
  129. karni_cli/upgrades/transforms/rename-export.cjs +96 -0
  130. karni_cli/upgrades/typescript.py +160 -0
  131. karni_cli/upgrades/v0_2/__init__.py +1 -0
  132. karni_cli/upgrades/v0_2/agent_guidance.py +269 -0
  133. karni_cli/upgrades/v1_0/__init__.py +5 -0
  134. karni_cli/upgrades/v1_0/all_objects_to_without.py +18 -0
  135. karni_cli/upgrades/v1_0/all_with_deleted_to_without.py +13 -0
  136. karni_cli/upgrades/v1_0/deleted_only_to_without_dead.py +13 -0
  137. karni_cli/upgrades/v1_0/object_permission_classes_to_access.py +16 -0
  138. karni_cli/upgrades/v1_0/related_lookups_to_optimize.py +15 -0
  139. karni_cli/upgrades/v1_0/scoped_related_lookups_to_optimize.py +15 -0
  140. karni_cli/upgrades/v1_0/soft_delete_manager_mixin_to_scoped.py +14 -0
  141. karni_cli-0.3.1.dist-info/METADATA +191 -0
  142. karni_cli-0.3.1.dist-info/RECORD +145 -0
  143. karni_cli-0.3.1.dist-info/WHEEL +4 -0
  144. karni_cli-0.3.1.dist-info/entry_points.txt +2 -0
  145. karni_cli-0.3.1.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,194 @@
1
+ """Project overrides: ``karni.rules.py`` at the project root.
2
+
3
+ The file is read **statically** — parsed with :mod:`ast`, every value
4
+ evaluated with :func:`ast.literal_eval` — and never imported or executed, so
5
+ the CLI and the MCP server can honour it without running project code.
6
+ Schema (every name optional)::
7
+
8
+ # karni.rules.py
9
+ ADD = [ # project rules; codes are yours (any prefix except K), e.g. P001
10
+ {"code": "P001", "rule": "Money is always a Decimal, never a float.",
11
+ "summary": "Money is Decimal, never float.", # optional (defaults to rule)
12
+ "category": "project", "severity": "must", # optional
13
+ "rationale": "...", "fix": "..."}, # optional
14
+ ]
15
+ OVERRIDE = {"K202": {"summary": "Page files stay at 80 lines or fewer."}}
16
+ DISABLE = ["K304"]
17
+ ADAPTERS = {"disable": ["copilot"], "paths": {"windsurf": ".devin/rules/karni.md"}}
18
+
19
+ ``ADD += [...]`` and ``DISABLE += [...]`` are accepted (the upgrade codemod
20
+ appends that way). Anything else at top level is an error that names the line.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import ast
26
+ import re
27
+ from dataclasses import dataclass, field, replace
28
+ from pathlib import Path
29
+ from typing import Any
30
+
31
+ from .rules import CATEGORIES, RULES, SEVERITIES, Rule
32
+
33
+ OVERRIDE_FILE = "karni.rules.py"
34
+
35
+ _NAMES = ("ADD", "OVERRIDE", "DISABLE", "ADAPTERS")
36
+ _EXTENDABLE = ("ADD", "DISABLE")
37
+ #: Fields a project may change on a Karni rule. ``code`` / ``enforced_by`` are fixed.
38
+ OVERRIDABLE_FIELDS = frozenset({"summary", "rule", "rationale", "fix", "severity", "category"})
39
+ _ADD_FIELDS = OVERRIDABLE_FIELDS | {"code"}
40
+ #: Project codes: 1-3 capitals (never a bare K…, which is Karni's) + 3 digits.
41
+ PROJECT_CODE_RE = re.compile(r"^(?!K\d)[A-Z]{1,3}\d{3}$")
42
+
43
+
44
+ class OverrideError(ValueError):
45
+ """``karni.rules.py`` is malformed; the message says where and why (code ``E701``)."""
46
+
47
+ code = "E701"
48
+
49
+ def __str__(self) -> str:
50
+ return f"{self.code}: {super().__str__()} (karni explain {self.code})"
51
+
52
+
53
+ @dataclass
54
+ class ProjectOverrides:
55
+ path: Path | None = None
56
+ add: list[dict[str, Any]] = field(default_factory=list)
57
+ override: dict[str, dict[str, Any]] = field(default_factory=dict)
58
+ disable: list[str] = field(default_factory=list)
59
+ adapters: dict[str, Any] = field(default_factory=dict)
60
+
61
+
62
+ def _literal(node: ast.expr, name: str, path: Path) -> Any:
63
+ try:
64
+ return ast.literal_eval(node)
65
+ except ValueError:
66
+ raise OverrideError(
67
+ f"{path}:{node.lineno}: {name} must be a literal (strings, lists, dicts) — "
68
+ "karni.rules.py is read statically and never executed."
69
+ ) from None
70
+
71
+
72
+ def parse_overrides(source: str, path: Path) -> ProjectOverrides:
73
+ try:
74
+ tree = ast.parse(source, filename=str(path))
75
+ except SyntaxError as exc:
76
+ raise OverrideError(f"{path}:{exc.lineno}: syntax error: {exc.msg}") from None
77
+ values: dict[str, Any] = {}
78
+ for stmt in tree.body:
79
+ if isinstance(stmt, ast.Expr) and isinstance(stmt.value, ast.Constant):
80
+ continue # docstrings / bare strings
81
+ if (
82
+ isinstance(stmt, ast.Assign)
83
+ and len(stmt.targets) == 1
84
+ and isinstance(stmt.targets[0], ast.Name)
85
+ and stmt.targets[0].id in _NAMES
86
+ ):
87
+ name = stmt.targets[0].id
88
+ values[name] = _literal(stmt.value, name, path)
89
+ continue
90
+ if (
91
+ isinstance(stmt, ast.AugAssign)
92
+ and isinstance(stmt.op, ast.Add)
93
+ and isinstance(stmt.target, ast.Name)
94
+ and stmt.target.id in _EXTENDABLE
95
+ ):
96
+ name = stmt.target.id
97
+ extra = _literal(stmt.value, name, path)
98
+ if not isinstance(extra, list):
99
+ raise OverrideError(f"{path}:{stmt.lineno}: {name} += expects a list.")
100
+ values[name] = [*values.get(name, []), *extra]
101
+ continue
102
+ raise OverrideError(
103
+ f"{path}:{stmt.lineno}: only literal assignments to {', '.join(_NAMES)} "
104
+ "(and ADD/DISABLE +=) are allowed in karni.rules.py."
105
+ )
106
+ return _validate(values, path)
107
+
108
+
109
+ def _validate(values: dict[str, Any], path: Path) -> ProjectOverrides:
110
+ known = {rule.code for rule in RULES}
111
+ add = values.get("ADD", [])
112
+ override = values.get("OVERRIDE", {})
113
+ disable = values.get("DISABLE", [])
114
+ adapters = values.get("ADAPTERS", {})
115
+ if not isinstance(add, list) or not all(isinstance(e, dict) for e in add):
116
+ raise OverrideError(f"{path}: ADD must be a list of dicts.")
117
+ if not isinstance(override, dict) or not all(isinstance(v, dict) for v in override.values()):
118
+ raise OverrideError(f"{path}: OVERRIDE must map codes to dicts of fields.")
119
+ if not isinstance(disable, list) or not all(isinstance(c, str) for c in disable):
120
+ raise OverrideError(f"{path}: DISABLE must be a list of codes.")
121
+ if not isinstance(adapters, dict) or set(adapters) - {"disable", "paths"}:
122
+ raise OverrideError(f'{path}: ADAPTERS accepts only "disable" and "paths".')
123
+
124
+ seen: set[str] = set()
125
+ for entry in add:
126
+ code = entry.get("code")
127
+ if not isinstance(code, str) or not PROJECT_CODE_RE.match(code):
128
+ raise OverrideError(
129
+ f"{path}: ADD code {code!r} must look like P001 (K codes are Karni's)."
130
+ )
131
+ if code in seen:
132
+ raise OverrideError(f"{path}: ADD code {code} is defined twice.")
133
+ seen.add(code)
134
+ if not isinstance(entry.get("rule"), str) or not entry["rule"].strip():
135
+ raise OverrideError(f"{path}: ADD {code} needs a non-empty 'rule'.")
136
+ _check_fields(entry, _ADD_FIELDS, f"ADD {code}", path)
137
+ for code, fields in override.items():
138
+ if code not in known:
139
+ raise OverrideError(f"{path}: OVERRIDE {code} is not a Karni rule code.")
140
+ _check_fields(fields, OVERRIDABLE_FIELDS, f"OVERRIDE {code}", path)
141
+ for code in disable:
142
+ if code not in known:
143
+ raise OverrideError(f"{path}: DISABLE {code} is not a Karni rule code.")
144
+ return ProjectOverrides(
145
+ path=path, add=add, override=override, disable=disable, adapters=adapters
146
+ )
147
+
148
+
149
+ def _check_fields(entry: dict[str, Any], allowed: frozenset[str], where: str, path: Path) -> None:
150
+ unknown = set(entry) - allowed
151
+ if unknown:
152
+ raise OverrideError(f"{path}: {where} has unknown field(s): {', '.join(sorted(unknown))}.")
153
+ for key, value in entry.items():
154
+ if not isinstance(value, str):
155
+ raise OverrideError(f"{path}: {where}.{key} must be a string.")
156
+ if "severity" in entry and entry["severity"] not in SEVERITIES:
157
+ raise OverrideError(f"{path}: {where}.severity must be one of {', '.join(SEVERITIES)}.")
158
+
159
+
160
+ def read_overrides(root: Path | str | None) -> ProjectOverrides:
161
+ """Overrides from ``<root>/karni.rules.py``; empty when there is no file."""
162
+ if root is None:
163
+ return ProjectOverrides()
164
+ path = Path(root) / OVERRIDE_FILE
165
+ if not path.is_file():
166
+ return ProjectOverrides()
167
+ return parse_overrides(path.read_text(encoding="utf-8"), path)
168
+
169
+
170
+ def resolve_rules(overrides: ProjectOverrides) -> list[Rule]:
171
+ """Karni's rules with the project's DISABLE / OVERRIDE / ADD applied, in render order."""
172
+ rules: list[Rule] = []
173
+ for rule in RULES:
174
+ if rule.code in overrides.disable:
175
+ continue
176
+ changes = overrides.override.get(rule.code)
177
+ rules.append(replace(rule, **changes) if changes else rule)
178
+ for entry in overrides.add:
179
+ rules.append(
180
+ Rule(
181
+ code=entry["code"],
182
+ category=entry.get("category", "project"),
183
+ severity=entry.get("severity", "must"),
184
+ summary=entry.get("summary", entry["rule"]),
185
+ rule=entry["rule"],
186
+ rationale=entry.get("rationale", ""),
187
+ fix=entry.get("fix", ""),
188
+ )
189
+ )
190
+ order = {name: i for i, name in enumerate(CATEGORIES)}
191
+ extra = [r.category for r in rules if r.category not in order]
192
+ for name in dict.fromkeys(extra):
193
+ order[name] = len(order)
194
+ return sorted(rules, key=lambda r: (order[r.category], r.code))