bluefox-cli 0.1.0__tar.gz
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.
- bluefox_cli-0.1.0/.github/workflows/publish.yml +20 -0
- bluefox_cli-0.1.0/.gitignore +207 -0
- bluefox_cli-0.1.0/.python-version +1 -0
- bluefox_cli-0.1.0/PKG-INFO +9 -0
- bluefox_cli-0.1.0/README.md +0 -0
- bluefox_cli-0.1.0/pyproject.toml +29 -0
- bluefox_cli-0.1.0/src/bluefox_cli/__init__.py +3 -0
- bluefox_cli-0.1.0/src/bluefox_cli/main.py +57 -0
- bluefox_cli-0.1.0/src/bluefox_cli/py.typed +0 -0
- bluefox_cli-0.1.0/src/bluefox_cli/templates.py +294 -0
- bluefox_cli-0.1.0/tests/__init__.py +0 -0
- bluefox_cli-0.1.0/tests/test_init.py +159 -0
- bluefox_cli-0.1.0/uv.lock +95 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
on:
|
|
3
|
+
push:
|
|
4
|
+
tags: ["v*"]
|
|
5
|
+
|
|
6
|
+
jobs:
|
|
7
|
+
publish:
|
|
8
|
+
runs-on: ubuntu-latest
|
|
9
|
+
environment: pypi
|
|
10
|
+
permissions:
|
|
11
|
+
contents: read
|
|
12
|
+
id-token: write
|
|
13
|
+
steps:
|
|
14
|
+
- uses: actions/checkout@v4
|
|
15
|
+
- uses: actions/setup-python@v5
|
|
16
|
+
with:
|
|
17
|
+
python-version: "3.12"
|
|
18
|
+
- uses: astral-sh/setup-uv@v5
|
|
19
|
+
- run: uv build
|
|
20
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
# Byte-compiled / optimized / DLL files
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[codz]
|
|
4
|
+
*$py.class
|
|
5
|
+
|
|
6
|
+
# C extensions
|
|
7
|
+
*.so
|
|
8
|
+
|
|
9
|
+
# Distribution / packaging
|
|
10
|
+
.Python
|
|
11
|
+
build/
|
|
12
|
+
develop-eggs/
|
|
13
|
+
dist/
|
|
14
|
+
downloads/
|
|
15
|
+
eggs/
|
|
16
|
+
.eggs/
|
|
17
|
+
lib/
|
|
18
|
+
lib64/
|
|
19
|
+
parts/
|
|
20
|
+
sdist/
|
|
21
|
+
var/
|
|
22
|
+
wheels/
|
|
23
|
+
share/python-wheels/
|
|
24
|
+
*.egg-info/
|
|
25
|
+
.installed.cfg
|
|
26
|
+
*.egg
|
|
27
|
+
MANIFEST
|
|
28
|
+
|
|
29
|
+
# PyInstaller
|
|
30
|
+
# Usually these files are written by a python script from a template
|
|
31
|
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
32
|
+
*.manifest
|
|
33
|
+
*.spec
|
|
34
|
+
|
|
35
|
+
# Installer logs
|
|
36
|
+
pip-log.txt
|
|
37
|
+
pip-delete-this-directory.txt
|
|
38
|
+
|
|
39
|
+
# Unit test / coverage reports
|
|
40
|
+
htmlcov/
|
|
41
|
+
.tox/
|
|
42
|
+
.nox/
|
|
43
|
+
.coverage
|
|
44
|
+
.coverage.*
|
|
45
|
+
.cache
|
|
46
|
+
nosetests.xml
|
|
47
|
+
coverage.xml
|
|
48
|
+
*.cover
|
|
49
|
+
*.py.cover
|
|
50
|
+
.hypothesis/
|
|
51
|
+
.pytest_cache/
|
|
52
|
+
cover/
|
|
53
|
+
|
|
54
|
+
# Translations
|
|
55
|
+
*.mo
|
|
56
|
+
*.pot
|
|
57
|
+
|
|
58
|
+
# Django stuff:
|
|
59
|
+
*.log
|
|
60
|
+
local_settings.py
|
|
61
|
+
db.sqlite3
|
|
62
|
+
db.sqlite3-journal
|
|
63
|
+
|
|
64
|
+
# Flask stuff:
|
|
65
|
+
instance/
|
|
66
|
+
.webassets-cache
|
|
67
|
+
|
|
68
|
+
# Scrapy stuff:
|
|
69
|
+
.scrapy
|
|
70
|
+
|
|
71
|
+
# Sphinx documentation
|
|
72
|
+
docs/_build/
|
|
73
|
+
|
|
74
|
+
# PyBuilder
|
|
75
|
+
.pybuilder/
|
|
76
|
+
target/
|
|
77
|
+
|
|
78
|
+
# Jupyter Notebook
|
|
79
|
+
.ipynb_checkpoints
|
|
80
|
+
|
|
81
|
+
# IPython
|
|
82
|
+
profile_default/
|
|
83
|
+
ipython_config.py
|
|
84
|
+
|
|
85
|
+
# pyenv
|
|
86
|
+
# For a library or package, you might want to ignore these files since the code is
|
|
87
|
+
# intended to run in multiple environments; otherwise, check them in:
|
|
88
|
+
# .python-version
|
|
89
|
+
|
|
90
|
+
# pipenv
|
|
91
|
+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
|
92
|
+
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
|
93
|
+
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
|
94
|
+
# install all needed dependencies.
|
|
95
|
+
#Pipfile.lock
|
|
96
|
+
|
|
97
|
+
# UV
|
|
98
|
+
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
|
99
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
100
|
+
# commonly ignored for libraries.
|
|
101
|
+
#uv.lock
|
|
102
|
+
|
|
103
|
+
# poetry
|
|
104
|
+
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
|
105
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
106
|
+
# commonly ignored for libraries.
|
|
107
|
+
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
|
108
|
+
#poetry.lock
|
|
109
|
+
#poetry.toml
|
|
110
|
+
|
|
111
|
+
# pdm
|
|
112
|
+
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
|
113
|
+
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
|
|
114
|
+
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
|
|
115
|
+
#pdm.lock
|
|
116
|
+
#pdm.toml
|
|
117
|
+
.pdm-python
|
|
118
|
+
.pdm-build/
|
|
119
|
+
|
|
120
|
+
# pixi
|
|
121
|
+
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
|
|
122
|
+
#pixi.lock
|
|
123
|
+
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
|
|
124
|
+
# in the .venv directory. It is recommended not to include this directory in version control.
|
|
125
|
+
.pixi
|
|
126
|
+
|
|
127
|
+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
|
128
|
+
__pypackages__/
|
|
129
|
+
|
|
130
|
+
# Celery stuff
|
|
131
|
+
celerybeat-schedule
|
|
132
|
+
celerybeat.pid
|
|
133
|
+
|
|
134
|
+
# SageMath parsed files
|
|
135
|
+
*.sage.py
|
|
136
|
+
|
|
137
|
+
# Environments
|
|
138
|
+
.env
|
|
139
|
+
.envrc
|
|
140
|
+
.venv
|
|
141
|
+
env/
|
|
142
|
+
venv/
|
|
143
|
+
ENV/
|
|
144
|
+
env.bak/
|
|
145
|
+
venv.bak/
|
|
146
|
+
|
|
147
|
+
# Spyder project settings
|
|
148
|
+
.spyderproject
|
|
149
|
+
.spyproject
|
|
150
|
+
|
|
151
|
+
# Rope project settings
|
|
152
|
+
.ropeproject
|
|
153
|
+
|
|
154
|
+
# mkdocs documentation
|
|
155
|
+
/site
|
|
156
|
+
|
|
157
|
+
# mypy
|
|
158
|
+
.mypy_cache/
|
|
159
|
+
.dmypy.json
|
|
160
|
+
dmypy.json
|
|
161
|
+
|
|
162
|
+
# Pyre type checker
|
|
163
|
+
.pyre/
|
|
164
|
+
|
|
165
|
+
# pytype static type analyzer
|
|
166
|
+
.pytype/
|
|
167
|
+
|
|
168
|
+
# Cython debug symbols
|
|
169
|
+
cython_debug/
|
|
170
|
+
|
|
171
|
+
# PyCharm
|
|
172
|
+
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
|
173
|
+
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
|
174
|
+
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
|
175
|
+
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
|
176
|
+
#.idea/
|
|
177
|
+
|
|
178
|
+
# Abstra
|
|
179
|
+
# Abstra is an AI-powered process automation framework.
|
|
180
|
+
# Ignore directories containing user credentials, local state, and settings.
|
|
181
|
+
# Learn more at https://abstra.io/docs
|
|
182
|
+
.abstra/
|
|
183
|
+
|
|
184
|
+
# Visual Studio Code
|
|
185
|
+
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
|
|
186
|
+
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
|
|
187
|
+
# and can be added to the global gitignore or merged into this file. However, if you prefer,
|
|
188
|
+
# you could uncomment the following to ignore the entire vscode folder
|
|
189
|
+
# .vscode/
|
|
190
|
+
|
|
191
|
+
# Ruff stuff:
|
|
192
|
+
.ruff_cache/
|
|
193
|
+
|
|
194
|
+
# PyPI configuration file
|
|
195
|
+
.pypirc
|
|
196
|
+
|
|
197
|
+
# Cursor
|
|
198
|
+
# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to
|
|
199
|
+
# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
|
|
200
|
+
# refer to https://docs.cursor.com/context/ignore-files
|
|
201
|
+
.cursorignore
|
|
202
|
+
.cursorindexingignore
|
|
203
|
+
|
|
204
|
+
# Marimo
|
|
205
|
+
marimo/_static/
|
|
206
|
+
marimo/_lsp/
|
|
207
|
+
__marimo__/
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.12
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: bluefox-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Scaffolding tool for the Bluefox Stack — one command to generate a complete FastAPI project.
|
|
5
|
+
Project-URL: Homepage, https://github.com/blue-fox-software/bluefox-cli
|
|
6
|
+
Project-URL: Repository, https://github.com/blue-fox-software/bluefox-cli
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Requires-Python: >=3.12
|
|
9
|
+
Requires-Dist: click>=8.0
|
|
File without changes
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "bluefox-cli"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Scaffolding tool for the Bluefox Stack — one command to generate a complete FastAPI project."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.12"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
dependencies = [
|
|
13
|
+
"click>=8.0",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[project.scripts]
|
|
17
|
+
bluefox = "bluefox_cli.main:cli"
|
|
18
|
+
|
|
19
|
+
[project.urls]
|
|
20
|
+
Homepage = "https://github.com/blue-fox-software/bluefox-cli"
|
|
21
|
+
Repository = "https://github.com/blue-fox-software/bluefox-cli"
|
|
22
|
+
|
|
23
|
+
[dependency-groups]
|
|
24
|
+
dev = [
|
|
25
|
+
"pytest>=8.0",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[tool.pytest.ini_options]
|
|
29
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import subprocess
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
|
|
8
|
+
from bluefox_cli.templates import TEMPLATES
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@click.group()
|
|
12
|
+
def cli():
|
|
13
|
+
"""Bluefox Stack CLI."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@cli.command()
|
|
17
|
+
@click.argument("project_name")
|
|
18
|
+
def init(project_name: str):
|
|
19
|
+
"""Create a new Bluefox project."""
|
|
20
|
+
project_dir = Path.cwd() / project_name
|
|
21
|
+
|
|
22
|
+
if project_dir.exists():
|
|
23
|
+
click.echo(f"Error: directory '{project_name}' already exists.", err=True)
|
|
24
|
+
sys.exit(1)
|
|
25
|
+
|
|
26
|
+
click.echo(f"Creating project '{project_name}'...")
|
|
27
|
+
|
|
28
|
+
# Create directories
|
|
29
|
+
project_dir.mkdir()
|
|
30
|
+
(project_dir / "migrations" / "versions").mkdir(parents=True)
|
|
31
|
+
(project_dir / "tests").mkdir()
|
|
32
|
+
|
|
33
|
+
# Write all template files
|
|
34
|
+
for rel_path, content_fn in TEMPLATES.items():
|
|
35
|
+
file_path = project_dir / rel_path
|
|
36
|
+
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
37
|
+
file_path.write_text(content_fn(project_name))
|
|
38
|
+
|
|
39
|
+
# Initialize uv project and add dependencies
|
|
40
|
+
_run(["uv", "init", "--no-readme"], cwd=project_dir)
|
|
41
|
+
_run(["uv", "add", "bluefox-core", "alembic", "asyncpg"], cwd=project_dir)
|
|
42
|
+
_run(["uv", "add", "--dev", "bluefox-test", "pytest"], cwd=project_dir)
|
|
43
|
+
|
|
44
|
+
click.echo("")
|
|
45
|
+
click.echo(f"Project '{project_name}' created successfully!")
|
|
46
|
+
click.echo("")
|
|
47
|
+
click.echo("Next steps:")
|
|
48
|
+
click.echo(f" cd {project_name}")
|
|
49
|
+
click.echo(" make dev")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _run(cmd: list[str], cwd: Path) -> None:
|
|
53
|
+
result = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True)
|
|
54
|
+
if result.returncode != 0:
|
|
55
|
+
click.echo(f"Error running {' '.join(cmd)}:", err=True)
|
|
56
|
+
click.echo(result.stderr, err=True)
|
|
57
|
+
sys.exit(1)
|
|
File without changes
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
"""Templates for generated project files.
|
|
2
|
+
|
|
3
|
+
Each function takes project_name and returns the file content as a string.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _main_py(project_name: str) -> str:
|
|
8
|
+
return """from bluefox_core import BluefoxSettings, create_bluefox_app
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def create_app() -> object:
|
|
12
|
+
settings = BluefoxSettings(MODELS_MODULE="models")
|
|
13
|
+
return create_bluefox_app(settings)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
app = create_app()
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _models_py(project_name: str) -> str:
|
|
21
|
+
return """from sqlalchemy import Column, Integer, String
|
|
22
|
+
from bluefox_core import BluefoxBase
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Example(BluefoxBase):
|
|
26
|
+
__tablename__ = "examples"
|
|
27
|
+
|
|
28
|
+
id = Column(Integer, primary_key=True)
|
|
29
|
+
name = Column(String, nullable=False)
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _env_example(project_name: str) -> str:
|
|
34
|
+
app_name = project_name.replace("_", "-")
|
|
35
|
+
return f"""# Application
|
|
36
|
+
APP_NAME={app_name}
|
|
37
|
+
ENVIRONMENT=development
|
|
38
|
+
DEBUG=false
|
|
39
|
+
LOG_LEVEL=INFO
|
|
40
|
+
SECRET_KEY=change-me-in-production
|
|
41
|
+
|
|
42
|
+
# Database (PostgreSQL)
|
|
43
|
+
DATABASE_URL=postgresql://myuser:mypass@localhost:5432/{project_name}
|
|
44
|
+
|
|
45
|
+
# Redis (optional)
|
|
46
|
+
REDIS_URL=
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _env(project_name: str) -> str:
|
|
51
|
+
app_name = project_name.replace("_", "-")
|
|
52
|
+
return f"""# Application
|
|
53
|
+
APP_NAME={app_name}
|
|
54
|
+
ENVIRONMENT=development
|
|
55
|
+
DEBUG=true
|
|
56
|
+
LOG_LEVEL=DEBUG
|
|
57
|
+
SECRET_KEY=dev-secret-key
|
|
58
|
+
|
|
59
|
+
# Database (PostgreSQL)
|
|
60
|
+
DATABASE_URL=postgresql://myuser:mypass@localhost:5432/{project_name}
|
|
61
|
+
|
|
62
|
+
# Redis (optional)
|
|
63
|
+
REDIS_URL=
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _makefile(project_name: str) -> str:
|
|
68
|
+
db_url = f"postgresql+asyncpg://myuser:mypass@localhost:5432/{project_name}"
|
|
69
|
+
return f""".PHONY: dev dev-down run migrate migrate-make test
|
|
70
|
+
|
|
71
|
+
DB_URL = {db_url}
|
|
72
|
+
|
|
73
|
+
dev:
|
|
74
|
+
docker compose -f docker-compose.yml -f docker-compose.dev.yml up --build
|
|
75
|
+
|
|
76
|
+
dev-down:
|
|
77
|
+
docker compose -f docker-compose.yml -f docker-compose.dev.yml down
|
|
78
|
+
|
|
79
|
+
run:
|
|
80
|
+
DATABASE_URL=$(DB_URL) uv run uvicorn main:app --reload --port 8000
|
|
81
|
+
|
|
82
|
+
migrate:
|
|
83
|
+
DATABASE_URL=$(DB_URL) uv run python -m alembic upgrade head
|
|
84
|
+
|
|
85
|
+
migrate-make:
|
|
86
|
+
DATABASE_URL=$(DB_URL) uv run python -m alembic revision --autogenerate -m "$(name)"
|
|
87
|
+
|
|
88
|
+
test:
|
|
89
|
+
uv run pytest
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _dockerfile(project_name: str) -> str:
|
|
94
|
+
return """# --- Build stage ---
|
|
95
|
+
FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim AS builder
|
|
96
|
+
WORKDIR /app
|
|
97
|
+
COPY pyproject.toml uv.lock ./
|
|
98
|
+
RUN uv sync --frozen --no-dev --no-install-project
|
|
99
|
+
COPY . .
|
|
100
|
+
RUN uv sync --frozen --no-dev
|
|
101
|
+
|
|
102
|
+
# --- Runtime stage ---
|
|
103
|
+
FROM python:3.12-slim-bookworm
|
|
104
|
+
WORKDIR /app
|
|
105
|
+
RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/* \\
|
|
106
|
+
&& groupadd --system app && useradd --system --gid app app
|
|
107
|
+
COPY --from=builder /app/.venv /app/.venv
|
|
108
|
+
COPY . .
|
|
109
|
+
ENV PATH="/app/.venv/bin:$PATH"
|
|
110
|
+
USER app
|
|
111
|
+
EXPOSE 8000
|
|
112
|
+
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _docker_compose(project_name: str) -> str:
|
|
117
|
+
return """services:
|
|
118
|
+
migrate:
|
|
119
|
+
build: .
|
|
120
|
+
environment:
|
|
121
|
+
- DATABASE_URL=${DATABASE_URL:-}
|
|
122
|
+
command: ["python", "-m", "alembic", "upgrade", "head"]
|
|
123
|
+
restart: "no"
|
|
124
|
+
networks:
|
|
125
|
+
- dokploy-network
|
|
126
|
+
|
|
127
|
+
app:
|
|
128
|
+
build: .
|
|
129
|
+
restart: unless-stopped
|
|
130
|
+
ports:
|
|
131
|
+
- "${APP_PORT:-8000}:8000"
|
|
132
|
+
environment:
|
|
133
|
+
- DATABASE_URL=${DATABASE_URL:-}
|
|
134
|
+
- SECRET_KEY=${SECRET_KEY:-change-me-in-production}
|
|
135
|
+
depends_on:
|
|
136
|
+
migrate:
|
|
137
|
+
condition: service_completed_successfully
|
|
138
|
+
healthcheck:
|
|
139
|
+
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
|
140
|
+
interval: 30s
|
|
141
|
+
timeout: 5s
|
|
142
|
+
start_period: 10s
|
|
143
|
+
retries: 3
|
|
144
|
+
networks:
|
|
145
|
+
- dokploy-network
|
|
146
|
+
|
|
147
|
+
networks:
|
|
148
|
+
dokploy-network:
|
|
149
|
+
external: true
|
|
150
|
+
"""
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _docker_compose_dev(project_name: str) -> str:
|
|
154
|
+
return f"""services:
|
|
155
|
+
db:
|
|
156
|
+
image: postgres:17-alpine
|
|
157
|
+
restart: unless-stopped
|
|
158
|
+
environment:
|
|
159
|
+
POSTGRES_USER: myuser
|
|
160
|
+
POSTGRES_PASSWORD: mypass
|
|
161
|
+
POSTGRES_DB: {project_name}
|
|
162
|
+
ports:
|
|
163
|
+
- "5432:5432"
|
|
164
|
+
volumes:
|
|
165
|
+
- pgdata:/var/lib/postgresql/data
|
|
166
|
+
healthcheck:
|
|
167
|
+
test: ["CMD-SHELL", "pg_isready -U myuser -d {project_name}"]
|
|
168
|
+
interval: 2s
|
|
169
|
+
timeout: 3s
|
|
170
|
+
retries: 10
|
|
171
|
+
|
|
172
|
+
migrate:
|
|
173
|
+
depends_on:
|
|
174
|
+
db:
|
|
175
|
+
condition: service_healthy
|
|
176
|
+
environment:
|
|
177
|
+
- DATABASE_URL=postgresql+asyncpg://myuser:mypass@db:5432/{project_name}
|
|
178
|
+
networks:
|
|
179
|
+
- default
|
|
180
|
+
|
|
181
|
+
app:
|
|
182
|
+
environment:
|
|
183
|
+
- DATABASE_URL=postgresql+asyncpg://myuser:mypass@db:5432/{project_name}
|
|
184
|
+
- ENVIRONMENT=development
|
|
185
|
+
- DEBUG=true
|
|
186
|
+
volumes:
|
|
187
|
+
- .:/app
|
|
188
|
+
command: ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
|
189
|
+
networks:
|
|
190
|
+
- default
|
|
191
|
+
|
|
192
|
+
networks:
|
|
193
|
+
dokploy-network:
|
|
194
|
+
driver: bridge
|
|
195
|
+
|
|
196
|
+
volumes:
|
|
197
|
+
pgdata:
|
|
198
|
+
"""
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _alembic_ini(project_name: str) -> str:
|
|
202
|
+
return """[alembic]
|
|
203
|
+
script_location = migrations
|
|
204
|
+
prepend_sys_path = .
|
|
205
|
+
# sqlalchemy.url is set via DATABASE_URL env var in migrations/env.py
|
|
206
|
+
|
|
207
|
+
[loggers]
|
|
208
|
+
keys = root,sqlalchemy,alembic
|
|
209
|
+
|
|
210
|
+
[handlers]
|
|
211
|
+
keys = console
|
|
212
|
+
|
|
213
|
+
[formatters]
|
|
214
|
+
keys = generic
|
|
215
|
+
|
|
216
|
+
[logger_root]
|
|
217
|
+
level = WARN
|
|
218
|
+
handlers = console
|
|
219
|
+
qualname =
|
|
220
|
+
|
|
221
|
+
[logger_sqlalchemy]
|
|
222
|
+
level = WARN
|
|
223
|
+
handlers =
|
|
224
|
+
qualname = sqlalchemy.engine
|
|
225
|
+
|
|
226
|
+
[logger_alembic]
|
|
227
|
+
level = INFO
|
|
228
|
+
handlers =
|
|
229
|
+
qualname = alembic
|
|
230
|
+
|
|
231
|
+
[handler_console]
|
|
232
|
+
class = StreamHandler
|
|
233
|
+
args = (sys.stderr,)
|
|
234
|
+
level = NOTSET
|
|
235
|
+
formatter = generic
|
|
236
|
+
|
|
237
|
+
[formatter_generic]
|
|
238
|
+
format = %(levelname)-5.5s [%(name)s] %(message)s
|
|
239
|
+
datefmt = %H:%M:%S
|
|
240
|
+
"""
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _migrations_env_py(project_name: str) -> str:
|
|
244
|
+
return """from alembic import context
|
|
245
|
+
from bluefox_core.migrations import configure_alembic
|
|
246
|
+
|
|
247
|
+
configure_alembic(context)
|
|
248
|
+
"""
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _conftest_py(project_name: str) -> str:
|
|
252
|
+
return """from bluefox_test import bluefox_test_setup
|
|
253
|
+
from models import Example # noqa: F401 — register models
|
|
254
|
+
from bluefox_core import BluefoxBase
|
|
255
|
+
|
|
256
|
+
globals().update(bluefox_test_setup(base=BluefoxBase, app_factory="main:create_app"))
|
|
257
|
+
"""
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _test_health_py(project_name: str) -> str:
|
|
261
|
+
return """import pytest
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
@pytest.mark.asyncio
|
|
265
|
+
async def test_health(client):
|
|
266
|
+
response = await client.get("/health")
|
|
267
|
+
assert response.status_code == 200
|
|
268
|
+
data = response.json()
|
|
269
|
+
assert data["status"] == "ok"
|
|
270
|
+
"""
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _pyproject_toml(project_name: str) -> str:
|
|
274
|
+
# This is intentionally empty — uv init will create it,
|
|
275
|
+
# then uv add will populate dependencies.
|
|
276
|
+
# We don't write this file.
|
|
277
|
+
return ""
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
# Map of relative paths to template functions
|
|
281
|
+
TEMPLATES: dict[str, callable] = {
|
|
282
|
+
"main.py": _main_py,
|
|
283
|
+
"models.py": _models_py,
|
|
284
|
+
".env.example": _env_example,
|
|
285
|
+
".env": _env,
|
|
286
|
+
"Makefile": _makefile,
|
|
287
|
+
"Dockerfile": _dockerfile,
|
|
288
|
+
"docker-compose.yml": _docker_compose,
|
|
289
|
+
"docker-compose.dev.yml": _docker_compose_dev,
|
|
290
|
+
"alembic.ini": _alembic_ini,
|
|
291
|
+
"migrations/env.py": _migrations_env_py,
|
|
292
|
+
"tests/conftest.py": _conftest_py,
|
|
293
|
+
"tests/test_health.py": _test_health_py,
|
|
294
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Tests for the bluefox init command."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
from click.testing import CliRunner
|
|
6
|
+
|
|
7
|
+
from bluefox_cli.main import cli
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_init_creates_project(tmp_path):
|
|
11
|
+
runner = CliRunner()
|
|
12
|
+
os.chdir(tmp_path)
|
|
13
|
+
result = runner.invoke(cli, ["init", "myapp"], catch_exceptions=False)
|
|
14
|
+
|
|
15
|
+
# Command should fail because uv add can't resolve bluefox-core in test env,
|
|
16
|
+
# but the directory and files should still be created before that point.
|
|
17
|
+
# So we test file generation separately.
|
|
18
|
+
project_dir = tmp_path / "myapp"
|
|
19
|
+
assert project_dir.exists()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_init_creates_all_expected_files(tmp_path):
|
|
23
|
+
"""Verify all expected files are written (before uv commands run)."""
|
|
24
|
+
from bluefox_cli.templates import TEMPLATES
|
|
25
|
+
|
|
26
|
+
runner = CliRunner()
|
|
27
|
+
os.chdir(tmp_path)
|
|
28
|
+
# This will fail at the uv step, but files should exist
|
|
29
|
+
runner.invoke(cli, ["init", "myapp"])
|
|
30
|
+
|
|
31
|
+
project_dir = tmp_path / "myapp"
|
|
32
|
+
for rel_path in TEMPLATES:
|
|
33
|
+
assert (project_dir / rel_path).exists(), f"Missing: {rel_path}"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_init_creates_migrations_versions_dir(tmp_path):
|
|
37
|
+
runner = CliRunner()
|
|
38
|
+
os.chdir(tmp_path)
|
|
39
|
+
runner.invoke(cli, ["init", "myapp"])
|
|
40
|
+
|
|
41
|
+
assert (tmp_path / "myapp" / "migrations" / "versions").is_dir()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_init_aborts_if_directory_exists(tmp_path):
|
|
45
|
+
runner = CliRunner()
|
|
46
|
+
os.chdir(tmp_path)
|
|
47
|
+
(tmp_path / "myapp").mkdir()
|
|
48
|
+
|
|
49
|
+
result = runner.invoke(cli, ["init", "myapp"])
|
|
50
|
+
assert result.exit_code != 0
|
|
51
|
+
assert "already exists" in result.output or "already exists" in (result.stderr or "")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_init_main_py_content(tmp_path):
|
|
55
|
+
runner = CliRunner()
|
|
56
|
+
os.chdir(tmp_path)
|
|
57
|
+
runner.invoke(cli, ["init", "myapp"])
|
|
58
|
+
|
|
59
|
+
content = (tmp_path / "myapp" / "main.py").read_text()
|
|
60
|
+
assert "from bluefox_core import BluefoxSettings, create_bluefox_app" in content
|
|
61
|
+
assert "create_app" in content
|
|
62
|
+
assert 'MODELS_MODULE="models"' in content
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_init_models_py_content(tmp_path):
|
|
66
|
+
runner = CliRunner()
|
|
67
|
+
os.chdir(tmp_path)
|
|
68
|
+
runner.invoke(cli, ["init", "myapp"])
|
|
69
|
+
|
|
70
|
+
content = (tmp_path / "myapp" / "models.py").read_text()
|
|
71
|
+
assert "BluefoxBase" in content
|
|
72
|
+
assert "Example" in content
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_init_env_files(tmp_path):
|
|
76
|
+
runner = CliRunner()
|
|
77
|
+
os.chdir(tmp_path)
|
|
78
|
+
runner.invoke(cli, ["init", "myapp"])
|
|
79
|
+
|
|
80
|
+
env_example = (tmp_path / "myapp" / ".env.example").read_text()
|
|
81
|
+
assert "DATABASE_URL=" in env_example
|
|
82
|
+
assert "myapp" in env_example
|
|
83
|
+
|
|
84
|
+
env = (tmp_path / "myapp" / ".env").read_text()
|
|
85
|
+
assert "DEBUG=true" in env
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_init_makefile_content(tmp_path):
|
|
89
|
+
runner = CliRunner()
|
|
90
|
+
os.chdir(tmp_path)
|
|
91
|
+
runner.invoke(cli, ["init", "myapp"])
|
|
92
|
+
|
|
93
|
+
content = (tmp_path / "myapp" / "Makefile").read_text()
|
|
94
|
+
assert "docker compose" in content
|
|
95
|
+
assert "dev:" in content
|
|
96
|
+
assert "dev-down:" in content
|
|
97
|
+
assert "test:" in content
|
|
98
|
+
assert "migrate:" in content
|
|
99
|
+
assert "migrate-make:" in content
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def test_init_dockerfile_content(tmp_path):
|
|
103
|
+
runner = CliRunner()
|
|
104
|
+
os.chdir(tmp_path)
|
|
105
|
+
runner.invoke(cli, ["init", "myapp"])
|
|
106
|
+
|
|
107
|
+
content = (tmp_path / "myapp" / "Dockerfile").read_text()
|
|
108
|
+
assert "FROM ghcr.io/astral-sh/uv" in content
|
|
109
|
+
assert "uvicorn" in content
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_init_docker_compose_content(tmp_path):
|
|
113
|
+
runner = CliRunner()
|
|
114
|
+
os.chdir(tmp_path)
|
|
115
|
+
runner.invoke(cli, ["init", "myapp"])
|
|
116
|
+
|
|
117
|
+
prod = (tmp_path / "myapp" / "docker-compose.yml").read_text()
|
|
118
|
+
assert "dokploy-network" in prod
|
|
119
|
+
assert "external: true" in prod
|
|
120
|
+
assert "service_completed_successfully" in prod
|
|
121
|
+
|
|
122
|
+
dev = (tmp_path / "myapp" / "docker-compose.dev.yml").read_text()
|
|
123
|
+
assert "myapp" in dev
|
|
124
|
+
assert "pgdata" in dev
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def test_init_alembic_config(tmp_path):
|
|
128
|
+
runner = CliRunner()
|
|
129
|
+
os.chdir(tmp_path)
|
|
130
|
+
runner.invoke(cli, ["init", "myapp"])
|
|
131
|
+
|
|
132
|
+
ini = (tmp_path / "myapp" / "alembic.ini").read_text()
|
|
133
|
+
assert "script_location = migrations" in ini
|
|
134
|
+
|
|
135
|
+
env_py = (tmp_path / "myapp" / "migrations" / "env.py").read_text()
|
|
136
|
+
assert "configure_alembic" in env_py
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def test_init_test_files(tmp_path):
|
|
140
|
+
runner = CliRunner()
|
|
141
|
+
os.chdir(tmp_path)
|
|
142
|
+
runner.invoke(cli, ["init", "myapp"])
|
|
143
|
+
|
|
144
|
+
conftest = (tmp_path / "myapp" / "tests" / "conftest.py").read_text()
|
|
145
|
+
assert "bluefox_test_setup" in conftest
|
|
146
|
+
|
|
147
|
+
test_health = (tmp_path / "myapp" / "tests" / "test_health.py").read_text()
|
|
148
|
+
assert "/health" in test_health
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def test_init_project_name_in_env(tmp_path):
|
|
152
|
+
"""Project name should appear in DATABASE_URL and APP_NAME."""
|
|
153
|
+
runner = CliRunner()
|
|
154
|
+
os.chdir(tmp_path)
|
|
155
|
+
runner.invoke(cli, ["init", "cool_project"])
|
|
156
|
+
|
|
157
|
+
env = (tmp_path / "cool_project" / ".env").read_text()
|
|
158
|
+
assert "cool_project" in env
|
|
159
|
+
assert "APP_NAME=cool-project" in env
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
version = 1
|
|
2
|
+
revision = 2
|
|
3
|
+
requires-python = ">=3.12"
|
|
4
|
+
|
|
5
|
+
[[package]]
|
|
6
|
+
name = "bluefox-cli"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
source = { editable = "." }
|
|
9
|
+
dependencies = [
|
|
10
|
+
{ name = "click" },
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
[package.dev-dependencies]
|
|
14
|
+
dev = [
|
|
15
|
+
{ name = "pytest" },
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[package.metadata]
|
|
19
|
+
requires-dist = [{ name = "click", specifier = ">=8.0" }]
|
|
20
|
+
|
|
21
|
+
[package.metadata.requires-dev]
|
|
22
|
+
dev = [{ name = "pytest", specifier = ">=8.0" }]
|
|
23
|
+
|
|
24
|
+
[[package]]
|
|
25
|
+
name = "click"
|
|
26
|
+
version = "8.3.1"
|
|
27
|
+
source = { registry = "https://pypi.org/simple" }
|
|
28
|
+
dependencies = [
|
|
29
|
+
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
|
30
|
+
]
|
|
31
|
+
sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" }
|
|
32
|
+
wheels = [
|
|
33
|
+
{ url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" },
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
[[package]]
|
|
37
|
+
name = "colorama"
|
|
38
|
+
version = "0.4.6"
|
|
39
|
+
source = { registry = "https://pypi.org/simple" }
|
|
40
|
+
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
|
41
|
+
wheels = [
|
|
42
|
+
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
[[package]]
|
|
46
|
+
name = "iniconfig"
|
|
47
|
+
version = "2.3.0"
|
|
48
|
+
source = { registry = "https://pypi.org/simple" }
|
|
49
|
+
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
|
50
|
+
wheels = [
|
|
51
|
+
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
[[package]]
|
|
55
|
+
name = "packaging"
|
|
56
|
+
version = "26.0"
|
|
57
|
+
source = { registry = "https://pypi.org/simple" }
|
|
58
|
+
sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
|
|
59
|
+
wheels = [
|
|
60
|
+
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
[[package]]
|
|
64
|
+
name = "pluggy"
|
|
65
|
+
version = "1.6.0"
|
|
66
|
+
source = { registry = "https://pypi.org/simple" }
|
|
67
|
+
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
|
68
|
+
wheels = [
|
|
69
|
+
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
[[package]]
|
|
73
|
+
name = "pygments"
|
|
74
|
+
version = "2.19.2"
|
|
75
|
+
source = { registry = "https://pypi.org/simple" }
|
|
76
|
+
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
|
|
77
|
+
wheels = [
|
|
78
|
+
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
[[package]]
|
|
82
|
+
name = "pytest"
|
|
83
|
+
version = "9.0.2"
|
|
84
|
+
source = { registry = "https://pypi.org/simple" }
|
|
85
|
+
dependencies = [
|
|
86
|
+
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
|
87
|
+
{ name = "iniconfig" },
|
|
88
|
+
{ name = "packaging" },
|
|
89
|
+
{ name = "pluggy" },
|
|
90
|
+
{ name = "pygments" },
|
|
91
|
+
]
|
|
92
|
+
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
|
|
93
|
+
wheels = [
|
|
94
|
+
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
|
|
95
|
+
]
|