diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml new file mode 100644 index 0000000000..241ee505b1 --- /dev/null +++ b/.github/workflows/pytest.yml @@ -0,0 +1,37 @@ +name: Python Tests +env: + PYTHON: "3.11" + +on: + push: + branches: [main] + paths: + - "ofd/**" + - "tests/**" + - "pyproject.toml" + - "uv.lock" + pull_request: + paths: + - "ofd/**" + - "tests/**" + - "pyproject.toml" + - "uv.lock" + +jobs: + pytest: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: go-task/setup-task@v2 + with: + version: 3.x + + - uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + python-version: ${{ env.PYTHON }} + + - name: Run tests + run: task test diff --git a/.gitignore b/.gitignore index 6853604079..ff7820aa42 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,6 @@ dist/ .cache/ .env .task/ +htmlcov/ +.coverage +local/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000000..1007eec349 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,46 @@ +# Contributing (Developer Setup) + +This guide is for developers working on the OFD codebase. For adding filament data, see the [main README](README.md). + +## Prerequisites + +- **uv** — `curl -LsSf https://astral.sh/uv/install.sh | sh` or see [docs.astral.sh/uv](https://docs.astral.sh/uv/getting-started/installation/) +- **Node.js 22+** +- **Task** — `brew install go-task` or see [taskfile.dev/installation](https://taskfile.dev/installation/) + +## Bootstrap + +```sh +task setup # installs Python deps (uv) + WebUI deps (npm) +``` + +## Common tasks + +```sh +task test # pytest +task test-all # pytest + Playwright E2E +task lint # ruff + svelte-check +task serve # start OFD API server +``` + +Run `task --list` for the full list. + +## Making a PR + +Run `task check` before opening a pull request — it covers lint, data validation, and all tests in one go. + +Keep each PR focused on a single logical change. Smaller, focused PRs are easier to review and less likely to conflict with concurrent work. + +## Frontend development + +No need to install Task just to work on the frontend — npm scripts work directly: + +```sh +cd webui +npm run dev # dev server +npm test # Playwright E2E tests +npm run check # svelte-check type checking +npm run build # production build +``` + +> **Note:** The frontend currently uses npm directly. If we ever switch to a different package manager (bun, pnpm, etc.), adding a `Taskfile.yml` inside `webui/` would let all tooling and CI use `task` commands, making the package manager an implementation detail that only Taskfile needs to know about. diff --git a/README.md b/README.md index 20f236536c..9aa2541588 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,8 @@ Click the **Fork** button in the top right of this page, a guide is [available h ### 3. Install our requirements If you don't have Git installed, [follow this guide](docs/installing-software.md#git). The OFD wrapper script will help you install Python and Node.js automatically (see step 5). +Developers working on the codebase itself should also read [CONTRIBUTING.md](CONTRIBUTING.md). + ### 4. Download the database Download the database using either [this guide](docs/cloning.md) or by just using the command below, with `YOUR_USERNAME` replaced ofc! ```bash diff --git a/Taskfile.yml b/Taskfile.yml index ac64c5771a..21fdc388d6 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -1,24 +1,52 @@ version: "3" +includes: + local: + taskfile: local/Taskfile.yml + optional: true # not checked in; create local/Taskfile.yml for personal tasks + +vars: + WEBUI_DIR: webui + +# NOTE: taskfile can be easily written to support different operating systems, but for simplicity we'll assume +# mostly *nix-like operating systems are used, esp. MacOS and Linux, meaning most rules would work without +# need for OS-specific variants + tasks: # ── Setup ──────────────────────────────────────────────────────────────── setup: - desc: Install Python dependencies + desc: Install all dependencies (Python + WebUI) # keep this task idempotent and without rules, it should instead depend on other smaller tasks to # make things ready for development. Ideally `task test` should just work after running this - deps: [.setup-python] + deps: [.setup-python, .install-playwright] .setup-python: - internal: true cmds: - - uv sync --frozen --extra dev + - uv sync --frozen sources: - pyproject.toml - uv.lock generates: - .venv/pyvenv.cfg + .setup-webui: + cmds: + - npm --prefix {{.WEBUI_DIR}} ci + - touch {{.WEBUI_DIR}}/node_modules/.webui-installed + sources: + - "{{.WEBUI_DIR}}/package.json" + - "{{.WEBUI_DIR}}/package-lock.json" + generates: + - "{{.WEBUI_DIR}}/node_modules/.webui-installed" + + .install-playwright: + deps: [.setup-webui] + dir: "{{.WEBUI_DIR}}" + status: + - test -f node_modules/.playwright-installed + cmd: npm run test:install && touch node_modules/.playwright-installed + # ── Python CLI ─────────────────────────────────────────────────────────── validate: @@ -51,13 +79,31 @@ tasks: cmds: - uv run -m ofd script {{.CLI_ARGS}} + # ── WebUI ──────────────────────────────────────────────────────────────── + + webui: + desc: Start the WebUI dev server (http://localhost:5173) + deps: [.setup-webui] + dir: "{{.WEBUI_DIR}}" + cmds: + - npm run dev + + webui-build: + desc: Build the WebUI for production + deps: [.setup-webui] + dir: "{{.WEBUI_DIR}}" + cmds: + - npm run build + # ── Code quality ───────────────────────────────────────────────────────── lint: - desc: Lint Python code with ruff - deps: [.setup-python] + desc: Lint all code (Python + WebUI) + deps: [.setup-python, .setup-webui] cmds: - uv run ruff check . + - uv run ruff format --check . + - npm --prefix {{.WEBUI_DIR}} run check lint-fix: desc: Lint and auto-fix Python code with ruff @@ -67,28 +113,47 @@ tasks: format: desc: Format Python code with ruff + aliases: ["fmt"] deps: [.setup-python] cmds: - uv run ruff format . test: - desc: Run Python test suite with pytest + desc: Run Python tests (pytest) deps: [.setup-python] cmds: - uv run pytest --durations 3 --ff {{.CLI_ARGS}} + test-cov: + desc: Run Python tests with coverage (terminal + HTML report in htmlcov/) + deps: [.setup-python] + cmds: + - uv run pytest --cov=ofd --cov-report= --cov-report=html {{.CLI_ARGS}} + - uv run coverage report --skip-covered --show-missing --sort=cover + + test-all: + desc: Run all tests (Python + WebUI) + deps: [test, .install-playwright] + cmds: + - npm --prefix {{.WEBUI_DIR}} run test + requirements: desc: Regenerate requirements.txt from uv.lock (for pip users) deps: [.setup-python] cmds: - uv export --no-hashes -o requirements.txt + # ── Maintenance ────────────────────────────────────────────────────────── + + clean: + desc: Remove Python cache files and build artefacts + cmds: # note: trailing plus makes command not to fail if there are no matches + - find ofd -type d -name __pycache__ -exec rm -rf {} + + - find ofd -type f -name "*.py[cod]" -delete + - find . -type d -name "*.egg-info" -exec rm -rf {} + + # ── Compound ───────────────────────────────────────────────────────────── check: - desc: Lint, format-check, and test (CI equivalent) - deps: [.setup-python] - cmds: - - uv run ruff check . - - uv run ruff format --check . - - uv run pytest + desc: Lint, format-check, validate, and test — Python + WebUI (CI equivalent) + deps: [lint, validate, test-all] diff --git a/pyproject.toml b/pyproject.toml index aeae6c843b..a40602e4a5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,46 +3,37 @@ name = "ofd" version = "1.0.0" description = "Open Filament Database CLI - Unified tooling for the OFD project" readme = "README.md" -requires-python = ">=3.10" +requires-python = ">=3.10" # NOTE: several other places assume 3.11 => require at least 3.11? license = { text = "MIT" } -authors = [ - { name = "Open Filament Collective" } -] +authors = [{ name = "Open Filament Collective" }] keywords = ["filament", "3d-printing", "database", "cli"] classifiers = [ - "Development Status :: 4 - Beta", - "Environment :: Console", - "Intended Audience :: Developers", - "License :: OSI Approved :: MIT License", - "Operating System :: OS Independent", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", - "Programming Language :: Python :: 3.13", - "Programming Language :: Python :: 3.14", - "Topic :: Database", - "Topic :: Utilities", + "Development Status :: 4 - Beta", + "Environment :: Console", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Topic :: Database", + "Topic :: Utilities", ] dependencies = [ - "argcomplete>=3.1.0", - "jsonschema>=4.23.0", - "markdown>=3.0", - "ofd-validator>=0.5.1", - "iniconfig>=2.0.0", - "referencing>=0.30.0", - "PyYAML>=6.0", - "requests>=2.28.0", + "argcomplete>=3.1.0", + "jsonschema>=4.23.0", + "markdown>=3.0", + "ofd-validator>=0.5.1", + "iniconfig>=2.0.0", + "referencing>=0.30.0", + "PyYAML>=6.0", + "requests>=2.28.0", ] -[project.optional-dependencies] -dev = [ - "pytest>=7.0.0", - "pytest-cov>=4.0.0", - "mypy>=1.0.0", - "ruff>=0.1.0", -] [project.scripts] ofd = "ofd.__main__:main" @@ -66,7 +57,7 @@ line-length = 100 [tool.ruff.lint] select = ["E", "F", "W", "I", "UP", "B", "C4"] -ignore = ["E501"] # Line too long - handled by formatter +ignore = ["E501"] # Line too long - handled by formatter [tool.mypy] python_version = "3.10" @@ -78,3 +69,8 @@ ignore_missing_imports = true testpaths = ["tests"] python_files = ["test_*.py"] python_functions = ["test_*"] + +# PEP 735 dependency groups — preferred over [project.optional-dependencies] for dev tooling. +# uv includes these by default (no --extra flag needed). +[dependency-groups] +dev = ["mypy>=1.0.0", "pytest>=7.0.0", "pytest-cov>=4.0.0", "ruff>=0.1.0"] diff --git a/tests/test_dummy.py b/tests/test_dummy.py deleted file mode 100644 index 5d7c114c23..0000000000 --- a/tests/test_dummy.py +++ /dev/null @@ -1,3 +0,0 @@ -# Delete once real tests exist. Verifies the pytest setup is functional. -def test_pytest_setup(): - assert 1 == 1, "math is not mathing" diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000000..daea6d08d6 --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,85 @@ +import uuid + +import pytest + +from ofd.builder import utils + + +def test_generate_brand_uuid_matches_spec() -> None: + assert utils.generate_brand_uuid("Prusament") == "ae5ff34e-298e-50c9-8f77-92a97fb30b09" + + +def test_generate_material_uuid_matches_spec() -> None: + brand_uuid = utils.generate_brand_uuid("Prusament") + assert ( + utils.generate_material_uuid(brand_uuid, "PLA Prusa Galaxy Black") + == "1aaca54a-431f-5601-adf5-85dd018f487f" + ) + + +def test_generate_material_uuid_accepts_uuid_object() -> None: + brand_str = utils.generate_brand_uuid("Prusament") + brand_obj = uuid.UUID(brand_str) + assert utils.generate_material_uuid(brand_str, "PLA") == utils.generate_material_uuid( + brand_obj, "PLA" + ) + + +@pytest.mark.parametrize( + "text, expected", + [ + ("MixedCase", "mixedcase"), + ("PLA Basic", "pla_basic"), + ("pla-basic", "pla_basic"), + ("Support@Plus!", "supportplus"), + ("multiple spaces", "multiple_spaces"), + ("_leading_", "leading"), + ("PLA+", "pla+"), + ("!!!", ""), + ], +) +def test_slugify(text: str, expected: str) -> None: + assert utils.slugify(text) == expected + + +@pytest.mark.parametrize( + "color, expected", + [ + ("#FF0000", "#FF0000"), + ("#ff0000", "#FF0000"), + ("#fff", "#FFFFFF"), + ("fff", "#FFFFFF"), + ("FF0000", "#FF0000"), + ], +) +def test_normalize_color_hex(color: str, expected: str) -> None: + assert utils.normalize_color_hex(color) == expected + + +# This seems a bit odd. I'd expect code to throw in case like this, but implemented based on current behaviour. +# Didn't fix type yet in source, because it's not yet certain what would be desired behaviour +def test_normalize_color_hex_list_takes_first() -> None: + assert utils.normalize_color_hex(["#ff0000", "#00ff00"]) == "#FF0000" # type: ignore[arg-type] + + +@pytest.mark.parametrize("color", [None, ""]) +def test_normalize_color_hex_empty_returns_none(color: str | None) -> None: + assert utils.normalize_color_hex(color) is None + + +def test_normalize_color_hex_unparseable_returned_as_is() -> None: + assert utils.normalize_color_hex("not-a-color") == "not-a-color" + + +@pytest.mark.parametrize( + "value, expected", + [ + (None, []), + ("pla", ["pla"]), + (42, [42]), + ({"k": "v"}, [{"k": "v"}]), + (["a", "b"], ["a", "b"]), + ], +) +def test_ensure_list(value: object, expected: list) -> None: + assert utils.ensure_list(value) == expected diff --git a/uv.lock b/uv.lock index 40a323c7e1..ca2076d9eb 100644 --- a/uv.lock +++ b/uv.lock @@ -454,7 +454,7 @@ dependencies = [ { name = "requests" }, ] -[package.optional-dependencies] +[package.dev-dependencies] dev = [ { name = "mypy" }, { name = "pytest" }, @@ -468,16 +468,19 @@ requires-dist = [ { name = "iniconfig", specifier = ">=2.0.0" }, { name = "jsonschema", specifier = ">=4.23.0" }, { name = "markdown", specifier = ">=3.0" }, - { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.0.0" }, { name = "ofd-validator", specifier = ">=0.5.1" }, - { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0.0" }, - { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.0.0" }, { name = "pyyaml", specifier = ">=6.0" }, { name = "referencing", specifier = ">=0.30.0" }, { name = "requests", specifier = ">=2.28.0" }, - { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.1.0" }, ] -provides-extras = ["dev"] + +[package.metadata.requires-dev] +dev = [ + { name = "mypy", specifier = ">=1.0.0" }, + { name = "pytest", specifier = ">=7.0.0" }, + { name = "pytest-cov", specifier = ">=4.0.0" }, + { name = "ruff", specifier = ">=0.1.0" }, +] [[package]] name = "ofd-validator"