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.
Files changed (47) hide show
  1. kraf-0.1.0.dist-info/METADATA +68 -0
  2. kraf-0.1.0.dist-info/RECORD +47 -0
  3. kraf-0.1.0.dist-info/WHEEL +4 -0
  4. kraf-0.1.0.dist-info/entry_points.txt +2 -0
  5. project_initializer/__init__.py +1 -0
  6. project_initializer/cli.py +71 -0
  7. project_initializer/config.py +81 -0
  8. project_initializer/errors.py +14 -0
  9. project_initializer/name_utils.py +36 -0
  10. project_initializer/pack.py +186 -0
  11. project_initializer/packs/common/pack.yaml +8 -0
  12. project_initializer/packs/common/templates/.gitignore.j2 +7 -0
  13. project_initializer/packs/common/templates/README.md.j2 +27 -0
  14. project_initializer/packs/database_postgres/pack.yaml +5 -0
  15. project_initializer/packs/database_sqlite/pack.yaml +3 -0
  16. project_initializer/packs/django/pack.yaml +22 -0
  17. project_initializer/packs/django/templates/manage.py.j2 +14 -0
  18. project_initializer/packs/django/templates/project/__init__.py.j2 +1 -0
  19. project_initializer/packs/django/templates/project/asgi.py.j2 +7 -0
  20. project_initializer/packs/django/templates/project/settings.py.j2 +82 -0
  21. project_initializer/packs/django/templates/project/urls.py.j2 +13 -0
  22. project_initializer/packs/django/templates/project/wsgi.py.j2 +7 -0
  23. project_initializer/packs/django_drf/pack.yaml +14 -0
  24. project_initializer/packs/django_drf/templates/api/__init__.py.j2 +1 -0
  25. project_initializer/packs/django_drf/templates/api/urls.py.j2 +7 -0
  26. project_initializer/packs/django_drf/templates/api/views.py.j2 +7 -0
  27. project_initializer/packs/django_models/pack.yaml +10 -0
  28. project_initializer/packs/django_models/templates/core/__init__.py.j2 +1 -0
  29. project_initializer/packs/django_models/templates/core/models.py.j2 +13 -0
  30. project_initializer/packs/docker/pack.yaml +9 -0
  31. project_initializer/packs/docker/templates/Dockerfile.j2 +14 -0
  32. project_initializer/packs/docker/templates/docker-compose.yml.j2 +33 -0
  33. project_initializer/packs/fastapi/pack.yaml +14 -0
  34. project_initializer/packs/fastapi/templates/app/api/health.py.j2 +8 -0
  35. project_initializer/packs/fastapi/templates/app/main.py.j2 +6 -0
  36. project_initializer/packs/migrations_alembic/pack.yaml +13 -0
  37. project_initializer/packs/migrations_alembic/templates/alembic/env.py.j2 +49 -0
  38. project_initializer/packs/migrations_alembic/templates/alembic.ini.j2 +38 -0
  39. project_initializer/packs/orm_sqlalchemy/pack.yaml +12 -0
  40. project_initializer/packs/orm_sqlalchemy/templates/app/db/base.py.j2 +27 -0
  41. project_initializer/packs/orm_sqlalchemy/templates/app/db/models.py.j2 +9 -0
  42. project_initializer/packs/orm_sqlalchemy/templates/app/db/session.py.j2 +9 -0
  43. project_initializer/packs/tooling_pytest/pack.yaml +5 -0
  44. project_initializer/packs/tooling_ruff/pack.yaml +6 -0
  45. project_initializer/prompts.py +165 -0
  46. project_initializer/renderer.py +168 -0
  47. 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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ kraf = project_initializer.cli:app
@@ -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,8 @@
1
+ name: common
2
+ files:
3
+ - source: README.md.j2
4
+ destination: README.md
5
+ - source: .gitignore.j2
6
+ destination: .gitignore
7
+ make_targets:
8
+ migrate: "python -c \"print('No migrations configured')\""
@@ -0,0 +1,7 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.py[cod]
4
+ .pytest_cache/
5
+ .ruff_cache/
6
+ .env
7
+ db.sqlite3
@@ -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,5 @@
1
+ name: database_postgres
2
+ dependencies:
3
+ - psycopg[binary]
4
+ env:
5
+ DATABASE_URL: postgresql://postgres:postgres@localhost:5432/app
@@ -0,0 +1,3 @@
1
+ name: database_sqlite
2
+ env:
3
+ DATABASE_URL: sqlite:///db.sqlite3
@@ -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.
@@ -0,0 +1,7 @@
1
+ import os
2
+
3
+ from django.core.asgi import get_asgi_application
4
+
5
+ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "{{ project.package_name }}.settings")
6
+
7
+ application = get_asgi_application()