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.
- hugpy_server/__init__.py +27 -0
- hugpy_server/app/__init__.py +8 -0
- hugpy_server/app/auth_common.py +53 -0
- hugpy_server/app/endpoints_explorer.py +420 -0
- hugpy_server/app/endpoints_view.py +75 -0
- hugpy_server/app/functions/__init__.py +0 -0
- hugpy_server/app/functions/chat/__init__.py +0 -0
- hugpy_server/app/functions/chat/streaming.py +385 -0
- hugpy_server/app/functions/chat/task_selection.py +5 -0
- hugpy_server/app/functions/imports/__init__.py +0 -0
- hugpy_server/app/functions/imports/options/__init__.py +0 -0
- hugpy_server/app/functions/imports/options/install.py +77 -0
- hugpy_server/app/functions/imports/options/search.py +25 -0
- hugpy_server/app/functions/imports/utils/__init__.py +0 -0
- hugpy_server/app/functions/imports/utils/api_keys.py +318 -0
- hugpy_server/app/functions/imports/utils/constants.py +34 -0
- hugpy_server/app/functions/imports/utils/discord_bindings.py +697 -0
- hugpy_server/app/functions/imports/utils/install_links.py +402 -0
- hugpy_server/app/functions/imports/utils/key_store.py +26 -0
- hugpy_server/app/functions/imports/utils/video_share_keys.py +163 -0
- hugpy_server/app/help_agent.py +800 -0
- hugpy_server/app/keeper_line.py +105 -0
- hugpy_server/app/member_auth.py +149 -0
- hugpy_server/app/operator_auth.py +730 -0
- hugpy_server/app/routes/__init__.py +30 -0
- hugpy_server/app/routes/agent_routes.py +1655 -0
- hugpy_server/app/routes/auth_proxy_routes.py +156 -0
- hugpy_server/app/routes/chat_routes.py +11 -0
- hugpy_server/app/routes/comms_routes.py +405 -0
- hugpy_server/app/routes/discord_routes.py +944 -0
- hugpy_server/app/routes/eviction_routes.py +259 -0
- hugpy_server/app/routes/fleet_doctrine_routes.py +192 -0
- hugpy_server/app/routes/fleet_routes.py +109 -0
- hugpy_server/app/routes/group_routes.py +53 -0
- hugpy_server/app/routes/help_routes.py +232 -0
- hugpy_server/app/routes/installer_assets/__init__.py +0 -0
- hugpy_server/app/routes/installer_assets/console-install-link.sh +80 -0
- hugpy_server/app/routes/installer_assets/console-install.sh +241 -0
- hugpy_server/app/routes/installer_assets/generate_icons.py +66 -0
- hugpy_server/app/routes/installer_assets/hugpy-icon.ico +0 -0
- hugpy_server/app/routes/installer_assets/hugpy-icon.png +0 -0
- hugpy_server/app/routes/installer_assets/station-fix.sh +249 -0
- hugpy_server/app/routes/interim_routes.py +148 -0
- hugpy_server/app/routes/keeper_help_routes.py +279 -0
- hugpy_server/app/routes/llm_storage_routes.py +921 -0
- hugpy_server/app/routes/messages_helpers.py +516 -0
- hugpy_server/app/routes/messages_routes.py +393 -0
- hugpy_server/app/routes/metrics_routes.py +767 -0
- hugpy_server/app/routes/ml_routes.py +348 -0
- hugpy_server/app/routes/model_group_routes.py +527 -0
- hugpy_server/app/routes/model_status_routes.py +1386 -0
- hugpy_server/app/routes/oracle_routes.py +697 -0
- hugpy_server/app/routes/phone_brick_routes.py +345 -0
- hugpy_server/app/routes/prompt_routes.py +120 -0
- hugpy_server/app/routes/pypi_routes.py +204 -0
- hugpy_server/app/routes/review_routes.py +1202 -0
- hugpy_server/app/routes/script_first_routes.py +338 -0
- hugpy_server/app/routes/search_routes.py +657 -0
- hugpy_server/app/routes/upload_routes.py +297 -0
- hugpy_server/app/routes/v1_helpers.py +498 -0
- hugpy_server/app/routes/v1_routes.py +727 -0
- hugpy_server/app/routes/video_assist_media.py +325 -0
- hugpy_server/app/routes/video_coordination.py +187 -0
- hugpy_server/app/routes/video_routes.py +6410 -0
- hugpy_server/app/routes/welcome_routes.py +129 -0
- hugpy_server/app/routes/worker_routes.py +6167 -0
- hugpy_server/app/transfer_ledger.py +260 -0
- hugpy_server/app/video_auth.py +189 -0
- hugpy_server/console_dist/SOURCE_HASH.json +5 -0
- hugpy_server/console_dist/assets/hugpy-mark.005dbad6b15491649f17.png +0 -0
- hugpy_server/console_dist/assets/hugpy.75b0380a164d2f2c08e9.png +0 -0
- hugpy_server/console_dist/assets/main.b72ac2a9e85d6bc0cbf4.js +2 -0
- hugpy_server/console_dist/assets/main.b72ac2a9e85d6bc0cbf4.js.LICENSE.txt +39 -0
- hugpy_server/console_dist/fleet/assets/index--EAYlp9c.css +1 -0
- hugpy_server/console_dist/fleet/assets/index-BEA6Z6Eq.js +66 -0
- hugpy_server/console_dist/fleet/index.html +71 -0
- hugpy_server/console_dist/hugpy-favicon-hex.svg +60 -0
- hugpy_server/console_dist/index.html +53 -0
- hugpy_server/console_dist/media/assets/hugpy-mark-BXktFcOQ.png +0 -0
- hugpy_server/console_dist/media/assets/index-Bfe0XM6M.css +1 -0
- hugpy_server/console_dist/media/assets/index-DPfu7_M1.js +262 -0
- hugpy_server/console_dist/media/index.html +102 -0
- hugpy_server/console_dist/video/assets/hugpy-mark-BXktFcOQ.png +0 -0
- hugpy_server/console_dist/video/assets/index-1i-4svLp.css +1 -0
- hugpy_server/console_dist/video/assets/index-CHczeEma.js +4190 -0
- hugpy_server/console_dist/video/index.html +71 -0
- hugpy_server/py.typed +0 -0
- hugpy_server/state.py +33 -0
- hugpy_server/wiring.py +222 -0
- hugpy_server/wsgi_app.py +679 -0
- hugpy_server-0.2.0a0.dist-info/METADATA +79 -0
- hugpy_server-0.2.0a0.dist-info/RECORD +96 -0
- hugpy_server-0.2.0a0.dist-info/WHEEL +5 -0
- hugpy_server-0.2.0a0.dist-info/entry_points.txt +2 -0
- hugpy_server-0.2.0a0.dist-info/licenses/LICENSE +41 -0
- hugpy_server-0.2.0a0.dist-info/top_level.txt +1 -0
hugpy_server/__init__.py
ADDED
|
@@ -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={'&':'&','<':'<','>':'>','"':'"',"'":'''};
|
|
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&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
|