csiapps 0.1.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.
csiapps/__init__.py ADDED
@@ -0,0 +1,54 @@
1
+ """csiapps: Python port of the CSIO ``csiapps`` R package.
2
+
3
+ Helper functions and utilities for CSI data warehouse ingestion and Shiny
4
+ (for Python) web applications.
5
+
6
+ The public API mirrors the R package's ``NAMESPACE`` and lands module by module
7
+ across the porting phases (see ``PORTING_PLAN.md``):
8
+
9
+ * ``config`` -- set_institute, is_sandbox_mode (phase 2)
10
+ * ``auth`` -- check_secrets, PKCE, token exchange (phase 2)
11
+ * ``client`` -- make_request, fetch_org_options/profiles (phase 3)
12
+ * ``sandbox`` -- register_sandbox_schema, create_*, ... (phase 4)
13
+ * ``app`` -- ui_wrapper, server_wrapper (phase 5)
14
+ """
15
+
16
+ from .app import server_wrapper, ui_wrapper
17
+ from .auth import check_secrets
18
+ from .client import (
19
+ fetch_org_options,
20
+ fetch_profile,
21
+ fetch_profiles,
22
+ flatten_profile,
23
+ make_request,
24
+ )
25
+ from .config import is_sandbox_mode, set_institute, set_sandbox_mode
26
+ from .sandbox import (
27
+ browse_sandbox,
28
+ clear_sandbox,
29
+ create_profile,
30
+ create_sport_org,
31
+ register_sandbox_schema,
32
+ )
33
+
34
+ __version__ = "0.1.0"
35
+
36
+ __all__ = [
37
+ "__version__",
38
+ "browse_sandbox",
39
+ "check_secrets",
40
+ "clear_sandbox",
41
+ "create_profile",
42
+ "create_sport_org",
43
+ "fetch_org_options",
44
+ "fetch_profile",
45
+ "fetch_profiles",
46
+ "flatten_profile",
47
+ "is_sandbox_mode",
48
+ "make_request",
49
+ "register_sandbox_schema",
50
+ "server_wrapper",
51
+ "set_institute",
52
+ "set_sandbox_mode",
53
+ "ui_wrapper",
54
+ ]
csiapps/app.py ADDED
@@ -0,0 +1,375 @@
1
+ """Shiny for Python wrappers for CSIAPPS apps.
2
+
3
+ Port of ``R/shiny.R``: ``ui_wrapper`` (consistent navbar/footer chrome + auth
4
+ status + sandbox banner) and ``server_wrapper`` (OAuth2 PKCE login, or a
5
+ simulated login in sandbox mode).
6
+
7
+ Framework mapping notes (R Shiny -> Shiny for Python):
8
+
9
+ * ``reactiveVal`` -> ``reactive.value``; ``observe`` -> ``@reactive.effect``;
10
+ ``observeEvent`` -> ``@reactive.effect`` + ``@reactive.event``;
11
+ ``renderUI``/``uiOutput`` -> ``@render.ui``/``ui.output_ui``.
12
+ * ``shinyjs::runjs`` has no Python port; the two `window.location` nudges use a
13
+ custom-message handler (``csip_reset``) injected in the head, same mechanism as
14
+ the redirect (``csip_redirect``). ``useShinyjs()`` is dropped.
15
+ * ``session$userData$csiapps_token`` -> :func:`csiapps.client.set_session_token`
16
+ (keyed on the session), read back by :func:`csiapps.client.current_token`.
17
+ * ``session$sendCustomMessage`` is a coroutine here, so effects that send
18
+ messages are ``async``.
19
+ """
20
+
21
+ import asyncio
22
+ import os
23
+ from collections.abc import Callable
24
+ from urllib.parse import parse_qs, urlencode
25
+
26
+ import httpx
27
+ from shiny import reactive, render, ui
28
+ from shiny.types import TagChild
29
+ from shiny.ui import Tag
30
+
31
+ from . import auth, client, config
32
+
33
+ _FAVICON = "https://csiontario.ca/wp-content/uploads/2022/04/cropped-CSIO-Favicon-192x192.png"
34
+
35
+ _HANDLERS_JS = """
36
+ Shiny.addCustomMessageHandler('csip_redirect', function(url) {
37
+ if (url && typeof url === 'string') { window.top.location.href = url; }
38
+ });
39
+ Shiny.addCustomMessageHandler('csip_reset', function(x) {
40
+ window.location.href = window.location.pathname;
41
+ });
42
+ """
43
+
44
+
45
+ # ---- chrome (navbar / footer / styles) ---------------------------------
46
+
47
+
48
+ def _csi_chrome_styles():
49
+ # Neutral frame: white bar, CSI-red accent line, soft shadow. Scoped by id +
50
+ # !important so a wrapped app's theme/CSS cannot override it.
51
+ accent = "#d81f26"
52
+ bar_bg = "#ffffff"
53
+ bar_text = "#1f2937"
54
+ css = f"""
55
+ #csi-navbar {{
56
+ background-color: {bar_bg} !important;
57
+ border-bottom: 3px solid {accent} !important;
58
+ box-shadow: 0 2px 4px rgba(0,0,0,.06), 0 1px 2px rgba(0,0,0,.04);
59
+ position: sticky;
60
+ top: 0;
61
+ z-index: 1030;
62
+ }}
63
+ #csi-navbar .navbar-brand,
64
+ #csi-navbar .navbar-brand:hover,
65
+ #csi-navbar .navbar-nav .nav-link {{
66
+ color: {bar_text} !important;
67
+ }}
68
+ #footer {{
69
+ background-color: {bar_bg} !important;
70
+ color: {bar_text} !important;
71
+ border-top: 1px solid #e6e6e6 !important;
72
+ z-index: 1030;
73
+ }}
74
+ #footer p, #footer a {{ color: {bar_text} !important; }}
75
+ """
76
+ return ui.tags.style(ui.HTML(css))
77
+
78
+
79
+ def _logo_src():
80
+ if config.get_institute() == "csipacific":
81
+ return "https://www.csipacific.ca/wp-content/uploads/2024/05/csi-pacific-logo-main.png"
82
+ return "https://csiontario.ca/wp-content/uploads/2022/03/logo-csi-ontario.png"
83
+
84
+
85
+ def _navbar_ui():
86
+ return ui.tags.nav(
87
+ ui.tags.div(
88
+ ui.tags.a(
89
+ ui.tags.img(src=_logo_src(), height="48px", style="margin-right: 8px;"),
90
+ class_="navbar-brand d-flex align-items-center",
91
+ href="#",
92
+ ),
93
+ class_="container-fluid",
94
+ ),
95
+ id="csi-navbar",
96
+ class_="navbar navbar-expand-lg navbar-light bg-white px-3",
97
+ )
98
+
99
+
100
+ def _footer_ui():
101
+ from datetime import date
102
+
103
+ name = "CSI Pacific" if config.get_institute() == "csipacific" else "CSI Ontario"
104
+ return ui.tags.footer(
105
+ ui.tags.div(
106
+ ui.tags.p(
107
+ ui.HTML(f"© {date.today().year} {name}"),
108
+ class_="col-md-4 mb-0",
109
+ ),
110
+ ui.tags.ul(class_="nav col-md-4 justify-content-end"),
111
+ class_="d-flex flex-wrap justify-content-between align-items-center py-3 container",
112
+ ),
113
+ id="footer",
114
+ class_="mt-4 bg-dark text-white border-top border-light fixed-bottom",
115
+ )
116
+
117
+
118
+ def _sandbox_banner():
119
+ return ui.tags.div(
120
+ ui.HTML("Sandbox mode — not connected to the live warehouse"),
121
+ class_="text-center border-bottom",
122
+ style="background:#faf6ec;color:#8a6d3b;font-size:12px;padding:3px 0;letter-spacing:.02em;",
123
+ )
124
+
125
+
126
+ def ui_wrapper(*args: TagChild, sandbox: bool | None = None) -> Tag:
127
+ """Wrap an app's UI in the standard CSI chrome.
128
+
129
+ Adds the CSI navbar, footer, an auth-status line, the favicon and
130
+ redirect/reset message handlers, and — in sandbox mode — a banner making it
131
+ obvious the app is not connected to the live warehouse. Use it in place of
132
+ ``ui.page_fluid`` at the top of an app's UI definition; pair it with
133
+ [`server_wrapper`][csiapps.app.server_wrapper] on the server side.
134
+
135
+ Args:
136
+ *args: The app's own UI elements (Shiny tags / components), rendered
137
+ below the auth-status line inside the chrome.
138
+ sandbox: Force the sandbox banner on (``True``) or off (``False``).
139
+ ``None`` (the default) resolves via
140
+ [`is_sandbox_mode`][csiapps.config.is_sandbox_mode].
141
+
142
+ Returns:
143
+ A ``ui.page_fluid`` page containing the chrome and the supplied UI.
144
+
145
+ Example:
146
+ ```python
147
+ from shiny import ui
148
+ import csiapps
149
+
150
+ app_ui = csiapps.ui_wrapper(
151
+ ui.h2("My app"),
152
+ ui.input_action_button("logout", "Log out"),
153
+ )
154
+ ```
155
+
156
+ Note:
157
+ The chrome styles are scoped by id and marked ``!important`` so a wrapped
158
+ app's own theme cannot override the navbar and footer. Include an
159
+ ``input_action_button("logout", ...)`` for the logout effect wired up by
160
+ [`server_wrapper`][csiapps.app.server_wrapper].
161
+ """
162
+ if sandbox is None:
163
+ sandbox = config.is_sandbox_mode()
164
+
165
+ children = [
166
+ ui.head_content(
167
+ ui.tags.script(ui.HTML(_HANDLERS_JS)),
168
+ ui.tags.link(rel="shortcut icon", href=_FAVICON),
169
+ ),
170
+ _csi_chrome_styles(),
171
+ _navbar_ui(),
172
+ ]
173
+ if sandbox:
174
+ children.append(_sandbox_banner())
175
+ # padding-bottom leaves room for the fixed-bottom footer so it never overlaps
176
+ # app content on short pages (mirrors the R wrapper's fluidPage style).
177
+ children.append(
178
+ ui.div(
179
+ ui.output_ui("auth_status"),
180
+ *args,
181
+ style="padding-bottom: 80px;",
182
+ )
183
+ )
184
+ children.append(_footer_ui())
185
+ return ui.page_fluid(*children)
186
+
187
+
188
+ # ---- server ------------------------------------------------------------
189
+
190
+
191
+ def _seed_token_value():
192
+ """The token value the sandbox seeds the session with (pure, so it is testable).
193
+
194
+ Mirrors R's .sandbox_seed_session(): the developer's existing access token is
195
+ adopted as the "granted" token; with none set, a sentinel marks the session
196
+ unauthenticated (the shared consumer then short-circuits before any network
197
+ call).
198
+ """
199
+ tok = os.environ.get("CSIAPPS_ACCESS_TOKEN", "")
200
+ if tok:
201
+ return {"access_token": tok, "sandbox": True}
202
+ return {"sandbox": True, "unauthenticated": True}
203
+
204
+
205
+ def _signed_in_text(userinfo, sandbox):
206
+ if userinfo and userinfo.get("first_name") and userinfo.get("last_name"):
207
+ text = f"Signed in as {userinfo['first_name']} {userinfo['last_name']}"
208
+ else:
209
+ text = "Signed in"
210
+ if sandbox:
211
+ text += " (sandbox)"
212
+ return text
213
+
214
+
215
+ def server_wrapper(
216
+ app_specific_logic: Callable, sandbox: bool | None = None
217
+ ) -> Callable:
218
+ """Wrap an app's server function with CSIAPPS authentication.
219
+
220
+ Returns a Shiny server function that handles login before delegating to your
221
+ own server logic. In production it runs the OAuth2 PKCE flow (redirect to
222
+ CSIAPPS, exchange the returned code for a token, load ``/me`` for the header).
223
+ In sandbox mode it simulates that login using ``CSIAPPS_ACCESS_TOKEN`` if
224
+ set, or marks the session unauthenticated otherwise. Either way the
225
+ per-session token is stored so [`make_request`][csiapps.client.make_request]
226
+ and the ``fetch_*`` helpers pick it up automatically.
227
+
228
+ Args:
229
+ app_specific_logic: Your app's server function with the usual Shiny
230
+ ``(input, output, session)`` signature. It is called after auth is
231
+ wired up, keeping its own lexical scope.
232
+ sandbox: Force sandbox (``True``) or production (``False``) auth
233
+ behaviour. ``None`` (the default) resolves via
234
+ [`is_sandbox_mode`][csiapps.config.is_sandbox_mode].
235
+
236
+ Returns:
237
+ Callable: A server function to hand to Shiny's ``App(app_ui, server)``.
238
+
239
+ Example:
240
+ ```python
241
+ from shiny import App
242
+ import csiapps
243
+
244
+ def my_server(input, output, session):
245
+ ...
246
+
247
+ app = App(app_ui, csiapps.server_wrapper(my_server))
248
+ ```
249
+
250
+ Note:
251
+ The wrapper registers a logout effect bound to an ``input.logout``
252
+ action button — include one in the UI (see
253
+ [`ui_wrapper`][csiapps.app.ui_wrapper]). Blocking token and ``/me`` calls
254
+ run off the event loop so a slow endpoint cannot stall other sessions.
255
+ """
256
+ if sandbox is None:
257
+ sandbox = config.is_sandbox_mode()
258
+
259
+ def server(input, output, session):
260
+ user_token = reactive.value(None)
261
+ userinfo = reactive.value(None)
262
+
263
+ if sandbox:
264
+ # Simulate the redirect: seed the token from the environment and hand
265
+ # it to the same consumer a production login would.
266
+ user_token.set(_seed_token_value())
267
+ else:
268
+
269
+ @reactive.effect
270
+ async def _oauth():
271
+ qs = parse_qs(session.clientdata.url_search().lstrip("?"))
272
+ code = qs.get("code", [None])[0]
273
+ state = qs.get("state", [None])[0]
274
+ err = qs.get("error", [None])[0]
275
+
276
+ if err:
277
+ user_token.set(
278
+ {"error": err, "error_description": qs.get("error_description", [None])[0]}
279
+ )
280
+ client.set_session_token(session, None)
281
+ await session.send_custom_message("csip_reset", {})
282
+
283
+ # 1) no code + no token -> redirect to CSI
284
+ if code is None and user_token() is None:
285
+ pk = auth.generate_pkce()
286
+ st = auth.pkce_state_encode(pk["verifier"])
287
+ params = {
288
+ "response_type": "code",
289
+ "client_id": os.environ.get("CSIAPPS_CLIENT_ID", ""),
290
+ "redirect_uri": os.environ.get("CSIAPPS_REDIRECT_URI", ""),
291
+ "scope": os.environ.get("CSIAPPS_SCOPE", "read write"),
292
+ "code_challenge": pk["challenge"],
293
+ "code_challenge_method": pk["method"],
294
+ "state": st,
295
+ }
296
+ await session.send_custom_message(
297
+ "csip_redirect", config.auth_url() + "?" + urlencode(params)
298
+ )
299
+ return
300
+
301
+ # 2) have code but no token yet -> exchange
302
+ if code is not None and user_token() is None:
303
+ verifier = auth.pkce_state_decode(state).get("v") if state else None
304
+ # exchange_code_for_token does a *blocking* httpx.post; run it
305
+ # off the event loop so a slow token endpoint can't stall every
306
+ # other session (a Python-only concern -- R has no shared loop).
307
+ tok = await asyncio.to_thread(
308
+ auth.exchange_code_for_token, code, verifier
309
+ )
310
+ user_token.set(tok)
311
+
312
+ # Shared consumer (production + sandbox): store the token per-session and
313
+ # load /me for the header.
314
+ @reactive.effect
315
+ @reactive.event(user_token)
316
+ async def _consume():
317
+ tok = user_token()
318
+ client.set_session_token(session, None)
319
+
320
+ if tok is None or tok.get("error"):
321
+ await session.send_custom_message("csip_reset", {})
322
+ return
323
+
324
+ access_token = tok.get("access_token")
325
+ if not access_token:
326
+ return
327
+
328
+ client.set_session_token(session, access_token)
329
+
330
+ userinfo_url = config.userinfo_url()
331
+ if userinfo_url:
332
+ try:
333
+ # Blocking httpx.get -> run off the event loop so a slow /me
334
+ # endpoint can't stall other sessions (Python-only concern).
335
+ resp = await asyncio.to_thread(
336
+ httpx.get,
337
+ userinfo_url,
338
+ headers={"Authorization": f"Bearer {access_token}"},
339
+ )
340
+ resp.raise_for_status()
341
+ userinfo.set(resp.json())
342
+ except Exception as e: # a stale/expired token degrades gracefully
343
+ ui.notification_show(f"Error loading user info: {e}", type="error")
344
+
345
+ @render.ui
346
+ def auth_status():
347
+ tok = user_token()
348
+ if tok is None:
349
+ return ui.tags.p("Redirecting to CSIAPPS for authentication...")
350
+ if tok.get("error"):
351
+ return ui.tags.p("Authentication error (see logs).")
352
+ if tok.get("unauthenticated"):
353
+ return ui.tags.p(
354
+ ui.HTML(
355
+ "Not authenticated — set CSIAPPS_ACCESS_TOKEN "
356
+ "to emulate login in sandbox mode."
357
+ )
358
+ )
359
+ return ui.TagList(ui.tags.br(), ui.tags.p(_signed_in_text(userinfo(), sandbox)))
360
+
361
+ @reactive.effect
362
+ @reactive.event(input.logout, ignore_none=True)
363
+ async def _logout():
364
+ userinfo.set(None)
365
+ client.set_session_token(session, None)
366
+ if sandbox:
367
+ user_token.set(_seed_token_value())
368
+ else:
369
+ user_token.set(None)
370
+ await session.send_custom_message("csip_reset", {})
371
+
372
+ # Call the app's own server function so it keeps its lexical scope.
373
+ app_specific_logic(input, output, session)
374
+
375
+ return server
csiapps/auth.py ADDED
@@ -0,0 +1,194 @@
1
+ """OAuth2 PKCE helpers, token exchange, and secret checks.
2
+
3
+ Ports the PKCE / token-exchange section of ``R/utils.R`` and ``check_secrets()``.
4
+ The R package leaned on ``httr2``/``openssl`` for these; in Python the PKCE
5
+ pieces are pure stdlib (``secrets`` + ``hashlib`` + ``base64``) and only the
6
+ token POST needs ``httpx``.
7
+ """
8
+
9
+ import base64
10
+ import hashlib
11
+ import json
12
+ import os
13
+ import re
14
+ import secrets
15
+
16
+ import httpx
17
+
18
+ from . import config
19
+ from .config import _message
20
+
21
+ # ---- PKCE base64url ----
22
+
23
+
24
+ def _b64url(raw: bytes) -> str:
25
+ """base64url-encode bytes with padding stripped (matches R's pkce_base64url)."""
26
+ return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
27
+
28
+
29
+ def _b64url_decode(s: str) -> bytes:
30
+ pad = -len(s) % 4
31
+ return base64.urlsafe_b64decode(s + "=" * pad)
32
+
33
+
34
+ def generate_pkce() -> dict:
35
+ """Generate a PKCE verifier/challenge pair.
36
+
37
+ Replaces ``httr2::oauth_flow_auth_code_pkce()``, which R's ``server_wrapper``
38
+ called inline. Returns ``{"verifier", "challenge", "method"}``.
39
+ """
40
+ verifier = _b64url(secrets.token_bytes(32))
41
+ challenge = _b64url(hashlib.sha256(verifier.encode("ascii")).digest())
42
+ return {"verifier": verifier, "challenge": challenge, "method": "S256"}
43
+
44
+
45
+ def pkce_state_encode(verifier: str) -> str:
46
+ """Encode a PKCE verifier into the opaque ``state`` string for the auth request."""
47
+ payload = json.dumps(
48
+ {"v": verifier, "r": secrets.randbelow(10**9) + 1},
49
+ separators=(",", ":"),
50
+ )
51
+ return _b64url(payload.encode("utf-8"))
52
+
53
+
54
+ def pkce_state_decode(state: str) -> dict:
55
+ """Reverse :func:`pkce_state_encode`; returns the decoded ``{"v", "r"}`` dict."""
56
+ return json.loads(_b64url_decode(state).decode("utf-8"))
57
+
58
+
59
+ # ---- Token exchange ----
60
+
61
+
62
+ def exchange_code_for_token(code: str, code_verifier: str | None = None) -> dict:
63
+ """Exchange an authorization code for tokens at the CSIAPPS token endpoint.
64
+
65
+ HTTP error statuses do **not** raise (they are returned as an ``error`` dict,
66
+ mirroring the R ``req_error(is_error = FALSE)`` behaviour); transport-level
67
+ failures propagate as ``httpx`` exceptions, as they did in R.
68
+ """
69
+ form = {
70
+ "grant_type": "authorization_code",
71
+ "code": code,
72
+ "redirect_uri": os.environ.get("CSIAPPS_REDIRECT_URI", ""),
73
+ "code_verifier": code_verifier,
74
+ }
75
+ # Drop unset fields (httr2's req_body_form omits NULLs).
76
+ form = {k: v for k, v in form.items() if v is not None}
77
+
78
+ resp = httpx.post(
79
+ config.token_url(),
80
+ auth=(
81
+ os.environ.get("CSIAPPS_CLIENT_ID", ""),
82
+ os.environ.get("CSIAPPS_CLIENT_SECRET", ""),
83
+ ),
84
+ data=form,
85
+ )
86
+
87
+ try:
88
+ body = resp.json()
89
+ except ValueError:
90
+ body = {"raw": resp.text}
91
+
92
+ if 200 <= resp.status_code < 300:
93
+ return body
94
+ return {
95
+ "error": "token_exchange_http_error",
96
+ "status": resp.status_code,
97
+ "payload": body,
98
+ }
99
+
100
+
101
+ # ---- Secret / env checks ----
102
+
103
+
104
+ def check_secrets(verbose: bool = False, sandbox: bool | None = None) -> bool:
105
+ """Validate that the environment is configured for authentication.
106
+
107
+ Call this once at app startup to fail fast on a misconfigured deployment.
108
+ Behaviour depends on the mode:
109
+
110
+ - **Sandbox mode:** OAuth secret checks are skipped entirely (the sandbox
111
+ simulates login and needs no client credentials). Instead the presence or
112
+ absence of ``CSIAPPS_ACCESS_TOKEN`` is reported to stderr, and the function
113
+ never raises.
114
+ - **Production mode:** the OAuth URLs derived from the configured institute
115
+ plus ``CSIAPPS_REDIRECT_URI`` are checked for a well-formed ``http(s)``
116
+ scheme, and a :class:`ValueError` is raised listing any that are missing or
117
+ malformed.
118
+
119
+ Args:
120
+ verbose: If ``True``, dump the resolved CSIAPPS environment (client id,
121
+ auth/token/userinfo URLs, redirect URI, scope, and whether a client
122
+ secret is set) to stderr. The client secret value itself is never
123
+ printed — only whether one is present. Defaults to ``False``.
124
+ sandbox: Force the mode for this check. ``True`` or ``False`` overrides
125
+ detection; ``None`` (the default) resolves via
126
+ [`is_sandbox_mode`][csiapps.config.is_sandbox_mode].
127
+
128
+ Returns:
129
+ bool: Always ``True`` when the environment is usable. In production the
130
+ function raises rather than returning ``False``, so a ``True`` return is
131
+ a positive assurance the required URLs are present.
132
+
133
+ Raises:
134
+ ValueError: In production mode only, if any of ``CSIAPPS_AUTH_URL``,
135
+ ``CSIAPPS_TOKEN_URL``, or ``CSIAPPS_REDIRECT_URI`` is missing or does
136
+ not start with ``http://`` or ``https://``.
137
+
138
+ Example:
139
+ ```python
140
+ import csiapps
141
+
142
+ # In sandbox mode: prints token status and returns True.
143
+ csiapps.check_secrets(verbose=True)
144
+ ```
145
+
146
+ Note:
147
+ The auth and token URLs are derived from the institute set via
148
+ [`set_institute`][csiapps.config.set_institute], not read directly from
149
+ the environment, so set the institute before calling this.
150
+ """
151
+ if sandbox is None:
152
+ sandbox = config.is_sandbox_mode()
153
+
154
+ if sandbox:
155
+ if os.environ.get("CSIAPPS_ACCESS_TOKEN", ""):
156
+ _message(
157
+ "csiapps sandbox: CSIAPPS_ACCESS_TOKEN found - real registration reads enabled"
158
+ )
159
+ else:
160
+ _message(
161
+ "csiapps sandbox: no CSIAPPS_ACCESS_TOKEN set - running unauthenticated "
162
+ "(set a token to emulate login and load /me)"
163
+ )
164
+ return True
165
+
166
+ url_re = re.compile(r"^https?://")
167
+ bad = []
168
+ if not url_re.match(config.auth_url()):
169
+ bad.append("CSIAPPS_AUTH_URL")
170
+ if not url_re.match(config.token_url()):
171
+ bad.append("CSIAPPS_TOKEN_URL")
172
+ if not url_re.match(os.environ.get("CSIAPPS_REDIRECT_URI", "")):
173
+ bad.append("CSIAPPS_REDIRECT_URI")
174
+ if bad:
175
+ raise ValueError("Invalid or missing URL env vars: " + ", ".join(bad))
176
+
177
+ if verbose:
178
+ _message(
179
+ f"AUTH_URL: '{config.auth_url()}' "
180
+ f"REDIRECT_URI: '{os.environ.get('CSIAPPS_REDIRECT_URI', '')}'"
181
+ )
182
+ env_dump = {
183
+ "CSIAPPS_CLIENT_ID": os.environ.get("CSIAPPS_CLIENT_ID", ""),
184
+ "CSIAPPS_CLIENT_SECRET_SET": bool(os.environ.get("CSIAPPS_CLIENT_SECRET", "")),
185
+ "CSIAPPS_AUTH_URL": config.auth_url(),
186
+ "CSIAPPS_TOKEN_URL": config.token_url(),
187
+ "CSIAPPS_REDIRECT_URI": os.environ.get("CSIAPPS_REDIRECT_URI", ""),
188
+ "CSIAPPS_SCOPE": os.environ.get("CSIAPPS_SCOPE", "read write"),
189
+ "CSIAPPS_USERINFO_URL": config.userinfo_url(),
190
+ }
191
+ _message("CSIAPPS environment on startup:")
192
+ _message(json.dumps(env_dump, indent=2))
193
+
194
+ return True