kraf 0.1.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.
- kraf-0.1.0.dist-info/METADATA +68 -0
- kraf-0.1.0.dist-info/RECORD +47 -0
- kraf-0.1.0.dist-info/WHEEL +4 -0
- kraf-0.1.0.dist-info/entry_points.txt +2 -0
- project_initializer/__init__.py +1 -0
- project_initializer/cli.py +71 -0
- project_initializer/config.py +81 -0
- project_initializer/errors.py +14 -0
- project_initializer/name_utils.py +36 -0
- project_initializer/pack.py +186 -0
- project_initializer/packs/common/pack.yaml +8 -0
- project_initializer/packs/common/templates/.gitignore.j2 +7 -0
- project_initializer/packs/common/templates/README.md.j2 +27 -0
- project_initializer/packs/database_postgres/pack.yaml +5 -0
- project_initializer/packs/database_sqlite/pack.yaml +3 -0
- project_initializer/packs/django/pack.yaml +22 -0
- project_initializer/packs/django/templates/manage.py.j2 +14 -0
- project_initializer/packs/django/templates/project/__init__.py.j2 +1 -0
- project_initializer/packs/django/templates/project/asgi.py.j2 +7 -0
- project_initializer/packs/django/templates/project/settings.py.j2 +82 -0
- project_initializer/packs/django/templates/project/urls.py.j2 +13 -0
- project_initializer/packs/django/templates/project/wsgi.py.j2 +7 -0
- project_initializer/packs/django_drf/pack.yaml +14 -0
- project_initializer/packs/django_drf/templates/api/__init__.py.j2 +1 -0
- project_initializer/packs/django_drf/templates/api/urls.py.j2 +7 -0
- project_initializer/packs/django_drf/templates/api/views.py.j2 +7 -0
- project_initializer/packs/django_models/pack.yaml +10 -0
- project_initializer/packs/django_models/templates/core/__init__.py.j2 +1 -0
- project_initializer/packs/django_models/templates/core/models.py.j2 +13 -0
- project_initializer/packs/docker/pack.yaml +9 -0
- project_initializer/packs/docker/templates/Dockerfile.j2 +14 -0
- project_initializer/packs/docker/templates/docker-compose.yml.j2 +33 -0
- project_initializer/packs/fastapi/pack.yaml +14 -0
- project_initializer/packs/fastapi/templates/app/api/health.py.j2 +8 -0
- project_initializer/packs/fastapi/templates/app/main.py.j2 +6 -0
- project_initializer/packs/migrations_alembic/pack.yaml +13 -0
- project_initializer/packs/migrations_alembic/templates/alembic/env.py.j2 +49 -0
- project_initializer/packs/migrations_alembic/templates/alembic.ini.j2 +38 -0
- project_initializer/packs/orm_sqlalchemy/pack.yaml +12 -0
- project_initializer/packs/orm_sqlalchemy/templates/app/db/base.py.j2 +27 -0
- project_initializer/packs/orm_sqlalchemy/templates/app/db/models.py.j2 +9 -0
- project_initializer/packs/orm_sqlalchemy/templates/app/db/session.py.j2 +9 -0
- project_initializer/packs/tooling_pytest/pack.yaml +5 -0
- project_initializer/packs/tooling_ruff/pack.yaml +6 -0
- project_initializer/prompts.py +165 -0
- project_initializer/renderer.py +168 -0
- project_initializer/resources.py +7 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: kraf
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Interactive Python web project initializer for Django, Django REST Framework, and FastAPI.
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Requires-Dist: jinja2>=3.1.0
|
|
7
|
+
Requires-Dist: pyyaml>=6.0.0
|
|
8
|
+
Requires-Dist: questionary>=2.1.0
|
|
9
|
+
Requires-Dist: typer>=0.12.0
|
|
10
|
+
Provides-Extra: dev
|
|
11
|
+
Requires-Dist: pytest>=8.0.0; extra == 'dev'
|
|
12
|
+
Requires-Dist: ruff>=0.5.0; extra == 'dev'
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# kraf
|
|
16
|
+
|
|
17
|
+
Interactive Python web project initializer for Django, Django REST Framework, and FastAPI.
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pipx install kraf
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Usage
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
kraf init
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
The CLI asks for:
|
|
32
|
+
|
|
33
|
+
- Project name
|
|
34
|
+
- Project type: Django, Django with DRF, or FastAPI
|
|
35
|
+
- Database: no database, SQLite, or PostgreSQL
|
|
36
|
+
- FastAPI ORM and migration choices
|
|
37
|
+
- Tooling choices: pytest, Ruff, and Docker
|
|
38
|
+
|
|
39
|
+
Generated projects include:
|
|
40
|
+
|
|
41
|
+
- `Makefile`
|
|
42
|
+
- `.env.example`
|
|
43
|
+
- `.gitignore`
|
|
44
|
+
- `README.md`
|
|
45
|
+
- Runtime dependencies in `requirements.txt`
|
|
46
|
+
- Development dependencies in `requirements-dev.txt`
|
|
47
|
+
|
|
48
|
+
Common generated commands:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
make venv
|
|
52
|
+
make install
|
|
53
|
+
make run
|
|
54
|
+
make test
|
|
55
|
+
make lint
|
|
56
|
+
make format
|
|
57
|
+
make migrate
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Development
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
python -m venv .venv
|
|
64
|
+
. .venv/bin/activate
|
|
65
|
+
pip install -e ".[dev]"
|
|
66
|
+
pytest
|
|
67
|
+
ruff check src tests
|
|
68
|
+
```
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
project_initializer/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
|
|
2
|
+
project_initializer/cli.py,sha256=SPRbHydzYGNGiUFi91fQane1st52JRLzYrxqPyQRRUY,2016
|
|
3
|
+
project_initializer/config.py,sha256=Bct2FpZhcMQAhtUqtiBAW78jDziKntnKDh8gLGR5apA,2465
|
|
4
|
+
project_initializer/errors.py,sha256=ermipxkkNJf9eNpvnuTOpeI2wleFYHAr2knlNUVfbk0,465
|
|
5
|
+
project_initializer/name_utils.py,sha256=tx5-eX082z4W5Bul_pSJocuaMdiz188upmfd-M-A_6s,1191
|
|
6
|
+
project_initializer/pack.py,sha256=Hm4dG8EIvQLgxy0S55CpXnwpzjJLEiAEXUmk9VJhb4g,6867
|
|
7
|
+
project_initializer/prompts.py,sha256=C0Z885RVnGQjItfeZFtYYG8-Q-px3X3nZ-oA-bkMNco,5429
|
|
8
|
+
project_initializer/renderer.py,sha256=GrPlMhMMEyWB6FIr-SlnX-UMzYzfyXCwHkT4rl_dVtI,5878
|
|
9
|
+
project_initializer/resources.py,sha256=dtL-Mf1sBiqy179eqqWF5ksKIew0I50flTSfa6C2m1E,243
|
|
10
|
+
project_initializer/packs/common/pack.yaml,sha256=_v_34fXXEqwmSnjcCFEn3S0pqUnrwW1ZVVLEXo5_rnU,201
|
|
11
|
+
project_initializer/packs/common/templates/.gitignore.j2,sha256=hGLkME_f24y8OeFJYPNkbThaRkxvhbSuyIkD0T5CTds,74
|
|
12
|
+
project_initializer/packs/common/templates/README.md.j2,sha256=DofEoghs8P3k_HE26culIwUFJTG8HjPbzGySsXeCIhw,262
|
|
13
|
+
project_initializer/packs/database_postgres/pack.yaml,sha256=h2hO9iYpiwXFJpBTak2LBUUJgHbWq-b92x4o_93RKUs,129
|
|
14
|
+
project_initializer/packs/database_sqlite/pack.yaml,sha256=Ahg601zlKKTOiJ1f5imavge_4AmF5xfEUpVDEuGhDKY,64
|
|
15
|
+
project_initializer/packs/django/pack.yaml,sha256=xpBHDSlDrlRLrkKXyK1S45Ys6ZLhWcw3Qoslz4J_O-U,725
|
|
16
|
+
project_initializer/packs/django/templates/manage.py.j2,sha256=mZePL41rWhgzYtCka8xPnKbPuRSmF8bzJIw6xe4TUDs,302
|
|
17
|
+
project_initializer/packs/django/templates/project/__init__.py.j2,sha256=n10dVDJufwarAfDH4BJ_44Q9ENIGNSDmQ8pyVulQ40Q,36
|
|
18
|
+
project_initializer/packs/django/templates/project/asgi.py.j2,sha256=_vKq1jq8N3HXNCxOjNsdA82bONQV6yYhK4pdEhujmlk,187
|
|
19
|
+
project_initializer/packs/django/templates/project/settings.py.j2,sha256=kzrrXljqPXrriMB0CYJsE0uFZvDD9kt0qqY1kkvGiso,2466
|
|
20
|
+
project_initializer/packs/django/templates/project/urls.py.j2,sha256=wpCopp_n-Z2k6_xaiW5qcjMfJj-UPdnWLW5sMvFbIOw,334
|
|
21
|
+
project_initializer/packs/django/templates/project/wsgi.py.j2,sha256=87NAin_P4MDI0OThHXk1G51ih4jO9iwWlK8Bu6DVI6o,187
|
|
22
|
+
project_initializer/packs/django_drf/pack.yaml,sha256=Ohh5PGyRkJbtM8LY7g4bstAS1cZ0h8-fQ3tI9On5LjI,284
|
|
23
|
+
project_initializer/packs/django_drf/templates/api/__init__.py.j2,sha256=GQU1tAKpALcoNTm0IxRru4urEtmSxS31lQegQJ9xnwA,47
|
|
24
|
+
project_initializer/packs/django_drf/templates/api/urls.py.j2,sha256=joG9AkPd0H2C11_bsG-xO19gwzHOn01d6_aB_PtAiyA,140
|
|
25
|
+
project_initializer/packs/django_drf/templates/api/views.py.j2,sha256=gT8MX9VXDaITRg5qGokRAnEY6gK4hBigpF242xWSwYo,178
|
|
26
|
+
project_initializer/packs/django_models/pack.yaml,sha256=8M8PFCOEOx1Yhcingv0e6XIX3nyQP11p--djDZoJvbc,199
|
|
27
|
+
project_initializer/packs/django_models/templates/core/__init__.py.j2,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
28
|
+
project_initializer/packs/django_models/templates/core/models.py.j2,sha256=5BcpJ3f5cxzHNIyh7remCcDkh9Da_DLWkYeHW7R0Gb8,367
|
|
29
|
+
project_initializer/packs/docker/pack.yaml,sha256=eYcQ9hV6mowc03aHrvx15Yy2dTPDa-VGFCB14FuRz6g,236
|
|
30
|
+
project_initializer/packs/docker/templates/Dockerfile.j2,sha256=SGgahHZm3IViFONVDbnFcOOo0aRBzxvLAkyLVWmHiRw,303
|
|
31
|
+
project_initializer/packs/docker/templates/docker-compose.yml.j2,sha256=rnAwbkUwuKFNAxWxt4v1-2fFTpE7BRUZPqS53sLAAqI,678
|
|
32
|
+
project_initializer/packs/fastapi/pack.yaml,sha256=1Mao3BnyHSHIeMX9sElusiruzcdLp2E49eroQKWslYM,301
|
|
33
|
+
project_initializer/packs/fastapi/templates/app/main.py.j2,sha256=MwtA16Xt2RhfoxM1t75yRVf4jqqbtKdy7DeWVJdQou4,165
|
|
34
|
+
project_initializer/packs/fastapi/templates/app/api/health.py.j2,sha256=KkvNTc-plGDKt_RarG5dDIUCNLgwjeZ-tUgW0v3aiQc,169
|
|
35
|
+
project_initializer/packs/migrations_alembic/pack.yaml,sha256=R7j_XUJ6IUXirT5P5bl6dPDVD5I5uYuhODdvVQKZCqU,349
|
|
36
|
+
project_initializer/packs/migrations_alembic/templates/alembic.ini.j2,sha256=yrwk2p9_cF_sYIJrXnEQHxrTFVogTiMVkll-X3ffMuM,569
|
|
37
|
+
project_initializer/packs/migrations_alembic/templates/alembic/env.py.j2,sha256=1-vu7alY9_bYvvtPo8omZVn3unQEI3RGXw1vLZdouLA,1245
|
|
38
|
+
project_initializer/packs/orm_sqlalchemy/pack.yaml,sha256=5IRahqdga7_CULQMvbjL58Z9rpI9mVer5mlBQWrM_Ko,275
|
|
39
|
+
project_initializer/packs/orm_sqlalchemy/templates/app/db/base.py.j2,sha256=7hjJLOID0XDa5yFGYm7kszTt3p1WVE6e25qfMxjdj_w,750
|
|
40
|
+
project_initializer/packs/orm_sqlalchemy/templates/app/db/models.py.j2,sha256=p4SMDBIBVYxnGZSj0Qvs8YGjWS20PaknYJt_BZK8roo,194
|
|
41
|
+
project_initializer/packs/orm_sqlalchemy/templates/app/db/session.py.j2,sha256=L1Y_TA_j2Vyh4FShjy-bIZ-KQIgAiwP4SzwdNxS9qcE,268
|
|
42
|
+
project_initializer/packs/tooling_pytest/pack.yaml,sha256=5FOALYdx2DFaNkHnRFLQu8H7-wC0OF1qzE_fI3REDdY,99
|
|
43
|
+
project_initializer/packs/tooling_ruff/pack.yaml,sha256=qduPLJKbbbdRhOl-0z7QRjafHZl-fQ6yUK6I6smBGYI,145
|
|
44
|
+
kraf-0.1.0.dist-info/METADATA,sha256=foR8-MMAZP01X5k69PYHCSnYb1op4zzHD6FKKPYd_FI,1259
|
|
45
|
+
kraf-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
46
|
+
kraf-0.1.0.dist-info/entry_points.txt,sha256=zIl37XEszwXvcbP8GGCrdAQ0Vu_pePx63pSB71TKcnY,53
|
|
47
|
+
kraf-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Annotated
|
|
3
|
+
|
|
4
|
+
import typer
|
|
5
|
+
|
|
6
|
+
from project_initializer import __version__
|
|
7
|
+
from project_initializer.config import normalize_answers
|
|
8
|
+
from project_initializer.errors import ProjectInitializerError
|
|
9
|
+
from project_initializer.pack import resolve_packs
|
|
10
|
+
from project_initializer.prompts import collect_answers
|
|
11
|
+
from project_initializer.renderer import render_project
|
|
12
|
+
from project_initializer.resources import builtin_pack_dirs
|
|
13
|
+
|
|
14
|
+
app = typer.Typer(
|
|
15
|
+
name="kraf",
|
|
16
|
+
help="Create production-ready Python web projects from interactive prompts.",
|
|
17
|
+
no_args_is_help=True,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _version_callback(value: bool) -> None:
|
|
22
|
+
if value:
|
|
23
|
+
typer.echo(f"kraf {__version__}")
|
|
24
|
+
raise typer.Exit()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@app.callback()
|
|
28
|
+
def main(
|
|
29
|
+
version: Annotated[
|
|
30
|
+
bool,
|
|
31
|
+
typer.Option(
|
|
32
|
+
"--version",
|
|
33
|
+
help="Show the installed kraf version.",
|
|
34
|
+
callback=_version_callback,
|
|
35
|
+
is_eager=True,
|
|
36
|
+
),
|
|
37
|
+
] = False,
|
|
38
|
+
) -> None:
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@app.command()
|
|
43
|
+
def init(
|
|
44
|
+
target_root: Annotated[
|
|
45
|
+
Path | None,
|
|
46
|
+
typer.Option(
|
|
47
|
+
"--target-root",
|
|
48
|
+
help="Directory where the generated project folder is created.",
|
|
49
|
+
),
|
|
50
|
+
] = None,
|
|
51
|
+
) -> None:
|
|
52
|
+
_generate(target_root or Path.cwd())
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _generate(target_root: Path) -> None:
|
|
56
|
+
try:
|
|
57
|
+
raw_answers = collect_answers()
|
|
58
|
+
config = normalize_answers(raw_answers, base_dir=target_root)
|
|
59
|
+
pack_dirs = {pack_dir.name: pack_dir for pack_dir in builtin_pack_dirs()}
|
|
60
|
+
packs = resolve_packs(config)
|
|
61
|
+
result = render_project(config, [(pack, pack_dirs[pack.name]) for pack in packs])
|
|
62
|
+
except ProjectInitializerError as exc:
|
|
63
|
+
typer.echo(f"Error: {exc}", err=True)
|
|
64
|
+
raise typer.Exit(code=1) from exc
|
|
65
|
+
|
|
66
|
+
typer.echo(f"Created project at {result.path}")
|
|
67
|
+
typer.echo("Next steps:")
|
|
68
|
+
typer.echo(f" cd {result.path}")
|
|
69
|
+
typer.echo(" make venv")
|
|
70
|
+
typer.echo(" make install")
|
|
71
|
+
typer.echo(" make run")
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from enum import StrEnum
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from project_initializer.errors import InvalidProjectNameError
|
|
7
|
+
from project_initializer.name_utils import normalize_package_name, normalize_project_slug
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ProjectType(StrEnum):
|
|
11
|
+
DJANGO = "django"
|
|
12
|
+
DJANGO_DRF = "django_drf"
|
|
13
|
+
FASTAPI = "fastapi"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Database(StrEnum):
|
|
17
|
+
NONE = "none"
|
|
18
|
+
SQLITE = "sqlite"
|
|
19
|
+
POSTGRESQL = "postgresql"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class ToolingOptions:
|
|
24
|
+
use_docker: bool
|
|
25
|
+
use_pytest: bool
|
|
26
|
+
use_ruff: bool
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class ProjectConfig:
|
|
31
|
+
project_name: str
|
|
32
|
+
project_slug: str
|
|
33
|
+
package_name: str
|
|
34
|
+
target_dir: Path
|
|
35
|
+
project_type: ProjectType
|
|
36
|
+
database: Database
|
|
37
|
+
tooling: ToolingOptions
|
|
38
|
+
use_sqlalchemy: bool
|
|
39
|
+
use_alembic: bool
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def normalize_answers(raw_answers: dict[str, Any], base_dir: Path | None = None) -> ProjectConfig:
|
|
43
|
+
raw_project_name = raw_answers.get("project_name")
|
|
44
|
+
if raw_project_name is None:
|
|
45
|
+
raise InvalidProjectNameError("Project name is required.")
|
|
46
|
+
if not isinstance(raw_project_name, str):
|
|
47
|
+
raise InvalidProjectNameError("Project name must be text.")
|
|
48
|
+
|
|
49
|
+
project_name = raw_project_name.strip()
|
|
50
|
+
project_type = ProjectType(str(raw_answers["project_type"]))
|
|
51
|
+
database = Database(str(raw_answers["database"]))
|
|
52
|
+
package_name = normalize_package_name(project_name)
|
|
53
|
+
project_slug = normalize_project_slug(project_name)
|
|
54
|
+
target_root = base_dir or Path.cwd()
|
|
55
|
+
|
|
56
|
+
use_sqlalchemy = bool(raw_answers.get("use_sqlalchemy", False))
|
|
57
|
+
use_alembic = bool(raw_answers.get("use_alembic", False))
|
|
58
|
+
if project_type is not ProjectType.FASTAPI:
|
|
59
|
+
use_sqlalchemy = False
|
|
60
|
+
use_alembic = False
|
|
61
|
+
if database is Database.NONE:
|
|
62
|
+
use_sqlalchemy = False
|
|
63
|
+
use_alembic = False
|
|
64
|
+
if not use_sqlalchemy:
|
|
65
|
+
use_alembic = False
|
|
66
|
+
|
|
67
|
+
return ProjectConfig(
|
|
68
|
+
project_name=project_name,
|
|
69
|
+
project_slug=project_slug,
|
|
70
|
+
package_name=package_name,
|
|
71
|
+
target_dir=target_root / project_slug,
|
|
72
|
+
project_type=project_type,
|
|
73
|
+
database=database,
|
|
74
|
+
tooling=ToolingOptions(
|
|
75
|
+
use_docker=bool(raw_answers.get("use_docker", False)),
|
|
76
|
+
use_pytest=bool(raw_answers.get("use_pytest", True)),
|
|
77
|
+
use_ruff=bool(raw_answers.get("use_ruff", True)),
|
|
78
|
+
),
|
|
79
|
+
use_sqlalchemy=use_sqlalchemy,
|
|
80
|
+
use_alembic=use_alembic,
|
|
81
|
+
)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
class ProjectInitializerError(Exception):
|
|
2
|
+
"""Base class for user-facing project initializer errors."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class InvalidProjectNameError(ProjectInitializerError):
|
|
6
|
+
"""Raised when a project name cannot produce a valid slug or package name."""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class PackError(ProjectInitializerError):
|
|
10
|
+
"""Raised when pack manifests or pack selections are invalid."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class RenderError(ProjectInitializerError):
|
|
14
|
+
"""Raised when a project cannot be rendered safely."""
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import keyword
|
|
2
|
+
import re
|
|
3
|
+
|
|
4
|
+
from project_initializer.errors import InvalidProjectNameError
|
|
5
|
+
|
|
6
|
+
_NON_ALNUM = re.compile(r"[^A-Za-z0-9]+")
|
|
7
|
+
_MULTIPLE_DASHES = re.compile(r"-+")
|
|
8
|
+
_MULTIPLE_UNDERSCORES = re.compile(r"_+")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def normalize_project_slug(project_name: str) -> str:
|
|
12
|
+
cleaned = project_name.strip()
|
|
13
|
+
if not cleaned:
|
|
14
|
+
raise InvalidProjectNameError("Project name is required.")
|
|
15
|
+
|
|
16
|
+
slug = _NON_ALNUM.sub("-", cleaned).strip("-").lower()
|
|
17
|
+
slug = _MULTIPLE_DASHES.sub("-", slug)
|
|
18
|
+
if not slug:
|
|
19
|
+
raise InvalidProjectNameError("Project name must contain letters or numbers.")
|
|
20
|
+
return slug
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def normalize_package_name(project_name: str) -> str:
|
|
24
|
+
cleaned = project_name.strip()
|
|
25
|
+
if not cleaned:
|
|
26
|
+
raise InvalidProjectNameError("Project name is required.")
|
|
27
|
+
|
|
28
|
+
package_name = _NON_ALNUM.sub("_", cleaned).strip("_").lower()
|
|
29
|
+
package_name = _MULTIPLE_UNDERSCORES.sub("_", package_name)
|
|
30
|
+
|
|
31
|
+
if not package_name or not package_name.isidentifier() or keyword.iskeyword(package_name):
|
|
32
|
+
raise InvalidProjectNameError(
|
|
33
|
+
f"Project name '{project_name}' cannot be converted into a valid Python package name."
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
return package_name
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
from collections.abc import Mapping
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import yaml
|
|
7
|
+
|
|
8
|
+
from project_initializer.config import Database, ProjectConfig, ProjectType
|
|
9
|
+
from project_initializer.errors import PackError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class PackManifest:
|
|
14
|
+
name: str
|
|
15
|
+
dependencies: tuple[str, ...] = ()
|
|
16
|
+
dev_dependencies: tuple[str, ...] = ()
|
|
17
|
+
files: tuple[tuple[str, str], ...] = ()
|
|
18
|
+
make_targets: dict[str, str] = field(default_factory=dict)
|
|
19
|
+
env: dict[str, str] = field(default_factory=dict)
|
|
20
|
+
requires: tuple[str, ...] = ()
|
|
21
|
+
conflicts: tuple[str, ...] = ()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def load_pack(pack_dir: Path) -> PackManifest:
|
|
25
|
+
manifest_path = pack_dir / "pack.yaml"
|
|
26
|
+
try:
|
|
27
|
+
raw = yaml.safe_load(manifest_path.read_text(encoding="utf-8"))
|
|
28
|
+
except FileNotFoundError as exc:
|
|
29
|
+
raise PackError(f"Pack at {pack_dir} is missing pack.yaml.") from exc
|
|
30
|
+
except yaml.YAMLError as exc:
|
|
31
|
+
raise PackError(f"Pack at {pack_dir} has invalid YAML: {exc}") from exc
|
|
32
|
+
|
|
33
|
+
if raw is None:
|
|
34
|
+
raw = {}
|
|
35
|
+
if not isinstance(raw, Mapping):
|
|
36
|
+
raise PackError(f"Pack at {pack_dir} manifest must be a mapping.")
|
|
37
|
+
if "name" not in raw:
|
|
38
|
+
raise PackError(f"Pack at {pack_dir} is missing required field 'name'.")
|
|
39
|
+
|
|
40
|
+
return _manifest_from_mapping(raw, pack_dir)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _manifest_from_mapping(raw: Mapping[str, Any], pack_dir: Path) -> PackManifest:
|
|
44
|
+
name = _require_non_empty_string(raw, "name", pack_dir)
|
|
45
|
+
files = _files_from_mapping(raw, pack_dir)
|
|
46
|
+
|
|
47
|
+
return PackManifest(
|
|
48
|
+
name=name,
|
|
49
|
+
dependencies=_string_tuple_field(raw, "dependencies", pack_dir),
|
|
50
|
+
dev_dependencies=_string_tuple_field(raw, "dev_dependencies", pack_dir),
|
|
51
|
+
files=files,
|
|
52
|
+
make_targets=_string_mapping_field(raw, "make_targets", pack_dir),
|
|
53
|
+
env=_string_mapping_field(raw, "env", pack_dir),
|
|
54
|
+
requires=_string_tuple_field(raw, "requires", pack_dir),
|
|
55
|
+
conflicts=_string_tuple_field(raw, "conflicts", pack_dir),
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _require_non_empty_string(
|
|
60
|
+
raw: Mapping[str, Any], field_name: str, pack_dir: Path
|
|
61
|
+
) -> str:
|
|
62
|
+
value = raw[field_name]
|
|
63
|
+
if not isinstance(value, str) or not value:
|
|
64
|
+
raise PackError(
|
|
65
|
+
f"Pack at {pack_dir} has invalid field '{field_name}'; expected a non-empty string."
|
|
66
|
+
)
|
|
67
|
+
return value
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _string_tuple_field(
|
|
71
|
+
raw: Mapping[str, Any], field_name: str, pack_dir: Path
|
|
72
|
+
) -> tuple[str, ...]:
|
|
73
|
+
value = raw.get(field_name, [])
|
|
74
|
+
if not isinstance(value, (list, tuple)):
|
|
75
|
+
raise PackError(f"Pack at {pack_dir} field '{field_name}' must be a list of strings.")
|
|
76
|
+
if not all(isinstance(item, str) for item in value):
|
|
77
|
+
raise PackError(f"Pack at {pack_dir} field '{field_name}' must be a list of strings.")
|
|
78
|
+
return tuple(value)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _string_mapping_field(
|
|
82
|
+
raw: Mapping[str, Any], field_name: str, pack_dir: Path
|
|
83
|
+
) -> dict[str, str]:
|
|
84
|
+
value = raw.get(field_name, {})
|
|
85
|
+
if not isinstance(value, Mapping):
|
|
86
|
+
raise PackError(
|
|
87
|
+
f"Pack at {pack_dir} field '{field_name}' must be a mapping of strings to strings."
|
|
88
|
+
)
|
|
89
|
+
if not all(isinstance(key, str) and isinstance(item, str) for key, item in value.items()):
|
|
90
|
+
raise PackError(
|
|
91
|
+
f"Pack at {pack_dir} field '{field_name}' must be a mapping of strings to strings."
|
|
92
|
+
)
|
|
93
|
+
return dict(value)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _files_from_mapping(
|
|
97
|
+
raw: Mapping[str, Any], pack_dir: Path
|
|
98
|
+
) -> tuple[tuple[str, str], ...]:
|
|
99
|
+
value = raw.get("files", [])
|
|
100
|
+
if not isinstance(value, (list, tuple)):
|
|
101
|
+
raise PackError(f"Pack at {pack_dir} field 'files' must be a list of file mappings.")
|
|
102
|
+
|
|
103
|
+
files = []
|
|
104
|
+
for file_entry in value:
|
|
105
|
+
if not isinstance(file_entry, Mapping):
|
|
106
|
+
raise PackError(f"Pack at {pack_dir} field 'files' must contain file mappings.")
|
|
107
|
+
source = file_entry.get("source")
|
|
108
|
+
destination = file_entry.get("destination")
|
|
109
|
+
if not isinstance(source, str) or not source:
|
|
110
|
+
raise PackError(f"Pack at {pack_dir} has a file entry without source and destination.")
|
|
111
|
+
if not isinstance(destination, str) or not destination:
|
|
112
|
+
raise PackError(f"Pack at {pack_dir} has a file entry without source and destination.")
|
|
113
|
+
files.append((str(source), str(destination)))
|
|
114
|
+
|
|
115
|
+
return tuple(files)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def resolve_packs(
|
|
119
|
+
config: ProjectConfig,
|
|
120
|
+
*,
|
|
121
|
+
available_packs: dict[str, PackManifest] | None = None,
|
|
122
|
+
explicit_names: list[str] | None = None,
|
|
123
|
+
) -> list[PackManifest]:
|
|
124
|
+
pack_map = _default_pack_manifest_map() if available_packs is None else available_packs
|
|
125
|
+
names = _pack_names_for_config(config) if explicit_names is None else explicit_names
|
|
126
|
+
|
|
127
|
+
selected = [_require_pack(pack_map, name) for name in names]
|
|
128
|
+
selected_names = {pack.name for pack in selected}
|
|
129
|
+
|
|
130
|
+
for pack in selected:
|
|
131
|
+
for required in pack.requires:
|
|
132
|
+
if required not in selected_names:
|
|
133
|
+
raise PackError(f"Pack '{pack.name}' requires pack '{required}'.")
|
|
134
|
+
for conflict in pack.conflicts:
|
|
135
|
+
if conflict in selected_names:
|
|
136
|
+
raise PackError(f"Pack '{pack.name}' conflicts with selected pack '{conflict}'.")
|
|
137
|
+
|
|
138
|
+
return selected
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _require_pack(pack_map: dict[str, PackManifest], name: str) -> PackManifest:
|
|
142
|
+
try:
|
|
143
|
+
return pack_map[name]
|
|
144
|
+
except KeyError as exc:
|
|
145
|
+
raise PackError(f"Required pack '{name}' is not available.") from exc
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _pack_names_for_config(config: ProjectConfig) -> list[str]:
|
|
149
|
+
names = ["common"]
|
|
150
|
+
|
|
151
|
+
if config.project_type is ProjectType.DJANGO:
|
|
152
|
+
names.append("django")
|
|
153
|
+
elif config.project_type is ProjectType.DJANGO_DRF:
|
|
154
|
+
names.extend(["django", "django_drf"])
|
|
155
|
+
elif config.project_type is ProjectType.FASTAPI:
|
|
156
|
+
names.append("fastapi")
|
|
157
|
+
|
|
158
|
+
if config.database is Database.SQLITE:
|
|
159
|
+
names.append("database_sqlite")
|
|
160
|
+
elif config.database is Database.POSTGRESQL:
|
|
161
|
+
names.append("database_postgres")
|
|
162
|
+
|
|
163
|
+
if config.project_type in {ProjectType.DJANGO, ProjectType.DJANGO_DRF} and (
|
|
164
|
+
config.database is not Database.NONE
|
|
165
|
+
):
|
|
166
|
+
names.append("django_models")
|
|
167
|
+
|
|
168
|
+
if config.use_sqlalchemy and config.database is not Database.NONE:
|
|
169
|
+
names.append("orm_sqlalchemy")
|
|
170
|
+
if config.use_alembic and config.database is not Database.NONE:
|
|
171
|
+
names.append("migrations_alembic")
|
|
172
|
+
if config.tooling.use_pytest:
|
|
173
|
+
names.append("tooling_pytest")
|
|
174
|
+
if config.tooling.use_ruff:
|
|
175
|
+
names.append("tooling_ruff")
|
|
176
|
+
if config.tooling.use_docker:
|
|
177
|
+
names.append("docker")
|
|
178
|
+
|
|
179
|
+
return names
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _default_pack_manifest_map() -> dict[str, PackManifest]:
|
|
183
|
+
from project_initializer.resources import builtin_pack_dirs
|
|
184
|
+
|
|
185
|
+
manifests = [load_pack(pack_dir) for pack_dir in builtin_pack_dirs()]
|
|
186
|
+
return {manifest.name: manifest for manifest in manifests}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# {{ project.project_name }}
|
|
2
|
+
|
|
3
|
+
Generated with kraf.
|
|
4
|
+
|
|
5
|
+
## Development
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
make venv
|
|
9
|
+
make install
|
|
10
|
+
make run
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Quality
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
make test
|
|
17
|
+
make lint
|
|
18
|
+
make format
|
|
19
|
+
```
|
|
20
|
+
{% if project.database.value != "none" %}
|
|
21
|
+
|
|
22
|
+
## Database
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
make migrate
|
|
26
|
+
```
|
|
27
|
+
{% endif %}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
name: django
|
|
2
|
+
dependencies:
|
|
3
|
+
- django
|
|
4
|
+
files:
|
|
5
|
+
- source: manage.py.j2
|
|
6
|
+
destination: manage.py
|
|
7
|
+
- source: project/__init__.py.j2
|
|
8
|
+
destination: "{{ project.package_name }}/__init__.py"
|
|
9
|
+
- source: project/settings.py.j2
|
|
10
|
+
destination: "{{ project.package_name }}/settings.py"
|
|
11
|
+
- source: project/urls.py.j2
|
|
12
|
+
destination: "{{ project.package_name }}/urls.py"
|
|
13
|
+
- source: project/wsgi.py.j2
|
|
14
|
+
destination: "{{ project.package_name }}/wsgi.py"
|
|
15
|
+
- source: project/asgi.py.j2
|
|
16
|
+
destination: "{{ project.package_name }}/asgi.py"
|
|
17
|
+
make_targets:
|
|
18
|
+
run: "$(VENV_PYTHON) manage.py runserver"
|
|
19
|
+
migrate: "$(VENV_PYTHON) manage.py migrate"
|
|
20
|
+
makemigrations: "$(VENV_PYTHON) manage.py makemigrations"
|
|
21
|
+
conflicts:
|
|
22
|
+
- fastapi
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def main() -> None:
|
|
7
|
+
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "{{ project.package_name }}.settings")
|
|
8
|
+
from django.core.management import execute_from_command_line
|
|
9
|
+
|
|
10
|
+
execute_from_command_line(sys.argv)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
if __name__ == "__main__":
|
|
14
|
+
main()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Generated Django project package.
|