Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: cli & export to pdf #4

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@ __pycache__
node_modules/

boredcharts/static/plotlyjs.min.js
*.pdf
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,13 @@ Build easy, minimal, PDF-able data reports with markdown and python.
- [ ] matplotlib figures as svg?
- [ ] support python 3.10, 3.11
- [ ] make plotting libraries optional
- [ ] pdf exports with selenium in headless mode
- [x] pdf exports with selenium in headless mode
- [ ] cli (`boredcharts init`, `boredcharts export [report]`, `boredcharts list`, `boredcharts dev`, `boredcharts run`)
- [x] list/export/dev/run
- [ ] init
- [ ] ability to archive reports (export to static html, move to archive directory,
still serve from archive directory, but can get rid of analysis—could just
be archive endpoints for figures?)
- [ ] cli? (`boredcharts init`, `boredcharts export [report]`, `boredcharts dev`, `boredcharts serve`)
- [ ] deploy to [bored-charts-example.oliverlambson.com](https://bored-charts-example.oliverlambson.com)
- [ ] dashboard layout with tighter grid layout
- [ ] example with database
Expand Down
5 changes: 5 additions & 0 deletions bored-charts/boredcharts/cli/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from boredcharts.cli.cli import main

__all__ = [
"main",
]
233 changes: 233 additions & 0 deletions bored-charts/boredcharts/cli/cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
import argparse
import asyncio
import importlib
import multiprocessing
import time
from pathlib import Path
from typing import Literal, NamedTuple
from urllib.error import URLError
from urllib.request import urlopen

import uvicorn
from fastapi import FastAPI
from starlette.routing import NoMatchFound

from boredcharts.cli.discover import get_import_string
from boredcharts.pdf import UrlToPdfFile, print_to_pdf_manual


class Report(NamedTuple):
name: str
urlpath: str
tag: str


def get_report_url(
path: Path | None,
app_name: str | None,
name: str,
) -> str:
import_str = get_import_string(path=path, app_name=app_name) # mutates sys.path
mod = importlib.import_module(import_str.split(":")[0])
app = getattr(mod, import_str.split(":")[1])
assert isinstance(app, FastAPI)
return app.url_path_for(name)


def get_reports(
path: Path | None,
app_name: str | None,
) -> list[Report]:
import_str = get_import_string(path=path, app_name=app_name) # mutates sys.path
mod = importlib.import_module(import_str.split(":")[0])
app = getattr(mod, import_str.split(":")[1])
assert isinstance(app, FastAPI)
openapi = app.openapi()
paths = openapi["paths"]
assert isinstance(paths, dict)

reports: list[Report] = []
for urlpath, methods in paths.items():
assert isinstance(urlpath, str)
assert isinstance(methods, dict)
for method, data in methods.items():
assert isinstance(method, str)
assert isinstance(data, dict)
if method != "get":
continue

tags = data.get("tags")
if tags is None:
continue
assert isinstance(tags, list)
tags = [t for t in tags if t.startswith("report")] # boredcharts convention
if not tags:
continue

name = data.get("summary")
assert isinstance(name, str)
name = name.lower().replace(" ", "_") # reverse fastapi name->summary
if name.startswith("index"): # boredcharts convention
continue

for tag in tags:
reports.append(Report(name=name, urlpath=urlpath, tag=tag))

return reports


def _run_uvicorn(
path: Path | None,
app_name: str | None,
reload: bool = False,
host: str = "127.0.0.1",
port: int = 4000,
log_level: Literal[
"critical",
"error",
"warning",
"info",
"debug",
"trace",
] = "info",
) -> None:
import_str = get_import_string(path=path, app_name=app_name)
uvicorn.run(
import_str,
host=host,
port=port,
proxy_headers=True,
forwarded_allow_ips="*",
reload=reload,
log_level=log_level,
)


def init(
path: Path | None,
app_name: str | None,
) -> None:
"""create a new project scaffolding"""
raise NotImplementedError


def list_reports(
path: Path | None,
app_name: str | None,
) -> None:
"""list available reports"""
reports = get_reports(path, app_name)
reports = sorted(reports, key=lambda x: f"{x.tag}::{x.name}")
urlpathwidth = max(len(r.urlpath) for r in reports)
name = max(len(r.name) for r in reports)
tagwidth = max(len(r.tag) for r in reports)
print(
f"{"REPORT".ljust(name)} {"CATEGORY".ljust(tagwidth)} {"URL".ljust(urlpathwidth)}"
)
for r in reports:
category = ":".join(r.tag.split(":")[1:]) or "-" # boredcharts convention
print(
f"{r.name.ljust(name)} {category.ljust(tagwidth)} {r.urlpath.ljust(urlpathwidth)}"
)


def export(
path: Path | None,
app_name: str | None,
report: str,
*,
exporter: UrlToPdfFile = print_to_pdf_manual,
) -> None:
"""write to pdf

TODO:
- [x] write to pdf
- [x] spin up server
- [x] provide list of reports
"""
try:
route = get_report_url(path, app_name, report)
except NoMatchFound:
print(f'Report "{report}" not found!')
print("Use `boredcharts list` to see available reports.")
raise SystemExit(1)

host = "127.0.0.1"
port = 4001 # different port just for exports
base_url = f"http://{host}:{port}"
process = multiprocessing.Process(
target=_run_uvicorn,
kwargs=dict(
path=path,
app_name=app_name,
reload=False,
host=host,
port=port,
log_level="warning",
),
)

print("Spinning up boredcharts app", end="", flush=True)
process.start()
for _ in range(10):
print(".", end="", flush=True)
time.sleep(0.1)
try:
with urlopen(f"{base_url}/healthz") as response:
status = response.status
except URLError:
continue
if status == 200:
print(" started!")
break
else:
print(" health check failed!")
raise Exception("Couldn't start app!")

url = f"{base_url}{route}"
file = Path(report.replace(".", "-")).absolute().with_suffix(".pdf")
asyncio.run(exporter(url, file))
print(f"Exported {report} to {file}")

process.terminate()


def dev(path: Path | None, app_name: str | None) -> None:
"""run uvicorn with reload"""
_run_uvicorn(path, app_name, reload=True)


def run(path: Path | None, app_name: str | None) -> None:
"""run uvicorn without reload"""
_run_uvicorn(path, app_name, reload=False)


def main() -> None:
"""cli entrypoint"""
parser = argparse.ArgumentParser(description="boredcharts CLI")
parser.add_argument("path", type=Path, default=None, help="Path to FastAPI app")
parser.add_argument("--app-name", type=str, default=None, help="FastAPI app name")

subparsers = parser.add_subparsers(dest="command")
subparsers.required = True

parser_init = subparsers.add_parser("init", help="Create a new project scaffolding")
parser_init.set_defaults(func=init)

parser_init = subparsers.add_parser("list", help="List available reports")
parser_init.set_defaults(func=list_reports)

parser_export = subparsers.add_parser("export", help="Write report to PDF")
parser_export.add_argument("report", type=str, help="The report to export")
parser_export.set_defaults(func=export)

parser_dev = subparsers.add_parser("dev", help="Run uvicorn with reload")
parser_dev.set_defaults(func=dev)

parser_serve = subparsers.add_parser("run", help="Run uvicorn without reload")
parser_serve.set_defaults(func=run)

args = parser.parse_args()

func_args = {k: v for k, v in vars(args).items() if k != "func" and k != "command"}
args.func(**func_args)
97 changes: 97 additions & 0 deletions bored-charts/boredcharts/cli/discover.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
"""forked from fastapi_cli.discover because i needed to get rid of the print statements"""

import importlib
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Union

from fastapi import FastAPI


def get_default_path() -> Path:
potential_paths = (
"main.py",
"app.py",
"api.py",
"app/main.py",
"app/app.py",
"app/api.py",
)

for full_path in potential_paths:
path = Path(full_path)
if path.is_file():
return path

raise Exception(
"Could not find a default file to run, please provide an explicit path"
)


@dataclass
class ModuleData:
module_import_str: str
extra_sys_path: Path


def get_module_data_from_path(path: Path) -> ModuleData:
use_path = path.resolve()
module_path = use_path
if use_path.is_file() and use_path.stem == "__init__":
module_path = use_path.parent
module_paths = [module_path]
extra_sys_path = module_path.parent
for parent in module_path.parents:
init_path = parent / "__init__.py"
if init_path.is_file():
module_paths.insert(0, parent)
extra_sys_path = parent.parent
else:
break
module_str = ".".join(p.stem for p in module_paths)
return ModuleData(
module_import_str=module_str, extra_sys_path=extra_sys_path.resolve()
)


def get_app_name(*, mod_data: ModuleData, app_name: Union[str, None] = None) -> str:
try:
mod = importlib.import_module(mod_data.module_import_str)
except (ImportError, ValueError):
raise # missing __init__.py?
object_names = dir(mod)
object_names_set = set(object_names)
if app_name:
if app_name not in object_names_set:
raise Exception(
f"Could not find app name {app_name} in {mod_data.module_import_str}"
)
app = getattr(mod, app_name)
if not isinstance(app, FastAPI):
raise Exception(
f"The app name {app_name} in {mod_data.module_import_str} doesn't seem to be a FastAPI app"
)
return app_name
for preferred_name in ["app", "api"]:
if preferred_name in object_names_set:
obj = getattr(mod, preferred_name)
if isinstance(obj, FastAPI):
return preferred_name
for name in object_names:
obj = getattr(mod, name)
if isinstance(obj, FastAPI):
return name
raise Exception("Could not find FastAPI app in module, try using --app")


def get_import_string(*, path: Path | None = None, app_name: str | None = None) -> str:
if not path:
path = get_default_path()
if not path.exists():
raise Exception(f"Path does not exist {path}")
mod_data = get_module_data_from_path(path)
sys.path.insert(0, str(mod_data.extra_sys_path))
use_app_name = get_app_name(mod_data=mod_data, app_name=app_name)
import_string = f"{mod_data.module_import_str}:{use_app_name}"
return import_string
Loading