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,343 @@
1
+ """The ``karni make model`` field DSL: ``name:type[:arg...][?]``.
2
+
3
+ :data:`FIELD_DOCS` lists every type with the Django field it produces (it is
4
+ also rendered into docs/cli.md). A trailing ``?`` makes the field optional:
5
+ ``null=True, blank=True``; string and file fields get ``blank=True`` only
6
+ (Django stores ``""``, not ``NULL``); an optional FK/O2O uses
7
+ ``on_delete=models.SET_NULL``. Choice values are separated by ``|`` (quote
8
+ the argument in a shell) or ``,``. Quote any field with ``?`` in zsh.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import keyword
14
+ import re
15
+ from dataclasses import dataclass, field
16
+
17
+ from karni_cli.generators.naming import pascal, snake
18
+ from karni_cli.generators.pysource import Import, call, pystr
19
+ from karni_cli.project import CliError
20
+
21
+ #: (syntax, what it generates) — the DSL reference, rendered into docs/cli.md.
22
+ FIELD_DOCS: tuple[tuple[str, str], ...] = (
23
+ ("title:str[:len]", "CharField(max_length=len) — 255 when omitted"),
24
+ ("body:text", "TextField()"),
25
+ ("count:int", "IntegerField()"),
26
+ ("ratio:float", "FloatField()"),
27
+ ("price:decimal[:max:places]", "DecimalField(max_digits=max, decimal_places=places) — 10, 2"),
28
+ ("done:bool", "BooleanField(default=False)"),
29
+ ("day:date / at:datetime", "DateField() / DateTimeField()"),
30
+ (
31
+ "email:email / site:url / slug:slug",
32
+ "EmailField() / URLField() / SlugField() (:len optional)",
33
+ ),
34
+ ("meta:json", "JSONField(default=dict)"),
35
+ (
36
+ "doc:file / photo:image",
37
+ 'FileField / ImageField(upload_to="<app>/<model>/") — image needs Pillow',
38
+ ),
39
+ ("ref:uuid", "UUIDField(default=uuid.uuid4)"),
40
+ ("status:choice:draft|live", "a TextChoices class + CharField(choices=..., default=first)"),
41
+ ("owner:fk:User", "ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)"),
42
+ ("venue:fk:Venue / fk:shop.Venue", 'ForeignKey("Venue") in the same app, or "app.Model"'),
43
+ ("tags:m2m:Tag / profile:o2o:User", "ManyToManyField / OneToOneField"),
44
+ ("name:type?", "optional: null=True, blank=True (strings and files: blank=True)"),
45
+ )
46
+
47
+ #: Fields BaseModel already declares, and names its methods/managers use.
48
+ RESERVED = frozenset(
49
+ {
50
+ "id",
51
+ "pk",
52
+ "created_at",
53
+ "updated_at",
54
+ "deleted_at",
55
+ "created_by",
56
+ "updated_by",
57
+ "objects",
58
+ "all_objects",
59
+ "is_deleted",
60
+ "delete",
61
+ "hard_delete",
62
+ "restore",
63
+ "save",
64
+ "clean",
65
+ }
66
+ )
67
+
68
+ ALIASES = {
69
+ "string": "str",
70
+ "char": "str",
71
+ "integer": "int",
72
+ "boolean": "bool",
73
+ "choices": "choice",
74
+ "foreignkey": "fk",
75
+ "manytomany": "m2m",
76
+ "onetoone": "o2o",
77
+ }
78
+
79
+ TYPES = (
80
+ "str",
81
+ "text",
82
+ "int",
83
+ "float",
84
+ "decimal",
85
+ "bool",
86
+ "date",
87
+ "datetime",
88
+ "email",
89
+ "url",
90
+ "slug",
91
+ "json",
92
+ "file",
93
+ "image",
94
+ "uuid",
95
+ "choice",
96
+ "fk",
97
+ "m2m",
98
+ "o2o",
99
+ )
100
+
101
+ _STRINGISH = frozenset({"str", "text", "email", "url", "slug", "file", "image", "choice"})
102
+ _RELATIONS = {"fk": "ForeignKey", "o2o": "OneToOneField", "m2m": "ManyToManyField"}
103
+ _SIMPLE = {
104
+ "text": "TextField",
105
+ "int": "IntegerField",
106
+ "float": "FloatField",
107
+ "date": "DateField",
108
+ "datetime": "DateTimeField",
109
+ }
110
+ _USER_NAMES = frozenset({"user", "auth.user", "settings.auth_user_model"})
111
+ _CHOICE_VALUE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]*$")
112
+
113
+
114
+ @dataclass(frozen=True)
115
+ class FieldSpec:
116
+ name: str
117
+ kind: str
118
+ args: tuple[str, ...]
119
+ optional: bool
120
+
121
+ @property
122
+ def is_relation(self) -> bool:
123
+ return self.kind in _RELATIONS
124
+
125
+ @property
126
+ def is_text(self) -> bool:
127
+ """A plain string column usable as ``__str__`` / search field."""
128
+ return self.kind in {"str", "slug", "email"}
129
+
130
+
131
+ @dataclass
132
+ class Rendered:
133
+ """A model body: inner classes, field lines and the imports they need."""
134
+
135
+ inner: list[str] = field(default_factory=list)
136
+ lines: list[str] = field(default_factory=list)
137
+ imports: list[Import] = field(default_factory=list)
138
+ notes: list[str] = field(default_factory=list)
139
+
140
+
141
+ def parse_field(token: str) -> FieldSpec:
142
+ raw = token.strip()
143
+ optional = raw.endswith("?")
144
+ parts = raw.rstrip("?").split(":")
145
+ if len(parts) < 2 or not parts[0] or not parts[1]:
146
+ raise CliError(
147
+ f"Field {token!r}: expected name:type[:arg][?], e.g. title:str:200 or "
148
+ "starts_at:datetime? (types: " + ", ".join(TYPES) + ").",
149
+ code="C104",
150
+ )
151
+ name, kind, *args = parts
152
+ kind = ALIASES.get(kind.lower(), kind.lower())
153
+ if kind not in TYPES:
154
+ raise CliError(
155
+ f"Field {token!r}: unknown type {kind!r} (types: {', '.join(TYPES)}).", code="C104"
156
+ )
157
+ if not name.isidentifier() or keyword.iskeyword(name) or name != name.lower():
158
+ raise CliError(f"Field {token!r}: {name!r} must be a snake_case identifier.", code="C104")
159
+ if name.startswith("_") or "__" in name:
160
+ raise CliError(
161
+ f"Field {token!r}: names may not start with _ or contain __ (Django).", code="C104"
162
+ )
163
+ if name in RESERVED:
164
+ raise CliError(
165
+ f"Field {token!r}: {name!r} is reserved — BaseModel already provides it "
166
+ "(id, created_at, updated_at, deleted_at, created_by, updated_by) or uses the name.",
167
+ code="C104",
168
+ )
169
+ return FieldSpec(name, kind, tuple(args), optional)
170
+
171
+
172
+ def parse_fields(tokens: list[str]) -> list[FieldSpec]:
173
+ specs = [parse_field(token) for token in tokens]
174
+ seen: set[str] = set()
175
+ for spec in specs:
176
+ if spec.name in seen:
177
+ raise CliError(f"Field {spec.name!r} is declared twice.", code="C104")
178
+ seen.add(spec.name)
179
+ return specs
180
+
181
+
182
+ def _int_arg(spec: FieldSpec, index: int, default: int, what: str) -> int:
183
+ if len(spec.args) <= index or spec.args[index] == "":
184
+ return default
185
+ value = spec.args[index]
186
+ if not value.isdigit() or int(value) <= 0:
187
+ raise CliError(
188
+ f"Field {spec.name}: {what} must be a positive integer, got {value!r}.", code="C104"
189
+ )
190
+ return int(value)
191
+
192
+
193
+ def _optional_kwargs(spec: FieldSpec) -> list[str]:
194
+ if not spec.optional:
195
+ return []
196
+ if spec.kind in _STRINGISH:
197
+ return ["blank=True"]
198
+ return ["null=True", "blank=True"]
199
+
200
+
201
+ @dataclass(frozen=True)
202
+ class RelationTarget:
203
+ """How a relation field refers to its target model."""
204
+
205
+ expr: str
206
+ is_user: bool
207
+ key: str
208
+
209
+
210
+ def render_model_body(
211
+ specs: list[FieldSpec],
212
+ *,
213
+ model: str,
214
+ app_label: str,
215
+ resolve: dict[str, RelationTarget],
216
+ line_length: int,
217
+ ) -> Rendered:
218
+ """Field lines (indented four spaces) for ``model`` in ``app_label``.
219
+
220
+ :param resolve: relation target per field name (``spec.args[0]`` resolved
221
+ by the caller, which can see the project's models).
222
+ """
223
+ out = Rendered()
224
+ indent = " "
225
+ targets = [resolve[s.name].key for s in specs if s.is_relation]
226
+ for spec in specs:
227
+ head = f"{spec.name} = models."
228
+ if spec.kind in {"str", "email", "url", "slug"}:
229
+ django = {"str": "CharField", "email": "EmailField", "url": "URLField"}.get(
230
+ spec.kind, "SlugField"
231
+ )
232
+ args: list[str] = []
233
+ if spec.kind == "str":
234
+ args.append(f"max_length={_int_arg(spec, 0, 255, 'max_length')}")
235
+ elif spec.args and spec.args[0]:
236
+ args.append(f"max_length={_int_arg(spec, 0, 1, 'max_length')}")
237
+ out.lines.append(
238
+ call(head + django, args + _optional_kwargs(spec), indent, line_length)
239
+ )
240
+ elif spec.kind in _SIMPLE:
241
+ out.lines.append(
242
+ call(head + _SIMPLE[spec.kind], _optional_kwargs(spec), indent, line_length)
243
+ )
244
+ elif spec.kind == "decimal":
245
+ digits = _int_arg(spec, 0, 10, "max_digits")
246
+ places = _int_arg(spec, 1, 2, "decimal_places")
247
+ if places > digits:
248
+ raise CliError(
249
+ f"Field {spec.name}: decimal_places ({places}) > max_digits ({digits}).",
250
+ code="C104",
251
+ )
252
+ args = [f"max_digits={digits}", f"decimal_places={places}", *_optional_kwargs(spec)]
253
+ out.lines.append(call(head + "DecimalField", args, indent, line_length))
254
+ elif spec.kind == "bool":
255
+ args = _optional_kwargs(spec) or ["default=False"]
256
+ out.lines.append(call(head + "BooleanField", args, indent, line_length))
257
+ elif spec.kind == "json":
258
+ args = _optional_kwargs(spec) or ["default=dict"]
259
+ out.lines.append(call(head + "JSONField", args, indent, line_length))
260
+ elif spec.kind == "uuid":
261
+ if spec.optional:
262
+ args = _optional_kwargs(spec)
263
+ else:
264
+ args = ["default=uuid.uuid4"]
265
+ out.imports.append(Import("uuid"))
266
+ out.lines.append(call(head + "UUIDField", args, indent, line_length))
267
+ elif spec.kind in {"file", "image"}:
268
+ django = "FileField" if spec.kind == "file" else "ImageField"
269
+ upload = pystr(f"{app_label}/{snake(model)}/")
270
+ args = [f"upload_to={upload}", *_optional_kwargs(spec)]
271
+ out.lines.append(call(head + django, args, indent, line_length))
272
+ if spec.kind == "image":
273
+ out.notes.append(
274
+ f"note: {model}.{spec.name} is an ImageField — the project needs Pillow "
275
+ "(pip install 'karni[images]')."
276
+ )
277
+ elif spec.kind == "choice":
278
+ _render_choice(spec, out, indent, line_length)
279
+ else:
280
+ target = resolve[spec.name]
281
+ args = [target.expr]
282
+ if spec.kind != "m2m":
283
+ if spec.optional:
284
+ args += ["on_delete=models.SET_NULL", "null=True", "blank=True"]
285
+ else:
286
+ args.append("on_delete=models.CASCADE")
287
+ elif spec.optional:
288
+ args.append("blank=True")
289
+ if targets.count(target.key) > 1:
290
+ args.append(f"related_name={pystr(snake(model) + '_' + spec.name + '_set')}")
291
+ out.lines.append(call(head + _RELATIONS[spec.kind], args, indent, line_length))
292
+ if target.is_user:
293
+ out.imports.append(Import("django.conf", "settings"))
294
+ if out.lines:
295
+ out.imports.append(Import("django.db", "models"))
296
+ return out
297
+
298
+
299
+ def _render_choice(spec: FieldSpec, out: Rendered, indent: str, line_length: int) -> None:
300
+ raw = ":".join(spec.args)
301
+ values = [v.strip() for v in re.split(r"[|,]", raw) if v.strip()]
302
+ if not values:
303
+ raise CliError(
304
+ f"Field {spec.name}: choice needs values, e.g. '{spec.name}:choice:draft|published'.",
305
+ code="C104",
306
+ )
307
+ members: list[tuple[str, str]] = []
308
+ for value in values:
309
+ if not _CHOICE_VALUE.match(value):
310
+ raise CliError(
311
+ f"Field {spec.name}: choice value {value!r} may only use letters, digits, - and _.",
312
+ code="C104",
313
+ )
314
+ member = re.sub(r"[^A-Za-z0-9]", "_", value).upper()
315
+ if member[0].isdigit():
316
+ member = f"V_{member}"
317
+ if member in {m for m, _ in members}:
318
+ raise CliError(
319
+ f"Field {spec.name}: choice value {value!r} is listed twice.", code="C104"
320
+ )
321
+ members.append((member, value))
322
+ enum = pascal(spec.name)
323
+ out.inner.append(f"{indent}class {enum}(models.TextChoices):")
324
+ out.inner += [f"{indent} {member} = {pystr(value)}" for member, value in members]
325
+ length = max(20, *(len(value) for _, value in members))
326
+ args = [f"max_length={length}", f"choices={enum}.choices"]
327
+ args += _optional_kwargs(spec) or [f"default={enum}.{members[0][0]}"]
328
+ out.lines.append(call(f"{spec.name} = models.CharField", args, indent, line_length))
329
+
330
+
331
+ def relation_name(spec: FieldSpec) -> str:
332
+ """The raw target the user wrote for a relation field."""
333
+ if not spec.args or not spec.args[0]:
334
+ raise CliError(
335
+ f"Field {spec.name}: {spec.kind} needs a target model, e.g. "
336
+ f"'{spec.name}:{spec.kind}:User' or '{spec.name}:{spec.kind}:shop.Product'.",
337
+ code="C104",
338
+ )
339
+ return spec.args[0]
340
+
341
+
342
+ def is_user_target(name: str) -> bool:
343
+ return name.lower() in _USER_NAMES
@@ -0,0 +1,263 @@
1
+ """Static (AST) facts about a project's apps — what already exists, without Django."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ import fnmatch
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from karni_cli.generators.core import SETTINGS, App, Workspace, local_apps
12
+ from karni_cli.generators.pysource import parse
13
+ from karni_cli.project import CliError
14
+
15
+ #: karni.conf.base's default KARNI_ROLES, mirrored (the CLI runs without karni):
16
+ #: staff holds everything, user and customer their own rows.
17
+ SHIPPED_ROLES: dict[str, dict[str, Any]] = {
18
+ "staff": {"grants": {"*": "all"}},
19
+ "user": {"grants": {"*": "own"}},
20
+ "customer": {"grants": {"*": "own"}},
21
+ }
22
+
23
+ _RELATION_CALLS = frozenset({"ForeignKey", "OneToOneField", "ManyToManyField"})
24
+ _AUDIT_BASES = frozenset({"BaseModel", "AuditedModel"})
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class ModelInfo:
29
+ app: App
30
+ name: str
31
+ path: Path
32
+ #: field name -> the called class name (``CharField``, ``ForeignKey`` …)
33
+ fields: dict[str, str]
34
+ bases: frozenset[str]
35
+
36
+ @property
37
+ def import_module(self) -> str:
38
+ return f"{self.app.module}.models"
39
+
40
+ def is_relation(self, name: str) -> bool:
41
+ if name in {"created_by", "updated_by"}:
42
+ return bool(self.bases & _AUDIT_BASES)
43
+ return self.fields.get(name) in _RELATION_CALLS
44
+
45
+ def has_field(self, name: str) -> bool:
46
+ if name in {"id", "pk", "created_at", "updated_at", "created_by", "updated_by"}:
47
+ return True
48
+ return name in self.fields
49
+
50
+
51
+ def _call_name(node: ast.expr) -> str:
52
+ if isinstance(node, ast.Call):
53
+ func = node.func
54
+ if isinstance(func, ast.Attribute):
55
+ return func.attr
56
+ if isinstance(func, ast.Name):
57
+ return func.id
58
+ return ""
59
+
60
+
61
+ def class_fields(cls: ast.ClassDef) -> dict[str, str]:
62
+ """``name = models.X(...)`` assignments in a class body → ``{name: "X"}``."""
63
+ found: dict[str, str] = {}
64
+ for stmt in cls.body:
65
+ if isinstance(stmt, ast.Assign) and len(stmt.targets) == 1:
66
+ target = stmt.targets[0]
67
+ called = _call_name(stmt.value)
68
+ if isinstance(target, ast.Name) and (
69
+ called.endswith("Field") or called in _RELATION_CALLS
70
+ ):
71
+ found[target.id] = called
72
+ return found
73
+
74
+
75
+ def _bases(cls: ast.ClassDef) -> frozenset[str]:
76
+ names: set[str] = set()
77
+ for base in cls.bases:
78
+ if isinstance(base, ast.Name):
79
+ names.add(base.id)
80
+ elif isinstance(base, ast.Attribute):
81
+ names.add(base.attr)
82
+ return frozenset(names)
83
+
84
+
85
+ def models_file(ws: Workspace, app: App) -> Path:
86
+ """``<app>/models.py``; a ``models/`` package is an unsupported layout."""
87
+ path = app.file("models.py")
88
+ if not ws.exists(path) and ws.is_dir(app.file("models")):
89
+ raise CliError(
90
+ f"{ws.rel(app.file('models'))}/ is a package — karni make writes "
91
+ f"{ws.rel(path)}. Add the class to the package yourself (and re-export it "
92
+ "from models/__init__.py).",
93
+ code="C107",
94
+ )
95
+ return path
96
+
97
+
98
+ def models_in(ws: Workspace, app: App) -> dict[str, ModelInfo]:
99
+ path = models_file(ws, app)
100
+ text = ws.read(path)
101
+ if text is None:
102
+ return {}
103
+ tree = parse(ws, ws.rel(path), text)
104
+ found: dict[str, ModelInfo] = {}
105
+ for node in tree.body:
106
+ if isinstance(node, ast.ClassDef):
107
+ found[node.name] = ModelInfo(app, node.name, path, class_fields(node), _bases(node))
108
+ return found
109
+
110
+
111
+ def find_model(ws: Workspace, app: App, name: str) -> ModelInfo | None:
112
+ """``name`` in ``app``'s models, else in exactly one other local app."""
113
+ own = models_in(ws, app).get(name)
114
+ if own is not None:
115
+ return own
116
+ others = [
117
+ info
118
+ for other in local_apps(ws)
119
+ if other != app
120
+ for info in [models_in(ws, other).get(name)]
121
+ if info is not None
122
+ ]
123
+ return others[0] if len(others) == 1 else None
124
+
125
+
126
+ def require_model(ws: Workspace, app: App, name: str) -> ModelInfo:
127
+ info = find_model(ws, app, name)
128
+ if info is None:
129
+ raise CliError(
130
+ f"No model {name} in {ws.rel(models_file(ws, app))} — create it first: "
131
+ f"karni make model {name} --app {app.label}",
132
+ code="C105",
133
+ )
134
+ return info
135
+
136
+
137
+ def viewsets_file(ws: Workspace, app: App) -> Path:
138
+ """``<app>/viewsets.py``, or ``<app>/api/viewsets.py`` when only that exists."""
139
+ plain = app.file("viewsets.py")
140
+ nested = app.file("api", "viewsets.py")
141
+ if not ws.exists(plain) and ws.exists(nested):
142
+ return nested
143
+ return plain
144
+
145
+
146
+ def viewset_for(ws: Workspace, app: App, model: str) -> tuple[Path, ast.ClassDef] | None:
147
+ """The class in the app's viewsets module whose body says ``model = <model>``."""
148
+ path = viewsets_file(ws, app)
149
+ text = ws.read(path)
150
+ if text is None:
151
+ return None
152
+ for node in parse(ws, ws.rel(path), text).body:
153
+ if not isinstance(node, ast.ClassDef):
154
+ continue
155
+ for stmt in node.body:
156
+ if (
157
+ isinstance(stmt, ast.Assign)
158
+ and any(isinstance(t, ast.Name) and t.id == "model" for t in stmt.targets)
159
+ and isinstance(stmt.value, ast.Name)
160
+ and stmt.value.id == model
161
+ ):
162
+ return path, node
163
+ return None
164
+
165
+
166
+ def owned_paths(cls: ast.ClassDef) -> list[str]:
167
+ """String arguments of every ``Owned("...")`` call in the class body."""
168
+ found: list[str] = []
169
+ for node in ast.walk(cls):
170
+ if (
171
+ isinstance(node, ast.Call)
172
+ and _call_name(node) == "Owned"
173
+ and node.args
174
+ and isinstance(node.args[0], ast.Constant)
175
+ and isinstance(node.args[0].value, str)
176
+ ):
177
+ found.append(node.args[0].value)
178
+ return found
179
+
180
+
181
+ def top_level_functions(ws: Workspace, path: Path) -> set[str]:
182
+ text = ws.read(path)
183
+ if text is None:
184
+ return set()
185
+ tree = parse(ws, ws.rel(path), text)
186
+ return {
187
+ node.name for node in tree.body if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef)
188
+ }
189
+
190
+
191
+ def top_level_assignments(ws: Workspace, path: Path) -> set[str]:
192
+ text = ws.read(path)
193
+ if text is None:
194
+ return set()
195
+ names: set[str] = set()
196
+ for node in parse(ws, ws.rel(path), text).body:
197
+ if isinstance(node, ast.Assign):
198
+ names |= {t.id for t in node.targets if isinstance(t, ast.Name)}
199
+ elif isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name):
200
+ names.add(node.target.id)
201
+ return names
202
+
203
+
204
+ def role_names(cls: ast.ClassDef) -> list[str]:
205
+ """String arguments of every ``HasRole("...")`` call in the class body, in order."""
206
+ found: list[str] = []
207
+ for node in ast.walk(cls):
208
+ if isinstance(node, ast.Call) and _call_name(node) == "HasRole":
209
+ for arg in node.args:
210
+ if (
211
+ isinstance(arg, ast.Constant)
212
+ and isinstance(arg.value, str)
213
+ and arg.value not in found
214
+ ):
215
+ found.append(arg.value)
216
+ return found
217
+
218
+
219
+ def granted_reads(cls: ast.ClassDef) -> bool:
220
+ """Whether the class body calls ``Granted()`` — the roles' grants decide its rows."""
221
+ return any(
222
+ isinstance(node, ast.Call) and _call_name(node) == "Granted" for node in ast.walk(cls)
223
+ )
224
+
225
+
226
+ def declared_roles(ws: Workspace) -> dict[str, dict[str, Any]]:
227
+ """``KARNI_ROLES`` from config/settings.py when it is a literal, else the shipped default."""
228
+ text = ws.read(SETTINGS)
229
+ if text is None:
230
+ return dict(SHIPPED_ROLES)
231
+ for node in parse(ws, ws.rel(SETTINGS), text).body:
232
+ if isinstance(node, ast.Assign) and any(
233
+ isinstance(target, ast.Name) and target.id == "KARNI_ROLES" for target in node.targets
234
+ ):
235
+ try:
236
+ value = ast.literal_eval(node.value)
237
+ except ValueError:
238
+ return {} # computed at runtime: unknown here
239
+ return value if isinstance(value, dict) else {}
240
+ return dict(SHIPPED_ROLES)
241
+
242
+
243
+ def grant_scopes(roles: dict[str, dict[str, Any]], key: str) -> dict[str, str]:
244
+ """Role slug → the scope its grants give ``key`` (``app.model:action``); later patterns win."""
245
+ found: dict[str, str] = {}
246
+ for slug, spec in roles.items():
247
+ grants = spec.get("grants") if isinstance(spec, dict) else None
248
+ if not isinstance(grants, dict):
249
+ continue
250
+ scope = None
251
+ for pattern, value in grants.items():
252
+ glob = "*:*" if pattern == "*" else str(pattern)
253
+ if fnmatch.fnmatchcase(key, glob):
254
+ scope = str(value)
255
+ if scope is not None:
256
+ found[slug] = scope
257
+ return found
258
+
259
+
260
+ def viewset_module(ws: Workspace, app: App, path: Path) -> str:
261
+ """The dotted module of a viewsets file of ``app`` (``events.viewsets``)."""
262
+ rel = path.relative_to(app.path).with_suffix("")
263
+ return ".".join([app.module, *rel.parts])