stkit 17.0.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.
Files changed (128) hide show
  1. stk/__init__.py +19 -0
  2. stk/__main__.py +6 -0
  3. stk/agent_login.py +78 -0
  4. stk/app.py +323 -0
  5. stk/checks.py +634 -0
  6. stk/cli/__init__.py +49 -0
  7. stk/cli/agent.py +267 -0
  8. stk/cli/base.py +79 -0
  9. stk/cli/database.py +199 -0
  10. stk/cli/develop.py +63 -0
  11. stk/cli/doctor.py +262 -0
  12. stk/cli/main.py +101 -0
  13. stk/cli/reports.py +296 -0
  14. stk/cli/smoke.py +399 -0
  15. stk/cli/users.py +259 -0
  16. stk/cli/watch.py +68 -0
  17. stk/extensions.py +29 -0
  18. stk/migrations/__init__.py +130 -0
  19. stk/migrations/env.py +58 -0
  20. stk/migrations/script.py.mako +17 -0
  21. stk/migrations/versions/stk_0001_initial_schema.py +213 -0
  22. stk/portal/views.py +34 -0
  23. stk/public/__init__.py +0 -0
  24. stk/public/models.py +0 -0
  25. stk/public/views.py +344 -0
  26. stk/scaffold/__init__.py +1 -0
  27. stk/scaffold/app/CLAUDE.md +65 -0
  28. stk/scaffold/app/Dockerfile +41 -0
  29. stk/scaffold/app/README.md +16 -0
  30. stk/scaffold/app/__app__/__init__.py +1 -0
  31. stk/scaffold/app/__app__/app.py +8 -0
  32. stk/scaffold/app/__app__/settings.py +7 -0
  33. stk/scaffold/app/claude/skills/stk-api/SKILL.md +52 -0
  34. stk/scaffold/app/claude/skills/stk-blueprint/SKILL.md +48 -0
  35. stk/scaffold/app/claude/skills/stk-deploy/SKILL.md +61 -0
  36. stk/scaffold/app/claude/skills/stk-lessons/SKILL.md +58 -0
  37. stk/scaffold/app/claude/skills/stk-migrate/SKILL.md +96 -0
  38. stk/scaffold/app/docker-compose.yml +88 -0
  39. stk/scaffold/app/dockerignore +13 -0
  40. stk/scaffold/app/env-sample +78 -0
  41. stk/scaffold/app/gitignore +8 -0
  42. stk/scaffold/app/nginx/nginx.conf +46 -0
  43. stk/scaffold/app/nginx/stk.conf +36 -0
  44. stk/scaffold/app/postgres/init-db.sh +8 -0
  45. stk/scaffold/app/pyproject.toml +23 -0
  46. stk/scaffold/app/tests/__init__.py +0 -0
  47. stk/scaffold/app/tests/test_app.py +23 -0
  48. stk/scaffold/generator.py +209 -0
  49. stk/scaffold/templates.py +437 -0
  50. stk/security.py +145 -0
  51. stk/settings.py +147 -0
  52. stk/shell.py +284 -0
  53. stk/static/VERSIONS.txt +4 -0
  54. stk/static/css/app.css +229 -0
  55. stk/static/css/layout.css +330 -0
  56. stk/static/css/vuetify.min.css +6 -0
  57. stk/static/icons/fonts/tabler-icons.woff2 +0 -0
  58. stk/static/icons/tabler-icons.min.css +4 -0
  59. stk/static/img/auth-bg.webp +0 -0
  60. stk/static/img/favicon.ico +0 -0
  61. stk/static/img/stk.svg +1664 -0
  62. stk/static/js/axios.min.js +5 -0
  63. stk/static/js/components/NotificationDropdown.js +156 -0
  64. stk/static/js/components/ThemeSwitcher.js +68 -0
  65. stk/static/js/components/TransitionExpand.js +64 -0
  66. stk/static/js/components/VerticalNavGroup.js +119 -0
  67. stk/static/js/components/VerticalNavLink.js +36 -0
  68. stk/static/js/components/VerticalNavSectionTitle.js +21 -0
  69. stk/static/js/components/index.js +47 -0
  70. stk/static/js/config.js +150 -0
  71. stk/static/js/vue.min.js +13 -0
  72. stk/static/js/vuetify.min.js +3148 -0
  73. stk/static/robots.txt +1 -0
  74. stk/tasks/__init__.py +78 -0
  75. stk/templates/401.html +14 -0
  76. stk/templates/404.html +12 -0
  77. stk/templates/500.html +13 -0
  78. stk/templates/_brand.html +1 -0
  79. stk/templates/auth_layout.html +26 -0
  80. stk/templates/cms/activities.html +135 -0
  81. stk/templates/cms/roles.html +215 -0
  82. stk/templates/cms/users.html +312 -0
  83. stk/templates/dashboard.html +132 -0
  84. stk/templates/index.html +52 -0
  85. stk/templates/layout.html +328 -0
  86. stk/templates/login-layout.html +54 -0
  87. stk/templates/security/_macros.html +39 -0
  88. stk/templates/security/_messages.html +12 -0
  89. stk/templates/security/change_password.html +100 -0
  90. stk/templates/security/confirm_email.html +44 -0
  91. stk/templates/security/email/change_notice.html +4 -0
  92. stk/templates/security/email/change_notice.txt +5 -0
  93. stk/templates/security/email/confirmation_instructions.html +3 -0
  94. stk/templates/security/email/confirmation_instructions.txt +3 -0
  95. stk/templates/security/email/login_instructions.html +5 -0
  96. stk/templates/security/email/login_instructions.txt +5 -0
  97. stk/templates/security/email/reset_instructions.html +1 -0
  98. stk/templates/security/email/reset_instructions.txt +3 -0
  99. stk/templates/security/email/reset_notice.html +1 -0
  100. stk/templates/security/email/reset_notice.txt +1 -0
  101. stk/templates/security/email/welcome.html +7 -0
  102. stk/templates/security/email/welcome.txt +7 -0
  103. stk/templates/security/forgot_password.html +63 -0
  104. stk/templates/security/login_user.html +206 -0
  105. stk/templates/security/mf_recovery.html +67 -0
  106. stk/templates/security/mf_recovery_codes.html +119 -0
  107. stk/templates/security/register_user.html +263 -0
  108. stk/templates/security/reset_password.html +53 -0
  109. stk/templates/security/send_confirmation.html +48 -0
  110. stk/templates/security/send_login.html +39 -0
  111. stk/templates/security/two_factor_select.html +78 -0
  112. stk/templates/security/two_factor_setup.html +166 -0
  113. stk/templates/security/two_factor_verify_code.html +71 -0
  114. stk/templates/security/wan_register.html +179 -0
  115. stk/templates/security/wan_signin.html +123 -0
  116. stk/templates/security/wan_verify.html +109 -0
  117. stk/user/__init__.py +0 -0
  118. stk/user/forms.py +10 -0
  119. stk/user/models.py +308 -0
  120. stk/user/views.py +339 -0
  121. stk/utils/email.py +32 -0
  122. stk/utils/ratelimit.py +67 -0
  123. stk/websocket.py +133 -0
  124. stkit-17.0.0.dist-info/METADATA +135 -0
  125. stkit-17.0.0.dist-info/RECORD +128 -0
  126. stkit-17.0.0.dist-info/WHEEL +4 -0
  127. stkit-17.0.0.dist-info/entry_points.txt +2 -0
  128. stkit-17.0.0.dist-info/licenses/LICENSE +21 -0
stk/security.py ADDED
@@ -0,0 +1,145 @@
1
+ """Host controls for all routes and server-side session transitions."""
2
+
3
+ import secrets
4
+ from datetime import datetime
5
+ from urllib.parse import urlsplit
6
+
7
+ from quart import abort, current_app, g, request, session
8
+ from quart_security import SecurityState, current_user
9
+ from quart_security.breach import password_is_breached
10
+ from quart_security.password import validate_password
11
+ from sqlalchemy import delete, select
12
+
13
+ from stk.user.models import Session
14
+
15
+
16
+ def csrf_exempt(view):
17
+ """Skip the CSRF token check for this view, e.g. a third-party webhook.
18
+
19
+ The view must authenticate the request itself (e.g. an HMAC signature).
20
+ Host, API body, and pagination checks still apply. Place it below the
21
+ route decorator: ``@bp.post("/webhook")`` then ``@csrf_exempt``.
22
+ """
23
+ view._stk_csrf_exempt = True
24
+ return view
25
+
26
+
27
+ def register_security_controls(app):
28
+ @app.before_request
29
+ async def protect_request():
30
+ public_url = app.config.get("STK_PUBLIC_URL")
31
+ if (
32
+ public_url
33
+ and request.path != "/health"
34
+ and request.host != urlsplit(public_url).netloc
35
+ ):
36
+ abort(400, description="Invalid host")
37
+ g.previous_auth_token = session.get("_id")
38
+ g.previous_second_factor = session.get("tf_user_id")
39
+ exempt = getattr(
40
+ current_app.view_functions.get(request.endpoint), "_stk_csrf_exempt", False
41
+ )
42
+ if request.method in {"POST", "PUT", "PATCH", "DELETE"}:
43
+ expected = session.get("_csrf_token")
44
+ supplied = request.headers.get("X-CSRFToken")
45
+ if not supplied:
46
+ supplied = (await request.form).get("csrf_token")
47
+ if not supplied:
48
+ body = await request.get_json(silent=True)
49
+ supplied = body.get("csrf_token") if isinstance(body, dict) else None
50
+ if not exempt and (
51
+ not isinstance(expected, str)
52
+ or not isinstance(supplied, str)
53
+ or not secrets.compare_digest(expected, supplied)
54
+ ):
55
+ abort(400, description="Invalid CSRF token")
56
+ if request.path.startswith("/api/") and request.method in {
57
+ "POST",
58
+ "PUT",
59
+ "PATCH",
60
+ }:
61
+ body = await request.get_json(silent=True)
62
+ if not isinstance(body, dict) or (
63
+ "item" in body and not isinstance(body["item"], dict)
64
+ ):
65
+ abort(400, description="Expected a JSON object")
66
+ if request.path.startswith("/api/"):
67
+ for name, low, high in (("page", 1, 100000), ("per_page", 1, 100)):
68
+ if name in request.args:
69
+ try:
70
+ value = int(request.args[name])
71
+ except ValueError:
72
+ abort(400, description="Invalid pagination")
73
+ if not low <= value <= high:
74
+ abort(400, description="Invalid pagination")
75
+
76
+ @app.after_request
77
+ async def synchronize_session(response):
78
+ if session.get("_csrf_token") or session.get("_id"):
79
+ response.headers.setdefault("Cache-Control", "private, no-store")
80
+ response.headers.setdefault("X-Content-Type-Options", "nosniff")
81
+ response.headers.setdefault("X-Frame-Options", "DENY")
82
+ response.headers.setdefault("Referrer-Policy", "same-origin")
83
+ response.headers.setdefault(
84
+ "Content-Security-Policy",
85
+ "frame-ancestors 'none'; base-uri 'self'; object-src 'none'",
86
+ )
87
+ if "previous_auth_token" not in g:
88
+ return response
89
+ previous = g.get("previous_auth_token")
90
+ token = session.get("_id")
91
+ if previous != token or g.get("previous_second_factor") != session.get(
92
+ "tf_user_id"
93
+ ):
94
+ interface = app.session_interface
95
+ if hasattr(session, "sid"):
96
+ await interface.delete(key=interface.key_prefix + session.sid, app=app)
97
+ session.sid = secrets.token_urlsafe(32)
98
+ session.modified = True
99
+ db = g.get("db_session")
100
+ if db is not None:
101
+ old = None
102
+ if previous and previous != token:
103
+ await db.execute(
104
+ delete(SecurityState).where(SecurityState.token == previous)
105
+ )
106
+ old = await db.scalar(
107
+ select(Session).where(Session.session_token == previous)
108
+ )
109
+ if old:
110
+ old.is_active = False
111
+ if token and current_user.is_authenticated:
112
+ record = await db.scalar(
113
+ select(Session).where(Session.session_token == token)
114
+ )
115
+ if record is None:
116
+ record = await Session.create_session(
117
+ current_user.id,
118
+ token,
119
+ request.remote_addr,
120
+ meta=old.meta if old else None,
121
+ )
122
+ record.expires_at = datetime.now() + app.permanent_session_lifetime
123
+ await db.commit()
124
+ return response
125
+
126
+
127
+ async def check_password_policy(password):
128
+ from quart import current_app
129
+
130
+ if not isinstance(password, str) or len(password) > 1024:
131
+ abort(400, description="Invalid password")
132
+ errors = validate_password(
133
+ password, min_length=current_app.config["SECURITY_PASSWORD_LENGTH_MIN"]
134
+ )
135
+ if errors:
136
+ abort(400, description=errors[0])
137
+ if current_app.config.get("SECURITY_PASSWORD_BREACH_CHECK", True):
138
+ breached = await password_is_breached(
139
+ password,
140
+ count_min=current_app.config.get("SECURITY_PASSWORD_BREACH_COUNT_MIN", 1),
141
+ )
142
+ if breached:
143
+ abort(
144
+ 400, description="Choose a password that has not appeared in a breach"
145
+ )
stk/settings.py ADDED
@@ -0,0 +1,147 @@
1
+ import importlib.util
2
+ import os
3
+ from datetime import timedelta
4
+
5
+ from dotenv import find_dotenv, load_dotenv
6
+
7
+ # Detect optional dependencies (installed via --extra full)
8
+ REDIS_AVAILABLE = importlib.util.find_spec("redis") is not None
9
+
10
+ # The app's .env, found from the working directory: stk itself may live in site-packages.
11
+ load_dotenv(find_dotenv(usecwd=True))
12
+
13
+ # Session configuration - computed at module level
14
+ _redis_url = os.environ.get("REDIS_URL") or os.environ.get("REDIS_SESSION")
15
+ if REDIS_AVAILABLE and _redis_url:
16
+ _SESSION_TYPE = "redis"
17
+ else:
18
+ # No server-side sessions — Quart's built-in cookie sessions will be used
19
+ _SESSION_TYPE = None
20
+
21
+
22
+ class Config:
23
+ # Checked when the app is built, so importing stk needs no environment.
24
+ SECRET_KEY = os.environ.get("SECRET_KEY")
25
+ # Product name and sign-in copy; apps set these instead of editing templates
26
+ APP_NAME = os.environ.get("APP_NAME", "stk")
27
+ APP_TAGLINE = os.environ.get("APP_TAGLINE", "Secure. Scalable. Swift.")
28
+ APP_DESCRIPTION = os.environ.get("APP_DESCRIPTION", "")
29
+
30
+ # Unset means SQLite at instance/stk.db, next to the app's package.
31
+ SQLALCHEMY_DATABASE_URI = os.environ.get("SQLALCHEMY_DATABASE_URI")
32
+
33
+ STK_PUBLIC_URL = os.environ.get("STK_PUBLIC_URL")
34
+
35
+ # security
36
+ SECURITY_REGISTERABLE = (
37
+ os.environ.get("SECURITY_REGISTERABLE", "False").lower() == "true"
38
+ )
39
+ SECURITY_RECOVERABLE = False
40
+ SECURITY_CONFIRMABLE = False
41
+ SECURITY_CHANGEABLE = True
42
+ SECURITY_TRACKABLE = True
43
+ SECURITY_PASSWORD_HASH = "argon2"
44
+ SECURITY_PASSWORD_SALT = os.environ.get("SECURITY_PASSWORD_SALT")
45
+ # quart-security handles email normalization internally
46
+
47
+ SECURITY_POST_LOGIN_VIEW = "/dashboard"
48
+ SECURITY_POST_CONFIRM_VIEW = "/dashboard"
49
+ SECURITY_POST_REGISTER_VIEW = "/login"
50
+
51
+ SECURITY_MULTI_FACTOR_RECOVERY_CODES = True
52
+ SECURITY_MULTI_FACTOR_RECOVERY_CODES_N = 3
53
+
54
+ SECURITY_TWO_FACTOR_ENABLED_METHODS = ["authenticator"]
55
+ SECURITY_TWO_FACTOR = True
56
+ _totp_keys = os.environ.get("SECURITY_TOTP_ENCRYPTION_KEYS")
57
+ SECURITY_TOTP_ENCRYPTION_KEYS = _totp_keys.split(",") if _totp_keys else None
58
+ SECURITY_API_ENABLED_METHODS = ["session"]
59
+
60
+ SECURITY_FRESHNESS = timedelta(minutes=60)
61
+ SECURITY_FRESHNESS_GRACE_PERIOD = timedelta(minutes=60)
62
+ SECURITY_PASSWORD_LENGTH_MIN = 12
63
+
64
+ SECURITY_TOTP_ISSUER = "stk"
65
+
66
+ SECURITY_WAN_RP_ID = os.environ.get("SECURITY_WAN_RP_ID")
67
+ SECURITY_WAN_EXPECTED_ORIGIN = os.environ.get("SECURITY_WAN_EXPECTED_ORIGIN")
68
+ SECURITY_WEBAUTHN = True
69
+ SECURITY_WAN_ALLOW_AS_FIRST_FACTOR = True
70
+ SECURITY_WAN_ALLOW_AS_MULTI_FACTOR = True
71
+
72
+ # Session configuration (quart-session for Redis, Quart cookie sessions otherwise)
73
+ SESSION_TYPE = _SESSION_TYPE
74
+ SESSION_URI = _redis_url # quart-session auto-creates async redis from URI
75
+ SESSION_KEY_PREFIX = "session:"
76
+ # quart-session's signer emits a bytes cookie value that modern werkzeug
77
+ # rejects (500 on login); random session ids don't need signing
78
+ SESSION_USE_SIGNER = False
79
+ PERMANENT_SESSION_LIFETIME = 3600
80
+ SESSION_COOKIE_SECURE = (
81
+ os.environ.get("SESSION_COOKIE_SECURE", "True").lower() == "true"
82
+ )
83
+ SECURITY_COOKIE_SECURE = SESSION_COOKIE_SECURE
84
+ SESSION_COOKIE_HTTPONLY = (
85
+ os.environ.get("SESSION_COOKIE_HTTPONLY", "True").lower() == "true"
86
+ )
87
+ SESSION_COOKIE_SAMESITE = os.environ.get("SESSION_COOKIE_SAMESITE", "Lax")
88
+
89
+ # Session management
90
+ DISABLE_MULTIPLE_SESSIONS = (
91
+ os.environ.get("DISABLE_MULTIPLE_SESSIONS", "False").lower() == "true"
92
+ )
93
+ # Test-only browser session handoff for agent-driven development.
94
+ STK_ENV = os.environ.get("STK_ENV", "production")
95
+ STK_ENABLE_AGENT_LOGIN = (
96
+ os.environ.get("STK_ENABLE_AGENT_LOGIN", "False").lower() == "true"
97
+ or os.environ.get("STK_ENABLE_AGENT_LOGIN") == "1"
98
+ )
99
+ STK_AGENT_LOGIN_MAX_TTL_SECONDS = int(
100
+ os.environ.get("STK_AGENT_LOGIN_MAX_TTL_SECONDS", "60")
101
+ )
102
+
103
+ # Email settings (used by aiosmtplib via AsyncMailUtil)
104
+ MAIL_SERVER = os.environ.get("MAIL_SERVER")
105
+ MAIL_PORT = 465
106
+ MAIL_USE_SSL = True
107
+ MAIL_USERNAME = os.environ.get("MAIL_USERNAME")
108
+ MAIL_PASSWORD = os.environ.get("MAIL_PASSWORD")
109
+ SECURITY_EMAIL_SENDER = os.environ.get("SECURITY_EMAIL_SENDER", "info@domain.com")
110
+ SECURITY_SEND_PASSWORD_CHANGE_EMAIL = False
111
+
112
+ # Google OAuth Settings
113
+ GOOGLE_AUTH_ENABLED = (
114
+ os.environ.get("GOOGLE_AUTH_ENABLED", "False").lower() == "true"
115
+ )
116
+ GOOGLE_OAUTH_CLIENT_ID = os.environ.get("GOOGLE_OAUTH_CLIENT_ID")
117
+ GOOGLE_OAUTH_CLIENT_SECRET = os.environ.get("GOOGLE_OAUTH_CLIENT_SECRET")
118
+ GOOGLE_OAUTH_REDIRECT_URI = os.environ.get(
119
+ "GOOGLE_OAUTH_REDIRECT_URI"
120
+ ) # Let the OAuth handler construct it dynamically
121
+ OAUTHLIB_INSECURE_TRANSPORT = os.environ.get("OAUTHLIB_INSECURE_TRANSPORT", "0")
122
+
123
+ # GitHub OAuth Settings
124
+ GITHUB_AUTH_ENABLED = (
125
+ os.environ.get("GITHUB_AUTH_ENABLED", "False").lower() == "true"
126
+ )
127
+ GITHUB_OAUTH_CLIENT_ID = os.environ.get("GITHUB_OAUTH_CLIENT_ID")
128
+ GITHUB_OAUTH_CLIENT_SECRET = os.environ.get("GITHUB_OAUTH_CLIENT_SECRET")
129
+
130
+
131
+ class TestingConfig(Config):
132
+ """Base for test configs. `.env` loads at import, so without these pins a
133
+ developer's Redis, mail, OAuth, or agent login settings reach every test."""
134
+
135
+ TESTING = True
136
+ SQLALCHEMY_DATABASE_URI = "sqlite+aiosqlite:///:memory:"
137
+ SESSION_TYPE = None
138
+ SESSION_URI = None
139
+ SECURITY_COOKIE_SECURE = False
140
+ SECURITY_PASSWORD_BREACH_CHECK = False
141
+ SECURITY_REGISTERABLE = False
142
+ QUART_RATE_LIMITER_ENABLED = False
143
+ DISABLE_MULTIPLE_SESSIONS = False
144
+ STK_ENABLE_AGENT_LOGIN = False
145
+ MAIL_SERVER = None
146
+ GOOGLE_AUTH_ENABLED = False
147
+ GITHUB_AUTH_ENABLED = False
stk/shell.py ADDED
@@ -0,0 +1,284 @@
1
+ """Async REPL for stk: live app, live DB session, top-level await."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ import asyncio
7
+ import atexit
8
+ import code
9
+ import inspect
10
+ import sys
11
+ import types
12
+ from datetime import datetime, timedelta
13
+ from pathlib import Path
14
+
15
+ from quart import g
16
+ from rich.console import Console
17
+ from sqlalchemy import delete, func, select, text, update
18
+ from sqlalchemy.exc import PendingRollbackError, SQLAlchemyError
19
+
20
+ import stk.extensions as ext
21
+ from stk.migrations import import_model_modules
22
+
23
+ console = Console()
24
+
25
+
26
+ def history_file(app) -> Path:
27
+ """The REPL history, kept with the app's instance data."""
28
+ return Path(app.instance_path) / ".stk_shell_history"
29
+
30
+
31
+ BANNER_HELPERS = [
32
+ ("find(Model, **kw)", "list rows matching equality filters"),
33
+ ("first(Model, **kw)", "first row matching equality filters"),
34
+ ("count(Model, **kw)", "row count"),
35
+ ("sql('select 1')", "run raw SQL, return rows"),
36
+ ("routes('user')", "print routes, optionally filtered"),
37
+ ("db", "live AsyncSession (also g.db_session)"),
38
+ ]
39
+
40
+
41
+ def discover_models() -> dict[str, type]:
42
+ """Return every mapped model class keyed by class name."""
43
+ import_model_modules()
44
+ return {
45
+ mapper.class_.__name__: mapper.class_ for mapper in ext.Base.registry.mappers
46
+ }
47
+
48
+
49
+ def build_namespace(app, db) -> dict:
50
+ """Build the shell namespace: app, session, models, query helpers."""
51
+ from stk.cli.reports import build_routes_report
52
+
53
+ async def execute(statement, params=None):
54
+ """Run a statement; never leave the session poisoned by an earlier failure."""
55
+ try:
56
+ return await db.execute(statement, params)
57
+ except PendingRollbackError:
58
+ # The failure was already reported where it happened; recover silently.
59
+ await db.rollback()
60
+ return await db.execute(statement, params)
61
+ except SQLAlchemyError:
62
+ await db.rollback()
63
+ raise
64
+
65
+ async def find(model, **filters):
66
+ return (await execute(select(model).filter_by(**filters))).scalars().all()
67
+
68
+ async def first(model, **filters):
69
+ stmt = select(model).filter_by(**filters).limit(1)
70
+ return (await execute(stmt)).scalars().first()
71
+
72
+ async def count(model, **filters):
73
+ stmt = select(func.count()).select_from(model).filter_by(**filters)
74
+ return (await execute(stmt)).scalar_one()
75
+
76
+ async def sql(statement, **params):
77
+ result = await execute(
78
+ text(statement) if isinstance(statement, str) else statement, params
79
+ )
80
+ return result.all() if result.returns_rows else result.rowcount
81
+
82
+ def routes(pattern=None):
83
+ for route in build_routes_report(app):
84
+ if (
85
+ pattern
86
+ and pattern not in route["rule"]
87
+ and pattern not in route["endpoint"]
88
+ ):
89
+ continue
90
+ methods = ",".join(route["methods"])
91
+ lock = "[yellow]auth[/]" if route["auth"]["required"] else "[dim]open[/]"
92
+ console.print(
93
+ f"[dim]{methods:14}[/]{route['rule']:44} {lock:16} [cyan]{route['endpoint']}[/]",
94
+ soft_wrap=True,
95
+ )
96
+
97
+ namespace = {
98
+ "app": app,
99
+ "db": db,
100
+ "ext": ext,
101
+ "select": select,
102
+ "func": func,
103
+ "text": text,
104
+ "delete": delete,
105
+ "update": update,
106
+ "datetime": datetime,
107
+ "timedelta": timedelta,
108
+ "find": find,
109
+ "first": first,
110
+ "count": count,
111
+ "sql": sql,
112
+ "execute": execute,
113
+ "routes": routes,
114
+ }
115
+ namespace.update(discover_models())
116
+ return namespace
117
+
118
+
119
+ def print_banner(models) -> None:
120
+ console.print(f"\n[bold]stk shell[/] [dim]python {sys.version.split()[0]}[/]")
121
+ console.print(f"[dim]db:[/] {_masked_database_url()}")
122
+ console.print(f"[dim]models:[/] {', '.join(models)}")
123
+ for name, description in BANNER_HELPERS:
124
+ console.print(f" [green]{name:22}[/] [dim]{description}[/]")
125
+ console.print(
126
+ "[dim]top-level await works. tab completes, F2 opens the menu, ctrl-d exits.[/]\n"
127
+ )
128
+
129
+
130
+ def _masked_database_url() -> str:
131
+ from sqlalchemy.engine import make_url
132
+
133
+ from stk.settings import Config
134
+
135
+ return make_url(Config.SQLALCHEMY_DATABASE_URI).render_as_string(hide_password=True)
136
+
137
+
138
+ def _setup_readline(namespace, history: Path) -> None:
139
+ """Tab completion and persistent history, best effort."""
140
+ try:
141
+ import readline
142
+ import rlcompleter
143
+ except ImportError: # pragma: no cover - windows / no readline build
144
+ return
145
+
146
+ readline.set_completer(rlcompleter.Completer(namespace).complete)
147
+ readline.parse_and_bind(
148
+ "bind ^I rl_complete"
149
+ if "libedit" in getattr(readline, "__doc__", "")
150
+ else "tab: complete"
151
+ )
152
+ readline.set_history_length(2000)
153
+ try:
154
+ history.parent.mkdir(parents=True, exist_ok=True)
155
+ if history.exists():
156
+ readline.read_history_file(str(history))
157
+ except OSError: # unreadable history is not worth failing the shell over
158
+ return
159
+ atexit.register(readline.write_history_file, str(history))
160
+
161
+
162
+ def _configure_repl(repl) -> None:
163
+ """Syntax highlighting, fish-style suggestions, completion as you type."""
164
+ repl.use_code_colorscheme("monokai")
165
+ repl.complete_while_typing = True
166
+ repl.enable_fuzzy_completion = True
167
+ repl.enable_auto_suggest = True
168
+ repl.enable_history_search = True
169
+ repl.show_signature = True
170
+ repl.show_docstring = True
171
+ repl.highlight_matching_parenthesis = True
172
+ repl.confirm_exit = False
173
+
174
+
175
+ async def _run_ptpython(app, db, namespace) -> None:
176
+ """Full-screen REPL. User code is awaited in this task, so `g` stays bound."""
177
+ from ptpython.repl import embed
178
+
179
+ history = history_file(app)
180
+ history.parent.mkdir(parents=True, exist_ok=True)
181
+ async with app.app_context():
182
+ g.db_session = db
183
+ await embed(
184
+ globals=namespace,
185
+ title="stk shell",
186
+ history_filename=str(history),
187
+ configure=_configure_repl,
188
+ patch_stdout=True,
189
+ return_asyncio_coroutine=True,
190
+ )
191
+
192
+
193
+ def _ptpython_usable() -> bool:
194
+ if not sys.stdin.isatty():
195
+ return False
196
+ try:
197
+ import ptpython # noqa: F401
198
+ except ImportError:
199
+ return False
200
+ return True
201
+
202
+
203
+ def build_awaiter(app, db, loop):
204
+ """Await a coroutine inside a fresh app context, like a request would."""
205
+
206
+ def await_(coro):
207
+ async def _wrapped():
208
+ async with app.app_context():
209
+ g.db_session = db
210
+ return await coro
211
+
212
+ return loop.run_until_complete(_wrapped())
213
+
214
+ return await_
215
+
216
+
217
+ class AsyncConsole(code.InteractiveConsole):
218
+ """InteractiveConsole that awaits top-level coroutines on a shared loop."""
219
+
220
+ def __init__(self, namespace, await_):
221
+ super().__init__(namespace)
222
+ self.await_ = await_
223
+ self.compile.compiler.flags |= ast.PyCF_ALLOW_TOP_LEVEL_AWAIT
224
+
225
+ def runcode(self, code_obj):
226
+ try:
227
+ result = types.FunctionType(code_obj, self.locals)()
228
+ if inspect.iscoroutine(result):
229
+ self.await_(result)
230
+ except SystemExit:
231
+ raise
232
+ except BaseException:
233
+ self.showtraceback()
234
+
235
+
236
+ def run_shell(app, source: str | None = None) -> None:
237
+ """Start the REPL, or execute `source` and print its result."""
238
+ loop = asyncio.new_event_loop()
239
+ asyncio.set_event_loop(loop)
240
+ db = ext.async_session_factory()
241
+ namespace = build_namespace(app, db)
242
+
243
+ try:
244
+ if source is not None:
245
+ _run_source(build_awaiter(app, db, loop), namespace, source)
246
+ return
247
+
248
+ print_banner(sorted(discover_models()))
249
+ if _ptpython_usable():
250
+ loop.run_until_complete(_run_ptpython(app, db, namespace))
251
+ return
252
+ _setup_readline(namespace, history_file(app))
253
+ AsyncConsole(namespace, build_awaiter(app, db, loop)).interact(
254
+ banner="", exitmsg=""
255
+ )
256
+ finally:
257
+ loop.run_until_complete(db.close())
258
+ if ext.engine:
259
+ loop.run_until_complete(ext.engine.dispose())
260
+ loop.close()
261
+
262
+
263
+ def _run_source(await_, namespace, source: str) -> None:
264
+ """Execute one-shot source with top-level await; print the last expression."""
265
+ block = ast.parse(source, mode="exec")
266
+ last = (
267
+ block.body.pop()
268
+ if block.body and isinstance(block.body[-1], ast.Expr)
269
+ else None
270
+ )
271
+
272
+ def run(node, mode):
273
+ code_obj = compile(
274
+ node, "<stk shell>", mode, flags=ast.PyCF_ALLOW_TOP_LEVEL_AWAIT
275
+ )
276
+ result = types.FunctionType(code_obj, namespace)()
277
+ return await_(result) if inspect.iscoroutine(result) else result
278
+
279
+ if block.body:
280
+ run(block, "exec")
281
+ if last is not None:
282
+ result = run(ast.Expression(last.value), "eval")
283
+ if result is not None:
284
+ print(result)
@@ -0,0 +1,4 @@
1
+ vue 3.5.40
2
+ vuetify 4.1.6
3
+ axios 1.18.1
4
+ @tabler/icons-webfont 3.45.0