From 92c1e8e10c4f627d9bae5526717eaa84662c5d90 Mon Sep 17 00:00:00 2001 From: dunderrrrrr Date: Wed, 8 Jul 2026 13:44:49 +0200 Subject: [PATCH 1/5] feat(docs): rebuild docs site This will rebuild and refresh the contents of blocket-api.se. --- CONTRIBUTING.md | 5 + docs/__init__.py | 0 docs/api-reference.md | 9 - docs/api.css | 33 -- docs/app.py | 71 ++++ docs/components/__init__.py | 0 docs/components/changelog.py | 87 ++++ docs/components/endpoints.py | 144 +++++++ docs/components/examples.py | 681 +++++++++++++++++++++++++++++++ docs/components/index.py | 477 ++++++++++++++++++++++ docs/components/python_lib.py | 311 +++++++++++++++ docs/components/quickstart.py | 305 ++++++++++++++ docs/components/tips.py | 511 ++++++++++++++++++++++++ docs/components_base.py | 410 +++++++++++++++++++ docs/custom.css | 7 - docs/examples.md | 111 ------ docs/index.md | 16 - docs/static/blocket-api.png | Bin 0 -> 48756 bytes docs/static/main.js | 132 +++++++ docs/static/style.css | 726 ++++++++++++++++++++++++++++++++++ docs/views/__init__.py | 0 docs/views/changelog.py | 42 ++ docs/views/endpoints.py | 42 ++ docs/views/examples.py | 55 +++ docs/views/index.py | 25 ++ docs/views/python.py | 38 ++ docs/views/quickstart.py | 39 ++ docs/views/tips.py | 43 ++ flake.nix | 8 +- pyproject.toml | 11 +- requirements.txt | 122 +----- tests/docs.py | 22 -- uv.lock | 372 +++-------------- 33 files changed, 4218 insertions(+), 637 deletions(-) create mode 100644 docs/__init__.py delete mode 100644 docs/api-reference.md delete mode 100644 docs/api.css create mode 100644 docs/app.py create mode 100644 docs/components/__init__.py create mode 100644 docs/components/changelog.py create mode 100644 docs/components/endpoints.py create mode 100644 docs/components/examples.py create mode 100644 docs/components/index.py create mode 100644 docs/components/python_lib.py create mode 100644 docs/components/quickstart.py create mode 100644 docs/components/tips.py create mode 100644 docs/components_base.py delete mode 100644 docs/custom.css delete mode 100644 docs/examples.md delete mode 100644 docs/index.md create mode 100644 docs/static/blocket-api.png create mode 100644 docs/static/main.js create mode 100644 docs/static/style.css create mode 100644 docs/views/__init__.py create mode 100644 docs/views/changelog.py create mode 100644 docs/views/endpoints.py create mode 100644 docs/views/examples.py create mode 100644 docs/views/index.py create mode 100644 docs/views/python.py create mode 100644 docs/views/quickstart.py create mode 100644 docs/views/tips.py delete mode 100644 tests/docs.py diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f29d976..bc44e96 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -59,6 +59,11 @@ uv run ruff check . uv run mypy . ``` +## Running docs suite +```sh +flask --app docs/app --debug run --host 0.0.0.0 +``` + ## Submitting Pull Requests 1. Commit your changes with a clear and descriptive commit message. Please try to follow the [Conventional Commits](https://www.conventionalcommits.org/) specification if possible. diff --git a/docs/__init__.py b/docs/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/docs/api-reference.md b/docs/api-reference.md deleted file mode 100644 index b1b9569..0000000 --- a/docs/api-reference.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -hide: - - toc - - navigation ---- - - - - diff --git a/docs/api.css b/docs/api.css deleted file mode 100644 index 41982ba..0000000 --- a/docs/api.css +++ /dev/null @@ -1,33 +0,0 @@ -#operations-tag-default, -section.models, -.info { - display:none; -} -body { - margin:0px; -} -.wrapper { - padding:0px !important; -} -.opblock-get, .opblock-summary-get { - border: 0px !important; - background-color: rgb(45, 44, 61); -} -.swagger-ui .opblock.opblock-get .opblock-summary-method, .swagger-ui .opblock.opblock-get .tab-header .tab-item.active h4 span::after { - background: #775c9b; -} -.swagger-ui .model-box-control:focus, .swagger-ui .models-control:focus, .swagger-ui .opblock-summary-control:focus { - outline: none; -} -.swagger-ui .btn { - background: #775C9B; - border: none; -} -.swagger-ui .btn.execute { - background-color: #775c9b; - border: none; - color: #fff; -} -.swagger-ui .btn.cancel, .btn.cancel:hover { - outline: none; -} \ No newline at end of file diff --git a/docs/app.py b/docs/app.py new file mode 100644 index 0000000..a5ef4dd --- /dev/null +++ b/docs/app.py @@ -0,0 +1,71 @@ +import httpx +from flask import Flask, Response + +from docs.views import changelog, endpoints, index, python, quickstart + +app = Flask(__name__, static_folder="static") + +app.add_url_rule("/", "index", index.view) +app.add_url_rule("/quickstart", "quickstart", quickstart.view) +app.add_url_rule("/python", "python", python.view) +app.add_url_rule("/endpoints", "endpoints", endpoints.view) +app.add_url_rule("/changelog", "changelog", changelog.view) + +app.add_url_rule("/_/index", "index_fragment", index.fragment_view) +app.add_url_rule("/_/quickstart", "quickstart_fragment", quickstart.fragment_view) +app.add_url_rule("/_/python", "python_fragment", python.fragment_view) +app.add_url_rule("/_/endpoints", "endpoints_fragment", endpoints.fragment_view) +app.add_url_rule("/_/changelog", "changelog_fragment", changelog.fragment_view) + + +def endpoints_body(): + from docs.components import endpoints as ep_c + + spec = httpx.get("https://blocket-api.se/swagger/openapi.json", timeout=10).json() + return str(ep_c.render(spec)) + + +app.add_url_rule("/_/endpoints-body", "endpoints_body", endpoints_body) + + +def robots_txt(): + body = ( + "User-agent: *\n" + "Allow: /\n" + "Disallow: /_/\n" + "\n" + "Sitemap: https://blocket-api.se/sitemap.xml\n" + ) + return Response(body, mimetype="text/plain") + + +app.add_url_rule("/robots.txt", "robots_txt", robots_txt) + + +def sitemap_xml(): + from docs.components_base import SITE_ROOT + + pages = ["", "/quickstart", "/python", "/endpoints", "/changelog"] + urls = "\n".join(f" {SITE_ROOT}{p}" for p in pages) + body = f'\n\n{urls}\n\n' + return Response(body, mimetype="application/xml") + + +app.add_url_rule("/sitemap.xml", "sitemap_xml", sitemap_xml) + + +def changelog_body(): + from docs.components import changelog as cl_c + + prs = httpx.get( + "https://api.github.com/repos/dunderrrrrr/blocket_api/pulls", + params={"state": "closed", "per_page": 100}, + timeout=10, + ).json() + return str(cl_c.render(prs)) + + +app.add_url_rule("/_/changelog-body", "changelog_body", changelog_body) + +if __name__ == "__main__": + app.run(debug=True) diff --git a/docs/components/__init__.py b/docs/components/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/docs/components/changelog.py b/docs/components/changelog.py new file mode 100644 index 0000000..c131164 --- /dev/null +++ b/docs/components/changelog.py @@ -0,0 +1,87 @@ +import re +from datetime import datetime + +import htpy as h + + +_BADGE_CLASS = { + "feat": "badge-feat", + "fix": "badge-fix", + "refactor": "badge-refactor", + "docs": "badge-docs", + "perf": "badge-perf", + "chore": "badge-refactor", +} + +_SKIP_LABELS = {"dependencies"} + + +def _parse_type(title: str) -> tuple[str, str]: + """Return (kind, clean_title) from a conventional-commit PR title.""" + m = re.match(r"^(\w+)(?:\([^)]+\))?[!]?:\s*(.+)$", title) + if m: + return m.group(1).lower(), m.group(2).strip() + return "feat", title.strip() + + +def _badge(kind: str): + cls = _BADGE_CLASS.get(kind, "badge-feat") + return h.span(f".badge.{cls}")[kind] + + +def _pr_row(pr: dict): + kind, title = _parse_type(pr["title"]) + date = datetime.fromisoformat(pr["merged_at"].replace("Z", "+00:00")) + user = pr.get("user", {}) + first_body_line = (pr.get("body") or "").strip().split("\n")[0].strip() + return h.div(".changelog-entry")[ + h.div(".changelog-line")[h.div(".changelog-dot")], + h.div(".changelog-body")[ + h.div(".changelog-meta")[ + _badge(kind), + h.span(".changelog-date")[date.strftime("%Y-%m-%d")], + h.a(".changelog-ref", href=pr["html_url"], target="_blank")[ + f"#{pr['number']} ↗" + ], + h.a( + ".changelog-author", href=user.get("html_url", "#"), target="_blank" + )[ + h.img( + ".changelog-avatar", + src=user.get("avatar_url", ""), + alt=user.get("login", ""), + ), + user.get("login", ""), + ], + ], + h.p(".changelog-title")[title], + h.p(".changelog-desc")[first_body_line] if first_body_line else "", + ], + ] + + +def render(prs: list): + merged = [ + pr + for pr in prs + if pr.get("merged_at") + and not any(lbl["name"] in _SKIP_LABELS for lbl in pr.get("labels", [])) + ] + + if not merged: + return h.p["No releases found."] + + by_year: dict[int, list] = {} + for pr in merged: + year = datetime.fromisoformat(pr["merged_at"].replace("Z", "+00:00")).year + by_year.setdefault(year, []).append(pr) + + sections = [ + h.div(".changelog-year-group")[ + h.h3(".changelog-year")[str(year)], + h.div(".changelog-timeline")[[_pr_row(pr) for pr in prs]], + ] + for year, prs in sorted(by_year.items(), reverse=True) + ] + + return h.div[sections] diff --git a/docs/components/endpoints.py b/docs/components/endpoints.py new file mode 100644 index 0000000..a3fee06 --- /dev/null +++ b/docs/components/endpoints.py @@ -0,0 +1,144 @@ +import htpy as h + +from docs.components_base import SWAGGER, sidebar_link, sidebar_section + + +def _resolve(schema: dict, schemas: dict) -> dict: + if not schema: + return {} + if "$ref" in schema: + return schemas.get(schema["$ref"].split("/")[-1], {}) + if "anyOf" in schema: + for s in schema["anyOf"]: + if s.get("type") != "null": + return _resolve(s, schemas) + return schema + + +def _type_label(schema: dict, schemas: dict) -> str: + s = _resolve(schema, schemas) + if s.get("type") == "array": + inner = _resolve(s.get("items", {}), schemas) + if inner.get("enum"): + return "array[enum]" + return f"array[{inner.get('type', 'string')}]" + return s.get("type", "string") + + +def _enum_values(schema: dict, schemas: dict) -> list: + s = _resolve(schema, schemas) + if s.get("enum"): + return s["enum"] + if s.get("type") == "array": + inner = _resolve(s.get("items", {}), schemas) + return inner.get("enum", []) + return [] + + +def _param_row(p: dict, schemas: dict): + schema = p.get("schema", {}) + required = p.get("required", False) + type_label = _type_label(schema, schemas) + default = _resolve(schema, schemas).get("default", "") + enums = _enum_values(schema, schemas) + + return h.tr[ + h.td[h.code[p["name"]]], + h.td[ + h.span(".param-required" if required else ".param-optional")[ + "required" if required else "optional" + ] + ], + h.td(".type-code")[type_label], + h.td[h.code(".default-code")[str(default)] if default != "" else "—"], + h.td(".ep-enum-cell")[[h.code[v] for v in enums] if enums else "—"], + ] + + +def _slugify(path: str) -> str: + return path.replace("/", "-").replace("{", "").replace("}", "").lstrip("-") + + +def _endpoint_block(path: str, op: dict, schemas: dict): + params = op.get("parameters", []) + slug = _slugify(path) + required_params = [p for p in params if p.get("required")] + qs = "&".join(f"{p['name']}=VALUE" for p in required_params) + curl_url = f"https://blocket-api.se{path}" + (f"?{qs}" if qs else "") + + return h.div(".ep-block", id=slug)[ + h.div(".ep-block-header")[ + h.span(".method-badge")["GET"], + h.code(".ep-path")[path], + ], + h.p(".ep-summary")[op.get("summary", "")], + h.div(".endpoint-table")[ + h.table[ + h.thead[ + h.tr[ + h.th["Parameter"], + h.th["Required"], + h.th["Type"], + h.th["Default"], + h.th["Values"], + ] + ], + h.tbody[ + [_param_row(p, schemas) for p in params] + if params + else h.tr[h.td(colspan="5")["No parameters"]] + ], + ] + ], + h.div(".ep-curl")[ + h.span(".install-prompt")["$"], + h.code[f'curl "{curl_url}"'], + ], + ] + + +def _sidebar(paths: dict): + links = [ + sidebar_link( + op.get("summary", path), + f"#{_slugify(path)}", + ) + for path, methods in paths.items() + for op in [methods.get("get", next(iter(methods.values())))] + ] + return [ + sidebar_section("Endpoints", links), + sidebar_section( + "More", + [ + sidebar_link("Quickstart", "/quickstart"), + sidebar_link("Examples", "/examples"), + sidebar_link("Swagger ↗", SWAGGER), + ], + ), + ] + + +def render(spec: dict): + schemas = spec.get("components", {}).get("schemas", {}) + paths = spec.get("paths", {}) + + endpoint_blocks = [ + _endpoint_block(path, methods.get("get", next(iter(methods.values()))), schemas) + for path, methods in paths.items() + ] + + swagger_tip = h.div(".ep-swagger-tip")[ + "Autogenerated from ", + h.a(href=f"{SWAGGER}/openapi.json", target="_blank")["openapi.json"], + ". Full interactive docs at ", + h.a(href=SWAGGER, target="_blank")["Swagger ↗"], + ".", + ] + + return h.div(style="max-width:1200px;margin:0 auto;")[ + h.div(".doc-layout")[ + h.aside(".sidebar")[_sidebar(paths)], + h.main[swagger_tip, endpoint_blocks], + ] + ] diff --git a/docs/components/examples.py b/docs/components/examples.py new file mode 100644 index 0000000..d3b8070 --- /dev/null +++ b/docs/components/examples.py @@ -0,0 +1,681 @@ +import htpy as h + +from docs.components_base import API_BASE, code_block, code_tabs, ic, tip_box + + +def example_card(id_: str, icon: str, title: str, desc: str, *blocks): + return h.div(".example-full-card", id=id_)[ + h.div(".example-card-header")[ + h.div(".example-card-icon")[icon], + h.div[h.h3[title], h.p[desc]], + ], + h.div(".example-card-body")[blocks], + ] + + +# ── Python snippets ─────────────────────────────────────────────────────────── + + +def general_py(): + return code_block( + "general_search.py", + "PY", + [ + h.span(".kw")["from"], + " blocket_api ", + h.span(".kw")["import"], + " ", + h.span(".cls")["BlocketAPI"], + "\n\n", + "api ", + h.span(".op")["="], + " ", + h.span(".cls")["BlocketAPI"], + "()\n", + "results ", + h.span(".op")["="], + " api.", + h.span(".fn")["search"], + "(", + h.span(".str")['"Tamagotchi"'], + ")\n\n", + h.span(".kw")["for"], + " item ", + h.span(".kw")["in"], + " results[", + h.span(".str")['"docs"'], + "]:\n", + " price ", + h.span(".op")["="], + " item.", + h.span(".fn")["get"], + "(", + h.span(".str")['"price"'], + ", {}).", + h.span(".fn")["get"], + "(", + h.span(".str")['"amount"'], + ", ", + h.span(".str")['"N/A"'], + ")\n", + " ", + h.span(".fn")["print"], + '(f"{item[', + h.span(".str")['"heading"'], + ']} — {price} SEK")', + ], + ) + + +def general_curl(): + return code_block( + "terminal", + "SH", + [ + f'curl "{API_BASE}/v1/search?query=Tamagotchi&sort_order=PRICE_ASC"', + ], + ) + + +def car_py(): + return code_block( + "car_filter.py", + "PY", + [ + h.span(".kw")["from"], + " blocket_api ", + h.span(".kw")["import"], + " (\n", + " ", + h.span(".cls")["BlocketAPI"], + ", ", + h.span(".cls")["CarModel"], + ", ", + h.span(".cls")["CarSortOrder"], + ",\n", + " ", + h.span(".cls")["CarTransmission"], + ", ", + h.span(".cls")["CarWheelDrive"], + ", ", + h.span(".cls")["Location"], + ",\n)\n\n", + "api ", + h.span(".op")["="], + " ", + h.span(".cls")["BlocketAPI"], + "()\n", + "cars ", + h.span(".op")["="], + " api.", + h.span(".fn")["search_car"], + "(\n", + " models", + h.span(".op")["="], + "[", + h.span(".cls")["CarModel"], + ".", + h.span(".var")["VOLVO"], + "],\n", + " locations", + h.span(".op")["="], + "[", + h.span(".cls")["Location"], + ".", + h.span(".var")["STOCKHOLM"], + "],\n", + " transmissions", + h.span(".op")["="], + "[", + h.span(".cls")["CarTransmission"], + ".", + h.span(".var")["AUTOMATIC"], + "],\n", + " wheel_drive", + h.span(".op")["="], + "[", + h.span(".cls")["CarWheelDrive"], + ".", + h.span(".var")["AWD"], + "],\n", + " sort_order", + h.span(".op")["="], + h.span(".cls")["CarSortOrder"], + ".", + h.span(".var")["PRICE_ASC"], + ",\n", + " price_to", + h.span(".op")["="], + h.span(".num")["200000"], + ",\n", + " year_from", + h.span(".op")["="], + h.span(".num")["2018"], + ",\n)\n\n", + h.span(".fn")["print"], + '(f"Found {len(cars[', + h.span(".str")['"docs"'], + '])} Volvos")', + ], + ) + + +def car_curl(): + return code_block( + "terminal", + "SH", + [ + f'curl "{API_BASE}/v1/search/car?models=VOLVO&locations=STOCKHOLM', + "&transmissions=AUTOMATIC&wheel_drive=AWD", + '&price_to=200000&year_from=2018&sort_order=PRICE_ASC"', + ], + ) + + +def alert_py(): + return code_block( + "price_alert.py", + "PY", + [ + h.span(".cmt")["# /// script"], + "\n", + h.span(".cmt")['# requires-python = ">=3.10"'], + "\n", + h.span(".cmt")['# dependencies = ["httpx"]'], + "\n", + h.span(".cmt")["# ///"], + "\n\n", + h.span(".kw")["import"], + " httpx, json\n", + h.span(".kw")["from"], + " pathlib ", + h.span(".kw")["import"], + " Path\n\n", + "QUERY, MAX_PRICE ", + h.span(".op")["="], + " ", + h.span(".str")['"Sony WH-1000XM5"'], + ", ", + h.span(".num")["1800"], + "\n", + "SEEN ", + h.span(".op")["="], + " Path(", + h.span(".str")['"seen.json"'], + ")\n", + "seen ", + h.span(".op")["="], + " ", + h.span(".fn")["set"], + "(json.", + h.span(".fn")["loads"], + "(SEEN.", + h.span(".fn")["read_text"], + "()) ", + h.span(".kw")["if"], + " SEEN.", + h.span(".fn")["exists"], + "() ", + h.span(".kw")["else"], + " [])\n\n", + "docs ", + h.span(".op")["="], + " httpx.", + h.span(".fn")["get"], + "(\n", + " ", + h.span(".str")[f'"{API_BASE}/v1/search"'], + ",\n", + " params", + h.span(".op")["="], + "{", + h.span(".str")['"query"'], + ": QUERY, ", + h.span(".str")['"sort_order"'], + ": ", + h.span(".str")['"PRICE_ASC"'], + "},\n", + ").", + h.span(".fn")["json"], + "()[", + h.span(".str")['"docs"'], + "]\n\n", + h.span(".kw")["for"], + " d ", + h.span(".kw")["in"], + " docs:\n", + " price ", + h.span(".op")["="], + " d.", + h.span(".fn")["get"], + "(", + h.span(".str")['"price"'], + ", {}).", + h.span(".fn")["get"], + "(", + h.span(".str")['"amount"'], + ", ", + h.span(".num")["9e9"], + ")\n", + " ", + h.span(".kw")["if"], + " price ", + h.span(".op")["<="], + " MAX_PRICE ", + h.span(".kw")["and"], + " d[", + h.span(".str")['"id"'], + "] ", + h.span(".kw")["not in"], + " seen:\n", + " ", + h.span(".fn")["print"], + "(f\"🔔 {d['heading']} — {price} SEK\")\n", + " seen.", + h.span(".fn")["add"], + "(d[", + h.span(".str")['"id"'], + "])\n\n", + "SEEN.", + h.span(".fn")["write_text"], + "(json.", + h.span(".fn")["dumps"], + "(", + h.span(".fn")["list"], + "(seen)))\n\n", + h.span(".cmt")["# */15 * * * * uv run price_alert.py"], + ], + ) + + +def alert_curl(): + return code_block( + "terminal", + "SH", + [ + h.span(".cmt")["# Poll the REST API directly — no Python needed"], + "\n", + f'curl "{API_BASE}/v1/search?query=Sony+WH-1000XM5&sort_order=PRICE_ASC"', + h.span(".cmt")["\n# pipe to jq to filter by price:\n"], + f'curl -s "{API_BASE}/v1/search?query=Sony+WH-1000XM5&sort_order=PRICE_ASC"', + " | jq '.docs[] | select(.price.amount <= 1800)'", + ], + ) + + +def alert_run(): + return code_block( + "terminal", + "SH", + [ + "uv run price_alert.py\n", + h.span(".cmt")["# 🔔 Sony WH-1000XM5 Like New — 1 750 SEK"], + ], + ) + + +def csv_py(): + return code_block( + "to_csv.py", + "PY", + [ + h.span(".cmt")["# /// script"], + "\n", + h.span(".cmt")['# dependencies = ["blocket-api", "pandas"]'], + "\n", + h.span(".cmt")["# ///"], + "\n\n", + h.span(".kw")["import"], + " pandas ", + h.span(".kw")["as"], + " pd\n", + h.span(".kw")["from"], + " blocket_api ", + h.span(".kw")["import"], + " ", + h.span(".cls")["BlocketAPI"], + ", ", + h.span(".cls")["CarModel"], + ", ", + h.span(".cls")["CarSortOrder"], + "\n\n", + "api ", + h.span(".op")["="], + " ", + h.span(".cls")["BlocketAPI"], + "()\n", + "raw ", + h.span(".op")["="], + " api.", + h.span(".fn")["search_car"], + "(\n", + " models", + h.span(".op")["="], + "[", + h.span(".cls")["CarModel"], + ".", + h.span(".var")["TESLA"], + "],\n", + " sort_order", + h.span(".op")["="], + h.span(".cls")["CarSortOrder"], + ".", + h.span(".var")["PRICE_ASC"], + ",\n)\n\n", + "rows ", + h.span(".op")["="], + " [{", + h.span(".str")['"heading"'], + ": d[", + h.span(".str")['"heading"'], + "], ", + h.span(".str")['"price"'], + ": d.", + h.span(".fn")["get"], + "(", + h.span(".str")['"price"'], + ", {}).", + h.span(".fn")["get"], + "(", + h.span(".str")['"amount"'], + ")} ", + h.span(".kw")["for"], + " d ", + h.span(".kw")["in"], + " raw[", + h.span(".str")['"docs"'], + "]]\n\n", + "df ", + h.span(".op")["="], + " pd.", + h.span(".cls")["DataFrame"], + "(rows)\n", + "df.", + h.span(".fn")["to_csv"], + "(", + h.span(".str")['"tesla_prices.csv"'], + ", index", + h.span(".op")["="], + h.span(".kw")["False"], + ")\n", + h.span(".fn")["print"], + "(df.", + h.span(".fn")["describe"], + "()[", + h.span(".str")['"price"'], + "])", + ], + ) + + +def csv_curl(): + return code_block( + "terminal", + "SH", + [ + h.span(".cmt")["# fetch JSON and convert to CSV with jq + awk"], + "\n", + f'curl -s "{API_BASE}/v1/search/car?models=TESLA&sort_order=PRICE_ASC"', + " \\\n | jq -r '.docs[] | [.heading, (.price.amount // \"\")] | @csv'", + ], + ) + + +def paginate_py(): + return code_block( + "paginate.py", + "PY", + [ + h.span(".kw")["from"], + " blocket_api ", + h.span(".kw")["import"], + " ", + h.span(".cls")["BlocketAPI"], + "\n\n", + "api ", + h.span(".op")["="], + " ", + h.span(".cls")["BlocketAPI"], + "()\n", + "all_results ", + h.span(".op")["="], + " []\n", + h.span(".kw")["for"], + " page ", + h.span(".kw")["in"], + " ", + h.span(".fn")["range"], + "(", + h.span(".num")["5"], + "):\n", + " batch ", + h.span(".op")["="], + " api.", + h.span(".fn")["search"], + "(", + h.span(".str")['"MacBook"'], + ", page", + h.span(".op")["="], + "page)[", + h.span(".str")['"docs"'], + "]\n", + " ", + h.span(".kw")["if"], + " ", + h.span(".kw")["not"], + " batch:\n ", + h.span(".kw")["break"], + "\n", + " all_results.", + h.span(".fn")["extend"], + "(batch)\n\n", + h.span(".fn")["print"], + '(f"Fetched {len(all_results)} total listings")', + ], + ) + + +def paginate_curl(): + return code_block( + "terminal", + "SH", + [ + h.span(".cmt")["# page parameter (1-based)"], + "\n", + f'curl "{API_BASE}/v1/search?query=MacBook&page=1"\n', + f'curl "{API_BASE}/v1/search?query=MacBook&page=2"\n', + f'curl "{API_BASE}/v1/search?query=MacBook&page=3"', + ], + ) + + +def dealer_py(): + return code_block( + "dealer_stock.py", + "PY", + [ + h.span(".kw")["from"], + " blocket_api ", + h.span(".kw")["import"], + " ", + h.span(".cls")["BlocketAPI"], + ", ", + h.span(".cls")["CarSortOrder"], + "\n\n", + "api ", + h.span(".op")["="], + " ", + h.span(".cls")["BlocketAPI"], + "()\n", + "dealer_cars ", + h.span(".op")["="], + " api.", + h.span(".fn")["search_car"], + "(\n", + " org_id", + h.span(".op")["="], + h.span(".str")['"12345"'], + ",\n", + " sort_order", + h.span(".op")["="], + h.span(".cls")["CarSortOrder"], + ".", + h.span(".var")["PRICE_ASC"], + ",\n)\n\n", + h.span(".fn")["print"], + '(f"Dealer has {len(dealer_cars[', + h.span(".str")['"docs"'], + '])} cars listed")', + ], + ) + + +def dealer_curl(): + return code_block( + "terminal", + "SH", + [ + f'curl "{API_BASE}/v1/search/car?org_id=12345&sort_order=PRICE_ASC"', + ], + ) + + +def boat_py(): + return code_block( + "boat_search.py", + "PY", + [ + h.span(".kw")["from"], + " blocket_api ", + h.span(".kw")["import"], + " ", + h.span(".cls")["BlocketAPI"], + ", ", + h.span(".cls")["Location"], + "\n\n", + "api ", + h.span(".op")["="], + " ", + h.span(".cls")["BlocketAPI"], + "()\n", + "boats ", + h.span(".op")["="], + " api.", + h.span(".fn")["search_boat"], + "(\n", + " locations", + h.span(".op")["="], + "[", + h.span(".cls")["Location"], + ".", + h.span(".var")["STOCKHOLM"], + "],\n", + " price_to", + h.span(".op")["="], + h.span(".num")["200000"], + ",\n)\n\n", + h.span(".kw")["for"], + " boat ", + h.span(".kw")["in"], + " boats[", + h.span(".str")['"docs"'], + "][:5]:\n", + " ", + h.span(".fn")["print"], + "(boat[", + h.span(".str")['"heading"'], + "], boat.", + h.span(".fn")["get"], + "(", + h.span(".str")['"description"'], + ", ", + h.span(".str")['"—"'], + ")[:80])", + ], + ) + + +def boat_curl(): + return code_block( + "terminal", + "SH", + [ + f'curl "{API_BASE}/v1/search/boat?locations=STOCKHOLM&price_to=200000"', + ], + ) + + +# ── Assembled cards ─────────────────────────────────────────────────────────── + + +def cards(): + return [ + example_card( + "general", + "🔍", + "General keyword search", + "Search across all Blocket categories by keyword — electronics, furniture, clothing, and more.", + code_tabs(("Python", general_py()), ("cURL", general_curl())), + ), + example_card( + "car", + "🚗", + "Car search with filters", + "Narrow down vehicles by model, transmission, wheel drive, price range, and year.", + code_tabs(("Python", car_py()), ("cURL", car_curl())), + ), + example_card( + "alert", + "🔔", + "Price alert script", + "Monitor Blocket for deals below your target price. Remembers seen ads so you only get notified once.", + code_tabs(("Python", alert_py()), ("cURL", alert_curl())), + alert_run(), + tip_box( + "⚡", + [ + "Run with ", + ic("uv run price_alert.py"), + " — no virtualenv or install step needed.", + ], + kind="tip", + ), + ), + example_card( + "csv", + "📊", + "Export to CSV with pandas", + "Pull search results into a DataFrame and export for analysis or charting.", + code_tabs(("Python", csv_py()), ("cURL", csv_curl())), + code_block("terminal", "SH", ["uv run to_csv.py"]), + ), + example_card( + "paginate", + "📄", + "Paginate results", + "Fetch more than 40 results by iterating over pages — useful for complete market scans.", + code_tabs(("Python", paginate_py()), ("cURL", paginate_curl())), + ), + example_card( + "dealer", + "🏢", + "Filter by dealer (org_id)", + "Narrow results to a specific dealership — useful for monitoring a dealer's stock.", + code_tabs(("Python", dealer_py()), ("cURL", dealer_curl())), + tip_box( + "💡", + [ + "Find an ", + ic("org_id"), + " by inspecting any dealer listing on Blocket.se — it's in the seller section.", + ], + ), + ), + example_card( + "boat", + "⛵", + "Boat search", + "Search for boats by location and price.", + code_tabs(("Python", boat_py()), ("cURL", boat_curl())), + ), + ] diff --git a/docs/components/index.py b/docs/components/index.py new file mode 100644 index 0000000..9df4e87 --- /dev/null +++ b/docs/components/index.py @@ -0,0 +1,477 @@ +import htpy as h +from flask import url_for + +from docs.components_base import ( + API_BASE, + GITHUB, + SWAGGER, + _htmx, + code_block, + code_tabs, + ic, +) + + +def pill(text: str): + return h.span(".pill")[h.span(".dot"), text] + + +def install_body(id_: str, cmd_id: str, cmd: str, active: bool = False): + return h.div( + ".install-body.active" if active else ".install-body", + id=id_, + )[ + h.div(".install-cmd")[ + h.span(".install-prompt")["$"], + h.code(id=cmd_id)[cmd], + h.button(".copy-btn", onclick=f"copyCmd('{cmd_id}',this)")["Copy"], + ], + ] + + +def install_block(): + return h.div(".install-block")[ + h.div(".install-tabs")[ + h.button(".install-tab.active", onclick="switchInstall(this,'uv')")["uv"], + h.button(".install-tab", onclick="switchInstall(this,'pip')")["pip"], + h.button(".install-tab", onclick="switchInstall(this,'curl')")["cURL"], + ], + install_body( + "inst-uv", + "cmd-uv", + "uv add blocket-api", + active=True, + ), + install_body("inst-pip", "cmd-pip", "pip install blocket-api"), + install_body( + "inst-curl", + "cmd-curl", + f'curl "{API_BASE}/v1/search?query=iPhone"', + ), + ] + + +# ── Code snippets ───────────────────────────────────────────────────────────── + + +def hero_code(): + return code_block( + "search.py", + "PY", + """\ +from blocket_api import BlocketAPI + +api = BlocketAPI() +results = api.search("iPhone 15") + +for item in results["docs"]: + print( + item["heading"], + item["price"]["amount"], + item["location"]["name"], + )""", + ) + + +def hero_response(): + return code_block( + "output", + "JSON", + """\ +{ + "docs": [ + { + "heading": "iPhone 15 Pro 256GB", + "price": {"amount": 8500}, + "location": {"name": "Stockholm"} + } + ] +}""", + ) + + +def car_code(): + return code_block( + "cars.py", + "PY", + """\ +from blocket_api import ( + BlocketAPI, CarModel, + CarSortOrder, Location, +) + +cars = BlocketAPI().search_car( + models=[CarModel.VOLVO], + locations=[Location.STOCKHOLM], + sort_order=CarSortOrder.PRICE_ASC, + price_to=80000, + year_from=2018, +)""", + ) + + +def alert_code(): + return code_block( + "alert.py", + "PY", + f"""\ +# /// script +# dependencies = ["httpx"] +# /// + +import httpx, json +from pathlib import Path + +SEEN = Path("seen.json") +seen = set(json.loads(SEEN.read_text()) if SEEN.exists() else []) + +docs = httpx.get( + "{API_BASE}/v1/search", + params={{"query": "Sony WH-1000XM5", "sort_order": "PRICE_ASC"}}, +).json()["docs"] + +for d in docs: + if d["price"]["amount"] <= 1800 and d["id"] not in seen: + print(f\"🔔 {{d['heading']}} — {{d['price']['amount']}} SEK\") + seen.add(d["id"]) + +SEEN.write_text(json.dumps(list(seen)))""", + ) + + +def uv_inline_code(): + return code_block( + "blocket.py", + "PY", + """\ +# /// script +# requires-python = ">=3.10" +# dependencies = ["blocket-api"] +# /// + +from blocket_api import BlocketAPI + +api = BlocketAPI() +results = api.search("Lego Technic") +print(results["docs"][:5])""", + ) + + +def uv_run_code(): + return code_block("terminal", "SH", "uv run blocket.py") + + +# ── Page sections ───────────────────────────────────────────────────────────── + + +def hero(): + return h.div(".hero-wrapper")[ + h.div(".hero-center")[ + h.div(".hero-title")[ + h.span(".hero-comment")["// "], + h.span(".hero-wordmark")["BlocketAPI"], + ], + h.p(".hero-subtitle")[ + "The unofficial API for ", + h.span(".hero-accent")["blocket.se"], + ], + h.p(".hero-desc")["Search cars, boats, motorcycles and general listings.",], + install_block(), + h.div(".hero-links")[ + h.a(href=url_for("quickstart"), **_htmx("quickstart"))["Quickstart →"], + h.a(href=SWAGGER, target="_blank")["Swagger ↗"], + h.a(href=GITHUB, target="_blank")["GitHub ↗"], + ], + ], + ] + + +def search_boat_code(): + return code_block( + "boats.py", + "PY", + """\ +from blocket_api import BlocketAPI, Location + +api = BlocketAPI() +boats = api.search_boat( + locations=[Location.STOCKHOLM], + price_to=150000, +) + +for boat in boats["docs"]: + print(boat["heading"], boat["price"]["amount"])""", + ) + + +def search_mc_code(): + return code_block( + "mc.py", + "PY", + """\ +from blocket_api import BlocketAPI, Location, McSortOrder + +api = BlocketAPI() +bikes = api.search_mc( + locations=[Location.STOCKHOLM], + sort_order=McSortOrder.PRICE_ASC, + price_to=50000, +) + +for bike in bikes["docs"]: + print(bike["heading"], bike["price"]["amount"])""", + ) + + +def examples_section(): + return h.section(".section.section-alt")[ + h.div(".container")[ + h.div(".section-header")[h.h2["Common patterns"],], + h.div(".examples-tabs")[ + h.div(".examples-tab-bar")[ + h.button( + ".ex-tab.active", onclick="switchExTab(this,'ext-general')" + )["General search"], + h.button(".ex-tab", onclick="switchExTab(this,'ext-car')")["Cars"], + h.button(".ex-tab", onclick="switchExTab(this,'ext-boat')")[ + "Boats" + ], + h.button(".ex-tab", onclick="switchExTab(this,'ext-mc')")[ + "Motorcycles" + ], + h.button(".ex-tab", onclick="switchExTab(this,'ext-alert')")[ + "Price alert" + ], + ], + h.div(".ex-tab-panel.active", id="ext-general")[ + code_tabs( + ("Python", hero_code()), + ( + "cURL", + code_block( + "terminal", + "SH", + f'curl "{API_BASE}/v1/search?query=iPhone+15"', + ), + ), + ), + h.p(".example-panel-note")[ + h.a(".link", href=url_for("quickstart"), **_htmx("quickstart"))[ + "Quickstart →" + ] + ], + ], + h.div(".ex-tab-panel", id="ext-car")[ + code_tabs( + ("Python", car_code()), + ( + "cURL", + code_block( + "terminal", + "SH", + f'curl "{API_BASE}/v1/search/car?models=VOLVO&locations=STOCKHOLM&price_to=80000&year_from=2018&sort_order=PRICE_ASC"', + ), + ), + ), + h.p(".example-panel-note")[ + "10+ filters: model, year, transmission, wheel drive, mileage. ", + h.a( + ".link", + href=url_for("endpoints") + "#search-car", + **_htmx("endpoints"), + )["All parameters →"], + ], + ], + h.div(".ex-tab-panel", id="ext-boat")[ + code_tabs( + ("Python", search_boat_code()), + ( + "cURL", + code_block( + "terminal", + "SH", + f'curl "{API_BASE}/v1/search/boat?locations=STOCKHOLM&price_to=150000"', + ), + ), + ), + h.p(".example-panel-note")[ + h.a( + ".link", + href=url_for("endpoints") + "#search-boat", + **_htmx("endpoints"), + )["All parameters →"] + ], + ], + h.div(".ex-tab-panel", id="ext-mc")[ + code_tabs( + ("Python", search_mc_code()), + ( + "cURL", + code_block( + "terminal", + "SH", + f'curl "{API_BASE}/v1/search/mc?locations=STOCKHOLM&price_to=50000&sort_order=PRICE_ASC"', + ), + ), + ), + h.p(".example-panel-note")[ + h.a( + ".link", + href=url_for("endpoints") + "#search-mc", + **_htmx("endpoints"), + )["All parameters →"] + ], + ], + h.div(".ex-tab-panel", id="ext-alert")[ + code_tabs( + ("Python", alert_code()), + ( + "cURL", + code_block( + "terminal", + "SH", + f"curl -s \"{API_BASE}/v1/search?query=Sony+WH-1000XM5&sort_order=PRICE_ASC\" \\\n | jq '.docs[] | select(.price.amount <= 1800)'", + ), + ), + ), + h.p(".example-panel-note")[ + h.a(".link", href=url_for("quickstart"), **_htmx("quickstart"))[ + "Quickstart →" + ] + ], + ], + ], + ], + ] + + +def feature(icon: str, title: str, desc: str): + return h.div(".feature-cell")[ + h.div(".feature-icon")[icon], + h.div[ + h.h3(".feature-title")[title], + h.p(".feature-desc")[desc], + ], + ] + + +def features(): + return h.section(".section.section-alt")[ + h.div(".container")[ + h.div(".section-header")[ + h.span(".section-label")["What you get"], + h.h2["Built for automation"], + ], + h.div(".feature-grid")[ + feature( + "🔌", + "REST API", + f"GET {API_BASE}/v1/search — works from any language, any HTTP client.", + ), + feature( + "🐍", + "Python library", + "Typed enums for models, locations, sort orders. No string guessing.", + ), + feature( + "🚗", + "Vehicle search", + "Cars, motorcycles, and boats each have dedicated endpoints with rich filters.", + ), + feature( + "📄", + "Consistent JSON", + "Every endpoint returns docs[], total, query. Predictable, easy to parse.", + ), + feature( + "⚡", + "No auth overhead", + "No tokens to refresh, no rate-limit headers to track, no signup flow.", + ), + feature( + "📦", + "uv compatible", + "Inline script metadata — ship a single .py file with zero setup.", + ), + ], + ], + ] + + +def uv_section(): + return h.section(".section")[ + h.div(".container")[ + h.div(".uv-grid")[ + h.div[ + h.span(".section-label")["Zero setup"], + h.h2(".uv-heading")[ + "One file. ", + h.span(".gradient-text")["No venv."], + ], + h.p(".uv-desc")[ + "Add a ", + ic("# /// script"), + " block and ", + ic("uv run"), + " handles dependencies automatically. No ", + ic("pyproject.toml"), + ", no activation, no friction.", + ], + h.div(".check-list")[ + h.div(".check-item")[ + h.span(".check")["✓"], " Deps install in milliseconds" + ], + h.div(".check-item")[ + h.span(".check")["✓"], " Single file, shareable anywhere" + ], + h.div(".check-item")[ + h.span(".check")["✓"], " Perfect for cron jobs" + ], + ], + h.div(".btn-row")[ + h.a( + ".btn.btn-primary", + href=url_for("quickstart"), + **_htmx("quickstart"), + )["Quickstart →"], + h.a( + ".btn.btn-secondary", + href=url_for("endpoints"), + **_htmx("endpoints"), + )["Endpoints"], + ], + ], + h.div(".uv-code-stack")[ + uv_inline_code(), + uv_run_code(), + ], + ], + ], + ] + + +def cta(): + return h.section(".section.section-cta")[ + h.div(".container")[ + h.div(".cta-terminal")[ + h.span(".cmt")["$ "], + h.span(".install-prompt")["curl "], + h.span(".str")[f'"{API_BASE}/v1/search?query=iPhone+15"'], + h.span(".cmt")[" | python3 -m json.tool"], + ], + h.p(".cta-desc")["That's it. No account. No key. Just start."], + h.div(".hero-actions")[ + h.a( + ".btn.btn-primary", + href=url_for("quickstart"), + **_htmx("quickstart"), + )["Get started →"], + h.a( + ".btn.btn-secondary", + href=url_for("endpoints"), + **_htmx("endpoints"), + )["API reference"], + h.a(".btn.btn-secondary", href=GITHUB, target="_blank")["GitHub ↗"], + ], + ], + ] diff --git a/docs/components/python_lib.py b/docs/components/python_lib.py new file mode 100644 index 0000000..2bedf3e --- /dev/null +++ b/docs/components/python_lib.py @@ -0,0 +1,311 @@ +import htpy as h +from flask import url_for + +from docs.components_base import ( + API_BASE, + GITHUB, + PYPI, + code_block, + code_tabs, + ic, + sidebar_link, + sidebar_section, + tip_box, +) + + +def search_py(): + return code_block( + "search.py", + "PY", + """\ +from blocket_api import BlocketAPI, Location, SortOrder + +api = BlocketAPI() +results = api.search( + "iPhone 15", + sort_order=SortOrder.PRICE_ASC, + locations=[Location.STOCKHOLM], +) + +for item in results["docs"]: + print(item["heading"], item["price"]["amount"])""", + ) + + +def search_curl(): + return code_block( + "terminal", + "SH", + f'curl "{API_BASE}/v1/search?query=iPhone+15&sort_order=PRICE_ASC&locations=STOCKHOLM"', + ) + + +def search_car_py(): + return code_block( + "search_car.py", + "PY", + """\ +from blocket_api import ( + BlocketAPI, CarModel, CarSortOrder, + CarTransmission, Location, +) + +api = BlocketAPI() +cars = api.search_car( + models=[CarModel.VOLVO], + locations=[Location.STOCKHOLM], + transmissions=[CarTransmission.AUTOMATIC], + sort_order=CarSortOrder.PRICE_ASC, + price_to=200000, + year_from=2018, +) + +for car in cars["docs"]: + print(car["heading"], car["price"]["amount"], "SEK")""", + ) + + +def search_car_curl(): + return code_block( + "terminal", + "SH", + f'curl "{API_BASE}/v1/search/car?models=VOLVO&locations=STOCKHOLM&transmissions=AUTOMATIC&price_to=200000&year_from=2018&sort_order=PRICE_ASC"', + ) + + +def search_boat_py(): + return code_block( + "search_boat.py", + "PY", + """\ +from blocket_api import BlocketAPI, Location + +api = BlocketAPI() +boats = api.search_boat( + locations=[Location.STOCKHOLM], + price_to=150000, +) + +for boat in boats["docs"]: + print(boat["heading"], boat["price"]["amount"])""", + ) + + +def search_boat_curl(): + return code_block( + "terminal", + "SH", + f'curl "{API_BASE}/v1/search/boat?locations=STOCKHOLM&price_to=150000"', + ) + + +def search_mc_py(): + return code_block( + "search_mc.py", + "PY", + """\ +from blocket_api import BlocketAPI, Location, McSortOrder + +api = BlocketAPI() +bikes = api.search_mc( + locations=[Location.STOCKHOLM], + sort_order=McSortOrder.PRICE_ASC, + price_to=50000, +) + +for bike in bikes["docs"]: + print(bike["heading"], bike["price"]["amount"])""", + ) + + +def search_mc_curl(): + return code_block( + "terminal", + "SH", + f'curl "{API_BASE}/v1/search/mc?locations=STOCKHOLM&price_to=50000&sort_order=PRICE_ASC"', + ) + + +def get_ad_py(): + return code_block( + "get_ad.py", + "PY", + """\ +from blocket_api import BlocketAPI + +api = BlocketAPI() + +# first get a listing from any search +results = api.search_car(models=[...]) +ad_ref = results["docs"][0] + +# fetch full ad detail +detail = api.get_ad(ad_ref) +print(detail)""", + ) + + +def get_ad_curl(): + return code_block("terminal", "SH", f'curl "{API_BASE}/v1/ad/car?id=12345678"') + + +def method_card( + id_: str, signature: str, desc: str, params: list[tuple[str, str, str]], *blocks +): + return h.div(".example-full-card", id=id_)[ + h.div(".example-card-header")[ + h.div(".example-card-icon")[h.code(".ep-path")[signature]], + ], + h.div(".example-card-body")[ + h.p(".ep-summary")[desc], + h.div(".endpoint-table")[ + h.table[ + h.thead[h.tr[h.th["Parameter"], h.th["Type"], h.th["Notes"]]], + h.tbody[ + [ + h.tr[ + h.td[h.code[name]], + h.td(".type-code")[typ], + h.td[note], + ] + for name, typ, note in params + ] + ], + ] + ], + blocks, + ], + ] + + +def sidebar(): + return [ + sidebar_section( + "Methods", + [ + sidebar_link("api.search()", "#search", active=True), + sidebar_link("api.search_car()", "#search-car"), + sidebar_link("api.search_boat()", "#search-boat"), + sidebar_link("api.search_mc()", "#search-mc"), + sidebar_link("api.get_ad()", "#get-ad"), + ], + ), + sidebar_section( + "Links", + [ + sidebar_link("PyPI", PYPI), + sidebar_link("GitHub", GITHUB), + sidebar_link("Endpoints", url_for("endpoints")), + ], + ), + ] + + +def install_section(): + return [ + h.h2(id="install")["Installation"], + h.p["Install via ", ic("uv"), " or ", ic("pip"), ":"], + code_tabs( + ("uv", code_block("terminal", "SH", "uv add blocket-api")), + ("pip", code_block("terminal", "SH", "pip install blocket-api")), + ), + tip_box( + "📦", + [ + "Available on ", + h.a(".link", href=PYPI, target="_blank")["PyPI ↗"], + ". Source on ", + h.a(".link", href=GITHUB, target="_blank")["GitHub ↗"], + ".", + ], + ), + ] + + +def methods_section(): + return [ + h.h2(".section-h2", id="methods")["Methods"], + method_card( + "search", + "api.search(query, **kwargs)", + "Search all categories by keyword.", + [ + ("query", "str", "Search term (required)"), + ("sort_order", "SortOrder", "RELEVANCE, PRICE_ASC, PRICE_DESC, …"), + ("locations", "list[Location]", "Filter by county"), + ("page", "int", "Page number, default 1"), + ("category", "Category", "Top-level category filter"), + ], + code_tabs(("Python", search_py()), ("cURL", search_curl())), + ), + method_card( + "search-car", + "api.search_car(**kwargs)", + "Search used cars with vehicle-specific filters.", + [ + ("models", "list[CarModel]", "Make/model enum values"), + ("locations", "list[Location]", "Filter by county"), + ("price_from", "int", "Minimum price (SEK)"), + ("price_to", "int", "Maximum price (SEK)"), + ("year_from", "int", "Earliest model year"), + ("year_to", "int", "Latest model year"), + ("milage_from", "int", "Minimum mileage (km)"), + ("milage_to", "int", "Maximum mileage (km)"), + ("transmissions", "list[CarTransmission]", "AUTOMATIC or MANUAL"), + ("wheel_drive", "list[CarWheelDrive]", "FWD, RWD, AWD, …"), + ("colors", "list[CarColor]", "Exterior colour"), + ("horsepower_from", "int", "Min horsepower"), + ("horsepower_to", "int", "Max horsepower"), + ("org_id", "int", "Filter by dealer org ID"), + ("sort_order", "CarSortOrder", "PRICE_ASC, PRICE_DESC, …"), + ], + code_tabs(("Python", search_car_py()), ("cURL", search_car_curl())), + ), + method_card( + "search-boat", + "api.search_boat(**kwargs)", + "Search used boats.", + [ + ("types", "list[BoatType]", "Boat class (BOWRIDER, DAYCRUISER, …)"), + ("locations", "list[Location]", "Filter by county"), + ("price_from", "int", "Minimum price (SEK)"), + ("price_to", "int", "Maximum price (SEK)"), + ("length_from", "int", "Min length (feet)"), + ("length_to", "int", "Max length (feet)"), + ("org_id", "int", "Filter by dealer org ID"), + ("sort_order", "BoatSortOrder", "PRICE_ASC, LENGTH_DESC, …"), + ], + code_tabs(("Python", search_boat_py()), ("cURL", search_boat_curl())), + ), + method_card( + "search-mc", + "api.search_mc(**kwargs)", + "Search used motorcycles.", + [ + ("models", "list[McModel]", "Make/model enum values"), + ("types", "list[McType]", "ADVENTURE, CHOPPER, …"), + ("locations", "list[Location]", "Filter by county"), + ("price_from", "int", "Minimum price (SEK)"), + ("price_to", "int", "Maximum price (SEK)"), + ("engine_volume_from", "int", "Min engine volume (cc)"), + ("engine_volume_to", "int", "Max engine volume (cc)"), + ("org_id", "int", "Filter by dealer org ID"), + ("sort_order", "McSortOrder", "PRICE_ASC, MILAGE_DESC, …"), + ], + code_tabs(("Python", search_mc_py()), ("cURL", search_mc_curl())), + ), + method_card( + "get-ad", + "api.get_ad(ad)", + "Fetch full detail for a single listing returned from any search.", + [ + ( + "ad", + "RecommerceAd | CarAd | BoatAd | McAd", + "Ad object from a search result", + ), + ], + code_tabs(("Python", get_ad_py()), ("cURL", get_ad_curl())), + ), + ] diff --git a/docs/components/quickstart.py b/docs/components/quickstart.py new file mode 100644 index 0000000..6b47eee --- /dev/null +++ b/docs/components/quickstart.py @@ -0,0 +1,305 @@ +import htpy as h +from flask import url_for + +from docs.components_base import ( + API_BASE, + GITHUB, + PYPI, + SWAGGER, + _htmx, + code_block, + code_tabs, + ic, + sidebar_link, + sidebar_section, + tip_box, +) + + +def tab_btn(key: str, label: str, active: bool = False): + return h.button( + ".tab-btn.active" if active else ".tab-btn", + onclick=f"switchTab(this,'{key}')", + )[label] + + +def tab_panel(key: str, content, active: bool = False): + return h.div(".tab-panel.active" if active else ".tab-panel", id=key)[content] + + +# ── Code snippets ───────────────────────────────────────────────────────────── + + +def install_uv(): + return code_block( + "terminal", + "SH", + """\ +# install uv if you haven't already +curl -LsSf https://astral.sh/uv/install.sh | sh + +# add blocket-api to your project +uv add blocket-api""", + ) + + +def install_pip(): + return code_block("terminal", "SH", "pip install blocket-api") + + +def first_search_py(): + return code_block( + "hello_blocket.py", + "PY", + """\ +from blocket_api import BlocketAPI + +api = BlocketAPI() +results = api.search("iPhone 15") + +for item in results["docs"]: + print(item["heading"], "-", item["price"]["amount"], "SEK")""", + ) + + +def first_search_rest(): + return code_block( + "terminal", + "SH", + f"""\ +# GET {API_BASE}/v1/search +curl "{API_BASE}/v1/search?query=iPhone+15" | python3 -m json.tool""", + ) + + +def first_search_js(): + return code_block( + "search.js", + "JS", + f"""\ +const res = await fetch( + "{API_BASE}/v1/search?query=iPhone+15" +).then(r => r.json()); + +res.docs.forEach(item => {{ + console.log(item.heading, item.price?.amount); +}});""", + ) + + +def car_search(): + return code_block( + "car_search.py", + "PY", + """\ +from blocket_api import ( + BlocketAPI, CarModel, Location, CarSortOrder, +) + +api = BlocketAPI() +cars = api.search_car( + models=[CarModel.BMW], + locations=[Location.STOCKHOLM], + sort_order=CarSortOrder.PRICE_ASC, + price_to=150000, +) + +for car in cars["docs"]: + print(car["heading"], car["price"]["amount"])""", + ) + + +def uv_script(): + return code_block( + "my_search.py", + "PY", + """\ +# /// script +# requires-python = ">=3.10" +# dependencies = ["blocket-api"] +# /// + +from blocket_api import BlocketAPI + +api = BlocketAPI() +results = api.search("Lego Technic") +print(results["docs"][0])""", + ) + + +def uv_run(): + return code_block("terminal", "SH", "uv run my_search.py") + + +# ── Sidebar ─────────────────────────────────────────────────────────────────── + + +def sidebar(): + return [ + sidebar_section( + "Getting started", + [ + sidebar_link("Installation", "#installation", active=True), + sidebar_link("Your first search", "#first-search"), + sidebar_link("Run with uv", "#uv"), + ], + ), + sidebar_section( + "Reference", + [ + sidebar_link("All endpoints", url_for("endpoints")), + sidebar_link("Swagger docs", SWAGGER), + sidebar_link("PyPI", PYPI), + sidebar_link("GitHub", GITHUB), + ], + ), + ] + + +# ── Page sections ───────────────────────────────────────────────────────────── + + +def installation(): + return [ + h.h2(id="installation")["Installation"], + h.p["BlocketAPI is available as a Python library or via direct REST calls."], + h.div(".tab-group")[ + tab_btn("qs-uv", "uv", active=True), + tab_btn("qs-pip", "pip"), + tab_btn("qs-rest", "cURL"), + tab_btn("qs-js", "js"), + ], + tab_panel("qs-uv", install_uv(), active=True), + tab_panel("qs-pip", install_pip()), + tab_panel( + "qs-rest", + h.p(".tab-note")[ + "No install needed. The REST API is publicly accessible at ", + h.a(".link", href=f"{API_BASE}/v1/", target="_blank")[ + f"{API_BASE}/v1/ ↗" + ], + ". See all available endpoints in the ", + h.a(".link", href=url_for("endpoints"), **_htmx("endpoints"))[ + "Endpoints reference" + ], + ".", + ], + ), + tab_panel( + "qs-js", + h.p(".tab-note")[ + "Use any HTTP client. The examples below use the browser's native ", + ic("fetch"), + " API.", + ], + ), + tip_box( + "✅", + [ + h.strong["No API key required."], + " BlocketAPI is free and open source. ", + h.a(".link", href=GITHUB, target="_blank")["View on GitHub ↗"], + ], + kind="tip", + ), + ] + + +def first_search(): + return [ + h.h2(".section-h2", id="first-search")["Your first search"], + h.p[ + "Pick your language and run — responses always return a JSON object with a ", + ic("docs"), + " list.", + ], + code_tabs( + ("Python", first_search_py()), + ("cURL", first_search_rest()), + ("js", first_search_js()), + ), + h.h3["Response shape"], + h.p["Every endpoint returns a consistent structure:"], + h.div(".endpoint-table")[ + h.table[ + h.thead[h.tr[h.th["Field"], h.th["Type"], h.th["Description"]]], + h.tbody[ + h.tr[ + h.td[ic("docs")], h.td["list"], h.td["Array of listing objects"] + ], + h.tr[ + h.td[ic("total")], + h.td["int"], + h.td["Total number of matching listings"], + ], + h.tr[h.td[ic("query")], h.td["str"], h.td["Echoed search query"]], + h.tr[ + h.td[ic("location")], + h.td["str"], + h.td["Echoed location filter"], + ], + ], + ] + ], + ] + + +def car_search_curl(): + return code_block( + "terminal", + "SH", + f'curl "{API_BASE}/v1/search/car?models=BMW&locations=STOCKHOLM&sort_order=PRICE_ASC&price_to=150000"', + ) + + +def car_section(): + return [ + h.h2(".section-h2", id="car-search")["Car search"], + h.p[ + "The ", + ic("search_car()"), + " method exposes rich vehicle-specific filters unavailable in a general search.", + ], + code_tabs(("Python", car_search()), ("cURL", car_search_curl())), + h.p(".ref-link")[ + "See all car parameters in the ", + h.a( + ".link", href=url_for("endpoints") + "#search-car", **_htmx("endpoints") + )["Endpoints reference"], + ".", + ], + tip_box( + "💡", + [ + "Use the ", + ic("Location"), + " enum for consistent county names: ", + ic("Location.STOCKHOLM"), + ", ", + ic("Location.GOTHENBURG"), + ", ", + ic("Location.MALMO"), + ", etc.", + ], + ), + ] + + +def uv_section(): + return [ + h.h2(".section-h2", id="uv")["Run scripts with uv"], + h.p[ + "Add a ", + ic("# /// script"), + " block to make your script self-contained — uv reads it and installs deps automatically.", + ], + uv_script(), + uv_run(), + h.div(".btn-row")[ + h.a(".btn.btn-primary", href=url_for("endpoints"), **_htmx("endpoints"))[ + "Endpoint reference →" + ], + h.a(".btn.btn-secondary", href=SWAGGER, target="_blank")["Swagger ↗"], + h.a(".btn.btn-secondary", href=GITHUB, target="_blank")["GitHub ↗"], + ], + ] diff --git a/docs/components/tips.py b/docs/components/tips.py new file mode 100644 index 0000000..f415f2d --- /dev/null +++ b/docs/components/tips.py @@ -0,0 +1,511 @@ +import htpy as h +from flask import url_for + +from docs.components_base import ( + _htmx, + code_block, + ic, + sidebar_link, + sidebar_section, + tip_box, +) + +# ── Sidebar ─────────────────────────────────────────────────────────────────── + + +def sidebar(): + return [ + sidebar_section( + "Tips", + [ + sidebar_link("Run with uv", "#uv", active=True), + sidebar_link("Cron scheduling", "#cron"), + sidebar_link("Pagination", "#pagination"), + sidebar_link("Sorting & filters", "#sorting"), + sidebar_link("Multi-location", "#location"), + sidebar_link("Org ID filtering", "#orgid"), + sidebar_link("Market analysis", "#analysis"), + ], + ), + sidebar_section( + "Reference", + [ + sidebar_link("Quickstart", url_for("quickstart")), + sidebar_link("Endpoints", url_for("endpoints")), + sidebar_link("Examples", url_for("examples")), + ], + ), + ] + + +# ── Code snippets ───────────────────────────────────────────────────────────── + + +def uv_global(): + return code_block( + "terminal", + "SH", + [ + h.span(".cmt")["# install uv globally"], + "\n", + "curl -LsSf https://astral.sh/uv/install.sh | sh\n\n", + h.span(".cmt")["# run any blocket script with zero setup"], + "\n", + "uv run my_search.py", + ], + ) + + +def uv_inline(): + return code_block( + "script.py", + "PY", + [ + h.span(".cmt")["# /// script"], + "\n", + h.span(".cmt")['# requires-python = ">=3.10"'], + "\n", + h.span(".cmt")['# dependencies = ["blocket-api"]'], + "\n", + h.span(".cmt")["# ///"], + "\n\n", + h.span(".kw")["from"], + " blocket_api ", + h.span(".kw")["import"], + " ", + h.span(".cls")["BlocketAPI"], + "\n\n", + "api ", + h.span(".op")["="], + " ", + h.span(".cls")["BlocketAPI"], + "()\n", + "results ", + h.span(".op")["="], + " api.", + h.span(".fn")["search"], + "(", + h.span(".str")['"Lego"'], + ")\n", + h.span(".fn")["print"], + "(results[", + h.span(".str")['"docs"'], + "][", + h.span(".num")["0"], + "][", + h.span(".str")['"heading"'], + "])", + ], + ) + + +def cron(): + return code_block( + "crontab -e", + "SH", + [ + h.span(".cmt")["# run price alert every 15 minutes"], + "\n", + "*/15 * * * * /usr/local/bin/uv run /home/user/price_alert.py >> /tmp/blocket.log 2>&1\n\n", + h.span(".cmt")["# daily full scan at 08:00"], + "\n", + "0 8 * * * /usr/local/bin/uv run /home/user/daily_scan.py", + ], + ) + + +def pagination(): + return code_block( + "paginate_all.py", + "PY", + [ + h.span(".kw")["from"], + " blocket_api ", + h.span(".kw")["import"], + " ", + h.span(".cls")["BlocketAPI"], + "\n\n", + "api ", + h.span(".op")["="], + " ", + h.span(".cls")["BlocketAPI"], + "()\n", + "all_docs ", + h.span(".op")["="], + " []\npage ", + h.span(".op")["="], + " ", + h.span(".num")["0"], + "\n\n", + h.span(".kw")["while"], + " ", + h.span(".kw")["True"], + ":\n", + " batch ", + h.span(".op")["="], + " api.", + h.span(".fn")["search"], + "(", + h.span(".str")['"MacBook"'], + ", page", + h.span(".op")["="], + "page)[", + h.span(".str")['"docs"'], + "]\n", + " ", + h.span(".kw")["if"], + " ", + h.span(".kw")["not"], + " batch:\n ", + h.span(".kw")["break"], + "\n", + " all_docs.", + h.span(".fn")["extend"], + "(batch)\n page ", + h.span(".op")["+="], + " ", + h.span(".num")["1"], + "\n\n", + h.span(".fn")["print"], + '(f"Total: {len(all_docs)} listings")', + ], + ) + + +def sort_filter(): + return code_block( + "sort_tip.py", + "PY", + [ + h.span(".kw")["from"], + " blocket_api ", + h.span(".kw")["import"], + " ", + h.span(".cls")["BlocketAPI"], + ", ", + h.span(".cls")["CarSortOrder"], + "\n\n", + "api ", + h.span(".op")["="], + " ", + h.span(".cls")["BlocketAPI"], + "()\n", + "cars ", + h.span(".op")["="], + " api.", + h.span(".fn")["search_car"], + "(\n", + " sort_order", + h.span(".op")["="], + h.span(".cls")["CarSortOrder"], + ".", + h.span(".var")["PRICE_ASC"], + ",\n", + " price_to", + h.span(".op")["="], + h.span(".num")["100000"], + ",\n)\n", + ], + ) + + +def multi_location(): + return code_block( + "multi_location.py", + "PY", + [ + h.span(".kw")["from"], + " blocket_api ", + h.span(".kw")["import"], + " ", + h.span(".cls")["BlocketAPI"], + ", ", + h.span(".cls")["Location"], + "\n\n", + "api ", + h.span(".op")["="], + " ", + h.span(".cls")["BlocketAPI"], + "()\n", + "results ", + h.span(".op")["="], + " api.", + h.span(".fn")["search_car"], + "(\n", + " locations", + h.span(".op")["="], + "[\n", + " ", + h.span(".cls")["Location"], + ".", + h.span(".var")["STOCKHOLM"], + ",\n", + " ", + h.span(".cls")["Location"], + ".", + h.span(".var")["GOTHENBURG"], + ",\n", + " ", + h.span(".cls")["Location"], + ".", + h.span(".var")["MALMO"], + ",\n", + " ],\n)\n", + ], + ) + + +def org_id(): + return code_block( + "org_id_scan.py", + "PY", + [ + h.span(".kw")["from"], + " blocket_api ", + h.span(".kw")["import"], + " ", + h.span(".cls")["BlocketAPI"], + "\n\n", + "api ", + h.span(".op")["="], + " ", + h.span(".cls")["BlocketAPI"], + "()\n", + "dealer_ads ", + h.span(".op")["="], + " api.", + h.span(".fn")["search"], + "(", + h.span(".str")['"iPhone"'], + ", org_id", + h.span(".op")["="], + h.span(".str")['"67890"'], + ")\n", + h.span(".fn")["print"], + '(f"{len(dealer_ads[', + h.span(".str")['"docs"'], + '])} ads from this seller")', + ], + ) + + +def median_price(): + return code_block( + "median_price.py", + "PY", + [ + h.span(".cmt")["# /// script"], + "\n", + h.span(".cmt")['# dependencies = ["blocket-api", "statistics"]'], + "\n", + h.span(".cmt")["# ///"], + "\n\n", + h.span(".kw")["import"], + " statistics\n", + h.span(".kw")["from"], + " blocket_api ", + h.span(".kw")["import"], + " ", + h.span(".cls")["BlocketAPI"], + ", ", + h.span(".cls")["CarModel"], + "\n\n", + "api ", + h.span(".op")["="], + " ", + h.span(".cls")["BlocketAPI"], + "()\n", + "cars ", + h.span(".op")["="], + " api.", + h.span(".fn")["search_car"], + "(models", + h.span(".op")["="], + "[", + h.span(".cls")["CarModel"], + ".", + h.span(".var")["TESLA"], + "])\n\n", + "prices ", + h.span(".op")["="], + " [d.", + h.span(".fn")["get"], + "(", + h.span(".str")['"price"'], + ", {}).", + h.span(".fn")["get"], + "(", + h.span(".str")['"amount"'], + ") ", + h.span(".kw")["for"], + " d ", + h.span(".kw")["in"], + " cars[", + h.span(".str")['"docs"'], + "] ", + h.span(".kw")["if"], + " d.", + h.span(".fn")["get"], + "(", + h.span(".str")['"price"'], + ")]\n\n", + h.span(".fn")["print"], + '(f"Median: {statistics.', + h.span(".fn")["median"], + '(prices):,.0f} SEK")', + ], + ) + + +# ── Page sections ───────────────────────────────────────────────────────────── + + +def uv_tip(): + return [ + h.h2(id="uv")["Run scripts with uv"], + h.p[ + h.a(".link", href="https://github.com/astral-sh/uv", target="_blank")["uv"], + " is an ultra-fast Python package manager. Use PEP 723 inline script metadata to declare " + "dependencies inside your file — no setup required.", + ], + uv_global(), + uv_inline(), + tip_box( + "🚀", + [ + "Share scripts with colleagues by copying a single file — ", + ic("uv run script.py"), + " handles everything.", + ], + kind="tip", + ), + ] + + +def cron_tip(): + return [ + h.h2(".section-h2", id="cron")["Schedule with cron"], + h.p[ + "Automate your scripts with cron. Always use the full path to ", + ic("uv"), + " so cron can find it.", + ], + cron(), + tip_box( + "💡", + [ + "Find where uv is installed with ", + ic("which uv"), + ". Use that full path in your crontab to avoid PATH issues.", + ], + ), + ] + + +def pagination_tip(): + return [ + h.h2(".section-h2", id="pagination")["Pagination"], + h.p[ + "By default, ", + ic("search()"), + " returns up to 40 results. Paginate by passing an incrementing ", + ic("page"), + " parameter.", + ], + pagination(), + tip_box( + "⚠️", + [ + "Be courteous — add a small delay between pages: ", + ic("import time; time.sleep(0.5)"), + ".", + ], + kind="warning", + ), + ] + + +def sorting_tip(): + return [ + h.h2(".section-h2", id="sorting")["Sorting & price filters"], + h.p[ + "Combine ", + ic("sort_order"), + " with ", + ic("price_from"), + " / ", + ic("price_to"), + " to find deals fast.", + ], + sort_filter(), + h.div(".endpoint-table")[ + h.table[ + h.thead[h.tr[h.th["Sort value"], h.th["Meaning"]]], + h.tbody[ + h.tr[h.td[ic("PRICE_ASC")], h.td["Cheapest first"]], + h.tr[h.td[ic("PRICE_DESC")], h.td["Most expensive first"]], + h.tr[h.td[ic("DATE_ASC")], h.td["Oldest first"]], + h.tr[h.td[ic("DATE_DESC")], h.td["Newest first (default)"]], + h.tr[ + h.td[ic("MILEAGE_ASC")], + h.td["Lowest mileage first (cars only)"], + ], + ], + ] + ], + ] + + +def location_tip(): + return [ + h.h2(".section-h2", id="location")["Multi-location search"], + h.p[ + "Pass a list of ", + ic("Location"), + " values to search across multiple Swedish counties simultaneously.", + ], + multi_location(), + ] + + +def orgid_tip(): + return [ + h.h2(".section-h2", id="orgid")["Filter by dealer (org_id)"], + h.p[ + "Use the ", + ic("org_id"), + " parameter to fetch all ads from a specific dealer. " + "You can find a seller's org_id by inspecting a listing on Blocket.se.", + ], + org_id(), + ] + + +def analysis_tip(): + return [ + h.h2(".section-h2", id="analysis")["Market analysis & price stats"], + h.p[ + "Use the ", + ic("statistics"), + " or ", + ic("pandas"), + " standard libraries to compute market insights.", + ], + median_price(), + code_block( + "terminal", + "SH", + [ + "uv run median_price.py\n", + h.span(".cmt")["# Median Tesla price: 389 000 SEK"], + ], + ), + h.div(".btn-row")[ + h.a(".btn.btn-primary", href=url_for("examples"), **_htmx("examples"))[ + "Browse full examples →" + ], + h.a(".btn.btn-secondary", href=url_for("endpoints"), **_htmx("endpoints"))[ + "Endpoint reference" + ], + ], + ] diff --git a/docs/components_base.py b/docs/components_base.py new file mode 100644 index 0000000..12318a7 --- /dev/null +++ b/docs/components_base.py @@ -0,0 +1,410 @@ +"""Shared htpy components. All pages are rendered via page().""" + +import json +import threading +import time +import urllib.request + +import htpy as h +from flask import url_for +from markupsafe import Markup + +_gh_cache: dict = {} +_gh_lock = threading.Lock() + + +def _gh_stats() -> dict: + now = time.time() + with _gh_lock: + if _gh_cache.get("ts", 0) + 300 > now: + return _gh_cache + try: + with urllib.request.urlopen( + "https://api.github.com/repos/dunderrrrrr/blocket_api", timeout=3 + ) as r: + data = json.loads(r.read()) + result = { + "stars": data.get("stargazers_count", 0), + "forks": data.get("forks_count", 0), + "version": None, + "ts": now, + } + except Exception: + result = {**_gh_cache, "ts": now} + try: + with urllib.request.urlopen( + "https://api.github.com/repos/dunderrrrrr/blocket_api/releases/latest", + timeout=3, + ) as r: + data = json.loads(r.read()) + result["version"] = data.get("tag_name") + except Exception: + pass + with _gh_lock: + _gh_cache.update(result) + return result + + +API_BASE = "https://blocket-api.se" +GITHUB = "https://github.com/dunderrrrrr/blocket_api" +PYPI = "https://pypi.org/project/blocket-api/" +SWAGGER = f"{API_BASE}/swagger" + +_NAV_PAGES = [ + ("index", "Home"), + ("quickstart", "Quickstart"), + ("python", "Python"), + ("endpoints", "Endpoints"), + ("changelog", "Changelog"), +] + + +# ── Nav ─────────────────────────────────────────────────────────────────────── + + +_LOGO_SVG = Markup( + '' + '' + '' + "" +) + + +def _htmx(ep: str) -> dict: + """Return htmx attributes for an internal page endpoint name.""" + return dict( + hx_get=url_for(f"{ep}_fragment"), + hx_target="#content", + hx_swap="outerHTML", + hx_push_url=url_for(ep), + ) + + +def _logo(): + return h.a(".nav-logo", href=url_for("index"), **_htmx("index"))[ + _LOGO_SVG, + h.span["BlocketAPI"], + ] + + +def _nav_links(active: str = "", mobile: bool = False): + extra = ( + {"onclick": "document.getElementById('mobileMenu').classList.remove('open')"} + if mobile + else {} + ) + internal = [ + h.a( + ".active" if ep == active else "", + href=url_for(ep), + **_htmx(ep), + **extra, + )[label] + for ep, label in _NAV_PAGES + ] + external = [ + h.a(".nav-external", href=SWAGGER, target="_blank")["Swagger ↗"], + ] + return internal, external + + +_GH_SVG = Markup( + '' + '' + "" +) + +# Material Symbols: star, call_split (fork), tag (version) +_ICON_STAR = Markup( + '' +) +_ICON_FORK = Markup( + '' +) +_ICON_TAG = Markup( + '' +) + + +def _gh_badges(): + stats = _gh_stats() + facts = [] + if stats.get("version"): + facts.append(h.li(".gh-fact")[_ICON_TAG, stats["version"]]) + if stats.get("stars") is not None: + facts.append(h.li(".gh-fact")[_ICON_STAR, str(stats["stars"])]) + if stats.get("forks") is not None: + facts.append(h.li(".gh-fact")[_ICON_FORK, str(stats["forks"])]) + return h.a(".gh-source", href=GITHUB, target="_blank")[ + h.div(".gh-source-icon")[_GH_SVG], + h.div(".gh-source-info")[ + "GitHub", + h.ul(".gh-facts")[facts], + ], + ] + + +def nav(active: str = ""): + internal, external = _nav_links(active) + return h.nav[ + h.div(".nav-inner")[ + _logo(), + h.div(".nav-links")[internal], + h.div(".nav-links.nav-links-right")[external], + _gh_badges(), + h.div(".hamburger", onclick="toggleMenu()")[h.span, h.span, h.span], + ] + ] + + +def mobile_menu(active: str = ""): + internal, external = _nav_links(active, mobile=True) + return h.div(".mobile-menu", id="mobileMenu")[internal, external] + + +# ── Footer ──────────────────────────────────────────────────────────────────── + + +def footer(): + return h.footer[ + h.div(".footer-inner")[ + h.div(".footer-brand")[ + h.a( + ".nav-logo", + href=url_for("index"), + style="display:inline-flex;margin-bottom:.5rem;", + **_htmx("index"), + )[h.span["BlocketAPI"]], + h.p[ + "The leading developer API for Blocket.se — Sweden's largest swedish online marketplace. Free to use, open source." + ], + ], + h.div(".footer-col")[ + h.h4["Documentation"], + [ + h.a(href=url_for(ep), **_htmx(ep))[label] + for ep, label in _NAV_PAGES + if ep != "index" + ], + ], + h.div(".footer-col")[ + h.h4["Resources"], + h.a(href=SWAGGER, target="_blank")["Swagger ↗"], + h.a(href=GITHUB, target="_blank")["GitHub"], + h.a(href=PYPI, target="_blank")["PyPI"], + ], + h.div(".footer-col")[ + h.h4["Legal"], + h.a(href=f"{GITHUB}/blob/main/LICENSE", target="_blank")[ + "License (WTFPL)" + ], + ], + ], + h.div(".footer-bottom")[ + h.span["© 2024 BlocketAPI. Not affiliated with Blocket AB."], + h.span["Built for developers, by developers 🇸🇪"], + ], + ] + + +SITE_ROOT = "https://blocket-api.se" +_OG_IMAGE = f"{SITE_ROOT}/static/blocket-api.png" + + +# ── Page shell ──────────────────────────────────────────────────────────────── + + +def page( + active: str, title: str, description: str, content_fn, extra_css: str | None = None +): + """Render a full page. content_fn() returns the page body (htpy nodes).""" + from flask import request + + canonical = SITE_ROOT + request.path + + head_links = [ + h.link(rel="canonical", href=canonical), + h.link(rel="stylesheet", href=url_for("static", filename="style.css")), + h.link(rel="preconnect", href="https://fonts.googleapis.com"), + h.link(rel="preconnect", href="https://fonts.gstatic.com", crossorigin=""), + h.link( + href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600&family=Space+Mono:wght@400;700&display=swap", + rel="stylesheet", + ), + ] + if extra_css: + head_links.append( + h.link(rel="stylesheet", href=url_for("static", filename=extra_css)) + ) + + og_tags = [ + h.meta(property="og:type", content="website"), + h.meta(property="og:url", content=canonical), + h.meta(property="og:title", content=title), + h.meta(property="og:description", content=description), + h.meta(property="og:image", content=_OG_IMAGE), + h.meta(property="og:site_name", content="BlocketAPI"), + h.meta(name="twitter:card", content="summary_large_image"), + h.meta(name="twitter:title", content=title), + h.meta(name="twitter:description", content=description), + h.meta(name="twitter:image", content=_OG_IMAGE), + ] + + doc = h.html(lang="en")[ + h.head[ + h.meta(charset="UTF-8"), + h.meta(name="viewport", content="width=device-width, initial-scale=1.0"), + h.title[title], + h.meta(name="description", content=description), + og_tags, + head_links, + h.script(src="https://unpkg.com/htmx.org@2.0.4/dist/htmx.min.js", defer=""), + h.link( + rel="stylesheet", + href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.10.0/styles/github.min.css", + ), + h.script( + src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.10.0/highlight.min.js" + ), + h.script["hljs.highlightAll();"], + ], + h.body[ + nav(active), + mobile_menu(active), + h.div(".page-loading-bar", id="page-loading-bar"), + h.div("#content")[content_fn()], + footer(), + h.script(src=url_for("static", filename="main.js")), + ], + ] + return Markup(doc) + + +def fragment(content_fn) -> str: + """Render only the inner content nodes — returned for htmx requests.""" + return str(h.div("#content")[content_fn()]) + + +# ── Reusable UI components ──────────────────────────────────────────────────── + + +_LANG_CLASS = { + "PY": "language-python", + "SH": "language-bash", + "JS": "language-javascript", + "JSON": "language-json", +} + + +def code_block(filename: str, lang: str, code: str): + lang_class = _LANG_CLASS.get(lang, "language-plaintext") + return h.div(".code-block")[ + h.div(".code-header")[ + h.button(".cb-copy", onclick="copyBlock(this)")["Copy"], + h.span[filename], + ], + h.pre[h.code(class_=lang_class)[code]], + ] + + +_tab_counter = [0] + + +def code_tabs(*variants): + """Render a tabbed code block with tabs embedded in the code header.""" + _tab_counter[0] += 1 + prefix = f"ct{_tab_counter[0]}" + + btns = [ + h.button( + ".ct-btn.active" if i == 0 else ".ct-btn", + onclick=f"switchTab(this,'{prefix}-{i}')", + )[label] + for i, (label, _) in enumerate(variants) + ] + + # Each panel wraps the inner
 from the code_block passed in
+    panels = [
+        h.div(
+            ".ct-panel.active" if i == 0 else ".ct-panel",
+            id=f"{prefix}-{i}",
+        )[block]
+        for i, (_, block) in enumerate(variants)
+    ]
+
+    return h.div(".code-block.code-tabs-wrap")[
+        h.div(".code-header")[
+            h.button(".cb-copy", onclick="copyBlock(this)")["Copy"],
+            h.div(".ct-group")[btns],
+        ],
+        panels,
+    ]
+
+
+def tip_box(icon: str, content, kind: str = ""):
+    return h.div(f".tip.{kind}" if kind else ".tip")[
+        h.div(".tip-icon")[icon],
+        h.p[content],
+    ]
+
+
+def section_header(label: str, title: str, description: str = ""):
+    return h.div(".section-header")[
+        h.span(".section-label")[label],
+        h.h2[title],
+        h.p[description] if description else "",
+    ]
+
+
+def page_hero(title: str, description, crumb: str):
+    return h.div(".page-hero", style="padding-left:1.5rem;padding-right:1.5rem;")[
+        h.div(".breadcrumb")[
+            h.a(href=url_for("index"))["Home"],
+            h.span["/"],
+            crumb,
+        ],
+        h.h1[title],
+        h.p[description] if isinstance(description, str) else description,
+    ]
+
+
+def ic(text: str):
+    """Inline code span."""
+    return h.span(".inline-code")[text]
+
+
+def doc_layout(sidebar_content, main_content):
+    return h.div(style="max-width:1200px;margin:0 auto;")[
+        h.div(".doc-layout")[
+            h.aside(".sidebar")[sidebar_content],
+            h.main[main_content],
+        ]
+    ]
+
+
+def sidebar_section(title: str, links: list):
+    return h.div(".sidebar-section")[
+        h.h4[title],
+        links,
+    ]
+
+
+def sidebar_link(label: str, href: str, active: bool = False):
+    ep = next(
+        (
+            ep
+            for ep, _ in _NAV_PAGES
+            if url_for(ep) == href or url_for(ep) == href.split("#")[0]
+        ),
+        None,
+    )
+    is_external = href.startswith("http")
+    if ep:
+        extra = _htmx(ep)
+    elif is_external:
+        extra = {"target": "_blank"}
+    else:
+        extra = {}
+    return h.a(
+        ".sidebar-link.active" if active else ".sidebar-link", href=href, **extra
+    )[label]
diff --git a/docs/custom.css b/docs/custom.css
deleted file mode 100644
index ec2e2d6..0000000
--- a/docs/custom.css
+++ /dev/null
@@ -1,7 +0,0 @@
-nav.md-tabs {
-    background-color: hsla(265.2, 89.3%, 78%, 0.1)
-}
-header.md-header {
-    background-color: #1E2029;
-    border-bottom: .05rem solid hsl(265.2, 82%, 76.1%);    
-}
\ No newline at end of file
diff --git a/docs/examples.md b/docs/examples.md
deleted file mode 100644
index ca8ed4a..0000000
--- a/docs/examples.md
+++ /dev/null
@@ -1,111 +0,0 @@
-# Examples
-
-This is some very basic examples of how to use the API. For more details, see the [API reference](https://blocket-api.se/api-reference).
-
-## Searching
-
-### Search everything
-
-Search for everything using keyword.
-
-```python
-import httpx
-
-items = httpx.get(
-    "https://blocket-api.se/v1/search",
-    params={"query": "flipper zero", "sort_order": "PUBLISHED_DESC"},
-).json()["docs"]
-
-for item in items:
-    data = httpx.get(
-        "https://blocket-api.se/v1/ad/recommerce",
-        params={"id": item["id"]},
-    ).json()["loaderData"]["item-recommerce"]["itemData"]
-
-    print(
-        f"{data['title']}\n{data['description']}\n{data['price']}\n"
-        f"{data['location']['postalName']}\n{item['canonical_url']}"
-    )
-    print("=" * 50)
-```
-
-Will return: 
-```
-Flipper zero
-Fin flipper zero, knappt använd! Kan skickas.
-2500
-Stockholm
-https://www.blocket.se/recommerce/forsale/item/19075245
-==================================================
-Flipper Zero oanvänd
-Säljer Flipper Zero som bara legat och samlat damm efter att jag införskaffade den på en konferens i Sverige.
-Förpackningen är endast öppnad för att fota innehållet.
-2500
-Linköping
-https://www.blocket.se/recommerce/forsale/item/19053126
-==================================================
-Flipper zero
-Flipper Zero säljes tillsammans med silikonskydd och extra tillbehör. Kort och flera taggar av olika slag följer med. Alla tillbehör är nya och oanvända. Flipper zero i nyskick. Se bilder för detaljer.
-2450
-Helsingborg
-https://www.blocket.se/recommerce/forsale/item/18847358
-==================================================
-```
-
-### Search for cars
-
-```python
-import httpx
-
-response = httpx.get(
-    "https://blocket-api.se/v1/search/car",
-    params={
-        "page": 1,
-        "locations": "STOCKHOLM",
-        "models": "AUDI",
-        "price_from": 10000,
-        "price_to": 50000,
-        "year_from": 2010,
-        "year_to": 2025,
-    },
-)
-
-for item in response.json()["docs"]:
-    print(f"({item["id"]})", item["price"]["amount"], "SEK -", item["heading"])
-```
-
-Will return: 
-
-```
-(18460933) 45000 SEK - Audi A4 Avant 2.0 TDI DPF Euro 5
-(18566460) 45000 SEK - Audi A4 Avant 2.0 TFSI E85 Euro 5
-(13436700) 10819 SEK - Audi A6 Avant e-tron Proline - Operationell leasing
-(18712562) 50000 SEK - Audi A4 2.0 TDI Avant quattro (170hk)
-```
-
-### Get specific store ads
-
-The id of the store can be found in the URL of the store page. For example, `https://www.blocket.se/mobility/search/car?orgId=XXXXXXXX` or `https://www.blocket.se/mobility/dealer/XXXXXX/store-name`.
-
-```python
-import httpx
-
-response = httpx.get(
-    "https://blocket-api.se/v1/search/car",
-    params={
-        "org_id": 1337,
-        # see all available params in the swagger documentation
-    },
-)
-```
-
-## Get full ad details
-
-Use the `/v1/ad/` endpoint to get full ad details.
-
-```python
-httpx.get(
-    "https://blocket-api.se/v1/ad/car",
-    params={"id": 1234},
-)
-```
\ No newline at end of file
diff --git a/docs/index.md b/docs/index.md
deleted file mode 100644
index 27c7a88..0000000
--- a/docs/index.md
+++ /dev/null
@@ -1,16 +0,0 @@
----
-hide:
-  - toc
-  - navigation
----
-
-

- -

- -# Welcome to BlocketAPI - -BlocketAPI provides a series of api endpoints for interacting with the blocket.se. It uses the [blocket_api](https://github.com/dunderrrrrr/blocket_api) library as a backend to provide a simple and easy to use api. The actual library documentation can be found on GitHub. This page aims to provide a simple overview of the rest api and it's functions. - -If you are working with Python, please see our package [blocket_api](https://github.com/dunderrrrrr/blocket_api). - diff --git a/docs/static/blocket-api.png b/docs/static/blocket-api.png new file mode 100644 index 0000000000000000000000000000000000000000..432cb2ffaf7673cbfad147e5602cfedca7c52e6f GIT binary patch literal 48756 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 document.getElementById("page-loading-bar"); +document.addEventListener("htmx:beforeRequest", () => { + const b = _bar(); if (b) { b.style.width = "30%"; b.style.opacity = "1"; } +}); +document.addEventListener("htmx:afterRequest", () => { + const b = _bar(); if (b) { b.style.width = "100%"; setTimeout(() => { b.style.opacity = "0"; b.style.width = "0"; }, 300); } +}); + +function toggleMenu() { + document.getElementById("mobileMenu").classList.toggle("open"); +} + +function initSidebarObserver() { + const anchors = document.querySelectorAll(".sidebar-link[href^='#']"); + if (!anchors.length) return; + + const targets = [...anchors].map(a => document.querySelector(a.getAttribute("href"))).filter(Boolean); + + const observer = new IntersectionObserver((entries) => { + entries.forEach(entry => { + if (entry.isIntersecting) { + anchors.forEach(a => a.classList.remove("active")); + const link = document.querySelector(`.sidebar-link[href="#${entry.target.id}"]`); + if (link) link.classList.add("active"); + } + }); + }, { rootMargin: "-20% 0px -70% 0px" }); + + targets.forEach(t => observer.observe(t)); +} + +initSidebarObserver(); + +function updateActiveNav() { + const path = window.location.pathname; + document.querySelectorAll(".nav-links a, .mobile-menu a").forEach((a) => { + const href = new URL(a.href, location.origin).pathname; + a.classList.toggle("active", href === path); + }); +} + +document.addEventListener("htmx:afterSettle", () => { + updateActiveNav(); + window.scrollTo({ top: 0, behavior: "instant" }); + initSidebarObserver(); + if (document.getElementById("ep-root")) loadHtmxTarget("ep-root", "/_/endpoints-body"); + if (document.getElementById("cl-root")) loadHtmxTarget("cl-root", "/_/changelog-body"); + document.querySelectorAll("pre code:not(.hljs)").forEach((el) => hljs.highlightElement(el)); +}); + +function loadHtmxTarget(id, url) { + const el = document.getElementById(id); + if (!el || !el.querySelector(".ep-spinner")) return; + htmx.ajax("GET", url, { target: "#" + id, swap: "innerHTML" }); +} + +function switchInstall(btn, name) { + document + .querySelectorAll(".install-tab") + .forEach((b) => b.classList.remove("active")); + document + .querySelectorAll(".install-body") + .forEach((p) => p.classList.remove("active")); + btn.classList.add("active"); + document.getElementById("inst-" + name).classList.add("active"); +} + +function switchExample(btn, name) { + document + .querySelectorAll(".example-btn") + .forEach((b) => b.classList.remove("active")); + document + .querySelectorAll(".example-panel") + .forEach((p) => p.classList.remove("active")); + btn.classList.add("active"); + document.getElementById(name).classList.add("active"); +} + +function switchExTab(btn, name) { + const bar = btn.closest(".examples-tab-bar"); + bar.querySelectorAll(".ex-tab").forEach((b) => b.classList.remove("active")); + bar.closest(".examples-tabs").querySelectorAll(".ex-tab-panel").forEach((p) => p.classList.remove("active")); + btn.classList.add("active"); + document.getElementById(name).classList.add("active"); +} + +function switchTab(btn, name) { + // ct-group: inline header tabs (.ct-btn / .ct-panel) + const ctGroup = btn.closest(".ct-group"); + if (ctGroup) { + const wrap = ctGroup.closest(".code-tabs-wrap"); + wrap.querySelectorAll(".ct-btn").forEach((b) => b.classList.remove("active")); + wrap.querySelectorAll(".ct-panel").forEach((p) => p.classList.remove("active")); + btn.classList.add("active"); + document.getElementById(name).classList.add("active"); + return; + } + // tab-group: pill tabs (.tab-btn / .tab-panel) + const group = btn.closest(".tab-group"); + const card = group ? group.parentElement : document; + card.querySelectorAll(".tab-btn").forEach((b) => b.classList.remove("active")); + card.querySelectorAll(".tab-panel").forEach((p) => p.classList.remove("active")); + btn.classList.add("active"); + document.getElementById(name).classList.add("active"); +} + +function copyBlock(btn) { + const block = btn.closest(".code-block"); + const active = block.querySelector(".ct-panel.active code") || block.querySelector("code"); + navigator.clipboard.writeText(active.textContent).then(() => { + btn.textContent = "Copied!"; + btn.classList.add("copied"); + setTimeout(() => { + btn.textContent = "Copy"; + btn.classList.remove("copied"); + }, 2000); + }); +} + +function copyCmd(id, btn) { + navigator.clipboard + .writeText(document.getElementById(id).textContent) + .then(() => { + btn.textContent = "Copied!"; + btn.classList.add("copied"); + setTimeout(() => { + btn.textContent = "Copy"; + btn.classList.remove("copied"); + }, 2000); + }); +} + diff --git a/docs/static/style.css b/docs/static/style.css new file mode 100644 index 0000000..3dc5c57 --- /dev/null +++ b/docs/static/style.css @@ -0,0 +1,726 @@ +:root { + --bg: #ffffff; + --bg2: #f9f9f9; + --bg3: #f2f2f2; + --border: #e8e8e8; + --accent: #e02b20; + --accent2: #c0201a; + --text: #111111; + --text-muted: #777777; + --text-dim: #bbbbbb; + --green: #16a34a; + --blue: #2563eb; + --orange: #d97706; + --red: #e02b20; + --radius: 8px; + --radius-sm: 5px; + --shadow: 0 1px 4px rgba(0,0,0,0.07), 0 4px 16px rgba(0,0,0,0.05); + --font-mono: "JetBrains Mono", "Fira Code", monospace; + --px: 2rem; +} + +* { box-sizing: border-box; margin: 0; padding: 0; } +html { scroll-behavior: smooth; } + +body { + background: var(--bg); + color: var(--text); + font-family: "Space Mono", var(--font-mono); + font-size: 15px; + line-height: 1.7; + -webkit-font-smoothing: antialiased; +} + +a { color: var(--accent); text-decoration: none; } +a:hover { text-decoration: underline; } + +/* ── NAV ── */ +nav { + position: sticky; + top: 0; + z-index: 100; + background: rgba(255,255,255,0.92); + backdrop-filter: blur(12px); + border-bottom: 1px solid var(--border); +} + +.nav-inner { + max-width: 1100px; + margin: 0 auto; + display: flex; + align-items: center; + height: 56px; + gap: 1.5rem; + padding: 0 var(--px); +} + +.nav-logo { + display: flex; + align-items: center; + gap: 0.5rem; + font-weight: 700; + font-size: 1rem; + color: var(--text); + text-decoration: none; + white-space: nowrap; + flex-shrink: 0; +} +.nav-logo span { color: var(--text); } + +.nav-links { display: flex; gap: 0.1rem; flex: 1; } +.nav-links-right { flex: 0; margin-left: auto; } + +.nav-links a { + color: var(--text-muted); + font-size: 0.875rem; + padding: 0.35rem 0.65rem; + border-radius: var(--radius-sm); + transition: color 0.1s; + text-decoration: none; + white-space: nowrap; +} +.nav-links a:hover { color: var(--text); text-decoration: none; } +.nav-links a.active { color: var(--text); font-weight: 600; } +.nav-external { opacity: 0.6; } +.nav-external:hover { opacity: 1; } + +.gh-source { + display: inline-flex; align-items: center; gap: 0; + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + text-decoration: none; + overflow: hidden; + transition: border-color 0.1s; + flex-shrink: 0; +} +.gh-source:hover { border-color: #bbb; text-decoration: none; } +.gh-source-icon { + display: flex; align-items: center; justify-content: center; + padding: 0 0.55rem; + border-right: 1px solid var(--border); + height: 100%; + background: var(--bg2); +} +.gh-badge-svg { width: 14px; height: 14px; fill: var(--text-muted); } +.gh-source:hover .gh-badge-svg { fill: var(--text); } +.gh-source-info { + display: flex; flex-direction: column; + padding: 0.25rem 0.65rem; + font-size: 0.72rem; + font-weight: 600; + color: var(--text); + line-height: 1.3; +} +.gh-facts { + display: flex; gap: 0.6rem; + list-style: none; margin: 0; padding: 0; +} +.gh-fact { display: flex; align-items: center; gap: 0.2rem; font-size: 0.65rem; font-weight: 400; color: var(--text-muted); } +.gh-fact-icon { width: 10px; height: 10px; fill: var(--text-muted); flex-shrink: 0; } + +.hamburger { + display: none; + flex-direction: column; + gap: 4px; + cursor: pointer; + padding: 4px; + margin-left: auto; +} +.hamburger span { + display: block; width: 20px; height: 1.5px; + background: var(--text-muted); border-radius: 2px; +} + +/* ── LAYOUT ── */ +.container { max-width: 1100px; margin: 0 auto; padding: 0 var(--px); } +.section { padding: 5rem var(--px); } +.section-alt { background: var(--bg2); border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); } +.section-cta { text-align: center; } +.container-narrow { max-width: 780px; } + +/* ── HERO ── */ +.hero-wrapper { + border-bottom: 1px solid var(--border); + overflow: hidden; +} + +.hero-center { + max-width: 640px; + margin: 0 auto; + padding: 6rem var(--px) 5rem; + text-align: center; +} +.hero-center .install-block { text-align: left; margin-top: 2.5rem; } +.hero-center .hero-links { justify-content: center; } + +.hero-split { + max-width: 1100px; + margin: 0 auto; + padding: 5rem var(--px) 4rem; + display: grid; + grid-template-columns: 1fr 1fr; + gap: 4rem; + align-items: center; +} + +.hero-title { + font-family: "Space Mono", var(--font-mono); + font-size: clamp(2.4rem, 6vw, 4.2rem); + font-weight: 700; + line-height: 1.0; + letter-spacing: -0.03em; + margin-bottom: 1.25rem; + color: var(--text); +} +.hero-title .hero-comment { + font-size: 0.45em; + vertical-align: middle; + margin-right: 0.2em; + color: var(--accent); + opacity: 1; + letter-spacing: 0; +} +.hero-title-accent { color: var(--accent); } + +.hero-wordmark { + color: var(--text); +} + +.hero-comment { color: var(--accent); } +.hero-accent { color: var(--accent); } + +.hero-subtitle { + font-size: 0.85rem; + color: var(--text-dim); + margin-bottom: 0.75rem; + display: flex; + align-items: center; + justify-content: center; + gap: 0.75rem; +} +.hero-subtitle::before, +.hero-subtitle::after { + content: ""; + height: 1px; + flex: 1; + max-width: 3rem; + background: var(--border); +} + +.hero-desc { + color: var(--text-muted); + font-size: 0.9rem; + line-height: 1.8; + margin-bottom: 0; + margin-top: 1rem; +} +.hero-desc-sub { + color: var(--text-dim); + font-size: 0.8rem; +} + +.hero-tag { + font-size: 0.72rem; font-weight: 600; + color: var(--accent); + background: rgba(224,43,32,0.07); + border: 1px solid rgba(224,43,32,0.2); + border-radius: 99px; + padding: 0.2rem 0.6rem; + letter-spacing: 0.03em; +} + +.hero-links { + display: flex; gap: 1.5rem; flex-wrap: wrap; + margin-top: 1rem; font-size: 0.875rem; +} +.hero-links a { color: var(--text-muted); } +.hero-links a:hover { color: var(--text); text-decoration: none; } + +.hero-right { display: flex; flex-direction: column; gap: 0.75rem; } + +/* ── INSTALL BLOCK ── */ +.install-block { + background: #111; + border-radius: var(--radius); + overflow: hidden; + margin-top: 1.5rem; + border: 1px solid #2a2a2a; +} +.install-tabs { + display: flex; + border-bottom: 1px solid #2a2a2a; + padding: 0 0.25rem; +} +.install-tab { + background: none; border: none; + color: #666; + font-size: 0.78rem; font-weight: 600; + font-family: inherit; + padding: 0.55rem 0.75rem; + cursor: pointer; + border-bottom: 2px solid transparent; + transition: color 0.1s; + letter-spacing: 0.03em; +} +.install-tab.active { color: #fff; border-bottom-color: var(--accent); } +.install-tab:hover:not(.active) { color: #ccc; } + +.install-body { display: none; padding: 1rem 1.25rem; } +.install-body.active { display: block; } +.install-cmd { display: flex; align-items: center; gap: 0.75rem; } +.install-prompt { + color: var(--accent); + font-family: var(--font-mono); + font-size: 0.875rem; + user-select: none; flex-shrink: 0; +} +.install-cmd code { + font-family: var(--font-mono); + font-size: 0.875rem; + color: #f0f0f0; + flex: 1; +} +.install-note { + font-size: 0.75rem; color: #555; + margin-top: 0.5rem; + font-family: var(--font-mono); +} +.copy-btn { + background: #222; border: 1px solid #333; + border-radius: 4px; color: #888; + font-size: 0.72rem; font-weight: 600; + padding: 0.2rem 0.55rem; + cursor: pointer; transition: all 0.1s; + white-space: nowrap; font-family: inherit; flex-shrink: 0; +} +.copy-btn:hover { color: #fff; border-color: #555; } +.copy-btn.copied { color: var(--green); border-color: var(--green); } + +/* ── CODE BLOCKS ── */ +.code-block { + background: #f6f8fa; + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; + margin: 1rem 0; + min-width: 0; +} +.code-block pre { + overflow-x: auto; + white-space: pre; +} +.code-block pre code.hljs { + background: transparent; + padding: 0; +} +.code-header { + display: flex; + align-items: center; + padding: 0.45rem 1rem; + background: #f0f2f4; + border-bottom: 1px solid var(--border); + font-size: 0.75rem; + color: var(--text-muted); + font-family: var(--font-mono); + gap: 0.5rem; +} +.code-dots { display: flex; gap: 5px; } +.code-dots span { width: 9px; height: 9px; border-radius: 50%; display: block; } +.dot-red { background: #ff5f57; } +.dot-yellow { background: #ffbd2e; } +.dot-green { background: #28c940; } +.code-lang { + margin-left: auto; + background: var(--bg3); + color: var(--text-dim); + font-size: 0.68rem; font-weight: 700; + padding: 0.1rem 0.4rem; + border-radius: 3px; + letter-spacing: 0.06em; +} +.cb-copy { + background: none; + border: 1px solid var(--border); + border-radius: 4px; + color: var(--text-dim); + font-size: 0.7rem; + font-family: inherit; + padding: 0.15rem 0.5rem; + cursor: pointer; + transition: all 0.1s; +} +.cb-copy:hover { color: var(--text-muted); border-color: #bbb; } +.cb-copy.copied { color: var(--green); border-color: var(--green); } + +pre { + padding: 1.1rem 1.25rem; + font-family: var(--font-mono); + font-size: 0.82rem; + line-height: 1.65; + color: var(--text); +} +code { font-family: var(--font-mono); } + +.inline-code { + background: var(--bg3); + border: 1px solid var(--border); + border-radius: 4px; + padding: 0.1rem 0.35rem; + font-family: var(--font-mono); + font-size: 0.83em; + color: var(--accent); +} + +/* ── CODE TABS ── */ +.code-tabs-wrap { + background: #f6f8fa; + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; + margin: 1rem 0; + min-width: 0; +} +.code-tabs-wrap .code-block { + border: none; border-radius: 0; margin: 0; +} +.ct-group { display: flex; gap: 0; margin-left: auto; } +.ct-btn { + background: none; border: none; + color: var(--text-dim); + font-size: 0.72rem; font-weight: 700; + padding: 0.45rem 0.7rem; + cursor: pointer; transition: color 0.1s; + font-family: inherit; + letter-spacing: 0.04em; +} +.ct-btn.active { color: var(--text); } +.ct-btn:hover:not(.active) { color: var(--text-muted); } +.ct-panel { display: none; } +.ct-panel.active { display: block; } +.ct-panel .code-block { border: none; border-radius: 0; margin: 0; } +.ct-panel .code-header { display: none; } + +/* ── PILL TABS (quickstart install) ── */ +.tabs { display: flex; gap: 0.25rem; background: var(--bg3); padding: 0.25rem; border-radius: var(--radius-sm); width: fit-content; margin-bottom: 1.5rem; } +.tab-btn { background: none; border: none; color: var(--text-muted); font-size: 0.85rem; font-weight: 500; font-family: inherit; padding: 0.35rem 0.8rem; border-radius: 4px; cursor: pointer; transition: all 0.1s; } +.tab-btn.active { background: #fff; color: var(--text); box-shadow: 0 1px 3px rgba(0,0,0,0.1); } +.tab-panel { display: none; } +.tab-panel.active { display: block; } +.tab-note { margin: .75rem 0; color: var(--text-muted); font-size: .9rem; } + +/* Tab group (quickstart) */ +.tab-group { display: flex; gap: .25rem; background: var(--bg3); padding: .25rem; border-radius: var(--radius-sm); width: fit-content; margin-bottom: .75rem; } + +/* ── BUTTONS ── */ +.btn { + display: inline-flex; align-items: center; gap: 0.4rem; + padding: 0.6rem 1.25rem; + border-radius: var(--radius-sm); + font-weight: 600; font-size: 0.875rem; + text-decoration: none; transition: all 0.15s; + cursor: pointer; border: none; +} +.btn-primary { background: var(--accent); color: #fff; } +.btn-primary:hover { background: var(--accent2); opacity: 1; transform: none; text-decoration: none; } +.btn-secondary { background: var(--bg3); color: var(--text); border: 1px solid var(--border); } +.btn-secondary:hover { border-color: #bbb; text-decoration: none; } +.btn-sm { font-size: 0.8rem; padding: 0.45rem 0.9rem; } +.btn-row { display: flex; gap: .75rem; flex-wrap: wrap; margin-top: 1.25rem; } +.hero-actions { display: flex; gap: 1rem; justify-content: center; flex-wrap: wrap; } + +/* ── EXAMPLES ── */ +.example-split { + display: grid; + grid-template-columns: 220px 1fr; + gap: 2rem; + align-items: start; + max-width: 1100px; + margin: 0 auto; + padding: 0 var(--px); +} +.example-nav { display: flex; flex-direction: column; gap: 0.25rem; } +.example-btn { + display: flex; align-items: center; gap: 0.65rem; + background: none; border: none; + border-radius: var(--radius-sm); + padding: 0.65rem 0.75rem; + cursor: pointer; transition: background 0.1s; + text-align: left; width: 100%; +} +.example-btn:hover { background: var(--bg3); } +.example-btn.active { background: var(--bg3); } +.example-btn-icon { font-size: 1.1rem; flex-shrink: 0; } +.example-btn-label { font-size: 0.875rem; font-weight: 600; color: var(--text); } +.example-btn-sub { font-size: 0.75rem; color: var(--text-muted); margin-top: 0.1rem; } +.example-btn.active .example-btn-label { color: var(--accent); } +.example-panel { display: none; } +.example-panel.active { display: block; } +.example-panel-note { margin-top: 0.5rem; font-size: 0.82rem; color: var(--text-muted); } + +/* ── FEATURES ── */ +.feature-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 0; + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; + margin-top: 2.5rem; +} +.feature-cell { + padding: 1.5rem; + display: flex; gap: 0.875rem; align-items: flex-start; + border-right: 1px solid var(--border); + border-bottom: 1px solid var(--border); + background: var(--bg); +} +.feature-cell:nth-child(3n) { border-right: none; } +.feature-cell:nth-last-child(-n+3) { border-bottom: none; } +.feature-icon { font-size: 1.1rem; flex-shrink: 0; margin-top: 0.15rem; } +.feature-title { font-size: 0.875rem; font-weight: 700; margin-bottom: 0.2rem; color: var(--text); } +.feature-desc { font-size: 0.78rem; color: var(--text-muted); line-height: 1.55; } + +/* ── SECTION HEADER ── */ +.section-header { text-align: center; margin-bottom: 3rem; } +.section-label { display: inline-block; font-size: 0.72rem; font-weight: 700; color: var(--accent); text-transform: uppercase; letter-spacing: 0.12em; margin-bottom: 0.6rem; } +.section-header h2 { font-size: clamp(1.5rem, 3.5vw, 2.2rem); font-weight: 800; letter-spacing: -0.02em; margin-bottom: 0.5rem; } +.section-header p { color: var(--text-muted); max-width: 500px; margin: 0 auto; } +.section-h2 { font-size: 1.4rem; font-weight: 700; letter-spacing: -0.01em; margin-top: 2.5rem; margin-bottom: 1rem; } + +/* ── UV SECTION ── */ +.uv-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 4rem; align-items: center; max-width: 1000px; margin: 0 auto; } +.uv-heading { font-size: clamp(1.4rem, 3vw, 2rem); font-weight: 800; letter-spacing: -0.02em; margin: 0.5rem 0 1rem; } +.uv-desc { color: var(--text-muted); line-height: 1.7; margin-bottom: 1.25rem; } +.uv-code-stack { display: flex; flex-direction: column; gap: 0.5rem; } +.check-list { display: flex; flex-direction: column; gap: 0.5rem; margin-bottom: 1.5rem; } +.check-item { display: flex; align-items: flex-start; gap: 0.5rem; font-size: 0.875rem; color: var(--text-muted); } +.check-item .check { color: var(--accent); flex-shrink: 0; font-weight: 700; } +.gradient-text { color: var(--accent); } + +/* ── CTA ── */ +.cta-terminal { + font-family: var(--font-mono); + font-size: 0.875rem; + background: var(--bg2); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 0.875rem 1.25rem; + display: inline-flex; flex-wrap: wrap; gap: 0.35rem; + margin-bottom: 1.25rem; + max-width: 100%; + overflow-x: auto; + color: var(--text); +} +.cta-terminal .cmt { color: var(--text-dim); } +.cta-terminal .install-prompt { color: var(--accent); } +.cta-terminal .str { color: var(--green); } +.cta-desc { color: var(--text-muted); max-width: 420px; margin: 0 auto 1.5rem; } + +/* ── TABLE ── */ +.table-wrap { overflow-x: auto; } +table { width: 100%; border-collapse: collapse; font-size: 0.875rem; } +th { background: var(--bg3); color: var(--text-muted); font-weight: 600; text-align: left; padding: 0.6rem 1rem; border-bottom: 1px solid var(--border); font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; } +td { padding: 0.65rem 1rem; border-bottom: 1px solid var(--border); vertical-align: top; } +tr:last-child td { border-bottom: none; } +.endpoint-table { overflow-x: auto; margin: 1.25rem 0; border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; } +.type-code { font-size: 0.78rem; color: var(--blue); font-family: var(--font-mono); } + +/* ── SIDEBAR LAYOUT ── */ +.doc-layout { + display: grid; + grid-template-columns: 210px 1fr; + gap: 3rem; + max-width: 1100px; + margin: 0 auto; + padding: 0 var(--px); + align-items: start; +} +.doc-layout > main { min-width: 0; } +.sidebar { position: sticky; top: 72px; } +.sidebar-section { margin-bottom: 1.75rem; } +.sidebar-section h4 { font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.1em; color: var(--text-dim); margin-bottom: 0.4rem; padding-left: 0.5rem; } +.sidebar-link { display: block; padding: 0.3rem 0.5rem; font-size: 0.85rem; color: var(--text-muted); border-radius: var(--radius-sm); text-decoration: none; transition: color 0.1s; } +.sidebar-link:hover { color: var(--text); text-decoration: none; } +.sidebar-link.active { color: var(--accent); font-weight: 600; } + +/* ── PAGE HERO ── */ +.page-hero { + padding: 3.5rem var(--px) 2.5rem; + border-bottom: 1px solid var(--border); + margin-bottom: 2.5rem; + max-width: 1100px; + margin-left: auto; margin-right: auto; +} +.page-hero h1 { font-size: clamp(1.6rem, 3.5vw, 2.4rem); font-weight: 800; letter-spacing: -0.02em; margin-bottom: 0.4rem; } +.page-hero p { color: var(--text-muted); font-size: 1rem; max-width: 560px; } +.breadcrumb { font-size: 0.78rem; color: var(--text-dim); margin-bottom: 1.25rem; } +.breadcrumb a { color: var(--text-dim); } +.breadcrumb a:hover { color: var(--text-muted); } +.breadcrumb span { margin: 0 0.35rem; } + +/* ── FOOTER ── */ +footer { + background: var(--bg2); + border-top: 1px solid var(--border); + padding: 3rem var(--px) 2rem; + margin-top: 4rem; +} +.footer-inner { max-width: 1100px; margin: 0 auto; display: grid; grid-template-columns: 2fr 1fr 1fr 1fr; gap: 2rem; } +.footer-brand p { font-size: 0.85rem; color: var(--text-muted); margin-top: 0.75rem; max-width: 260px; line-height: 1.6; } +.footer-col h4 { font-size: 0.72rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-dim); margin-bottom: 0.75rem; } +.footer-col a { display: block; font-size: 0.875rem; color: var(--text-muted); text-decoration: none; margin-bottom: 0.35rem; transition: color 0.1s; } +.footer-col a:hover { color: var(--text); } +.footer-bottom { max-width: 1100px; margin: 1.5rem auto 0; padding-top: 1.5rem; border-top: 1px solid var(--border); display: flex; justify-content: space-between; align-items: center; font-size: 0.78rem; color: var(--text-dim); flex-wrap: wrap; gap: 0.5rem; } + +/* ── MOBILE MENU ── */ +.mobile-menu { display: none; background: var(--bg); border-bottom: 1px solid var(--border); padding: 0.75rem 1.5rem; flex-direction: column; gap: 0.15rem; } +.mobile-menu a { display: block; padding: 0.45rem 0.5rem; color: var(--text-muted); border-radius: var(--radius-sm); font-size: 0.9rem; text-decoration: none; } +.mobile-menu a:hover { color: var(--text); } +.mobile-menu.open { display: flex; } + +/* ── TIP ── */ +.tip { display: flex; gap: 0.75rem; background: var(--bg2); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 0.875rem 1.1rem; margin: 1.25rem 0; } +.tip-icon { font-size: 1rem; flex-shrink: 0; } +.tip p { font-size: 0.875rem; color: var(--text-muted); margin: 0; } +.tip p strong { color: var(--text); } + +/* ── BADGES ── */ +.badge { display: inline-block; padding: .15rem .45rem; border-radius: 4px; font-size: .7rem; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; } +.badge-feat { background: #dcfce7; color: #16a34a; } +.badge-fix { background: #fee2e2; color: #dc2626; } +.badge-refactor { background: #dbeafe; color: #2563eb; } +.badge-docs { background: #fef9c3; color: #a16207; } +.badge-perf { background: #dbeafe; color: #2563eb; } +.badge-latest { background: var(--accent); color: #fff; } + +/* ── EXAMPLES TABS (home page) ── */ +.examples-tabs { margin-top: 1.5rem; } +.examples-tab-bar { + display: flex; + gap: 0.1rem; + border-bottom: 1px solid var(--border); + margin-bottom: 1.25rem; + flex-wrap: wrap; +} +.ex-tab { + background: none; border: none; + color: var(--text-muted); + font-size: 0.8rem; font-weight: 400; + font-family: inherit; + padding: 0.5rem 0.9rem; + cursor: pointer; + border-bottom: 2px solid transparent; + margin-bottom: -1px; + white-space: nowrap; + transition: color 0.1s; +} +.ex-tab:hover { color: var(--text); } +.ex-tab.active { color: var(--text); font-weight: 600; border-bottom-color: var(--accent); } +.ex-tab-panel { display: none; } +.ex-tab-panel.active { display: block; } + +/* ── EXAMPLE CARDS (python page) ── */ +.example-full-card { background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; margin-bottom: 2rem; } +.example-card-header { padding: 1.1rem 1.25rem; border-bottom: 1px solid var(--border); } +.example-card-icon { font-size: 1rem; } +.example-card-body { padding: 1.25rem; } +.ep-summary { color: var(--text-muted); font-size: 0.875rem; margin-bottom: 1rem; } + +/* ── ENDPOINTS ── */ +.ep-spinner { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 1rem; padding: 8rem 0; color: var(--text-muted); font-size: .9rem; } +.ep-spinner-ring { width: 32px; height: 32px; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: ep-spin .7s linear infinite; flex-shrink: 0; } +@keyframes ep-spin { to { transform: rotate(360deg); } } +.ep-error { color: var(--red); padding: 2rem 0; font-family: var(--font-mono); font-size: .85rem; } +.ep-swagger-tip { font-size: .82rem; color: var(--text-muted); background: var(--bg2); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: .6rem 1rem; margin-bottom: 2rem; } +.ep-swagger-tip a { color: var(--accent); } +.ep-block { margin-bottom: 3rem; scroll-margin-top: 80px; } +.ep-block-header { display: flex; align-items: center; gap: .75rem; margin-bottom: .5rem; } +.ep-path { font-family: var(--font-mono); font-size: .95rem; color: var(--text); font-weight: 600; } +.ep-summary { color: var(--text-muted); font-size: .875rem; margin-bottom: 1rem; } +.ep-curl { display: flex; align-items: center; gap: .6rem; font-family: var(--font-mono); font-size: .78rem; background: var(--bg2); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: .6rem 1rem; margin-top: .75rem; overflow-x: auto; color: var(--text-muted); } +.ep-curl code { color: var(--text); } +.ep-no-params { color: var(--text-dim); font-size: .85rem; padding: .75rem; } +.ep-enum-cell { font-size: .75rem; line-height: 1.8; max-width: 300px; } +.ep-enum-cell code { background: var(--bg3); border: 1px solid var(--border); border-radius: 3px; padding: .1rem .35rem; font-family: var(--font-mono); font-size: .72rem; color: var(--text-muted); } +.method-badge { display: inline-block; font-family: var(--font-mono); font-size: .7rem; font-weight: 700; padding: .15rem .5rem; border-radius: 4px; background: rgba(22,163,74,.12); color: var(--green); letter-spacing: .05em; } +.param-required { display: inline-block; font-size: .68rem; font-weight: 700; padding: .1rem .35rem; border-radius: 4px; background: rgba(224,43,32,.08); color: var(--red); text-transform: uppercase; letter-spacing: .04em; vertical-align: middle; margin-left: .25rem; } +.param-optional { display: inline-block; font-size: .68rem; font-weight: 700; padding: .1rem .35rem; border-radius: 4px; background: var(--bg3); color: var(--text-dim); text-transform: uppercase; letter-spacing: .04em; vertical-align: middle; margin-left: .25rem; } +.default-code { font-size: .78rem; color: var(--text-dim); font-family: var(--font-mono); } + +/* ── CHANGELOG ── */ +.changelog-timeline { margin-top: 2rem; } +.changelog-entry { display: flex; gap: 1.5rem; margin-bottom: 2rem; } +.changelog-line { flex-shrink: 0; width: 1px; background: var(--border); margin-top: .4rem; position: relative; } +.changelog-dot { position: absolute; top: .1rem; left: 50%; transform: translateX(-50%); width: .55rem; height: .55rem; background: var(--accent); border-radius: 50%; border: 2px solid var(--bg); } +.changelog-body { flex: 1; padding-bottom: 1.5rem; border-bottom: 1px solid var(--border); } +.changelog-meta { display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; margin-bottom: .6rem; } +.changelog-date { color: var(--text-dim); font-size: .8rem; } +.changelog-ref { color: var(--text-dim); font-size: .8rem; font-family: var(--font-mono); } +.changelog-title { font-size: .9rem; font-weight: 600; color: var(--text); margin-bottom: .2rem; } +.changelog-desc { font-size: .8rem; color: var(--text-muted); } +.changelog-author { display: inline-flex; align-items: center; gap: .3rem; color: var(--text-dim); font-size: .8rem; text-decoration: none; margin-left: auto; } +.changelog-author:hover { color: var(--text-muted); text-decoration: none; } +.changelog-avatar { width: 16px; height: 16px; border-radius: 50%; border: 1px solid var(--border); vertical-align: middle; } +.changelog-year-group { margin-bottom: 2.5rem; } +.changelog-year { font-size: 0.75rem; font-weight: 700; color: var(--text-dim); text-transform: uppercase; letter-spacing: .1em; margin-bottom: 1rem; padding-bottom: .5rem; border-bottom: 1px solid var(--border); } + +/* ── MISC ── */ +.link { color: var(--accent); } +.link:hover { text-decoration: underline; } +.ref-link { margin-top: .75rem; font-size: .875rem; color: var(--text-muted); } +.inline-run-block { background: var(--bg3); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: .65rem 1rem; display: flex; align-items: center; gap: .75rem; } +.pill { display: inline-flex; align-items: center; gap: .35rem; background: var(--bg3); border: 1px solid var(--border); border-radius: 99px; font-size: 0.8rem; color: var(--text-muted); padding: .25rem .7rem; } +.pill .dot { width: 5px; height: 5px; border-radius: 50%; background: var(--green); flex-shrink: 0; } +.page-loading-bar { height: 2px; background: var(--accent); width: 0; position: fixed; top: 0; left: 0; z-index: 9999; transition: width .3s; } + +/* Tags */ +.tag { display: inline-block; font-size: .7rem; font-weight: 600; padding: .12rem .4rem; border-radius: 4px; font-family: var(--font-mono); } +.tag-optional { background: var(--bg3); color: var(--text-dim); } +.tag-required { background: rgba(224,43,32,.08); color: var(--red); } +.tag-string { background: rgba(22,163,74,.1); color: var(--green); } +.tag-int { background: rgba(217,119,6,.1); color: var(--orange); } +.tag-list { background: rgba(37,99,235,.1); color: var(--blue); } +.tag-enum { background: rgba(224,43,32,.08); color: var(--accent); } + +/* ── RESPONSIVE ── */ +@media (max-width: 860px) { + .hero-split { grid-template-columns: 1fr; gap: 2rem; padding: 3rem 1.25rem 2rem; } + .example-split { grid-template-columns: 1fr; } + .uv-grid { grid-template-columns: 1fr; gap: 2rem; } + .feature-grid { grid-template-columns: 1fr 1fr; } + .feature-cell:nth-child(3n) { border-right: 1px solid var(--border); } + .feature-cell:nth-child(2n) { border-right: none; } + .footer-inner { grid-template-columns: 1fr 1fr; } + .doc-layout { grid-template-columns: 1fr; } + .sidebar { display: none; } +} +@media (max-width: 768px) { + :root { --px: 1rem; } + .nav-links { display: none; } + .gh-badges { display: none; } + .hamburger { display: flex; } + .install-block { font-size: 0.82rem; } + .install-cmd code { font-size: 0.8rem; } + .examples-tab-bar { gap: 0; } + .ex-tab { font-size: 0.75rem; padding: 0.45rem 0.6rem; } + th, td { padding: 0.5rem 0.75rem; } + .ep-path { font-size: 0.85rem; } + .cta-terminal { font-size: 0.78rem; } +} +@media (max-width: 560px) { + :root { --px: 0.875rem; } + .feature-grid { grid-template-columns: 1fr; } + .feature-cell { border-right: none !important; } + .footer-inner { grid-template-columns: 1fr; gap: 1.5rem; } + pre { font-size: 0.76rem; padding: 0.875rem 1rem; } + .hero-title { font-size: 1.9rem; } + .hero-subtitle { font-size: 0.8rem; } + .section-header h2 { font-size: 1.4rem; } + .page-hero h1 { font-size: 1.5rem; } + .btn { font-size: 0.8rem; padding: 0.5rem 1rem; } + .btn-row { flex-direction: column; } + .install-tab { font-size: 0.72rem; padding: 0.45rem 0.6rem; } + table { font-size: 0.75rem; } + th, td { padding: 0.4rem 0.6rem; } +} diff --git a/docs/views/__init__.py b/docs/views/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/docs/views/changelog.py b/docs/views/changelog.py new file mode 100644 index 0000000..f3bd0e9 --- /dev/null +++ b/docs/views/changelog.py @@ -0,0 +1,42 @@ +import htpy as h +from flask import url_for + +from docs.components_base import fragment, page, page_hero + + +def content(): + return [ + page_hero( + "Changelog", + "Merged pull requests from GitHub — features, fixes, and improvements.", + "Changelog", + ), + h.section(".section")[ + h.div(".container.container-narrow")[ + h.div( + "#cl-root", + hx_get=url_for("changelog_body"), + hx_trigger="load", + hx_swap="innerHTML", + )[ + h.div(".ep-spinner")[ + h.div(".ep-spinner-ring"), + h.span["Loading changelog…"], + ], + ], + ], + ], + ] + + +def view(): + return page( + active="changelog", + title="Changelog — BlocketAPI", + description="Full release history for BlocketAPI — Python library and REST API. Features, fixes and new endpoints.", + content_fn=content, + ) + + +def fragment_view(): + return fragment(content) diff --git a/docs/views/endpoints.py b/docs/views/endpoints.py new file mode 100644 index 0000000..b94f333 --- /dev/null +++ b/docs/views/endpoints.py @@ -0,0 +1,42 @@ +import htpy as h +from flask import url_for + +from docs.components_base import fragment, page, page_hero + + +def content(): + return [ + page_hero( + "Endpoint Reference", + "Full parameter documentation for every BlocketAPI endpoint. All endpoints return JSON.", + "Endpoints", + ), + h.section(".section")[ + h.div(".container")[ + h.div( + "#ep-root", + hx_get=url_for("endpoints_body"), + hx_trigger="load", + hx_swap="innerHTML", + )[ + h.div(".ep-spinner")[ + h.div(".ep-spinner-ring"), + h.span["Loading API spec…"], + ], + ], + ], + ], + ] + + +def view(): + return page( + active="endpoints", + title="Endpoints — BlocketAPI", + description="Full parameter reference for all BlocketAPI REST endpoints: search, car, motorcycle, boat, and ad detail.", + content_fn=content, + ) + + +def fragment_view(): + return fragment(content) diff --git a/docs/views/examples.py b/docs/views/examples.py new file mode 100644 index 0000000..34f6671 --- /dev/null +++ b/docs/views/examples.py @@ -0,0 +1,55 @@ +import htpy as h + +from docs.components import examples as c +from docs.components_base import ( + GITHUB, + fragment, + page, + page_hero, + section_header, + tip_box, +) + + +def content(): + return [ + page_hero( + "Examples", + "Ready-to-run code snippets covering the most common use cases. Copy, paste, run.", + "Examples", + ), + h.section(".section")[ + h.div(".container")[ + section_header( + "Code Examples", + "From simple searches to production scripts", + "Every example runs standalone with uv — no environment setup required.", + ), + h.div(".example-cards")[c.cards()], + tip_box( + "🤝", + [ + "Have an example you'd like to share? ", + h.a(".link", href=GITHUB + "/issues", target="_blank")[ + "Open an issue on GitHub ↗" + ], + " or submit a PR.", + ], + kind="tip", + ), + ], + ], + ] + + +def view(): + return page( + active="examples", + title="Examples — BlocketAPI", + description="Ready-to-run BlocketAPI code examples: general search, car filters, price alerts, CSV export, pagination, and more.", + content_fn=content, + ) + + +def fragment_view(): + return fragment(content) diff --git a/docs/views/index.py b/docs/views/index.py new file mode 100644 index 0000000..bc8342d --- /dev/null +++ b/docs/views/index.py @@ -0,0 +1,25 @@ +from docs.components import index as c +from docs.components_base import fragment, page + + +def content(): + return [ + c.hero(), + c.examples_section(), + c.features(), + c.uv_section(), + c.cta(), + ] + + +def view(): + return page( + active="index", + title="BlocketAPI — The unofficial API for blocket.se", + description="BlocketAPI is the leading developer API for blocket.se. Search ads, cars, boats, motorcycles and more. Free to use, no API key required.", + content_fn=content, + ) + + +def fragment_view(): + return fragment(content) diff --git a/docs/views/python.py b/docs/views/python.py new file mode 100644 index 0000000..c37b56e --- /dev/null +++ b/docs/views/python.py @@ -0,0 +1,38 @@ +import htpy as h + +from docs.components import python_lib as c +from docs.components_base import doc_layout, fragment, page, page_hero + + +def content(): + return [ + page_hero( + "Python Library", + "Use BlocketAPI as a Python package — typed enums, clean return values, no boilerplate.", + "Python", + ), + h.section(".section")[ + h.div(".container")[ + doc_layout( + c.sidebar(), + [ + *c.install_section(), + *c.methods_section(), + ], + ), + ], + ], + ] + + +def view(): + return page( + active="python", + title="Python Library — BlocketAPI", + description="BlocketAPI Python package documentation — search, car, boat, motorcycle and ad detail methods.", + content_fn=content, + ) + + +def fragment_view(): + return fragment(content) diff --git a/docs/views/quickstart.py b/docs/views/quickstart.py new file mode 100644 index 0000000..62ffd9e --- /dev/null +++ b/docs/views/quickstart.py @@ -0,0 +1,39 @@ +import htpy as h + +from docs.components import quickstart as c +from docs.components_base import doc_layout, fragment, page, page_hero + + +def content(): + return [ + page_hero( + "Quickstart", + "Get from zero to your first API response in under 5 minutes — no account, no API key.", + "Quickstart", + ), + h.section(".section")[ + h.div(".container")[ + doc_layout( + c.sidebar(), + [ + *c.installation(), + *c.first_search(), + *c.uv_section(), + ], + ), + ], + ], + ] + + +def view(): + return page( + active="quickstart", + title="Quickstart — BlocketAPI", + description="Install BlocketAPI and make your first search in under 5 minutes. Supports Python, REST, curl and JavaScript.", + content_fn=content, + ) + + +def fragment_view(): + return fragment(content) diff --git a/docs/views/tips.py b/docs/views/tips.py new file mode 100644 index 0000000..a19c3bd --- /dev/null +++ b/docs/views/tips.py @@ -0,0 +1,43 @@ +import htpy as h + +from docs.components import tips as c +from docs.components_base import doc_layout, fragment, page, page_hero + + +def content(): + return [ + page_hero( + "Tips & Tricks", + "Patterns and techniques to get the most out of BlocketAPI — from automation to market analysis.", + "Tips & Tricks", + ), + h.section(".section")[ + h.div(".container")[ + doc_layout( + c.sidebar(), + [ + *c.uv_tip(), + *c.cron_tip(), + *c.pagination_tip(), + *c.sorting_tip(), + *c.location_tip(), + *c.orgid_tip(), + *c.analysis_tip(), + ], + ), + ], + ], + ] + + +def view(): + return page( + active="tips", + title="Tips & Tricks — BlocketAPI", + description="BlocketAPI tips: running scripts with uv, scheduling with cron, pagination, multi-location searches, price analysis and more.", + content_fn=content, + ) + + +def fragment_view(): + return fragment(content) diff --git a/flake.nix b/flake.nix index 0689a9f..65bdbb2 100644 --- a/flake.nix +++ b/flake.nix @@ -23,16 +23,10 @@ pkgs.uv pkgs.ruff python - (pkgs.writeShellScriptBin "build" '' - git pull - mkdocs build -d /srv/blocket-api/ - '') - ]; shellHook = '' - uv venv + uv sync --extra dev source .venv/bin/activate - uv pip sync requirements.txt pre-commit install --overwrite set -a source .env 2> /dev/null diff --git a/pyproject.toml b/pyproject.toml index 1eac3db..3a9ecbc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,14 +7,17 @@ readme = "README.md" requires-python = ">=3.10" dependencies = [ "httpx>=0.28.1", + "beautifulsoup4>=4.14.3", +] + +[project.optional-dependencies] +dev = [ "pre-commit>=4.5.1", "pytest>=9.0.2", "respx>=0.22.0", "mypy>=1.19.1", - "mkdocs>=1.6.1", - "mkdocs-material>=9.7.1", - "mkdocs-swagger-ui-tag>=0.7.2", - "beautifulsoup4>=4.14.3", + "flask>=3.1.3", + "htpy>=26.5.1", ] diff --git a/requirements.txt b/requirements.txt index 07342e1..9215f6b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,141 +2,25 @@ # uv pip compile pyproject.toml -o requirements.txt anyio==4.9.0 # via httpx -babel==2.17.0 - # via mkdocs-material -backrefs==5.9 - # via mkdocs-material beautifulsoup4==4.14.3 - # via - # blocket-api (pyproject.toml) - # mkdocs-swagger-ui-tag + # via blocket-api (pyproject.toml) certifi==2025.6.15 # via # httpcore # httpx - # requests -cfgv==3.4.0 - # via pre-commit -charset-normalizer==3.4.3 - # via requests -click==8.3.0 - # via mkdocs -colorama==0.4.6 - # via mkdocs-material -distlib==0.3.9 - # via virtualenv -filelock==3.18.0 - # via virtualenv -ghp-import==2.1.0 - # via mkdocs h11==0.16.0 # via httpcore httpcore==1.0.9 # via httpx httpx==0.28.1 - # via - # blocket-api (pyproject.toml) - # respx -identify==2.6.12 - # via pre-commit + # via blocket-api (pyproject.toml) idna==3.10 # via # anyio # httpx - # requests -iniconfig==2.1.0 - # via pytest -jinja2==3.1.6 - # via - # mkdocs - # mkdocs-material -librt==0.7.7 - # via mypy -markdown==3.9 - # via - # mkdocs - # mkdocs-material - # pymdown-extensions -markupsafe==3.0.3 - # via - # jinja2 - # mkdocs -mergedeep==1.3.4 - # via - # mkdocs - # mkdocs-get-deps -mkdocs==1.6.1 - # via - # blocket-api (pyproject.toml) - # mkdocs-material -mkdocs-get-deps==0.2.0 - # via mkdocs -mkdocs-material==9.7.1 - # via blocket-api (pyproject.toml) -mkdocs-material-extensions==1.3.1 - # via mkdocs-material -mkdocs-swagger-ui-tag==0.7.2 - # via blocket-api (pyproject.toml) -mypy==1.19.1 - # via blocket-api (pyproject.toml) -mypy-extensions==1.1.0 - # via mypy -nodeenv==1.9.1 - # via pre-commit -packaging==25.0 - # via - # mkdocs - # pytest -paginate==0.5.7 - # via mkdocs-material -pathspec==0.12.1 - # via - # mkdocs - # mypy -platformdirs==4.3.8 - # via - # mkdocs-get-deps - # virtualenv -pluggy==1.6.0 - # via pytest -pre-commit==4.5.1 - # via blocket-api (pyproject.toml) -pygments==2.19.2 - # via - # mkdocs-material - # pytest -pymdown-extensions==10.16.1 - # via mkdocs-material -pytest==9.0.2 - # via blocket-api (pyproject.toml) -python-dateutil==2.9.0.post0 - # via ghp-import -pyyaml==6.0.2 - # via - # mkdocs - # mkdocs-get-deps - # pre-commit - # pymdown-extensions - # pyyaml-env-tag -pyyaml-env-tag==1.1 - # via mkdocs -requests==2.32.5 - # via mkdocs-material -respx==0.22.0 - # via blocket-api (pyproject.toml) -six==1.17.0 - # via python-dateutil sniffio==1.3.1 # via anyio soupsieve==2.8 # via beautifulsoup4 typing-extensions==4.14.1 - # via - # beautifulsoup4 - # mypy -urllib3==2.5.0 - # via requests -virtualenv==20.31.2 - # via pre-commit -watchdog==6.0.0 - # via mkdocs + # via beautifulsoup4 diff --git a/tests/docs.py b/tests/docs.py deleted file mode 100644 index 59d794f..0000000 --- a/tests/docs.py +++ /dev/null @@ -1,22 +0,0 @@ -import os -import shutil -import tempfile - -from mkdocs.commands.build import build -from mkdocs.config import load_config - - -def test_mkdocs_valid_config() -> None: - config = load_config("mkdocs.yml") - assert config["site_name"] == "Blocket-API.se" - - -def test_mkdocs_build() -> None: - tmpdir = tempfile.mkdtemp() - config = load_config("mkdocs.yml") - config["site_dir"] = tmpdir - build(config) - - index = os.path.join(tmpdir, "index.html") - assert os.path.exists(index) - shutil.rmtree(tmpdir) diff --git a/uv.lock b/uv.lock index 1727271..7316dae 100644 --- a/uv.lock +++ b/uv.lock @@ -17,29 +17,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a1/ee/48ca1a7c89ffec8b6a0c5d02b89c305671d5ffd8d3c94acf8b8c408575bb/anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c", size = 100916, upload-time = "2025-03-17T00:02:52.713Z" }, ] -[[package]] -name = "babel" -version = "2.17.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7d/6b/d52e42361e1aa00709585ecc30b3f9684b3ab62530771402248b1b1d6240/babel-2.17.0.tar.gz", hash = "sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d", size = 9951852, upload-time = "2025-02-01T15:17:41.026Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/b8/3fe70c75fe32afc4bb507f75563d39bc5642255d1d94f1f23604725780bf/babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2", size = 10182537, upload-time = "2025-02-01T15:17:37.39Z" }, -] - -[[package]] -name = "backrefs" -version = "5.9" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/eb/a7/312f673df6a79003279e1f55619abbe7daebbb87c17c976ddc0345c04c7b/backrefs-5.9.tar.gz", hash = "sha256:808548cb708d66b82ee231f962cb36faaf4f2baab032f2fbb783e9c2fdddaa59", size = 5765857, upload-time = "2025-06-22T19:34:13.97Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/19/4d/798dc1f30468134906575156c089c492cf79b5a5fd373f07fe26c4d046bf/backrefs-5.9-py310-none-any.whl", hash = "sha256:db8e8ba0e9de81fcd635f440deab5ae5f2591b54ac1ebe0550a2ca063488cd9f", size = 380267, upload-time = "2025-06-22T19:34:05.252Z" }, - { url = "https://files.pythonhosted.org/packages/55/07/f0b3375bf0d06014e9787797e6b7cc02b38ac9ff9726ccfe834d94e9991e/backrefs-5.9-py311-none-any.whl", hash = "sha256:6907635edebbe9b2dc3de3a2befff44d74f30a4562adbb8b36f21252ea19c5cf", size = 392072, upload-time = "2025-06-22T19:34:06.743Z" }, - { url = "https://files.pythonhosted.org/packages/9d/12/4f345407259dd60a0997107758ba3f221cf89a9b5a0f8ed5b961aef97253/backrefs-5.9-py312-none-any.whl", hash = "sha256:7fdf9771f63e6028d7fee7e0c497c81abda597ea45d6b8f89e8ad76994f5befa", size = 397947, upload-time = "2025-06-22T19:34:08.172Z" }, - { url = "https://files.pythonhosted.org/packages/10/bf/fa31834dc27a7f05e5290eae47c82690edc3a7b37d58f7fb35a1bdbf355b/backrefs-5.9-py313-none-any.whl", hash = "sha256:cc37b19fa219e93ff825ed1fed8879e47b4d89aa7a1884860e2db64ccd7c676b", size = 399843, upload-time = "2025-06-22T19:34:09.68Z" }, - { url = "https://files.pythonhosted.org/packages/fc/24/b29af34b2c9c41645a9f4ff117bae860291780d73880f449e0b5d948c070/backrefs-5.9-py314-none-any.whl", hash = "sha256:df5e169836cc8acb5e440ebae9aad4bf9d15e226d3bad049cf3f6a5c20cc8dc9", size = 411762, upload-time = "2025-06-22T19:34:11.037Z" }, - { url = "https://files.pythonhosted.org/packages/41/ff/392bff89415399a979be4a65357a41d92729ae8580a66073d8ec8d810f98/backrefs-5.9-py39-none-any.whl", hash = "sha256:f48ee18f6252b8f5777a22a00a09a85de0ca931658f1dd96d4406a34f3748c60", size = 380265, upload-time = "2025-06-22T19:34:12.405Z" }, -] - [[package]] name = "beautifulsoup4" version = "4.14.3" @@ -53,6 +30,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" }, ] +[[package]] +name = "blinker" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, +] + [[package]] name = "blocket-api" version = "0.5.1" @@ -60,9 +46,12 @@ source = { virtual = "." } dependencies = [ { name = "beautifulsoup4" }, { name = "httpx" }, - { name = "mkdocs" }, - { name = "mkdocs-material" }, - { name = "mkdocs-swagger-ui-tag" }, +] + +[package.optional-dependencies] +dev = [ + { name = "flask" }, + { name = "htpy" }, { name = "mypy" }, { name = "pre-commit" }, { name = "pytest" }, @@ -72,15 +61,15 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "beautifulsoup4", specifier = ">=4.14.3" }, + { name = "flask", marker = "extra == 'dev'", specifier = ">=3.1.3" }, + { name = "htpy", marker = "extra == 'dev'", specifier = ">=26.5.1" }, { name = "httpx", specifier = ">=0.28.1" }, - { name = "mkdocs", specifier = ">=1.6.1" }, - { name = "mkdocs-material", specifier = ">=9.7.1" }, - { name = "mkdocs-swagger-ui-tag", specifier = ">=0.7.2" }, - { name = "mypy", specifier = ">=1.19.1" }, - { name = "pre-commit", specifier = ">=4.5.1" }, - { name = "pytest", specifier = ">=9.0.2" }, - { name = "respx", specifier = ">=0.22.0" }, + { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.19.1" }, + { name = "pre-commit", marker = "extra == 'dev'", specifier = ">=4.5.1" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.2" }, + { name = "respx", marker = "extra == 'dev'", specifier = ">=0.22.0" }, ] +provides-extras = ["dev"] [[package]] name = "certifi" @@ -100,70 +89,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", size = 7249, upload-time = "2023-08-12T20:38:16.269Z" }, ] -[[package]] -name = "charset-normalizer" -version = "3.4.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/83/2d/5fd176ceb9b2fc619e63405525573493ca23441330fcdaee6bef9460e924/charset_normalizer-3.4.3.tar.gz", hash = "sha256:6fce4b8500244f6fcb71465d4a4930d132ba9ab8e71a7859e6a5d59851068d14", size = 122371, upload-time = "2025-08-09T07:57:28.46Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d6/98/f3b8013223728a99b908c9344da3aa04ee6e3fa235f19409033eda92fb78/charset_normalizer-3.4.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:fb7f67a1bfa6e40b438170ebdc8158b78dc465a5a67b6dde178a46987b244a72", size = 207695, upload-time = "2025-08-09T07:55:36.452Z" }, - { url = "https://files.pythonhosted.org/packages/21/40/5188be1e3118c82dcb7c2a5ba101b783822cfb413a0268ed3be0468532de/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc9370a2da1ac13f0153780040f465839e6cccb4a1e44810124b4e22483c93fe", size = 147153, upload-time = "2025-08-09T07:55:38.467Z" }, - { url = "https://files.pythonhosted.org/packages/37/60/5d0d74bc1e1380f0b72c327948d9c2aca14b46a9efd87604e724260f384c/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:07a0eae9e2787b586e129fdcbe1af6997f8d0e5abaa0bc98c0e20e124d67e601", size = 160428, upload-time = "2025-08-09T07:55:40.072Z" }, - { url = "https://files.pythonhosted.org/packages/85/9a/d891f63722d9158688de58d050c59dc3da560ea7f04f4c53e769de5140f5/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:74d77e25adda8581ffc1c720f1c81ca082921329452eba58b16233ab1842141c", size = 157627, upload-time = "2025-08-09T07:55:41.706Z" }, - { url = "https://files.pythonhosted.org/packages/65/1a/7425c952944a6521a9cfa7e675343f83fd82085b8af2b1373a2409c683dc/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d0e909868420b7049dafd3a31d45125b31143eec59235311fc4c57ea26a4acd2", size = 152388, upload-time = "2025-08-09T07:55:43.262Z" }, - { url = "https://files.pythonhosted.org/packages/f0/c9/a2c9c2a355a8594ce2446085e2ec97fd44d323c684ff32042e2a6b718e1d/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c6f162aabe9a91a309510d74eeb6507fab5fff92337a15acbe77753d88d9dcf0", size = 150077, upload-time = "2025-08-09T07:55:44.903Z" }, - { url = "https://files.pythonhosted.org/packages/3b/38/20a1f44e4851aa1c9105d6e7110c9d020e093dfa5836d712a5f074a12bf7/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:4ca4c094de7771a98d7fbd67d9e5dbf1eb73efa4f744a730437d8a3a5cf994f0", size = 161631, upload-time = "2025-08-09T07:55:46.346Z" }, - { url = "https://files.pythonhosted.org/packages/a4/fa/384d2c0f57edad03d7bec3ebefb462090d8905b4ff5a2d2525f3bb711fac/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:02425242e96bcf29a49711b0ca9f37e451da7c70562bc10e8ed992a5a7a25cc0", size = 159210, upload-time = "2025-08-09T07:55:47.539Z" }, - { url = "https://files.pythonhosted.org/packages/33/9e/eca49d35867ca2db336b6ca27617deed4653b97ebf45dfc21311ce473c37/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:78deba4d8f9590fe4dae384aeff04082510a709957e968753ff3c48399f6f92a", size = 153739, upload-time = "2025-08-09T07:55:48.744Z" }, - { url = "https://files.pythonhosted.org/packages/2a/91/26c3036e62dfe8de8061182d33be5025e2424002125c9500faff74a6735e/charset_normalizer-3.4.3-cp310-cp310-win32.whl", hash = "sha256:d79c198e27580c8e958906f803e63cddb77653731be08851c7df0b1a14a8fc0f", size = 99825, upload-time = "2025-08-09T07:55:50.305Z" }, - { url = "https://files.pythonhosted.org/packages/e2/c6/f05db471f81af1fa01839d44ae2a8bfeec8d2a8b4590f16c4e7393afd323/charset_normalizer-3.4.3-cp310-cp310-win_amd64.whl", hash = "sha256:c6e490913a46fa054e03699c70019ab869e990270597018cef1d8562132c2669", size = 107452, upload-time = "2025-08-09T07:55:51.461Z" }, - { url = "https://files.pythonhosted.org/packages/7f/b5/991245018615474a60965a7c9cd2b4efbaabd16d582a5547c47ee1c7730b/charset_normalizer-3.4.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b256ee2e749283ef3ddcff51a675ff43798d92d746d1a6e4631bf8c707d22d0b", size = 204483, upload-time = "2025-08-09T07:55:53.12Z" }, - { url = "https://files.pythonhosted.org/packages/c7/2a/ae245c41c06299ec18262825c1569c5d3298fc920e4ddf56ab011b417efd/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13faeacfe61784e2559e690fc53fa4c5ae97c6fcedb8eb6fb8d0a15b475d2c64", size = 145520, upload-time = "2025-08-09T07:55:54.712Z" }, - { url = "https://files.pythonhosted.org/packages/3a/a4/b3b6c76e7a635748c4421d2b92c7b8f90a432f98bda5082049af37ffc8e3/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:00237675befef519d9af72169d8604a067d92755e84fe76492fef5441db05b91", size = 158876, upload-time = "2025-08-09T07:55:56.024Z" }, - { url = "https://files.pythonhosted.org/packages/e2/e6/63bb0e10f90a8243c5def74b5b105b3bbbfb3e7bb753915fe333fb0c11ea/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:585f3b2a80fbd26b048a0be90c5aae8f06605d3c92615911c3a2b03a8a3b796f", size = 156083, upload-time = "2025-08-09T07:55:57.582Z" }, - { url = "https://files.pythonhosted.org/packages/87/df/b7737ff046c974b183ea9aa111b74185ac8c3a326c6262d413bd5a1b8c69/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e78314bdc32fa80696f72fa16dc61168fda4d6a0c014e0380f9d02f0e5d8a07", size = 150295, upload-time = "2025-08-09T07:55:59.147Z" }, - { url = "https://files.pythonhosted.org/packages/61/f1/190d9977e0084d3f1dc169acd060d479bbbc71b90bf3e7bf7b9927dec3eb/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:96b2b3d1a83ad55310de8c7b4a2d04d9277d5591f40761274856635acc5fcb30", size = 148379, upload-time = "2025-08-09T07:56:00.364Z" }, - { url = "https://files.pythonhosted.org/packages/4c/92/27dbe365d34c68cfe0ca76f1edd70e8705d82b378cb54ebbaeabc2e3029d/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:939578d9d8fd4299220161fdd76e86c6a251987476f5243e8864a7844476ba14", size = 160018, upload-time = "2025-08-09T07:56:01.678Z" }, - { url = "https://files.pythonhosted.org/packages/99/04/baae2a1ea1893a01635d475b9261c889a18fd48393634b6270827869fa34/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:fd10de089bcdcd1be95a2f73dbe6254798ec1bda9f450d5828c96f93e2536b9c", size = 157430, upload-time = "2025-08-09T07:56:02.87Z" }, - { url = "https://files.pythonhosted.org/packages/2f/36/77da9c6a328c54d17b960c89eccacfab8271fdaaa228305330915b88afa9/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1e8ac75d72fa3775e0b7cb7e4629cec13b7514d928d15ef8ea06bca03ef01cae", size = 151600, upload-time = "2025-08-09T07:56:04.089Z" }, - { url = "https://files.pythonhosted.org/packages/64/d4/9eb4ff2c167edbbf08cdd28e19078bf195762e9bd63371689cab5ecd3d0d/charset_normalizer-3.4.3-cp311-cp311-win32.whl", hash = "sha256:6cf8fd4c04756b6b60146d98cd8a77d0cdae0e1ca20329da2ac85eed779b6849", size = 99616, upload-time = "2025-08-09T07:56:05.658Z" }, - { url = "https://files.pythonhosted.org/packages/f4/9c/996a4a028222e7761a96634d1820de8a744ff4327a00ada9c8942033089b/charset_normalizer-3.4.3-cp311-cp311-win_amd64.whl", hash = "sha256:31a9a6f775f9bcd865d88ee350f0ffb0e25936a7f930ca98995c05abf1faf21c", size = 107108, upload-time = "2025-08-09T07:56:07.176Z" }, - { url = "https://files.pythonhosted.org/packages/e9/5e/14c94999e418d9b87682734589404a25854d5f5d0408df68bc15b6ff54bb/charset_normalizer-3.4.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e28e334d3ff134e88989d90ba04b47d84382a828c061d0d1027b1b12a62b39b1", size = 205655, upload-time = "2025-08-09T07:56:08.475Z" }, - { url = "https://files.pythonhosted.org/packages/7d/a8/c6ec5d389672521f644505a257f50544c074cf5fc292d5390331cd6fc9c3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0cacf8f7297b0c4fcb74227692ca46b4a5852f8f4f24b3c766dd94a1075c4884", size = 146223, upload-time = "2025-08-09T07:56:09.708Z" }, - { url = "https://files.pythonhosted.org/packages/fc/eb/a2ffb08547f4e1e5415fb69eb7db25932c52a52bed371429648db4d84fb1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c6fd51128a41297f5409deab284fecbe5305ebd7e5a1f959bee1c054622b7018", size = 159366, upload-time = "2025-08-09T07:56:11.326Z" }, - { url = "https://files.pythonhosted.org/packages/82/10/0fd19f20c624b278dddaf83b8464dcddc2456cb4b02bb902a6da126b87a1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cfb2aad70f2c6debfbcb717f23b7eb55febc0bb23dcffc0f076009da10c6392", size = 157104, upload-time = "2025-08-09T07:56:13.014Z" }, - { url = "https://files.pythonhosted.org/packages/16/ab/0233c3231af734f5dfcf0844aa9582d5a1466c985bbed6cedab85af9bfe3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1606f4a55c0fd363d754049cdf400175ee96c992b1f8018b993941f221221c5f", size = 151830, upload-time = "2025-08-09T07:56:14.428Z" }, - { url = "https://files.pythonhosted.org/packages/ae/02/e29e22b4e02839a0e4a06557b1999d0a47db3567e82989b5bb21f3fbbd9f/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:027b776c26d38b7f15b26a5da1044f376455fb3766df8fc38563b4efbc515154", size = 148854, upload-time = "2025-08-09T07:56:16.051Z" }, - { url = "https://files.pythonhosted.org/packages/05/6b/e2539a0a4be302b481e8cafb5af8792da8093b486885a1ae4d15d452bcec/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:42e5088973e56e31e4fa58eb6bd709e42fc03799c11c42929592889a2e54c491", size = 160670, upload-time = "2025-08-09T07:56:17.314Z" }, - { url = "https://files.pythonhosted.org/packages/31/e7/883ee5676a2ef217a40ce0bffcc3d0dfbf9e64cbcfbdf822c52981c3304b/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cc34f233c9e71701040d772aa7490318673aa7164a0efe3172b2981218c26d93", size = 158501, upload-time = "2025-08-09T07:56:18.641Z" }, - { url = "https://files.pythonhosted.org/packages/c1/35/6525b21aa0db614cf8b5792d232021dca3df7f90a1944db934efa5d20bb1/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:320e8e66157cc4e247d9ddca8e21f427efc7a04bbd0ac8a9faf56583fa543f9f", size = 153173, upload-time = "2025-08-09T07:56:20.289Z" }, - { url = "https://files.pythonhosted.org/packages/50/ee/f4704bad8201de513fdc8aac1cabc87e38c5818c93857140e06e772b5892/charset_normalizer-3.4.3-cp312-cp312-win32.whl", hash = "sha256:fb6fecfd65564f208cbf0fba07f107fb661bcd1a7c389edbced3f7a493f70e37", size = 99822, upload-time = "2025-08-09T07:56:21.551Z" }, - { url = "https://files.pythonhosted.org/packages/39/f5/3b3836ca6064d0992c58c7561c6b6eee1b3892e9665d650c803bd5614522/charset_normalizer-3.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:86df271bf921c2ee3818f0522e9a5b8092ca2ad8b065ece5d7d9d0e9f4849bcc", size = 107543, upload-time = "2025-08-09T07:56:23.115Z" }, - { url = "https://files.pythonhosted.org/packages/65/ca/2135ac97709b400c7654b4b764daf5c5567c2da45a30cdd20f9eefe2d658/charset_normalizer-3.4.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:14c2a87c65b351109f6abfc424cab3927b3bdece6f706e4d12faaf3d52ee5efe", size = 205326, upload-time = "2025-08-09T07:56:24.721Z" }, - { url = "https://files.pythonhosted.org/packages/71/11/98a04c3c97dd34e49c7d247083af03645ca3730809a5509443f3c37f7c99/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41d1fc408ff5fdfb910200ec0e74abc40387bccb3252f3f27c0676731df2b2c8", size = 146008, upload-time = "2025-08-09T07:56:26.004Z" }, - { url = "https://files.pythonhosted.org/packages/60/f5/4659a4cb3c4ec146bec80c32d8bb16033752574c20b1252ee842a95d1a1e/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1bb60174149316da1c35fa5233681f7c0f9f514509b8e399ab70fea5f17e45c9", size = 159196, upload-time = "2025-08-09T07:56:27.25Z" }, - { url = "https://files.pythonhosted.org/packages/86/9e/f552f7a00611f168b9a5865a1414179b2c6de8235a4fa40189f6f79a1753/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30d006f98569de3459c2fc1f2acde170b7b2bd265dc1943e87e1a4efe1b67c31", size = 156819, upload-time = "2025-08-09T07:56:28.515Z" }, - { url = "https://files.pythonhosted.org/packages/7e/95/42aa2156235cbc8fa61208aded06ef46111c4d3f0de233107b3f38631803/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:416175faf02e4b0810f1f38bcb54682878a4af94059a1cd63b8747244420801f", size = 151350, upload-time = "2025-08-09T07:56:29.716Z" }, - { url = "https://files.pythonhosted.org/packages/c2/a9/3865b02c56f300a6f94fc631ef54f0a8a29da74fb45a773dfd3dcd380af7/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6aab0f181c486f973bc7262a97f5aca3ee7e1437011ef0c2ec04b5a11d16c927", size = 148644, upload-time = "2025-08-09T07:56:30.984Z" }, - { url = "https://files.pythonhosted.org/packages/77/d9/cbcf1a2a5c7d7856f11e7ac2d782aec12bdfea60d104e60e0aa1c97849dc/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:fdabf8315679312cfa71302f9bd509ded4f2f263fb5b765cf1433b39106c3cc9", size = 160468, upload-time = "2025-08-09T07:56:32.252Z" }, - { url = "https://files.pythonhosted.org/packages/f6/42/6f45efee8697b89fda4d50580f292b8f7f9306cb2971d4b53f8914e4d890/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:bd28b817ea8c70215401f657edef3a8aa83c29d447fb0b622c35403780ba11d5", size = 158187, upload-time = "2025-08-09T07:56:33.481Z" }, - { url = "https://files.pythonhosted.org/packages/70/99/f1c3bdcfaa9c45b3ce96f70b14f070411366fa19549c1d4832c935d8e2c3/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:18343b2d246dc6761a249ba1fb13f9ee9a2bcd95decc767319506056ea4ad4dc", size = 152699, upload-time = "2025-08-09T07:56:34.739Z" }, - { url = "https://files.pythonhosted.org/packages/a3/ad/b0081f2f99a4b194bcbb1934ef3b12aa4d9702ced80a37026b7607c72e58/charset_normalizer-3.4.3-cp313-cp313-win32.whl", hash = "sha256:6fb70de56f1859a3f71261cbe41005f56a7842cc348d3aeb26237560bfa5e0ce", size = 99580, upload-time = "2025-08-09T07:56:35.981Z" }, - { url = "https://files.pythonhosted.org/packages/9a/8f/ae790790c7b64f925e5c953b924aaa42a243fb778fed9e41f147b2a5715a/charset_normalizer-3.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:cf1ebb7d78e1ad8ec2a8c4732c7be2e736f6e5123a4146c5b89c9d1f585f8cef", size = 107366, upload-time = "2025-08-09T07:56:37.339Z" }, - { url = "https://files.pythonhosted.org/packages/8e/91/b5a06ad970ddc7a0e513112d40113e834638f4ca1120eb727a249fb2715e/charset_normalizer-3.4.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3cd35b7e8aedeb9e34c41385fda4f73ba609e561faedfae0a9e75e44ac558a15", size = 204342, upload-time = "2025-08-09T07:56:38.687Z" }, - { url = "https://files.pythonhosted.org/packages/ce/ec/1edc30a377f0a02689342f214455c3f6c2fbedd896a1d2f856c002fc3062/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b89bc04de1d83006373429975f8ef9e7932534b8cc9ca582e4db7d20d91816db", size = 145995, upload-time = "2025-08-09T07:56:40.048Z" }, - { url = "https://files.pythonhosted.org/packages/17/e5/5e67ab85e6d22b04641acb5399c8684f4d37caf7558a53859f0283a650e9/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2001a39612b241dae17b4687898843f254f8748b796a2e16f1051a17078d991d", size = 158640, upload-time = "2025-08-09T07:56:41.311Z" }, - { url = "https://files.pythonhosted.org/packages/f1/e5/38421987f6c697ee3722981289d554957c4be652f963d71c5e46a262e135/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8dcfc373f888e4fb39a7bc57e93e3b845e7f462dacc008d9749568b1c4ece096", size = 156636, upload-time = "2025-08-09T07:56:43.195Z" }, - { url = "https://files.pythonhosted.org/packages/a0/e4/5a075de8daa3ec0745a9a3b54467e0c2967daaaf2cec04c845f73493e9a1/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18b97b8404387b96cdbd30ad660f6407799126d26a39ca65729162fd810a99aa", size = 150939, upload-time = "2025-08-09T07:56:44.819Z" }, - { url = "https://files.pythonhosted.org/packages/02/f7/3611b32318b30974131db62b4043f335861d4d9b49adc6d57c1149cc49d4/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ccf600859c183d70eb47e05a44cd80a4ce77394d1ac0f79dbd2dd90a69a3a049", size = 148580, upload-time = "2025-08-09T07:56:46.684Z" }, - { url = "https://files.pythonhosted.org/packages/7e/61/19b36f4bd67f2793ab6a99b979b4e4f3d8fc754cbdffb805335df4337126/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:53cd68b185d98dde4ad8990e56a58dea83a4162161b1ea9272e5c9182ce415e0", size = 159870, upload-time = "2025-08-09T07:56:47.941Z" }, - { url = "https://files.pythonhosted.org/packages/06/57/84722eefdd338c04cf3030ada66889298eaedf3e7a30a624201e0cbe424a/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:30a96e1e1f865f78b030d65241c1ee850cdf422d869e9028e2fc1d5e4db73b92", size = 157797, upload-time = "2025-08-09T07:56:49.756Z" }, - { url = "https://files.pythonhosted.org/packages/72/2a/aff5dd112b2f14bcc3462c312dce5445806bfc8ab3a7328555da95330e4b/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d716a916938e03231e86e43782ca7878fb602a125a91e7acb8b5112e2e96ac16", size = 152224, upload-time = "2025-08-09T07:56:51.369Z" }, - { url = "https://files.pythonhosted.org/packages/b7/8c/9839225320046ed279c6e839d51f028342eb77c91c89b8ef2549f951f3ec/charset_normalizer-3.4.3-cp314-cp314-win32.whl", hash = "sha256:c6dbd0ccdda3a2ba7c2ecd9d77b37f3b5831687d8dc1b6ca5f56a4880cc7b7ce", size = 100086, upload-time = "2025-08-09T07:56:52.722Z" }, - { url = "https://files.pythonhosted.org/packages/ee/7a/36fbcf646e41f710ce0a563c1c9a343c6edf9be80786edeb15b6f62e17db/charset_normalizer-3.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:73dc19b562516fc9bcf6e5d6e596df0b4eb98d87e4f79f3ae71840e6ed21361c", size = 107400, upload-time = "2025-08-09T07:56:55.172Z" }, - { url = "https://files.pythonhosted.org/packages/8a/1f/f041989e93b001bc4e44bb1669ccdcf54d3f00e628229a85b08d330615c5/charset_normalizer-3.4.3-py3-none-any.whl", hash = "sha256:ce571ab16d890d23b5c278547ba694193a45011ff86a9162a71307ed9f86759a", size = 53175, upload-time = "2025-08-09T07:57:26.864Z" }, -] - [[package]] name = "click" version = "8.3.0" @@ -216,15 +141,20 @@ wheels = [ ] [[package]] -name = "ghp-import" -version = "2.1.0" +name = "flask" +version = "3.1.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "python-dateutil" }, + { name = "blinker" }, + { name = "click" }, + { name = "itsdangerous" }, + { name = "jinja2" }, + { name = "markupsafe" }, + { name = "werkzeug" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d9/29/d40217cbe2f6b1359e00c6c307bb3fc876ba74068cbab3dde77f03ca0dc4/ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343", size = 10943, upload-time = "2022-05-02T15:47:16.11Z" } +sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034, upload-time = "2022-05-02T15:47:14.552Z" }, + { url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" }, ] [[package]] @@ -236,6 +166,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] +[[package]] +name = "htpy" +version = "26.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d3/18/48b0fe5f7b23c8bb0b45b09c306f511417fee74afc49a0765178d6dcbac2/htpy-26.5.1.tar.gz", hash = "sha256:43a365c1fc670094da781b923883019339cec031397d61d5fe5f8e2f2278b63e", size = 292049, upload-time = "2026-05-22T08:31:31.387Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/68/ad12d6519ccc48852ebb1effbe5a160ff21d7b48c484d0dae6fd173f6d22/htpy-26.5.1-py3-none-any.whl", hash = "sha256:148566db9d720c897baa5832e2a496954ce317ab06ef49a103b44f8a15322c3b", size = 21428, upload-time = "2026-05-22T08:31:30.153Z" }, +] + [[package]] name = "httpcore" version = "1.0.9" @@ -291,6 +234,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, ] +[[package]] +name = "itsdangerous" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, +] + [[package]] name = "jinja2" version = "3.1.6" @@ -376,15 +328,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/51/0e/b756c7708143a63fca65a51ca07990fa647db2cc8fcd65177b9e96680255/librt-0.7.7-cp314-cp314t-win_arm64.whl", hash = "sha256:142c2cd91794b79fd0ce113bd658993b7ede0fe93057668c2f98a45ca00b7e91", size = 39724, upload-time = "2026-01-01T23:52:09.745Z" }, ] -[[package]] -name = "markdown" -version = "3.9" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8d/37/02347f6d6d8279247a5837082ebc26fc0d5aaeaf75aa013fcbb433c777ab/markdown-3.9.tar.gz", hash = "sha256:d2900fe1782bd33bdbbd56859defef70c2e78fc46668f8eb9df3128138f2cb6a", size = 364585, upload-time = "2025-09-04T20:25:22.885Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/70/ae/44c4a6a4cbb496d93c6257954260fe3a6e91b7bed2240e5dad2a717f5111/markdown-3.9-py3-none-any.whl", hash = "sha256:9f4d91ed810864ea88a6f32c07ba8bee1346c0cc1f6b1f9f6c822f2a9667d280", size = 107441, upload-time = "2025-09-04T20:25:21.784Z" }, -] - [[package]] name = "markupsafe" version = "3.0.3" @@ -470,96 +413,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, ] -[[package]] -name = "mergedeep" -version = "1.3.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3a/41/580bb4006e3ed0361b8151a01d324fb03f420815446c7def45d02f74c270/mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8", size = 4661, upload-time = "2021-02-05T18:55:30.623Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307", size = 6354, upload-time = "2021-02-05T18:55:29.583Z" }, -] - -[[package]] -name = "mkdocs" -version = "1.6.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "ghp-import" }, - { name = "jinja2" }, - { name = "markdown" }, - { name = "markupsafe" }, - { name = "mergedeep" }, - { name = "mkdocs-get-deps" }, - { name = "packaging" }, - { name = "pathspec" }, - { name = "pyyaml" }, - { name = "pyyaml-env-tag" }, - { name = "watchdog" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/bc/c6/bbd4f061bd16b378247f12953ffcb04786a618ce5e904b8c5a01a0309061/mkdocs-1.6.1.tar.gz", hash = "sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2", size = 3889159, upload-time = "2024-08-30T12:24:06.899Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e", size = 3864451, upload-time = "2024-08-30T12:24:05.054Z" }, -] - -[[package]] -name = "mkdocs-get-deps" -version = "0.2.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "mergedeep" }, - { name = "platformdirs" }, - { name = "pyyaml" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/98/f5/ed29cd50067784976f25ed0ed6fcd3c2ce9eb90650aa3b2796ddf7b6870b/mkdocs_get_deps-0.2.0.tar.gz", hash = "sha256:162b3d129c7fad9b19abfdcb9c1458a651628e4b1dea628ac68790fb3061c60c", size = 10239, upload-time = "2023-11-20T17:51:09.981Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9f/d4/029f984e8d3f3b6b726bd33cafc473b75e9e44c0f7e80a5b29abc466bdea/mkdocs_get_deps-0.2.0-py3-none-any.whl", hash = "sha256:2bf11d0b133e77a0dd036abeeb06dec8775e46efa526dc70667d8863eefc6134", size = 9521, upload-time = "2023-11-20T17:51:08.587Z" }, -] - -[[package]] -name = "mkdocs-material" -version = "9.7.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "babel" }, - { name = "backrefs" }, - { name = "colorama" }, - { name = "jinja2" }, - { name = "markdown" }, - { name = "mkdocs" }, - { name = "mkdocs-material-extensions" }, - { name = "paginate" }, - { name = "pygments" }, - { name = "pymdown-extensions" }, - { name = "requests" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/27/e2/2ffc356cd72f1473d07c7719d82a8f2cbd261666828614ecb95b12169f41/mkdocs_material-9.7.1.tar.gz", hash = "sha256:89601b8f2c3e6c6ee0a918cc3566cb201d40bf37c3cd3c2067e26fadb8cce2b8", size = 4094392, upload-time = "2025-12-18T09:49:00.308Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3e/32/ed071cb721aca8c227718cffcf7bd539620e9799bbf2619e90c757bfd030/mkdocs_material-9.7.1-py3-none-any.whl", hash = "sha256:3f6100937d7d731f87f1e3e3b021c97f7239666b9ba1151ab476cabb96c60d5c", size = 9297166, upload-time = "2025-12-18T09:48:56.664Z" }, -] - -[[package]] -name = "mkdocs-material-extensions" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/79/9b/9b4c96d6593b2a541e1cb8b34899a6d021d208bb357042823d4d2cabdbe7/mkdocs_material_extensions-1.3.1.tar.gz", hash = "sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443", size = 11847, upload-time = "2023-11-22T19:09:45.208Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5b/54/662a4743aa81d9582ee9339d4ffa3c8fd40a4965e033d77b9da9774d3960/mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31", size = 8728, upload-time = "2023-11-22T19:09:43.465Z" }, -] - -[[package]] -name = "mkdocs-swagger-ui-tag" -version = "0.7.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "beautifulsoup4" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ac/98/37df3ff6895cc25ad5e95922e4c862248533b0af630c1c3fd31ce8ece6b2/mkdocs_swagger_ui_tag-0.7.2.tar.gz", hash = "sha256:d3030e70a3937b1b2c2a399cf5a77c54a13c2eb6dd88e7c45864a175bf3a283e", size = 1277948, upload-time = "2025-08-23T16:24:47.495Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cc/9f/8b9664d53021a6fe4272f7ec4c3d73dcef44546ba7f2959398a15396b7e4/mkdocs_swagger_ui_tag-0.7.2-py3-none-any.whl", hash = "sha256:6e96e983f6990c5b7e96604c28a33f35984c862df18879ef31d96f3df2dbe5c7", size = 1292596, upload-time = "2025-08-23T16:24:45.515Z" }, -] - [[package]] name = "mypy" version = "1.19.1" @@ -633,15 +486,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, ] -[[package]] -name = "paginate" -version = "0.5.7" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ec/46/68dde5b6bc00c1296ec6466ab27dddede6aec9af1b99090e1107091b3b84/paginate-0.5.7.tar.gz", hash = "sha256:22bd083ab41e1a8b4f3690544afb2c60c25e5c9a63a30fa2f483f6c60c8e5945", size = 19252, upload-time = "2024-08-25T14:17:24.139Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl", hash = "sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591", size = 13746, upload-time = "2024-08-25T14:17:22.55Z" }, -] - [[package]] name = "pathspec" version = "0.12.1" @@ -694,19 +538,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, ] -[[package]] -name = "pymdown-extensions" -version = "10.16.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markdown" }, - { name = "pyyaml" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/55/b3/6d2b3f149bc5413b0a29761c2c5832d8ce904a1d7f621e86616d96f505cc/pymdown_extensions-10.16.1.tar.gz", hash = "sha256:aace82bcccba3efc03e25d584e6a22d27a8e17caa3f4dd9f207e49b787aa9a91", size = 853277, upload-time = "2025-07-28T16:19:34.167Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e4/06/43084e6cbd4b3bc0e80f6be743b2e79fbc6eed8de9ad8c629939fa55d972/pymdown_extensions-10.16.1-py3-none-any.whl", hash = "sha256:d6ba157a6c03146a7fb122b2b9a121300056384eafeec9c9f9e584adfdb2a32d", size = 266178, upload-time = "2025-07-28T16:19:31.401Z" }, -] - [[package]] name = "pytest" version = "9.0.2" @@ -725,18 +556,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, ] -[[package]] -name = "python-dateutil" -version = "2.9.0.post0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "six" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, -] - [[package]] name = "pyyaml" version = "6.0.2" @@ -781,33 +600,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload-time = "2024-08-06T20:33:04.33Z" }, ] -[[package]] -name = "pyyaml-env-tag" -version = "1.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pyyaml" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/eb/2e/79c822141bfd05a853236b504869ebc6b70159afc570e1d5a20641782eaa/pyyaml_env_tag-1.1.tar.gz", hash = "sha256:2eb38b75a2d21ee0475d6d97ec19c63287a7e140231e4214969d0eac923cd7ff", size = 5737, upload-time = "2025-05-13T15:24:01.64Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/04/11/432f32f8097b03e3cd5fe57e88efb685d964e2e5178a48ed61e841f7fdce/pyyaml_env_tag-1.1-py3-none-any.whl", hash = "sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04", size = 4722, upload-time = "2025-05-13T15:23:59.629Z" }, -] - -[[package]] -name = "requests" -version = "2.32.5" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "charset-normalizer" }, - { name = "idna" }, - { name = "urllib3" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, -] - [[package]] name = "respx" version = "0.22.0" @@ -820,15 +612,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8e/67/afbb0978d5399bc9ea200f1d4489a23c9a1dad4eee6376242b8182389c79/respx-0.22.0-py2.py3-none-any.whl", hash = "sha256:631128d4c9aba15e56903fb5f66fb1eff412ce28dd387ca3a81339e52dbd3ad0", size = 25127, upload-time = "2024-12-19T22:33:57.837Z" }, ] -[[package]] -name = "six" -version = "1.17.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, -] - [[package]] name = "sniffio" version = "1.3.1" @@ -895,15 +678,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b5/00/d631e67a838026495268c2f6884f3711a15a9a2a96cd244fdaea53b823fb/typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76", size = 43906, upload-time = "2025-07-04T13:28:32.743Z" }, ] -[[package]] -name = "urllib3" -version = "2.5.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, -] - [[package]] name = "virtualenv" version = "20.31.2" @@ -919,33 +693,13 @@ wheels = [ ] [[package]] -name = "watchdog" -version = "6.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/56/90994d789c61df619bfc5ce2ecdabd5eeff564e1eb47512bd01b5e019569/watchdog-6.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d1cdb490583ebd691c012b3d6dae011000fe42edb7a82ece80965b42abd61f26", size = 96390, upload-time = "2024-11-01T14:06:24.793Z" }, - { url = "https://files.pythonhosted.org/packages/55/46/9a67ee697342ddf3c6daa97e3a587a56d6c4052f881ed926a849fcf7371c/watchdog-6.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bc64ab3bdb6a04d69d4023b29422170b74681784ffb9463ed4870cf2f3e66112", size = 88389, upload-time = "2024-11-01T14:06:27.112Z" }, - { url = "https://files.pythonhosted.org/packages/44/65/91b0985747c52064d8701e1075eb96f8c40a79df889e59a399453adfb882/watchdog-6.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c897ac1b55c5a1461e16dae288d22bb2e412ba9807df8397a635d88f671d36c3", size = 89020, upload-time = "2024-11-01T14:06:29.876Z" }, - { url = "https://files.pythonhosted.org/packages/e0/24/d9be5cd6642a6aa68352ded4b4b10fb0d7889cb7f45814fb92cecd35f101/watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c", size = 96393, upload-time = "2024-11-01T14:06:31.756Z" }, - { url = "https://files.pythonhosted.org/packages/63/7a/6013b0d8dbc56adca7fdd4f0beed381c59f6752341b12fa0886fa7afc78b/watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2", size = 88392, upload-time = "2024-11-01T14:06:32.99Z" }, - { url = "https://files.pythonhosted.org/packages/d1/40/b75381494851556de56281e053700e46bff5b37bf4c7267e858640af5a7f/watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c", size = 89019, upload-time = "2024-11-01T14:06:34.963Z" }, - { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" }, - { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" }, - { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" }, - { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" }, - { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" }, - { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" }, - { url = "https://files.pythonhosted.org/packages/30/ad/d17b5d42e28a8b91f8ed01cb949da092827afb9995d4559fd448d0472763/watchdog-6.0.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c7ac31a19f4545dd92fc25d200694098f42c9a8e391bc00bdd362c5736dbf881", size = 87902, upload-time = "2024-11-01T14:06:53.119Z" }, - { url = "https://files.pythonhosted.org/packages/5c/ca/c3649991d140ff6ab67bfc85ab42b165ead119c9e12211e08089d763ece5/watchdog-6.0.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9513f27a1a582d9808cf21a07dae516f0fab1cf2d7683a742c498b93eedabb11", size = 88380, upload-time = "2024-11-01T14:06:55.19Z" }, - { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, - { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, - { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, - { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" }, - { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" }, - { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" }, - { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" }, - { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" }, - { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, - { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, +name = "werkzeug" +version = "3.1.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dd/b2/381be8cfdee792dd117872481b6e378f85c957dd7c5bca38897b08f765fd/werkzeug-3.1.8.tar.gz", hash = "sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44", size = 875852, upload-time = "2026-04-02T18:49:14.268Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/8c/2e650f2afeb7ee576912636c23ddb621c91ac6a98e66dc8d29c3c69446e1/werkzeug-3.1.8-py3-none-any.whl", hash = "sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50", size = 226459, upload-time = "2026-04-02T18:49:12.72Z" }, ] From 805ac97cba87bb73aea1c1f550d9d012e76a2db9 Mon Sep 17 00:00:00 2001 From: dunderrrrrr Date: Wed, 8 Jul 2026 13:46:19 +0200 Subject: [PATCH 2/5] fix(ci): use dev deps --- .github/actions/setup/action.yml | 5 +---- pyproject.toml | 1 + 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index 8252235..3015ce3 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -12,8 +12,5 @@ runs: run: curl -LsSf https://astral.sh/uv/install.sh | sh shell: bash - name: Install dependencies - run: | - uv venv - uv pip sync requirements.txt - uv add ruff + run: uv sync --extra dev shell: bash diff --git a/pyproject.toml b/pyproject.toml index 3a9ecbc..6afd636 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,7 @@ dev = [ "pytest>=9.0.2", "respx>=0.22.0", "mypy>=1.19.1", + "ruff>=0.9.0", "flask>=3.1.3", "htpy>=26.5.1", ] From 53609836b1610a2ca7710ed974322f15ec2eb9be Mon Sep 17 00:00:00 2001 From: dunderrrrrr Date: Wed, 8 Jul 2026 13:48:13 +0200 Subject: [PATCH 3/5] fix cspell --- cspell.json | 211 ++++++++++++++++++++++++++-------------------------- 1 file changed, 107 insertions(+), 104 deletions(-) diff --git a/cspell.json b/cspell.json index a2e1fa1..9f037ab 100644 --- a/cspell.json +++ b/cspell.json @@ -1,106 +1,109 @@ { - "version": "0.2", - "language": "en,sv", - "allowCompoundWords": true, - "ignorePaths": [ - "requirements.txt", - "ba.code-workspace", - "**/*.nix", - "pyproject.toml", - ], - "words": [ - "adformat", - "adid", - "adout", - "adsize", - "adtitle", - "adtype", - "blocket", - "companyad", - "direnv", - "httpx", - "incarousel", - "lagenheter", - "monokai", - "mypy", - "notinapp", - "opblock", - "ppid", - "pydantic", - "pymdownx", - "pyproject", - "pytest", - "qasa", - "recommerce", - "respx", - "schenvhash", - "stjacka", - "venv", - "ekey", - "pmce", - "oweb", - "uptodate", - "noti", - "dedup", - "uptodate", - "noti", - "MESG", - "AIWAYS", - "SIDDELEY", - "HEALEY", - "AUTOBIANCHI", - "CUPRA", - "TOMASO", - "DESOTO", - "DFSK", - "FISKER", - "HEINKEL", - "HONGQI", - "INFINITI", - "IVECO", - "LEVC", - "LYNK", - "MAXUS", - "SIMCA", - "XPENG", - "ZEEKR", - "ABARTH", - "Tamagotchi", - "AGIRRA", - "AIXAM", - "BAJAJ", - "BAOTIAN", - "BENDA", - "BENGHE", - "BIMOTA", - "HOSS", - "BUELL", - "CAGIVA", - "CFMOTO", - "ENERGICA", - "HYOSUNG", - "JAWA", - "KYMCO", - "LAMBRETTA", - "LIFAN", - "LIGIER", - "GUZZI", - "AGUSTA", - "PIAGGIO", - "QINGQI", - "QUADRO", - "RIEJU", - "ROMET", - "SHERCO", - "SOCO", - "TOMOS", - "VIARELLI", - "VOGE", - "YADEA", - "ADLY", - "elts", - "Avant", - "TFSI", - "quattro", - ], + "version": "0.2", + "language": "en,sv", + "allowCompoundWords": true, + "ignorePaths": [ + "requirements.txt", + "ba.code-workspace", + "**/*.nix", + "pyproject.toml" + ], + "words": [ + "adformat", + "adid", + "adout", + "adsize", + "adtitle", + "adtype", + "blocket", + "companyad", + "direnv", + "httpx", + "incarousel", + "lagenheter", + "monokai", + "mypy", + "notinapp", + "opblock", + "ppid", + "pydantic", + "pymdownx", + "pyproject", + "pytest", + "qasa", + "recommerce", + "respx", + "schenvhash", + "stjacka", + "venv", + "ekey", + "pmce", + "oweb", + "uptodate", + "noti", + "dedup", + "uptodate", + "noti", + "MESG", + "AIWAYS", + "SIDDELEY", + "HEALEY", + "AUTOBIANCHI", + "CUPRA", + "TOMASO", + "DESOTO", + "DFSK", + "FISKER", + "HEINKEL", + "HONGQI", + "INFINITI", + "IVECO", + "LEVC", + "LYNK", + "MAXUS", + "SIMCA", + "XPENG", + "ZEEKR", + "ABARTH", + "Tamagotchi", + "AGIRRA", + "AIXAM", + "BAJAJ", + "BAOTIAN", + "BENDA", + "BENGHE", + "BIMOTA", + "HOSS", + "BUELL", + "CAGIVA", + "CFMOTO", + "ENERGICA", + "HYOSUNG", + "JAWA", + "KYMCO", + "LAMBRETTA", + "LIFAN", + "LIGIER", + "GUZZI", + "AGUSTA", + "PIAGGIO", + "QINGQI", + "QUADRO", + "RIEJU", + "ROMET", + "SHERCO", + "SOCO", + "TOMOS", + "VIARELLI", + "VOGE", + "YADEA", + "ADLY", + "elts", + "Avant", + "TFSI", + "quattro", + "htmx", + "htpy", + "orgid" + ] } From 3663e75eb55d424463d5cc6e0a7c8109bb56195a Mon Sep 17 00:00:00 2001 From: dunderrrrrr Date: Wed, 8 Jul 2026 13:52:22 +0200 Subject: [PATCH 4/5] fix: add gunicorn --- pyproject.toml | 1 + uv.lock | 41 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 6afd636..1a0a9aa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,7 @@ dev = [ "ruff>=0.9.0", "flask>=3.1.3", "htpy>=26.5.1", + "gunicorn>=26.0.0", ] diff --git a/uv.lock b/uv.lock index 7316dae..81b9392 100644 --- a/uv.lock +++ b/uv.lock @@ -51,23 +51,27 @@ dependencies = [ [package.optional-dependencies] dev = [ { name = "flask" }, + { name = "gunicorn" }, { name = "htpy" }, { name = "mypy" }, { name = "pre-commit" }, { name = "pytest" }, { name = "respx" }, + { name = "ruff" }, ] [package.metadata] requires-dist = [ { name = "beautifulsoup4", specifier = ">=4.14.3" }, { name = "flask", marker = "extra == 'dev'", specifier = ">=3.1.3" }, + { name = "gunicorn", marker = "extra == 'dev'", specifier = ">=26.0.0" }, { name = "htpy", marker = "extra == 'dev'", specifier = ">=26.5.1" }, { name = "httpx", specifier = ">=0.28.1" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.19.1" }, { name = "pre-commit", marker = "extra == 'dev'", specifier = ">=4.5.1" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.2" }, { name = "respx", marker = "extra == 'dev'", specifier = ">=0.22.0" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.9.0" }, ] provides-extras = ["dev"] @@ -157,6 +161,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" }, ] +[[package]] +name = "gunicorn" +version = "26.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6d/b7/a4a3f632f823e432ce6bc65f62961b7980c898c77f075a2f7118cb3846fe/gunicorn-26.0.0.tar.gz", hash = "sha256:ca9346f85e3a4aeeb64d491045c16b9a35647abd37ea15efe53080eb8b090baf", size = 727286, upload-time = "2026-05-05T06:38:25.529Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/40/9c2384fc2be4ad25dd4a49decd5ad9ea5a3639814c11bd40ab77cb9f0a14/gunicorn-26.0.0-py3-none-any.whl", hash = "sha256:40233d26a5f0d1872916188c276e21641155111c2853f0c2cd55260aec0d24fc", size = 212009, upload-time = "2026-05-05T06:38:23.007Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -612,6 +628,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8e/67/afbb0978d5399bc9ea200f1d4489a23c9a1dad4eee6376242b8182389c79/respx-0.22.0-py2.py3-none-any.whl", hash = "sha256:631128d4c9aba15e56903fb5f66fb1eff412ce28dd387ca3a81339e52dbd3ad0", size = 25127, upload-time = "2024-12-19T22:33:57.837Z" }, ] +[[package]] +name = "ruff" +version = "0.15.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/dc/35b341fc554ba02f217fc10da57d1a75168cfbcf75b0ef2202176d4c4f2d/ruff-0.15.20.tar.gz", hash = "sha256:1416eb04349192646b54de98f146c4f59afe37d0decfc02c3cbbf396f3a28566", size = 4755489, upload-time = "2026-06-25T17:20:37.578Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/d9/2d5014f0253ba541d2061d9fa7193f48e941c8b21bb88a7ff9bbe0bd0596/ruff-0.15.20-py3-none-linux_armv6l.whl", hash = "sha256:00e188c53e499c3c1637f73c91dcf2fb56d576cab76ce1be50a27c4e80e37078", size = 10839665, upload-time = "2026-06-25T17:19:44.702Z" }, + { url = "https://files.pythonhosted.org/packages/c6/d3/ac1798ba64f670698867fcfc591d50e7e421bef137db564858f619a30fcf/ruff-0.15.20-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9ebd1fd9b9c95fc0bd7b2761aebec1f030013d2e193a2901b224af68fe47251b", size = 11208649, upload-time = "2026-06-25T17:19:48.787Z" }, + { url = "https://files.pythonhosted.org/packages/47/47/d3ac899991202095dfcf3d5176be4272642be3cf981a2f1a30f72a2afb95/ruff-0.15.20-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5b16cdd67ca108185cd36dce98c576350c03b1660a751de725fb049193a0632", size = 10622638, upload-time = "2026-06-25T17:19:51.354Z" }, + { url = "https://files.pythonhosted.org/packages/33/13/4e043fe30aa94d4ff5213a9881fc296d12960f5971b234a5263fdc225312/ruff-0.15.20-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3413bb3c3d2ca6a8208f1f4809cd2dca3c6de6d0b491c0e70847672bde6e6efd", size = 10984227, upload-time = "2026-06-25T17:19:54.044Z" }, + { url = "https://files.pythonhosted.org/packages/76/e6/92e7bf40388bc5800073b96564f56264f7e48bfd1a498f5ced6ae6d5a769/ruff-0.15.20-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd7ec42b3bb3da066488db093308a69c4ac5ee6d2af333a86ba6e2eb2e7dd44b", size = 10622882, upload-time = "2026-06-25T17:19:57.037Z" }, + { url = "https://files.pythonhosted.org/packages/13/7a/43460be3f24495a3aa46d4b16873e2c4941b3b5f0b00cf88c03b7b94b339/ruff-0.15.20-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e1a36ad0eb77fba9aabfb69ede54de6f376d04ac18ebea022847046d340a8267", size = 11474808, upload-time = "2026-06-25T17:20:00.357Z" }, + { url = "https://files.pythonhosted.org/packages/27/a0/f37077884873221c6b33b4ab49eb18f9f88e54a16a25a5bca59bef46dd66/ruff-0.15.20-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b6df3b1e4610432f0386dba04d853b5f08cbbc903410c6fcc02f620f05aff53c", size = 12293094, upload-time = "2026-06-25T17:20:03.446Z" }, + { url = "https://files.pythonhosted.org/packages/a6/74/165545b60256a9704c21ac0ec4a0d07933b320812f9584836c9f4aca4292/ruff-0.15.20-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e89f198a1ea6ef0d727c1cf16088bc91a6cb0ab947dedc966715691647186eae", size = 11526176, upload-time = "2026-06-25T17:20:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/86/b1/a976a136d40ade83ce743578399865f57001003a409acadc0ecbb3051082/ruff-0.15.20-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309809086c2acb67624950a3c8133e80f32d0d3e27106c0cd60ff26657c9f24b", size = 11520767, upload-time = "2026-06-25T17:20:09.191Z" }, + { url = "https://files.pythonhosted.org/packages/19/0f/f032696cb01c9b54c0263fa393474d7758f1cdc021a01b04e3cbc2500999/ruff-0.15.20-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2d2374caa2f2c2f9e2b7da0a50802cfb8b79f55a9b5e49379f564544fbf56487", size = 11500132, upload-time = "2026-06-25T17:20:13.602Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f4/51b1a14bc69e8c224b15dab9cce8e99b425e0455d462caa2b3c9be2b6a8e/ruff-0.15.20-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a1ed17b65293e0c2f22fc387bc13198a5de94bf4429589b0ff6946b0feaf21a3", size = 10943828, upload-time = "2026-06-25T17:20:16.635Z" }, + { url = "https://files.pythonhosted.org/packages/71/4b/fe267640783cd02bf6c5cc290b1df1051be2ec294c678b5c15fe19e52343/ruff-0.15.20-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f701305e66b38ea6c91882490eb73459796808e4c6362a1b765255e0cdcd4053", size = 10645418, upload-time = "2026-06-25T17:20:19.4Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c0/a65aa4ec2f5e87a1df32dc3ec1fede434fe3dfd5cbcf3b503cafc676ab54/ruff-0.15.20-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5b9c0c367ad8e5d0d5b5b8537864c469a0a0e55417aadfbeca41fa61333be9f4", size = 11211770, upload-time = "2026-06-25T17:20:22.033Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a4/0caa331d954ae2723d729d351c989cb4ca8b6077d5c6c2cb6de75e98c041/ruff-0.15.20-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:01cc00dd58f0df339d0e902219dd53990ea99996a0344e5d9cc8d45d5307e460", size = 11618698, upload-time = "2026-06-25T17:20:25.259Z" }, + { url = "https://files.pythonhosted.org/packages/10/9b/5f14927848d2fd4aa891fd88d883788c5a7baba561c7874732364045708c/ruff-0.15.20-py3-none-win32.whl", hash = "sha256:ed65ef510e43a137207e0f01cfcf998aeddb1aeeda5c9d35023e910284d7cf21", size = 10857322, upload-time = "2026-06-25T17:20:28.612Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f0/fe47c501f9dea92a26d788ff98bb5d92ed4cb4c88792c5c88af6b697dc8e/ruff-0.15.20-py3-none-win_amd64.whl", hash = "sha256:a525c81c70fb0380344dd1d8745d8cc1c890b7fc94a58d5a07bd8eb9557b8415", size = 11993274, upload-time = "2026-06-25T17:20:31.871Z" }, + { url = "https://files.pythonhosted.org/packages/d7/2b/9555445e1201d92b3195f45cdb153a0b68f24e0a4273f6e3d5ab46e212bb/ruff-0.15.20-py3-none-win_arm64.whl", hash = "sha256:2f5b2a6d614e8700388806a14996c40fab2c47b819ef57d790a34878858ed9ca", size = 11343498, upload-time = "2026-06-25T17:20:35.03Z" }, +] + [[package]] name = "sniffio" version = "1.3.1" From 8c8d2d1e98fd56ac7d2defd5ed2ccc6a4f8fc4ac Mon Sep 17 00:00:00 2001 From: dunderrrrrr Date: Wed, 8 Jul 2026 13:53:43 +0200 Subject: [PATCH 5/5] fix: mypy --- docs/app.py | 8 +++---- docs/components/changelog.py | 6 ++--- docs/components/endpoints.py | 8 +++---- docs/components/examples.py | 34 +++++++++++++-------------- docs/components/index.py | 34 +++++++++++++-------------- docs/components/python_lib.py | 34 +++++++++++++++------------ docs/components/quickstart.py | 32 +++++++++++++------------- docs/components/tips.py | 32 +++++++++++++------------- docs/components_base.py | 43 ++++++++++++++++++++--------------- docs/views/changelog.py | 7 +++--- docs/views/endpoints.py | 7 +++--- docs/views/examples.py | 7 +++--- docs/views/index.py | 8 ++++--- docs/views/python.py | 7 +++--- docs/views/quickstart.py | 7 +++--- docs/views/tips.py | 7 +++--- 16 files changed, 150 insertions(+), 131 deletions(-) diff --git a/docs/app.py b/docs/app.py index a5ef4dd..f24ecce 100644 --- a/docs/app.py +++ b/docs/app.py @@ -18,7 +18,7 @@ app.add_url_rule("/_/changelog", "changelog_fragment", changelog.fragment_view) -def endpoints_body(): +def endpoints_body() -> str: from docs.components import endpoints as ep_c spec = httpx.get("https://blocket-api.se/swagger/openapi.json", timeout=10).json() @@ -28,7 +28,7 @@ def endpoints_body(): app.add_url_rule("/_/endpoints-body", "endpoints_body", endpoints_body) -def robots_txt(): +def robots_txt() -> Response: body = ( "User-agent: *\n" "Allow: /\n" @@ -42,7 +42,7 @@ def robots_txt(): app.add_url_rule("/robots.txt", "robots_txt", robots_txt) -def sitemap_xml(): +def sitemap_xml() -> Response: from docs.components_base import SITE_ROOT pages = ["", "/quickstart", "/python", "/endpoints", "/changelog"] @@ -54,7 +54,7 @@ def sitemap_xml(): app.add_url_rule("/sitemap.xml", "sitemap_xml", sitemap_xml) -def changelog_body(): +def changelog_body() -> str: from docs.components import changelog as cl_c prs = httpx.get( diff --git a/docs/components/changelog.py b/docs/components/changelog.py index c131164..3fbf31f 100644 --- a/docs/components/changelog.py +++ b/docs/components/changelog.py @@ -24,12 +24,12 @@ def _parse_type(title: str) -> tuple[str, str]: return "feat", title.strip() -def _badge(kind: str): +def _badge(kind: str) -> h.Node: cls = _BADGE_CLASS.get(kind, "badge-feat") return h.span(f".badge.{cls}")[kind] -def _pr_row(pr: dict): +def _pr_row(pr: dict) -> h.Node: kind, title = _parse_type(pr["title"]) date = datetime.fromisoformat(pr["merged_at"].replace("Z", "+00:00")) user = pr.get("user", {}) @@ -60,7 +60,7 @@ def _pr_row(pr: dict): ] -def render(prs: list): +def render(prs: list) -> h.Node: merged = [ pr for pr in prs diff --git a/docs/components/endpoints.py b/docs/components/endpoints.py index a3fee06..d91c2c3 100644 --- a/docs/components/endpoints.py +++ b/docs/components/endpoints.py @@ -35,7 +35,7 @@ def _enum_values(schema: dict, schemas: dict) -> list: return [] -def _param_row(p: dict, schemas: dict): +def _param_row(p: dict, schemas: dict) -> h.Node: schema = p.get("schema", {}) required = p.get("required", False) type_label = _type_label(schema, schemas) @@ -59,7 +59,7 @@ def _slugify(path: str) -> str: return path.replace("/", "-").replace("{", "").replace("}", "").lstrip("-") -def _endpoint_block(path: str, op: dict, schemas: dict): +def _endpoint_block(path: str, op: dict, schemas: dict) -> h.Node: params = op.get("parameters", []) slug = _slugify(path) required_params = [p for p in params if p.get("required")] @@ -97,7 +97,7 @@ def _endpoint_block(path: str, op: dict, schemas: dict): ] -def _sidebar(paths: dict): +def _sidebar(paths: dict) -> list[h.Node]: links = [ sidebar_link( op.get("summary", path), @@ -119,7 +119,7 @@ def _sidebar(paths: dict): ] -def render(spec: dict): +def render(spec: dict) -> h.Node: schemas = spec.get("components", {}).get("schemas", {}) paths = spec.get("paths", {}) diff --git a/docs/components/examples.py b/docs/components/examples.py index d3b8070..e943e30 100644 --- a/docs/components/examples.py +++ b/docs/components/examples.py @@ -3,7 +3,7 @@ from docs.components_base import API_BASE, code_block, code_tabs, ic, tip_box -def example_card(id_: str, icon: str, title: str, desc: str, *blocks): +def example_card(id_: str, icon: str, title: str, desc: str, *blocks: h.Node) -> h.Node: return h.div(".example-full-card", id=id_)[ h.div(".example-card-header")[ h.div(".example-card-icon")[icon], @@ -16,7 +16,7 @@ def example_card(id_: str, icon: str, title: str, desc: str, *blocks): # ── Python snippets ─────────────────────────────────────────────────────────── -def general_py(): +def general_py() -> h.Node: return code_block( "general_search.py", "PY", @@ -67,7 +67,7 @@ def general_py(): ) -def general_curl(): +def general_curl() -> h.Node: return code_block( "terminal", "SH", @@ -77,7 +77,7 @@ def general_curl(): ) -def car_py(): +def car_py() -> h.Node: return code_block( "car_filter.py", "PY", @@ -160,7 +160,7 @@ def car_py(): ) -def car_curl(): +def car_curl() -> h.Node: return code_block( "terminal", "SH", @@ -172,7 +172,7 @@ def car_curl(): ) -def alert_py(): +def alert_py() -> h.Node: return code_block( "price_alert.py", "PY", @@ -288,7 +288,7 @@ def alert_py(): ) -def alert_curl(): +def alert_curl() -> h.Node: return code_block( "terminal", "SH", @@ -303,7 +303,7 @@ def alert_curl(): ) -def alert_run(): +def alert_run() -> h.Node: return code_block( "terminal", "SH", @@ -314,7 +314,7 @@ def alert_run(): ) -def csv_py(): +def csv_py() -> h.Node: return code_block( "to_csv.py", "PY", @@ -408,7 +408,7 @@ def csv_py(): ) -def csv_curl(): +def csv_curl() -> h.Node: return code_block( "terminal", "SH", @@ -421,7 +421,7 @@ def csv_curl(): ) -def paginate_py(): +def paginate_py() -> h.Node: return code_block( "paginate.py", "PY", @@ -475,7 +475,7 @@ def paginate_py(): ) -def paginate_curl(): +def paginate_curl() -> h.Node: return code_block( "terminal", "SH", @@ -489,7 +489,7 @@ def paginate_curl(): ) -def dealer_py(): +def dealer_py() -> h.Node: return code_block( "dealer_stock.py", "PY", @@ -530,7 +530,7 @@ def dealer_py(): ) -def dealer_curl(): +def dealer_curl() -> h.Node: return code_block( "terminal", "SH", @@ -540,7 +540,7 @@ def dealer_curl(): ) -def boat_py(): +def boat_py() -> h.Node: return code_block( "boat_search.py", "PY", @@ -595,7 +595,7 @@ def boat_py(): ) -def boat_curl(): +def boat_curl() -> h.Node: return code_block( "terminal", "SH", @@ -608,7 +608,7 @@ def boat_curl(): # ── Assembled cards ─────────────────────────────────────────────────────────── -def cards(): +def cards() -> list[h.Node]: return [ example_card( "general", diff --git a/docs/components/index.py b/docs/components/index.py index 9df4e87..e7bdbac 100644 --- a/docs/components/index.py +++ b/docs/components/index.py @@ -12,11 +12,11 @@ ) -def pill(text: str): +def pill(text: str) -> h.Node: return h.span(".pill")[h.span(".dot"), text] -def install_body(id_: str, cmd_id: str, cmd: str, active: bool = False): +def install_body(id_: str, cmd_id: str, cmd: str, active: bool = False) -> h.Node: return h.div( ".install-body.active" if active else ".install-body", id=id_, @@ -29,7 +29,7 @@ def install_body(id_: str, cmd_id: str, cmd: str, active: bool = False): ] -def install_block(): +def install_block() -> h.Node: return h.div(".install-block")[ h.div(".install-tabs")[ h.button(".install-tab.active", onclick="switchInstall(this,'uv')")["uv"], @@ -54,7 +54,7 @@ def install_block(): # ── Code snippets ───────────────────────────────────────────────────────────── -def hero_code(): +def hero_code() -> h.Node: return code_block( "search.py", "PY", @@ -73,7 +73,7 @@ def hero_code(): ) -def hero_response(): +def hero_response() -> h.Node: return code_block( "output", "JSON", @@ -90,7 +90,7 @@ def hero_response(): ) -def car_code(): +def car_code() -> h.Node: return code_block( "cars.py", "PY", @@ -110,7 +110,7 @@ def car_code(): ) -def alert_code(): +def alert_code() -> h.Node: return code_block( "alert.py", "PY", @@ -139,7 +139,7 @@ def alert_code(): ) -def uv_inline_code(): +def uv_inline_code() -> h.Node: return code_block( "blocket.py", "PY", @@ -157,14 +157,14 @@ def uv_inline_code(): ) -def uv_run_code(): +def uv_run_code() -> h.Node: return code_block("terminal", "SH", "uv run blocket.py") # ── Page sections ───────────────────────────────────────────────────────────── -def hero(): +def hero() -> h.Node: return h.div(".hero-wrapper")[ h.div(".hero-center")[ h.div(".hero-title")[ @@ -186,7 +186,7 @@ def hero(): ] -def search_boat_code(): +def search_boat_code() -> h.Node: return code_block( "boats.py", "PY", @@ -204,7 +204,7 @@ def search_boat_code(): ) -def search_mc_code(): +def search_mc_code() -> h.Node: return code_block( "mc.py", "PY", @@ -223,7 +223,7 @@ def search_mc_code(): ) -def examples_section(): +def examples_section() -> h.Node: return h.section(".section.section-alt")[ h.div(".container")[ h.div(".section-header")[h.h2["Common patterns"],], @@ -345,7 +345,7 @@ def examples_section(): ] -def feature(icon: str, title: str, desc: str): +def feature(icon: str, title: str, desc: str) -> h.Node: return h.div(".feature-cell")[ h.div(".feature-icon")[icon], h.div[ @@ -355,7 +355,7 @@ def feature(icon: str, title: str, desc: str): ] -def features(): +def features() -> h.Node: return h.section(".section.section-alt")[ h.div(".container")[ h.div(".section-header")[ @@ -398,7 +398,7 @@ def features(): ] -def uv_section(): +def uv_section() -> h.Node: return h.section(".section")[ h.div(".container")[ h.div(".uv-grid")[ @@ -450,7 +450,7 @@ def uv_section(): ] -def cta(): +def cta() -> h.Node: return h.section(".section.section-cta")[ h.div(".container")[ h.div(".cta-terminal")[ diff --git a/docs/components/python_lib.py b/docs/components/python_lib.py index 2bedf3e..d47ab13 100644 --- a/docs/components/python_lib.py +++ b/docs/components/python_lib.py @@ -14,7 +14,7 @@ ) -def search_py(): +def search_py() -> h.Node: return code_block( "search.py", "PY", @@ -33,7 +33,7 @@ def search_py(): ) -def search_curl(): +def search_curl() -> h.Node: return code_block( "terminal", "SH", @@ -41,7 +41,7 @@ def search_curl(): ) -def search_car_py(): +def search_car_py() -> h.Node: return code_block( "search_car.py", "PY", @@ -66,7 +66,7 @@ def search_car_py(): ) -def search_car_curl(): +def search_car_curl() -> h.Node: return code_block( "terminal", "SH", @@ -74,7 +74,7 @@ def search_car_curl(): ) -def search_boat_py(): +def search_boat_py() -> h.Node: return code_block( "search_boat.py", "PY", @@ -92,7 +92,7 @@ def search_boat_py(): ) -def search_boat_curl(): +def search_boat_curl() -> h.Node: return code_block( "terminal", "SH", @@ -100,7 +100,7 @@ def search_boat_curl(): ) -def search_mc_py(): +def search_mc_py() -> h.Node: return code_block( "search_mc.py", "PY", @@ -119,7 +119,7 @@ def search_mc_py(): ) -def search_mc_curl(): +def search_mc_curl() -> h.Node: return code_block( "terminal", "SH", @@ -127,7 +127,7 @@ def search_mc_curl(): ) -def get_ad_py(): +def get_ad_py() -> h.Node: return code_block( "get_ad.py", "PY", @@ -146,13 +146,17 @@ def get_ad_py(): ) -def get_ad_curl(): +def get_ad_curl() -> h.Node: return code_block("terminal", "SH", f'curl "{API_BASE}/v1/ad/car?id=12345678"') def method_card( - id_: str, signature: str, desc: str, params: list[tuple[str, str, str]], *blocks -): + id_: str, + signature: str, + desc: str, + params: list[tuple[str, str, str]], + *blocks: h.Node, +) -> h.Node: return h.div(".example-full-card", id=id_)[ h.div(".example-card-header")[ h.div(".example-card-icon")[h.code(".ep-path")[signature]], @@ -179,7 +183,7 @@ def method_card( ] -def sidebar(): +def sidebar() -> list[h.Node]: return [ sidebar_section( "Methods", @@ -202,7 +206,7 @@ def sidebar(): ] -def install_section(): +def install_section() -> list[h.Node]: return [ h.h2(id="install")["Installation"], h.p["Install via ", ic("uv"), " or ", ic("pip"), ":"], @@ -223,7 +227,7 @@ def install_section(): ] -def methods_section(): +def methods_section() -> list[h.Node]: return [ h.h2(".section-h2", id="methods")["Methods"], method_card( diff --git a/docs/components/quickstart.py b/docs/components/quickstart.py index 6b47eee..0976a4c 100644 --- a/docs/components/quickstart.py +++ b/docs/components/quickstart.py @@ -16,21 +16,21 @@ ) -def tab_btn(key: str, label: str, active: bool = False): +def tab_btn(key: str, label: str, active: bool = False) -> h.Node: return h.button( ".tab-btn.active" if active else ".tab-btn", onclick=f"switchTab(this,'{key}')", )[label] -def tab_panel(key: str, content, active: bool = False): +def tab_panel(key: str, content: h.Node, active: bool = False) -> h.Node: return h.div(".tab-panel.active" if active else ".tab-panel", id=key)[content] # ── Code snippets ───────────────────────────────────────────────────────────── -def install_uv(): +def install_uv() -> h.Node: return code_block( "terminal", "SH", @@ -43,11 +43,11 @@ def install_uv(): ) -def install_pip(): +def install_pip() -> h.Node: return code_block("terminal", "SH", "pip install blocket-api") -def first_search_py(): +def first_search_py() -> h.Node: return code_block( "hello_blocket.py", "PY", @@ -62,7 +62,7 @@ def first_search_py(): ) -def first_search_rest(): +def first_search_rest() -> h.Node: return code_block( "terminal", "SH", @@ -72,7 +72,7 @@ def first_search_rest(): ) -def first_search_js(): +def first_search_js() -> h.Node: return code_block( "search.js", "JS", @@ -87,7 +87,7 @@ def first_search_js(): ) -def car_search(): +def car_search() -> h.Node: return code_block( "car_search.py", "PY", @@ -109,7 +109,7 @@ def car_search(): ) -def uv_script(): +def uv_script() -> h.Node: return code_block( "my_search.py", "PY", @@ -127,14 +127,14 @@ def uv_script(): ) -def uv_run(): +def uv_run() -> h.Node: return code_block("terminal", "SH", "uv run my_search.py") # ── Sidebar ─────────────────────────────────────────────────────────────────── -def sidebar(): +def sidebar() -> list[h.Node]: return [ sidebar_section( "Getting started", @@ -159,7 +159,7 @@ def sidebar(): # ── Page sections ───────────────────────────────────────────────────────────── -def installation(): +def installation() -> list[h.Node]: return [ h.h2(id="installation")["Installation"], h.p["BlocketAPI is available as a Python library or via direct REST calls."], @@ -205,7 +205,7 @@ def installation(): ] -def first_search(): +def first_search() -> list[h.Node]: return [ h.h2(".section-h2", id="first-search")["Your first search"], h.p[ @@ -244,7 +244,7 @@ def first_search(): ] -def car_search_curl(): +def car_search_curl() -> h.Node: return code_block( "terminal", "SH", @@ -252,7 +252,7 @@ def car_search_curl(): ) -def car_section(): +def car_section() -> list[h.Node]: return [ h.h2(".section-h2", id="car-search")["Car search"], h.p[ @@ -285,7 +285,7 @@ def car_section(): ] -def uv_section(): +def uv_section() -> list[h.Node]: return [ h.h2(".section-h2", id="uv")["Run scripts with uv"], h.p[ diff --git a/docs/components/tips.py b/docs/components/tips.py index f415f2d..76e1074 100644 --- a/docs/components/tips.py +++ b/docs/components/tips.py @@ -13,7 +13,7 @@ # ── Sidebar ─────────────────────────────────────────────────────────────────── -def sidebar(): +def sidebar() -> list[h.Node]: return [ sidebar_section( "Tips", @@ -41,7 +41,7 @@ def sidebar(): # ── Code snippets ───────────────────────────────────────────────────────────── -def uv_global(): +def uv_global() -> h.Node: return code_block( "terminal", "SH", @@ -56,7 +56,7 @@ def uv_global(): ) -def uv_inline(): +def uv_inline() -> h.Node: return code_block( "script.py", "PY", @@ -99,7 +99,7 @@ def uv_inline(): ) -def cron(): +def cron() -> h.Node: return code_block( "crontab -e", "SH", @@ -114,7 +114,7 @@ def cron(): ) -def pagination(): +def pagination() -> h.Node: return code_block( "paginate_all.py", "PY", @@ -172,7 +172,7 @@ def pagination(): ) -def sort_filter(): +def sort_filter() -> h.Node: return code_block( "sort_tip.py", "PY", @@ -209,7 +209,7 @@ def sort_filter(): ) -def multi_location(): +def multi_location() -> h.Node: return code_block( "multi_location.py", "PY", @@ -255,7 +255,7 @@ def multi_location(): ) -def org_id(): +def org_id() -> h.Node: return code_block( "org_id_scan.py", "PY", @@ -289,7 +289,7 @@ def org_id(): ) -def median_price(): +def median_price() -> h.Node: return code_block( "median_price.py", "PY", @@ -360,7 +360,7 @@ def median_price(): # ── Page sections ───────────────────────────────────────────────────────────── -def uv_tip(): +def uv_tip() -> list[h.Node]: return [ h.h2(id="uv")["Run scripts with uv"], h.p[ @@ -382,7 +382,7 @@ def uv_tip(): ] -def cron_tip(): +def cron_tip() -> list[h.Node]: return [ h.h2(".section-h2", id="cron")["Schedule with cron"], h.p[ @@ -402,7 +402,7 @@ def cron_tip(): ] -def pagination_tip(): +def pagination_tip() -> list[h.Node]: return [ h.h2(".section-h2", id="pagination")["Pagination"], h.p[ @@ -425,7 +425,7 @@ def pagination_tip(): ] -def sorting_tip(): +def sorting_tip() -> list[h.Node]: return [ h.h2(".section-h2", id="sorting")["Sorting & price filters"], h.p[ @@ -456,7 +456,7 @@ def sorting_tip(): ] -def location_tip(): +def location_tip() -> list[h.Node]: return [ h.h2(".section-h2", id="location")["Multi-location search"], h.p[ @@ -468,7 +468,7 @@ def location_tip(): ] -def orgid_tip(): +def orgid_tip() -> list[h.Node]: return [ h.h2(".section-h2", id="orgid")["Filter by dealer (org_id)"], h.p[ @@ -481,7 +481,7 @@ def orgid_tip(): ] -def analysis_tip(): +def analysis_tip() -> list[h.Node]: return [ h.h2(".section-h2", id="analysis")["Market analysis & price stats"], h.p[ diff --git a/docs/components_base.py b/docs/components_base.py index 12318a7..b0bf778 100644 --- a/docs/components_base.py +++ b/docs/components_base.py @@ -4,6 +4,7 @@ import threading import time import urllib.request +from collections.abc import Callable import htpy as h from flask import url_for @@ -80,14 +81,16 @@ def _htmx(ep: str) -> dict: ) -def _logo(): +def _logo() -> h.Node: return h.a(".nav-logo", href=url_for("index"), **_htmx("index"))[ _LOGO_SVG, h.span["BlocketAPI"], ] -def _nav_links(active: str = "", mobile: bool = False): +def _nav_links( + active: str = "", mobile: bool = False +) -> tuple[list[h.Element], list[h.Element]]: extra = ( {"onclick": "document.getElementById('mobileMenu').classList.remove('open')"} if mobile @@ -126,7 +129,7 @@ def _nav_links(active: str = "", mobile: bool = False): ) -def _gh_badges(): +def _gh_badges() -> h.Node: stats = _gh_stats() facts = [] if stats.get("version"): @@ -144,7 +147,7 @@ def _gh_badges(): ] -def nav(active: str = ""): +def nav(active: str = "") -> h.Node: internal, external = _nav_links(active) return h.nav[ h.div(".nav-inner")[ @@ -157,7 +160,7 @@ def nav(active: str = ""): ] -def mobile_menu(active: str = ""): +def mobile_menu(active: str = "") -> h.Node: internal, external = _nav_links(active, mobile=True) return h.div(".mobile-menu", id="mobileMenu")[internal, external] @@ -165,7 +168,7 @@ def mobile_menu(active: str = ""): # ── Footer ──────────────────────────────────────────────────────────────────── -def footer(): +def footer() -> h.Node: return h.footer[ h.div(".footer-inner")[ h.div(".footer-brand")[ @@ -215,8 +218,12 @@ def footer(): def page( - active: str, title: str, description: str, content_fn, extra_css: str | None = None -): + active: str, + title: str, + description: str, + content_fn: Callable[[], h.Node], + extra_css: str | None = None, +) -> Markup: """Render a full page. content_fn() returns the page body (htpy nodes).""" from flask import request @@ -280,7 +287,7 @@ def page( return Markup(doc) -def fragment(content_fn) -> str: +def fragment(content_fn: Callable[[], h.Node]) -> str: """Render only the inner content nodes — returned for htmx requests.""" return str(h.div("#content")[content_fn()]) @@ -296,7 +303,7 @@ def fragment(content_fn) -> str: } -def code_block(filename: str, lang: str, code: str): +def code_block(filename: str, lang: str, code: str | h.Node) -> h.Node: lang_class = _LANG_CLASS.get(lang, "language-plaintext") return h.div(".code-block")[ h.div(".code-header")[ @@ -310,7 +317,7 @@ def code_block(filename: str, lang: str, code: str): _tab_counter = [0] -def code_tabs(*variants): +def code_tabs(*variants: tuple[str, h.Node]) -> h.Node: """Render a tabbed code block with tabs embedded in the code header.""" _tab_counter[0] += 1 prefix = f"ct{_tab_counter[0]}" @@ -341,14 +348,14 @@ def code_tabs(*variants): ] -def tip_box(icon: str, content, kind: str = ""): +def tip_box(icon: str, content: h.Node, kind: str = "") -> h.Node: return h.div(f".tip.{kind}" if kind else ".tip")[ h.div(".tip-icon")[icon], h.p[content], ] -def section_header(label: str, title: str, description: str = ""): +def section_header(label: str, title: str, description: str = "") -> h.Node: return h.div(".section-header")[ h.span(".section-label")[label], h.h2[title], @@ -356,7 +363,7 @@ def section_header(label: str, title: str, description: str = ""): ] -def page_hero(title: str, description, crumb: str): +def page_hero(title: str, description: str | h.Node, crumb: str) -> h.Node: return h.div(".page-hero", style="padding-left:1.5rem;padding-right:1.5rem;")[ h.div(".breadcrumb")[ h.a(href=url_for("index"))["Home"], @@ -368,12 +375,12 @@ def page_hero(title: str, description, crumb: str): ] -def ic(text: str): +def ic(text: str) -> h.Node: """Inline code span.""" return h.span(".inline-code")[text] -def doc_layout(sidebar_content, main_content): +def doc_layout(sidebar_content: h.Node, main_content: h.Node) -> h.Node: return h.div(style="max-width:1200px;margin:0 auto;")[ h.div(".doc-layout")[ h.aside(".sidebar")[sidebar_content], @@ -382,14 +389,14 @@ def doc_layout(sidebar_content, main_content): ] -def sidebar_section(title: str, links: list): +def sidebar_section(title: str, links: list[h.Node]) -> h.Node: return h.div(".sidebar-section")[ h.h4[title], links, ] -def sidebar_link(label: str, href: str, active: bool = False): +def sidebar_link(label: str, href: str, active: bool = False) -> h.Node: ep = next( ( ep diff --git a/docs/views/changelog.py b/docs/views/changelog.py index f3bd0e9..3a19e15 100644 --- a/docs/views/changelog.py +++ b/docs/views/changelog.py @@ -1,10 +1,11 @@ import htpy as h from flask import url_for +from markupsafe import Markup from docs.components_base import fragment, page, page_hero -def content(): +def content() -> list[h.Node]: return [ page_hero( "Changelog", @@ -29,7 +30,7 @@ def content(): ] -def view(): +def view() -> Markup: return page( active="changelog", title="Changelog — BlocketAPI", @@ -38,5 +39,5 @@ def view(): ) -def fragment_view(): +def fragment_view() -> str: return fragment(content) diff --git a/docs/views/endpoints.py b/docs/views/endpoints.py index b94f333..bc27e37 100644 --- a/docs/views/endpoints.py +++ b/docs/views/endpoints.py @@ -1,10 +1,11 @@ import htpy as h from flask import url_for +from markupsafe import Markup from docs.components_base import fragment, page, page_hero -def content(): +def content() -> list[h.Node]: return [ page_hero( "Endpoint Reference", @@ -29,7 +30,7 @@ def content(): ] -def view(): +def view() -> Markup: return page( active="endpoints", title="Endpoints — BlocketAPI", @@ -38,5 +39,5 @@ def view(): ) -def fragment_view(): +def fragment_view() -> str: return fragment(content) diff --git a/docs/views/examples.py b/docs/views/examples.py index 34f6671..ea74aa3 100644 --- a/docs/views/examples.py +++ b/docs/views/examples.py @@ -1,6 +1,7 @@ import htpy as h from docs.components import examples as c +from markupsafe import Markup from docs.components_base import ( GITHUB, fragment, @@ -11,7 +12,7 @@ ) -def content(): +def content() -> list[h.Node]: return [ page_hero( "Examples", @@ -42,7 +43,7 @@ def content(): ] -def view(): +def view() -> Markup: return page( active="examples", title="Examples — BlocketAPI", @@ -51,5 +52,5 @@ def view(): ) -def fragment_view(): +def fragment_view() -> str: return fragment(content) diff --git a/docs/views/index.py b/docs/views/index.py index bc8342d..bd93fd4 100644 --- a/docs/views/index.py +++ b/docs/views/index.py @@ -1,8 +1,10 @@ +import htpy as h from docs.components import index as c +from markupsafe import Markup from docs.components_base import fragment, page -def content(): +def content() -> list[h.Node]: return [ c.hero(), c.examples_section(), @@ -12,7 +14,7 @@ def content(): ] -def view(): +def view() -> Markup: return page( active="index", title="BlocketAPI — The unofficial API for blocket.se", @@ -21,5 +23,5 @@ def view(): ) -def fragment_view(): +def fragment_view() -> str: return fragment(content) diff --git a/docs/views/python.py b/docs/views/python.py index c37b56e..dd0cd6f 100644 --- a/docs/views/python.py +++ b/docs/views/python.py @@ -1,10 +1,11 @@ import htpy as h from docs.components import python_lib as c +from markupsafe import Markup from docs.components_base import doc_layout, fragment, page, page_hero -def content(): +def content() -> list[h.Node]: return [ page_hero( "Python Library", @@ -25,7 +26,7 @@ def content(): ] -def view(): +def view() -> Markup: return page( active="python", title="Python Library — BlocketAPI", @@ -34,5 +35,5 @@ def view(): ) -def fragment_view(): +def fragment_view() -> str: return fragment(content) diff --git a/docs/views/quickstart.py b/docs/views/quickstart.py index 62ffd9e..5691f41 100644 --- a/docs/views/quickstart.py +++ b/docs/views/quickstart.py @@ -1,10 +1,11 @@ import htpy as h from docs.components import quickstart as c +from markupsafe import Markup from docs.components_base import doc_layout, fragment, page, page_hero -def content(): +def content() -> list[h.Node]: return [ page_hero( "Quickstart", @@ -26,7 +27,7 @@ def content(): ] -def view(): +def view() -> Markup: return page( active="quickstart", title="Quickstart — BlocketAPI", @@ -35,5 +36,5 @@ def view(): ) -def fragment_view(): +def fragment_view() -> str: return fragment(content) diff --git a/docs/views/tips.py b/docs/views/tips.py index a19c3bd..5514bbf 100644 --- a/docs/views/tips.py +++ b/docs/views/tips.py @@ -1,10 +1,11 @@ import htpy as h from docs.components import tips as c +from markupsafe import Markup from docs.components_base import doc_layout, fragment, page, page_hero -def content(): +def content() -> list[h.Node]: return [ page_hero( "Tips & Tricks", @@ -30,7 +31,7 @@ def content(): ] -def view(): +def view() -> Markup: return page( active="tips", title="Tips & Tricks — BlocketAPI", @@ -39,5 +40,5 @@ def view(): ) -def fragment_view(): +def fragment_view() -> str: return fragment(content)