flaskit-cli 0.2.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.
flaskit/__init__.py ADDED
@@ -0,0 +1,13 @@
1
+ """flaskit — CLI scaffolding for Flask-Smorest applications."""
2
+
3
+ from __future__ import annotations
4
+
5
+ try:
6
+ from importlib.metadata import PackageNotFoundError, version
7
+ except ImportError: # pragma: no cover
8
+ from importlib_metadata import PackageNotFoundError, version # type: ignore
9
+
10
+ try:
11
+ __version__ = version("flaskit-cli")
12
+ except PackageNotFoundError: # pragma: no cover - editable / source tree
13
+ __version__ = "0.0.0.dev0"
flaskit/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """python -m flaskit entry point."""
2
+
3
+ from flaskit.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
flaskit/cli.py ADDED
@@ -0,0 +1,294 @@
1
+ """flaskit command-line interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ import shutil
8
+ import sys
9
+ from importlib import resources
10
+ from pathlib import Path
11
+
12
+ import click
13
+ from jinja2 import Environment, FileSystemLoader, StrictUndefined, TemplateNotFound
14
+ from jinja2 import select_autoescape
15
+
16
+ MODULE_NAME_RE = re.compile(r"^[a-z][a-z0-9_]*$")
17
+
18
+ TEMPLATE_ALIASES = {
19
+ "project/.env.example.j2": "project/env.example.j2",
20
+ }
21
+
22
+
23
+ def _templates_root() -> Path:
24
+ """Return filesystem path to packaged Jinja templates."""
25
+ traversable = resources.files("flaskit").joinpath("templates")
26
+ try:
27
+ path = Path(os.fspath(traversable)) # type: ignore[arg-type]
28
+ except TypeError:
29
+ path = Path(str(traversable))
30
+ if not path.is_dir():
31
+ raise click.ClickException(
32
+ "flaskit templates are missing from the installation. "
33
+ "Reinstall with: pip install -U git+https://github.com/mghorbani2357/flaskit.git"
34
+ )
35
+ return path
36
+
37
+
38
+ def _env() -> Environment:
39
+ return Environment(
40
+ loader=FileSystemLoader(_templates_root()),
41
+ undefined=StrictUndefined,
42
+ autoescape=select_autoescape(enabled_extensions=()),
43
+ keep_trailing_newline=True,
44
+ )
45
+
46
+
47
+ def _render(template_name: str, **context: object) -> str:
48
+ name = TEMPLATE_ALIASES.get(template_name, template_name)
49
+ env = _env()
50
+ try:
51
+ return env.get_template(name).render(**context)
52
+ except TemplateNotFound as exc:
53
+ available = sorted(env.list_templates(extensions=("j2",)))
54
+ raise click.ClickException(
55
+ f"Template not found: {template_name} (resolved: {name}). "
56
+ f"Available: {', '.join(available) or '(none)'}. "
57
+ "Reinstall flaskit from the latest git tag/master."
58
+ ) from exc
59
+
60
+
61
+ def _write(path: Path, content: str, *, force: bool = False) -> None:
62
+ if path.exists() and not force:
63
+ raise click.ClickException(f"Refusing to overwrite existing file: {path}")
64
+ path.parent.mkdir(parents=True, exist_ok=True)
65
+ path.write_text(content, encoding="utf-8")
66
+
67
+
68
+ def _ensure_dir(path: Path) -> None:
69
+ """Create an empty directory (no .gitkeep)."""
70
+ path.mkdir(parents=True, exist_ok=True)
71
+
72
+
73
+ def _copy_tree(src: Path, dst: Path) -> None:
74
+ """Copy a directory tree; works cross-platform."""
75
+ if dst.exists():
76
+ shutil.rmtree(dst)
77
+ shutil.copytree(src, dst)
78
+
79
+
80
+ @click.group(context_settings={"help_option_names": ["-h", "--help"]})
81
+ @click.version_option(package_name="flaskit-cli")
82
+ def main() -> None:
83
+ """Scaffold Flask-Smorest projects and resource modules.
84
+
85
+ \b
86
+ Examples:
87
+ flaskit init myapi
88
+ flaskit generate module users
89
+ flaskit generate module order_items --path ./myapi
90
+ python -m flaskit -h
91
+
92
+ \b
93
+ After init (use Python 3.12):
94
+ cd myapi
95
+ python3.12 -m venv .venv
96
+ pip install -r requirements.txt
97
+ flaskit generate module users
98
+ python app.py
99
+ """
100
+
101
+
102
+ @main.command("init")
103
+ @click.argument(
104
+ "path",
105
+ required=False,
106
+ default=".",
107
+ type=click.Path(file_okay=False, path_type=Path),
108
+ )
109
+ def init_project(path: Path) -> None:
110
+ """Create a new flaskit project (app.py, config.py, resources/, assets/).
111
+
112
+ The folder name becomes the default API_TITLE / project name.
113
+
114
+ \b
115
+ Examples:
116
+ flaskit init
117
+ flaskit init myapi
118
+ flaskit init ./services/payments
119
+ python -m flaskit init myapi
120
+ """
121
+ target = path.resolve()
122
+ target.mkdir(parents=True, exist_ok=True)
123
+ project_name = target.name
124
+
125
+ if sys.version_info >= (3, 14):
126
+ click.echo(
127
+ "WARNING: Python "
128
+ f"{sys.version_info.major}.{sys.version_info.minor} detected. "
129
+ "Generated apps expect Python 3.11-3.13 (3.12 recommended). "
130
+ "Create the venv with Python 3.12, e.g.:\n"
131
+ " brew install python@3.12\n"
132
+ " /opt/homebrew/opt/python@3.12/bin/python3.12 -m venv .venv",
133
+ err=True,
134
+ )
135
+
136
+ project_files = {
137
+ "app.py": "project/app.py.j2",
138
+ "config.py": "project/config.py.j2",
139
+ "requirements.txt": "project/requirements.txt.j2",
140
+ ".env.example": "project/env.example.j2",
141
+ }
142
+
143
+ for dest_name, template_name in project_files.items():
144
+ dest = target / dest_name
145
+ if dest.exists():
146
+ raise click.ClickException(f"Project already initialized (found {dest_name})")
147
+ _write(dest, _render(template_name, project_name=project_name))
148
+
149
+ package_root = _templates_root() / "project"
150
+ for dest_name, src_name in (
151
+ ("Dockerfile", "Dockerfile"),
152
+ (".dockerignore", "dockerignore"),
153
+ (".python-version", "python-version"),
154
+ ):
155
+ dest = target / dest_name
156
+ if dest.exists():
157
+ raise click.ClickException(f"Project already initialized (found {dest_name})")
158
+ src = package_root / src_name
159
+ if not src.is_file():
160
+ raise click.ClickException(
161
+ f"Missing packaged file templates/project/{src_name}. Reinstall flaskit."
162
+ )
163
+ dest.write_text(src.read_text(encoding="utf-8"), encoding="utf-8")
164
+
165
+ _ensure_dir(target / "resources")
166
+ _ensure_dir(target / "assets")
167
+ _ensure_dir(target / "assets" / "statics")
168
+ _ensure_dir(target / "templates")
169
+
170
+ swagger_src = package_root / "assets" / "swagger-ui"
171
+ if not swagger_src.is_dir():
172
+ raise click.ClickException(
173
+ "Missing packaged swagger-ui assets. Reinstall flaskit from latest git tag."
174
+ )
175
+ _copy_tree(swagger_src, target / "assets" / "swagger-ui")
176
+
177
+ swagger_html = package_root / "templates" / "swagger_ui.html"
178
+ if not swagger_html.is_file():
179
+ raise click.ClickException("Missing templates/swagger_ui.html in package.")
180
+ _write(
181
+ target / "templates" / "swagger_ui.html",
182
+ swagger_html.read_text(encoding="utf-8"),
183
+ force=True,
184
+ )
185
+
186
+ statics_src = package_root / "assets" / "statics"
187
+ statics_dst = target / "assets" / "statics"
188
+ if statics_src.is_dir():
189
+ for item in statics_src.iterdir():
190
+ if item.name.lower().startswith("readme"):
191
+ continue
192
+ dest = statics_dst / item.name
193
+ if not dest.exists():
194
+ if item.is_file():
195
+ shutil.copy2(item, dest)
196
+ elif item.is_dir():
197
+ shutil.copytree(item, dest)
198
+
199
+ click.echo(f"Initialized flaskit project '{project_name}' in {target}")
200
+ click.echo("Next steps:")
201
+ cwd = Path.cwd().resolve()
202
+ if target == cwd:
203
+ click.echo(" 1. (already in the project directory)")
204
+ else:
205
+ try:
206
+ cd_arg = str(target.relative_to(cwd))
207
+ except ValueError:
208
+ cd_arg = str(target)
209
+ click.echo(f" 1. cd {cd_arg}")
210
+ click.echo(" 2. python3.12 -m venv .venv && activate it # 3.11-3.13 required")
211
+ click.echo(" 3. pip install -U pip && pip install -r requirements.txt")
212
+ click.echo(" 4. flaskit generate module <name>")
213
+ click.echo(" 5. python app.py # ENV=development (Flask)")
214
+ click.echo(
215
+ f" # production (bjoern, Docker only): "
216
+ f"docker build --build-arg DOCKER_TAG=1.0.0 -t {project_name} ."
217
+ )
218
+
219
+
220
+ @main.group("generate")
221
+ def generate() -> None:
222
+ """Generate project artifacts.
223
+
224
+ \b
225
+ Examples:
226
+ flaskit generate module users
227
+ flaskit generate module order_items --path ./myapi
228
+ """
229
+
230
+
231
+ @generate.command("module")
232
+ @click.argument("name")
233
+ @click.option(
234
+ "--path",
235
+ "project_path",
236
+ default=".",
237
+ type=click.Path(file_okay=False, exists=True, path_type=Path),
238
+ help="Project root containing resources/ (default: current directory).",
239
+ )
240
+ def generate_module(name: str, project_path: Path) -> None:
241
+ """Generate a resource module under resources/<name>/.
242
+
243
+ Creates Flask blueprint (flask_bp) + Smorest blueprint (smorest_blp) stubs.
244
+
245
+ \b
246
+ Examples:
247
+ flaskit generate module users
248
+ flaskit generate module order_items
249
+ flaskit generate module invoices --path ./myapi
250
+ python -m flaskit generate module users
251
+ """
252
+ if not MODULE_NAME_RE.match(name):
253
+ raise click.ClickException(
254
+ "Module name must be snake_case starting with a letter "
255
+ "(e.g. users, order_items)."
256
+ )
257
+
258
+ root = project_path.resolve()
259
+ resources_dir = root / "resources"
260
+ if not resources_dir.is_dir():
261
+ raise click.ClickException(
262
+ f"No resources/ directory in {root}. Run 'flaskit init' first."
263
+ )
264
+
265
+ module_dir = resources_dir / name
266
+ if module_dir.exists():
267
+ raise click.ClickException(f"Module already exists: {module_dir}")
268
+
269
+ class_prefix = "".join(part.capitalize() for part in name.split("_"))
270
+ context = {
271
+ "name": name,
272
+ "class_prefix": class_prefix,
273
+ "title": name.replace("_", " ").title(),
274
+ }
275
+
276
+ module_dir.mkdir(parents=True, exist_ok=False)
277
+ try:
278
+ for filename, template_name in (
279
+ ("blueprint.py", "module/blueprint.py.j2"),
280
+ ("resource.py", "module/resource.py.j2"),
281
+ ("schema.py", "module/schema.py.j2"),
282
+ ):
283
+ _write(module_dir / filename, _render(template_name, **context))
284
+ except Exception:
285
+ shutil.rmtree(module_dir, ignore_errors=True)
286
+ raise
287
+
288
+ click.echo(f"Generated module resources/{name}/")
289
+ click.echo(" blueprint.py (flask_bp) resource.py (smorest_blp) schema.py")
290
+ click.echo("Restart the app to pick up the module in Swagger.")
291
+
292
+
293
+ if __name__ == "__main__":
294
+ main()
@@ -0,0 +1,10 @@
1
+ """Flask blueprint for the {{ name }} module."""
2
+
3
+ from flask import Blueprint
4
+
5
+ # Mount path under app BASE_PATH (from config / env).
6
+ # Final routes: {BASE_PATH}{URL_PREFIX}/...
7
+ URL_PREFIX = "/{{ name }}"
8
+
9
+ # Flask application blueprint (URL mounting / registration)
10
+ flask_bp = Blueprint("{{ name }}", __name__)
@@ -0,0 +1,48 @@
1
+ from flask.views import MethodView
2
+ from flask_smorest import Blueprint as SmorestBlueprint
3
+
4
+ from resources.{{ name }}.blueprint import flask_bp
5
+ from resources.{{ name }}.schema import {{ class_prefix }}Request, {{ class_prefix }}Response
6
+
7
+ # Flask-Smorest blueprint (OpenAPI / request-response schemas)
8
+ smorest_blp = SmorestBlueprint(
9
+ "{{ name }}_api",
10
+ __name__,
11
+ description="{{ title }}",
12
+ )
13
+
14
+
15
+ @smorest_blp.route("/")
16
+ class {{ class_prefix }}List(MethodView):
17
+ @smorest_blp.doc(tags=["{{ name }}"])
18
+ @smorest_blp.response(200, {{ class_prefix }}Response(many=True))
19
+ def get(self):
20
+ return []
21
+
22
+ @smorest_blp.doc(tags=["{{ name }}"])
23
+ @smorest_blp.arguments({{ class_prefix }}Request)
24
+ @smorest_blp.response(201, {{ class_prefix }}Response)
25
+ def post(self, body):
26
+ return {}
27
+
28
+
29
+ @smorest_blp.route("/<int:item_id>")
30
+ class {{ class_prefix }}Item(MethodView):
31
+ @smorest_blp.doc(tags=["{{ name }}"])
32
+ @smorest_blp.response(200, {{ class_prefix }}Response)
33
+ def get(self, item_id):
34
+ return {}
35
+
36
+ @smorest_blp.doc(tags=["{{ name }}"])
37
+ @smorest_blp.arguments({{ class_prefix }}Request)
38
+ @smorest_blp.response(200, {{ class_prefix }}Response)
39
+ def patch(self, body, item_id):
40
+ return {}
41
+
42
+ @smorest_blp.doc(tags=["{{ name }}"])
43
+ @smorest_blp.response(204)
44
+ def delete(self, item_id):
45
+ return None
46
+
47
+
48
+ flask_bp.register_blueprint(smorest_blp)
@@ -0,0 +1,9 @@
1
+ from marshmallow import Schema
2
+
3
+
4
+ class {{ class_prefix }}Request(Schema):
5
+ pass
6
+
7
+
8
+ class {{ class_prefix }}Response(Schema):
9
+ pass
@@ -0,0 +1,56 @@
1
+ # syntax=docker/dockerfile:1
2
+ # Python 3.13 — current stable with broad wheel support (LTS-quality baseline)
3
+
4
+ FROM python:3.13-bookworm AS builder
5
+
6
+ ENV PYTHONDONTWRITEBYTECODE=1 \
7
+ PIP_DISABLE_PIP_VERSION_CHECK=1 \
8
+ PIP_NO_CACHE_DIR=1
9
+
10
+ WORKDIR /app
11
+
12
+ RUN apt-get update \
13
+ && apt-get install -y --no-install-recommends \
14
+ build-essential \
15
+ libev-dev \
16
+ && rm -rf /var/lib/apt/lists/* \
17
+ && python -m pip install --upgrade pip
18
+
19
+ COPY requirements.txt .
20
+ RUN pip install --prefix=/install -r requirements.txt \
21
+ && pip install --prefix=/install "bjoern>=3.2.2"
22
+
23
+ FROM python:3.13-slim-bookworm AS base
24
+
25
+ ARG DOCKER_TAG=0.0.0
26
+
27
+ ENV PYTHONDONTWRITEBYTECODE=1 \
28
+ PYTHONUNBUFFERED=1 \
29
+ PIP_DISABLE_PIP_VERSION_CHECK=1 \
30
+ ENV=production \
31
+ HOST=0.0.0.0 \
32
+ PORT=80 \
33
+ API_VERSION=${DOCKER_TAG} \
34
+ TIMEZONE=UTC \
35
+ BJOERN_REUSE_PORT=true \
36
+ LOG_OUTPUT=stream \
37
+ LOG_STREAM=stderr
38
+
39
+ EXPOSE 80
40
+
41
+ WORKDIR /app
42
+
43
+ RUN apt-get update \
44
+ && apt-get install -y --no-install-recommends libev4 \
45
+ && rm -rf /var/lib/apt/lists/* \
46
+ && groupadd --system --gid 1000 app \
47
+ && useradd --system --uid 1000 --gid app --home-dir /app --shell /usr/sbin/nologin app \
48
+ && mkdir -p /app/logs \
49
+ && chown -R app:app /app
50
+
51
+ COPY --from=builder /install /usr/local
52
+ COPY --chown=app:app . .
53
+
54
+ USER app
55
+
56
+ ENTRYPOINT ["python3", "app.py"]