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.
- karni_cli/__init__.py +3 -0
- karni_cli/_agent/__init__.py +82 -0
- karni_cli/_agent/__main__.py +44 -0
- karni_cli/_agent/check.py +658 -0
- karni_cli/_agent/history.py +328 -0
- karni_cli/_agent/history_chronicle.py +246 -0
- karni_cli/_agent/overrides.py +194 -0
- karni_cli/_agent/rules.py +673 -0
- karni_cli/_changelog/0.2.0.toml +304 -0
- karni_cli/_changelog/0.3.0.toml +25 -0
- karni_cli/_changelog/__init__.py +248 -0
- karni_cli/_errorcodes/__init__.py +1171 -0
- karni_cli/_errorcodes/__main__.py +34 -0
- karni_cli/_errorcodes/links.py +217 -0
- karni_cli/_templates/project/cookiecutter.json +8 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/.dockerignore +7 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/.env.example +85 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/.gitignore +10 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/Caddyfile +22 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/Dockerfile +53 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/Makefile +52 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/README.md +169 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/config/__init__.py +16 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/config/asgi.py +14 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/config/celery.py +17 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/config/settings.py +28 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/config/urls.py +31 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/config/wsgi.py +14 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/docker-compose.yml +126 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/.dockerignore +2 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/.jscpd.json +9 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/.npmrc +7 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/Dockerfile.dev +17 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/eslint.config.js +32 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/index.html +14 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/package.json +43 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/postcss.config.js +6 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/public/favicon.svg +6 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/App.tsx +18 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/api/health.ts +21 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/index.css +11 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/layout.tsx +51 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/main.tsx +11 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/pages/home.tsx +28 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/routes.tsx +24 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/vite-env.d.ts +1 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/tailwind.config.ts +13 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/tsconfig.json +19 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/vite.config.ts +18 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/manage.py +23 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/pyproject.toml +31 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/tests/__init__.py +0 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/tests/test_api_schema.py +17 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/tests/test_smoke.py +11 -0
- karni_cli/adapters.py +373 -0
- karni_cli/agent_adapters.toml +160 -0
- karni_cli/agent_data.py +260 -0
- karni_cli/brand.py +55 -0
- karni_cli/clidoc.py +254 -0
- karni_cli/commands/__init__.py +0 -0
- karni_cli/commands/add.py +144 -0
- karni_cli/commands/beat.py +36 -0
- karni_cli/commands/check.py +101 -0
- karni_cli/commands/config.py +81 -0
- karni_cli/commands/context.py +171 -0
- karni_cli/commands/createsuperuser.py +28 -0
- karni_cli/commands/doctor.py +136 -0
- karni_cli/commands/explain.py +60 -0
- karni_cli/commands/make.py +55 -0
- karni_cli/commands/manage_passthrough.py +28 -0
- karni_cli/commands/mcp.py +20 -0
- karni_cli/commands/migrate.py +23 -0
- karni_cli/commands/new.py +106 -0
- karni_cli/commands/routes.py +20 -0
- karni_cli/commands/rules.py +139 -0
- karni_cli/commands/run_tests.py +38 -0
- karni_cli/commands/schedule.py +40 -0
- karni_cli/commands/seed.py +34 -0
- karni_cli/commands/serve.py +50 -0
- karni_cli/commands/shell.py +28 -0
- karni_cli/commands/sync_roles.py +32 -0
- karni_cli/commands/types.py +72 -0
- karni_cli/commands/upgrade.py +231 -0
- karni_cli/commands/worker.py +63 -0
- karni_cli/config.py +142 -0
- karni_cli/environment.py +29 -0
- karni_cli/errorcodes.py +377 -0
- karni_cli/errordoc.py +227 -0
- karni_cli/generators/__init__.py +67 -0
- karni_cli/generators/apitest.py +206 -0
- karni_cli/generators/app.py +103 -0
- karni_cli/generators/command.py +98 -0
- karni_cli/generators/component.py +115 -0
- karni_cli/generators/core.py +319 -0
- karni_cli/generators/event.py +57 -0
- karni_cli/generators/factory.py +88 -0
- karni_cli/generators/fields.py +343 -0
- karni_cli/generators/introspect.py +263 -0
- karni_cli/generators/listener.py +168 -0
- karni_cli/generators/middleware.py +95 -0
- karni_cli/generators/migration.py +82 -0
- karni_cli/generators/model.py +235 -0
- karni_cli/generators/module.py +255 -0
- karni_cli/generators/naming.py +100 -0
- karni_cli/generators/page.py +373 -0
- karni_cli/generators/pysource.py +304 -0
- karni_cli/generators/schedule.py +174 -0
- karni_cli/generators/seeder.py +86 -0
- karni_cli/generators/serializer.py +165 -0
- karni_cli/generators/service.py +68 -0
- karni_cli/generators/task.py +84 -0
- karni_cli/generators/viewset.py +179 -0
- karni_cli/history.py +59 -0
- karni_cli/local_state.py +73 -0
- karni_cli/main.py +104 -0
- karni_cli/mcp_server.py +103 -0
- karni_cli/project.py +248 -0
- karni_cli/rules_render.py +86 -0
- karni_cli/scaffold.py +197 -0
- karni_cli/settings_edit.py +128 -0
- karni_cli/tips.py +53 -0
- karni_cli/typegen.py +101 -0
- karni_cli/upgrades/__init__.py +28 -0
- karni_cli/upgrades/changelog.py +123 -0
- karni_cli/upgrades/codemod.py +125 -0
- karni_cli/upgrades/engine.py +224 -0
- karni_cli/upgrades/legacy_agent_files.py +138 -0
- karni_cli/upgrades/python.py +670 -0
- karni_cli/upgrades/transforms/rename-export.cjs +96 -0
- karni_cli/upgrades/typescript.py +160 -0
- karni_cli/upgrades/v0_2/__init__.py +1 -0
- karni_cli/upgrades/v0_2/agent_guidance.py +269 -0
- karni_cli/upgrades/v1_0/__init__.py +5 -0
- karni_cli/upgrades/v1_0/all_objects_to_without.py +18 -0
- karni_cli/upgrades/v1_0/all_with_deleted_to_without.py +13 -0
- karni_cli/upgrades/v1_0/deleted_only_to_without_dead.py +13 -0
- karni_cli/upgrades/v1_0/object_permission_classes_to_access.py +16 -0
- karni_cli/upgrades/v1_0/related_lookups_to_optimize.py +15 -0
- karni_cli/upgrades/v1_0/scoped_related_lookups_to_optimize.py +15 -0
- karni_cli/upgrades/v1_0/soft_delete_manager_mixin_to_scoped.py +14 -0
- karni_cli-0.3.1.dist-info/METADATA +191 -0
- karni_cli-0.3.1.dist-info/RECORD +145 -0
- karni_cli-0.3.1.dist-info/WHEEL +4 -0
- karni_cli-0.3.1.dist-info/entry_points.txt +2 -0
- 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])
|