diff --git a/.gitignore b/.gitignore index 33fd2fd..e9fcd6b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,7 @@ .venv/ __pycache__/ +*.egg-info/ +dist/ +build/ figs/ diff --git a/pyproject.toml b/pyproject.toml index 07db82d..7bfb983 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,7 @@ +[build-system] +requires = ["uv_build>=0.5.0,<0.9"] +build-backend = "uv_build" + [project] name = "dimension-reduction-sandbox" version = "0.0.1" @@ -23,6 +27,17 @@ dependencies = [ "sse-starlette>=2.1.3", ] +[project.scripts] +dr-sandbox = "dr_sandbox.cli:main" + +# Only the thin CLI shim is a distributable package. `app/`, `flows/`, +# `scripts/` stay source-tree-local — they're run in-place, not installed. +# Because the project slug ("dimension-reduction-sandbox") differs from +# the importable module, tell uv_build where to look. +[tool.uv.build-backend] +module-name = "dr_sandbox" +module-root = "src" + [tool.uv] override-dependencies = [ "annoy ; sys_platform == 'never'", # block source build of annoy; annoy-mm provides the module diff --git a/src/dr_sandbox/__init__.py b/src/dr_sandbox/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/dr_sandbox/assets/docker-compose.yml.tmpl b/src/dr_sandbox/assets/docker-compose.yml.tmpl new file mode 100644 index 0000000..7c3ab39 --- /dev/null +++ b/src/dr_sandbox/assets/docker-compose.yml.tmpl @@ -0,0 +1,98 @@ +# Prefect 3 self-hosted stack. +# Generated by `dr-sandbox setup` — re-run to regenerate with different values. +# Pinned to a known stable patch release; bump deliberately, not via `latest`. + +x-prefect-image: &prefect-image prefecthq/prefect:@@IMAGE@@ + +x-prefect-env: &prefect-env + PREFECT_API_DATABASE_CONNECTION_URL: postgresql+asyncpg://prefect:@@PASSWORD@@@postgres:5432/prefect + PREFECT_API_DATABASE_MIGRATE_ON_START: "true" + PREFECT_SERVER_ANALYTICS_ENABLED: "false" + DO_NOT_TRACK: "1" + PREFECT_LOGGING_LEVEL: INFO + PREFECT_MESSAGING_BROKER: prefect_redis.messaging + PREFECT_MESSAGING_CACHE: prefect_redis.messaging + PREFECT_REDIS_MESSAGING_HOST: redis + PREFECT_REDIS_MESSAGING_PORT: "6379" + PREFECT_REDIS_MESSAGING_DB: "0" + +services: + postgres: + image: postgres:16-alpine + restart: always + environment: + POSTGRES_USER: prefect + POSTGRES_PASSWORD: @@PASSWORD@@ + POSTGRES_DB: prefect + volumes: + - prefect_postgres:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U prefect -d prefect"] + interval: 5s + timeout: 5s + retries: 10 + networks: [prefect] + + redis: + image: redis:7-alpine + restart: always + # No AOF/RDB persistence: Prefect uses Redis as an ephemeral + # messaging broker + cache — restarts re-emit from the server. + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 5s + retries: 10 + networks: [prefect] + + prefect-server: + image: *prefect-image + restart: always + depends_on: + postgres: { condition: service_healthy } + redis: { condition: service_healthy } + environment: + <<: *prefect-env + PREFECT_SERVER_API_HOST: 0.0.0.0 + PREFECT_SERVER_API_PORT: "4200" + # Browser-facing URL; override at runtime via the env var of the same name. + PREFECT_SERVER_UI_API_URL: ${PREFECT_SERVER_UI_API_URL:-@@UI_API_URL@@} + PREFECT_UI_ENABLED: "true" + command: ["prefect", "server", "start", "--no-services", "--host", "0.0.0.0", "--port", "4200"] + ports: + - "@@PORT@@:4200" + expose: + - "4200" + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request,sys; sys.exit(0) if urllib.request.urlopen('http://localhost:4200/api/health', timeout=2).status==200 else sys.exit(1)"] + interval: 30s + timeout: 5s + retries: 5 + start_period: 30s + networks: [prefect] + + prefect-services: + image: *prefect-image + restart: always + depends_on: + prefect-server: { condition: service_healthy } + environment: + <<: *prefect-env + PREFECT_API_URL: http://prefect-server:4200/api + command: ["prefect", "server", "services", "start"] + networks: [prefect] + +volumes: + prefect_postgres: + +networks: + prefect: + name: prefect +# Other stacks can attach to this network to reach the API at +# http://prefect-server:4200/api — add to the other compose file: +# +# networks: +# prefect: +# external: true +# name: prefect +# diff --git a/src/dr_sandbox/cli.py b/src/dr_sandbox/cli.py new file mode 100644 index 0000000..eeafb92 --- /dev/null +++ b/src/dr_sandbox/cli.py @@ -0,0 +1,118 @@ +"""dr-sandbox CLI — one-shot helpers for setting up this project. + +For now, just `dr-sandbox setup`: renders the Prefect stack +docker-compose.yml into a destination path. Prompts for each value (enter +accepts the default shown in brackets). Pass --auto to take every default +without prompting. +""" + +from __future__ import annotations + +import argparse +import sys +from importlib import resources +from pathlib import Path +from typing import Dict, List, Optional + + +DEFAULT_OUTPUT = "./prefect/docker-compose.yml" + +DEFAULTS: Dict[str, str] = { + "IMAGE": "3.6.27-python3.12", # Prefect docker image tag + "PORT": "4200", # host-exposed port -> 4200 inside + "PASSWORD": "prefect", # postgres password + "UI_API_URL": "http://localhost:4200/api", +} + + +def _prompt(label: str, default: str) -> str: + try: + resp = input(f" {label} [{default}]: ").strip() + except EOFError: + return default + return resp or default + + +def _render(values: Dict[str, str]) -> str: + tmpl = ( + resources.files("dr_sandbox") + .joinpath("assets/docker-compose.yml.tmpl") + .read_text(encoding="utf-8") + ) + out = tmpl + for k, v in values.items(): + out = out.replace(f"@@{k}@@", v) + return out + + +def _cmd_setup(args: argparse.Namespace) -> int: + if args.auto: + out_path = Path(DEFAULT_OUTPUT) + values = dict(DEFAULTS) + else: + print("prefect stack config — press enter to accept each default\n") + out_path = Path(_prompt("output path", DEFAULT_OUTPUT)) + values = {k: _prompt(_LABELS[k], DEFAULTS[k]) for k in DEFAULTS} + + out_path = out_path.expanduser().resolve() + if out_path.exists() and not args.force: + print( + f"refusing to overwrite {out_path} — pass --force to replace", + file=sys.stderr, + ) + return 1 + + rendered = _render(values) + out_path.parent.mkdir(parents=True, exist_ok=True) + out_path.write_text(rendered, encoding="utf-8") + + print() + print(f"wrote {out_path}") + print() + print("next steps:") + print(f" (cd {out_path.parent} && docker compose up -d)") + print(f" uv sync # install python deps in .venv") + print(f" make service-install # optional: run the worker as a systemd user service") + return 0 + + +_LABELS: Dict[str, str] = { + "IMAGE": "prefect image tag", + "PORT": "host-exposed API port", + "PASSWORD": "postgres password", + "UI_API_URL": "browser-facing API URL", +} + + +def main(argv: Optional[List[str]] = None) -> int: + ap = argparse.ArgumentParser( + prog="dr-sandbox", + description="dr-sandbox helpers — today, just the first-time-setup flow.", + ) + sub = ap.add_subparsers(dest="cmd", required=True) + + p_setup = sub.add_parser( + "setup", + help="render the Prefect docker-compose.yml for this project", + description=( + "Render the Prefect stack docker-compose.yml. Prompts for each " + "knob (image tag, port, postgres password, UI URL); press enter " + "to accept the default shown. Use --auto for non-interactive." + ), + ) + p_setup.add_argument( + "--auto", action="store_true", + help="take every default without prompting", + ) + p_setup.add_argument( + "--force", action="store_true", + help="overwrite the output file if it already exists", + ) + p_setup.set_defaults(fn=_cmd_setup) + + args = ap.parse_args(argv) + return args.fn(args) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/uv.lock b/uv.lock index 2c5c8b7..63c2c4d 100644 --- a/uv.lock +++ b/uv.lock @@ -747,7 +747,7 @@ wheels = [ [[package]] name = "dimension-reduction-sandbox" version = "0.0.1" -source = { virtual = "." } +source = { editable = "." } dependencies = [ { name = "annoy-mm" }, { name = "fastapi" },