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 +13 -0
- flaskit/__main__.py +6 -0
- flaskit/cli.py +294 -0
- flaskit/templates/module/blueprint.py.j2 +10 -0
- flaskit/templates/module/resource.py.j2 +48 -0
- flaskit/templates/module/schema.py.j2 +9 -0
- flaskit/templates/project/Dockerfile +56 -0
- flaskit/templates/project/app.py.j2 +551 -0
- flaskit/templates/project/assets/swagger-ui/swagger-ui-bundle.js +2 -0
- flaskit/templates/project/assets/swagger-ui/swagger-ui-standalone-preset.js +2 -0
- flaskit/templates/project/assets/swagger-ui/swagger-ui.css +3 -0
- flaskit/templates/project/config.py.j2 +127 -0
- flaskit/templates/project/dockerignore +21 -0
- flaskit/templates/project/env.example.j2 +61 -0
- flaskit/templates/project/python-version +1 -0
- flaskit/templates/project/requirements.txt.j2 +14 -0
- flaskit/templates/project/templates/swagger_ui.html +43 -0
- flaskit_cli-0.2.1.dist-info/METADATA +127 -0
- flaskit_cli-0.2.1.dist-info/RECORD +23 -0
- flaskit_cli-0.2.1.dist-info/WHEEL +5 -0
- flaskit_cli-0.2.1.dist-info/entry_points.txt +2 -0
- flaskit_cli-0.2.1.dist-info/licenses/LICENSE +674 -0
- flaskit_cli-0.2.1.dist-info/top_level.txt +1 -0
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
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,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"]
|