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.
- stk/__init__.py +19 -0
- stk/__main__.py +6 -0
- stk/agent_login.py +78 -0
- stk/app.py +323 -0
- stk/checks.py +634 -0
- stk/cli/__init__.py +49 -0
- stk/cli/agent.py +267 -0
- stk/cli/base.py +79 -0
- stk/cli/database.py +199 -0
- stk/cli/develop.py +63 -0
- stk/cli/doctor.py +262 -0
- stk/cli/main.py +101 -0
- stk/cli/reports.py +296 -0
- stk/cli/smoke.py +399 -0
- stk/cli/users.py +259 -0
- stk/cli/watch.py +68 -0
- stk/extensions.py +29 -0
- stk/migrations/__init__.py +130 -0
- stk/migrations/env.py +58 -0
- stk/migrations/script.py.mako +17 -0
- stk/migrations/versions/stk_0001_initial_schema.py +213 -0
- stk/portal/views.py +34 -0
- stk/public/__init__.py +0 -0
- stk/public/models.py +0 -0
- stk/public/views.py +344 -0
- stk/scaffold/__init__.py +1 -0
- stk/scaffold/app/CLAUDE.md +65 -0
- stk/scaffold/app/Dockerfile +41 -0
- stk/scaffold/app/README.md +16 -0
- stk/scaffold/app/__app__/__init__.py +1 -0
- stk/scaffold/app/__app__/app.py +8 -0
- stk/scaffold/app/__app__/settings.py +7 -0
- stk/scaffold/app/claude/skills/stk-api/SKILL.md +52 -0
- stk/scaffold/app/claude/skills/stk-blueprint/SKILL.md +48 -0
- stk/scaffold/app/claude/skills/stk-deploy/SKILL.md +61 -0
- stk/scaffold/app/claude/skills/stk-lessons/SKILL.md +58 -0
- stk/scaffold/app/claude/skills/stk-migrate/SKILL.md +96 -0
- stk/scaffold/app/docker-compose.yml +88 -0
- stk/scaffold/app/dockerignore +13 -0
- stk/scaffold/app/env-sample +78 -0
- stk/scaffold/app/gitignore +8 -0
- stk/scaffold/app/nginx/nginx.conf +46 -0
- stk/scaffold/app/nginx/stk.conf +36 -0
- stk/scaffold/app/postgres/init-db.sh +8 -0
- stk/scaffold/app/pyproject.toml +23 -0
- stk/scaffold/app/tests/__init__.py +0 -0
- stk/scaffold/app/tests/test_app.py +23 -0
- stk/scaffold/generator.py +209 -0
- stk/scaffold/templates.py +437 -0
- stk/security.py +145 -0
- stk/settings.py +147 -0
- stk/shell.py +284 -0
- stk/static/VERSIONS.txt +4 -0
- stk/static/css/app.css +229 -0
- stk/static/css/layout.css +330 -0
- stk/static/css/vuetify.min.css +6 -0
- stk/static/icons/fonts/tabler-icons.woff2 +0 -0
- stk/static/icons/tabler-icons.min.css +4 -0
- stk/static/img/auth-bg.webp +0 -0
- stk/static/img/favicon.ico +0 -0
- stk/static/img/stk.svg +1664 -0
- stk/static/js/axios.min.js +5 -0
- stk/static/js/components/NotificationDropdown.js +156 -0
- stk/static/js/components/ThemeSwitcher.js +68 -0
- stk/static/js/components/TransitionExpand.js +64 -0
- stk/static/js/components/VerticalNavGroup.js +119 -0
- stk/static/js/components/VerticalNavLink.js +36 -0
- stk/static/js/components/VerticalNavSectionTitle.js +21 -0
- stk/static/js/components/index.js +47 -0
- stk/static/js/config.js +150 -0
- stk/static/js/vue.min.js +13 -0
- stk/static/js/vuetify.min.js +3148 -0
- stk/static/robots.txt +1 -0
- stk/tasks/__init__.py +78 -0
- stk/templates/401.html +14 -0
- stk/templates/404.html +12 -0
- stk/templates/500.html +13 -0
- stk/templates/_brand.html +1 -0
- stk/templates/auth_layout.html +26 -0
- stk/templates/cms/activities.html +135 -0
- stk/templates/cms/roles.html +215 -0
- stk/templates/cms/users.html +312 -0
- stk/templates/dashboard.html +132 -0
- stk/templates/index.html +52 -0
- stk/templates/layout.html +328 -0
- stk/templates/login-layout.html +54 -0
- stk/templates/security/_macros.html +39 -0
- stk/templates/security/_messages.html +12 -0
- stk/templates/security/change_password.html +100 -0
- stk/templates/security/confirm_email.html +44 -0
- stk/templates/security/email/change_notice.html +4 -0
- stk/templates/security/email/change_notice.txt +5 -0
- stk/templates/security/email/confirmation_instructions.html +3 -0
- stk/templates/security/email/confirmation_instructions.txt +3 -0
- stk/templates/security/email/login_instructions.html +5 -0
- stk/templates/security/email/login_instructions.txt +5 -0
- stk/templates/security/email/reset_instructions.html +1 -0
- stk/templates/security/email/reset_instructions.txt +3 -0
- stk/templates/security/email/reset_notice.html +1 -0
- stk/templates/security/email/reset_notice.txt +1 -0
- stk/templates/security/email/welcome.html +7 -0
- stk/templates/security/email/welcome.txt +7 -0
- stk/templates/security/forgot_password.html +63 -0
- stk/templates/security/login_user.html +206 -0
- stk/templates/security/mf_recovery.html +67 -0
- stk/templates/security/mf_recovery_codes.html +119 -0
- stk/templates/security/register_user.html +263 -0
- stk/templates/security/reset_password.html +53 -0
- stk/templates/security/send_confirmation.html +48 -0
- stk/templates/security/send_login.html +39 -0
- stk/templates/security/two_factor_select.html +78 -0
- stk/templates/security/two_factor_setup.html +166 -0
- stk/templates/security/two_factor_verify_code.html +71 -0
- stk/templates/security/wan_register.html +179 -0
- stk/templates/security/wan_signin.html +123 -0
- stk/templates/security/wan_verify.html +109 -0
- stk/user/__init__.py +0 -0
- stk/user/forms.py +10 -0
- stk/user/models.py +308 -0
- stk/user/views.py +339 -0
- stk/utils/email.py +32 -0
- stk/utils/ratelimit.py +67 -0
- stk/websocket.py +133 -0
- stkit-17.0.0.dist-info/METADATA +135 -0
- stkit-17.0.0.dist-info/RECORD +128 -0
- stkit-17.0.0.dist-info/WHEEL +4 -0
- stkit-17.0.0.dist-info/entry_points.txt +2 -0
- 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)
|
stk/static/VERSIONS.txt
ADDED