From 50e0918a98c381f304d0a319a0f484089f525f3a Mon Sep 17 00:00:00 2001 From: Oliver Lambson Date: Sun, 25 Aug 2024 20:01:39 +0200 Subject: [PATCH 1/5] it works --- bored-charts/boredcharts/__init__.py | 2 +- bored-charts/boredcharts/templates/index.html | 38 ++-- .../boredcharts/templates/report.html | 2 +- bored-charts/boredcharts/utils.py | 24 +++ bored-charts/boredcharts/webapp.py | 164 ++++++++++++++---- examples/full/Dockerfile | 5 +- examples/full/pages/more/test.md | 1 + tests/test_utils.py | 26 +++ 8 files changed, 216 insertions(+), 46 deletions(-) create mode 100644 bored-charts/boredcharts/utils.py create mode 100644 examples/full/pages/more/test.md create mode 100644 tests/test_utils.py diff --git a/bored-charts/boredcharts/__init__.py b/bored-charts/boredcharts/__init__.py index 69617fc..48757b1 100644 --- a/bored-charts/boredcharts/__init__.py +++ b/bored-charts/boredcharts/__init__.py @@ -1,4 +1,4 @@ -__version__ = "0.11.0" +__version__ = "0.12.0" from boredcharts.router import FigureRouter from boredcharts.webapp import boredcharts diff --git a/bored-charts/boredcharts/templates/index.html b/bored-charts/boredcharts/templates/index.html index 9df51d5..f9e5825 100644 --- a/bored-charts/boredcharts/templates/index.html +++ b/bored-charts/boredcharts/templates/index.html @@ -1,16 +1,30 @@ -{% extends "base.html" %} -{% block main %} -
-
+ {{ parent / dir.name }} + + + {{ render_tree(dir, parent / dir.name) }} + + {% endfor %} + +{% endmacro %} +{% extends "base.html" %} +{% block main %} +
{{ render_tree(report_tree, parent) }}
{% endblock main %} diff --git a/bored-charts/boredcharts/templates/report.html b/bored-charts/boredcharts/templates/report.html index 37f2d64..73db73d 100644 --- a/bored-charts/boredcharts/templates/report.html +++ b/bored-charts/boredcharts/templates/report.html @@ -3,7 +3,7 @@
{% filter markdown %} - {%- include report ~ ".md" %} + {%- include report %} {% endfilter %}
diff --git a/bored-charts/boredcharts/utils.py b/bored-charts/boredcharts/utils.py new file mode 100644 index 0000000..49e3ebb --- /dev/null +++ b/bored-charts/boredcharts/utils.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from pathlib import Path +from typing import NamedTuple + + +class DirTree(NamedTuple): + name: Path + files: list[Path] + dirs: list[DirTree] + + +def get_dirtree(parent: Path, directory: Path = Path()) -> DirTree: + full_dir = parent / directory + + files = [] + dirs = [] + for item in full_dir.iterdir(): + if item.is_file(): + files.append(item.relative_to(full_dir)) + elif item.is_dir(): + dirs.append(get_dirtree(full_dir, item.relative_to(full_dir))) + + return DirTree(name=directory, files=files, dirs=dirs) diff --git a/bored-charts/boredcharts/webapp.py b/bored-charts/boredcharts/webapp.py index 2796d2d..cccd09a 100644 --- a/bored-charts/boredcharts/webapp.py +++ b/bored-charts/boredcharts/webapp.py @@ -1,5 +1,8 @@ +import logging +from collections.abc import Awaitable, Callable from enum import Enum from pathlib import Path +from typing import NamedTuple from fastapi import FastAPI, Request from fastapi.responses import HTMLResponse @@ -10,22 +13,47 @@ from boredcharts.jinja import figure, md_to_html, row from boredcharts.router import FigureRouter +from boredcharts.utils import DirTree, get_dirtree + +logger = logging.getLogger("boredcharts") module_root = Path(__file__).parent.absolute() +class ReportEndpoint(NamedTuple): + name: str + path: str + endpoint: Callable[..., Awaitable[HTMLResponse]] + + +def to_name(path: Path | str, kind: str = "") -> str: + if isinstance(path, str): + path = Path(path) + if path.name: + path = path.with_suffix("") + if path.root.startswith("/"): + path = path.relative_to("/") + return str(Path(kind) / path).replace("/", ".").strip(".") + + +def to_url_path(path: Path) -> str: + if path.name: + path = path.with_suffix("") + return str(Path("/") / path) + + def boredcharts( pages: Path, figures: FigureRouter | list[FigureRouter], *, - name: str = "bored-charts", + index_name: str = "bored-charts", ) -> FastAPI: """Creates a boredcharts app.""" static_root = module_root / "static" templates_root = module_root / "templates" Path(static_root / "plotlyjs.min.js").write_text(get_plotlyjs()) - app = FastAPI(title=name) + app = FastAPI(title=index_name) app.mount( "/static", @@ -33,6 +61,7 @@ def boredcharts( "static", ) + # --- templates ------------------------------------------------------------ templates = Jinja2Templates( env=Environment( loader=FileSystemLoader( @@ -46,40 +75,17 @@ def boredcharts( undefined=StrictUndefined, ), ) - templates.env.globals["title"] = name - templates.env.globals["reports"] = [ - {"name": f.stem} - for f in sorted( - pages.glob("*.md"), - reverse=True, - ) - ] + templates.env.globals["title"] = index_name templates.env.filters["markdown"] = md_to_html templates.env.globals["figure"] = figure templates.env.globals["row"] = row + templates.env.globals["to_name"] = to_name - @app.get("/healthz") - async def healthz() -> dict[str, str]: - return {"status": "ok"} - - @app.get("/", tags=["reports"]) - async def index(request: Request) -> HTMLResponse: - return templates.TemplateResponse( - "index.html", - {"request": request}, - ) - - # TODO: pass pages path into framework, auto generate this route - @app.get("/report/{report_name}", name="report", tags=["reports"]) - async def report(report_name: str, request: Request) -> HTMLResponse: - return templates.TemplateResponse( - "report.html", - { - "request": request, - "report": report_name, - }, - ) + # --- report endpoints ----------------------------------------------------- + report_tree = get_dirtree(pages) + create_report_endpoints(app, templates, report_tree) + # --- figure endpoints ----------------------------------------------------- if not isinstance(figures, list): figures = [figures] for router in figures: @@ -93,4 +99,100 @@ async def report(report_name: str, request: Request) -> HTMLResponse: app.include_router(router, prefix="/figure", tags=tags) + @app.get("/healthz") + async def healthz() -> dict[str, str]: + return {"status": "ok"} + return app + + +def create_report_endpoints( + app: FastAPI, + templates: Jinja2Templates, + report_tree: DirTree, + parent: Path = Path(), +) -> None: + tag = ":".join(["reports"] + list(report_tree.name.parts)) + + # recurse + for subtree in report_tree.dirs: + create_report_endpoints( + app, + templates, + subtree, + parent=report_tree.name, + ) + + # index + index_dir = parent / report_tree.name + index_path = to_url_path(index_dir) + + index_name = to_name(index_path, "index") + logger.debug( + f"Creating index endpoint for: path={index_path}, name={index_name}, parent={index_dir}" + ) + app.router.add_api_route( + path=index_path, + endpoint=create_index_endpoint(report_tree, templates, str(index_dir)), + name=index_name, + tags=[tag], + ) + + # reports + report_endpoints: list[ReportEndpoint] = [] + for report_file in report_tree.files: + report_file = parent / report_tree.name / report_file + report_path = to_url_path(report_file) + report_name = to_name(report_path) + logger.debug( + f"Creating report endpoint for: file={report_file}, path={report_path}, name={report_name}" + ) + + report_endpoints.append( + ReportEndpoint( + name=report_name, + path=report_path, + endpoint=create_report_endpoint(str(report_file), templates), + ) + ) + for report_endpoint in report_endpoints: + app.router.add_api_route( + path=report_endpoint.path, + endpoint=report_endpoint.endpoint, + name=report_endpoint.name, + tags=[tag], + ) + + +def create_index_endpoint( + report_tree: DirTree, + templates: Jinja2Templates, + parent: str = "", +) -> Callable[..., Awaitable[HTMLResponse]]: + async def index_endpoint(request: Request) -> HTMLResponse: + return templates.TemplateResponse( + "index.html", + { + "request": request, + "report_tree": report_tree, + "parent": parent, + }, + ) + + return index_endpoint + + +def create_report_endpoint( + report: str, + templates: Jinja2Templates, +) -> Callable[..., Awaitable[HTMLResponse]]: + async def report_endpoint(request: Request) -> HTMLResponse: + return templates.TemplateResponse( + "report.html", + { + "request": request, + "report": report, + }, + ) + + return report_endpoint diff --git a/examples/full/Dockerfile b/examples/full/Dockerfile index 69f2a14..af8eb69 100644 --- a/examples/full/Dockerfile +++ b/examples/full/Dockerfile @@ -1,7 +1,10 @@ FROM python:3.12-slim COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv -RUN useradd --uid 1001 --user-group --home-dir=/home/bored-charts --create-home --shell=/bin/false bored-charts +RUN apt-get update && apt-get install -y --no-install-recommends curl && \ + rm -rf /var/lib/apt/lists/* &&\ + useradd --uid 1001 --user-group --home-dir=/home/bored-charts --create-home --shell=/bin/false bored-charts + USER bored-charts COPY --chown=bored-charts pyproject.toml /app/pyproject.toml diff --git a/examples/full/pages/more/test.md b/examples/full/pages/more/test.md new file mode 100644 index 0000000..45b983b --- /dev/null +++ b/examples/full/pages/more/test.md @@ -0,0 +1 @@ +hi diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..f07762b --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,26 @@ +from pathlib import Path + +from boredcharts.utils import DirTree, get_dirtree + + +def test_get_dirtree() -> None: + directory = Path(__file__).parent.parent / "examples/full/pages" + expected: DirTree = DirTree( + name=Path(), + files=[ + Path("price-elasticity.md"), + Path("vega-lite-is-cool.md"), + Path("populations.md"), + ], + dirs=[ + DirTree( + name=Path("more"), + files=[ + Path("test.md"), + ], + dirs=[], + ) + ], + ) + result = get_dirtree(directory) + assert expected == result From 20f8539b34119157acbd40bd6b3ff8e975d2298d Mon Sep 17 00:00:00 2001 From: Oliver Lambson Date: Mon, 26 Aug 2024 00:54:08 +0200 Subject: [PATCH 2/5] style --- bored-charts/boredcharts/templates/index.html | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bored-charts/boredcharts/templates/index.html b/bored-charts/boredcharts/templates/index.html index f9e5825..6774a2b 100644 --- a/bored-charts/boredcharts/templates/index.html +++ b/bored-charts/boredcharts/templates/index.html @@ -11,15 +11,15 @@ {% endfor %} {% for dir in dirtree.dirs %}
- + - {{ parent / dir.name }} + {{ parent / dir.name ~ "/" }} - {{ render_tree(dir, parent / dir.name) }} +
{{ render_tree(dir, parent / dir.name) }}
{% endfor %} From 88d43ff44fae8beb406776573710230859076bf9 Mon Sep 17 00:00:00 2001 From: Oliver Lambson Date: Mon, 26 Aug 2024 00:58:25 +0200 Subject: [PATCH 3/5] clean up --- bored-charts/boredcharts/utils.py | 16 +++++++++++++ bored-charts/boredcharts/webapp.py | 37 +++++++++++++++--------------- 2 files changed, 35 insertions(+), 18 deletions(-) diff --git a/bored-charts/boredcharts/utils.py b/bored-charts/boredcharts/utils.py index 49e3ebb..12663bc 100644 --- a/bored-charts/boredcharts/utils.py +++ b/bored-charts/boredcharts/utils.py @@ -22,3 +22,19 @@ def get_dirtree(parent: Path, directory: Path = Path()) -> DirTree: dirs.append(get_dirtree(full_dir, item.relative_to(full_dir))) return DirTree(name=directory, files=files, dirs=dirs) + + +def to_name(path: Path | str, kind: str = "") -> str: + if isinstance(path, str): + path = Path(path) + if path.name: + path = path.with_suffix("") + if path.root.startswith("/"): + path = path.relative_to("/") + return str(Path(kind) / path).replace("/", ".").strip(".") + + +def to_url_path(path: Path) -> str: + if path.name: + path = path.with_suffix("") + return str(Path("/") / path) diff --git a/bored-charts/boredcharts/webapp.py b/bored-charts/boredcharts/webapp.py index cccd09a..13a3f50 100644 --- a/bored-charts/boredcharts/webapp.py +++ b/bored-charts/boredcharts/webapp.py @@ -13,7 +13,7 @@ from boredcharts.jinja import figure, md_to_html, row from boredcharts.router import FigureRouter -from boredcharts.utils import DirTree, get_dirtree +from boredcharts.utils import DirTree, get_dirtree, to_name, to_url_path logger = logging.getLogger("boredcharts") @@ -26,29 +26,30 @@ class ReportEndpoint(NamedTuple): endpoint: Callable[..., Awaitable[HTMLResponse]] -def to_name(path: Path | str, kind: str = "") -> str: - if isinstance(path, str): - path = Path(path) - if path.name: - path = path.with_suffix("") - if path.root.startswith("/"): - path = path.relative_to("/") - return str(Path(kind) / path).replace("/", ".").strip(".") - - -def to_url_path(path: Path) -> str: - if path.name: - path = path.with_suffix("") - return str(Path("/") / path) - - def boredcharts( pages: Path, figures: FigureRouter | list[FigureRouter], *, index_name: str = "bored-charts", ) -> FastAPI: - """Creates a boredcharts app.""" + """Creates a boredcharts app. + + Usage: + + ```py + from pathlib import Path + from boredcharts import FigureRouter, boredcharts + import plotly.graph_objects as go + + router = FigureRouter() + + @router.chart("my_chart") + async def my_chart() -> go.Figure: + return go.Figure() + + app = boredcharts(Path("pages"), router) + ``` + """ static_root = module_root / "static" templates_root = module_root / "templates" Path(static_root / "plotlyjs.min.js").write_text(get_plotlyjs()) From 638d7dd01e358e1560c1cecdfa96cc12417c65d4 Mon Sep 17 00:00:00 2001 From: Oliver Lambson Date: Mon, 26 Aug 2024 01:02:30 +0200 Subject: [PATCH 4/5] docs --- examples/full/README.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/examples/full/README.md b/examples/full/README.md index 2b94444..28eeb39 100644 --- a/examples/full/README.md +++ b/examples/full/README.md @@ -16,13 +16,15 @@ Project structure: │ ├── elasticity.py │ ├── medals.py │ ├── population.py -│ └── ... <-- add more figures here +│ └── ... <-- add more figures here ├── pages -│ ├── populations.md +│ ├── more - note you can create nested paths: +│ │ └── test.md <- this will be at /more/test +│ ├── populations.md <- this will be at /populations │ ├── price-elasticity.md │ ├── vega-lite-is-cool.md -│ └── ... <-- add more reports here -├── app.py <-- the main bored-charts app +│ └── ... <-- add more reports here +├── app.py - the main bored-charts app ├── pyproject.toml └── README.md ``` From 687868eadcf24c72981ad841fa520106982b70b6 Mon Sep 17 00:00:00 2001 From: Oliver Lambson Date: Mon, 26 Aug 2024 01:07:10 +0200 Subject: [PATCH 5/5] isolate test --- tests/pages/nest/doublenest/test2.md | 0 tests/pages/nest/test.md | 0 tests/test_utils.py | 18 ++++++++++++------ 3 files changed, 12 insertions(+), 6 deletions(-) create mode 100644 tests/pages/nest/doublenest/test2.md create mode 100644 tests/pages/nest/test.md diff --git a/tests/pages/nest/doublenest/test2.md b/tests/pages/nest/doublenest/test2.md new file mode 100644 index 0000000..e69de29 diff --git a/tests/pages/nest/test.md b/tests/pages/nest/test.md new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_utils.py b/tests/test_utils.py index f07762b..c72a089 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -4,21 +4,27 @@ def test_get_dirtree() -> None: - directory = Path(__file__).parent.parent / "examples/full/pages" + directory = Path(__file__).parent / "pages" expected: DirTree = DirTree( name=Path(), files=[ - Path("price-elasticity.md"), - Path("vega-lite-is-cool.md"), - Path("populations.md"), + Path("example.md"), ], dirs=[ DirTree( - name=Path("more"), + name=Path("nest"), files=[ Path("test.md"), ], - dirs=[], + dirs=[ + DirTree( + name=Path("doublenest"), + files=[ + Path("test2.md"), + ], + dirs=[], + ) + ], ) ], )