cuneus 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.
- cuneus/__init__.py +63 -0
- cuneus/cli/__init__.py +394 -0
- cuneus/cli/console.py +208 -0
- cuneus/core/__init__.py +0 -0
- cuneus/core/application.py +230 -0
- cuneus/core/execptions.py +214 -0
- cuneus/ext/__init__.py +0 -0
- cuneus/ext/health.py +128 -0
- cuneus/middleware/__init__.py +0 -0
- cuneus/middleware/logging.py +165 -0
- cuneus/py.typed +0 -0
- cuneus-0.2.1.dist-info/METADATA +224 -0
- cuneus-0.2.1.dist-info/RECORD +15 -0
- cuneus-0.2.1.dist-info/WHEEL +4 -0
- cuneus-0.2.1.dist-info/entry_points.txt +2 -0
cuneus/__init__.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""
|
|
2
|
+
qtip - A wrapper for FastAPI applications, like the artist.
|
|
3
|
+
|
|
4
|
+
Example:
|
|
5
|
+
from qtip import Application, Settings
|
|
6
|
+
from qtip.ext.database import DatabaseExtension
|
|
7
|
+
|
|
8
|
+
class AppSettings(Settings):
|
|
9
|
+
database_url: str
|
|
10
|
+
|
|
11
|
+
settings = AppSettings()
|
|
12
|
+
app = Application(settings)
|
|
13
|
+
app.add_extension(DatabaseExtension(settings))
|
|
14
|
+
|
|
15
|
+
fastapi_app = app.build()
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from cuneus.core.application import (
|
|
19
|
+
BaseExtension,
|
|
20
|
+
Extension,
|
|
21
|
+
Settings,
|
|
22
|
+
build_lifespan,
|
|
23
|
+
get_settings,
|
|
24
|
+
load_pyproject_config,
|
|
25
|
+
)
|
|
26
|
+
from cuneus.core.execptions import (
|
|
27
|
+
AppException,
|
|
28
|
+
BadRequest,
|
|
29
|
+
Unauthorized,
|
|
30
|
+
Forbidden,
|
|
31
|
+
NotFound,
|
|
32
|
+
Conflict,
|
|
33
|
+
RateLimited,
|
|
34
|
+
ServiceUnavailable,
|
|
35
|
+
DatabaseError,
|
|
36
|
+
RedisError,
|
|
37
|
+
ExternalServiceError,
|
|
38
|
+
ExceptionExtension,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
__version__ = "0.2.1"
|
|
42
|
+
__all__ = [
|
|
43
|
+
# Core
|
|
44
|
+
"BaseExtension",
|
|
45
|
+
"Extension",
|
|
46
|
+
"Settings",
|
|
47
|
+
"build_lifespan",
|
|
48
|
+
"get_settings",
|
|
49
|
+
"load_pyproject_config",
|
|
50
|
+
# Exceptions
|
|
51
|
+
"AppException",
|
|
52
|
+
"BadRequest",
|
|
53
|
+
"Unauthorized",
|
|
54
|
+
"Forbidden",
|
|
55
|
+
"NotFound",
|
|
56
|
+
"Conflict",
|
|
57
|
+
"RateLimited",
|
|
58
|
+
"ServiceUnavailable",
|
|
59
|
+
"DatabaseError",
|
|
60
|
+
"RedisError",
|
|
61
|
+
"ExternalServiceError",
|
|
62
|
+
"ExceptionExtension",
|
|
63
|
+
]
|
cuneus/cli/__init__.py
ADDED
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CLI tools for application management.
|
|
3
|
+
|
|
4
|
+
Reads configuration from pyproject.toml:
|
|
5
|
+
|
|
6
|
+
[tool.qtip]
|
|
7
|
+
app_name = "myapp"
|
|
8
|
+
app_module = "myapp.main:application"
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import importlib
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
import click
|
|
18
|
+
|
|
19
|
+
from cuneus.core.application import load_pyproject_config, DEFAULT_TOOL_NAME
|
|
20
|
+
from cuneus.cli.console import (
|
|
21
|
+
info,
|
|
22
|
+
success,
|
|
23
|
+
warning,
|
|
24
|
+
error,
|
|
25
|
+
fatal,
|
|
26
|
+
dim,
|
|
27
|
+
header,
|
|
28
|
+
step,
|
|
29
|
+
hint_for_error,
|
|
30
|
+
key_value,
|
|
31
|
+
confirm,
|
|
32
|
+
table,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def get_config() -> dict:
|
|
37
|
+
"""Load configuration from pyproject.toml."""
|
|
38
|
+
config = load_pyproject_config()
|
|
39
|
+
if not config:
|
|
40
|
+
hint_for_error("no_pyproject")
|
|
41
|
+
sys.exit(1)
|
|
42
|
+
return config
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def get_app():
|
|
46
|
+
"""Load the Application instance."""
|
|
47
|
+
config = get_config()
|
|
48
|
+
module_path = config.get("app_module", "app.main:app")
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
module_name, attr_name = module_path.rsplit(":", 1)
|
|
52
|
+
module = importlib.import_module(module_name)
|
|
53
|
+
return getattr(module, attr_name)
|
|
54
|
+
except ImportError as e:
|
|
55
|
+
hint_for_error("import_error", e)
|
|
56
|
+
dim(f" Tried to import: {module_path}")
|
|
57
|
+
sys.exit(1)
|
|
58
|
+
except AttributeError as e:
|
|
59
|
+
hint_for_error("no_app_module", e)
|
|
60
|
+
sys.exit(1)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@click.group()
|
|
64
|
+
@click.option(
|
|
65
|
+
"--config", "-c", type=click.Path(exists=True), help="Path to pyproject.toml"
|
|
66
|
+
)
|
|
67
|
+
@click.option("--verbose", "-v", is_flag=True, help="Verbose output")
|
|
68
|
+
@click.pass_context
|
|
69
|
+
def cli(ctx: click.Context, config: str | None, verbose: bool) -> None:
|
|
70
|
+
"""Application management CLI."""
|
|
71
|
+
ctx.ensure_object(dict)
|
|
72
|
+
ctx.obj["verbose"] = verbose
|
|
73
|
+
if config:
|
|
74
|
+
ctx.obj["config_path"] = config
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@cli.command()
|
|
78
|
+
@click.option("--host", default="0.0.0.0", help="Bind host")
|
|
79
|
+
@click.option("--port", default=8000, type=int, help="Bind port")
|
|
80
|
+
@click.option("--workers", default=1, type=int, help="Number of workers")
|
|
81
|
+
@click.option("--reload", is_flag=True, help="Enable auto-reload")
|
|
82
|
+
@click.pass_context
|
|
83
|
+
def run(ctx: click.Context, host: str, port: int, workers: int, reload: bool) -> None:
|
|
84
|
+
"""Run the application server."""
|
|
85
|
+
import uvicorn
|
|
86
|
+
|
|
87
|
+
config = get_config()
|
|
88
|
+
app_module = config.get("app_module", "app.main:app")
|
|
89
|
+
|
|
90
|
+
# Convert Application reference to FastAPI app reference
|
|
91
|
+
if ":application" in app_module:
|
|
92
|
+
fastapi_module = app_module.replace(":application", ":app")
|
|
93
|
+
else:
|
|
94
|
+
fastapi_module = app_module
|
|
95
|
+
|
|
96
|
+
info(f"Starting {config.get('app_name', 'app')}")
|
|
97
|
+
dim(f" Module: {fastapi_module}")
|
|
98
|
+
dim(f" Address: http://{host}:{port}")
|
|
99
|
+
|
|
100
|
+
if reload:
|
|
101
|
+
warning("Running in reload mode (not for production)")
|
|
102
|
+
|
|
103
|
+
if workers > 1 and reload:
|
|
104
|
+
warning("--reload is incompatible with multiple workers, using 1 worker")
|
|
105
|
+
workers = 1
|
|
106
|
+
|
|
107
|
+
click.echo()
|
|
108
|
+
|
|
109
|
+
try:
|
|
110
|
+
uvicorn.run(
|
|
111
|
+
fastapi_module,
|
|
112
|
+
host=host,
|
|
113
|
+
port=port,
|
|
114
|
+
workers=workers,
|
|
115
|
+
reload=reload,
|
|
116
|
+
log_level="info",
|
|
117
|
+
)
|
|
118
|
+
except OSError as e:
|
|
119
|
+
if "address already in use" in str(e).lower():
|
|
120
|
+
hint_for_error("port_in_use", e)
|
|
121
|
+
else:
|
|
122
|
+
raise
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@cli.group()
|
|
126
|
+
def db() -> None:
|
|
127
|
+
"""Database management commands."""
|
|
128
|
+
pass
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@db.command()
|
|
132
|
+
@click.option("-m", "--message", required=True, help="Migration message")
|
|
133
|
+
def migrate(message: str) -> None:
|
|
134
|
+
"""Generate a new migration."""
|
|
135
|
+
from alembic import command
|
|
136
|
+
from alembic.util.exc import CommandError
|
|
137
|
+
|
|
138
|
+
info(f"Creating migration: {message}")
|
|
139
|
+
|
|
140
|
+
try:
|
|
141
|
+
with step("Generating migration"):
|
|
142
|
+
alembic_cfg = _get_alembic_config()
|
|
143
|
+
command.revision(alembic_cfg, message=message, autogenerate=True)
|
|
144
|
+
success(f"Created migration: {message}")
|
|
145
|
+
except CommandError as e:
|
|
146
|
+
if "Target database is not up to date" in str(e):
|
|
147
|
+
error("Database is not up to date")
|
|
148
|
+
dim(" hint: Run 'db upgrade' first, then create the migration")
|
|
149
|
+
else:
|
|
150
|
+
error(f"Migration failed: {e}")
|
|
151
|
+
sys.exit(1)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@db.command()
|
|
155
|
+
@click.option("--revision", default="head", help="Target revision")
|
|
156
|
+
def upgrade(revision: str) -> None:
|
|
157
|
+
"""Upgrade database to a revision."""
|
|
158
|
+
from alembic import command
|
|
159
|
+
from sqlalchemy.exc import OperationalError
|
|
160
|
+
|
|
161
|
+
info(f"Upgrading database to: {revision}")
|
|
162
|
+
|
|
163
|
+
try:
|
|
164
|
+
with step("Running migrations"):
|
|
165
|
+
alembic_cfg = _get_alembic_config()
|
|
166
|
+
command.upgrade(alembic_cfg, revision)
|
|
167
|
+
success("Database upgraded successfully")
|
|
168
|
+
except OperationalError as e:
|
|
169
|
+
hint_for_error("database_connection", e)
|
|
170
|
+
sys.exit(1)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@db.command()
|
|
174
|
+
@click.option("--revision", default="-1", help="Target revision")
|
|
175
|
+
@click.option("--yes", "-y", is_flag=True, help="Skip confirmation")
|
|
176
|
+
def downgrade(revision: str, yes: bool) -> None:
|
|
177
|
+
"""Downgrade database to a revision."""
|
|
178
|
+
from alembic import command
|
|
179
|
+
|
|
180
|
+
if not yes:
|
|
181
|
+
warning("This will downgrade your database and may cause data loss")
|
|
182
|
+
if not confirm("Are you sure you want to continue?"):
|
|
183
|
+
info("Aborted")
|
|
184
|
+
return
|
|
185
|
+
|
|
186
|
+
info(f"Downgrading database to: {revision}")
|
|
187
|
+
|
|
188
|
+
with step("Running downgrade"):
|
|
189
|
+
alembic_cfg = _get_alembic_config()
|
|
190
|
+
command.downgrade(alembic_cfg, revision)
|
|
191
|
+
|
|
192
|
+
success("Database downgraded successfully")
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
@db.command()
|
|
196
|
+
def history() -> None:
|
|
197
|
+
"""Show migration history."""
|
|
198
|
+
from alembic import command
|
|
199
|
+
from io import StringIO
|
|
200
|
+
|
|
201
|
+
header("Migration History")
|
|
202
|
+
alembic_cfg = _get_alembic_config()
|
|
203
|
+
command.history(alembic_cfg, verbose=True)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@db.command()
|
|
207
|
+
def current() -> None:
|
|
208
|
+
"""Show current revision."""
|
|
209
|
+
from alembic import command
|
|
210
|
+
|
|
211
|
+
header("Current Database Revision")
|
|
212
|
+
alembic_cfg = _get_alembic_config()
|
|
213
|
+
command.current(alembic_cfg, verbose=True)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
@db.command()
|
|
217
|
+
def heads() -> None:
|
|
218
|
+
"""Show migration heads (useful for detecting conflicts)."""
|
|
219
|
+
from alembic import command
|
|
220
|
+
|
|
221
|
+
header("Migration Heads")
|
|
222
|
+
alembic_cfg = _get_alembic_config()
|
|
223
|
+
command.heads(alembic_cfg, verbose=True)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
@db.command()
|
|
227
|
+
def check() -> None:
|
|
228
|
+
"""Check if database is up to date."""
|
|
229
|
+
from alembic.script import ScriptDirectory
|
|
230
|
+
from alembic.runtime.migration import MigrationContext
|
|
231
|
+
from sqlalchemy import create_engine
|
|
232
|
+
|
|
233
|
+
alembic_cfg = _get_alembic_config()
|
|
234
|
+
|
|
235
|
+
with step("Checking database status"):
|
|
236
|
+
script = ScriptDirectory.from_config(alembic_cfg)
|
|
237
|
+
head = script.get_current_head()
|
|
238
|
+
|
|
239
|
+
db_url = alembic_cfg.get_main_option("sqlalchemy.url")
|
|
240
|
+
if db_url is None:
|
|
241
|
+
warning('unable to check "sqlalchemy.url" is missing')
|
|
242
|
+
return
|
|
243
|
+
engine = create_engine(db_url)
|
|
244
|
+
|
|
245
|
+
with engine.connect() as conn:
|
|
246
|
+
context = MigrationContext.configure(conn)
|
|
247
|
+
current = context.get_current_revision()
|
|
248
|
+
|
|
249
|
+
if current == head:
|
|
250
|
+
success("Database is up to date")
|
|
251
|
+
else:
|
|
252
|
+
warning(f"Database is behind: {current} → {head}")
|
|
253
|
+
dim(" Run 'db upgrade' to apply pending migrations")
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _get_alembic_config():
|
|
257
|
+
"""Get Alembic config with database URL from settings."""
|
|
258
|
+
from alembic.config import Config
|
|
259
|
+
|
|
260
|
+
alembic_ini = Path("alembic.ini")
|
|
261
|
+
if not alembic_ini.exists():
|
|
262
|
+
fatal("alembic.ini not found", hint="Run 'alembic init alembic' to create it")
|
|
263
|
+
|
|
264
|
+
alembic_cfg = Config("alembic.ini")
|
|
265
|
+
|
|
266
|
+
app = get_app()
|
|
267
|
+
if hasattr(app.settings, "database_url"):
|
|
268
|
+
db_url = app.settings.database_url
|
|
269
|
+
if db_url.startswith("postgresql+asyncpg://"):
|
|
270
|
+
db_url = db_url.replace("postgresql+asyncpg://", "postgresql://")
|
|
271
|
+
alembic_cfg.set_main_option("sqlalchemy.url", db_url)
|
|
272
|
+
else:
|
|
273
|
+
fatal(
|
|
274
|
+
"No database_url in settings",
|
|
275
|
+
hint="Add database_url to your Settings class",
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
return alembic_cfg
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
@cli.command()
|
|
282
|
+
def shell() -> None:
|
|
283
|
+
"""Start an interactive shell with app context."""
|
|
284
|
+
import asyncio
|
|
285
|
+
import code
|
|
286
|
+
|
|
287
|
+
app = get_app()
|
|
288
|
+
|
|
289
|
+
info(f"Starting {app.settings.app_name} shell")
|
|
290
|
+
|
|
291
|
+
with step("Building application"):
|
|
292
|
+
fastapi_app = app.build()
|
|
293
|
+
|
|
294
|
+
with step("Running startup hooks"):
|
|
295
|
+
|
|
296
|
+
async def setup():
|
|
297
|
+
async with asyncio.timeout(30):
|
|
298
|
+
ctx = fastapi_app.router.lifespan_context(fastapi_app)
|
|
299
|
+
await ctx.__aenter__()
|
|
300
|
+
return ctx
|
|
301
|
+
|
|
302
|
+
ctx = asyncio.run(setup())
|
|
303
|
+
|
|
304
|
+
success("Shell ready")
|
|
305
|
+
click.echo()
|
|
306
|
+
|
|
307
|
+
banner = f"""{click.style(app.settings.app_name, fg='cyan', bold=True)} Interactive Shell
|
|
308
|
+
|
|
309
|
+
Available objects:
|
|
310
|
+
{click.style('app', fg='green')} Application instance
|
|
311
|
+
{click.style('fastapi_app', fg='green')} FastAPI instance
|
|
312
|
+
{click.style('settings', fg='green')} Application settings
|
|
313
|
+
{click.style('state', fg='green')} app.state (db_engine, redis, etc.)
|
|
314
|
+
"""
|
|
315
|
+
|
|
316
|
+
local_vars = {
|
|
317
|
+
"app": app,
|
|
318
|
+
"fastapi_app": fastapi_app,
|
|
319
|
+
"settings": app.settings,
|
|
320
|
+
"state": fastapi_app.state,
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
code.interact(banner=banner, local=local_vars)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
@cli.command("config")
|
|
327
|
+
def show_config() -> None:
|
|
328
|
+
"""Show current configuration."""
|
|
329
|
+
config = get_config()
|
|
330
|
+
|
|
331
|
+
header("Configuration")
|
|
332
|
+
key_value(config)
|
|
333
|
+
|
|
334
|
+
# Show where config was loaded from
|
|
335
|
+
click.echo()
|
|
336
|
+
dim(f" Source: pyproject.toml [tool.{DEFAULT_TOOL_NAME}]")
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
@cli.command()
|
|
340
|
+
def health() -> None:
|
|
341
|
+
"""Verify configuration and connectivity."""
|
|
342
|
+
import asyncio
|
|
343
|
+
|
|
344
|
+
header("Configuration Check")
|
|
345
|
+
|
|
346
|
+
# Check config loads
|
|
347
|
+
with step("Loading configuration"):
|
|
348
|
+
config = get_config()
|
|
349
|
+
|
|
350
|
+
# Check app loads
|
|
351
|
+
with step("Loading application"):
|
|
352
|
+
app = get_app()
|
|
353
|
+
|
|
354
|
+
with step("Building FastAPI app"):
|
|
355
|
+
fastapi_app = app.build()
|
|
356
|
+
|
|
357
|
+
# Check connections
|
|
358
|
+
header("Connectivity Check")
|
|
359
|
+
|
|
360
|
+
async def check_connections():
|
|
361
|
+
async with asyncio.timeout(10):
|
|
362
|
+
ctx = fastapi_app.router.lifespan_context(fastapi_app)
|
|
363
|
+
await ctx.__aenter__()
|
|
364
|
+
|
|
365
|
+
# Check database
|
|
366
|
+
if hasattr(fastapi_app.state, "db_engine"):
|
|
367
|
+
with step("Database connection"):
|
|
368
|
+
async with fastapi_app.state.db_engine.connect() as conn:
|
|
369
|
+
await conn.execute("SELECT 1")
|
|
370
|
+
|
|
371
|
+
# Check Redis
|
|
372
|
+
if hasattr(fastapi_app.state, "redis") and fastapi_app.state.redis:
|
|
373
|
+
with step("Redis connection"):
|
|
374
|
+
await fastapi_app.state.redis.ping()
|
|
375
|
+
|
|
376
|
+
await ctx.__aexit__(None, None, None)
|
|
377
|
+
|
|
378
|
+
try:
|
|
379
|
+
asyncio.run(check_connections())
|
|
380
|
+
click.echo()
|
|
381
|
+
success("All checks passed!")
|
|
382
|
+
except Exception as e:
|
|
383
|
+
click.echo()
|
|
384
|
+
error(f"Check failed: {e}")
|
|
385
|
+
sys.exit(1)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def main() -> None:
|
|
389
|
+
"""Main entry point."""
|
|
390
|
+
cli()
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
if __name__ == "__main__":
|
|
394
|
+
main()
|
cuneus/cli/console.py
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Console output utilities for helpful CLI messaging.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import sys
|
|
8
|
+
from contextlib import contextmanager
|
|
9
|
+
from typing import NoReturn
|
|
10
|
+
|
|
11
|
+
import click
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# === Styled Output ===
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def info(message: str) -> None:
|
|
18
|
+
"""Print an info message."""
|
|
19
|
+
click.echo(click.style("ℹ ", fg="blue") + message)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def success(message: str) -> None:
|
|
23
|
+
"""Print a success message."""
|
|
24
|
+
click.echo(click.style("✓ ", fg="green") + message)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def warning(message: str) -> None:
|
|
28
|
+
"""Print a warning message."""
|
|
29
|
+
click.echo(click.style("⚠ ", fg="yellow") + message, err=True)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def error(message: str) -> None:
|
|
33
|
+
"""Print an error message."""
|
|
34
|
+
click.echo(click.style("✗ ", fg="red") + message, err=True)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def fatal(message: str, hint: str | None = None) -> NoReturn:
|
|
38
|
+
"""Print an error and exit."""
|
|
39
|
+
error(message)
|
|
40
|
+
if hint:
|
|
41
|
+
click.echo(click.style(" hint: ", fg="cyan") + hint, err=True)
|
|
42
|
+
sys.exit(1)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def dim(message: str) -> None:
|
|
46
|
+
"""Print dimmed/secondary text."""
|
|
47
|
+
click.echo(click.style(message, dim=True))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def header(title: str) -> None:
|
|
51
|
+
"""Print a section header."""
|
|
52
|
+
click.echo()
|
|
53
|
+
click.echo(
|
|
54
|
+
click.style(f"── {title} ", fg="cyan", bold=True)
|
|
55
|
+
+ click.style("─" * 40, dim=True)
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# === Progress Indicators ===
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@contextmanager
|
|
63
|
+
def spinner(message: str):
|
|
64
|
+
"""
|
|
65
|
+
Simple spinner for long operations.
|
|
66
|
+
|
|
67
|
+
Usage:
|
|
68
|
+
with spinner("Connecting to database..."):
|
|
69
|
+
await db.connect()
|
|
70
|
+
"""
|
|
71
|
+
click.echo(click.style("◐ ", fg="blue") + message, nl=False)
|
|
72
|
+
try:
|
|
73
|
+
yield
|
|
74
|
+
click.echo(click.style(" ✓", fg="green"))
|
|
75
|
+
except Exception:
|
|
76
|
+
click.echo(click.style(" ✗", fg="red"))
|
|
77
|
+
raise
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@contextmanager
|
|
81
|
+
def step(message: str):
|
|
82
|
+
"""
|
|
83
|
+
Step indicator with pass/fail.
|
|
84
|
+
|
|
85
|
+
Usage:
|
|
86
|
+
with step("Running migrations"):
|
|
87
|
+
run_migrations()
|
|
88
|
+
"""
|
|
89
|
+
click.echo(click.style("→ ", fg="blue") + message + "... ", nl=False)
|
|
90
|
+
try:
|
|
91
|
+
yield
|
|
92
|
+
click.echo(click.style("done", fg="green"))
|
|
93
|
+
except Exception as e:
|
|
94
|
+
click.echo(click.style("failed", fg="red"))
|
|
95
|
+
raise
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# === Helpful Error Messages ===
|
|
99
|
+
|
|
100
|
+
ERROR_HINTS = {
|
|
101
|
+
"no_pyproject": (
|
|
102
|
+
"Could not find pyproject.toml",
|
|
103
|
+
"Make sure you're running from your project root, or use --config to specify the path",
|
|
104
|
+
),
|
|
105
|
+
"no_app_module": (
|
|
106
|
+
"Could not load application module",
|
|
107
|
+
"Check that [tool.qtip].app_module points to your Application instance",
|
|
108
|
+
),
|
|
109
|
+
"no_database_url": (
|
|
110
|
+
"DATABASE_URL is not configured",
|
|
111
|
+
"Set it in pyproject.toml, .env, or as an environment variable",
|
|
112
|
+
),
|
|
113
|
+
"database_connection": (
|
|
114
|
+
"Could not connect to database",
|
|
115
|
+
"Check that your database is running and DATABASE_URL is correct",
|
|
116
|
+
),
|
|
117
|
+
"redis_connection": (
|
|
118
|
+
"Could not connect to Redis",
|
|
119
|
+
"Check that Redis is running and REDIS_URL is correct",
|
|
120
|
+
),
|
|
121
|
+
"migration_conflict": (
|
|
122
|
+
"Migration conflict detected",
|
|
123
|
+
"You may have multiple heads. Run 'db heads' to see them, then 'db merge' to resolve",
|
|
124
|
+
),
|
|
125
|
+
"port_in_use": (
|
|
126
|
+
"Port is already in use",
|
|
127
|
+
"Either stop the other process or use --port to specify a different port",
|
|
128
|
+
),
|
|
129
|
+
"import_error": (
|
|
130
|
+
"Could not import module",
|
|
131
|
+
"Check your PYTHONPATH and that all dependencies are installed",
|
|
132
|
+
),
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def hint_for_error(error_key: str, exception: Exception | None = None) -> None:
|
|
137
|
+
"""Print a helpful error message with hint."""
|
|
138
|
+
if error_key in ERROR_HINTS:
|
|
139
|
+
msg, hint = ERROR_HINTS[error_key]
|
|
140
|
+
error(msg)
|
|
141
|
+
if exception:
|
|
142
|
+
dim(f" {type(exception).__name__}: {exception}")
|
|
143
|
+
click.echo(click.style(" hint: ", fg="cyan") + hint, err=True)
|
|
144
|
+
elif exception:
|
|
145
|
+
error(str(exception))
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
# === Tables and Structured Output ===
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def table(headers: list[str], rows: list[list[str]]) -> None:
|
|
152
|
+
"""Print a simple table."""
|
|
153
|
+
if not rows:
|
|
154
|
+
dim(" (no data)")
|
|
155
|
+
return
|
|
156
|
+
|
|
157
|
+
# Calculate column widths
|
|
158
|
+
widths = [len(h) for h in headers]
|
|
159
|
+
for row in rows:
|
|
160
|
+
for i, cell in enumerate(row):
|
|
161
|
+
widths[i] = max(widths[i], len(str(cell)))
|
|
162
|
+
|
|
163
|
+
# Print header
|
|
164
|
+
header_row = " ".join(
|
|
165
|
+
click.style(h.ljust(widths[i]), bold=True) for i, h in enumerate(headers)
|
|
166
|
+
)
|
|
167
|
+
click.echo(header_row)
|
|
168
|
+
click.echo(click.style("─" * (sum(widths) + 2 * (len(headers) - 1)), dim=True))
|
|
169
|
+
|
|
170
|
+
# Print rows
|
|
171
|
+
for row in rows:
|
|
172
|
+
click.echo(" ".join(str(cell).ljust(widths[i]) for i, cell in enumerate(row)))
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def key_value(data: dict[str, str], title: str | None = None) -> None:
|
|
176
|
+
"""Print key-value pairs."""
|
|
177
|
+
if title:
|
|
178
|
+
header(title)
|
|
179
|
+
|
|
180
|
+
max_key_len = max(len(k) for k in data.keys()) if data else 0
|
|
181
|
+
|
|
182
|
+
for key, value in data.items():
|
|
183
|
+
key_str = click.style(key.ljust(max_key_len), fg="cyan")
|
|
184
|
+
# Mask secrets
|
|
185
|
+
if (
|
|
186
|
+
"secret" in key.lower()
|
|
187
|
+
or "password" in key.lower()
|
|
188
|
+
or "token" in key.lower()
|
|
189
|
+
):
|
|
190
|
+
value = "***"
|
|
191
|
+
click.echo(f" {key_str} {value}")
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
# === Confirmations ===
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def confirm(message: str, default: bool = False) -> bool:
|
|
198
|
+
"""Ask for confirmation."""
|
|
199
|
+
return click.confirm(click.style("? ", fg="yellow") + message, default=default)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def prompt(message: str, default: str | None = None, hide_input: bool = False) -> str:
|
|
203
|
+
"""Prompt for input."""
|
|
204
|
+
return click.prompt(
|
|
205
|
+
click.style("? ", fg="yellow") + message,
|
|
206
|
+
default=default,
|
|
207
|
+
hide_input=hide_input,
|
|
208
|
+
)
|
cuneus/core/__init__.py
ADDED
|
File without changes
|