Skip to content

Development Guide

The repository holds two applications. Pick the section for the one you are working on; the Architecture page explains how they relate.


Prerequisites

Tool Version Needed for
Python 3.11+ Sidecar backend, legacy app
Node + npm 20 recommended React renderer
Rust ≥ 1.85 Tauri shell
Tauri CLI 2.x Building and running the desktop app

Rust 1.85 is a hard floor: the Tauri 2.x dependency tree pulls crates that use edition 2024. Install via rustup and let it manage the toolchain — see Rust Toolchain for the details and for what goes wrong on an older compiler.

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
. "$HOME/.cargo/env"     # add this to your shell profile
rustc --version          # expect >= 1.85

V2 stack

Backend (FastAPI sidecar)

python -m venv .venv
.venv/bin/pip install -e "backend[dev,package]"
cd backend && ../.venv/bin/pytest        # 84 tests collected

backend/pyproject.toml sets pythonpath = ["src"], so no editable install is strictly required to run the tests.

Run the sidecar directly, the way the Tauri shell does:

PORTFOLIO_SIDECAR_TOKEN=dev-token \
  .venv/bin/python -m portfolio_manager.cli.sidecar --port 8765

curl -s 127.0.0.1:8765/health
curl -s -H "X-API-Key: dev-token" 127.0.0.1:8765/api/v1/projects

Frontend (React renderer)

All npm scripts run from the repository root, even though the Vite and TypeScript configs live in frontend/. The scripts pass the correct root through; running vite or tsc bare from the root picks up no config.

npm install
npm test              # Vitest — 22 tests across 7 files
npm run typecheck     # tsc --noEmit
npm run build         # typecheck + production bundle

Tauri shell

cd src-tauri && cargo test -- --test-threads=1
npm run tauri dev             # from the repo root — runs the full stack

Use --test-threads=1 for deterministic local validation. cargo test compiles the Tauri context, which requires two artifacts to exist before it will build at all:

  • src-tauri/binaries/portfolio-sidecar/ — the frozen sidecar, shipped through bundle.resources in tauri.conf.json. Build it first (below).
  • src-tauri/icons/icon.png in RGBA format. A non-RGBA PNG fails inside tauri::generate_context! with a message that does not obviously point at the icon.

Contract types

python scripts/generate_openapi.py           # regenerate frontend/src/contracts/generated
python scripts/verify_no_renderer_http.py    # assert the renderer has no direct HTTP

The second script is a guard rail for ADR-001: the renderer must reach the sidecar only through Tauri commands.


Packaging (macOS)

# 1. Freeze the sidecar for your architecture
.venv/bin/python -m pip install -e "backend[dev,package]"
.venv/bin/python scripts/build_sidecar.py

# 2. Build the app bundle
npm run tauri build

# 3. Sign it and install into /Applications
scripts/install_macos_app.sh

The default scripts/build_sidecar.py mode is onedir. That output is bundled as a Tauri resource and starts much faster than the PyInstaller onefile layout. Use onefile only when deliberately testing the fallback path.

Use x86_64-apple-darwin on Intel when building architecture-specific artifacts. Per ADR-010 the sidecar is built per-architecture; a universal binary comes later.

Add --bundles dmg for a distributable disk image. Build the .app last if you intend to sign it — the DMG step deletes the staged .app after packing it, so install_macos_app.sh will not find a bundle if dmg ran most recently.

Three things reliably go wrong on a first build:

Symptom Cause
beforeBuildCommand ... failed, tsc: command not found npm run build runs from the repository root. If npm dependencies did not install (see network mounts below), pre-build frontend/dist and skip the hook with --config '{"build":{"beforeBuildCommand":""}}'
feature 'edition2024' is required The Tauri CLI shells out to whichever cargo is on PATH. A non-rustup Rust will shadow the rustup toolchain — put $HOME/.cargo/bin first
resource path ... doesn't exist The frozen sidecar has not been built for your target triple yet

install_macos_app.sh signs nested Mach-O code before the bundle itself, verifies the signature, then stages and swaps the install so a failed copy cannot destroy a working app. It picks a Developer ID Application certificate if the keychain has one — adding the hardened runtime and the entitlements in src-tauri/entitlements.plist — and otherwise falls back to ad-hoc signing, which runs locally but cannot be notarized or distributed. Run scripts/install_macos_app.sh --help for the options.

Gatekeeper and ad-hoc signatures

Without a Developer ID the app is signed ad-hoc. spctl --assess returns rejected, which is expected and does not stop it running. A locally built bundle carries no quarantine attribute, so it opens normally on the machine that built it. Copied to another Mac it would be quarantined and need right-click → Open, or xattr -dr com.apple.quarantine.


Legacy Tkinter app

python3 -m venv .venv && source .venv/bin/activate
pip install -e .[dev]
python -m portfolio_manager      # or: bash launch.sh

create_shortcut.sh builds a .app wrapper for the Dock that calls launch.sh from the repo, so it never needs rebuilding after a git pull.

Adding a database migration

The current Tauri app uses the FastAPI sidecar migration registry:

  1. Open backend/src/portfolio_manager/infrastructure/db/migrations/versions.py.
  2. Append a tuple to build_migrations():
("v2", "Add color column to project", "ALTER TABLE project ADD COLUMN color TEXT;")
  1. Run the sidecar or desktop app. The migration is applied on next startup, after a backup is written to <name>.db.bak.

If the legacy Tkinter app must remain compatible with the same database shape, mirror the migration in src/portfolio_manager/db/migrations.py and its tests.


Code style

Tool Purpose Config
black Formatter (line length 88) pyproject.toml
ruff Linter pyproject.toml, backend/pyproject.toml
mypy Type checker (strict in backend/) pyproject.toml, backend/pyproject.toml
tsc TypeScript type checker (strict) frontend/tsconfig.json
black src/ tests/
ruff check src/ tests/
mypy src/
npm run typecheck

Public Python classes and functions use reStructuredText docstrings:

def complete_session(self, session_id: int, notes: str = "") -> Session:
    """Mark a session as completed and record the completion timestamp.

    :param session_id: Primary key of the session to complete.
    :param notes: Optional session notes.
    :returns: The updated Session domain object.
    :rtype: Session
    :raises SessionStateError: If the session is already completed or cancelled.
    """

Optional pre-commit hooks run black and ruff:

pip install pre-commit && pre-commit install

Logging

Both applications use the standard logging module — never print() for diagnostics. Logs rotate at ~/.portfolio_manager/logs/app.log (max 5 MB, 2 backups). The sidecar and the Rust shell both install redaction filters so the per-launch API token cannot reach a log file.


Building the docs

docs/ is generated output, not source. mkdocs.yml sets docs_dir: site/src and site_dir: docs, so every build wipes docs/ and rewrites it. Edit site/src/, never docs/.

site/src/guide/ is also generated: the tools/prepare_guide.py hook copies it from the DITA output in guide/out/markdown/ on every build. To change the user guide, edit the .dita sources in guide/ and regenerate.

mkdocs serve             # live preview
mkdocs build --strict    # writes docs/ — the GitHub Pages root

Known environment issues

npm on a network mount. If the repository lives on an SMB or NFS share, npm install fails part-way with ENOTEMPTY: directory not empty, rename ... and leaves a partial node_modules with no .bin directory. Symptoms are imports that cannot be resolved and npx silently fetching a different package version than the one pinned. Work on a local-disk clone, or install into a local-disk prefix.

Flaky port test. security::port::tests::picks_a_nonzero_loopback_port fails intermittently (roughly 1 run in 10) under parallel execution and never under cargo test -- --test-threads=1. The test asserts that a released ephemeral port is immediately re-bindable, which is precisely the race the module documents as unavoidable and handles with readiness polling.