hugpy-server 0.2.0a0__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 (96) hide show
  1. hugpy_server/__init__.py +27 -0
  2. hugpy_server/app/__init__.py +8 -0
  3. hugpy_server/app/auth_common.py +53 -0
  4. hugpy_server/app/endpoints_explorer.py +420 -0
  5. hugpy_server/app/endpoints_view.py +75 -0
  6. hugpy_server/app/functions/__init__.py +0 -0
  7. hugpy_server/app/functions/chat/__init__.py +0 -0
  8. hugpy_server/app/functions/chat/streaming.py +385 -0
  9. hugpy_server/app/functions/chat/task_selection.py +5 -0
  10. hugpy_server/app/functions/imports/__init__.py +0 -0
  11. hugpy_server/app/functions/imports/options/__init__.py +0 -0
  12. hugpy_server/app/functions/imports/options/install.py +77 -0
  13. hugpy_server/app/functions/imports/options/search.py +25 -0
  14. hugpy_server/app/functions/imports/utils/__init__.py +0 -0
  15. hugpy_server/app/functions/imports/utils/api_keys.py +318 -0
  16. hugpy_server/app/functions/imports/utils/constants.py +34 -0
  17. hugpy_server/app/functions/imports/utils/discord_bindings.py +697 -0
  18. hugpy_server/app/functions/imports/utils/install_links.py +402 -0
  19. hugpy_server/app/functions/imports/utils/key_store.py +26 -0
  20. hugpy_server/app/functions/imports/utils/video_share_keys.py +163 -0
  21. hugpy_server/app/help_agent.py +800 -0
  22. hugpy_server/app/keeper_line.py +105 -0
  23. hugpy_server/app/member_auth.py +149 -0
  24. hugpy_server/app/operator_auth.py +730 -0
  25. hugpy_server/app/routes/__init__.py +30 -0
  26. hugpy_server/app/routes/agent_routes.py +1655 -0
  27. hugpy_server/app/routes/auth_proxy_routes.py +156 -0
  28. hugpy_server/app/routes/chat_routes.py +11 -0
  29. hugpy_server/app/routes/comms_routes.py +405 -0
  30. hugpy_server/app/routes/discord_routes.py +944 -0
  31. hugpy_server/app/routes/eviction_routes.py +259 -0
  32. hugpy_server/app/routes/fleet_doctrine_routes.py +192 -0
  33. hugpy_server/app/routes/fleet_routes.py +109 -0
  34. hugpy_server/app/routes/group_routes.py +53 -0
  35. hugpy_server/app/routes/help_routes.py +232 -0
  36. hugpy_server/app/routes/installer_assets/__init__.py +0 -0
  37. hugpy_server/app/routes/installer_assets/console-install-link.sh +80 -0
  38. hugpy_server/app/routes/installer_assets/console-install.sh +241 -0
  39. hugpy_server/app/routes/installer_assets/generate_icons.py +66 -0
  40. hugpy_server/app/routes/installer_assets/hugpy-icon.ico +0 -0
  41. hugpy_server/app/routes/installer_assets/hugpy-icon.png +0 -0
  42. hugpy_server/app/routes/installer_assets/station-fix.sh +249 -0
  43. hugpy_server/app/routes/interim_routes.py +148 -0
  44. hugpy_server/app/routes/keeper_help_routes.py +279 -0
  45. hugpy_server/app/routes/llm_storage_routes.py +921 -0
  46. hugpy_server/app/routes/messages_helpers.py +516 -0
  47. hugpy_server/app/routes/messages_routes.py +393 -0
  48. hugpy_server/app/routes/metrics_routes.py +767 -0
  49. hugpy_server/app/routes/ml_routes.py +348 -0
  50. hugpy_server/app/routes/model_group_routes.py +527 -0
  51. hugpy_server/app/routes/model_status_routes.py +1386 -0
  52. hugpy_server/app/routes/oracle_routes.py +697 -0
  53. hugpy_server/app/routes/phone_brick_routes.py +345 -0
  54. hugpy_server/app/routes/prompt_routes.py +120 -0
  55. hugpy_server/app/routes/pypi_routes.py +204 -0
  56. hugpy_server/app/routes/review_routes.py +1202 -0
  57. hugpy_server/app/routes/script_first_routes.py +338 -0
  58. hugpy_server/app/routes/search_routes.py +657 -0
  59. hugpy_server/app/routes/upload_routes.py +297 -0
  60. hugpy_server/app/routes/v1_helpers.py +498 -0
  61. hugpy_server/app/routes/v1_routes.py +727 -0
  62. hugpy_server/app/routes/video_assist_media.py +325 -0
  63. hugpy_server/app/routes/video_coordination.py +187 -0
  64. hugpy_server/app/routes/video_routes.py +6410 -0
  65. hugpy_server/app/routes/welcome_routes.py +129 -0
  66. hugpy_server/app/routes/worker_routes.py +6167 -0
  67. hugpy_server/app/transfer_ledger.py +260 -0
  68. hugpy_server/app/video_auth.py +189 -0
  69. hugpy_server/console_dist/SOURCE_HASH.json +5 -0
  70. hugpy_server/console_dist/assets/hugpy-mark.005dbad6b15491649f17.png +0 -0
  71. hugpy_server/console_dist/assets/hugpy.75b0380a164d2f2c08e9.png +0 -0
  72. hugpy_server/console_dist/assets/main.b72ac2a9e85d6bc0cbf4.js +2 -0
  73. hugpy_server/console_dist/assets/main.b72ac2a9e85d6bc0cbf4.js.LICENSE.txt +39 -0
  74. hugpy_server/console_dist/fleet/assets/index--EAYlp9c.css +1 -0
  75. hugpy_server/console_dist/fleet/assets/index-BEA6Z6Eq.js +66 -0
  76. hugpy_server/console_dist/fleet/index.html +71 -0
  77. hugpy_server/console_dist/hugpy-favicon-hex.svg +60 -0
  78. hugpy_server/console_dist/index.html +53 -0
  79. hugpy_server/console_dist/media/assets/hugpy-mark-BXktFcOQ.png +0 -0
  80. hugpy_server/console_dist/media/assets/index-Bfe0XM6M.css +1 -0
  81. hugpy_server/console_dist/media/assets/index-DPfu7_M1.js +262 -0
  82. hugpy_server/console_dist/media/index.html +102 -0
  83. hugpy_server/console_dist/video/assets/hugpy-mark-BXktFcOQ.png +0 -0
  84. hugpy_server/console_dist/video/assets/index-1i-4svLp.css +1 -0
  85. hugpy_server/console_dist/video/assets/index-CHczeEma.js +4190 -0
  86. hugpy_server/console_dist/video/index.html +71 -0
  87. hugpy_server/py.typed +0 -0
  88. hugpy_server/state.py +33 -0
  89. hugpy_server/wiring.py +222 -0
  90. hugpy_server/wsgi_app.py +679 -0
  91. hugpy_server-0.2.0a0.dist-info/METADATA +79 -0
  92. hugpy_server-0.2.0a0.dist-info/RECORD +96 -0
  93. hugpy_server-0.2.0a0.dist-info/WHEEL +5 -0
  94. hugpy_server-0.2.0a0.dist-info/entry_points.txt +2 -0
  95. hugpy_server-0.2.0a0.dist-info/licenses/LICENSE +41 -0
  96. hugpy_server-0.2.0a0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,27 @@
1
+ """hugpy-server: the Flask composition root of the Hugpy ecosystem.
2
+
3
+ Public surface (all lazy — importing this package imports no Flask app):
4
+
5
+ * ``create_app`` / ``get_hugpy_flask`` — the app factory (``hugpy_server.wsgi_app``)
6
+ * ``install_all`` — the composition wiring (``hugpy_server.wiring``)
7
+ * ``main`` — the ``hugpy-serve`` entry point
8
+ """
9
+ from __future__ import annotations
10
+
11
+ try: # the installed distribution's version: the workspace tag/commit, never a literal
12
+ from importlib.metadata import version as _dist_version
13
+ __version__ = _dist_version("hugpy-server")
14
+ except Exception: # noqa: BLE001 — source tree without metadata
15
+ __version__ = "0.0.0+unknown"
16
+
17
+ __all__ = ["__version__", "create_app", "get_hugpy_flask", "install_all", "main"]
18
+
19
+
20
+ def __getattr__(name: str):
21
+ if name in ("create_app", "get_hugpy_flask", "main"):
22
+ from hugpy_server import wsgi_app
23
+ return getattr(wsgi_app, name)
24
+ if name == "install_all":
25
+ from hugpy_server.wiring import install_all
26
+ return install_all
27
+ raise AttributeError(f"module 'hugpy_server' has no attribute {name!r}")
@@ -0,0 +1,8 @@
1
+ from hugpy_server.app.routes.chat_routes import chat_bp
2
+ from hugpy_server.app.routes.search_routes import search_bp
3
+ from hugpy_server.app.routes.upload_routes import upload_bp
4
+ from hugpy_server.app.routes.worker_routes import worker_bp
5
+ from hugpy_server.app.routes.prompt_routes import prompt_bp
6
+ from hugpy_server.app.routes.phone_brick_routes import phone_brick_bp
7
+ from hugpy_server.app.routes.discord_routes import discord_bp
8
+
@@ -0,0 +1,53 @@
1
+ """Shared request-shape and strict member checks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from flask import abort, request
6
+
7
+
8
+ def caller_username() -> str | None:
9
+ """Resolve the session username, or None for unavailable auth."""
10
+ try:
11
+ from hugpy_server.app.operator_auth import principal_username
12
+ return principal_username()
13
+ except Exception: # noqa: BLE001 — an unavailable gate has no principal
14
+ return None
15
+
16
+
17
+ def client_ip() -> str:
18
+ forwarded = request.headers.get("X-Forwarded-For", "")
19
+ if forwarded:
20
+ return forwarded.split(",")[0].strip()
21
+ return request.remote_addr or ""
22
+
23
+
24
+ def normalized_path() -> str:
25
+ path = request.path or "/"
26
+ if path == "/api" or path.startswith("/api/"):
27
+ path = path[len("/api"):] or "/"
28
+ return path
29
+
30
+
31
+ def bearer_token() -> str | None:
32
+ """Bearer header, or the query API key used by CLI clients."""
33
+ auth = request.headers.get("Authorization", "")
34
+ if auth.lower().startswith("bearer "):
35
+ return auth[7:].strip()
36
+ return request.args.get("api_key")
37
+
38
+
39
+ def is_shell_request() -> bool:
40
+ """Whether this request navigates to the SPA shell."""
41
+ if request.endpoint == "_hugpy_ui":
42
+ return True
43
+ return request.headers.get("Sec-Fetch-Dest") == "document"
44
+
45
+
46
+ def require_member_strict() -> None:
47
+ """Require a member or operator without an open-mode waiver."""
48
+ try:
49
+ from hugpy_server.app.operator_auth import member_authenticated
50
+ except Exception:
51
+ abort(401, description="Authentication required for this route.")
52
+ if not member_authenticated():
53
+ abort(401, description="Authentication required for this route.")
@@ -0,0 +1,420 @@
1
+ """endpoints_explorer — a drop-in interactive API explorer for any Flask /
2
+ ``abstract_flask`` app. Framework-only (Flask + stdlib), no app-specific imports.
3
+
4
+ This is the reusable generalization of hugpy's ``/endpoints`` page — the intended
5
+ *upgrade to* ``abstract_flask.generator``. Where the generator turns Python functions
6
+ into routes (and wires ``offer_help`` so each supports ``?help``), this turns an app's
7
+ ``url_map`` into a browsable, searchable, **try-it** console:
8
+
9
+ from hugpy_server.app.endpoints_explorer import install_endpoints_explorer
10
+ install_endpoints_explorer(app) # one call, zero config
11
+
12
+ One call gives you, at ``/endpoints``:
13
+ * curl / ``Accept: application/json`` / ``?format=json`` -> the faithful
14
+ ``[{endpoint,url,methods}]`` JSON (same shape ``abstract_flask``'s inspector
15
+ already serves — programmatic clients are unaffected).
16
+ * a browser -> a rendered page: search, group-by-prefix, and an inline **try-it**
17
+ form per endpoint (path params from the rule, query, JSON body, method, Send ->
18
+ live same-origin response, and a ``params (?help)`` button that surfaces the
19
+ ``offer_help`` schema for generator-built routes).
20
+
21
+ Two optional hooks make it curate + gate itself for apps that have a notion of
22
+ "internal / operator-only" routes (permissive defaults, so a bare app needs neither):
23
+
24
+ install_endpoints_explorer(app,
25
+ classify_internal=lambda url, methods: ..., # True -> hidden by default
26
+ can_view_internal=lambda: is_operator(), # gate for ?all=1
27
+ brand="my-api", accent="#8ab4ff")
28
+
29
+ It overrides ``abstract_flask``'s existing ``global_endpoint_inspector`` /
30
+ ``prefix_inspector`` views IN PLACE when present (no duplicate routes), else registers
31
+ ``/endpoints`` (+ ``/prefixes`` if that inspector exists). Curation is a docs nicety,
32
+ NOT a security boundary — real routes must still enforce their own auth.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import html as _html
38
+ import json as _json
39
+ from typing import Callable, Dict, List, Optional
40
+
41
+ from flask import Response, jsonify, request
42
+
43
+ Classifier = Callable[[str, List[str]], bool]
44
+ Gate = Callable[[], bool]
45
+
46
+ _MUTATING = {"POST", "PUT", "PATCH", "DELETE"}
47
+ _METHOD_ORDER = {"GET": 0, "POST": 1, "PUT": 2, "PATCH": 3, "DELETE": 4}
48
+
49
+
50
+ # ── collection ────────────────────────────────────────────────────────────────
51
+
52
+ def collect_endpoints(app, classify: Optional[Classifier] = None) -> List[Dict]:
53
+ """Rich per-route records: ``{endpoint,url,methods,args,internal}``, sorted by
54
+ url. ``classify(url, methods) -> bool`` flags internal routes (default: none)."""
55
+ out: List[Dict] = []
56
+ for rule in app.url_map.iter_rules():
57
+ if rule.endpoint == "static":
58
+ continue
59
+ methods = sorted((rule.methods or set()) - {"HEAD", "OPTIONS"})
60
+ url = str(rule)
61
+ internal = False
62
+ if classify is not None:
63
+ try:
64
+ internal = bool(classify(url, methods))
65
+ except Exception:
66
+ internal = False
67
+ out.append({
68
+ "endpoint": rule.endpoint, "url": url, "methods": methods,
69
+ "args": sorted(rule.arguments or ()), "internal": internal,
70
+ })
71
+ return sorted(out, key=lambda x: x["url"])
72
+
73
+
74
+ def _visible(entries: List[Dict], include_internal: bool) -> List[Dict]:
75
+ return entries if include_internal else [e for e in entries if not e["internal"]]
76
+
77
+
78
+ def _public_json(entries: List[Dict], include_internal: bool) -> List[Dict]:
79
+ return [{"endpoint": e["endpoint"], "url": e["url"], "methods": e["methods"]}
80
+ for e in _visible(entries, include_internal)]
81
+
82
+
83
+ def _top_segment(url: str) -> str:
84
+ stripped = url.lstrip("/")
85
+ if not stripped:
86
+ return "/"
87
+ return "/" + stripped.split("/")[0].split("<")[0].rstrip("/")
88
+
89
+
90
+ def collect_prefixes(entries: List[Dict], include_internal: bool) -> List[str]:
91
+ return sorted({_top_segment(e["url"]) for e in _visible(entries, include_internal)})
92
+
93
+
94
+ # ── request policy (negotiation + gating) ─────────────────────────────────────
95
+
96
+ def _wants_html(req) -> bool:
97
+ fmt = (req.args.get("format") or "").strip().lower()
98
+ if fmt in ("json", "raw"):
99
+ return False
100
+ if fmt in ("html", "view", "page"):
101
+ return True
102
+ accept = req.accept_mimetypes
103
+ best = accept.best_match(["application/json", "text/html"])
104
+ return best == "text/html" and accept["text/html"] >= accept["application/json"]
105
+
106
+
107
+ def _all_requested(req) -> bool:
108
+ return (req.args.get("all") or "").strip().lower() in ("1", "true", "yes", "on")
109
+
110
+
111
+ # ── HTML rendering (self-contained: inline CSS/JS, Google-fonts editorial look) ─
112
+
113
+ def render_html(entries: List[Dict], *, host: str, show_all: bool, all_allowed: bool,
114
+ title: str, accent: str, call_base: str = "") -> str:
115
+ include = show_all and all_allowed
116
+ visible = _visible(entries, include)
117
+ hidden_internal = 0 if include else sum(1 for e in entries if e["internal"])
118
+
119
+ groups: Dict[str, List[Dict]] = {}
120
+ for e in visible:
121
+ groups.setdefault(_top_segment(e["url"]), []).append(e)
122
+
123
+ rows: List[str] = []
124
+ for seg in sorted(groups):
125
+ items = groups[seg]
126
+ rows.append(
127
+ f'<tr class="grp" data-seg="{_html.escape(seg)}">'
128
+ f'<td colspan="3"><span class="seg">{_html.escape(seg)}</span>'
129
+ f'<span class="segn">{len(items)}</span></td></tr>'
130
+ )
131
+ for e in items:
132
+ methods = sorted(e["methods"], key=lambda m: _METHOD_ORDER.get(m, 9))
133
+ badges = "".join(
134
+ f'<span class="m m-{_html.escape(m.lower())}">{_html.escape(m)}</span>'
135
+ for m in methods)
136
+ mutating = bool(set(methods) & _MUTATING)
137
+ flags = ""
138
+ if e["internal"]:
139
+ flags += '<span class="flag flag-int" title="operator-gated / internal">internal</span>'
140
+ if mutating:
141
+ flags += '<span class="flag flag-mut" title="mutating — changes state">mutates</span>'
142
+ data = _html.escape(_json.dumps({
143
+ "url": e["url"], "methods": methods, "args": e["args"],
144
+ "internal": e["internal"], "mutating": mutating, "endpoint": e["endpoint"],
145
+ }), quote=True)
146
+ hay = _html.escape(f'{e["url"]} {e["endpoint"]} {" ".join(methods)}'.lower())
147
+ rows.append(
148
+ f'<tr class="ep" data-h="{hay}" data-ep="{data}">'
149
+ f'<td class="c-m">{badges}</td>'
150
+ f'<td class="c-u"><code>{_html.escape(e["url"])}</code>{flags}</td>'
151
+ f'<td class="c-e">{_html.escape(e["endpoint"])}</td></tr>'
152
+ )
153
+
154
+ host_line = f" · <span class='host'>{_html.escape(host)}</span>" if host else ""
155
+ if include:
156
+ toggle = '<a href="?">public only</a>'
157
+ elif show_all and not all_allowed:
158
+ toggle = '<span class="muted">internal view needs operator auth</span> · <a href="?">public</a>'
159
+ else:
160
+ extra = f" ({hidden_internal} internal hidden)" if hidden_internal else ""
161
+ toggle = f'<a href="?all=1">show all{extra}</a>'
162
+
163
+ return (_PAGE
164
+ .replace("__TITLE__", _html.escape(title))
165
+ .replace("__ACCENT__", accent)
166
+ .replace("__CALLBASE__", _json.dumps(call_base))
167
+ .replace("__TOTAL__", str(len(visible)))
168
+ .replace("__HOSTLINE__", host_line)
169
+ .replace("__TOGGLE__", toggle)
170
+ .replace("__ROWS__", "\n".join(rows)))
171
+
172
+
173
+ _PAGE = r"""<!doctype html>
174
+ <html lang="en"><head>
175
+ <meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
176
+ <meta name="theme-color" content="#050505">
177
+ <title>__TITLE__</title>
178
+ <link rel="preconnect" href="https://fonts.googleapis.com">
179
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
180
+ <link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=Newsreader:ital,wght@0,400;0,500;1,400&display=swap" rel="stylesheet">
181
+ <style>
182
+ :root{
183
+ --bg:#050505;--panel:#111113;--fg:#ededed;--dim:#ffffffb3;--faint:#ffffff66;
184
+ --line:#ffffff26;--line-lt:#ffffff14;--accent:__ACCENT__;--warn:#e3b341;--bad:#f87171;
185
+ --serif:'Newsreader',Georgia,'Times New Roman',serif;
186
+ --mono:'IBM Plex Mono',ui-monospace,SFMono-Regular,Menlo,monospace;
187
+ }
188
+ *{box-sizing:border-box}
189
+ html,body{margin:0;background:var(--bg);color:var(--fg);overflow-x:hidden;}
190
+ body{font:14px/1.6 var(--mono);-webkit-font-smoothing:antialiased;}
191
+ header{position:sticky;top:0;background:rgba(5,5,5,.92);backdrop-filter:blur(8px);border-bottom:1px solid var(--line);padding:20px 22px 14px;z-index:5;}
192
+ h1{margin:0;font-family:var(--serif);font-size:26px;font-weight:500;letter-spacing:-.01em;}
193
+ h1 .n{color:var(--accent);font-style:italic;}
194
+ .sub{color:var(--dim);font-size:12px;margin-top:4px;letter-spacing:.02em;}
195
+ .sub a{color:var(--accent);text-decoration:none;border-bottom:1px solid transparent;}
196
+ .sub a:hover{border-bottom-color:var(--accent);}
197
+ .muted{color:var(--faint);}
198
+ .tools{display:flex;gap:10px;align-items:center;margin-top:12px;flex-wrap:wrap;}
199
+ #q{flex:1;min-width:200px;background:var(--panel);border:1px solid var(--line);color:var(--fg);border-radius:8px;padding:9px 12px;font:13px var(--mono);}
200
+ #q:focus{outline:none;border-color:var(--accent);}
201
+ #q::placeholder{color:var(--faint);}
202
+ #count{color:var(--dim);font-size:12px;white-space:nowrap;}
203
+ .wrap{max-width:1080px;margin:0 auto;padding:0 22px 80px;}
204
+ table{width:100%;border-collapse:collapse;}
205
+ td{padding:8px 8px;border-bottom:1px solid var(--line-lt);vertical-align:top;}
206
+ tr.grp td{border-bottom:1px solid var(--line);padding:26px 8px 8px;}
207
+ .seg{font-family:var(--serif);font-style:italic;font-size:16px;color:var(--fg);}
208
+ .segn{color:var(--faint);font-size:11px;margin-left:9px;}
209
+ tr.ep{cursor:pointer;}
210
+ tr.ep:hover td,tr.ep.open td{background:var(--panel);}
211
+ .c-m{width:118px;white-space:nowrap;}
212
+ .c-u code{font:12.5px/1.6 var(--mono);color:var(--fg);word-break:break-all;}
213
+ .c-e{color:var(--faint);font:11.5px/1.6 var(--mono);word-break:break-all;}
214
+ .m{display:inline-block;font:500 10px/1 var(--mono);padding:3px 6px;border-radius:5px;margin-right:4px;letter-spacing:.04em;}
215
+ .m-get{background:#2ea04326;color:#7ee787;}.m-post{background:#8ab4ff26;color:#8ab4ff;}
216
+ .m-put{background:#e3b34126;color:#e3b341;}.m-patch{background:#a371f726;color:#c9a2ff;}.m-delete{background:#f8717126;color:#f87171;}
217
+ .flag{display:inline-block;font:500 9px/1 var(--mono);padding:2px 5px;border-radius:4px;margin-left:7px;vertical-align:middle;letter-spacing:.04em;text-transform:uppercase;}
218
+ .flag-int{background:#a371f71f;color:#c9a2ff;border:1px solid #a371f73d;}
219
+ .flag-mut{background:#e3b3411f;color:var(--warn);border:1px solid #e3b3413d;}
220
+ .panel td{padding:0;background:var(--panel);}
221
+ .tryit{padding:16px 18px;border-left:2px solid var(--accent);margin:0 0 6px;}
222
+ .tryit .row{display:flex;gap:10px;align-items:center;margin:8px 0;flex-wrap:wrap;}
223
+ .tryit label{font:500 10.5px/1 var(--mono);color:var(--faint);min-width:98px;text-transform:uppercase;letter-spacing:.06em;}
224
+ .tryit input,.tryit textarea,.tryit select{background:var(--bg);border:1px solid var(--line);color:var(--fg);border-radius:7px;padding:7px 10px;font:12.5px var(--mono);}
225
+ .tryit input,.tryit textarea{flex:1;min-width:170px;}
226
+ .tryit textarea{min-height:66px;resize:vertical;width:100%;}
227
+ .tryit input:focus,.tryit textarea:focus,.tryit select:focus{outline:none;border-color:var(--accent);}
228
+ .tryit .u{font:12.5px var(--mono);color:var(--dim);word-break:break-all;}
229
+ .btn{background:var(--accent);color:#08131f;border:none;border-radius:7px;padding:8px 16px;font:500 12px var(--mono);cursor:pointer;letter-spacing:.02em;}
230
+ .btn:hover{filter:brightness(1.08);}
231
+ .btn.sec{background:transparent;color:var(--accent);border:1px solid var(--line);}
232
+ .btn:disabled{opacity:.5;cursor:default;}
233
+ .warn{color:var(--warn);font:11.5px var(--mono);margin:6px 0;}
234
+ .resp{margin-top:10px;}
235
+ .resp .st{font:500 11.5px var(--mono);margin-bottom:5px;letter-spacing:.02em;}
236
+ .resp pre{background:var(--bg);border:1px solid var(--line);border-radius:8px;padding:12px;overflow:auto;max-height:380px;font:12px/1.5 var(--mono);white-space:pre-wrap;word-break:break-word;margin:0;color:var(--fg);}
237
+ .st.ok{color:#7ee787;}.st.err{color:var(--bad);}
238
+ .empty{color:var(--faint);padding:40px 8px;text-align:center;font-family:var(--serif);font-style:italic;font-size:16px;}
239
+ ::-webkit-scrollbar{width:10px;height:10px;}::-webkit-scrollbar-thumb{background:var(--line);border-radius:6px;}
240
+ </style></head><body>
241
+ <header>
242
+ <h1>__TITLE__</h1>
243
+ <div class="sub"><span id="total">__TOTAL__</span> routes__HOSTLINE__ ·
244
+ <a href="?format=json">raw JSON</a> · <a href="/prefixes">/prefixes</a> · __TOGGLE__</div>
245
+ <div class="tools">
246
+ <input id="q" type="search" placeholder="filter by path, method, or endpoint name…" autocomplete="off" autofocus>
247
+ <span id="count"></span>
248
+ </div>
249
+ </header>
250
+ <div class="wrap"><table><tbody id="t">
251
+ __ROWS__
252
+ </tbody></table><div class="empty" id="none" hidden>no endpoints match your filter</div></div>
253
+ <script>
254
+ (function(){
255
+ var CALL_BASE=__CALLBASE__;
256
+ var q=document.getElementById('q'),t=document.getElementById('t'),
257
+ cnt=document.getElementById('count'),none=document.getElementById('none'),
258
+ eps=[].slice.call(t.querySelectorAll('tr.ep')),
259
+ grps=[].slice.call(t.querySelectorAll('tr.grp'));
260
+ var ESC={'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'};
261
+ function esc(s){return String(s==null?'':s).replace(/[&<>"']/g,function(c){return ESC[c];});}
262
+ function buildURL(tpl,argVals){
263
+ var out=tpl.replace(/<[^>]+>/g,function(tok){
264
+ var isPath=/<path:/.test(tok);
265
+ var name=tok.replace(/[<>]/g,'').split(':').pop();
266
+ var v=argVals[name]!=null?argVals[name]:'';
267
+ return isPath?v.split('/').map(encodeURIComponent).join('/'):encodeURIComponent(v);
268
+ });
269
+ return out.replace(/^\/{2,}/,'/');
270
+ }
271
+ // Route the actual try-it call through the host's API base (e.g. "/api") when
272
+ // set: the dev front proxies only /api to Flask and SPA-swallows every other
273
+ // bare path into index.html, so a bare fetch would return HTML, not the
274
+ // endpoint. Strip an existing /api first so both bare- and /api-listed routes
275
+ // resolve to the one proxied path. CALL_BASE="" (default) leaves URLs as-is.
276
+ function callPath(u){
277
+ if(!CALL_BASE)return u;
278
+ u=u.replace(/^\/api(?=\/|$)/,'');
279
+ return CALL_BASE+(u||'/');
280
+ }
281
+ function sameOrigin(path){try{return new URL(path,location.href).origin===location.origin;}catch(e){return false;}}
282
+ function panelFor(row){
283
+ var ep=JSON.parse(row.getAttribute('data-ep'));
284
+ var methods=ep.methods.slice(),defM=methods.indexOf('GET')>-1?'GET':methods[0];
285
+ var wrap=document.createElement('tr');wrap.className='panel';
286
+ var td=document.createElement('td');td.colSpan=3;wrap.appendChild(td);
287
+ var h='<div class="tryit">';
288
+ if(methods.length>1){
289
+ h+='<div class="row"><label>method</label><select class="mm">'+
290
+ methods.map(function(m){return '<option'+(m===defM?' selected':'')+'>'+esc(m)+'</option>';}).join('')+'</select></div>';
291
+ }else{h+='<div class="row"><label>method</label><span class="u">'+esc(defM)+'</span></div>';}
292
+ (ep.args||[]).forEach(function(a){
293
+ h+='<div class="row"><label>'+esc(a)+'</label><input class="pa" data-a="'+esc(a)+'" placeholder="'+esc(a)+'"></div>';
294
+ });
295
+ h+='<div class="row"><label>query</label><input class="qq" placeholder="key=val&amp;k2=v2"></div>';
296
+ h+='<div class="row bodyrow"><label>body (JSON)</label><textarea class="bb" placeholder="{ }"></textarea></div>';
297
+ if(ep.mutating){h+='<div class="warn">⚠ this method changes state'+(ep.internal?' and is operator-gated':'')+' — it runs for real when you Send.</div>';}
298
+ h+='<div class="row"><span class="u" data-role="preview"></span></div>';
299
+ h+='<div class="row"><button class="btn go">Send</button>';
300
+ h+='<button class="btn sec help">params (?help)</button></div>';
301
+ h+='<div class="resp" hidden><div class="st"></div><pre></pre></div>';
302
+ h+='</div>';
303
+ td.innerHTML=h;
304
+ var pas=[].slice.call(td.querySelectorAll('.pa')),
305
+ mm=td.querySelector('.mm'),qq=td.querySelector('.qq'),bb=td.querySelector('.bb'),
306
+ bodyrow=td.querySelector('.bodyrow'),preview=td.querySelector('[data-role=preview]'),
307
+ resp=td.querySelector('.resp'),st=td.querySelector('.st'),pre=td.querySelector('pre');
308
+ function curMethod(){return mm?mm.value:defM;}
309
+ function argVals(){var o={};pas.forEach(function(i){o[i.getAttribute('data-a')]=i.value;});return o;}
310
+ function fullPath(){var u=callPath(buildURL(ep.url,argVals())),query=(qq.value||'').trim();return u+(query?((u.indexOf('?')>-1?'&':'?')+query):'');}
311
+ function refresh(){var m=curMethod();bodyrow.style.display=(m==='GET'||m==='DELETE')?'none':'';preview.textContent=m+' '+fullPath();}
312
+ pas.concat([qq]).forEach(function(i){i.addEventListener('input',refresh);});
313
+ if(mm)mm.addEventListener('change',refresh);
314
+ refresh();
315
+ function send(path,method,useBody){
316
+ if(!sameOrigin(path)){resp.hidden=false;st.className='st err';st.textContent='refused: not same-origin';pre.textContent='This tool only calls '+location.origin;return;}
317
+ var opts={method:method,headers:{}};
318
+ if(useBody&&bb.value.trim()){opts.headers['Content-Type']='application/json';opts.body=bb.value;}
319
+ resp.hidden=false;st.className='st';st.textContent='…';pre.textContent='';
320
+ var t0=Date.now();
321
+ fetch(path,opts).then(function(r){return r.text().then(function(txt){
322
+ st.className='st '+(r.ok?'ok':'err');
323
+ st.textContent=r.status+' '+r.statusText+' · '+(Date.now()-t0)+'ms · '+(r.headers.get('content-type')||'');
324
+ try{pre.textContent=JSON.stringify(JSON.parse(txt),null,2);}catch(e){pre.textContent=txt.slice(0,20000);}
325
+ });}).catch(function(e){st.className='st err';st.textContent='network error';pre.textContent=String(e);});
326
+ }
327
+ td.querySelector('.go').addEventListener('click',function(){
328
+ var m=curMethod();
329
+ if(m!=='GET'&&!confirm(m+' '+fullPath()+'\n\nThis calls the API for real'+(ep.mutating?' and may change state':'')+'. Continue?'))return;
330
+ send(fullPath(),m,m!=='GET'&&m!=='DELETE');
331
+ });
332
+ td.querySelector('.help').addEventListener('click',function(){
333
+ var u=callPath(buildURL(ep.url,argVals()));send(u+(u.indexOf('?')>-1?'&':'?')+'help','GET',false);
334
+ });
335
+ return wrap;
336
+ }
337
+ eps.forEach(function(row){
338
+ row.addEventListener('click',function(e){
339
+ if(e.target.closest('.panel'))return;
340
+ var nx=row.nextElementSibling;
341
+ if(nx&&nx.classList.contains('panel')){nx.remove();row.classList.remove('open');return;}
342
+ row.classList.add('open');
343
+ row.parentNode.insertBefore(panelFor(row),row.nextElementSibling);
344
+ });
345
+ });
346
+ function apply(){
347
+ var s=q.value.trim().toLowerCase(),shown=0;
348
+ eps.forEach(function(r){
349
+ var m=!s||r.dataset.h.indexOf(s)>-1;r.hidden=!m;if(m)shown++;
350
+ var nx=r.nextElementSibling;if(nx&&nx.classList.contains('panel'))nx.hidden=!m;
351
+ });
352
+ grps.forEach(function(g){
353
+ var n=g.nextElementSibling,any=false;
354
+ while(n&&!n.classList.contains('grp')){if(n.classList.contains('ep')&&!n.hidden)any=true;n=n.nextElementSibling;}
355
+ g.hidden=!any;
356
+ });
357
+ cnt.textContent=s?(shown+' shown'):'';none.hidden=shown>0;
358
+ }
359
+ q.addEventListener('input',apply);apply();
360
+ })();
361
+ </script>
362
+ </body></html>"""
363
+
364
+
365
+ # ── install ──────────────────────────────────────────────────────────────────
366
+
367
+ def install_endpoints_explorer(app, *, classify_internal: Optional[Classifier] = None,
368
+ can_view_internal: Optional[Gate] = None,
369
+ brand: str = "API endpoints",
370
+ accent: str = "#8ab4ff",
371
+ call_base: str = "") -> None:
372
+ """Install the explorer on ``app``. Overrides ``abstract_flask``'s inspector
373
+ views in place when present; otherwise registers ``/endpoints``.
374
+
375
+ classify_internal(url, methods) -> True to hide a route by default (docs
376
+ curation). can_view_internal() -> True to allow ?all=1 to reveal internal
377
+ routes (called in request context). Both optional; defaults keep everything
378
+ public and permissive, so a bare app works with a bare call.
379
+
380
+ call_base: prefix the try-it FETCH with this (e.g. "/api") when the page is
381
+ served behind a front that only proxies that base to the app and SPA-swallows
382
+ other bare paths (the dev webpack front does exactly this — a bare fetch would
383
+ return index.html, not the endpoint). An existing "/api" on the route is
384
+ stripped first, so both bare- and /api-listed routes resolve to the one
385
+ proxied path. Default "" leaves try-it URLs exactly as listed."""
386
+ def _gate() -> bool:
387
+ if can_view_internal is None:
388
+ return True
389
+ try:
390
+ return bool(can_view_internal())
391
+ except Exception:
392
+ return False
393
+
394
+ def _include() -> bool:
395
+ return _all_requested(request) and _gate()
396
+
397
+ def endpoints_view(*_a, **_k):
398
+ entries = collect_endpoints(app, classify_internal)
399
+ if _wants_html(request):
400
+ return Response(render_html(
401
+ entries, host=request.host or "", show_all=_all_requested(request),
402
+ all_allowed=_gate(), title=brand, accent=accent,
403
+ call_base=call_base), mimetype="text/html",
404
+ # Never let a browser serve a cached copy of this page: it carries
405
+ # the try-it JS, and a stale copy silently calls old (bare) URLs.
406
+ headers={"Cache-Control": "no-store"})
407
+ return jsonify(_public_json(entries, include_internal=_include())), 200
408
+
409
+ def prefixes_view(*_a, **_k):
410
+ entries = collect_endpoints(app, classify_internal)
411
+ return jsonify(collect_prefixes(entries, include_internal=_include())), 200
412
+
413
+ if "global_endpoint_inspector" in app.view_functions:
414
+ app.view_functions["global_endpoint_inspector"] = endpoints_view
415
+ else:
416
+ app.add_url_rule("/endpoints", endpoint="global_endpoint_inspector",
417
+ view_func=endpoints_view, methods=["GET"])
418
+
419
+ if "prefix_inspector" in app.view_functions:
420
+ app.view_functions["prefix_inspector"] = prefixes_view
@@ -0,0 +1,75 @@
1
+ """hugpy adapter for the reusable ``endpoints_explorer``.
2
+
3
+ The interactive ``/endpoints`` explorer (curated listing + try-it console + /media
4
+ styling) is a portable, framework-only module — ``endpoints_explorer`` — so it can be
5
+ lifted into ``abstract_flask`` as an upgrade to the generator. This file is the thin
6
+ hugpy-specific wiring: it injects hugpy's own notion of "internal" (reusing
7
+ ``operator_auth._SENSITIVE`` so the docs flag can't drift from what actually gates the
8
+ routes) and hugpy's operator check (``operator_authenticated``) for the ``?all=1`` gate.
9
+
10
+ Everything else — collection, content negotiation, curation, the try-it page — lives in
11
+ ``endpoints_explorer`` and is app-agnostic. Install is wrapped by the caller in
12
+ try/except; it must never break boot.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import re as _re
18
+ from typing import List
19
+
20
+ from hugpy_server.app.endpoints_explorer import install_endpoints_explorer
21
+
22
+
23
+ # ── hugpy sensitivity classification (reuse operator_auth's own allowlist) ────
24
+
25
+ def _sensitive_rules():
26
+ """operator_auth's (methods, path-regex) allowlist — the same one that gates the
27
+ real routes, so the docs "internal" flag stays in lockstep. [] if unavailable."""
28
+ try:
29
+ from hugpy_server.app.operator_auth import _SENSITIVE
30
+ return _SENSITIVE
31
+ except Exception:
32
+ return []
33
+
34
+
35
+ def _norm_path(url: str) -> str:
36
+ # Match operator_auth's normalization: strip a leading /api (gunicorn dual-mount),
37
+ # collapse <converters> so its concrete-path regexes match rule strings.
38
+ if url == "/api" or url.startswith("/api/"):
39
+ url = url[len("/api"):] or "/"
40
+ return _re.sub(r"<[^>]+>", "X", url)
41
+
42
+
43
+ def _classify_internal(url: str, methods: List[str]) -> bool:
44
+ path = _norm_path(url)
45
+ mset = set(methods)
46
+ for smethods, rx in _sensitive_rules():
47
+ if (mset & smethods) and rx.match(path):
48
+ return True
49
+ return False
50
+
51
+
52
+ def _operator_ok() -> bool:
53
+ """Whether the caller may see internal routes (?all=1 gate). Permissive in the
54
+ self-hosted 'open' mode; enforced once the operator auth gate is active."""
55
+ try:
56
+ from hugpy_server.app.operator_auth import operator_authenticated
57
+ return bool(operator_authenticated())
58
+ except Exception:
59
+ return True
60
+
61
+
62
+ def install_endpoints_view(app) -> None:
63
+ install_endpoints_explorer(
64
+ app,
65
+ classify_internal=lambda url, methods: _classify_internal(url, methods),
66
+ can_view_internal=lambda: _operator_ok(),
67
+ brand="hugpy · API endpoints",
68
+ accent="#8ab4ff",
69
+ # dev.hugpy.ai's front (:7001 webpack) proxies ONLY /api to Flask and
70
+ # SPA-swallows every other bare path into index.html — so a try-it fetch
71
+ # to a bare route returns HTML, not the endpoint. Route calls through /api
72
+ # (hugpy dual-mounts every route there); the explorer strips a listed
73
+ # /api first so bare- and /api-listed rows both resolve to the one path.
74
+ call_base="/api",
75
+ )
File without changes
File without changes