vflask 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.
- vflask/__init__.py +5 -0
- vflask/cli/__init__.py +5 -0
- vflask/cli/main.py +55 -0
- vflask/cli/watch.py +15 -0
- vflask/core/__init__.py +7 -0
- vflask/core/docgen.py +49 -0
- vflask/core/scaffolder.py +203 -0
- vflask/core/watcher.py +68 -0
- vflask/templates/module/__init__.py.j2 +1 -0
- vflask/templates/module/docs.md.j2 +43 -0
- vflask/templates/module/handlers.py.j2 +73 -0
- vflask/templates/module/models.py.j2 +32 -0
- vflask/templates/module/routes.py.j2 +58 -0
- vflask/templates/module/services.py.j2 +100 -0
- vflask/templates/module/test_module.py.j2 +0 -0
- vflask/templates/project/README.md.j2 +47 -0
- vflask/templates/project/app/cli.py.j2 +57 -0
- vflask/templates/project/app/templates/base.html.j2 +42 -0
- vflask/templates/project/app/templates/logs.html.j2 +23 -0
- vflask/templates/project/app/watcher.py.j2 +60 -0
- vflask/templates/project/migrations/env.py.j2 +49 -0
- vflask/templates/project/scripts/build.sh.j2 +26 -0
- vflask/templates/project/scripts/dev.sh.j2 +25 -0
- vflask/templates/project/scripts/help.sh.j2 +11 -0
- vflask/templates/project/scripts/push.sh.j2 +16 -0
- vflask/templates/project/scripts/setup.sh.j2 +37 -0
- vflask/templates/project/scripts/start.sh.j2 +32 -0
- vflask/templates/project/tests/conftest.py.j2 +53 -0
- vflask/templates/project/tests/helpers.py.j2 +18 -0
- vflask-0.1.0.dist-info/METADATA +401 -0
- vflask-0.1.0.dist-info/RECORD +34 -0
- vflask-0.1.0.dist-info/WHEEL +5 -0
- vflask-0.1.0.dist-info/entry_points.txt +2 -0
- vflask-0.1.0.dist-info/top_level.txt +1 -0
vflask/__init__.py
ADDED
vflask/cli/__init__.py
ADDED
vflask/cli/main.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Command line interface for vflask."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
|
|
9
|
+
from vflask.core.scaffolder import ModuleScaffolder, ProjectScaffolder, parse_field_spec
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@click.group()
|
|
13
|
+
def cli() -> None:
|
|
14
|
+
"""Create typed Flask projects and modules."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@cli.command("new")
|
|
18
|
+
@click.argument("project_name")
|
|
19
|
+
@click.option("--path", "destination", default=".", show_default=True, help="Directory to generate the project into.")
|
|
20
|
+
def new_project(project_name: str, destination: str) -> None:
|
|
21
|
+
"""Create a new project scaffold."""
|
|
22
|
+
root = Path(destination)
|
|
23
|
+
project_dir = ProjectScaffolder.render_project(project_name, root)
|
|
24
|
+
click.echo(f"✅ Created project '{project_name}' in {project_dir}")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@cli.group("module")
|
|
28
|
+
def module_group() -> None:
|
|
29
|
+
"""Module-related commands."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@module_group.command("create")
|
|
33
|
+
@click.argument("module_name")
|
|
34
|
+
@click.option("-f", "--field", "fields", multiple=True, help="Field definition as name:type[:flag]. Example: name:string:index")
|
|
35
|
+
@click.option("-r", "--role", "roles", multiple=True, default=("admin",), show_default=True, help="Roles allowed to access the module.")
|
|
36
|
+
@click.option("--project-root", default=".", show_default=True, help="Root of the generated project.")
|
|
37
|
+
def create_module(module_name: str, fields: tuple[str, ...], roles: tuple[str, ...], project_root: str) -> None:
|
|
38
|
+
"""Create a Flask module inside an existing project."""
|
|
39
|
+
project_dir = Path(project_root).resolve()
|
|
40
|
+
parsed_fields = [parse_field_spec(spec) for spec in fields]
|
|
41
|
+
ModuleScaffolder.create_module(project_dir, module_name, parsed_fields, list(roles))
|
|
42
|
+
click.echo(f"✅ Created module '{module_name}' in {project_dir / 'app' / 'modules'}")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@cli.command("watch")
|
|
46
|
+
@click.option("--project-root", default=".", show_default=True, help="Project root to watch.")
|
|
47
|
+
def watch(project_root: str) -> None:
|
|
48
|
+
"""Start the project watcher for module docs auto-generation."""
|
|
49
|
+
from vflask.core.watcher import start_watcher
|
|
50
|
+
|
|
51
|
+
start_watcher(Path(project_root).resolve())
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
if __name__ == "__main__":
|
|
55
|
+
cli()
|
vflask/cli/watch.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Watch command entrypoint for vflask generated projects."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from vflask.core.watcher import start_watcher
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def main() -> None:
|
|
11
|
+
start_watcher(Path.cwd())
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
if __name__ == "__main__":
|
|
15
|
+
main()
|
vflask/core/__init__.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Core generation logic for vflask."""
|
|
2
|
+
|
|
3
|
+
from vflask.core.docgen import DocGenerator
|
|
4
|
+
from vflask.core.scaffolder import ModuleScaffolder, ProjectScaffolder
|
|
5
|
+
from vflask.core.watcher import start_watcher
|
|
6
|
+
|
|
7
|
+
__all__ = ["DocGenerator", "ModuleScaffolder", "ProjectScaffolder", "start_watcher"]
|
vflask/core/docgen.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Documentation generation helpers for vflask modules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from jinja2 import Environment, FileSystemLoader, StrictUndefined, select_autoescape
|
|
9
|
+
|
|
10
|
+
TEMPLATE_ROOT = Path(__file__).resolve().parents[1] / "templates"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class DocGenerator:
|
|
14
|
+
"""Generate markdown documentation for module folders."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, project_root: str | Path):
|
|
17
|
+
self.project_root = Path(project_root).expanduser().resolve()
|
|
18
|
+
self.env = Environment(
|
|
19
|
+
loader=FileSystemLoader(str(TEMPLATE_ROOT)),
|
|
20
|
+
autoescape=select_autoescape(["html", "xml", "md"]),
|
|
21
|
+
trim_blocks=True,
|
|
22
|
+
lstrip_blocks=True,
|
|
23
|
+
undefined=StrictUndefined,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
def generate_for_module(self, module_dir: str | Path) -> Path:
|
|
27
|
+
module_path = Path(module_dir).expanduser().resolve()
|
|
28
|
+
module_name = module_path.name
|
|
29
|
+
class_name = "".join(part.capitalize() for part in re.split(r"[_-]+", module_name))
|
|
30
|
+
roles = ["admin"]
|
|
31
|
+
template = self.env.get_template("module/docs.md.j2")
|
|
32
|
+
content = template.render(
|
|
33
|
+
module={
|
|
34
|
+
"name": module_name,
|
|
35
|
+
"class_name": class_name,
|
|
36
|
+
"table_name": module_name,
|
|
37
|
+
"plural_class": class_name,
|
|
38
|
+
"singular": module_name.rstrip("s"),
|
|
39
|
+
"url_prefix": f"/api/v1/{module_name}",
|
|
40
|
+
},
|
|
41
|
+
fields=[],
|
|
42
|
+
roles=roles,
|
|
43
|
+
)
|
|
44
|
+
output = module_path / "docs.md"
|
|
45
|
+
output.write_text(content, encoding="utf-8")
|
|
46
|
+
return output
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
__all__ = ["DocGenerator"]
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""Scaffolding logic for creating typed Flask projects and modules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
|
12
|
+
|
|
13
|
+
TEMPLATE_ROOT = Path(__file__).resolve().parents[1] / "templates"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(slots=True)
|
|
17
|
+
class FieldSpec:
|
|
18
|
+
name: str
|
|
19
|
+
type: str
|
|
20
|
+
nullable: bool = False
|
|
21
|
+
unique: bool = False
|
|
22
|
+
index: bool = False
|
|
23
|
+
default: str | None = None
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def sqlalchemy_type(self) -> str:
|
|
27
|
+
mapping = {
|
|
28
|
+
"string": "db.String(255)",
|
|
29
|
+
"text": "db.Text",
|
|
30
|
+
"integer": "db.Integer",
|
|
31
|
+
"int": "db.Integer",
|
|
32
|
+
"float": "db.Float",
|
|
33
|
+
"decimal": "db.Numeric(10, 2)",
|
|
34
|
+
"boolean": "db.Boolean",
|
|
35
|
+
"bool": "db.Boolean",
|
|
36
|
+
"date": "db.Date",
|
|
37
|
+
"datetime": "db.DateTime",
|
|
38
|
+
"json": "db.JSON",
|
|
39
|
+
"uuid": "db.String(36)",
|
|
40
|
+
}
|
|
41
|
+
return mapping.get(self.type.lower(), "db.String(255)")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def parse_field_spec(spec: str) -> dict[str, Any]:
|
|
45
|
+
if not spec or ":" not in spec:
|
|
46
|
+
raise ValueError(f"Invalid field definition: {spec!r}. Use name:type[:flag]")
|
|
47
|
+
|
|
48
|
+
name, type_name, *flags = spec.split(":")
|
|
49
|
+
name = name.strip()
|
|
50
|
+
type_name = (type_name or "string").strip().lower()
|
|
51
|
+
field = {
|
|
52
|
+
"name": name,
|
|
53
|
+
"type": type_name,
|
|
54
|
+
"nullable": True,
|
|
55
|
+
"unique": False,
|
|
56
|
+
"index": False,
|
|
57
|
+
"default": None,
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
for flag in flags:
|
|
61
|
+
cleaned = flag.strip().lower()
|
|
62
|
+
if not cleaned:
|
|
63
|
+
continue
|
|
64
|
+
if cleaned in {"required", "notnull", "nonnullable"}:
|
|
65
|
+
field["nullable"] = False
|
|
66
|
+
elif cleaned in {"unique"}:
|
|
67
|
+
field["unique"] = True
|
|
68
|
+
elif cleaned in {"index"}:
|
|
69
|
+
field["index"] = True
|
|
70
|
+
elif cleaned.startswith("default="):
|
|
71
|
+
field["default"] = cleaned.split("=", 1)[1]
|
|
72
|
+
elif cleaned in {"nullable"}:
|
|
73
|
+
field["nullable"] = True
|
|
74
|
+
|
|
75
|
+
if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", name):
|
|
76
|
+
raise ValueError(f"Invalid field name: {name!r}")
|
|
77
|
+
|
|
78
|
+
return field
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class ProjectScaffolder:
|
|
82
|
+
"""Scaffold a generated Flask project from Jinja2 templates."""
|
|
83
|
+
|
|
84
|
+
@staticmethod
|
|
85
|
+
def render_project(project_name: str, destination: str | Path = ".") -> Path:
|
|
86
|
+
root = Path(destination).expanduser().resolve()
|
|
87
|
+
name = project_name.strip()
|
|
88
|
+
if not name:
|
|
89
|
+
raise ValueError("Project name cannot be empty.")
|
|
90
|
+
|
|
91
|
+
project_dir = root if root.name == name else root / name
|
|
92
|
+
project_dir.mkdir(parents=True, exist_ok=True)
|
|
93
|
+
|
|
94
|
+
env = Environment(
|
|
95
|
+
loader=FileSystemLoader(str(TEMPLATE_ROOT)),
|
|
96
|
+
autoescape=select_autoescape(["html", "xml", "md"]),
|
|
97
|
+
trim_blocks=True,
|
|
98
|
+
lstrip_blocks=True,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
files_to_render = {
|
|
102
|
+
"project/README.md.j2": project_dir / "README.md",
|
|
103
|
+
"project/app/cli.py.j2": project_dir / "app" / "cli.py",
|
|
104
|
+
"project/app/watcher.py.j2": project_dir / "app" / "watcher.py",
|
|
105
|
+
"project/scripts/start.sh.j2": project_dir / "scripts" / "start.sh",
|
|
106
|
+
"project/scripts/push.sh.j2": project_dir / "scripts" / "push.sh",
|
|
107
|
+
"project/scripts/help.sh.j2": project_dir / "scripts" / "help.sh",
|
|
108
|
+
"project/tests/conftest.py.j2": project_dir / "tests" / "conftest.py",
|
|
109
|
+
"project/tests/helpers.py.j2": project_dir / "tests" / "helpers.py",
|
|
110
|
+
"project/migrations/env.py.j2": project_dir / "migrations" / "env.py",
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
for template_name, output_path in files_to_render.items():
|
|
114
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
115
|
+
content = env.get_template(template_name).render(project_name=name)
|
|
116
|
+
output_path.write_text(content, encoding="utf-8")
|
|
117
|
+
if template_name.endswith(".sh.j2"):
|
|
118
|
+
os.chmod(output_path, 0o755)
|
|
119
|
+
|
|
120
|
+
generated_files = {
|
|
121
|
+
"requirements.txt": "click==8.1.7\nFlask==3.1.0\nFlask-SQLAlchemy==3.1.1\nFlask-Migrate==4.0.7\nFlask-JWT-Extended==4.7.1\nFlask-Cors==5.0.0\npsycopg[binary]==3.3.6\npython-dotenv==1.0.1\nwatchdog==4.0.1\nrich==13.0.0\ngunicorn==23.0.0\npytest==8.3.1\n",
|
|
122
|
+
".env.example": "FLASK_APP=app:create_app\nFLASK_DEBUG=1\nSECRET_KEY=changeme\nDATABASE_URL=postgresql+psycopg://app:app@localhost:5432/app\nJWT_SECRET_KEY=super-secret\nREDIS_URL=redis://localhost:6379/0\nPORT=5000\n",
|
|
123
|
+
"docker-compose.yml": """services:\n db:\n image: postgres:16\n environment:\n POSTGRES_DB: app\n POSTGRES_USER: app\n POSTGRES_PASSWORD: app\n ports:\n - \"5432:5432\"\n volumes:\n - postgres_data:/var/lib/postgresql/data\n\n redis:\n image: redis:7-alpine\n ports:\n - \"6379:6379\"\n\nvolumes:\n postgres_data:\n""",
|
|
124
|
+
"app/__init__.py": """from __future__ import annotations\n\nfrom flask import Flask\n\nfrom app.config import AppConfig\nfrom app.extensions import db, jwt, migrate\n\n\ndef create_app(config_object: type | None = None) -> Flask:\n app = Flask(__name__)\n app.config.from_object(config_object or AppConfig)\n\n db.init_app(app)\n jwt.init_app(app)\n migrate.init_app(app, db)\n\n from app.modules import register_modules\n\n register_modules(app)\n\n @app.get(\"/api/v1/health\")\n def healthcheck() -> tuple[dict, int]:\n return {\"status\": \"ok\"}, 200\n\n return app\n""",
|
|
125
|
+
"app/config.py": """from __future__ import annotations\n\nimport os\n\n\nclass AppConfig:\n SECRET_KEY = os.getenv(\"SECRET_KEY\", \"development-secret\")\n SQLALCHEMY_DATABASE_URI = os.getenv(\"DATABASE_URL\", \"postgresql+psycopg://app:app@localhost:5432/app\")\n SQLALCHEMY_TRACK_MODIFICATIONS = False\n JWT_SECRET_KEY = os.getenv(\"JWT_SECRET_KEY\", \"jwt-secret\")\n JSON_SORT_KEYS = False\n\n\nclass TestingConfig(AppConfig):\n TESTING = True\n SQLALCHEMY_DATABASE_URI = \"sqlite:///:memory:\"\n""",
|
|
126
|
+
"app/extensions.py": """from __future__ import annotations\n\nfrom flask_jwt_extended import JWTManager\nfrom flask_migrate import Migrate\nfrom flask_sqlalchemy import SQLAlchemy\n\ndb = SQLAlchemy()\nmigrate = Migrate()\njwt = JWTManager()\n""",
|
|
127
|
+
"app/base.py": """from __future__ import annotations\n\nfrom functools import wraps\nfrom typing import Any\n\nfrom flask import jsonify, request\nfrom flask_jwt_extended import get_jwt_identity, jwt_required\n\n\ndef api_ok(data: Any | None = None, msg: str = \"success\", status: int = 200):\n return jsonify({\"status\": status, \"msg\": msg, \"data\": data}), status\n\n\ndef api_error(msg: str, status: int = 400):\n return jsonify({\"status\": status, \"msg\": msg, \"data\": None}), status\n\n\ndef paginated_response(data: list[Any], total: int, page: int, limit: int):\n return api_ok({\"items\": data, \"total\": total, \"page\": page, \"limit\": limit})\n\n\ndef auth_required(view):\n @wraps(view)\n @jwt_required()\n def wrapper(*args, **kwargs):\n return view(*args, **kwargs)\n\n return wrapper\n\n\ndef role_required(*roles: str):\n def decorator(view):\n @wraps(view)\n @jwt_required()\n def wrapper(*args, **kwargs):\n identity = get_jwt_identity()\n if not identity:\n return api_error(\"Authentication required\", 401)\n if roles and identity not in roles:\n return api_error(\"Forbidden\", 403)\n return view(*args, **kwargs)\n\n return wrapper\n\n return decorator\n\n
|
|
128
|
+
def get_user_id() -> int:\n return int(get_jwt_identity())\n""",
|
|
129
|
+
"app/shared/__init__.py": """from app.shared.models import Role, User\n""",
|
|
130
|
+
"app/shared/mixins.py": """from __future__ import annotations\n\nfrom datetime import datetime\n\nfrom app.extensions import db\n\n\nclass TimestampMixin:\n created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)\n updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)\n\n\nclass SoftDeleteMixin:\n deleted_at = db.Column(db.DateTime, nullable=True, default=None)\n\n def soft_delete(self) -> None:\n self.deleted_at = datetime.utcnow()\n\n def restore(self) -> None:\n self.deleted_at = None\n""",
|
|
131
|
+
"app/shared/models.py": """from __future__ import annotations\n\nfrom datetime import datetime\n\nfrom werkzeug.security import check_password_hash, generate_password_hash\n\nfrom app.extensions import db\nfrom app.shared.mixins import SoftDeleteMixin, TimestampMixin\n\n\nclass Role(db.Model):\n __tablename__ = \"roles\"\n\n id = db.Column(db.Integer, primary_key=True)\n name = db.Column(db.String(80), unique=True, nullable=False, index=True)\n description = db.Column(db.String(255), nullable=True)\n\n\nuser_roles = db.Table(\n \"user_roles\",\n db.Column(\"user_id\", db.Integer, db.ForeignKey(\"users.id\"), primary_key=True),\n db.Column(\"role_id\", db.Integer, db.ForeignKey(\"roles.id\"), primary_key=True),\n)\n\n\nclass User(SoftDeleteMixin, TimestampMixin, db.Model):\n __tablename__ = \"users\"\n\n id = db.Column(db.Integer, primary_key=True)\n email = db.Column(db.String(255), unique=True, nullable=False, index=True)\n password_hash = db.Column(db.String(255), nullable=False)\n name = db.Column(db.String(120), nullable=False)\n is_active = db.Column(db.Boolean, default=True, nullable=False)\n roles = db.relationship(\"Role\", secondary=user_roles, backref=db.backref(\"users\", lazy=\"dynamic\"))\n\n def set_password(self, password: str) -> None:\n self.password_hash = generate_password_hash(password)\n\n def check_password(self, password: str) -> bool:\n return check_password_hash(self.password_hash, password)\n\n def to_dict(self) -> dict:\n return {\n \"id\": self.id,\n \"email\": self.email,\n \"name\": self.name,\n \"isActive\": self.is_active,\n \"roles\": [role.name for role in self.roles],\n }\n""",
|
|
132
|
+
"app/shared/rbac.py": """from __future__ import annotations\n\nfrom app.extensions import db\nfrom app.shared.models import Role, User\n\n\nclass RBACService:\n @staticmethod\n def seed_roles() -> None:\n defaults = [\n (\"admin\", \"Full access\"),\n (\"editor\", \"Can edit records\"),\n (\"viewer\", \"Read-only access\"),\n ]\n for name, description in defaults:\n if not db.session.query(Role).filter_by(name=name).first():\n db.session.add(Role(name=name, description=description))\n db.session.commit()\n\n @staticmethod\n def assign_role(user: User, role_name: str) -> None:\n role = db.session.query(Role).filter_by(name=role_name).first()\n if role and role not in user.roles:\n user.roles.append(role)\n db.session.commit()\n\n @staticmethod\n def user_has_role(user: User, *required_roles: str) -> bool:\n if not required_roles:\n return True\n if not user:\n return False\n names = {role.name for role in user.roles}\n return bool(set(required_roles) & names)\n""",
|
|
133
|
+
"app/modules/__init__.py": """from __future__ import annotations\n\nfrom importlib import import_module\nfrom pathlib import Path\n\nfrom flask import Flask\n\n\ndef register_modules(app: Flask) -> None:\n modules_dir = Path(__file__).resolve().parent\n for child in sorted(modules_dir.iterdir()):\n if child.is_dir() and (child / \"routes.py\").exists():\n module = import_module(f\"app.modules.{child.name}.routes\")\n if hasattr(module, \"register_routes\"):\n module.register_routes(app)\n""",
|
|
134
|
+
"app/modules/.gitkeep": "",
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
for relative_path, content in generated_files.items():
|
|
138
|
+
file_path = project_dir / relative_path
|
|
139
|
+
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
140
|
+
if relative_path.endswith(".gitkeep"):
|
|
141
|
+
file_path.touch(exist_ok=True)
|
|
142
|
+
continue
|
|
143
|
+
file_path.write_text(content, encoding="utf-8")
|
|
144
|
+
|
|
145
|
+
return project_dir
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class ModuleScaffolder:
|
|
149
|
+
"""Create a module inside a generated Flask project."""
|
|
150
|
+
|
|
151
|
+
@staticmethod
|
|
152
|
+
def create_module(project_root: str | Path, module_name: str, fields: list[dict[str, Any]] | None = None, roles: list[str] | None = None) -> Path:
|
|
153
|
+
root = Path(project_root).expanduser().resolve()
|
|
154
|
+
module_key = re.sub(r"[^a-zA-Z0-9_]+", "_", module_name).strip("_").lower() or "module"
|
|
155
|
+
class_name = "".join(part.capitalize() for part in module_key.split("_"))
|
|
156
|
+
plural_name = module_key if module_key.endswith("s") else f"{module_key}s"
|
|
157
|
+
singular_name = module_key
|
|
158
|
+
|
|
159
|
+
module_dir = root / "app" / "modules" / plural_name
|
|
160
|
+
module_dir.mkdir(parents=True, exist_ok=True)
|
|
161
|
+
|
|
162
|
+
env = Environment(
|
|
163
|
+
loader=FileSystemLoader(str(TEMPLATE_ROOT)),
|
|
164
|
+
autoescape=select_autoescape(["html", "xml", "md"]),
|
|
165
|
+
trim_blocks=True,
|
|
166
|
+
lstrip_blocks=True,
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
context = {
|
|
170
|
+
"module": {
|
|
171
|
+
"name": module_key,
|
|
172
|
+
"plural_class": class_name if class_name.endswith("s") else class_name,
|
|
173
|
+
"class_name": class_name,
|
|
174
|
+
"table_name": plural_name,
|
|
175
|
+
"singular": singular_name,
|
|
176
|
+
"url_prefix": f"/api/v1/{plural_name}",
|
|
177
|
+
},
|
|
178
|
+
"fields": [
|
|
179
|
+
{
|
|
180
|
+
**field,
|
|
181
|
+
"sqlalchemy_type": FieldSpec(**field).sqlalchemy_type,
|
|
182
|
+
}
|
|
183
|
+
for field in (fields or [])
|
|
184
|
+
],
|
|
185
|
+
"roles": list(roles or ["admin"]),
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
for template_name, out_name in {
|
|
189
|
+
"module/__init__.py.j2": "__init__.py",
|
|
190
|
+
"module/models.py.j2": "models.py",
|
|
191
|
+
"module/services.py.j2": "service.py",
|
|
192
|
+
"module/handlers.py.j2": "handlers.py",
|
|
193
|
+
"module/routes.py.j2": "routes.py",
|
|
194
|
+
"module/docs.md.j2": "docs.md",
|
|
195
|
+
"module/test_module.py.j2": "test_module.py",
|
|
196
|
+
}.items():
|
|
197
|
+
content = env.get_template(template_name).render(**context)
|
|
198
|
+
(module_dir / out_name).write_text(content, encoding="utf-8")
|
|
199
|
+
|
|
200
|
+
return module_dir
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
__all__ = ["FieldSpec", "ProjectScaffolder", "ModuleScaffolder", "parse_field_spec"]
|
vflask/core/watcher.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Watch for generated module changes and regenerate docs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
from watchdog.events import FileSystemEvent, FileSystemEventHandler
|
|
10
|
+
from watchdog.observers import Observer
|
|
11
|
+
|
|
12
|
+
console = Console()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ModuleHandler(FileSystemEventHandler):
|
|
16
|
+
def __init__(self, project_root: Path):
|
|
17
|
+
self.project_root = project_root
|
|
18
|
+
self.debounce: dict[str, float] = {}
|
|
19
|
+
|
|
20
|
+
def on_modified(self, event: FileSystemEvent) -> None:
|
|
21
|
+
if event.is_directory:
|
|
22
|
+
return
|
|
23
|
+
path = Path(event.src_path)
|
|
24
|
+
if path.name != "handlers.py" or "__pycache__" in path.parts:
|
|
25
|
+
return
|
|
26
|
+
|
|
27
|
+
now = time.time()
|
|
28
|
+
key = str(path)
|
|
29
|
+
if key in self.debounce and now - self.debounce[key] < 0.5:
|
|
30
|
+
return
|
|
31
|
+
self.debounce[key] = now
|
|
32
|
+
|
|
33
|
+
console.print(f" [yellow]📝[/] {path.relative_to(self.project_root)}")
|
|
34
|
+
self._regenerate_docs(path.parent)
|
|
35
|
+
|
|
36
|
+
def _regenerate_docs(self, module_dir: Path) -> None:
|
|
37
|
+
try:
|
|
38
|
+
from vflask.core.docgen import DocGenerator
|
|
39
|
+
|
|
40
|
+
docgen = DocGenerator(self.project_root)
|
|
41
|
+
docgen.generate_for_module(module_dir)
|
|
42
|
+
console.print(f" [green]📚[/] {module_dir.name}/docs.md regenerated")
|
|
43
|
+
except Exception as exc: # pragma: no cover
|
|
44
|
+
console.print(f" [red]✗[/] Doc error: {exc}")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def start_watcher(project_root: Path) -> None:
|
|
48
|
+
observer = Observer()
|
|
49
|
+
handler = ModuleHandler(Path(project_root).resolve())
|
|
50
|
+
modules_path = Path(project_root).resolve() / "app" / "modules"
|
|
51
|
+
if not modules_path.exists():
|
|
52
|
+
console.print(f"[yellow]No modules directory at {modules_path}; creating it.[/]")
|
|
53
|
+
modules_path.mkdir(parents=True, exist_ok=True)
|
|
54
|
+
|
|
55
|
+
observer.schedule(handler, str(modules_path), recursive=True)
|
|
56
|
+
observer.start()
|
|
57
|
+
console.print(f"[bold green]👁️ Watching {modules_path}[/]")
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
while True:
|
|
61
|
+
time.sleep(1)
|
|
62
|
+
except KeyboardInterrupt:
|
|
63
|
+
observer.stop()
|
|
64
|
+
finally:
|
|
65
|
+
observer.join()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
__all__ = ["ModuleHandler", "start_watcher"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""{{ module.plural_class }} module."""
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# {{ module.plural_class }} Module
|
|
2
|
+
|
|
3
|
+
Auto-generated documentation for the `{{ module.table_name }}` module.
|
|
4
|
+
|
|
5
|
+
## Model
|
|
6
|
+
|
|
7
|
+
**{{ module.class_name }}**
|
|
8
|
+
|
|
9
|
+
| Field | Type | Nullable | Unique | Index |
|
|
10
|
+
|-------|------|----------|--------|-------|
|
|
11
|
+
| id | Integer | No | Yes | Yes |
|
|
12
|
+
{% for field in fields %}
|
|
13
|
+
| {{ field.name }} | {{ field.type }} | {{ "Yes" if field.nullable else "No" }} | {{ "Yes" if field.unique else "No" }} | {{ "Yes" if field.index else "No" }} |
|
|
14
|
+
{% endfor %}
|
|
15
|
+
| created_at | DateTime | No | No | No |
|
|
16
|
+
| updated_at | DateTime | No | No | No |
|
|
17
|
+
| deleted_at | DateTime | Yes | No | No |
|
|
18
|
+
|
|
19
|
+
## Endpoints
|
|
20
|
+
|
|
21
|
+
| Method | Path | Auth | Roles | Description |
|
|
22
|
+
|--------|------|------|-------|-------------|
|
|
23
|
+
| POST | `{{ module.url_prefix }}/list` | ✅ | {{ roles|join(', ') }} | List {{ module.table_name }} |
|
|
24
|
+
| POST | `{{ module.url_prefix }}/` | ✅ | {{ roles|join(', ') }} | Create {{ module.singular }} |
|
|
25
|
+
| GET | `{{ module.url_prefix }}/<id>` | ✅ | Any | Get {{ module.singular }} |
|
|
26
|
+
| PUT | `{{ module.url_prefix }}/<id>` | ✅ | {{ roles|join(', ') }} | Update {{ module.singular }} |
|
|
27
|
+
| DELETE | `{{ module.url_prefix }}/<id>` | ✅ | {{ roles|join(', ') }} | Delete {{ module.singular }} |
|
|
28
|
+
| POST | `{{ module.url_prefix }}/bulk-delete` | ✅ | {{ roles|join(', ') }} | Bulk delete |
|
|
29
|
+
|
|
30
|
+
## Usage Examples
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
# List {{ module.table_name }}
|
|
34
|
+
curl -X POST http://localhost:5000{{ module.url_prefix }}/list \\
|
|
35
|
+
-H "Authorization: Bearer $TOKEN" \\
|
|
36
|
+
-H "Content-Type: application/json" \\
|
|
37
|
+
-d '{"pagination": {"page": 1, "limit": 10}, "columns": [], "search": ""}'
|
|
38
|
+
|
|
39
|
+
# Create {{ module.singular }}
|
|
40
|
+
curl -X POST http://localhost:5000{{ module.url_prefix }}/ \\
|
|
41
|
+
-H "Authorization: Bearer $TOKEN" \\
|
|
42
|
+
-H "Content-Type: application/json" \\
|
|
43
|
+
-d '{ {% for field in fields %}"{{ field.name }}": ...{% if not loop.last %}, {% endif %}{% endfor %} }'
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""{{ module.plural_class }} request handlers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from flask import request
|
|
6
|
+
|
|
7
|
+
from app.base import api_error, api_ok, paginated_response
|
|
8
|
+
from app.modules.{{ module.table_name }}.service import {{ module.class_name }}Service
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class {{ module.class_name }}Handler:
|
|
12
|
+
"""Handle HTTP requests for {{ module.plural_class }}."""
|
|
13
|
+
|
|
14
|
+
def __init__(self) -> None:
|
|
15
|
+
self.service = {{ module.class_name }}Service()
|
|
16
|
+
|
|
17
|
+
def list(self) -> tuple:
|
|
18
|
+
"""List {{ module.table_name }} with pagination and filters."""
|
|
19
|
+
req = request.get_json(silent=True) or {}
|
|
20
|
+
if "pagination" not in req:
|
|
21
|
+
return api_error("Invalid payload: pagination required")
|
|
22
|
+
try:
|
|
23
|
+
items, total = self.service.list_{{ module.table_name }}(req)
|
|
24
|
+
except ValueError as e:
|
|
25
|
+
return api_error(str(e))
|
|
26
|
+
except Exception as e:
|
|
27
|
+
return api_error(str(e), 500)
|
|
28
|
+
return paginated_response(
|
|
29
|
+
[i.to_dict() for i in items],
|
|
30
|
+
total,
|
|
31
|
+
req["pagination"]["page"],
|
|
32
|
+
req["pagination"]["limit"],
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
def create(self) -> tuple:
|
|
36
|
+
"""Create a new {{ module.singular }}."""
|
|
37
|
+
body = request.get_json(silent=True) or {}
|
|
38
|
+
if not body:
|
|
39
|
+
return api_error("Invalid payload")
|
|
40
|
+
try:
|
|
41
|
+
item = self.service.create_{{ module.singular }}(body)
|
|
42
|
+
except Exception as e:
|
|
43
|
+
return api_error(str(e), 500)
|
|
44
|
+
return api_ok(item.to_dict(), msg="created", status=201)
|
|
45
|
+
|
|
46
|
+
def get(self, item_id: int) -> tuple:
|
|
47
|
+
"""Get a single {{ module.singular }}."""
|
|
48
|
+
item = self.service.get_{{ module.singular }}(item_id)
|
|
49
|
+
if not item or item.deleted_at:
|
|
50
|
+
return api_error("{{ module.class_name }} not found", 404)
|
|
51
|
+
return api_ok(item.to_dict())
|
|
52
|
+
|
|
53
|
+
def update(self, item_id: int) -> tuple:
|
|
54
|
+
"""Update a {{ module.singular }}."""
|
|
55
|
+
body = request.get_json(silent=True) or {}
|
|
56
|
+
item = self.service.update_{{ module.singular }}(item_id, body)
|
|
57
|
+
if not item:
|
|
58
|
+
return api_error("{{ module.class_name }} not found", 404)
|
|
59
|
+
return api_ok(item.to_dict(), msg="updated")
|
|
60
|
+
|
|
61
|
+
def delete(self, item_id: int) -> tuple:
|
|
62
|
+
"""Soft delete a {{ module.singular }}."""
|
|
63
|
+
if not self.service.delete_{{ module.singular }}(item_id):
|
|
64
|
+
return api_error("{{ module.class_name }} not found", 404)
|
|
65
|
+
return api_ok(msg="deleted")
|
|
66
|
+
|
|
67
|
+
def bulk_delete(self) -> tuple:
|
|
68
|
+
"""Soft delete multiple {{ module.table_name }}."""
|
|
69
|
+
body = request.get_json(silent=True) or {}
|
|
70
|
+
if "ids" not in body or not isinstance(body["ids"], list):
|
|
71
|
+
return api_error("Invalid payload: ids array required")
|
|
72
|
+
count = self.service.bulk_delete(body["ids"])
|
|
73
|
+
return api_ok({"deleted": count}, msg="bulk deleted successfully")
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""{{ module.plural_class }} models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from app.extensions import db
|
|
6
|
+
from app.shared.mixins import SoftDeleteMixin, TimestampMixin
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class {{ module.class_name }}(SoftDeleteMixin, TimestampMixin, db.Model):
|
|
10
|
+
"""{{ module.class_name }} model."""
|
|
11
|
+
__tablename__ = "{{ module.table_name }}"
|
|
12
|
+
|
|
13
|
+
id = db.Column(db.Integer, primary_key=True)
|
|
14
|
+
{% for field in fields %}
|
|
15
|
+
{{ field.name }} = db.Column({{ field.sqlalchemy_type }}{% if field.nullable %}, nullable=True{% else %}, nullable=False{% endif %}{% if field.unique %}, unique=True{% endif %}{% if field.index %}, index=True{% endif %}{% if field.default %}, default={{ field.default }}{% endif %})
|
|
16
|
+
{% endfor %}
|
|
17
|
+
{% if not fields %}
|
|
18
|
+
name = db.Column(db.String(255), nullable=False, index=True)
|
|
19
|
+
{% endif %}
|
|
20
|
+
|
|
21
|
+
def to_dict(self) -> dict:
|
|
22
|
+
return {
|
|
23
|
+
"id": self.id,
|
|
24
|
+
{% for field in fields %}
|
|
25
|
+
"{{ field.name }}": self.{{ field.name }},
|
|
26
|
+
{% endfor %}
|
|
27
|
+
{% if not fields %}
|
|
28
|
+
"name": self.name,
|
|
29
|
+
{% endif %}
|
|
30
|
+
"createdAt": self.created_at.isoformat(),
|
|
31
|
+
"updatedAt": self.updated_at.isoformat(),
|
|
32
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""{{ module.plural_class }} routes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from flask import Blueprint
|
|
6
|
+
|
|
7
|
+
from app.base import auth_required, role_required
|
|
8
|
+
from app.modules.{{ module.table_name }}.handlers import {{ module.class_name }}Handler
|
|
9
|
+
|
|
10
|
+
bp = Blueprint("{{ module.table_name }}", __name__, url_prefix="{{ module.url_prefix }}")
|
|
11
|
+
handler = {{ module.class_name }}Handler()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@bp.post("/list")
|
|
15
|
+
@auth_required
|
|
16
|
+
@role_required({% for role in roles %}"{{ role }}"{% if not loop.last %}, {% endif %}{% endfor %})
|
|
17
|
+
def list_{{ module.table_name }}() -> tuple:
|
|
18
|
+
"""List {{ module.table_name }}."""
|
|
19
|
+
return handler.list()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@bp.post("/")
|
|
23
|
+
@auth_required
|
|
24
|
+
@role_required({% for role in roles %}"{{ role }}"{% if not loop.last %}, {% endif %}{% endfor %})
|
|
25
|
+
def create_{{ module.singular }}() -> tuple:
|
|
26
|
+
"""Create {{ module.singular }}."""
|
|
27
|
+
return handler.create()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@bp.get("/<int:item_id>")
|
|
31
|
+
@auth_required
|
|
32
|
+
def get_{{ module.singular }}(item_id: int) -> tuple:
|
|
33
|
+
"""Get {{ module.singular }} by ID."""
|
|
34
|
+
return handler.get(item_id)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@bp.put("/<int:item_id>")
|
|
38
|
+
@auth_required
|
|
39
|
+
@role_required({% for role in roles %}"{{ role }}"{% if not loop.last %}, {% endif %}{% endfor %})
|
|
40
|
+
def update_{{ module.singular }}(item_id: int) -> tuple:
|
|
41
|
+
"""Update {{ module.singular }}."""
|
|
42
|
+
return handler.update(item_id)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@bp.delete("/<int:item_id>")
|
|
46
|
+
@auth_required
|
|
47
|
+
@role_required({% for role in roles %}"{{ role }}"{% if not loop.last %}, {% endif %}{% endfor %})
|
|
48
|
+
def delete_{{ module.singular }}(item_id: int) -> tuple:
|
|
49
|
+
"""Delete {{ module.singular }}."""
|
|
50
|
+
return handler.delete(item_id)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@bp.post("/bulk-delete")
|
|
54
|
+
@auth_required
|
|
55
|
+
@role_required({% for role in roles %}"{{ role }}"{% if not loop.last %}, {% endif %}{% endfor %})
|
|
56
|
+
def bulk_delete_{{ module.table_name }}() -> tuple:
|
|
57
|
+
"""Bulk delete {{ module.table_name }}."""
|
|
58
|
+
return handler.bulk_delete()
|