diff --git a/.agents/INSTRUCTION_SURFACE.md b/.agents/INSTRUCTION_SURFACE.md deleted file mode 120000 index 104d20f32..000000000 --- a/.agents/INSTRUCTION_SURFACE.md +++ /dev/null @@ -1 +0,0 @@ -../../.agents/INSTRUCTION_SURFACE.md \ No newline at end of file diff --git a/.beads/.gitignore b/.beads/.gitignore deleted file mode 100644 index 22f79637c..000000000 --- a/.beads/.gitignore +++ /dev/null @@ -1,45 +0,0 @@ -# SQLite databases -*.db -*.db?* -*.db-journal -*.db-wal -*.db-shm - -# Daemon runtime files -daemon.lock -daemon.log -daemon.pid -bd.sock -sync-state.json -last-touched - -# Local version tracking (prevents upgrade notification spam after git ops) -.local_version - -# Legacy database files -db.sqlite -bd.db - -# Worktree redirect file (contains relative path to main repo's .beads/) -# Must not be committed as paths would be wrong in other clones -redirect - -# Merge artifacts (temporary files from 3-way merge) -beads.base.jsonl -beads.base.meta.json -beads.left.jsonl -beads.left.meta.json -beads.right.jsonl -beads.right.meta.json - -# Sync state (local-only, per-machine) -# These files are machine-specific and should not be shared across clones -.sync.lock -sync_base.jsonl -export-state/ - -# NOTE: Do NOT add negation patterns (e.g., !issues.jsonl) here. -# They would override fork protection in .git/info/exclude, allowing -# contributors to accidentally commit upstream issue databases. -# The JSONL files (issues.jsonl, interactions.jsonl) and config files -# are tracked by git by default since no pattern above ignores them. diff --git a/.beads/README.md b/.beads/README.md deleted file mode 100644 index 913a1ac14..000000000 --- a/.beads/README.md +++ /dev/null @@ -1,85 +0,0 @@ -# Beads - AI-Native Issue Tracking - -Welcome to Beads! This repository uses **Beads** for issue tracking - a modern, AI-native tool designed to live directly in your codebase alongside your code. - -## What is Beads? - -Beads is issue tracking that lives in your repo, making it perfect for AI coding agents and developers who want their issues close to their code. No web UI required - everything works through the CLI and integrates seamlessly with git. - -**Learn more:** [github.com/steveyegge/beads](https://github.com/steveyegge/beads) - -## Quick Start - -### Essential Commands - -```bash -# Create new issues -bd create "Add user authentication" - -# View all issues -bd list - -# View issue details -bd show - -# Update issue status -bd update --status in_progress -bd update --status done - -# Sync with git remote -bd sync -``` - -### Working with Issues - -Issues in Beads are: - -- **Git-native**: Stored in `.beads/issues.jsonl` and synced like code -- **AI-friendly**: CLI-first design works perfectly with AI coding agents -- **Branch-aware**: Issues can follow your branch workflow -- **Always in sync**: Auto-syncs with your commits - -## Why Beads? - -โœจ **AI-Native Design** - -- Built specifically for AI-assisted development workflows -- CLI-first interface works seamlessly with AI coding agents -- No context switching to web UIs - -๐Ÿš€ **Developer Focused** - -- Issues live in your repo, right next to your code -- Works offline, syncs when you push -- Fast, lightweight, and stays out of your way - -๐Ÿ”ง **Git Integration** - -- Automatic sync with git commits -- Branch-aware issue tracking -- Intelligent JSONL merge resolution - -## Get Started with Beads - -Try Beads in your own projects: - -```bash -# Install Beads -curl -sSL https://raw.githubusercontent.com/steveyegge/beads/main/scripts/install.sh | bash - -# Initialize in your repo -bd init - -# Create your first issue -bd create "Try out Beads" -``` - -## Learn More - -- **Documentation**: [github.com/steveyegge/beads/docs](https://github.com/steveyegge/beads/tree/main/docs) -- **Quick Start Guide**: Run `bd quickstart` -- **Examples**: [github.com/steveyegge/beads/examples](https://github.com/steveyegge/beads/tree/main/examples) - ---- - -*Beads: Issue tracking that moves at the speed of thought* โšก diff --git a/.beads/backup/LOCK b/.beads/backup/LOCK deleted file mode 100644 index e69de29bb..000000000 diff --git a/.beads/backup/backup_state.json b/.beads/backup/backup_state.json deleted file mode 100644 index 914776acf..000000000 --- a/.beads/backup/backup_state.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "last_dolt_commit": "ujtbvuurgkn4sao69jmeeo54uf2k8fbi", - "timestamp": "2026-07-09T18:46:20.524710347Z" -} \ No newline at end of file diff --git a/.beads/backup/h6mbga66itkhrhtji8j986g8944trmlt.darc b/.beads/backup/h6mbga66itkhrhtji8j986g8944trmlt.darc deleted file mode 100644 index b24cfc795..000000000 Binary files a/.beads/backup/h6mbga66itkhrhtji8j986g8944trmlt.darc and /dev/null differ diff --git a/.beads/backup/i5r9boc7btoavocdoffrrcnho6dfr995.darc b/.beads/backup/i5r9boc7btoavocdoffrrcnho6dfr995.darc deleted file mode 100644 index a6d8b3854..000000000 Binary files a/.beads/backup/i5r9boc7btoavocdoffrrcnho6dfr995.darc and /dev/null differ diff --git a/.beads/backup/manifest b/.beads/backup/manifest deleted file mode 100644 index c649adb0a..000000000 --- a/.beads/backup/manifest +++ /dev/null @@ -1 +0,0 @@ -5:__DOLT__:ehgngebb2remv4suv02ln5j7ns6mgv5t:u46gp6ld9ga970ftjerrlvp3lq0tlusc:00000000000000000000000000000000:h6mbga66itkhrhtji8j986g8944trmlt:9:i5r9boc7btoavocdoffrrcnho6dfr995:30:mt450ckcsvqtjh4v6ufap2cla4p8bjc9:111 \ No newline at end of file diff --git a/.beads/backup/mt450ckcsvqtjh4v6ufap2cla4p8bjc9.darc b/.beads/backup/mt450ckcsvqtjh4v6ufap2cla4p8bjc9.darc deleted file mode 100644 index ec3b20fbd..000000000 Binary files a/.beads/backup/mt450ckcsvqtjh4v6ufap2cla4p8bjc9.darc and /dev/null differ diff --git a/.beads/config.yaml b/.beads/config.yaml index ff8bc9210..3c03f440d 100644 --- a/.beads/config.yaml +++ b/.beads/config.yaml @@ -1,67 +1,23 @@ -# Beads Configuration File -# This file configures default behavior for all bd commands in this repository -# All settings can also be set via environment variables (BD_* prefix) -# or overridden with command-line flags - -# Issue prefix for this repository (used by bd init) -# If not set, bd init will auto-detect from directory name -# Example: issue-prefix: "myproject" creates issues like "myproject-1", "myproject-2", etc. -# issue-prefix: "" - -# Use no-db mode: load from JSONL, no SQLite, write back after each command -# When true, bd will use .beads/issues.jsonl as the source of truth -# instead of SQLite database -# no-db: false - -# Disable daemon for RPC communication (forces direct database access) -# no-daemon: false - -# Disable auto-flush of database to JSONL after mutations -# no-auto-flush: false - -# Disable auto-import from JSONL when it's newer than database -# no-auto-import: false - -# Enable JSON output by default -# json: false - -# Default actor for audit trails (overridden by BD_ACTOR or --actor) -# actor: "" - -# Path to database (overridden by BEADS_DB or --db) -# db: "" - -# Auto-start daemon if not running (can also use BEADS_AUTO_START_DAEMON) -# auto-start-daemon: true - -# Debounce interval for auto-flush (can also use BEADS_FLUSH_DEBOUNCE) -# flush-debounce: "5s" - -# Export events (audit trail) to .beads/events.jsonl on each flush/sync -# When enabled, new events are appended incrementally using a high-water mark. -# Use 'bd export --events' to trigger manually regardless of this setting. -# events-export: false - -# Git branch for beads commits (bd sync will commit to this branch) -# IMPORTANT: Set this for team projects so all clones use the same sync branch. -# This setting persists across clones (unlike database config which is gitignored). -# Can also use BEADS_SYNC_BRANCH env var for local override. -# If not set, bd sync will require you to run 'bd config set sync.branch '. -# sync-branch: "beads-sync" - -# Multi-repo configuration (experimental - bd-307) -# Allows hydrating from multiple repositories and routing writes to the correct JSONL -# repos: -# primary: "." # Primary repo (where this database lives) -# additional: # Additional repos to hydrate from (read-only) -# - ~/beads-planning # Personal planning repo -# - ~/work-planning # Work planning repo - -# Integration settings (access with 'bd config get/set') -# These are stored in the database, not in this file: -# - jira.url -# - jira.project -# - linear.url -# - linear.api-key -# - github.org -# - github.repo +# @flext-managed: continuous +# @flext-regenerate: make gen WHAT=apply APPLY=Y +# @flext-ssot: config/workspace.yaml (ledger_id) + flext-infra config/codegen.yaml (toolchain.beads.server) +# @flext-maintenance: do not edit generated projections; edit the SSOT and regenerate +# Generated by `flext-infra gen conform`. +# Source: issue_prefix/database = workspace manifest ledger_id (config key: +# ledger_id) falling back to the canonical project name when undeclared. +# Source: dolt server block = flext-infra config/codegen.yaml +# toolchain.beads.server (machine-wide shared Dolt server facts). +# Owned ledger config: the workspace root owns this tracker; conform +# initializes and verifies it against the pinned binary. +issue-prefix: "flext-core" + +# Dolt shared-server connection (machine-wide shared server on :3308). +# The `flext-core` database lives there; a repository-local server is never used. +dolt: + mode: server + shared-server: true + host: 127.0.0.1 + port: 3308 + user: root + database: flext-core + auto-commit: "on" diff --git a/.beads/embeddeddolt/beads/.dolt/config.json b/.beads/embeddeddolt/beads/.dolt/config.json deleted file mode 100755 index 9e26dfeeb..000000000 --- a/.beads/embeddeddolt/beads/.dolt/config.json +++ /dev/null @@ -1 +0,0 @@ -{} \ No newline at end of file diff --git a/.beads/embeddeddolt/beads/.dolt/noms/LOCK b/.beads/embeddeddolt/beads/.dolt/noms/LOCK deleted file mode 100644 index e69de29bb..000000000 diff --git a/.beads/embeddeddolt/beads/.dolt/noms/journal.idx b/.beads/embeddeddolt/beads/.dolt/noms/journal.idx deleted file mode 100644 index 600489816..000000000 Binary files a/.beads/embeddeddolt/beads/.dolt/noms/journal.idx and /dev/null differ diff --git a/.beads/embeddeddolt/beads/.dolt/noms/manifest b/.beads/embeddeddolt/beads/.dolt/noms/manifest deleted file mode 100644 index 38cf8a81d..000000000 --- a/.beads/embeddeddolt/beads/.dolt/noms/manifest +++ /dev/null @@ -1 +0,0 @@ -5:__DOLT__:e9gl1qcjhb07b0i24vieg9bi8e4u6afg:gkvh7h36s620at1vt2vpqbokett6itoj:00000000000000000000000000000000:vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv:1880 \ No newline at end of file diff --git a/.beads/embeddeddolt/beads/.dolt/noms/nbs_manifest_3630928294 b/.beads/embeddeddolt/beads/.dolt/noms/nbs_manifest_3630928294 deleted file mode 100644 index 491291da6..000000000 --- a/.beads/embeddeddolt/beads/.dolt/noms/nbs_manifest_3630928294 +++ /dev/null @@ -1 +0,0 @@ -5:__DOLT__:5jt1vggaj83mumbrciru2di5ga0puvfs:9a7pucsied47pdqi3podt63bbv03oat3:00000000000000000000000000000000:vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv:1674 \ No newline at end of file diff --git a/.beads/embeddeddolt/beads/.dolt/noms/vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv b/.beads/embeddeddolt/beads/.dolt/noms/vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv deleted file mode 100644 index 2737c430a..000000000 Binary files a/.beads/embeddeddolt/beads/.dolt/noms/vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv and /dev/null differ diff --git a/.beads/embeddeddolt/beads/.dolt/repo_state.json b/.beads/embeddeddolt/beads/.dolt/repo_state.json deleted file mode 100755 index f1c091dea..000000000 --- a/.beads/embeddeddolt/beads/.dolt/repo_state.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "head": "refs/heads/main", - "remotes": {}, - "backups": { - "backup_export": { - "name": "backup_export", - "url": "file:///home/marlonsc/flext/flext-core/.beads/backup", - "fetch_specs": [ - "refs/heads/*:refs/remotes/backup_export/*" - ], - "params": {} - } - }, - "branches": {} -} \ No newline at end of file diff --git a/.beads/embeddeddolt/beads/.dolt/temptf/dolt_embedded_metrics b/.beads/embeddeddolt/beads/.dolt/temptf/dolt_embedded_metrics deleted file mode 100644 index e69de29bb..000000000 diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl deleted file mode 100644 index e69de29bb..000000000 diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl deleted file mode 100644 index e69de29bb..000000000 diff --git a/.beads/metadata.json b/.beads/metadata.json deleted file mode 100644 index c787975e1..000000000 --- a/.beads/metadata.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "database": "beads.db", - "jsonl_export": "issues.jsonl" -} \ No newline at end of file diff --git a/.envrc b/.envrc index f4be9fcae..a74bca2de 100644 --- a/.envrc +++ b/.envrc @@ -1,56 +1,48 @@ -# @generated by: flext_infra workspace sync -# Canonical direnv activation for FLEXT Python workspaces. - +# Generated by `flext-infra codegen conform`. +# === SECTION: header (managed) === +# Source: template (base/.envrc.j2) +# Free: no +# Canonical direnv activation for one inferred repository environment. +# End SECTION: header + +# === SECTION: strict evaluation and watched inputs (managed) === +# Source: template (direnv stdlib contract) strict_env - -WORKSPACE_ROOT="${PWD}" -AI_HUB="${AI_HUB:-${HOME}/.ai-hub}" -VENV_DIR="${WORKSPACE_ROOT}/.venv" -MISE_SHIMS="${WORKSPACE_MISE_SHIMS:-${MISE_SHIMS:-${HOME}/.local/share/mise/shims}}" -PYPROJECT_FILE="${WORKSPACE_ROOT}/pyproject.toml" - -export AI_HUB -export WORKSPACE_ROOT -export WORKSPACE_MISE_SHIMS="${MISE_SHIMS}" -export MISE_SHIMS +watch_file .mise.toml +watch_file pyproject.toml +# End SECTION: strict evaluation and watched inputs + +# === SECTION: repository environment (managed) === +# Source: computed:nearest-pyproject-via-direnv-find_up + template +PROJECT_ROOT="$(find_up pyproject.toml)" +PROJECT_ROOT="${PROJECT_ROOT%/*}" +VENV_DIR="${PROJECT_ROOT}/.venv" +export PROJECT_ROOT export PYTHON_KEYRING_BACKEND="keyring.backends.null.Keyring" export PYTHONDONTWRITEBYTECODE=1 export PYTHONUNBUFFERED=1 +# End SECTION: repository environment +# === SECTION: mise activation (managed) === +# Source: template (optional installed mise runtime) if command -v mise >/dev/null 2>&1; then eval "$(mise activate bash --shims)" fi +# End SECTION: mise activation -path_prepend_once() { - case ":${PATH}:" in - *":$1:"*) ;; - *) PATH="$1${PATH:+:${PATH}}" ;; - esac -} - -if [[ -d "${WORKSPACE_ROOT}/bin" ]]; then - path_prepend_once "${WORKSPACE_ROOT}/bin" -fi +# === SECTION: configured paths (managed) === +# Source: template + config:environment_path_prepends +PATH_add "${PROJECT_ROOT}/bin" +# End SECTION: configured paths -if [[ -d "${AI_HUB}/.venv/bin" ]]; then - path_prepend_once "${AI_HUB}/.venv/bin" -fi - -path_prepend_once "${MISE_SHIMS}" - -if [[ -f "${PYPROJECT_FILE}" && -d "${VENV_DIR}" ]]; then +# === SECTION: virtual environment (managed) === +# Source: computed:nearest-pyproject-root/.venv + template +if [[ -f "${PROJECT_ROOT}/pyproject.toml" && -d "${VENV_DIR}" ]]; then export UV_PROJECT_ENVIRONMENT="${VENV_DIR}" export VIRTUAL_ENV="${VENV_DIR}" - path_prepend_once "${VENV_DIR}/bin" - PYTHON_VERSION="$(python -c 'import sys; u.Cli.print(f"{sys.version_info.major}.{sys.version_info.minor}")')" - export PYTHON_VERSION - log_status "workspace activated (python ${PYTHON_VERSION:-?}, venv ${VENV_DIR})" -elif [[ -f "${PYPROJECT_FILE}" ]]; then - log_error ".venv not found - run: uv venv && uv sync" + PATH_add "${VENV_DIR}/bin" else unset UV_PROJECT_ENVIRONMENT unset VIRTUAL_ENV - log_status "workspace activated without Python project (pyproject.toml not found)" fi - -export PATH +# End SECTION: virtual environment diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 860070a26..17f07c778 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,64 +1,24 @@ +# Generated by `flext-infra codegen conform` for flext-core โ€” DO NOT EDIT. version: 2 updates: - # Python dependencies - - package-ecosystem: "pip" - directory: "/" + - package-ecosystem: github-actions + directory: / schedule: - interval: "weekly" - day: "monday" - time: "03:00" - open-pull-requests-limit: 10 - labels: - - "dependencies" - - "python" - reviewers: - - "flext-sh/core-team" - commit-message: - prefix: "chore" - include: "scope" - groups: - dev-dependencies: - patterns: - - "pytest*" - - "mypy" - - "ruff" - - "black" - - "isort" - - "bandit" - - "safety" - docs-dependencies: - patterns: - - "mkdocs*" - - "mkdocstrings*" + interval: weekly + open-pull-requests-limit: 5 + labels: [dependencies, github-actions] - # GitHub Actions - - package-ecosystem: "github-actions" - directory: "/" + - package-ecosystem: devcontainers + directory: / schedule: - interval: "weekly" - day: "monday" - time: "03:00" - labels: - - "dependencies" - - "github-actions" - reviewers: - - "flext-sh/core-team" - commit-message: - prefix: "ci" - include: "scope" + interval: weekly + open-pull-requests-limit: 5 + labels: [dependencies, devcontainers] - # Docker - - package-ecosystem: "docker" - directory: "/" + - package-ecosystem: pip + directory: / schedule: - interval: "weekly" - day: "monday" - time: "03:00" - labels: - - "dependencies" - - "docker" - reviewers: - - "flext-sh/core-team" - commit-message: - prefix: "build" - include: "scope" + interval: weekly + open-pull-requests-limit: 5 + labels: [dependencies, python] + diff --git a/.github/workflows/ci-matrix.yml b/.github/workflows/ci-matrix.yml new file mode 100644 index 000000000..17ce9f2e6 --- /dev/null +++ b/.github/workflows/ci-matrix.yml @@ -0,0 +1,116 @@ +# Generated by `flext_infra codegen conform` for flext-core. +# === SECTION: header (managed) === +# Source: template (base/.github/workflows/ci-matrix.yml.j2) +# Free: no +# End SECTION: header +# Multi-environment CI base: proves the project bootstrap and canonical Make +# verbs work identically across distros, macOS, and Windows. The CI invokes +# the project's own Make surface; it never reimplements bootstrap. +# Runs only on main (direct push or non-draft PR targeting main). +--- +name: ci-matrix + +# === SECTION: triggers (managed) === +# Source: main promotion only (integration branch uses blocking ci.yml alone) +"on": + push: + branches: [main] + pull_request: + branches: [main] + types: [opened, synchronize, reopened, ready_for_review] + workflow_dispatch: {} +# End SECTION: triggers + +# === SECTION: permissions (managed) === +# Source: template (minimal read-only permissions) +permissions: + contents: read +# End SECTION: permissions + +# === SECTION: concurrency (managed) === +# Source: template (one run per workflow+ref) +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true +# End SECTION: concurrency + +jobs: + # === SECTION: distro-matrix (managed) === + # Source: template + config:github_actions.checkout (distro list is template literal by design) + distro-matrix: + # Clean-machine proof per distro: build the distro image (which runs the + # full project bootstrap at image-build time), then smoke the verb surface + # inside the built container. + if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + distro: [ubuntu, debian, fedora, alpine, arch] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + submodules: false + - name: Build ${{ matrix.distro }} image + run: >- + docker build + -f tests/fixtures/ci/docker/${{ matrix.distro }}.Dockerfile + -t ci-matrix-${{ matrix.distro }} + . + - name: Bootstrap + verb smoke (${{ matrix.distro }}) + run: | + docker run --rm ci-matrix-${{ matrix.distro }} make help + docker run --rm ci-matrix-${{ matrix.distro }} make check + # End SECTION: distro-matrix + + # === SECTION: macos (managed) === + # Source: template + config:github_actions.checkout/setup-python/mise/setup-uv + macos: + if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }} + runs-on: macos-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + submodules: false + - name: Setup Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version-file: .python-version + - name: Install mise toolchain + uses: jdx/mise-action@dba19683ed58901619b14f395a24841710cb4925 # v4.1.0 + - name: Install uv + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + with: + enable-cache: true + - name: Bootstrap + run: make setup + - name: Verb surface + run: make help + # End SECTION: macos + + # === SECTION: windows (managed) === + # Source: template + config:github_actions.checkout/setup-python/mise/setup-uv + windows: + if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }} + runs-on: windows-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + submodules: false + - name: Setup Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version-file: .python-version + - name: Install mise toolchain + uses: jdx/mise-action@dba19683ed58901619b14f395a24841710cb4925 # v4.1.0 + - name: Install uv + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + with: + enable-cache: true + - name: Bootstrap + shell: bash + run: make setup + - name: Verb surface + shell: bash + run: make help + # End SECTION: windows diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3229501c4..f65161d0a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,48 +1,73 @@ -# Generated by flext_infra.github.workflows - DO NOT EDIT +# Generated by flext_infra codegen for flext-core โ€” DO NOT EDIT +# === SECTION: header (managed) === +# Source: template (base/.github/workflows/ci.yml.j2) +# Free: no +# End SECTION: header + name: CI -on: - pull_request: +# === SECTION: triggers (managed) === +# Source: operator CI policy โ€” integration push = blocking CI only; +# main push/PR (non-draft) = blocking CI; draft and other branches = none. +"on": push: + branches: + - dev + - develop + - 0.12.0-dev + - main + pull_request: branches: - main - workflow_dispatch: + types: [opened, synchronize, reopened, ready_for_review] +# End SECTION: triggers +# === SECTION: permissions (managed) === +# Source: template (minimal read-only permissions) permissions: contents: read +# End SECTION: permissions jobs: + # === SECTION: ci job (managed) === + # Source: template + config:github_actions.* ci: name: ci + if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }} runs-on: ubuntu-latest timeout-minutes: 60 + env: + CI: Y steps: - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - submodules: recursive + submodules: false fetch-depth: 0 - - name: Setup Python - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 - with: - python-version: "3.13" + - name: Install mise toolchain + uses: jdx/mise-action@dba19683ed58901619b14f395a24841710cb4925 # v4.1.0 - name: Install uv - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 - with: - enable-cache: true + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + - - name: Setup (blocking) + - name: setup (blocking) run: make setup - - name: Check (blocking) + - name: gen (blocking) + run: make gen APPLY=Y + + - name: fmt (blocking) + run: make fmt APPLY=Y + + - name: fix (blocking) + run: make fix APPLY=Y + + - name: check (blocking) run: make check - - name: Test (advisory) - continue-on-error: true + - name: test (blocking) run: make test - - name: Val (advisory) - continue-on-error: true - run: make val + # End SECTION: ci job diff --git a/.gitignore b/.gitignore index c309e9f64..fa926f1d5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,95 +1,3 @@ -# BLOCK EVERYTHING BY DEFAULT -/* -/*/ -!/LICENSE - -# WHITELIST: Workspace Directories -!docker/ -!docs/ -!examples/ -!.github/ -!src/ -!tests/ -!typings/ -!libs/ -!.vscode/ -!.code-review-graph/ -!.claude/ -!.claude-plugin/ -!/config/ -!.claude/CLAUDE.md -!.claude/settings.json -!.claude/settings.local.json -!.agents/ -!.agents/** -.agents/skills/*/report.json - -# Dispatcher-based command surface (Make SSOT) -!scripts/ -!scripts/cmd/ -!scripts/cmd/** -scripts/*.py -scripts/*.sh -scripts/*.md -!scripts/dispatch.py -!scripts/__init__.py - -# Python configuration -!pyproject.toml -!.taplo.toml -!pyrefly.toml -!poetry.lock -!uv.lock -!ruff-shared.toml - -# mise toolchain pinning -!.mise.toml -!.default-python-packages -!.python-version - -# Docker configuration -!.dockerignore - -# Build and automation -!Makefile -!Makefile.flext-docker -!*.mk - -# Documentation -!README.md -!AGENTS.md -!AGENTS.md -!codex.md -!.markdownlint.json -!.markdownlintignore -!.shellcheckrc - -# Git configuration -!.gitignore -!.gitattributes - -# direnv environment (no secrets โ€” venv activation only) -!.envrc -!.envrc.local - -# Environment templates (NOT .env - keep secrets out!) -!.env.test -!.env.example -!.env.template - -# WHITELIST: Contents Inside Allowed Directories -!docker/** -!docs/** -!examples/** -!.github/** -!src/** -!tests/** -!typings/** -!libs/** -!.vscode/** -!.claude/** -!.claude-plugin/** - # BLOCK: AI/Agent Local Data (NEVER track these) .claude/* !.agents/skills/ @@ -280,21 +188,30 @@ secrets.yml .devcontainer/.env .github/*.rej -# --- workspace-migrate: required ignores (auto-managed) --- +# --- codegen conform: tracked owner inputs --- !base.mk # FINAL BEADS TRACKING POLICY +!.beads/ +.beads/* +.beads/.* +!.beads/config.yaml +.beads/proxieddb/ +.dolt/ *.db +.beads-credential-key .worktrees/ legado/ # codegen module-standardizer reject artifacts (ADR-005 ยง9) *.tpl.rej -# --- workspace-sync: required ignores (auto-managed) --- +# --- codegen conform: generated runtime ignores --- .direnv/ .reports/ .venv/ +.bin/ +.tools/ __pycache__/ base.mk !.pre-commit-config.yaml @@ -302,6 +219,7 @@ base.mk # Derived build and tool artifacts __pyrefly_virtual__/ .benchmarks/ +.bkp/ .cache/ .codegraph/ .dolt_dropped_databases/ @@ -329,7 +247,6 @@ site/ target/ .pyrefly_cache/ .tox/ -.flext-deps/ .flext-sync.lock .sync.lock .planning/ @@ -337,3 +254,36 @@ skill-create-output/ .debug-journal.md .mcp.json CLAUDE.local.md + +# Tracked managed artifacts +!.github/ci-template/ +!.github/ci-template/ci.yml +!.github/copilot-instructions.md +!.github/dependabot.yml +!.github/prompts/ +!.github/prompts/flext-aggressive-scale-refactor.prompt.md +!.github/prompts/flext-strict-jsonvalue-session-continuation.prompt.md +!.github/scripts/ +!.github/scripts/check-beads-policy.sh +!.github/scripts/check-cqrs-compliance.sh +!.github/scripts/flext-law-loop.sh +!.github/scripts/install-git-hooks.sh +!tests/fixtures/ +!tests/fixtures/ci/ +!tests/fixtures/ci/docker/ +!tests/fixtures/ci/docker/alpine.Dockerfile +!tests/fixtures/ci/docker/arch.Dockerfile +!tests/fixtures/ci/docker/debian.Dockerfile +!tests/fixtures/ci/docker/fedora.Dockerfile +!tests/fixtures/ci/docker/ubuntu.Dockerfile +!.github/workflows/ +!.github/workflows/ci-matrix.yml +!.github/workflows/ci.yml +!.github/workflows/docs.yml +!.github/workflows/release.yml +!sgconfig.yml +!.vscode/settings.json +!LICENSE +!config/ +!config/workspace.yaml +!custom.mk diff --git a/.mise.toml b/.mise.toml index d888519a8..11f252b03 100644 --- a/.mise.toml +++ b/.mise.toml @@ -1,14 +1,28 @@ -# Generated by `flext-infra codegen conform`. -# NOTE: mise carries ONLY language runtimes (python, uv) as exact fleet-wide -# pins from the config SSOT (Infra.codegen.toolchain). Linters/type-checkers -# come exclusively from the locked dependency groups (pyproject dev group), -# installed into the workspace .venv by uv; gates invoke them via python -m, -# so mise never pins linter tools. +# Generated by `flext_infra codegen conform`. +# === SECTION: header (managed) === +# Source: template (base/.mise.toml.j2) +# Free: no +# End SECTION: header +# NOTE: mise carries the Python runtime and native tools from the config SSOT. +# Python dependencies and uv are resolved by the caller-provided environment. +# === SECTION: tools (managed) === +# Source: config:python_version, config:codegen.scaffold.project.dev native toolchain floors +# (kubectl_version, helm_version, kind_version, taplo_version, ast_grep_version, +# gitleaks_version, tokei_version, beads.selector, beads.version) [tools] -python = "3.13.11" -uv = "0.11.32" -# Deploy toolchain (single source: Infra.codegen.toolchain in codegen.yaml). +python = "3.13" +# Native toolchain (single source: Infra.codegen.toolchain in codegen.yaml). kubectl = "1.32.0" helm = "3.19.4" kind = "0.31.0" +taplo = "0.10.0" +ast-grep = "0.45.0" +gitleaks = "8.30.1" +tokei = "14.0.0" +# mise resolves go: backend selectors through a dependency graph, so the Go +# runtime must be a declared tool; without it mise looks for go on PATH and +# every runner without an ambient toolchain fails to install beads. +go = "1.26.5" +"go:github.com/steveyegge/beads/cmd/bd" = "423afdcb2813e36b2bc4c96b07e0fc3516a34495" +# End SECTION: tools diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4ae1c83f4..82e4bb09b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,154 +1,12 @@ -# STRICT Enterprise Pre-commit Configuration -# Zero tolerance for quality violations -# All tools managed by Poetry - -minimum_pre_commit_version: "3.5.0" -fail_fast: false -default_stages: [pre-commit, pre-push] -default_language_version: - python: python3.13 - -repos: [] # DISABLED BY SCRIPT - All hooks disabled -# -# # Poetry-managed tools via local repo -# - repo: local -# hooks: -# - id: ruff-format -# name: "โšก Ruff Format (PEP8 Strict - 79 chars)" -# entry: poetry run ruff format -# language: system -# types: [python] -# require_serial: true -# args: [--check, --diff, src/, tests/, scripts/, examples/] -# -# - id: ruff-lint -# name: "๐Ÿ”ฅ Ruff Lint (PEP8 + ALL categories)" -# entry: poetry run ruff check -# language: system -# types: [python] -# require_serial: true -# args: [--fix, --exit-non-zero-on-fix, src/, tests/, scripts/, examples/] -# -# # isort import sorting -# # - id: isort -# # name: "๐Ÿ“ฆ Import Sort" -# # entry: poetry run isort -# # language: system -# # types: [python] -# # require_serial: true -# # args: [--check-only, --diff] -# -# # Bandit security -# - id: bandit -# name: "๐Ÿ”’ Bandit Security" -# entry: poetry run bandit -# language: system -# types: [python] -# require_serial: true -# pass_filenames: false -# args: [-r, src/, --exclude, src/flext_tests, --severity-level=medium] -# -# # Vulture dead code - TEMPORARILY DISABLED while configuring whitelist -# # - id: vulture -# # name: "๐Ÿฆ… Dead Code Detection" -# # entry: poetry run vulture -# # language: system -# # types: [python] -# # pass_filenames: false -# # args: [src/, .vulture_whitelist.py, --min-confidence=80] -# -# # Radon complexity -# - id: radon-cc -# name: "๐Ÿ“Š Cyclomatic Complexity" -# entry: poetry run radon cc -# language: system -# types: [python] -# pass_filenames: false -# args: [src/, -a, -nb, --total-average] -# -# - id: radon-mi -# name: "๐Ÿ“Š Maintainability Index" -# entry: poetry run radon mi -# language: system -# types: [python] -# pass_filenames: false -# args: [src/, -nb] -# -# # DEPRECATED - Use flext-quality instead -# # - id: check-pydantic-v2 -# # name: "๐Ÿ Pydantic v2 Compliance Check" -# # entry: python scripts/check_pydantic_v2_precommit.py -# # language: system -# # types: [python] -# # pass_filenames: true -# # stages: [commit] -# -# # YAML/TOML/JSON validation -# - repo: https://github.com/pre-commit/pre-commit-hooks -# rev: v5.0.0 -# hooks: -# - id: check-yaml -# name: "๐Ÿ“‹ YAML Syntax Check" -# - id: check-toml -# name: "๐Ÿ“‹ TOML Syntax Check" -# - id: check-json -# name: "๐Ÿ“‹ JSON Syntax Check" -# - id: end-of-file-fixer -# name: "๐Ÿ“„ EOF Fixer" -# - id: trailing-whitespace -# name: "โœ‚๏ธ Trailing Whitespace" -# - id: check-added-large-files -# name: "๐Ÿ“ฆ Large File Check" -# args: [--maxkb=1000] -# - id: check-case-conflict -# name: "๐Ÿ”ค Case Conflict Check" -# - id: check-merge-conflict -# name: "โš”๏ธ Merge Conflict Check" -# - id: mixed-line-ending -# name: "๐Ÿ“ Line Ending Check" -# args: [--fix=lf] -# - id: debug-statements -# name: "๐Ÿ› Debug Statement Check" -# -# # Python-specific checks -# - repo: https://github.com/pre-commit/pygrep-hooks -# rev: v1.10.0 -# hooks: -# - id: python-check-blanket-noqa -# name: "๐Ÿšซ Blanket noqa Check" -# - id: python-check-blanket-type-ignore -# name: "๐Ÿšซ Blanket type: ignore Check" -# - id: python-no-eval -# name: "๐Ÿšซ No eval() Check" -# - id: python-no-log-warn -# name: "โš ๏ธ No log.warn Check" -# - id: python-use-type-annotations -# name: "๐Ÿ“ Type Annotations Check" -# -# # Commit message validation via Poetry -# - repo: local -# hooks: -# - id: commitizen -# name: "๐Ÿ’ฌ Commit Message Check" -# entry: poetry run cz check -# language: system -# stages: [commit-msg] -# pass_filenames: false -# args: [--commit-msg-file] -# -# CI configuration -ci: - autofix_prs: false # No automatic fixes - must be intentional - autoupdate_schedule: weekly - skip: [ - # Local Poetry hooks do not work in CI - CI runs via Makefile targets - ruff-format, - ruff-lint, - isort, - bandit, # Run via make security - vulture, - radon-cc, # Run via make complexity - radon-mi, # Run via make complexity - commitizen, - ] - submodules: false +# Generated by flext_infra codegen for flext-core โ€” DO NOT EDIT +repos: + - repo: local + hooks: + - id: flext-canonical-workflow + name: FLEXT canonical workflow + language: system + entry: >- + bash -eu -o pipefail -c + 'make gen APPLY=Y && make fmt APPLY=Y && make fix APPLY=Y && make check && make test' + pass_filenames: false + always_run: true diff --git a/.python-version b/.python-version index 2c45fe3a7..24ee5b1be 100644 --- a/.python-version +++ b/.python-version @@ -1 +1 @@ -3.13.11 +3.13 diff --git a/.vscode/settings.json b/.vscode/settings.json index 617a3625f..734cc1838 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -13,8 +13,7 @@ "shellcheck.useWorkspaceRootAsCwd": true, "python-envs.workspaceSearchPaths": [ "./.venv", - "./*/.venv", - "./apps/*/.venv" + "./*/.venv" ], "files.exclude": { "**/__pycache__": true, @@ -51,7 +50,11 @@ "**/dist": true, "**/node_modules": true, "**/site": true, - "**/target": true + "**/target": true, + "**/.archive": true, + "**/.bin": true, + "**/.tools": true, + "**/.bkp": true }, "files.watcherExclude": { "**/__pycache__/**": true, @@ -89,7 +92,11 @@ "**/build/**": true, "**/dist/**": true, "**/node_modules/**": true, - "**/target/**": true + "**/target/**": true, + "**/.archive/**": true, + "**/.bin/**": true, + "**/.tools/**": true, + "**/.bkp/**": true }, "search.exclude": { "**/__pycache__": true, @@ -126,7 +133,11 @@ "**/dist": true, "**/node_modules": true, "**/site": true, - "**/target": true + "**/target": true, + "**/.archive": true, + "**/.bin": true, + "**/.tools": true, + "**/.bkp": true }, "python.analysis.diagnosticSeverityOverrides": { "reportUntypedBaseClass": "none" diff --git a/AGENTS.md b/AGENTS.md index 48a81d83e..54fcfbfef 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,242 +1,51 @@ # AGENTS.md โ€” flext-core - - - -## Universal Agent Engineering Core - -`~/.agents` is the sole universal authority. AI Hub distributes and configures -it but never competes with it. Project law may be stricter; the newest explicit -operator instruction prevails and lower authority must be reconciled. - -### P0 โ€” Tests validate config/settings changes by construction - -Tests, golden files, and executable documentation (including markdown examples and -docstring snippets) must remain valid when config or settings change. They are -never allowed to hardcode, freeze, or implicitly assume the values that exist today. - -- The canonical owner of every configurable fact is `config/*.yaml`, `settings`, - or the generator that derives from them. Tests only validate that owner. -- Expected values owned by config/settings must be read from the same typed SSOT - production reads, or proven through a generator/consumer round-trip. -- A test that breaks on a legitimate config/settings change is a test defect. - Fix the test; never freeze the configuration to keep the test green. -- This rule applies to all test tiers, markdown examples, and docstring snippets - validated by the pytest plugin. -- Literal expectations in tests are reserved for immutable external protocol - contracts, not for values the project owns through config/settings. - -1. **Truth with evidence.** Claims require the exact command, working directory, - exit status, decisive output, and bounded scope. -2. **Research before mutation.** Read current authority, intent, owner Bead, - implementation owner, consumers, generated projections, concurrent WIP, and - validation route. Never invent behavior or results. -3. **One active intent.** Preserve the goal, target, Bead, exclusions, phase, - required gates, and stop condition through delegation and continuation. -4. **Root cause and one owner.** Change the canonical owner and complete the - cutover. No bypass, fallback, shim, suppression, hardcode, fake, duplicate - route, silent default, or old-and-new coexistence. -5. **Fix forward.** Preserve shared work; never destructively discard unknown - changes. Re-read mutable files and classify relevant paths and hunks. -6. **Typed and generated boundaries.** Parse untrusted input once into canonical - types. Change sources, not projections; regenerate and prove idempotence. -7. **Continuous green.** No completion while the project or environment is - broken, partially migrated, dirty from task WIP, ahead of remote, missing - real-use QA, or carrying stale generated output or docs. Run native global - and changed-scope gates; Python requires Ruff, Pyrefly, Pyright, Mypy, and - Pytest coverage plus applicable build and integrated validation. Lint and - type gates cover `examples/`, `scripts/`, and `tests/` with the same rigor - as production source: blanket `per-file-ignores`/exclude patterns that - hide violations in those trees are prohibited (operator law 2026-07-20). - The only permitted exceptions are test-idiom rules explicitly justified - per rule (e.g. `S101` assert usage, `PT` pytest conventions). Every - violation in those trees is fixed at its root, never masked. -8. **Beads is execution truth.** Beads owns work, plans, memory, dependencies, - status, evidence, and closure. GitHub is its continuous external coordination, - PR, review, and CI mirror after the orchestrator organizes Beads completely. -9. **Separated roles.** The orchestrator coordinates, owns semantic Beads state, - validates, approves or rejects merges, rolls out, and closes; it does not - implement. Workers directly implement one Bead in one branch and worktree but - never merge or close. The standing documenter continuously audits, updates, - validates, and removes stale canonical skills, ADRs, docs, Python docstrings, - examples, and executable snippets under the same validated PR flow; the - governance/CI helper also remains active. -10. **No stall by reporting.** Five-minute status reports include the agent table - and epic evolution and never pause execution. Compaction, continuation, and - status transfer context only. -11. **Historical material is evidence only.** Archives, generated or tool homes, - backups, sessions, caches, and legacy trees are never live authority. -12. **Stop only for a real blocker.** Ask one precise question only when authority - conflicts or an action would be destructive; otherwise continue to the - observable stop condition. -13. **Short validated slices.** Deliver in small, independently validated - units that merge to the integration branch quickly โ€” one Bead, one - reviewable PR, hours not days. Mega-lanes and long-lived WIP are defects; - the orchestrator splits any unit that cannot merge green within a session. -14. **Living documentation.** Project knowledge is durable, never rebuilt - per session. On entering a project, read its docs first and validate key - claims quickly against live reality. Every change that produces new - understanding or behavior updates the affected docs in the SAME change; - stale docs are defects filed as beads, never worked around. -15. **Runtime reality precedes implementation and tests.** Establish the correct - behavior from the official external contract and the real consumer first. For - generated or deployed artifacts, validate the staged artifact with that real - consumer before deployment, restart, tests, or static gates. Then align the - canonical implementation and models; only afterward may tests encode the - observed behavior. Tests and static analysis are subordinate confirmation, - never discovery authority, design input, a substitute for consumer validation, - or permission to publish a runtime-broken artifact. A test that contradicts - observed canonical behavior is corrected, never accommodated by production. - Performance optimization is - evidence-first: profile with cProfile to find the hot path before changing - anything, then optimize with the project's typed OO/MRO/lazy-import patterns; - accelerate test selection with impact analysis (e.g. pytest-testmon) and - parallelism (pytest-xdist) rather than deleting or weakening coverage. - See P0 above: tests of `config`/`settings` validate contracts and behavior - for arbitrary valid values and read expected config-owned values from the - same typed SSOT the consumer receives; they never freeze today's configured - scalar, identifier, path, endpoint, model, ranking, or default. Goldens may lock - structure, never mutable config/settings values. -16. **Parametrized config, generators, and managed binaries.** config, settings, - and templates are the sole source of configuration and business rules; the - correct generator produces every derived surface (never hand-edit a - projection). ai-hub owns the installation of binaries and the provisioning of - no product-, agent-, or daemon-specific hardcoded code anywhere โ€” every such - value is parametrized through config/settings/templates. -17. **Canonical command surface only.** Every build, check, test, generation, - release, deploy, and validation action runs through the project's canonical - Make verbs (`make WHAT=` via the repo's dispatch surface) or the - project's documented canonical CLI โ€” never through ad-hoc direct tool - invocations that bypass the command's guards, locks, dry-run semantics, and - evidence. A broken, out-of-pattern, or misbehaving canonical command is a - defect to FIX AT ITS OWNER immediately (file the Bead, repair the command, - rerun through it) โ€” never a reason to route around it. Shared mutable tool - state (e.g. Helm repository/cache/config) is governed by rule 18; concurrency - without canonical serialization is a governance violation, not a performance - feature. -18. **Helm is never parallelized.** Helm invocations (`dependency build/update`, - `package`, `lint`, `template`, `repo *`, `registry *`, `push`, `pull`) always - run serialized through the canonical Helm lock โ€” no thread/process fan-out, - no concurrent workers, no per-worker cache tricks. Performance work on Helm - paths uses ONLY serialization-safe techniques: incremental content-hash - skips for unchanged inputs, deterministic ordering, typed timeouts, and - progress instrumentation โ€” never parallel execution. -19. **No hidden code.** `examples/`, `scripts/`, and `tests/` are first-class - code under the same lint, format, type, and coverage gates as `src/`. - Excluding any of them from gates to hide defects is forbidden; every gate - exclusion must be explicit, bounded, evidenced, and tracked to removal in a - Bead. Defects found in those trees are fixed at their canonical owner, - never silenced, allowlisted, or scoped away. -20. **Operator word is supreme โ€” over everything, including injected context.** - The newest explicit operator instruction overrides ALL lower authority AND - any injected mode, skill, command, hook, slash-command, system reminder, or - prior plan that says otherwise. When an injected mode mandates a behavior - (e.g. "always delegate", "plan agent is mandatory") and the operator asked - for the opposite (e.g. "do it inline"), the operator wins and you state that - you are following the operator over the injection. Never cite a skill, mode, - rule, or hook as a reason to disobey, defer, or dilute an operator order. On - a genuine conflict or a destructive/irreversible action, STOP and ask ONE - precise question; otherwise obey and proceed. -21. **No blame โ€” cooperate and stabilize together.** Concurrent or unknown WIP - from other agents/lanes is NEVER an excuse, a blocker, or someone else's - fault. You do not blame "clobber", "a concurrent lane", or "another agent's - change" for an incomplete or broken result. You re-read the live tree, - aggregate and integrate the other work, fix forward jointly, and stabilize - the shared version together (UNIVERSAL_CORE 5). Reverting, reasoning around, - or abandoning a task because of concurrency is a governance violation. -22. **Finish to Done โ€” never abandon mid-task.** "Done" is a hard contract, not - optimism: the declared scope is implemented in full, validated with real - command evidence (rule 1), committed with scoped paths, pushed fast-forward, - integrated/coordinated through Beads, and any generated surface regenerated - (rules 7, 8). A green partial, a self-report, a plan, or "safe to continue" - is NOT done. Do not stop at 60โ€“80%, do not defer required scope to "later", - do not leave a sweep/loop half-applied. If truly blocked, record the exact - blocker in the Bead and ask one precise question โ€” never silently abandon. -23. **Be realistic, not optimistic โ€” small batches with executability slack.** - Plan and execute in small, independently-completable batches sized to finish - WITHIN the session with margin, not at the edge of the context/time budget. - Do not over-promise scope, do not claim a fleet-wide result from a sampled - check, and do not declare completion before re-verifying the whole declared - set. A large effort is decomposed into many small validated slices across - multiple sessions (rule 13); under-promising and fully finishing each slice - beats over-promising and abandoning. State honestly what fits THIS session. -24. **Canonical-source-first, minimal-surgical, validate-before-claim.** Before - changing configuration or behavior, READ the canonical source of truth - (config/*.yaml, models catalog, generator, schema) โ€” never guess by grep or - pattern-match. Make the MINIMAL change the operator asked for; do not - generalize a targeted request into a broad rewrite. Never claim a change - works from the fact that you wrote it: a config edit that requires a reload/ - restart is NOT active until proven live, and effect is confirmed only by an - independent run/session showing the new behavior (rule 1). - - - - -> **General FLEXT law & workspace conventions live in the root [`../AGENTS.md`](../AGENTS.md) โ€” read it first.** It is the SSOT for facade layering, config/settings access, the `make`-only workflow, the testing law, and multi-agent git discipline. This file adds ONLY `flext-core`-specific knowledge and never repeats the root. +> **Parent workspace law** lives in [`../AGENTS.md`](../AGENTS.md) โ€” read it first. +> Universal engineering core: `~/.agents/UNIVERSAL_CORE.md`. Composition: global skills + parent/root `AGENTS.md` + this scope delta. Do not re-embed universal law. > -> **Standalone / independent mode:** if this package is checked out on its own (imported as a dependency, vendored, or cloned solo) there is no parent workspace, so `../AGENTS.md` does not resolve. Then read the root law from the raw file on the SAME branch/release the project is on: (pin the branch/tag to your working line, never `main`). +> **Standalone / independent mode:** when `../AGENTS.md` does not resolve, pin the parent raw `AGENTS.md` URL to the same branch/release as this package (never `main`). -**Package:** `flext_core` ยท ~30.9k src LOC ยท deps: **none** (workspace root of the dependency graph) + +**Package:** `flext_core` ยท ~30.9k src LOC ยท deps: **none** (foundation of the dependency graph) ## Overview -Enterprise Foundation Framework (Python 3.13 + Clean Architecture). Defines the facade alphabet every other `flext-*` package composes via MRO. Because it depends on nothing, it can never import another `flext-*` package. +Enterprise Foundation Framework (Python 3.13 + Clean Architecture). Defines the facade alphabet every other `flext-*` package composes via MRO. Depends on nothing; must never import another `flext-*` package. No public `api.py` โ€” this package *is* the foundation. ## Structure -``` +```text src/flext_core/ -โ”œโ”€โ”€ constants.py models.py protocols.py typings.py utilities.py # AUTO-GENERATED facet roots (thin MRO facades) -โ”œโ”€โ”€ result.py exceptions.py mixins.py handlers.py decorators.py # operational: r / e / x / h / d -โ”œโ”€โ”€ service.py container.py context.py dispatcher.py registry.py # DI + CQRS + runtime: s -โ”œโ”€โ”€ runtime.py loggings.py lazy.py # runtime, logging factory, PEP-562 lazy exports -โ”œโ”€โ”€ _config.py _settings.py # LAYER-0 SSOT singletons (config / settings) -โ”œโ”€โ”€ _constants/ _models/ _protocols/ _typings/ _utilities/ # domain impl behind each facet -โ”‚ โ””โ”€โ”€ โ€ฆ_parts/ # fragmentation pattern (composition units, not APIs) -โ”œโ”€โ”€ _result_parts/ # construction / composition / behavior / transforms / unwrap of FlextResult -โ”œโ”€โ”€ _handlers_parts/ _exceptions/ _decorators/ _lazy_parts/ _runtime/ _beartype/ -โ””โ”€โ”€ __init__.py # AUTO-GENERATED lazy export map +โ”œโ”€โ”€ constants.py models.py protocols.py typings.py utilities.py # facet roots (c/t/p/m/u) +โ”œโ”€โ”€ result.py exceptions.py mixins.py handlers.py decorators.py # r / e / x / h / d +โ”œโ”€โ”€ service.py container.py context.py dispatcher.py registry.py # DI + CQRS (s) +โ”œโ”€โ”€ runtime.py loggings.py lazy.py _config.py _settings.py +โ”œโ”€โ”€ _constants/ _models/ _protocols/ _typings/ _utilities/ โ€ฆ_parts/ +โ””โ”€โ”€ __init__.py # AUTO-GENERATED lazy export map ``` ## Code Map -Central symbols (LSP reference counts within the package, approximate): - -| Symbol | Kind | Location | Refs | Role | -|--------|------|----------|------|------| -| `FlextTypes` (`t`) | class | `typings.py:23` | ~54 | composite type aliases | -| `FlextConstants` (`c`) | class | `constants.py:32` | ~34 | constants facade | -| `FlextProtocols` (`p`) | class | `protocols.py:23` | ~31 | structural protocols | -| `FlextModels` (`m`) | class | `models.py:38` | ~22 | Pydantic-2 models | -| `FlextResult` (`r`) | class | `result.py:24` | ~17 | railway result channel (ADR-001) | -| `FlextContainer` | class | `container.py:41` | ~14 | DI container (bind/scope/wire/config sync) | -| `FlextExceptions` (`e`) | class | `exceptions.py:23` | ~14 | exception factories | -| `FlextService` (`s`) | class | `service.py:31` | ~6 | singleton service base (`fetch_global()`) | - -Facades are thin MRO aggregators โ€” each root imports its domain components from the matching `_/` dir. There is **no `api.py`**: this package *is* the foundation. +| Symbol | Kind | Location | Role | +| --- | --- | --- | --- | +| `FlextTypes` (`t`) | class | `typings.py` | composite type aliases | +| `FlextConstants` (`c`) | class | `constants.py` | constants facade | +| `FlextProtocols` (`p`) | class | `protocols.py` | structural protocols | +| `FlextModels` (`m`) | class | `models.py` | Pydantic-2 models | +| `FlextResult` (`r`) | class | `result.py` | railway result (ADR-001) | +| `FlextContainer` | class | `container.py` | DI container | +| `FlextService` (`s`) | class | `service.py` | singleton service base | ## Conventions (specific to this package) -- `_config.py` / `_settings.py` are **layer-0 pure**: import only stdlib + pydantic/pydantic-settings, never a project facade. `_config.py` lazily loads `config/config.yaml` into the `config` singleton; `_settings.py` owns the `settings` singleton (incl. universal XDG dirs accessed at the root singleton, e.g. `settings.work_dir`). -- `lazy.py` (`FlextLazy`) provides `build_lazy_import_map` / `install_lazy_exports` used by every package's generated `__init__`. - -## Anti-Patterns / Gotchas - -- **Never hand-edit** `__init__.py` or the generated facet roots (`# AUTO-GENERATED`). -- **Do not collapse `_parts` composition** without updating the facade imports + export machinery โ€” it breaks the lazy map. -- Preserve lazy imports + `TYPE_CHECKING`-only reverse references: operational modules import public aliases from `flext_core`, so changing export timing creates import cycles. +- `_config.py` / `_settings.py` are layer-0 pure (stdlib + pydantic only); they own `config` / `settings` singletons. +- `lazy.py` (`FlextLazy`) builds the PEP-562 export map used by every generated `__init__`. +- Never hand-edit `__init__.py` or `# AUTO-GENERATED` facet roots; do not collapse `_parts` without updating facade imports (breaks lazy map / cycles). ## Commands ```bash -make check PROJECT=flext-core # ruff/pyrefly/mypy/pyright -make test PROJECT=flext-core # tests/{unit,integration,benchmark,templates} +make check PROJECT=flext-core +make test PROJECT=flext-core ``` - - -## Workspace providers - -These routes are generated from provider-owned manifests. - -- flext: read `.agents/skills/flext-context-routing/SKILL.md` first. - + diff --git a/Makefile b/Makefile index b702dcaec..d6f3ac41c 100644 --- a/Makefile +++ b/Makefile @@ -1,30 +1,155 @@ +# @flext-managed: continuous +# @flext-regenerate: make gen WHAT=apply APPLY=Y +# @flext-ssot: flext-infra/config/codegen.yaml + flext-infra/src/flext_infra/templates/project/base/Makefile.j2 +# @flext-maintenance: do not edit generated projections; edit the SSOT and regenerate # flext-core โ€” generated project interface. # Managed by flext-infra codegen conform for new and existing repositories. +# === SECTION: header (managed) === +# Source: template (base/Makefile.j2) +# Free: no +# End SECTION: header SHELL := /bin/sh .DEFAULT_GOAL := help +# === SECTION: project identity (managed) === +# Source: config:dist / config:make_profile / config:workspace_root_rel / config:uv_link_mode PROJECT_NAME := flext-core MAKE_PROFILE := workspace-member -WORKSPACE_ROOT_REL := .. +WORKSPACE_ROOT_REL := . +# === SECTION: workspace members (managed) === +# Source: config:workspace_members (list), config:workspace_repositories (list) +# Computed: MANAGED_GITLINKS mirrors WORKSPACE_MEMBERS for workspace-root gitlink +# governance; standalone projects discover managed submodules at runtime from +# .gitmodules (flext-managed=true). WORKSPACE_MEMBERS := flext-api flext-auth flext-cli flext-core flext-db-oracle flext-dbt-ldap flext-dbt-ldif flext-dbt-oracle flext-dbt-oracle-wms flext-grpc flext-infra flext-ldap flext-ldif flext-meltano flext-observability flext-oracle-oic flext-oracle-wms flext-plugin flext-quality flext-tap-ldap flext-tap-ldif flext-tap-oracle flext-tap-oracle-oic flext-tap-oracle-wms flext-target-ldap flext-target-ldif flext-target-oracle flext-target-oracle-oic flext-target-oracle-wms flext-tests flext-web +MANAGED_GITLINKS := WORKSPACE_EDITABLES := $(PROJECT_NAME):. UV_LINK_MODE := copy +# End SECTION: project identity +# === SECTION: user overrides (managed) === +# Source: template (canonical public knobs documented by base.mk) +# Free: no โ€” values are caller-supplied each invocation, not preserved in the file. APPLY ?= N +# The seeded absent value means "not applying", so every guard compares against +# APPLYING and a plain read-only run never trips the write-enable check. +APPLYING := $(if $(filter-out N,$(strip $(APPLY))),$(strip $(APPLY))) ARGS ?= +CHECK_GATES ?= +DEPENDENCY ?= +FAIL_FAST ?= 0 +FILE ?= +MATCH ?= +PROJECT ?= PROJECTS ?= +BASE ?= +BRANCH ?= +PYTEST_ARGS ?= +PYTEST_DIAG_ARGS ?= -rA --durations=0 --tb=long --showlocals +PYTEST_REPORT_ARGS ?= -ra --durations=25 --durations-min=0.001 --tb=short +PYTEST_PROCESS_TIMEOUT_SECONDS ?= 60 +# mro-99ae: the pytest process inherits a hard wall-clock boundary, mirroring +# MYPY_BOUNDED, so a hung run is terminated even if the typed runner stalls. +PYTEST_BOUNDED = timeout --signal=TERM --kill-after=5s "$(PYTEST_PROCESS_TIMEOUT_SECONDS)s" +PYTEST_REPORTS_DIR ?= .reports/tests +override PYTEST_CASE_TIMEOUT_SECONDS := 30 +override PYTEST_RUN_TIMEOUT_SECONDS := 300 +override PYTEST_TERMINATION_GRACE_SECONDS := 2 +override PYTEST_TIMEOUT_EXIT_CODE := 124 +override PYTEST_ENFORCEMENT_PLUGIN := flext_tests_enforcement +override PYTEST_PROGRESS_ARGS := --verbose +override PYTEST_REPORT_ARGS := -ra --durations=25 --durations-min=0.001 --tb=short +override PYTEST_DIAG_ARGS := -rA --durations=0 --tb=long --showlocals +override PYTEST_PARALLEL_WORKERS := 4 +override PYTEST_PARALLEL_DISTRIBUTION := worksteal +override PYTEST_PROFILE_SORT := cumulative +override PYTEST_PROFILE_LIMIT := 50 +override PROCESS_TIMEOUT_COMMAND := timeout +override export FLEXT_PYTEST_ARGS_RAW := $(value PYTEST_ARGS) +override export FLEXT_PYTEST_FILE_RAW := $(value FILE) +override export FLEXT_PYTEST_FILES_RAW := $(value FILES) +override export FLEXT_PYTEST_MATCH_RAW := $(value MATCH) +override export FLEXT_PYTEST_DIAG_RAW := $(value DIAG) +override export FLEXT_PYTEST_FAIL_FAST_RAW := $(value FAIL_FAST) +override export FLEXT_PYTEST_REPORTS_RAW := $(value PYTEST_REPORTS_DIR) +override export FLEXT_PYTEST_WHAT_RAW := $(value WHAT) +override export FLEXT_PYTEST_VERBOSE_RAW := $(value VERBOSE) WHAT ?= - -PROJECT_ROOT := $(shell pwd -P) -PUBLIC_VERBS := help setup deps build check test format run status docs clean release codegen -# A workspace root orchestrates its members, so its lint and type scope is the -# union of every member's source and tests. Members are expanded from the -# manifest SSOT, never listed by hand, and the paths stay existence-filtered so -# a member without one of the trees cannot break the gate. -WORKSPACE_CHECK_PATHS := -RUFF_PATHS := $(PROJECT_ROOT)/src $(PROJECT_ROOT)/tests $(WORKSPACE_CHECK_PATHS) -MYPY_PATHS := $(PROJECT_ROOT)/src $(PROJECT_ROOT)/tests $(WORKSPACE_CHECK_PATHS) +# End SECTION: user overrides + +# === SECTION: derived paths (managed) === +# Source: computed (git rev-parse, MAKEFILE_LIST, abspath) +# Rule: PROJECT_ROOT is the checkout that OWNS this Makefile, never the caller's +# CWD. Deriving it from `pwd -P` made a member validate whatever tree the +# caller happened to stand in: `make -f /Makefile` invoked from the +# superproject resolved RUFF_PATHS to the SUPERPROJECT's src/tests, so the +# member linted files it does not even contain. With many shared worktrees that +# silently validates the wrong tree. +SELF_MAKEFILE := $(abspath $(firstword $(MAKEFILE_LIST))) +MAKEFILE_ROOT := $(patsubst %/,%,$(dir $(SELF_MAKEFILE))) +PROJECT_ROOT := $(MAKEFILE_ROOT) +override export FLEXT_PYTEST_TARGET_RAW := tests +WORKSPACE ?= $(PROJECT_ROOT) +# === SECTION: WORKSPACE_ROOT isolation (managed) === +# Source: computed (rule: derive from current checkout unless caller overrides) +# Rule: WORKSPACE_ROOT is always derived from the current checkout unless the +# caller passed it on the command line or via an override origin. An inherited +# environment WORKSPACE_ROOT (e.g. a leaked .envrc export from a foreign checkout) +# must never redirect verbs to another working tree. The git queries therefore +# run inside MAKEFILE_ROOT: run from a foreign CWD they would report THAT +# checkout's topology and redirect the verb to the wrong tree. +ifeq ($(filter command line override,$(origin WORKSPACE_ROOT)),) +WORKSPACE_ROOT := $(shell cd "$(MAKEFILE_ROOT)" && root=$$(git rev-parse --show-superproject-working-tree 2>/dev/null); if [ -n "$$root" ]; then printf '%s\n' "$$root"; else git rev-parse --show-toplevel 2>/dev/null || printf '%s\n' "$(MAKEFILE_ROOT)"; fi) +endif +# End SECTION: WORKSPACE_ROOT isolation + +# === SECTION: verb dispatch (managed) === +# Source: config:make.verbs[*].whats, config:make.check_gates_allowed, +# config:make.check_gates_default, config:make.serialization.verbs +PUBLIC_VERBS := help setup deps build check test fmt fix run status docs clean release gen worktree +BUILTIN_VERBS := help setup deps build check test fmt fix run status docs clean release gen worktree +SCRIPT_VERBS := + +_ALLOWED_WHATS_help := usage $(shell sed -n 's/^_custom_help_\([a-z0-9_-]*\):.*/\1/p' "$(MAKEFILE_ROOT)/custom.mk" 2>/dev/null | sort -u | tr '\n' ' ') +_ALLOWED_WHATS_setup := environment $(shell sed -n 's/^_custom_setup_\([a-z0-9_-]*\):.*/\1/p' "$(MAKEFILE_ROOT)/custom.mk" 2>/dev/null | sort -u | tr '\n' ' ') +_ALLOWED_WHATS_deps := check lock upgrade $(shell sed -n 's/^_custom_deps_\([a-z0-9_-]*\):.*/\1/p' "$(MAKEFILE_ROOT)/custom.mk" 2>/dev/null | sort -u | tr '\n' ' ') +_ALLOWED_WHATS_build := artifacts $(shell sed -n 's/^_custom_build_\([a-z0-9_-]*\):.*/\1/p' "$(MAKEFILE_ROOT)/custom.mk" 2>/dev/null | sort -u | tr '\n' ' ') +_ALLOWED_WHATS_check := all $(shell sed -n 's/^_custom_check_\([a-z0-9_-]*\):.*/\1/p' "$(MAKEFILE_ROOT)/custom.mk" 2>/dev/null | sort -u | tr '\n' ' ') +_ALLOWED_WHATS_test := all $(shell sed -n 's/^_custom_test_\([a-z0-9_-]*\):.*/\1/p' "$(MAKEFILE_ROOT)/custom.mk" 2>/dev/null | sort -u | tr '\n' ' ') +_ALLOWED_WHATS_fmt := check all $(shell sed -n 's/^_custom_fmt_\([a-z0-9_-]*\):.*/\1/p' "$(MAKEFILE_ROOT)/custom.mk" 2>/dev/null | sort -u | tr '\n' ' ') +_ALLOWED_WHATS_fix := check all $(shell sed -n 's/^_custom_fix_\([a-z0-9_-]*\):.*/\1/p' "$(MAKEFILE_ROOT)/custom.mk" 2>/dev/null | sort -u | tr '\n' ' ') +_ALLOWED_WHATS_run := default $(shell sed -n 's/^_custom_run_\([a-z0-9_-]*\):.*/\1/p' "$(MAKEFILE_ROOT)/custom.mk" 2>/dev/null | sort -u | tr '\n' ' ') +_ALLOWED_WHATS_status := diagnostics $(shell sed -n 's/^_custom_status_\([a-z0-9_-]*\):.*/\1/p' "$(MAKEFILE_ROOT)/custom.mk" 2>/dev/null | sort -u | tr '\n' ' ') +_ALLOWED_WHATS_docs := all generate fix audit build validate $(shell sed -n 's/^_custom_docs_\([a-z0-9_-]*\):.*/\1/p' "$(MAKEFILE_ROOT)/custom.mk" 2>/dev/null | sort -u | tr '\n' ' ') +_ALLOWED_WHATS_clean := generated $(shell sed -n 's/^_custom_clean_\([a-z0-9_-]*\):.*/\1/p' "$(MAKEFILE_ROOT)/custom.mk" 2>/dev/null | sort -u | tr '\n' ' ') +_ALLOWED_WHATS_release := status $(shell sed -n 's/^_custom_release_\([a-z0-9_-]*\):.*/\1/p' "$(MAKEFILE_ROOT)/custom.mk" 2>/dev/null | sort -u | tr '\n' ' ') +_ALLOWED_WHATS_gen := check all $(shell sed -n 's/^_custom_gen_\([a-z0-9_-]*\):.*/\1/p' "$(MAKEFILE_ROOT)/custom.mk" 2>/dev/null | sort -u | tr '\n' ' ') +_ALLOWED_WHATS_worktree := list add update remove $(shell sed -n 's/^_custom_worktree_\([a-z0-9_-]*\):.*/\1/p' "$(MAKEFILE_ROOT)/custom.mk" 2>/dev/null | sort -u | tr '\n' ' ') + +CHECK_GATES_ALLOWED := lint format pyrefly mypy pyright security markdown smells +CHECK_GATES_DEFAULT := lint pyrefly mypy pyright security markdown smells +DOCS_ACTIONS := generate fix audit build validate +SERIALIZED_VERBS := check test gen fmt fix deps clean worktree +SERIALIZED_TARGETS := _serialized_check _serialized_test _serialized_gen _serialized_fmt _serialized_fix _serialized_deps _serialized_clean _serialized_worktree +# End SECTION: verb dispatch + +# === SECTION: lint/type paths (managed) === +# Source: template + computed (script_dispatch conditional) +RUFF_PATHS := $(PROJECT_ROOT)/src $(PROJECT_ROOT)/tests +MYPY_PATHS := $(PROJECT_ROOT)/src $(PROJECT_ROOT)/tests +# End SECTION: lint/type paths + +# === SECTION: infra bootstrap (managed) === +# Source: config:infra_repository.*, config:infra_source_root_rel, template (UV default) +UV ?= uv +UV_REQUESTED := $(UV) +CALLER_PATH := $(PATH) +CALLER_VIRTUAL_ENV := $(patsubst %/,%,$(VIRTUAL_ENV)) +FLEXT_INFRA_BOOTSTRAP_REQUIREMENT := flext-infra @ git+https://github.com/flext-sh/flext-infra.git@0.12.0-dev +FLEXT_INFRA_SOURCE_ROOT_REL := +UV_BOOTSTRAP_FLAGS := --isolated --all-groups --all-extras +# End SECTION: infra bootstrap # === MYPY RESOURCE LIMIT === # mro-0ftd.3.11: every Mypy process inherits validated memory and time caps. @@ -37,45 +162,89 @@ export MYPY_MEMORY_LIMIT_MB MYPY_TIMEOUT_SECONDS _DEFAULT_help := usage -_DEFAULT_setup := environment _DEFAULT_deps := check _DEFAULT_build := artifacts _DEFAULT_check := all _DEFAULT_test := all -_DEFAULT_format := check +_DEFAULT_fmt := check +_DEFAULT_fix := check _DEFAULT_run := default _DEFAULT_status := diagnostics -_DEFAULT_docs := check +_DEFAULT_docs := all _DEFAULT_clean := generated _DEFAULT_release := status -_DEFAULT_codegen := check - - +_DEFAULT_gen := check +_DEFAULT_worktree := list + +_APPLY_WHAT_deps := upgrade +_APPLY_WHAT_fmt := all +_APPLY_WHAT_fix := all +_APPLY_WHAT_run := default +_APPLY_WHAT_clean := generated +_APPLY_WHAT_gen := all +_APPLY_WHAT_worktree := update + + +# === SECTION: profile routing (managed) === +# Source: config:workspace manifest (role), computed (WORKSPACE_ROOT) +# Rule: workspace-member delegates runtime to the principal (RUNTIME_ROOT is +# the governing workspace root); workspace-root and standalone own their +# runtime locally. An attached member is never promoted to a local runtime. ifneq ($(filter $(MAKE_PROFILE),workspace-root workspace-member standalone),$(MAKE_PROFILE)) $(error Invalid MAKE_PROFILE '$(MAKE_PROFILE)') endif ifeq ($(MAKE_PROFILE),workspace-member) -DECLARED_WORKSPACE_ROOT := $(shell cd "$(PROJECT_ROOT)/$(WORKSPACE_ROOT_REL)" 2>/dev/null && pwd -P) -SUPERPROJECT_ROOT_RAW := $(shell git rev-parse --show-superproject-working-tree 2>/dev/null) -SUPERPROJECT_ROOT := $(shell test -n "$(SUPERPROJECT_ROOT_RAW)" && cd "$(SUPERPROJECT_ROOT_RAW)" 2>/dev/null && pwd -P) -ifeq ($(SUPERPROJECT_ROOT),$(DECLARED_WORKSPACE_ROOT)) -ATTACHED_MEMBER := Y -RUNTIME_ROOT := $(DECLARED_WORKSPACE_ROOT) +RUNTIME_ROOT := $(WORKSPACE_ROOT) else -ATTACHED_MEMBER := N -RUNTIME_ROOT := $(PROJECT_ROOT) -endif -else -ATTACHED_MEMBER := N RUNTIME_ROOT := $(PROJECT_ROOT) endif +# End SECTION: profile routing RUNTIME_VENV := $(RUNTIME_ROOT)/.venv +FLEXT_INFRA_RUNTIME_ROOT := $(if $(filter $(MAKEFILE_ROOT),$(PROJECT_ROOT)),$(RUNTIME_ROOT),$(MAKEFILE_ROOT)) +ifeq ($(OS),Windows_NT) +RUNTIME_BIN := $(RUNTIME_VENV)/Scripts +RUNTIME_PYTHON := $(RUNTIME_BIN)/python.exe +FLEXT_INFRA_RUNTIME_PYTHON := $(FLEXT_INFRA_RUNTIME_ROOT)/.venv/Scripts/python.exe +NORMALIZED_CALLER_PATH := $(shell cygpath --path "$(CALLER_PATH)" 2>/dev/null) +NORMALIZED_CALLER_VIRTUAL_ENV := $(shell cygpath --unix "$(CALLER_VIRTUAL_ENV)" 2>/dev/null) +CALLER_VIRTUAL_ENV_BIN := $(NORMALIZED_CALLER_VIRTUAL_ENV)/Scripts +else +RUNTIME_BIN := $(RUNTIME_VENV)/bin +RUNTIME_PYTHON := $(RUNTIME_BIN)/python +FLEXT_INFRA_RUNTIME_PYTHON := $(FLEXT_INFRA_RUNTIME_ROOT)/.venv/bin/python +NORMALIZED_CALLER_PATH := $(CALLER_PATH) +NORMALIZED_CALLER_VIRTUAL_ENV := $(CALLER_VIRTUAL_ENV) +CALLER_VIRTUAL_ENV_BIN := $(NORMALIZED_CALLER_VIRTUAL_ENV)/bin +endif +SANITIZED_CALLER_PATH := $(NORMALIZED_CALLER_PATH) +ifneq ($(strip $(NORMALIZED_CALLER_VIRTUAL_ENV)),) +SANITIZED_CALLER_PATH := $(subst $(CALLER_VIRTUAL_ENV_BIN):,,$(SANITIZED_CALLER_PATH)) +SANITIZED_CALLER_PATH := $(subst :$(CALLER_VIRTUAL_ENV_BIN),,$(SANITIZED_CALLER_PATH)) +ifeq ($(SANITIZED_CALLER_PATH),$(CALLER_VIRTUAL_ENV_BIN)) +SANITIZED_CALLER_PATH := +endif +endif +RESOLVED_UV := $(shell PATH="$(SANITIZED_CALLER_PATH)" command -v "$(UV_REQUESTED)" 2>/dev/null) +ifeq ($(strip $(RESOLVED_UV)),) +$(error Required uv executable not found: $(UV_REQUESTED)) +endif +override UV := $(RESOLVED_UV) +override FLEXT_INFRA_PYTHON := $(FLEXT_INFRA_RUNTIME_PYTHON) override UV_PROJECT := $(RUNTIME_ROOT) override UV_PROJECT_ENVIRONMENT := $(RUNTIME_VENV) override VIRTUAL_ENV := $(RUNTIME_VENV) -export UV_PROJECT UV_PROJECT_ENVIRONMENT VIRTUAL_ENV +override PATH := $(RUNTIME_BIN):$(SANITIZED_CALLER_PATH) +export FLEXT_INFRA_PYTHON UV UV_PROJECT UV_PROJECT_ENVIRONMENT VIRTUAL_ENV PATH + +ifneq ($(strip $(FLEXT_INFRA_SOURCE_ROOT_REL)),) +FLEXT_INFRA_SOURCE_ROOT := $(abspath $(PROJECT_ROOT)/$(FLEXT_INFRA_SOURCE_ROOT_REL)) +FLEXT_INFRA_BOOTSTRAP := env -u PYTHONPATH -u MYPYPATH -u VIRTUAL_ENV -u UV_PROJECT -u UV_PROJECT_ENVIRONMENT PATH="$(SANITIZED_CALLER_PATH)" $(UV) run --project "$(PROJECT_ROOT)" $(UV_BOOTSTRAP_FLAGS) --with-editable "$(FLEXT_INFRA_SOURCE_ROOT)" python -m flext_infra +else +FLEXT_INFRA_SOURCE_ROOT := +FLEXT_INFRA_BOOTSTRAP := env -u PYTHONPATH -u MYPYPATH -u VIRTUAL_ENV -u UV_PROJECT -u UV_PROJECT_ENVIRONMENT PATH="$(SANITIZED_CALLER_PATH)" $(UV) run --project "$(PROJECT_ROOT)" $(UV_BOOTSTRAP_FLAGS) --with "$(FLEXT_INFRA_BOOTSTRAP_REQUIREMENT)" python -m flext_infra +endif ifeq ($(MAKE_PROFILE),workspace-root) CODEGEN_SCOPE := all @@ -89,58 +258,93 @@ endif # `flext-infra workspace orchestrate` primitive (verb allowlist + CLI group come # from the constants SSOT, never hardcoded here). Members and standalone projects # run the gate locally. FAIL_FAST forwards the stop-on-first-failure policy. -WORKSPACE_ORCHESTRATE := $(UV_RUN) python -m flext_infra workspace orchestrate -ORCHESTRATED_VERBS := build check clean docs scan test val +# Provisioning is a probe-then-repair pair, declared once and shared by every +# profile so the two branches below can never drift apart. `uv sync --check` +# reports drift without touching the tree; only a non-zero exit escalates to a +# real `uv sync`. Creating a missing venv is provisioning, so it is allowed; +# clearing a present one is destruction, so it never happens. +SETUP_ENVIRONMENT_RECIPE = set -eu; \ + if [ ! -x "$(RUNTIME_PYTHON)" ]; then \ + $(UV) venv "$(RUNTIME_VENV)"; \ + fi; \ + if ! $(UV) sync --project "$(PROJECT_ROOT)" $(UV_SYNC_FLAGS) --link-mode "$(UV_LINK_MODE)" --check >/dev/null 2>&1; then \ + $(UV) sync --project "$(PROJECT_ROOT)" $(UV_SYNC_FLAGS) --link-mode "$(UV_LINK_MODE)"; \ + fi -UV_RUN := uv run --project "$(RUNTIME_ROOT)" --no-sync +WORKSPACE_ORCHESTRATE = $(UV_RUN) python -m flext_infra workspace orchestrate +REQUESTED_PROJECTS := $(strip $(if $(PROJECT),$(PROJECT),$(PROJECTS))) +# A workspace root owns no local gate implementation: its verbs fan out to the +# declared members. Selecting the root here would make it orchestrate itself. +DEFAULT_PROJECTS := $(WORKSPACE_MEMBERS) . +SELECTED_PROJECTS := $(if $(strip $(REQUESTED_PROJECTS)),$(REQUESTED_PROJECTS),$(DEFAULT_PROJECTS)) +WORKSPACE_PROJECT_ARGS := $(foreach project,$(SELECTED_PROJECTS),--projects $(project)) +WORKSPACE_CHECK_ARGS := $(if $(strip $(CHECK_GATES)),--make-arg "CHECK_GATES=$(strip $(CHECK_GATES))") +WORKSPACE_TEST_ARGS := $(if $(strip $(FLEXT_PYTEST_FILE_RAW)),--file "$${FLEXT_PYTEST_FILE_RAW}") $(if $(strip $(FLEXT_PYTEST_MATCH_RAW)),--match "$${FLEXT_PYTEST_MATCH_RAW}") $(if $(strip $(FLEXT_PYTEST_WHAT_RAW)),--what "$${FLEXT_PYTEST_WHAT_RAW}") +DOCS_PROJECT_ARGS := $(foreach project,$(REQUESTED_PROJECTS),--projects $(project)) +ORCHESTRATED_VERBS := build check clean docs fmt fix scan test val + +UV_RUN := env -u PYTHONPATH -u MYPYPATH $(UV) run --project "$(RUNTIME_ROOT)" --no-sync +PROJECT_INFRA_PYTHONPATH ?= $(MAKEFILE_ROOT)/src +PROJECT_FLEXT_INFRA := test -x "$(FLEXT_INFRA_PYTHON)" || { printf 'ERROR: FLEXT_INFRA_PYTHON must name an executable managed Python\n' >&2; exit 2; }; env -u PYTHONPATH -u MYPYPATH -u VIRTUAL_ENV -u UV_PROJECT -u UV_PROJECT_ENVIRONMENT PATH="$(dir $(FLEXT_INFRA_PYTHON)):$(SANITIZED_CALLER_PATH)" PYTHONPATH="$(PROJECT_INFRA_PYTHONPATH)" $(FLEXT_INFRA_PYTHON) -m flext_infra # mro-j47u (codex): scaffold dev tools live in the validated optional dev # profile; a fresh project must create its lock before later check-mode locks. -UV_SYNC_FLAGS := --all-extras --all-groups +# Keyed on the environment's OWNER, not on the caller's profile. A member has +# no local venv -- RUNTIME_VENV is RUNTIME_ROOT/.venv -- so every checkout that +# provisions a shared environment must describe the same contents. A member +# syncing without --all-packages treats the siblings already installed there as +# surplus and uninstalls them, undoing the root's provisioning and leaving +# `uv sync --check` permanently divergent. A standalone project owns its venv +# alone and has no workspace packages to include. +SHARED_RUNTIME := $(if $(filter-out $(PROJECT_ROOT),$(RUNTIME_ROOT)),1,$(if $(strip $(WORKSPACE_MEMBERS)),1,)) +UV_SYNC_FLAGS := $(if $(SHARED_RUNTIME),--all-packages ,)--all-extras --all-groups + +ifneq ($(strip $(PROJECT)),) +ifneq ($(strip $(PROJECTS)),) +$(error ERROR: Cannot use PROJECT and PROJECTS together) +endif +endif -# The custom Make surface is the single extension point for every profile: it -# carries the project's own commands, WHATs and hooks. Its name comes from the -# constants SSOT, so there is no per-profile variant and no second surface. -include custom.mk - -_BUILTIN_HANDLERS := \ - _builtin_help_usage \ - _builtin_setup_environment \ - _builtin_deps_check \ - _builtin_deps_lock \ - _builtin_deps_upgrade \ - _builtin_build_artifacts \ - _builtin_check_all \ - _builtin_test_all \ - _builtin_format_check \ - _builtin_format_apply \ - _builtin_run_default \ - _builtin_status_diagnostics \ - _builtin_docs_check \ - _builtin_clean_generated \ - _builtin_release_status \ - _builtin_codegen_check \ - _builtin_codegen_apply +SELF_MAKE := $(MAKE) --no-print-directory -f "$(SELF_MAKEFILE)" define _dispatch @what="$(strip $(WHAT))"; \ - if [ -z "$$what" ]; then what="$(_DEFAULT_$@)"; fi; \ + applying="$(strip $(APPLYING))"; \ + if [ -n "$$applying" ] && [ "$$applying" != "Y" ]; then \ + printf 'ERROR: APPLY must be Y when set\n' >&2; exit 2; \ + fi; \ + if [ -n "$$applying" ] && [ -z "$(_APPLY_WHAT_$(1))" ]; then \ + printf 'ERROR: verb %s is read-only and does not accept APPLY\n' "$(1)" >&2; exit 2; \ + fi; \ + if [ -z "$$what" ] && [ -n "$$applying" ] && [ -n "$(_APPLY_WHAT_$(1))" ]; then \ + what="$(_APPLY_WHAT_$(1))"; \ + fi; \ + if [ -z "$$what" ]; then what="$(_DEFAULT_$(1))"; fi; \ case "$$what" in \ *[!a-z0-9_-]*|'') printf 'ERROR: invalid WHAT selector %s\n' "$$what" >&2; exit 2 ;; \ esac; \ - builtin="_builtin_$@_$$what"; \ - custom="_custom_$@_$$what"; \ - for hook in "pre-$@" "pre-$@-$$what"; do \ - $(MAKE) --no-print-directory -q "$$hook" >/dev/null 2>&1; rc=$$?; \ - if [ "$$rc" -ne 2 ]; then $(MAKE) --no-print-directory "$$hook" || exit $$?; fi; \ + custom="_custom_$(1)_$$what"; \ + $(SELF_MAKE) -q "$$custom" >/dev/null 2>&1; custom_rc=$$?; \ + if [ "$$custom_rc" -eq 2 ]; then \ + case " $(_ALLOWED_WHATS_$(1)) " in \ + *" $$what "*) ;; \ + *) printf 'ERROR: unsupported %s WHAT=%s (allowed:%s)\n' "$(1)" "$$what" "$(_ALLOWED_WHATS_$(1))" >&2; exit 2 ;; \ + esac; \ + fi; \ + builtin="_builtin_$(1)_$$what"; \ + for hook in "pre-$(1)" "pre-$(1)-$$what"; do \ + $(SELF_MAKE) -q "$$hook" >/dev/null 2>&1; rc=$$?; \ + if [ "$$rc" -ne 2 ]; then $(SELF_MAKE) "$$hook" || exit $$?; fi; \ done; \ - case " $(_BUILTIN_HANDLERS) " in \ - *" $$builtin "*) $(MAKE) --no-print-directory "$$builtin" || exit $$? ;; \ - *) $(MAKE) --no-print-directory "$$custom" || exit $$? ;; \ - esac; \ - for hook in "post-$@-$$what" "post-$@"; do \ - $(MAKE) --no-print-directory -q "$$hook" >/dev/null 2>&1; rc=$$?; \ - if [ "$$rc" -ne 2 ]; then $(MAKE) --no-print-directory "$$hook" || exit $$?; fi; \ + if [ "$$custom_rc" -ne 2 ]; then \ + $(SELF_MAKE) "$$custom" || exit $$?; \ + else \ + $(SELF_MAKE) "$$builtin" || exit $$?; \ + fi; \ + for hook in "post-$(1)-$$what" "post-$(1)"; do \ + $(SELF_MAKE) -q "$$hook" >/dev/null 2>&1; rc=$$?; \ + if [ "$$rc" -ne 2 ]; then $(SELF_MAKE) "$$hook" || exit $$?; fi; \ done endef @@ -153,55 +357,165 @@ endef define _run_for_selected_projects @set -eu; \ - selected="$(strip $(PROJECTS))"; \ + selected="$(strip $(if $(PROJECT),$(PROJECT),$(PROJECTS)))"; \ if [ -z "$$selected" ]; then selected="."; fi; \ for project in $$selected; do \ case " $(ALLOWED_PROJECTS) " in \ *" $$project "*) ;; \ *) printf 'ERROR: undeclared project %s\n' "$$project" >&2; exit 2 ;; \ esac; \ - uv lock --project "$(PROJECT_ROOT)/$$project" $(1); \ + if [ "$$project" = "." ]; then project_root="$(PROJECT_ROOT)"; \ + else project_root="$(PROJECT_ROOT)/$$project"; fi; \ + $(UV) lock --project "$$project_root" $(1); \ done endef -.PHONY: $(PUBLIC_VERBS) $(_BUILTIN_HANDLERS) +.PHONY: $(PUBLIC_VERBS) $(SERIALIZED_TARGETS) _builtin_help_usage _builtin_setup_environment _builtin_deps_check _builtin_deps_lock _builtin_deps_upgrade _builtin_build_artifacts _builtin_check_all _builtin_test_all _builtin_fmt_check _builtin_fmt_all _builtin_fix_check _builtin_fix_all _builtin_run_default _builtin_status_diagnostics _builtin_docs_all _builtin_docs_generate _builtin_docs_fix _builtin_docs_audit _builtin_docs_build _builtin_docs_validate _builtin_clean_generated _builtin_release_status _builtin_gen_check _builtin_gen_all _builtin_worktree_list _builtin_worktree_add _builtin_worktree_update _builtin_worktree_remove + +$(filter-out setup $(SERIALIZED_VERBS),$(PUBLIC_VERBS)): + $(call _dispatch,$@) + + +check: _builtin_require_environment + @$(PROJECT_FLEXT_INFRA) workspace serialize-make --workspace "$(PROJECT_ROOT)" --makefile "$(SELF_MAKEFILE)" --verb "check" --selector-value "$(WHAT)" --apply-token "$(APPLY)" + +_serialized_check: + $(call _dispatch,check) + + +test: _builtin_require_environment + @$(PROJECT_FLEXT_INFRA) workspace serialize-make --workspace "$(PROJECT_ROOT)" --makefile "$(SELF_MAKEFILE)" --verb "test" --selector-value "$(WHAT)" --apply-token "$(APPLY)" + +_serialized_test: + $(call _dispatch,test) + + +gen: _builtin_require_environment + @$(PROJECT_FLEXT_INFRA) workspace serialize-make --workspace "$(PROJECT_ROOT)" --makefile "$(SELF_MAKEFILE)" --verb "gen" --selector-value "$(WHAT)" --apply-token "$(APPLY)" + +_serialized_gen: + $(call _dispatch,gen) + + +fmt: _builtin_require_environment + @$(PROJECT_FLEXT_INFRA) workspace serialize-make --workspace "$(PROJECT_ROOT)" --makefile "$(SELF_MAKEFILE)" --verb "fmt" --selector-value "$(WHAT)" --apply-token "$(APPLY)" + +_serialized_fmt: + $(call _dispatch,fmt) + -$(PUBLIC_VERBS): - $(call _dispatch) +fix: _builtin_require_environment + @$(PROJECT_FLEXT_INFRA) workspace serialize-make --workspace "$(PROJECT_ROOT)" --makefile "$(SELF_MAKEFILE)" --verb "fix" --selector-value "$(WHAT)" --apply-token "$(APPLY)" + +_serialized_fix: + $(call _dispatch,fix) + + +deps: _builtin_require_environment + @$(PROJECT_FLEXT_INFRA) workspace serialize-make --workspace "$(PROJECT_ROOT)" --makefile "$(SELF_MAKEFILE)" --verb "deps" --selector-value "$(WHAT)" --apply-token "$(APPLY)" + +_serialized_deps: + $(call _dispatch,deps) + + +clean: _builtin_require_environment + @$(PROJECT_FLEXT_INFRA) workspace serialize-make --workspace "$(PROJECT_ROOT)" --makefile "$(SELF_MAKEFILE)" --verb "clean" --selector-value "$(WHAT)" --apply-token "$(APPLY)" + +_serialized_clean: + $(call _dispatch,clean) + + +worktree: _builtin_require_environment + @$(PROJECT_FLEXT_INFRA) workspace serialize-make --workspace "$(PROJECT_ROOT)" --makefile "$(SELF_MAKEFILE)" --verb "worktree" --selector-value "$(WHAT)" --apply-token "$(APPLY)" + +_serialized_worktree: + $(call _dispatch,worktree) + + + +# `setup` keeps its own recipe (it must not require the environment it is about +# to build), but it still runs the pre-/post-setup lifecycle hooks so a project +# declaring them in the custom handler surface is actually honoured. +setup: + @for hook in "pre-setup"; do \ + $(SELF_MAKE) -q "$$hook" >/dev/null 2>&1; rc=$$?; \ + if [ "$$rc" -ne 2 ]; then $(SELF_MAKE) "$$hook" || exit $$?; fi; \ + done + @$(SELF_MAKE) _builtin_setup_environment + @for hook in "post-setup"; do \ + $(SELF_MAKE) -q "$$hook" >/dev/null 2>&1; rc=$$?; \ + if [ "$$rc" -ne 2 ]; then $(SELF_MAKE) "$$hook" || exit $$?; fi; \ + done _builtin_help_usage: - @printf '%s\n' 'flext-core [workspace-member]' '' + @printf '%s\n' 'flext-core [workspace-member]' ''; + + + @printf ' %-10s WHAT=%s\n' 'help' "$$(printf '%s' '$(_ALLOWED_WHATS_help)' | awk '{$$1=$$1; gsub(/ /, "|"); print}')"; + + + + @printf ' %-10s\n' 'setup'; + + + + @printf ' %-10s WHAT=%s APPLY=Y\n' 'deps' "$$(printf '%s' '$(_ALLOWED_WHATS_deps)' | awk '{$$1=$$1; gsub(/ /, "|"); print}')"; + + + + @printf ' %-10s WHAT=%s\n' 'build' "$$(printf '%s' '$(_ALLOWED_WHATS_build)' | awk '{$$1=$$1; gsub(/ /, "|"); print}')"; + + + + @printf ' %-10s WHAT=%s\n' 'check' "$$(printf '%s' '$(_ALLOWED_WHATS_check)' | awk '{$$1=$$1; gsub(/ /, "|"); print}')"; + + + + @printf ' %-10s WHAT=%s\n' 'test' "$$(printf '%s' '$(_ALLOWED_WHATS_test)' | awk '{$$1=$$1; gsub(/ /, "|"); print}')"; + + + + @printf ' %-10s WHAT=%s APPLY=Y\n' 'fmt' "$$(printf '%s' '$(_ALLOWED_WHATS_fmt)' | awk '{$$1=$$1; gsub(/ /, "|"); print}')"; - @printf ' %-10s WHAT=%s\n' 'help' 'usage' - @printf ' %-10s WHAT=%s\n' 'setup' 'environment' - @printf ' %-10s WHAT=%s\n' 'deps' 'check' + @printf ' %-10s WHAT=%s APPLY=Y\n' 'fix' "$$(printf '%s' '$(_ALLOWED_WHATS_fix)' | awk '{$$1=$$1; gsub(/ /, "|"); print}')"; - @printf ' %-10s WHAT=%s\n' 'build' 'artifacts' - @printf ' %-10s WHAT=%s\n' 'check' 'all' - @printf ' %-10s WHAT=%s\n' 'test' 'all' + @printf ' %-10s WHAT=%s APPLY=Y\n' 'run' "$$(printf '%s' '$(_ALLOWED_WHATS_run)' | awk '{$$1=$$1; gsub(/ /, "|"); print}')"; - @printf ' %-10s WHAT=%s APPLY=Y\n' 'format' 'check' - @printf ' %-10s WHAT=%s\n' 'run' 'default' - @printf ' %-10s WHAT=%s\n' 'status' 'diagnostics' + @printf ' %-10s WHAT=%s\n' 'status' "$$(printf '%s' '$(_ALLOWED_WHATS_status)' | awk '{$$1=$$1; gsub(/ /, "|"); print}')"; - @printf ' %-10s WHAT=%s\n' 'docs' 'check' - @printf ' %-10s WHAT=%s\n' 'clean' 'generated' - @printf ' %-10s WHAT=%s\n' 'release' 'status' + @printf ' %-10s WHAT=%s\n' 'docs' "$$(printf '%s' '$(_ALLOWED_WHATS_docs)' | awk '{$$1=$$1; gsub(/ /, "|"); print}')"; - @printf ' %-10s WHAT=%s APPLY=Y\n' 'codegen' 'check' - @printf '\n%s\n' 'Custom hooks (custom.mk):' - @printf ' %s\n' 'Define pre-, post-, pre--, post--' - @printf ' %s\n' 'in custom.mk to run extra steps at the start or end of any verb,' - @printf ' %s\n' 'for all or some WHATs. Add _custom__ to define a new WHAT.' + + @printf ' %-10s WHAT=%s APPLY=Y\n' 'clean' "$$(printf '%s' '$(_ALLOWED_WHATS_clean)' | awk '{$$1=$$1; gsub(/ /, "|"); print}')"; + + + + @printf ' %-10s WHAT=%s\n' 'release' "$$(printf '%s' '$(_ALLOWED_WHATS_release)' | awk '{$$1=$$1; gsub(/ /, "|"); print}')"; + + + + @printf ' %-10s WHAT=%s APPLY=Y\n' 'gen' "$$(printf '%s' '$(_ALLOWED_WHATS_gen)' | awk '{$$1=$$1; gsub(/ /, "|"); print}')"; + + + + @printf ' %-10s WHAT=%s APPLY=Y\n' 'worktree' "$$(printf '%s' '$(_ALLOWED_WHATS_worktree)' | awk '{$$1=$$1; gsub(/ /, "|"); print}')"; + + + @printf ' %-10s %s\n' 'WORKSPACE' 'target repository (default: current project)'; + @printf ' %-10s %s\n' 'BASE' 'required for worktree add/update'; + @printf '\n%s\n' 'Custom hooks (custom.mk):'; + @printf ' %s\n' 'Define pre-, post-, pre--, post--'; + @printf ' %s\n' 'in custom.mk to wrap one declared handler.'; + @printf ' %s\n' 'Add _custom__ to define a new WHAT.'; @if [ -f custom.mk ]; then \ hooks=$$(grep -oE '^(pre|post)-[a-z][a-z0-9-]*|^_custom_[a-z][a-z0-9_-]*' custom.mk 2>/dev/null | sort -u); \ if [ -n "$$hooks" ]; then \ @@ -210,80 +524,317 @@ _builtin_help_usage: fi; \ fi -ifeq ($(MAKE_PROFILE),workspace-root) -_builtin_setup_environment: - @uv sync --project "$(PROJECT_ROOT)" $(UV_SYNC_FLAGS) - @uv pip install --python "$(PROJECT_ROOT)/.venv/bin/python" --no-deps --editable "$(PROJECT_ROOT)" --link-mode "$(UV_LINK_MODE)" - @set -eu; for member in $(WORKSPACE_MEMBERS); do \ - uv pip install --python "$(PROJECT_ROOT)/.venv/bin/python" --no-deps --editable "$(PROJECT_ROOT)/$$member" --link-mode "$(UV_LINK_MODE)"; \ +# A project owns the sources declared by its manifest. The generated setup +# reconciler validates every initialized checkout before mutation, initializes +# only missing modules, and preserves declared branches that fix forward beyond +# the recorded gitlink. +.PHONY: _builtin_setup_submodules + +# === SECTION: submodule setup (managed) === +# Source: template (submodule_setup_recipe.j2) +# Computed: workspace-root uses WORKSPACE_MEMBERS from config; standalone discovers +# submodules with flext-managed=true from .gitmodules at runtime. +# Rule: setup PROVISIONS an absent governed gitlink and VERIFIES a present one. +# An absent checkout holds no work, so setup initializes it at the recorded +# gitlink. A present checkout is never destroyed: git checkout and git reset +# are forbidden. Detached HEAD is attached via branch + symbolic-ref so dirty +# work is carried. Pin validity is HEAD contains gitlink โ€” origin may lag the +# pin without failing verify. Declared branch is the named integration line; +# legacy branch=. still resolves to the superproject named branch if present. +# Fetch skips when local already contains pin and origin tip. +# Free: no +# End SECTION: submodule setup +_builtin_setup_submodules: + @set -eu; \ + root="$(PROJECT_ROOT)"; \ + if [ ! -f "$$root/.gitmodules" ]; then exit 0; fi; \ + profile="$(MAKE_PROFILE)"; \ + if [ "$$profile" = "workspace-root" ]; then \ + managed="$(WORKSPACE_MEMBERS)"; \ + else \ + managed=""; \ + keys=$$(git -C "$$root" config -f .gitmodules --name-only --get-regexp '^submodule\..*\.flext-managed$$' || :); \ + for key in $$keys; do \ + value=$$(git -C "$$root" config -f .gitmodules --get "$$key"); \ + if [ "$$value" = "true" ]; then \ + section=$${key%.flext-managed}; \ + path=$$(git -C "$$root" config -f .gitmodules --get --default "" "$$section.path"); \ + if [ -n "$$path" ]; then \ + managed="$$managed $$path"; \ + fi; \ + fi; \ + done; \ + fi; \ + managed=$$(printf '%s' "$$managed" | tr ' ' '\n' | sort -u | tr '\n' ' '); \ + if [ -z "$$managed" ]; then exit 0; fi; \ + attach_branch_at_head() { \ + child_root="$$1"; \ + branch="$$2"; \ + git -C "$$child_root" branch --quiet -f "$$branch" HEAD || { \ + printf 'ERROR: %s: could not create branch %s at HEAD\n' "$$child_root" "$$branch" >&2; \ + exit 1; \ + }; \ + git -C "$$child_root" symbolic-ref HEAD "refs/heads/$$branch" || { \ + printf 'ERROR: %s: could not attach HEAD to %s without moving the tree\n' "$$child_root" "$$branch" >&2; \ + exit 1; \ + }; \ + git -C "$$child_root" branch --quiet --set-upstream-to "origin/$$branch" "$$branch" >/dev/null 2>&1 || :; \ + }; \ + validate_submodule() { \ + superproject="$$1"; \ + child_path="$$2"; \ + child_root="$$superproject/$$child_path"; \ + keys=$$(git -C "$$superproject" config -f .gitmodules --name-only --get-regexp '^submodule\..*\.path$$' || :); \ + section=""; \ + for key in $$keys; do \ + declared=$$(git -C "$$superproject" config -f .gitmodules --get "$$key"); \ + if [ "$$declared" = "$$child_path" ]; then \ + if [ -n "$$section" ]; then \ + printf 'ERROR: governed gitlink path is duplicated: %s\n' "$$child_path" >&2; \ + exit 2; \ + fi; \ + section=$${key%.path}; \ + fi; \ + done; \ + if [ -z "$$section" ]; then \ + printf 'ERROR: governed gitlink is absent from .gitmodules: %s\n' "$$child_path" >&2; \ + exit 2; \ + fi; \ + branch=$$(git -C "$$superproject" config -f .gitmodules --get --default "" "$$section.branch"); \ + if [ -z "$$branch" ]; then \ + printf 'ERROR: governed gitlink has no declared branch: %s\n' "$$child_path" >&2; \ + exit 2; \ + fi; \ + if [ "$$branch" = "." ]; then \ + branch=$$(git -C "$$superproject" branch --show-current); \ + if [ -z "$$branch" ]; then \ + printf 'ERROR: %s: branch = . requires a named superproject branch\n' "$$child_path" >&2; \ + exit 1; \ + fi; \ + fi; \ + git check-ref-format --branch "$$branch" >/dev/null || { \ + printf 'ERROR: %s: invalid declared branch %s\n' "$$child_path" "$$branch" >&2; \ + exit 1; \ + }; \ + gitlink=$$(git -C "$$superproject" ls-files --stage -- "$$child_path" | awk '$$1 == "160000" {print $$2}'); \ + if [ -z "$$gitlink" ]; then \ + printf 'ERROR: governed gitlink is absent from the index: %s\n' "$$child_path" >&2; \ + exit 2; \ + fi; \ + if [ ! -e "$$child_root/.git" ]; then \ + git -C "$$superproject" submodule update --init -- "$$child_path" || { \ + printf 'ERROR: %s: could not initialize the governed gitlink\n' "$$child_path" >&2; \ + exit 1; \ + }; \ + attach_branch_at_head "$$child_root" "$$branch"; \ + fi; \ + remote_ref="refs/remotes/origin/$$branch"; \ + current=$$(git -C "$$child_root" branch --show-current); \ + head=$$(git -C "$$child_root" rev-parse HEAD); \ + if [ -n "$$current" ] && [ "$$current" != "$$branch" ]; then \ + printf 'ERROR: %s: conflicting branch %s; expected %s (setup never runs checkout/reset; switch it yourself while keeping dirty)\n' "$$child_path" "$$current" "$$branch" >&2; \ + exit 1; \ + fi; \ + need_fetch=1; \ + if git -C "$$child_root" rev-parse --verify "$$remote_ref" >/dev/null 2>&1 && \ + git -C "$$child_root" merge-base --is-ancestor "$$gitlink" HEAD && \ + git -C "$$child_root" merge-base --is-ancestor "$$remote_ref" HEAD; then \ + need_fetch=0; \ + fi; \ + if [ "$$need_fetch" -eq 1 ]; then \ + git -C "$$child_root" fetch --quiet origin "$$branch" || { \ + printf 'ERROR: %s: fetch origin %s failed\n' "$$child_path" "$$branch" >&2; \ + exit 1; \ + }; \ + fi; \ + current=$$(git -C "$$child_root" branch --show-current); \ + head=$$(git -C "$$child_root" rev-parse HEAD); \ + if [ -n "$$current" ] && [ "$$current" != "$$branch" ]; then \ + printf 'ERROR: %s: conflicting branch %s; expected %s (setup never runs checkout/reset; switch it yourself while keeping dirty)\n' "$$child_path" "$$current" "$$branch" >&2; \ + exit 1; \ + fi; \ + if [ -z "$$current" ]; then \ + if git -C "$$child_root" merge-base --is-ancestor "$$gitlink" HEAD; then \ + attach_branch_at_head "$$child_root" "$$branch"; \ + elif git -C "$$child_root" rev-parse --verify "$$remote_ref" >/dev/null 2>&1 && \ + git -C "$$child_root" merge-base --is-ancestor "$$head" "$$remote_ref"; then \ + attach_branch_at_head "$$child_root" "$$branch"; \ + else \ + printf 'ERROR: %s: detached HEAD %s is not on the recorded gitlink and not contained in origin/%s; reconcile it yourself (setup never discards commits)\n' "$$child_path" "$$head" "$$branch" >&2; \ + exit 1; \ + fi; \ + current="$$branch"; \ + fi; \ + if ! git -C "$$child_root" merge-base --is-ancestor "$$gitlink" HEAD; then \ + printf 'ERROR: %s: branch %s diverges from recorded gitlink %s (setup never runs checkout/reset; advance or switch it yourself while keeping dirty)\n' "$$child_path" "$$branch" "$$gitlink" >&2; \ + exit 1; \ + fi; \ + if [ -f "$$child_root/.gitmodules" ]; then \ + nested_keys=$$(git -C "$$child_root" config -f .gitmodules --name-only --get-regexp '^submodule\..*\.path$$' || :); \ + for nested_key in $$nested_keys; do \ + nested_path=$$(git -C "$$child_root" config -f .gitmodules --get "$$nested_key"); \ + validate_submodule "$$child_root" "$$nested_path"; \ + done; \ + fi; \ + }; \ + for child_path in $$managed; do \ + validate_submodule "$$root" "$$child_path"; \ done - @uv pip check --python "$(PROJECT_ROOT)/.venv/bin/python" -else ifeq ($(MAKE_PROFILE),workspace-member) -ifeq ($(ATTACHED_MEMBER),Y) -_builtin_setup_environment: - @$(MAKE) --no-print-directory -C "$(RUNTIME_ROOT)" setup WHAT=environment -else -_builtin_setup_environment: - @uv sync --project "$(PROJECT_ROOT)" $(UV_SYNC_FLAGS) -endif + +_builtin_require_environment: + @if [ ! -x "$(RUNTIME_PYTHON)" ]; then \ + printf 'ERROR: missing environment interpreter %s; make setup creates it\n' "$(RUNTIME_PYTHON)" >&2; \ + exit 2; \ + fi + +# === SECTION: setup environment (managed) === +# Source: computed (MAKE_PROFILE routing) + operator contract (mro-e9j0.6 C7) +# Operator contract: setup PROVISIONS tooling only โ€” mise, venv, dependencies. +# It never generates, conforms, or mutates project code; `make gen` (APPLY=Y) +# is the single public conformance/generation surface. +# Every verb invokes setup, so it must be cheap when the tooling already +# matches the lock and must repair it when it does not. `uv sync --check` is +# that probe: it compares the live venv against the resolved lock and exits +# non-zero on any drift, so it can never report a broken environment as good. +# The venv is disposable and is rebuilt whenever it is missing; it is never +# cleared while present, because a concurrent lane may be running against it. +# Profile routing: workspace-member delegates the environment to the +# principal (the uv workspace venv lives at RUNTIME_ROOT); workspace-root and +# standalone build their own environment locally. +# The delegation only means something when the principal is another checkout. +# An isolated `git worktree` of a member has no superproject, so WORKSPACE_ROOT +# falls back to the worktree itself and RUNTIME_ROOT equals PROJECT_ROOT -- while +# MAKE_PROFILE stays workspace-member, because it is fixed at generation time. +# Delegating there re-entered Make on the same target, which Make treats as +# already satisfied: setup exited 0 having created nothing, and the next verb +# failed with "missing environment interpreter". Provision locally instead. +ifeq ($(MAKE_PROFILE),workspace-member) +_builtin_setup_environment: _builtin_setup_submodules + @if [ "$(RUNTIME_ROOT)" = "$(PROJECT_ROOT)" ]; then \ + $(SETUP_ENVIRONMENT_RECIPE); \ + else \ + $(MAKE) -C "$(RUNTIME_ROOT)" _builtin_setup_environment; \ + fi +else ifeq ($(MAKE_PROFILE),workspace-root) +_builtin_setup_environment: _builtin_setup_submodules + @$(SETUP_ENVIRONMENT_RECIPE) + @$(UV) pip check --python "$(RUNTIME_VENV)" else -_builtin_setup_environment: - @uv sync --project "$(PROJECT_ROOT)" $(UV_SYNC_FLAGS) +_builtin_setup_environment: _builtin_setup_submodules + @$(SETUP_ENVIRONMENT_RECIPE) endif +# End SECTION: setup environment -_builtin_deps_check: +_builtin_deps_check: _builtin_require_environment $(call _run_for_selected_projects,--check) _builtin_deps_lock: $(call _require_apply) $(call _run_for_selected_projects,) -_builtin_deps_upgrade: +_builtin_deps_upgrade: _builtin_require_environment $(call _require_apply) - $(call _run_for_selected_projects,--upgrade) + @dependency="$(strip $(DEPENDENCY))"; \ + if [ -n "$$dependency" ]; then \ + case "$$dependency" in \ + [-._]*|*[!A-Za-z0-9._-]*) \ + printf 'ERROR: DEPENDENCY must be one normalized distribution name\n' >&2; \ + exit 2 ;; \ + esac; \ + fi + $(call _run_for_selected_projects,$(if $(strip $(DEPENDENCY)),--upgrade-package "$(strip $(DEPENDENCY))",--upgrade)) + @set -eu; \ + selected="$(strip $(PROJECTS))"; \ + if [ -z "$$selected" ]; then selected="."; fi; \ + set --; \ + for project in $$selected; do set -- "$$@" --projects "$$project"; done; \ + $(PROJECT_FLEXT_INFRA) deps modernize --workspace "$(PROJECT_ROOT)" \ + --apply --rewrite-constraints --skip-check "$$@" + $(call _run_for_selected_projects,) _builtin_build_artifacts: - @uv build --project "$(PROJECT_ROOT)" + @$(UV) build --project "$(PROJECT_ROOT)" -_builtin_check_all: - @$(UV_RUN) ruff check --no-fix $(RUFF_PATHS) - @$(UV_RUN) ruff format --check $(RUFF_PATHS) - @$(UV_RUN) pyrefly check - @$(VALIDATE_MYPY_LIMITS); $(MYPY_BOUNDED) $(UV_RUN) python -m mypy $(MYPY_PATHS) || { $(REPORT_MYPY_FAILURE); exit $$code; } - @$(UV_RUN) pyright - @# NOTE (multi-agent, mro-j47u): Vulture reads its scope from generated pyproject. - @$(UV_RUN) python -m vulture +# `check` is read-only by contract: it never mutates the tree. Fixing is owned +# by `make fix APPLY=Y` and formatting by `make fmt APPLY=Y`, both run BEFORE +# check. APPLY here made the same tools run twice with conflicting intents, +# so it is rejected instead of silently honoured; FIX=1 became the `fix` verb. +_builtin_check_all: _builtin_require_environment + @set -eu; \ + gates="$(strip $(CHECK_GATES))"; \ + if [ -z "$$gates" ]; then gates="$$(printf '%s' '$(CHECK_GATES_DEFAULT)' | tr ' ' ',')"; fi; \ + gates="$$(printf '%s' "$$gates" | tr -d '[:space:]')"; \ + for gate in $$(printf '%s' "$$gates" | tr ',' ' '); do \ + case " $(CHECK_GATES_ALLOWED) " in *" $$gate "*) ;; \ + *) printf 'ERROR: unknown CHECK_GATES value: %s (allowed: %s)\n' "$$gate" "$(CHECK_GATES_ALLOWED)" >&2; exit 2 ;; \ + esac; \ + done; \ + $(PROJECT_FLEXT_INFRA) check run --workspace "$(PROJECT_ROOT)" --gates "$$gates" --projects . -_builtin_test_all: - @$(UV_RUN) python -m pytest "$(PROJECT_ROOT)/tests" +_builtin_test_all: _builtin_require_environment + @$(PYTEST_BOUNDED) $(UV_RUN) python -m flext_infra._pytest_entry -_builtin_format_check: - @$(UV_RUN) ruff check --no-fix $(RUFF_PATHS) +# One tool, one verb: `fmt` only formats, `check` only lints (--no-fix) and +# `fix` owns the mutating lint pass. Running ruff twice per gate was the +# duplication this split removes. +_builtin_fmt_check: _builtin_require_environment @$(UV_RUN) ruff format --check $(RUFF_PATHS) -_builtin_format_apply: +_builtin_fmt_all: _builtin_require_environment $(call _require_apply) - @$(UV_RUN) ruff check --fix $(RUFF_PATHS) @$(UV_RUN) ruff format $(RUFF_PATHS) -_builtin_run_default: +_builtin_fix_check: + @printf 'ERROR: make fix requires APPLY=Y\n' >&2; exit 2 + +_builtin_fix_all: _builtin_require_environment + $(call _require_apply) + @$(UV_RUN) ruff check --fix $(RUFF_PATHS) + + +_builtin_run_default: _builtin_require_environment @$(UV_RUN) $(PROJECT_NAME) $(ARGS) -_builtin_status_diagnostics: +_builtin_status_diagnostics: _builtin_require_environment @printf 'profile=%s\nattached=%s\nproject=%s\nruntime=%s\n' \ '$(MAKE_PROFILE)' '$(ATTACHED_MEMBER)' '$(PROJECT_ROOT)' '$(RUNTIME_ROOT)' - @uv --version - @uv lock --project "$(PROJECT_ROOT)" --check - @if [ -x "$(RUNTIME_ROOT)/.venv/bin/python" ]; then \ - uv pip check --python "$(RUNTIME_ROOT)/.venv/bin/python"; \ + @$(UV) --version + @$(UV) lock --project "$(PROJECT_ROOT)" --check + @if [ -x "$(RUNTIME_PYTHON)" ]; then \ + $(UV) pip check --python "$(RUNTIME_VENV)"; \ fi @git -C "$(PROJECT_ROOT)" status --short +_builtin_docs_all: + @set -eu; \ + for action in $(DOCS_ACTIONS); do \ + case "$$action" in generate|fix) mode=$(if $(filter Y,$(APPLY)),--apply,--check) ;; *) mode= ;; esac; \ + $(PROJECT_FLEXT_INFRA) docs "$$action" --workspace "$(PROJECT_ROOT)" --output-dir "$(PROJECT_ROOT)/.reports/docs" $$mode $(DOCS_PROJECT_ARGS); \ + done + + +_builtin_docs_generate: + @$(PROJECT_FLEXT_INFRA) docs generate --workspace "$(PROJECT_ROOT)" --output-dir "$(PROJECT_ROOT)/.reports/docs" $(if $(filter Y,$(APPLY)),--apply,--check) $(DOCS_PROJECT_ARGS) + + +_builtin_docs_fix: + @$(PROJECT_FLEXT_INFRA) docs fix --workspace "$(PROJECT_ROOT)" --output-dir "$(PROJECT_ROOT)/.reports/docs" $(if $(filter Y,$(APPLY)),--apply,--check) $(DOCS_PROJECT_ARGS) + + +_builtin_docs_audit: + @$(PROJECT_FLEXT_INFRA) docs audit --workspace "$(PROJECT_ROOT)" --output-dir "$(PROJECT_ROOT)/.reports/docs" $(DOCS_PROJECT_ARGS) + + +_builtin_docs_build: + @$(PROJECT_FLEXT_INFRA) docs build --workspace "$(PROJECT_ROOT)" --output-dir "$(PROJECT_ROOT)/.reports/docs" $(DOCS_PROJECT_ARGS) + + +_builtin_docs_validate: + @$(PROJECT_FLEXT_INFRA) docs validate --workspace "$(PROJECT_ROOT)" --output-dir "$(PROJECT_ROOT)/.reports/docs" $(DOCS_PROJECT_ARGS) + -_builtin_docs_check: - @test -s "$(PROJECT_ROOT)/README.md" _builtin_clean_generated: $(call _require_apply) @@ -293,15 +844,42 @@ _builtin_clean_generated: @rm -rf "$(PROJECT_ROOT)/build" "$(PROJECT_ROOT)/dist" "$(PROJECT_ROOT)/htmlcov" @rm -f "$(PROJECT_ROOT)/.coverage" - -_builtin_release_status: - @uv lock --project "$(PROJECT_ROOT)" --check +_builtin_release_status: _builtin_require_environment + @$(UV) lock --project "$(PROJECT_ROOT)" --check @git -C "$(PROJECT_ROOT)" diff --quiet @git -C "$(PROJECT_ROOT)" diff --cached --quiet -_builtin_codegen_check: - @$(UV_RUN) python -m flext_infra codegen conform --root "$(PROJECT_ROOT)" --scope "$(CODEGEN_SCOPE)" --mode check +# Every command here writes to the SAME root, derived from the invocation +# point. `deps modernize`/`extra-paths` used to receive WORKSPACE_ROOT while +# `conform` received PROJECT_ROOT, so a gen run inside one member rewrote the +# pyproject of ~30 siblings and left each dirty. Because gen runs inside check +# and check runs in the pre-commit hook, one commit in any lane dirtied every +# sibling -- the "workspace changed during serialized Make check" abort. It +# also kept the fixed point out of reach: each run rewrote the siblings, so +# the next run found a difference again. At the workspace root PROJECT_ROOT is +# already the workspace, so fan-out survives exactly where it belongs. +_builtin_gen_check: _builtin_require_environment + @$(PROJECT_FLEXT_INFRA) codegen conform --root "$(PROJECT_ROOT)" --scope "$(CODEGEN_SCOPE)" --mode check + @$(PROJECT_FLEXT_INFRA) deps modernize --workspace "$(PROJECT_ROOT)" --check + @$(PROJECT_FLEXT_INFRA) deps extra-paths --workspace "$(PROJECT_ROOT)" --check + +_builtin_gen_all: _builtin_require_environment + $(call _require_apply) + @$(PROJECT_FLEXT_INFRA) codegen conform --root "$(PROJECT_ROOT)" --scope "$(CODEGEN_SCOPE)" --mode apply + @$(PROJECT_FLEXT_INFRA) deps modernize --workspace "$(PROJECT_ROOT)" --apply + @$(PROJECT_FLEXT_INFRA) deps extra-paths --workspace "$(PROJECT_ROOT)" --apply + +_builtin_worktree_list: + @$(PROJECT_FLEXT_INFRA) workspace worktree --workspace "$(WORKSPACE)" --operation list + +_builtin_worktree_add: + $(call _require_apply) + @$(PROJECT_FLEXT_INFRA) workspace worktree --workspace "$(WORKSPACE)" --operation add --branch "$(BRANCH)" --base "$(BASE)" --apply + +_builtin_worktree_update: + $(call _require_apply) + @$(PROJECT_FLEXT_INFRA) workspace worktree --workspace "$(WORKSPACE)" --operation update --branch "$(BRANCH)" --base "$(BASE)" --apply -_builtin_codegen_apply: +_builtin_worktree_remove: $(call _require_apply) - @$(UV_RUN) python -m flext_infra codegen conform --root "$(PROJECT_ROOT)" --scope "$(CODEGEN_SCOPE)" --mode apply + @$(PROJECT_FLEXT_INFRA) workspace worktree --workspace "$(WORKSPACE)" --operation remove --branch "$(BRANCH)" --apply diff --git a/examples/_models/ex00.py b/examples/_models/ex00.py index 2149f3379..9215d935c 100644 --- a/examples/_models/ex00.py +++ b/examples/_models/ex00.py @@ -20,11 +20,12 @@ class UserProfile(m.Entity): c.Status.ACTIVE ) - def activate(self) -> p.Result[None]: + def activate(self) -> p.Result[bool]: """Activate user once.""" if self.status == c.Status.ACTIVE: - return r[None].fail("Already active") - return r[None].ok(None) + return r[bool].fail("Already active") + self.status = c.Status.ACTIVE + return r[bool].ok(True) class UserInput(m.Value): """Raw user input model.""" diff --git a/examples/ex_04_flext_dispatcher.py b/examples/ex_04_flext_dispatcher.py index db19d6c81..b7f9983bf 100644 --- a/examples/ex_04_flext_dispatcher.py +++ b/examples/ex_04_flext_dispatcher.py @@ -15,7 +15,7 @@ class _CreateUserHandler: message_type = m.Examples.CreateUser - def handle(self, message: p.Routable) -> p.Result[str]: + def handle(self, message: p.Routable) -> r[str]: if not isinstance(message, m.Examples.CreateUser): return r[str].fail("unexpected_message") return r[str].ok(f"created:{message.username}") @@ -26,7 +26,7 @@ class _GetUserHandler: def dispatch_message( self, message: p.Routable, operation: str = "dispatch" - ) -> p.Result[str]: + ) -> r[str]: if not isinstance(message, m.Examples.GetUser): return r[str].fail(f"{operation}:unexpected_message") return r[str].ok(f"active:{message.username}") @@ -35,7 +35,7 @@ def dispatch_message( class _DeleteUserHandler: message_type = m.Examples.DeleteUser - def execute(self, message: p.Routable) -> p.Result[str]: + def execute(self, message: p.Routable) -> r[str]: if not isinstance(message, m.Examples.DeleteUser): return r[str].fail("unexpected_message") return r[str].ok(f"deleted:{message.username}") @@ -45,7 +45,7 @@ class _AutoFallbackHandler: def can_handle(self, message_type: type) -> bool: return message_type is m.Examples.UnknownQuery - def handle(self, message: p.Routable) -> p.Result[str]: + def handle(self, message: p.Routable) -> r[str]: if not isinstance(message, m.Examples.UnknownQuery): return r[str].fail("unexpected_message") return r[str].ok("auto:fallback") @@ -57,7 +57,7 @@ class _EventSubscriber: def __init__(self) -> None: self.events: list[str] = [] - def handle(self, message: p.Routable) -> p.Result[bool]: + def handle(self, message: p.Routable) -> r[bool]: if not isinstance(message, m.Examples.UserCreated): return r[bool].fail("unexpected_message") self.events.append(message.username) @@ -70,7 +70,7 @@ class _AuditSubscriber: def __init__(self) -> None: self.events: list[str] = [] - def handle(self, message: p.Routable) -> p.Result[bool]: + def handle(self, message: p.Routable) -> r[bool]: if not isinstance(message, m.Examples.UserCreated): return r[bool].fail("unexpected_message") self.events.append(f"audit:{message.username}") @@ -80,7 +80,7 @@ def handle(self, message: p.Routable) -> p.Result[bool]: class _PingHandler: message_type = m.Examples.Ping - def __call__(self, message: p.Routable) -> p.Result[str]: + def __call__(self, message: p.Routable) -> r[str]: if not isinstance(message, m.Examples.Ping): return r[str].fail("unexpected_message") return r[str].ok(f"pong:{message.value}") @@ -89,13 +89,13 @@ def __call__(self, message: p.Routable) -> p.Result[str]: class _FailingDeleteHandler: message_type = m.Examples.FailingDelete - def __call__(self, message: p.Routable) -> p.Result[str]: + def __call__(self, message: p.Routable) -> r[str]: if not isinstance(message, m.Examples.FailingDelete): return r[str].fail("unexpected_message") return r[str].fail("delete_failed") -def _no_route_handler(message: p.Routable) -> p.Result[str]: +def _no_route_handler(message: p.Routable) -> r[str]: _ = message return r[str].ok("no-route") @@ -114,7 +114,7 @@ def build_dispatcher() -> p.Dispatcher: return dispatcher @classmethod - def run(cls) -> p.Result[str]: + def run(cls) -> r[str]: """Dispatch a real ping command through the public dispatcher.""" dispatcher = cls.build_dispatcher() result = dispatcher.dispatch(m.Examples.Ping(value="dispatcher-example")) diff --git a/examples/typings.py b/examples/typings.py index 3f63f5b5e..d7101e750 100644 --- a/examples/typings.py +++ b/examples/typings.py @@ -15,8 +15,8 @@ class Examples: t.JsonPayload | t.ScalarOrModel | m.ConfigMap - | p.ResultLike[t.JsonPayload] - | p.ResultLike[t.JsonValue] + | p.Result[t.JsonPayload] + | p.Result[t.JsonValue] ) diff --git a/pyproject.toml b/pyproject.toml index a4c59867c..f481f00a1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,101 +1,11 @@ +# @flext-managed: continuous +# @flext-regenerate: make deps WHAT=upgrade APPLY=Y +# @flext-ssot: flext-infra/src/flext_infra/_constants/deps.py +# @flext-maintenance: do not edit managed sections; edit the SSOT # [MANAGED] FLEXT pyproject standardization # Sections with [MANAGED] are enforced by flext_infra.deps.modernizer. -# Run `make mod` to regenerate all managed pyproject sections. # Sections with [CUSTOM] are project-specific extension points. -# [MANAGED] build system -[build-system] -build-backend = "hatchling.build" -requires = ["hatchling"] - -[dependency-groups] -codegen = [ - "flext-infra @ git+https://github.com/flext-sh/flext-infra.git@0.12.0-dev", -] -dev = [ - "autoflake>=2.3.1", - "bandit>=1.8", - "black>=25.1", - "blacken-docs>=1.19", - "codespell>=2.3", - "deptry>=0.23", - "detect-secrets>=1.5.0", - "django-stubs>=5.2.2", - "factory-boy>=3.3.1", - "faker>=37.4", - "flext-cli @ git+https://github.com/flext-sh/flext-cli.git@0.12.0-dev", - "flext-infra @ git+https://github.com/flext-sh/flext-infra.git@0.12.0-dev", - "flext-tests @ git+https://github.com/flext-sh/flext-tests.git@0.12.0-dev", - "hypothesis>=6.125", - "interrogate>=1.7.0", - "isort>=6.0.1", - "libcst>=1.8.6", - "matplotlib-stubs>=0.3", - "mkdocs>=1.6", - "mkdocs-awesome-pages-plugin>=2.9", - "mkdocs-encryptcontent-plugin>=2", - "mkdocs-exclude>=1.0.2", - "mkdocs-get-deps>=0.2", - "mkdocs-git-revision-date-localized-plugin>=1.2", - "mkdocs-literate-nav>=0.6", - "mkdocs-macros-plugin>=1", - "mkdocs-material>=9.5", - "mkdocs-material-extensions>=1.3.1", - "mkdocs-mermaid2-plugin>=1.1", - "mkdocs-minify-plugin>=0.7", - "mkdocs-print-site-plugin>=0.8", - "mkdocs-redirects>=1.2", - "mkdocs-section-index>=0.3.12", - "mkdocs-versioning>=0.2", - "mkdocstrings>=0.24", - "mkdocstrings-python>=1.7", - "mypy>=1.20.2", - "pip-audit>=2.7.3", - "pre-commit>=4.6.0", - "pylint>=3.3", - "pyrefly>=1.1.1,<1.2.0", - "pytest>=8.4", - "pytest-benchmark>=5.1", - "pytest-clarity>=1.0.1", - "pytest-codeblocks>=0.17.0", - "pytest-cov>=6.2", - "pytest-deadfixtures>=2.2.1", - "pytest-env>=1.1.5", - "pytest-markdown-docs>=0.9.2", - "pytest-mock>=3.14", - "pytest-randomly>=3.16", - "pytest-sugar>=1", - "pytest-timeout>=2.4", - "pytest-xdist>=3.8", - "pyupgrade>=3.19", - "radon>=6.0.1", - "rope>=1.14.0", - "ruff>=0.12.3", - "types-cachetools>=6.2", - "types-cffi>=2.0", - "types-click>=7.1", - "types-defusedxml>=0.7.0.20260408", - "types-docker>=7.1", - "types-flask>=1.1.6", - "types-jsonschema>=4.26", - "types-ldap3>=2.9.13.20250622", - "types-paramiko>=4.0", - "types-protobuf>=6.30.2.20250703", - "types-psutil>=7", - "types-psycopg2>=2.9.21.20250718", - "types-pyasn1>=0.6", - "types-pyopenssl>=24.1", - "types-python-dateutil>=2.9", - "types-pyyaml>=6.0.12.20260518", - "types-redis>=4.6", - "types-requests>=2.32.4", - "types-setuptools>=80.9", - "types-tabulate>=0.10", - "types-toml>=0.10.8.20240310", - "vulture>=2.16", - "yamlfix>=1.19.1,<2", -] - # [CUSTOM] project metadata [project] classifiers = [ @@ -125,7 +35,6 @@ dependencies = [ "python-dotenv>=1.2.2", "python-on-whales>=0.81.0", "pyyaml>=6.0.3", - "returns>=0.27.0", "rope>=1.14.0", "structlog>=25.5.0", "tomlkit>=0.14.0", @@ -155,11 +64,18 @@ email = "team@flext.sh" name = "FLEXT Team" # [MANAGED] consolidated development dependencies +[project.optional-dependencies] + [project.urls] Documentation = "https://github.com/flext-sh/flext-core/blob/main/README.md" Homepage = "https://github.com/flext-sh/flext-core" Repository = "https://github.com/flext-sh/flext-core" +# [MANAGED] build system +[build-system] +build-backend = "hatchling.build" +requires = ["hatchling"] + [tool.bandit] severity = "MEDIUM" skips = ["B101"] @@ -184,6 +100,7 @@ skip_covered = false [tool.coverage.run] omit = ["*/dependency_injector/providers.pyx"] +source = ["src"] # [MANAGED] deptry [tool.deptry] @@ -239,10 +156,16 @@ extra_checks = true follow_imports = "normal" ignore_missing_imports = true local_partial_types = true -mypy_path = [".", "src"] +mypy_path = [ + "src", + ".", + "../flext-cli/src", + "../flext-infra/src", + "../flext-tests/src", +] namespace_packages = true no_implicit_reexport = true -plugins = ["pydantic.mypy", "returns.contrib.mypy.returns_plugin"] +plugins = ["pydantic.mypy"] python_version = "3.13" strict_equality = true warn_redundant_casts = true @@ -290,11 +213,13 @@ project-includes = [ "src/**/*.py*", "tests/**/*.py*", ] -python-interpreter-path = "../.venv/bin/python" python-version = "3.13" search-path = [ - ".", - "src", + "src", + ".", + "../flext-cli/src", + "../flext-infra/src", + "../flext-tests/src", ] use-ignore-files = false @@ -412,7 +337,7 @@ exclude = [ ".venv", "vendor", ] -extraPaths = [".", "src"] +extraPaths = ["src", "."] include = ["examples", "scripts", "src", "tests"] pythonPlatform = "Linux" pythonVersion = "3.13" @@ -445,6 +370,8 @@ reportUnusedFunction = "error" reportUnusedImport = "error" reportUnusedVariable = "error" typeCheckingMode = "strict" +venv = ".venv" +venvPath = ".." [[tool.pyright.executionEnvironments]] extraPaths = ["src"] @@ -520,10 +447,11 @@ root = "scripts" # [MANAGED] pytest [tool.pytest.ini_options] addopts = [ + "--benchmark-disable", "--durations=10", "--markdown-docs", "--strict-markers", - "--timeout=10", + "--timeout=30", ] enable_assertion_pass_hook = true filterwarnings = [ @@ -559,7 +487,10 @@ mi_min = "B" # [MANAGED] ruff [tool.ruff] exclude = [ + "**/.archive/**", "**/__pyrefly_virtual__/**", + ".archive", + ".archive/**", ".git", ".mypy_cache", ".pytest_cache", @@ -697,7 +628,8 @@ split-on-trailing-comma = false "undocumented-public-function", "undocumented-public-method", ] -"scripts/cmd/**/*.py" = ["invalid-module-name"] +"scripts/cmd/**/*.py" = ["invalid-module-name", "shebang-not-executable"] +"**/examples/**/*.py" = ["invalid-module-name", "shebang-not-executable"] # [MANAGED] tomlsort [tool.tomlsort] @@ -707,7 +639,24 @@ sort_first = ["build-system", "dependency-groups", "project", "tool"] [tool.uv] link-mode = "copy" -required-version = "==0.11.32" + +[[tool.uv.exclude-dependencies]] +dependencies = ["flext-core"] + +[tool.uv.exclude-dependencies.package] +name = "flext-infra" + +[[tool.uv.exclude-dependencies]] +dependencies = ["flext-core"] + +[tool.uv.exclude-dependencies.package] +name = "flext-tests" + +[[tool.uv.exclude-dependencies]] +dependencies = ["flext-core"] + +[tool.uv.exclude-dependencies.package] +name = "flext-cli" [tool.vulture] exclude = ["*/_protocols/*"] @@ -722,3 +671,95 @@ line_length = 88 preserve_quotes = true section_whitelines = 1 whitelines = 1 + +[dependency-groups] +codegen = [ + "flext-infra @ git+https://github.com/flext-sh/flext-infra.git@0.12.0-dev", +] +dev = [ + "actionlint-py>=1.7.12.24", + "autoflake>=2.3.1", + "bandit>=1.8", + "black>=25.1", + "blacken-docs>=1.19", + "codespell>=2.3", + "deptry>=0.23", + "detect-secrets>=1.5.0", + "django-stubs>=5.2.2", + "factory-boy>=3.3.1", + "faker>=37.4", + "flext-cli @ git+https://github.com/flext-sh/flext-cli.git@0.12.0-dev", + "flext-infra @ git+https://github.com/flext-sh/flext-infra.git@0.12.0-dev", + "flext-tests @ git+https://github.com/flext-sh/flext-tests.git@0.12.0-dev", + "hypothesis>=6.125", + "interrogate>=1.7.0", + "isort>=6.0.1", + "libcst>=1.8.6", + "matplotlib-stubs>=0.3", + "mkdocs>=1.6", + "mkdocs-awesome-pages-plugin>=2.9", + "mkdocs-encryptcontent-plugin>=2", + "mkdocs-exclude>=1.0.2", + "mkdocs-get-deps>=0.2", + "mkdocs-git-revision-date-localized-plugin>=1.2", + "mkdocs-literate-nav>=0.6", + "mkdocs-macros-plugin>=1", + "mkdocs-material>=9.5", + "mkdocs-material-extensions>=1.3.1", + "mkdocs-mermaid2-plugin>=1.1", + "mkdocs-minify-plugin>=0.7", + "mkdocs-print-site-plugin>=0.8", + "mkdocs-redirects>=1.2", + "mkdocs-section-index>=0.3.12", + "mkdocs-versioning>=0.2", + "mkdocstrings>=0.24", + "mkdocstrings-python>=1.7", + "mypy>=1.20.2", + "pip-audit>=2.7.3", + "pre-commit>=4.6.0", + "pylint>=3.3", + "pyrefly>=1.1.1", + "pyright>=1.1.411", + "pytest>=8.4", + "pytest-benchmark>=5.1", + "pytest-clarity>=1.0.1", + "pytest-codeblocks>=0.17.0", + "pytest-cov>=6.2", + "pytest-deadfixtures>=2.2.1", + "pytest-env>=1.1.5", + "pytest-markdown-docs>=0.9.2", + "pytest-mock>=3.14", + "pytest-randomly>=3.16", + "pytest-sugar>=1", + "pytest-timeout>=2.4", + "pytest-xdist>=3.8", + "pyupgrade>=3.19", + "radon>=6.0.1", + "rope>=1.14.0", + "ruff>=0.12.3", + "rumdl>=0.2.46", + "types-cachetools>=6.2", + "types-cffi>=2.0", + "types-click>=7.1", + "types-defusedxml>=0.7.0.20260408", + "types-docker>=7.1", + "types-flask>=1.1.6", + "types-jsonschema>=4.26", + "types-ldap3>=2.9.13.20250622", + "types-openpyxl>=3.1.5.20260518", + "types-paramiko>=4.0", + "types-protobuf>=6.30.2.20250703", + "types-psutil>=7", + "types-psycopg2>=2.9.21.20250718", + "types-pyasn1>=0.6", + "types-pyopenssl>=24.1", + "types-python-dateutil>=2.9", + "types-pyyaml>=6.0.12.20260518", + "types-redis>=4.6", + "types-requests>=2.32.4", + "types-setuptools>=80.9", + "types-tabulate>=0.10", + "types-toml>=0.10.8.20240310", + "vulture>=2.16", + "yamlfix>=1.19.1", +] diff --git a/sgconfig.yml b/sgconfig.yml new file mode 100644 index 000000000..0ffe8d645 --- /dev/null +++ b/sgconfig.yml @@ -0,0 +1,11 @@ +# @generated by flext_infra codegen from config/codegen.yaml -- DO NOT EDIT. +# Run `make gen` to regenerate. Change the rule and fixture directories in the +# codegen SSOT (Infra.codegen.sgconfig), never in this file. +# +# ast-grep project config. Rule directories are the domain declarations; +# fixtures and snapshots only validate them, they never define the policy. +ruleDirs: + - ast-grep-rules +testConfigs: + - testDir: ast-grep-rule-tests + diff --git a/src/flext_core/__version__.py b/src/flext_core/__version__.py index 9891f91bb..d752acb04 100644 --- a/src/flext_core/__version__.py +++ b/src/flext_core/__version__.py @@ -47,11 +47,12 @@ def _resolve_homepage(package_metadata: PackageMetadata) -> str: if homepage := package_metadata.get("Home-Page", ""): return homepage for project_url in package_metadata.get_all("Project-URL") or (): - label, separator, url = project_url.partition(",") + project_url_text = str(project_url) + label, separator, url = project_url_text.partition(",") if label.strip().casefold() != "homepage": continue if not separator or not url.strip(): - msg = f"invalid Homepage project URL metadata: {project_url!r}" + msg = f"invalid Homepage project URL metadata: {project_url_text!r}" raise ValueError(msg) return url.strip() return "" diff --git a/src/flext_core/_decorators/_railway.py b/src/flext_core/_decorators/_railway.py index 27740894b..25686fc38 100644 --- a/src/flext_core/_decorators/_railway.py +++ b/src/flext_core/_decorators/_railway.py @@ -10,13 +10,13 @@ from functools import wraps from typing import TYPE_CHECKING +from flext_core import r from flext_core._constants.errors import FlextConstantsErrors as ce from flext_core._constants.infrastructure import FlextConstantsInfrastructure as ci from flext_core._constants.validation import FlextConstantsValidation as cv from flext_core._decorators._logging import FlextDecoratorsLogging from flext_core._exceptions.types import FlextExceptionsTypes as et from flext_core._models.settings import FlextModelsSettings as ms -from flext_core.result import FlextResult as r if TYPE_CHECKING: from collections.abc import Callable diff --git a/src/flext_core/_exceptions/_factories_parts/flextexceptionsfactories_part_01.py b/src/flext_core/_exceptions/_factories_parts/flextexceptionsfactories_part_01.py index ceb04b18c..f31135c89 100644 --- a/src/flext_core/_exceptions/_factories_parts/flextexceptionsfactories_part_01.py +++ b/src/flext_core/_exceptions/_factories_parts/flextexceptionsfactories_part_01.py @@ -17,7 +17,7 @@ TExceptionParams = TypeVar("TExceptionParams", bound=mp.BaseModel) if TYPE_CHECKING: - from flext_core.result import FlextResult + from flext_core import r class FlextExceptionsFactories: @@ -28,13 +28,13 @@ class FlextExceptionsFactories: @staticmethod def _result_type[TValue]( - result_type: type[FlextResult[TValue]] | None = None, - ) -> type[FlextResult[TValue]]: + result_type: type[r[TValue]] | None = None, + ) -> type[r[TValue]]: """Resolve FlextResult lazily to avoid runtime import cycles.""" if result_type is not None: return result_type result_module = import_module("flext_core") - result_cls: type[FlextResult[TValue]] = result_module.FlextResult + result_cls: type[r[TValue]] = result_module.FlextResult return result_cls @staticmethod @@ -87,7 +87,7 @@ def _fail_result[TResult]( *, options: m.ExceptionFactoryOptions | None = None, default_error_code: str, - result_type: type[FlextResult[TResult]] | None = None, + result_type: type[r[TResult]] | None = None, ) -> p.Result[TResult]: options, error = FlextExceptionsFactories._resolve_options(options) return FlextExceptionsFactories._result_type(result_type).fail( @@ -105,7 +105,7 @@ def fail_operation[TResult]( exc: Exception | str | None = None, *, error_code: str | None = None, - result_type: type[FlextResult[TResult]] | None = None, + result_type: type[r[TResult]] | None = None, ) -> p.Result[TResult]: """Return r[T].fail with a canonical operation-error message. @@ -134,7 +134,7 @@ def fail_not_found[TResult]( resource_id: str, *, error_code: str | None = None, - result_type: type[FlextResult[TResult]] | None = None, + result_type: type[r[TResult]] | None = None, ) -> p.Result[TResult]: """Return r[T].fail with a canonical not-found message. diff --git a/src/flext_core/_exceptions/_factories_parts/flextexceptionsfactories_part_02.py b/src/flext_core/_exceptions/_factories_parts/flextexceptionsfactories_part_02.py index b074262f2..6c7b70d27 100644 --- a/src/flext_core/_exceptions/_factories_parts/flextexceptionsfactories_part_02.py +++ b/src/flext_core/_exceptions/_factories_parts/flextexceptionsfactories_part_02.py @@ -17,7 +17,7 @@ ) if TYPE_CHECKING: - from flext_core.result import FlextResult + from flext_core import r class FlextExceptionsFactories(FlextExceptionsFactoriesPart01): @@ -27,7 +27,7 @@ def fail_type_mismatch[TResult]( actual: str | None = None, *, error_code: str | None = None, - result_type: type[FlextResult[TResult]] | None = None, + result_type: type[r[TResult]] | None = None, ) -> p.Result[TResult]: """Return r[T].fail with a canonical type-mismatch message. @@ -69,7 +69,7 @@ def fail_validation[TResult]( *, error_code: str | None = None, error: Exception | str | None = None, - result_type: type[FlextResult[TResult]] | None = None, + result_type: type[r[TResult]] | None = None, ) -> p.Result[TResult]: """Return r[T].fail with a canonical validation-failed message. @@ -117,7 +117,7 @@ def fail_config_error[TResult]( config_source: str | None = None, *, options: m.ExceptionFactoryOptions | None = None, - result_type: type[FlextResult[TResult]] | None = None, + result_type: type[r[TResult]] | None = None, ) -> p.Result[TResult]: """Return r[T].fail with a canonical configuration-error message. diff --git a/src/flext_core/_exceptions/_factories_parts/flextexceptionsfactories_part_03.py b/src/flext_core/_exceptions/_factories_parts/flextexceptionsfactories_part_03.py index 6558efb8f..2599c147f 100644 --- a/src/flext_core/_exceptions/_factories_parts/flextexceptionsfactories_part_03.py +++ b/src/flext_core/_exceptions/_factories_parts/flextexceptionsfactories_part_03.py @@ -16,7 +16,7 @@ ) if TYPE_CHECKING: - from flext_core.result import FlextResult + from flext_core import r class FlextExceptionsFactories(FlextExceptionsFactoriesPart02): @@ -26,7 +26,7 @@ def fail_connection[TResult]( *, params: m.ConnectionErrorParams | None = None, options: m.ExceptionFactoryOptions | None = None, - result_type: type[FlextResult[TResult]] | None = None, + result_type: type[r[TResult]] | None = None, ) -> p.Result[TResult]: """Return r[T].fail with a canonical connection-error message. @@ -58,7 +58,7 @@ def fail_timeout[TResult]( operation: str | None = None, *, error_code: str | None = None, - result_type: type[FlextResult[TResult]] | None = None, + result_type: type[r[TResult]] | None = None, ) -> p.Result[TResult]: """Return r[T].fail with a canonical timeout message. @@ -87,7 +87,7 @@ def fail_auth[TResult]( user_id: str | None = None, *, options: m.ExceptionFactoryOptions | None = None, - result_type: type[FlextResult[TResult]] | None = None, + result_type: type[r[TResult]] | None = None, ) -> p.Result[TResult]: """Return r[T].fail with a canonical authentication-error message. @@ -116,7 +116,7 @@ def fail_authz[TResult]( permission: str | None = None, *, options: m.ExceptionFactoryOptions | None = None, - result_type: type[FlextResult[TResult]] | None = None, + result_type: type[r[TResult]] | None = None, ) -> p.Result[TResult]: """Return r[T].fail with a canonical authorization-error message. diff --git a/src/flext_core/_exceptions/_factories_parts/flextexceptionsfactories_part_04.py b/src/flext_core/_exceptions/_factories_parts/flextexceptionsfactories_part_04.py index 0fab48d16..bb0f02d11 100644 --- a/src/flext_core/_exceptions/_factories_parts/flextexceptionsfactories_part_04.py +++ b/src/flext_core/_exceptions/_factories_parts/flextexceptionsfactories_part_04.py @@ -16,7 +16,7 @@ ) if TYPE_CHECKING: - from flext_core.result import FlextResult + from flext_core import r class FlextExceptionsFactories(FlextExceptionsFactoriesPart03): @@ -27,7 +27,7 @@ def fail_conflict[TResult]( reason: str | None = None, *, options: m.ExceptionFactoryOptions | None = None, - result_type: type[FlextResult[TResult]] | None = None, + result_type: type[r[TResult]] | None = None, ) -> p.Result[TResult]: """Return r[T].fail with a canonical conflict message. diff --git a/src/flext_core/_models/_context/__scope_parts/flextmodelscontextscope_part_03.py b/src/flext_core/_models/_context/__scope_parts/flextmodelscontextscope_part_03.py index 3f016c1bc..6b6023145 100644 --- a/src/flext_core/_models/_context/__scope_parts/flextmodelscontextscope_part_03.py +++ b/src/flext_core/_models/_context/__scope_parts/flextmodelscontextscope_part_03.py @@ -29,7 +29,7 @@ class ContextContainerState(FlextModelsBase.ArbitraryTypesModel): ), ] = None - @mp.computed_field() + @mp.computed_field def configured(self) -> bool: """Whether a container is configured for service access.""" return self.container is not None diff --git a/src/flext_core/_models/errors.py b/src/flext_core/_models/errors.py index 93cbba81f..37400dd3f 100644 --- a/src/flext_core/_models/errors.py +++ b/src/flext_core/_models/errors.py @@ -38,7 +38,7 @@ class ExceptionMetricsSnapshot(m.StrictModel): mp.Field(description="Number of unique exception types recorded."), ] = 0 - @up.computed_field() + @up.computed_field @property def has_exceptions(self) -> bool: """Whether the metrics snapshot contains recorded exceptions.""" @@ -63,19 +63,19 @@ class ExceptionMetricsState(m.StrictModel): mp.Field(description="Recorded counts keyed by exception type name."), ] = mp.Field(default_factory=lambda: MappingProxyType({})) - @up.computed_field() + @up.computed_field @property def total_exceptions(self) -> int: """Total recorded exception occurrences.""" return sum(self.exception_counts.values(), 0) - @up.computed_field() + @up.computed_field @property def unique_exception_types(self) -> int: """Number of unique exception types recorded.""" return len(self.exception_counts) - @up.computed_field() + @up.computed_field @property def exception_counts_summary(self) -> str: """Human-readable summary for logs and diagnostics.""" diff --git a/src/flext_core/_models/handler.py b/src/flext_core/_models/handler.py index f05bea683..335d5a846 100644 --- a/src/flext_core/_models/handler.py +++ b/src/flext_core/_models/handler.py @@ -107,7 +107,7 @@ class ExecutionContext(m.ArbitraryTypesModel): ), ] = mp.Field(default_factory=lambda: mc.Dict(root={})) - @up.computed_field() + @up.computed_field @property def execution_time_ms(self) -> float: """Elapsed execution time in milliseconds (0 until started).""" @@ -128,13 +128,13 @@ class HandlerRuntimeState(m.ArbitraryTypesModel): mp.Field(description="Stack of nested execution contexts."), ] = mp.Field(default_factory=list) - @mp.computed_field() + @mp.computed_field @property def handler_name(self) -> str: """Active handler name taken from the execution context.""" return self.execution_context.handler_name - @mp.computed_field() + @mp.computed_field @property def handler_mode(self) -> c.HandlerType: """Active handler mode taken from the execution context.""" @@ -167,6 +167,14 @@ class DecoratorConfig(m.ArbitraryTypesModel): middleware: Annotated[ t.SequenceOf[type[p.Middleware]], mp.Field(description="Middleware types to apply to this handler"), + mp.PlainSerializer( + lambda value: [ + f"{middleware_type.__module__}.{middleware_type.__qualname__}" + for middleware_type in value + ], + return_type=list[str], + when_used="always", + ), ] = mp.Field(default_factory=tuple) class CombinedRailwayOptions(m.ImmutableValueModel): diff --git a/src/flext_core/_models/pydantic.py b/src/flext_core/_models/pydantic.py index c762e2a89..4e743c42b 100644 --- a/src/flext_core/_models/pydantic.py +++ b/src/flext_core/_models/pydantic.py @@ -121,7 +121,10 @@ class RootModel[RootValueT](PydanticRootModel[RootValueT]): # function class attribute called through the facade (mixins.py:59 error). PrivateAttr = staticmethod(PrivateAttr) SkipValidation = SkipValidation - computed_field = computed_field + # Same unwrapped-class-attribute problem as PrivateAttr above: pyright + # binds the bare decorator through the facade and infers the facade type + # for every decorated property (reportIndexIssue on real consumers). + computed_field = staticmethod(computed_field) field_validator = field_validator # Annotation validators diff --git a/src/flext_core/_models/registry.py b/src/flext_core/_models/registry.py index 375694845..324bd543b 100644 --- a/src/flext_core/_models/registry.py +++ b/src/flext_core/_models/registry.py @@ -38,7 +38,7 @@ class RegistryState(m.ArbitraryTypesModel): ), ] = mp.Field(default_factory=frozenset) - @up.computed_field() + @up.computed_field @property def configured(self) -> bool: """Whether a dispatcher has been materialized for the registry.""" @@ -68,13 +68,13 @@ class RegistrySummary(FlextModelsEntity.Value): ), ] = mp.Field(default_factory=list[str]) - @up.computed_field() + @up.computed_field @property def failure(self) -> bool: """Indicate whether the batch registration had errors.""" return bool(self.errors) - @up.computed_field() + @up.computed_field @property def success(self) -> bool: """Indicate whether the batch registration fully succeeded.""" diff --git a/src/flext_core/_protocols/_result_parts/flextprotocolsresult_part_01.py b/src/flext_core/_protocols/_result_parts/flextprotocolsresult_part_01.py deleted file mode 100644 index fb16740df..000000000 --- a/src/flext_core/_protocols/_result_parts/flextprotocolsresult_part_01.py +++ /dev/null @@ -1,158 +0,0 @@ -"""FlextProtocolsResult - result and model-dump contracts. - -The public ``p.Result`` contract is nominal for direct static typing, while -auxiliary structural protocols segment the instance API by concern. Today only -``ResultLike`` has a direct structural consumer in the workspace, but the other -protocols still document and organize the full public result surface. - -Copyright (c) 2025 FLEXT Team. All rights reserved. -SPDX-License-Identifier: MIT -""" - -from __future__ import annotations - -from typing import TYPE_CHECKING, Protocol, overload, runtime_checkable - -if TYPE_CHECKING: - from collections.abc import Callable - - from flext_core import FlextTypes as t - - -class FlextProtocolsResult: - """Protocols for railway result contracts and model dump shape.""" - - @runtime_checkable - class ResultLike[T](Protocol): - """Minimal observable result contract for automatic narrowing.""" - - @property - def error(self) -> str | None: - """Error message (available on failure, None on success).""" - ... - - @property - def error_code(self) -> str | None: - """Structured error code when available.""" - ... - - @property - def error_data(self) -> t.JsonMapping | None: - """Structured error metadata when available.""" - ... - - @property - def exception(self) -> BaseException | None: - """Captured exception when available.""" - ... - - @property - def failure(self) -> bool: - """Failure status (strict: not success).""" - ... - - @property - def success(self) -> bool: - """Success status (strict: True only when operation succeeded).""" - ... - - @property - def value(self) -> T: - """Result value (available on success, strictly typed as T).""" - ... - - def unwrap(self) -> T: - """Unwrap success value (raises on failure).""" - ... - - @overload - def unwrap_or(self, default: T) -> T: ... - @overload - def unwrap_or[D](self, default: D) -> T | D: ... - def unwrap_or[D](self, default: D) -> T | D: - """Return success value or the provided default.""" - ... - - @overload - def unwrap_or_else(self, func: Callable[[], T]) -> T: ... - @overload - def unwrap_or_else[D](self, func: Callable[[], D]) -> T | D: ... - def unwrap_or_else[D](self, func: Callable[[], D]) -> T | D: - """Return success value or the result of the fallback callable.""" - ... - - @runtime_checkable - class ResultObservable[T](Protocol): - """Read-only observation of result state. - - Provides success/failure inspection and access to value, error, - error_code, error_data, and exception properties. - T variance is automatically inferred as covariant (output-only). - """ - - @property - def error(self) -> str | None: - """Error message (available on failure, None on success).""" - ... - - @property - def error_code(self) -> str | None: - """Error code for categorization (structured error support).""" - ... - - @property - def error_data(self) -> t.JsonMapping | None: - """Error metadata with structured error context (optional).""" - ... - - @property - def exception(self) -> BaseException | None: - """Exception captured during operation (if any).""" - ... - - @property - def failure(self) -> bool: - """Failure status (strict: not success).""" - ... - - @property - def success(self) -> bool: - """Success status (strict: True only when operation succeeded).""" - ... - - @property - def value(self) -> T: - """Result value (available on success, strictly typed as T).""" - ... - - @runtime_checkable - class ResultUnwrappable[T](Protocol): - """Value extraction from result. - - Provides unwrap, unwrap_or, and unwrap_or_else for - extracting the success value with different failure strategies. - T variance is automatically inferred as invariant (input + output). - """ - - def unwrap(self) -> T: - """Unwrap success value (raises materialized exception on failure).""" - ... - - @overload - def unwrap_or(self, default: T) -> T: ... - @overload - def unwrap_or[D](self, default: D) -> T | D: ... - def unwrap_or[D](self, default: D) -> T | D: - """Return success value or the provided default.""" - ... - - @overload - def unwrap_or_else(self, func: Callable[[], T]) -> T: ... - @overload - def unwrap_or_else[D](self, func: Callable[[], D]) -> T | D: ... - def unwrap_or_else[D](self, func: Callable[[], D]) -> T | D: - """Return success value or the result of the fallback callable.""" - ... - - -__all__: list[str] = ["FlextProtocolsResult"] diff --git a/src/flext_core/_protocols/_result_parts/flextprotocolsresult_part_02.py b/src/flext_core/_protocols/_result_parts/flextprotocolsresult_part_02.py deleted file mode 100644 index 681b27626..000000000 --- a/src/flext_core/_protocols/_result_parts/flextprotocolsresult_part_02.py +++ /dev/null @@ -1,170 +0,0 @@ -"""FlextProtocolsResult - result and model-dump contracts. - -The public ``p.Result`` contract is nominal for direct static typing, while -auxiliary structural protocols segment the instance API by concern. Today only -``ResultLike`` has a direct structural consumer in the workspace, but the other -protocols still document and organize the full public result surface. - -Copyright (c) 2025 FLEXT Team. All rights reserved. -SPDX-License-Identifier: MIT -""" - -from __future__ import annotations - -from abc import ABC, abstractmethod -from typing import TYPE_CHECKING, Self, overload, override - -from flext_core._models.pydantic import FlextModelsPydantic as mp -from flext_core._protocols._result_parts.flextprotocolsresult_part_01 import ( - FlextProtocolsResult as FlextProtocolsResultPart01, -) - -if TYPE_CHECKING: - from collections.abc import Callable - from types import TracebackType - - from flext_core._typings.base import FlextTypingBase as t - - -class FlextProtocolsResult(FlextProtocolsResultPart01): - class Result[T_co](ABC): - """Nominal public result contract for direct static typing across FLEXT. - - Pure ABC (not inheriting ResultLike Protocol) so Pydantic BaseModel - subclasses (FlextResult) can inherit without metaclass conflict - between ``_ProtocolMeta`` and ``ModelMetaclass``. ResultLike remains - available as a @runtime_checkable Protocol for structural isinstance. - """ - - @property - @abstractmethod - def error(self) -> str | None: ... - - @property - @abstractmethod - def error_code(self) -> str | None: ... - - @property - @abstractmethod - def error_data(self) -> t.JsonMapping | None: ... - - @property - @abstractmethod - def success(self) -> bool: ... - - @property - @abstractmethod - def exception(self) -> BaseException | None: ... - - @property - @abstractmethod - def failure(self) -> bool: ... - - @property - @abstractmethod - def value(self) -> T_co: ... - - @abstractmethod - def __enter__(self) -> Self: ... - - @abstractmethod - def __exit__( - self, - _exc_type: type[BaseException] | None, - _exc_val: BaseException | None, - _exc_tb: TracebackType | None, - ) -> None: ... - - @overload - def __or__(self, default: T_co) -> T_co: ... - - @overload - def __or__[D](self, default: D) -> T_co | D: ... - - @abstractmethod - def __or__[D](self, default: T_co | D) -> T_co | D: ... - - @abstractmethod - def unwrap(self) -> T_co: ... - - @abstractmethod - def unwrap_or[D](self, default: D) -> T_co | D: ... - - @abstractmethod - def unwrap_or_else[D](self, func: Callable[[], D]) -> T_co | D: ... - - @abstractmethod - def flat_map[U]( - self, func: Callable[[T_co], FlextProtocolsResult.Result[U]] - ) -> FlextProtocolsResult.Result[U]: ... - - @abstractmethod - def fold[U]( - self, on_failure: Callable[[str], U], on_success: Callable[[T_co], U] - ) -> U: ... - - @abstractmethod - def lash( - self, func: Callable[[str], FlextProtocolsResult.Result[T_co]] - ) -> FlextProtocolsResult.Result[T_co]: ... - - @abstractmethod - def map[U]( - self, func: Callable[[T_co], U] - ) -> FlextProtocolsResult.Result[U]: ... - - @abstractmethod - def flow_through( - self, *funcs: Callable[[T_co], FlextProtocolsResult.Result[T_co]] - ) -> FlextProtocolsResult.Result[T_co]: ... - - @abstractmethod - def map_error( - self, func: Callable[[str], str] - ) -> FlextProtocolsResult.Result[T_co]: ... - - @overload - def map_or(self, default: None, func: None = None) -> T_co | None: ... - @overload - def map_or[U](self, default: U, func: None = None) -> T_co | U: ... - @overload - def map_or[U](self, default: U, func: Callable[[T_co], U]) -> U: ... - - @abstractmethod - def map_or[U]( - self, default: U, func: Callable[[T_co], U] | None = None - ) -> U | T_co: ... - - @abstractmethod - def tap( - self, func: Callable[[T_co], None] - ) -> FlextProtocolsResult.Result[T_co]: ... - - @abstractmethod - def tap_error(self, func: Callable[[str], None]) -> Self: ... - - @abstractmethod - def filter( - self, predicate: Callable[[T_co], bool] - ) -> FlextProtocolsResult.Result[T_co]: ... - - @abstractmethod - def recover[U]( - self, func: Callable[[str], U] - ) -> FlextProtocolsResult.Result[T_co | U]: ... - - @abstractmethod - def to_model[U: mp.BaseModel]( - self, model: type[U] - ) -> FlextProtocolsResult.Result[U]: ... - - @abstractmethod - def __bool__(self) -> bool: ... - - @override - def __repr__(self) -> str: - """Default repr for Result ABC.""" - return f"{type(self).__name__}(success={self.success})" - - -__all__: list[str] = ["FlextProtocolsResult"] diff --git a/src/flext_core/_protocols/_result_parts/flextprotocolsresult_part_03.py b/src/flext_core/_protocols/_result_parts/flextprotocolsresult_part_03.py deleted file mode 100644 index 8e343bfe0..000000000 --- a/src/flext_core/_protocols/_result_parts/flextprotocolsresult_part_03.py +++ /dev/null @@ -1,124 +0,0 @@ -"""FlextProtocolsResult - result and model-dump contracts. - -The public ``p.Result`` contract is nominal for direct static typing, while -auxiliary structural protocols segment the instance API by concern. Today only -``ResultLike`` has a direct structural consumer in the workspace, but the other -protocols still document and organize the full public result surface. - -Copyright (c) 2025 FLEXT Team. All rights reserved. -SPDX-License-Identifier: MIT -""" - -from __future__ import annotations - -from typing import TYPE_CHECKING, Protocol, Self, overload, runtime_checkable - -from flext_core._models.pydantic import FlextModelsPydantic as mp -from flext_core._protocols._result_parts.flextprotocolsresult_part_02 import ( - FlextProtocolsResult as FlextProtocolsResultPart02, -) - -if TYPE_CHECKING: - from collections.abc import Callable - - -class FlextProtocolsResult(FlextProtocolsResultPart02): - @runtime_checkable - class ResultMonadic[T](Protocol): - """Monadic chaining operations on result. - - Provides map, flat_map, fold, and lash for composing - result-returning operations in a railway-oriented style. - All return types use the protocol (never the concrete carrier). - T variance is automatically inferred as invariant (consumed via callbacks). - """ - - def flat_map[U]( - self, func: Callable[[T], FlextProtocolsResult.Result[U]] - ) -> FlextProtocolsResult.Result[U]: - """Chain operations that return structural FLEXT results.""" - ... - - def fold[U]( - self, on_failure: Callable[[str], U], on_success: Callable[[T], U] - ) -> U: - """Reduce result into a single value.""" - ... - - def lash( - self, func: Callable[[str], FlextProtocolsResult.Result[T]] - ) -> FlextProtocolsResult.Result[T]: - """Recover from failure using another structural result.""" - ... - - def map[U](self, func: Callable[[T], U]) -> FlextProtocolsResult.Result[U]: - """Transform the success value.""" - ... - - @runtime_checkable - class ResultTappable[T](Protocol): - """Side-effect and error transform operations. - - Provides tap, tap_error, flow_through, map_error, and map_or - for observing or transforming result state without consuming it. - T variance is automatically inferred as invariant (consumed via callbacks). - """ - - def flow_through( - self, *funcs: Callable[[T], FlextProtocolsResult.Result[T]] - ) -> FlextProtocolsResult.Result[T]: - """Apply multiple Result-returning steps in sequence.""" - ... - - def map_error( - self, func: Callable[[str], str] - ) -> FlextProtocolsResult.Result[T]: - """Transform the failure message.""" - ... - - @overload - def map_or(self, default: None, func: None = None) -> T | None: ... - @overload - def map_or[U](self, default: U, func: None = None) -> T | U: ... - @overload - def map_or[U](self, default: U, func: Callable[[T], U]) -> U: ... - def map_or[U](self, default: U, func: Callable[[T], U] | None = None) -> U | T: - """Map success value or return default.""" - ... - - def tap(self, func: Callable[[T], None]) -> FlextProtocolsResult.Result[T]: - """Apply a side effect to the success value.""" - ... - - def tap_error(self, func: Callable[[str], None]) -> Self: - """Apply a side effect to the failure value.""" - ... - - @runtime_checkable - class ResultRecoverable[T](Protocol): - """Fallback and predicate-based result operations.""" - - def filter( - self, predicate: Callable[[T], bool] - ) -> FlextProtocolsResult.Result[T]: - """Keep the value only when the predicate passes.""" - ... - - def recover[U]( - self, func: Callable[[str], U] - ) -> FlextProtocolsResult.Result[T | U]: - """Recover a failure into a success value.""" - ... - - @runtime_checkable - class ResultConvertible[T](Protocol): - """Conversion helpers from raw result payloads to validated types.""" - - def to_model[U: mp.BaseModel]( - self, model: type[U] - ) -> FlextProtocolsResult.Result[U]: - """Convert the success payload into a validated model.""" - ... - - -__all__: list[str] = ["FlextProtocolsResult"] diff --git a/src/flext_core/_protocols/_result_parts/flextprotocolsresult_part_04.py b/src/flext_core/_protocols/_result_parts/flextprotocolsresult_part_04.py deleted file mode 100644 index 007029ecf..000000000 --- a/src/flext_core/_protocols/_result_parts/flextprotocolsresult_part_04.py +++ /dev/null @@ -1,101 +0,0 @@ -"""FlextProtocolsResult - result and model-dump contracts. - -The public ``p.Result`` contract is nominal for direct static typing, while -auxiliary structural protocols segment the instance API by concern. Today only -``ResultLike`` has a direct structural consumer in the workspace, but the other -protocols still document and organize the full public result surface. - -Copyright (c) 2025 FLEXT Team. All rights reserved. -SPDX-License-Identifier: MIT -""" - -from __future__ import annotations - -from typing import TYPE_CHECKING, Protocol, runtime_checkable - -if TYPE_CHECKING: - from flext_core import FlextModels as m, FlextTypes as t -from flext_core._protocols._result_parts.flextprotocolsresult_part_03 import ( - FlextProtocolsResult as FlextProtocolsResultPart03, -) - - -class FlextProtocolsResult(FlextProtocolsResultPart03): - @runtime_checkable - class HasModelDump(Protocol): - """Protocol for items that can dump model data. - - Used for Pydantic model compatibility and serialization. - """ - - def model_dump( - self, *, mode: str = "python" - ) -> t.MappingKV[str, t.JsonPayload | None]: - """Dump model data to a mapping that runtime helpers can normalize.""" - ... - - @runtime_checkable - class StructuredError(Protocol): - """Protocol for structured error handling in Results.""" - - @property - def error_domain(self) -> str | None: - """Error domain category (e.g., 'VALIDATION', 'NETWORK', 'AUTH').""" - ... - - @property - def error_code(self) -> str | None: - """Specific error code for routing and categorization.""" - ... - - @property - def error_message(self) -> str | None: - """Human-readable error message.""" - ... - - @property - def message(self) -> str: - """Canonical exception message.""" - ... - - @property - def metadata(self) -> m.Metadata: - """Structured metadata attached to the error.""" - ... - - def matches_error_domain(self, domain: str) -> bool: - """Whether the error belongs to a specific domain.""" - ... - - @runtime_checkable - class SuccessCheckable(Protocol): - """Protocol for any model with success/failure outcome semantics. - - Lighter than Result โ€” requires only success/failure status properties. - Satisfied by RuntimeResult, FlextResult, BatchResult, HTTP response models, - and any domain model that reports pass/fail status. - """ - - @property - def success(self) -> bool: - """True when the operation succeeded.""" - ... - - @property - def failure(self) -> bool: - """True when the operation failed.""" - ... - - @runtime_checkable - class ErrorDomainProtocol(Protocol): - """Protocol for error domain enumeration. - - Defines standard error categories for structured error handling - across FLEXT. Enables strict error routing and categorization. - """ - - value: str # e.g., "VALIDATION", "NETWORK", "AUTH" - name: str # e.g., "ValidationError", "NetworkError" - - -__all__: list[str] = ["FlextProtocolsResult"] diff --git a/src/flext_core/_protocols/handler.py b/src/flext_core/_protocols/handler.py index 77030db7e..77094d7c6 100644 --- a/src/flext_core/_protocols/handler.py +++ b/src/flext_core/_protocols/handler.py @@ -182,7 +182,7 @@ class DispatchMessage(Protocol): def dispatch_message( self, message: p.Routable, operation: str = ... - ) -> pr.ResultLike[t.JsonPayload] | t.JsonPayload | None: ... + ) -> pr.Result[t.JsonPayload] | t.JsonPayload | None: ... @runtime_checkable class Handle(Protocol): @@ -190,7 +190,7 @@ class Handle(Protocol): def handle( self, message: p.Routable - ) -> pr.ResultLike[t.JsonPayload] | t.JsonPayload | None: ... + ) -> pr.Result[t.JsonPayload] | t.JsonPayload | None: ... @runtime_checkable class Execute(Protocol): @@ -198,7 +198,7 @@ class Execute(Protocol): def execute( self, message: p.Routable - ) -> pr.ResultLike[t.JsonPayload] | t.JsonPayload | None: ... + ) -> pr.Result[t.JsonPayload] | t.JsonPayload | None: ... @runtime_checkable class AutoDiscoverableHandler(Protocol): diff --git a/src/flext_core/_protocols/result.py b/src/flext_core/_protocols/result.py index 5c204e747..4ad445e95 100644 --- a/src/flext_core/_protocols/result.py +++ b/src/flext_core/_protocols/result.py @@ -1,9 +1,4 @@ -"""FlextProtocolsResult - result and model-dump contracts. - -The public ``p.Result`` contract is nominal for direct static typing, while -auxiliary structural protocols segment the instance API by concern. Today only -``ResultLike`` has a direct structural consumer in the workspace, but the other -protocols still document and organize the full public result surface. +"""Structural result and model-dump contracts for FLEXT. Copyright (c) 2025 FLEXT Team. All rights reserved. SPDX-License-Identifier: MIT @@ -11,13 +6,154 @@ from __future__ import annotations -from ._result_parts.flextprotocolsresult_part_04 import ( - FlextProtocolsResult as FlextProtocolsResultPartFinal, -) +from typing import TYPE_CHECKING, Protocol, Self, TypeVar, overload, runtime_checkable + +from flext_core._models.pydantic import FlextModelsPydantic as mp + +ResultT_co = TypeVar("ResultT_co", covariant=True) + +if TYPE_CHECKING: + from collections.abc import Callable + from types import TracebackType + + from flext_core._typings.base import FlextTypingBase as t + from flext_core._typings.services import FlextTypesServices as ts + from flext_core import FlextModels as m + + +class FlextProtocolsResult: + """Single structural result contract used across FLEXT.""" + + @runtime_checkable + class FailureLike(Protocol): + @property + def error(self) -> str | None: ... + @property + def error_code(self) -> str | None: ... + @property + def error_data(self) -> t.JsonMapping | None: ... + @property + def exception(self) -> BaseException | None: ... + @property + def failure(self) -> bool: ... + @property + def success(self) -> bool: ... + + @runtime_checkable + class Result(Protocol[ResultT_co]): + """Structural railway result contract; covariant payload.""" + + @property + def error(self) -> str | None: ... + @property + def error_code(self) -> str | None: ... + @property + def error_data(self) -> t.JsonMapping | None: ... + @property + def success(self) -> bool: ... + @property + def exception(self) -> BaseException | None: ... + @property + def failure(self) -> bool: ... + @property + def value(self) -> ResultT_co: ... + + def __enter__(self) -> Self: ... + + def __exit__( + self, + _exc_type: type[BaseException] | None, + _exc_val: BaseException | None, + _exc_tb: TracebackType | None, + ) -> None: ... + + def __or__[D](self, default: D) -> ResultT_co | D: ... + + def unwrap(self) -> ResultT_co: ... + def unwrap_or[D](self, default: D) -> ResultT_co | D: ... + def unwrap_or_else[D](self, func: Callable[[], D]) -> ResultT_co | D: ... + + def flat_map[U]( + self, func: Callable[..., FlextProtocolsResult.Result[U]] + ) -> FlextProtocolsResult.Result[U]: ... + + def fold[U]( + self, on_failure: Callable[[str], U], on_success: Callable[..., U] + ) -> U: ... + + def lash[U]( + self, func: Callable[[str], FlextProtocolsResult.Result[U]] + ) -> FlextProtocolsResult.Result[ResultT_co | U]: ... + + def map[U](self, func: Callable[..., U]) -> FlextProtocolsResult.Result[U]: ... + + def flow_through( + self, *funcs: Callable[..., FlextProtocolsResult.Result[ResultT_co]] + ) -> FlextProtocolsResult.Result[ResultT_co]: ... + + def map_error( + self, func: Callable[[str], str] + ) -> FlextProtocolsResult.Result[ResultT_co]: ... + + @overload + def map_or(self, default: None, func: None = None) -> ResultT_co | None: ... + @overload + def map_or[U](self, default: U, func: None = None) -> ResultT_co | U: ... + @overload + def map_or[U](self, default: U, func: Callable[..., U]) -> U: ... + def map_or[U]( + self, default: U, func: Callable[..., U] | None = None + ) -> U | ResultT_co: ... + + def tap( + self, func: Callable[..., None] + ) -> FlextProtocolsResult.Result[ResultT_co]: ... + + def tap_error( + self, func: Callable[[str], None] + ) -> FlextProtocolsResult.Result[ResultT_co]: ... + + def filter( + self, predicate: Callable[..., bool] + ) -> FlextProtocolsResult.Result[ResultT_co]: ... + + def recover[U]( + self, func: Callable[[str], U] + ) -> FlextProtocolsResult.Result[ResultT_co | U]: ... + + def to_model[U: mp.BaseModel]( + self, model: type[U] + ) -> FlextProtocolsResult.Result[U]: ... + + def __bool__(self) -> bool: ... + + @runtime_checkable + class SuccessCheckable(Protocol): + @property + def success(self) -> bool: ... + @property + def failure(self) -> bool: ... + + @runtime_checkable + class StructuredError(Protocol): + @property + def error_domain(self) -> str | None: ... + @property + def error_code(self) -> str | None: ... + @property + def error_message(self) -> str | None: ... + @property + def message(self) -> str: ... + @property + def metadata(self) -> m.Metadata: ... + def matches_error_domain(self, domain: str) -> bool: ... -class FlextProtocolsResult(FlextProtocolsResultPartFinal): - """Public facade for FlextProtocolsResult.""" + @runtime_checkable + class HasModelDump(Protocol): + def model_dump( + self, *, mode: str = "python" + ) -> t.MappingKV[str, ts.JsonPayload | None]: ... __all__: list[str] = ["FlextProtocolsResult"] diff --git a/src/flext_core/_protocols/_result_parts/__init__.py b/src/flext_core/_result/__init__.py similarity index 100% rename from src/flext_core/_protocols/_result_parts/__init__.py rename to src/flext_core/_result/__init__.py diff --git a/src/flext_core/_result/base.py b/src/flext_core/_result/base.py new file mode 100644 index 000000000..411a5a407 --- /dev/null +++ b/src/flext_core/_result/base.py @@ -0,0 +1,73 @@ +"""Internal data model for FlextResult. + +Copyright (c) 2025 FLEXT Team. All rights reserved. +SPDX-License-Identifier: MIT +""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import TypeVar, cast + +from pydantic import BaseModel, PrivateAttr + +from flext_core._protocols.result import FlextProtocolsResult as prt +from flext_core._typings.base import FlextTypingBase as t +from flext_core._typings.pydantic import FlextTypesPydantic as tp +from flext_core._typings.services import FlextTypesServices as ts + +type JsonMapping = Mapping[str, tp.JsonValue] +type JsonDict = dict[str, tp.JsonValue] +type ConfigModelInput = prt.HasModelDump | JsonMapping + + +T = TypeVar("T") + + +class FlextResultBase[T](BaseModel): + """Internal result data container.""" + + model_config = {"arbitrary_types_allowed": True, "populate_by_name": True} + + success: bool = True + error: str | None = None + error_code: str | None = None + error_data: JsonDict | None = None + + _payload: T = PrivateAttr() + _exception: BaseException | None = PrivateAttr(default=None) + + @staticmethod + def validate_error_data( + error_data: t.JsonMapping | ts.ConfigModelInput | None, + ) -> JsonDict | None: + from flext_core._runtime._metadata import FlextRuntimeMetadata as FlextRuntime + + normalized = FlextRuntime.normalize_model_input_mapping(error_data) + if normalized is None: + return None + return dict(normalized) + + def __init__( + self, + error_code: str | None = None, + error_data: JsonMapping | ConfigModelInput | None = None, + *, + value: T | None = None, + error: str | None = None, + success: bool = True, + exception: BaseException | None = None, + ) -> None: + super().__init__( + error=error, + error_code=error_code, + success=success, + error_data=self.validate_error_data(error_data), + ) + if success: + self._payload = cast("T", value) + elif exception is not None: + self._exception = exception + + +__all__: list[str] = ["FlextResultBase"] diff --git a/src/flext_core/_result/behavior.py b/src/flext_core/_result/behavior.py new file mode 100644 index 000000000..6ef46d676 --- /dev/null +++ b/src/flext_core/_result/behavior.py @@ -0,0 +1,59 @@ +"""Shared behavior contract for FlextResult.""" + +from __future__ import annotations + +from typing import Self, overload, override + +from .base import FlextResultBase + + +class FlextResultBehavior[T](FlextResultBase[T]): + """Behavior layer: context manager, dunder methods, error accessors.""" + + @property + def failure(self) -> bool: + return not self.success + + @property + def value(self) -> T: + if not self.success: + error_msg = self.error or "" + msg = f"Cannot access value of failed result: {error_msg}" + raise RuntimeError(msg) + return self._payload + + @property + def exception(self) -> BaseException | None: + return self._exception + + def __enter__(self) -> Self: + return self + + def __exit__( + self, + _exc_type: type[BaseException] | None, + _exc_val: BaseException | None, + _exc_tb: object, + ) -> None: + pass + + @overload + def __or__(self, default: T) -> T: ... + @overload + def __or__[D](self, default: D) -> T | D: ... + def __or__[D](self, default: T | D) -> T | D: + if self.success: + return self._payload + return default + + def __bool__(self) -> bool: + return self.success + + @override + def __repr__(self) -> str: + if self.success: + return f"r[T].ok({self._payload!r})" + return f"r[T].fail({self.error!r})" + + +__all__: list[str] = ["FlextResultBehavior"] diff --git a/src/flext_core/_result_parts/composition.py b/src/flext_core/_result/composition.py similarity index 53% rename from src/flext_core/_result_parts/composition.py rename to src/flext_core/_result/composition.py index 568bbb62d..3495c52d5 100644 --- a/src/flext_core/_result_parts/composition.py +++ b/src/flext_core/_result/composition.py @@ -2,30 +2,28 @@ from __future__ import annotations -from abc import ABC -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING, Self from pydantic import ValidationError -from flext_core._constants.errors import FlextConstantsErrors as c +from flext_core import c -from .construction import FlextResultConstructionMixin +from .transforms import FlextResultTransforms if TYPE_CHECKING: from collections.abc import Callable, MutableSequence, Sequence - from flext_core._protocols.result import FlextProtocolsResult as p - from flext_core._typings.base import FlextTypingBase as t + from flext_core import p, t + from flext_core import FlextResult as rt -class FlextResultCompositionMixin[T](FlextResultConstructionMixin[T], ABC): +class FlextResultComposition[T](FlextResultTransforms[T]): """Sequence, resource, and decorator composition helpers.""" @classmethod def accumulate_errors[ValueT]( - cls, *results: p.Result[ValueT] - ) -> p.Result[Sequence[ValueT]]: - """Collect successes or all errors combined.""" + cls: type[Self], *results: p.Result[ValueT] + ) -> rt[Sequence[ValueT]]: successes: MutableSequence[ValueT] = [] errors: MutableSequence[str] = [] for result in results: @@ -34,66 +32,57 @@ def accumulate_errors[ValueT]( else: errors.append(cls.require_error(result)) if errors: - # Type bridge: accumulated failures carry no payload value. - result_class = cast( - "type[FlextResultConstructionMixin[Sequence[ValueT]]]", cls - ) - return result_class.fail("; ".join(errors)) + return cls.fail("; ".join(errors)) return cls.ok(successes) @classmethod def traverse[V, U]( - cls, + cls: type[Self], items: t.SequenceOf[V], func: Callable[[V], p.Result[U]], *, fail_fast: bool = True, - ) -> p.Result[Sequence[U]]: - """Map sequence via func; fail_fast stops on first error.""" + ) -> rt[Sequence[U]]: if fail_fast: results: MutableSequence[U] = [] - result_class = cast("type[FlextResultConstructionMixin[Sequence[U]]]", cls) for item in items: try: result = func(item) except c.CATCHABLE_RUNTIME_EXCEPTIONS as exc: - return result_class.fail(str(exc), exception=exc) + return cls.fail(str(exc), exception=exc) if result.failure: - return result_class.from_failure(result) + return cls.from_failure(result) results.append(result.value) return cls.ok(results) - item_result_class = cast("type[FlextResultConstructionMixin[U]]", cls) - all_results: MutableSequence[p.Result[U]] = [] + all_results: MutableSequence[rt[U]] = [] for item in items: try: all_results.append(cls.from_result(func(item))) except c.CATCHABLE_RUNTIME_EXCEPTIONS as exc: - all_results.append(item_result_class.fail(str(exc), exception=exc)) + all_results.append(cls.fail(str(exc), exception=exc)) return cls.accumulate_errors(*all_results) @classmethod def with_resource[R, U]( - cls, + cls: type[Self], factory: Callable[[], R], op: Callable[[R], p.Result[U]], cleanup: Callable[[R], None] | None = None, - ) -> p.Result[U]: - """Manage resource lifecycle with automatic cleanup.""" - result_class = cast("type[FlextResultConstructionMixin[U]]", cls) + ) -> rt[U]: try: resource = factory() except c.CATCHABLE_RUNTIME_EXCEPTIONS as exc: - return result_class.fail(str(exc), exception=exc) - result: p.Result[U] + return cls.fail(str(exc), exception=exc) + result: rt[U] try: result = cls.from_result(op(resource)) except c.CATCHABLE_RUNTIME_EXCEPTIONS as exc: - result = result_class.fail(str(exc), exception=exc) + result = cls.fail(str(exc), exception=exc) if cleanup: try: cleanup(resource) except c.CATCHABLE_RUNTIME_EXCEPTIONS as exc: - return result_class.fail(str(exc), exception=exc) + return cls.fail(str(exc), exception=exc) return result @staticmethod @@ -106,18 +95,16 @@ def _model_error_message(error: BaseException) -> str: return str(error) @classmethod - def safe[U, **PFunc](cls, func: Callable[PFunc, U]) -> Callable[PFunc, p.Result[U]]: - """Wrap function in FlextResult, catching exceptions.""" - - def wrapper(*args: PFunc.args, **kwargs: PFunc.kwargs) -> p.Result[U]: + def safe[U, **PFunc]( + cls: type[Self], func: Callable[PFunc, U] + ) -> Callable[PFunc, rt[U]]: + def wrapper(*args: PFunc.args, **kwargs: PFunc.kwargs) -> rt[U]: try: return cls.ok(func(*args, **kwargs)) except c.CATCHABLE_RUNTIME_EXCEPTIONS as exc: - # Type bridge: decorator failures match the wrapped return type. - result_class = cast("type[FlextResultConstructionMixin[U]]", cls) - return result_class.fail(str(exc), exception=exc) + return cls.fail(str(exc), exception=exc) return wrapper -__all__: list[str] = ["FlextResultCompositionMixin"] +__all__: list[str] = ["FlextResultComposition"] diff --git a/src/flext_core/_result/construction.py b/src/flext_core/_result/construction.py new file mode 100644 index 000000000..42478de25 --- /dev/null +++ b/src/flext_core/_result/construction.py @@ -0,0 +1,171 @@ +"""Construction operations for FlextResult.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Self + +from pydantic import BaseModel, ValidationError + +from flext_core import c + +from .behavior import FlextResultBehavior + +if TYPE_CHECKING: + from collections.abc import Callable + + from flext_core import FlextResult, p, t + + +class FlextResultConstruction[T](FlextResultBehavior[T]): + """Factory methods for the concrete result facade.""" + + @staticmethod + def require_error(source: p.FailureLike) -> str: + """Extract error message from any failed Result.""" + error = source.error + if not error: + msg = c.ERR_RESULT_FAILURE_MESSAGE_REQUIRED + raise ValueError(msg) + return error + + @classmethod + def from_failure[V](cls: type[Self], source: p.FailureLike) -> FlextResult[V]: + if source.success: + msg = c.ERR_RESULT_FAILURE_REQUIRED + raise ValueError(msg) + return cls.fail( + cls.require_error(source), + error_code=source.error_code, + error_data=source.error_data, + exception=source.exception, + ) + + @classmethod + def _extract_exception_error_code( + cls, exception: BaseException | None + ) -> str | None: + if exception is None: + return None + error_code = getattr(exception, "error_code", None) + return error_code if isinstance(error_code, str) and error_code else None + + @classmethod + def _extract_exception_error_data( + cls, exception: BaseException | None + ) -> t.JsonDict | None: + if exception is None: + return None + metadata = getattr(exception, "metadata", None) + raw_attributes = getattr(metadata, c.FIELD_ATTRIBUTES, None) + if raw_attributes is None: + return None + try: + payload = cls.validate_error_data(raw_attributes) + except ValidationError: + return None + if payload is None: + return None + correlation_id = getattr(exception, "correlation_id", None) + if isinstance(correlation_id, str) and correlation_id: + payload[c.ContextKey.CORRELATION_ID] = correlation_id + return payload + + @classmethod + def _from_result[V](cls: type[Self], source: p.Result[V]) -> FlextResult[V]: + if source.success: + try: + return cls.ok(source.value) + except ValueError as exc: + return cls.fail(str(exc)) + return cls.fail( + cls.require_error(source), + error_code=source.error_code, + error_data=source.error_data, + exception=source.exception, + ) + + @classmethod + def create_from_callable[V]( + cls: type[Self], func: Callable[[], V | None], error_code: str | None = None + ) -> FlextResult[V]: + try: + value = func() + if value is None: + return cls.fail("Callable returned None", error_code=error_code) + return cls.ok(value) + except c.EXC_BROAD_RUNTIME as exc: + return cls.fail(str(exc), error_code=error_code, exception=exc) + + @classmethod + def fail[V]( + cls: type[Self], + error: str | None, + *, + error_code: str | None = None, + error_data: t.JsonMapping | t.ConfigModelInput | None = None, + exception: BaseException | None = None, + ) -> FlextResult[V]: + from flext_core import FlextResult + + error_msg = error if error is not None else "" + resolved_error_code = error_code or cls._extract_exception_error_code(exception) + resolved_error_data = ( + error_data + if error_data is not None + else cls._extract_exception_error_data(exception) + ) + return FlextResult( + error_code=resolved_error_code, + error_data=cls.validate_error_data(resolved_error_data), + error=error_msg, + success=False, + exception=exception, + ) + + @classmethod + def fail_op[V]( + cls: type[Self], operation: str, exc: Exception | str | None = None + ) -> FlextResult[V]: + if isinstance(exc, Exception): + return cls.fail(f"{operation} failed: {exc}", exception=exc) + error_msg = ( + f"{operation} failed" if exc is None else f"{operation} failed: {exc}" + ) + return cls.fail(error_msg) + + @classmethod + def from_validation[ModelT: BaseModel]( + cls: type[Self], data: t.ModelInput, model: t.ModelClass[ModelT] + ) -> FlextResult[ModelT]: + try: + validated: ModelT = model.model_validate(data) + return cls.ok(validated) + except c.EXC_ATTR_RUNTIME_VALIDATION as exc: + return cls.fail(str(exc), exception=exc) + + @classmethod + def ok[V](cls: type[Self], value: V) -> FlextResult[V]: + from flext_core import FlextResult + + return FlextResult(value=value, success=True) + + @staticmethod + def successful_result(obj: object) -> bool: + """Check whether an object is a successful FlextResult.""" + from flext_core import FlextResult + + return isinstance(obj, FlextResult) and obj.success + + @staticmethod + def failed_result(obj: object) -> bool: + """Check whether an object is a failed FlextResult.""" + from flext_core import FlextResult + + return isinstance(obj, FlextResult) and obj.failure + + @classmethod + def from_result[V](cls: type[Self], source: p.Result[V]) -> FlextResult[V]: + return cls._from_result(source) + + +__all__: list[str] = ["FlextResultConstruction"] diff --git a/src/flext_core/_result/transforms.py b/src/flext_core/_result/transforms.py new file mode 100644 index 000000000..2ad8291e7 --- /dev/null +++ b/src/flext_core/_result/transforms.py @@ -0,0 +1,160 @@ +"""Transform operations for FlextResult.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, cast, overload + +from pydantic import BaseModel + +from flext_core import c + +from .construction import FlextResultConstruction + +if TYPE_CHECKING: + from collections.abc import Callable + + from flext_core import FlextResult as rt + from flext_core import p + + +class FlextResultTransforms[T](FlextResultConstruction[T]): + """Instance transformation methods for result values and errors.""" + + def _as_result(self) -> rt[T]: + """Structural cast of self to p.Result for internal calls.""" + return cast("rt[T]", self) + + def filter(self, predicate: Callable[[T], bool]) -> rt[T]: + if self.success: + try: + if predicate(self._payload): + return self._as_result() + return self.__class__.fail(c.ERR_RESULT_FILTER_PREDICATE_FAILED) + except c.EXC_BROAD_RUNTIME as exc: + return self.__class__.fail(str(exc), exception=exc) + return self._as_result() + + def flat_map[U](self, func: Callable[[T], p.Result[U]]) -> rt[U]: + if self.failure: + return self.__class__.fail( + self.require_error(self._as_result()), + error_code=self.error_code, + error_data=self.error_data, + exception=self._exception, + ) + try: + return self.__class__.from_result(func(self._payload)) + except c.EXC_BROAD_RUNTIME as exc: + return self.__class__.fail(str(exc), exception=exc) + + def flow_through(self, *funcs: Callable[[T], p.Result[T]]) -> rt[T]: + current: rt[T] = self._as_result() + for func in funcs: + if current.success: + try: + current = current.__class__.from_result(func(current.value)) + except c.EXC_BROAD_RUNTIME as exc: + current = current.__class__.fail(str(exc), exception=exc) + else: + break + return current + + def fold[U]( + self, on_failure: Callable[[str], U], on_success: Callable[[T], U] + ) -> U: + if self.success: + return on_success(self._payload) + return on_failure(self.require_error(self._as_result())) + + def lash[U](self, func: Callable[[str], p.Result[U]]) -> rt[T | U]: + if self.failure: + try: + return cast( + "rt[T | U]", + self.__class__.from_result( + func(self.require_error(self._as_result())) + ), + ) + except c.EXC_BROAD_RUNTIME as exc: + return self.__class__.fail(str(exc), exception=exc) + return cast("rt[T | U]", self._as_result()) + + def map[U](self, func: Callable[[T], U]) -> rt[U]: + if self.success: + try: + return self.__class__.ok(func(self._payload)) + except c.EXC_BROAD_RUNTIME as exc: + return self.__class__.fail(str(exc), exception=exc) + return self.__class__.fail( + self.require_error(self._as_result()), + error_code=self.error_code, + error_data=self.error_data, + exception=self._exception, + ) + + def map_error(self, func: Callable[[str], str]) -> rt[T]: + if self.failure: + try: + return self.__class__.fail( + func(self.require_error(self._as_result())), + error_code=self.error_code, + error_data=self.error_data, + exception=self._exception, + ) + except c.EXC_BROAD_RUNTIME as exc: + return self.__class__.fail(str(exc), exception=exc) + return self._as_result() + + @overload + def map_or(self, default: None, func: None = None) -> T | None: ... + @overload + def map_or[U](self, default: U, func: None = None) -> T | U: ... + @overload + def map_or[U](self, default: U, func: Callable[[T], U]) -> U: ... + + def map_or[U](self, default: U, func: Callable[[T], U] | None = None) -> U | T: + if self.success: + if func is not None: + return func(self._payload) + return self._payload + return default + + def recover[U](self, func: Callable[[str], U]) -> rt[T | U]: + if self.success: + return cast("rt[T | U]", self) + try: + return self.__class__.ok(func(self.require_error(self._as_result()))) + except c.EXC_BROAD_RUNTIME as exc: + return self.__class__.fail(str(exc), exception=exc) + + def tap(self, func: Callable[[T], None]) -> rt[T]: + if self.success: + try: + func(self._payload) + except c.EXC_BROAD_RUNTIME as exc: + return self.__class__.fail(str(exc), exception=exc) + return self._as_result() + + def tap_error(self, func: Callable[[str], None]) -> rt[T]: + if self.failure: + try: + func(self.require_error(self._as_result())) + except c.EXC_BROAD_RUNTIME as exc: + return self.__class__.fail(str(exc), exception=exc) + return self._as_result() + + def to_model[U: BaseModel](self, model: type[U]) -> rt[U]: + if self.failure: + return self.__class__.fail( + self.require_error(self._as_result()), + error_code=self.error_code, + error_data=self.error_data, + exception=self._exception, + ) + try: + return self.__class__.ok(model.model_validate(self._payload)) + except c.EXC_ATTR_RUNTIME_VALIDATION as exc: + return self.__class__.fail(str(exc), exception=exc) + + +__all__: list[str] = ["FlextResultTransforms"] diff --git a/src/flext_core/_result_parts/unwrap.py b/src/flext_core/_result/unwrap.py similarity index 61% rename from src/flext_core/_result_parts/unwrap.py rename to src/flext_core/_result/unwrap.py index dad10e639..0322ff0e7 100644 --- a/src/flext_core/_result_parts/unwrap.py +++ b/src/flext_core/_result/unwrap.py @@ -2,23 +2,20 @@ from __future__ import annotations -from abc import ABC -from typing import TYPE_CHECKING, overload, override +from typing import TYPE_CHECKING, overload from flext_core._constants.errors import FlextConstantsErrors as c -from .behavior import FlextResultBehaviorMixin +from .composition import FlextResultComposition if TYPE_CHECKING: from collections.abc import Callable -class FlextResultUnwrapMixin[T](FlextResultBehaviorMixin[T], ABC): +class FlextResultUnwrap[T](FlextResultComposition[T]): """Value extraction helpers for results.""" - @override def unwrap(self) -> T: - """Unwrap the success value or raise RuntimeError.""" if self.failure: msg = c.ERR_RESULT_CANNOT_UNWRAP.format(error=self.error) raise RuntimeError(msg) @@ -29,11 +26,9 @@ def unwrap_or(self, default: T) -> T: ... @overload def unwrap_or[DefaultT](self, default: DefaultT) -> T | DefaultT: ... - @override def unwrap_or[DefaultT](self, default: DefaultT) -> T | DefaultT: - """Return success value or default; safe extraction.""" - if self.success and self.value is not None: - return self.value + if self.success: + return self._payload return default @overload @@ -43,12 +38,10 @@ def unwrap_or_else[DefaultT]( self, func: Callable[[], DefaultT] ) -> T | DefaultT: ... - @override def unwrap_or_else[DefaultT](self, func: Callable[[], DefaultT]) -> T | DefaultT: - """Return the success value or call func if failed.""" - if self.success and self.value is not None: - return self.value + if self.success: + return self._payload return func() -__all__: list[str] = ["FlextResultUnwrapMixin"] +__all__: list[str] = ["FlextResultUnwrap"] diff --git a/src/flext_core/_result_parts/__init__.py b/src/flext_core/_result_parts/__init__.py deleted file mode 100644 index daf5cf4d4..000000000 --- a/src/flext_core/_result_parts/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -# AUTO-GENERATED FILE โ€” Regenerate with: make gen -"""Result Parts package.""" - -from __future__ import annotations - -__all__: tuple[str, ...] = () diff --git a/src/flext_core/_result_parts/behavior.py b/src/flext_core/_result_parts/behavior.py deleted file mode 100644 index 18e76b45c..000000000 --- a/src/flext_core/_result_parts/behavior.py +++ /dev/null @@ -1,41 +0,0 @@ -"""Shared typing contract for FlextResult implementation mixins.""" - -from __future__ import annotations - -from abc import ABC, abstractmethod -from typing import TYPE_CHECKING - -from flext_core._protocols.result import FlextProtocolsResult as p - -if TYPE_CHECKING: - from returns.result import Result - - from flext_core import FlextTypes as t - from flext_core._models.containers import FlextModelsContainers as mc - - -class FlextResultBehaviorMixin[T](p.Result[T], ABC): - """Internal contract supplied by the public FlextResult facade.""" - - _exception: BaseException | None - _result: Result[T, str] | None - - def __init__( - self, - error_code: str | None = None, - error_data: t.JsonMapping | t.ConfigModelInput | None = None, - *, - value: T | None = None, - error: str | None = None, - success: bool = True, - exception: BaseException | None = None, - ) -> None: ... - - @staticmethod - @abstractmethod - def _validate_error_data( - error_data: t.JsonMapping | t.ConfigModelInput | None, - ) -> mc.ConfigMap | None: ... - - -__all__: list[str] = ["FlextResultBehaviorMixin"] diff --git a/src/flext_core/_result_parts/construction.py b/src/flext_core/_result_parts/construction.py deleted file mode 100644 index 7990a94d2..000000000 --- a/src/flext_core/_result_parts/construction.py +++ /dev/null @@ -1,182 +0,0 @@ -"""Construction and factory operations for FlextResult.""" - -from __future__ import annotations - -from abc import ABC -from typing import TYPE_CHECKING, cast - -from pydantic import ValidationError - -from flext_core._constants.errors import FlextConstantsErrors as c -from flext_core._constants.infrastructure import FlextConstantsInfrastructure -from flext_core._constants.mixins import FlextConstantsMixins -from flext_core._models.pydantic import FlextModelsPydantic as mp - -if TYPE_CHECKING: - from collections.abc import Callable - - from flext_core import FlextTypes as t - from flext_core._models.containers import FlextModelsContainers as mc - from flext_core._protocols.result import FlextProtocolsResult as p - -from .behavior import FlextResultBehaviorMixin - - -class FlextResultConstructionMixin[T](FlextResultBehaviorMixin[T], ABC): - """Factory methods for the concrete result facade.""" - - @staticmethod - def require_error[V](source: p.ResultLike[V]) -> str: - """Return a failure message or raise when the Result invariant is broken.""" - error = source.error - if not error: - raise ValueError(c.ERR_RESULT_FAILURE_MESSAGE_REQUIRED) - return error - - @classmethod - def from_failure[V]( - cls: type[FlextResultConstructionMixin[T]], source: p.ResultLike[V] - ) -> FlextResultConstructionMixin[T]: - """Rebind one failure payload type while preserving its full error state.""" - if source.success: - raise ValueError(c.ERR_RESULT_FAILURE_REQUIRED) - return cls.fail( - cls.require_error(source), - error_code=source.error_code, - error_data=source.error_data, - exception=source.exception, - ) - - @classmethod - def _extract_exception_error_code( - cls, exception: BaseException | None - ) -> str | None: - if exception is None: - return None - error_code = getattr(exception, "error_code", None) - return error_code if isinstance(error_code, str) and error_code else None - - @classmethod - def _extract_exception_error_data( - cls, exception: BaseException | None - ) -> mc.ConfigMap | None: - if exception is None: - return None - metadata = getattr(exception, "metadata", None) - raw_attributes = getattr(metadata, FlextConstantsMixins.FIELD_ATTRIBUTES, None) - if raw_attributes is None: - return None - try: - payload = cls._validate_error_data(raw_attributes) - except ValidationError: - return None - if payload is None: - return None - correlation_id = getattr(exception, "correlation_id", None) - if isinstance(correlation_id, str) and correlation_id: - payload[FlextConstantsInfrastructure.ContextKey.CORRELATION_ID] = ( - correlation_id - ) - return payload - - @classmethod - def _from_result[V](cls, source: p.Result[V]) -> FlextResultConstructionMixin[V]: - if source.success: - # Cannot use source.value: it raises ValueError when _payload is None. - # r[None].ok(None) is a valid success with a None payload โ€” access the - # private attribute directly to bypass the non-None guard on the property. - raw: V | None = getattr(source, "_payload", None) - return cls.ok(cast("V", raw)) - # Type bridge: normalized failures carry the source result payload type. - result_class = cast("type[FlextResultConstructionMixin[V]]", cls) - return result_class.fail( - cls.require_error(source), - error_code=source.error_code, - error_data=source.error_data, - exception=source.exception, - ) - - @classmethod - def create_from_callable[V]( - cls, func: Callable[[], V | None], error_code: str | None = None - ) -> FlextResultConstructionMixin[V]: - """Execute callable; catch exceptions and None returns.""" - try: - value = func() - if value is None: - # Type bridge: callable failures carry the callable payload type. - result_class = cast("type[FlextResultConstructionMixin[V]]", cls) - return result_class.fail( - "Callable returned None", error_code=error_code - ) - return cls.ok(value) - except c.EXC_BROAD_RUNTIME as exc: - # Type bridge: callable exceptions carry the callable payload type. - result_class = cast("type[FlextResultConstructionMixin[V]]", cls) - return result_class.fail(str(exc), error_code=error_code, exception=exc) - - @classmethod - def fail[V]( - cls: type[FlextResultConstructionMixin[V]], - error: str | None, - *, - error_code: str | None = None, - error_data: t.JsonMapping | t.ConfigModelInput | None = None, - exception: BaseException | None = None, - ) -> FlextResultConstructionMixin[V]: - """Create failed result with error message, optional code and metadata.""" - error_msg = error if error is not None else "" - resolved_error_code = error_code or cls._extract_exception_error_code(exception) - resolved_error_data = ( - error_data - if error_data is not None - else cls._extract_exception_error_data(exception) - ) - return cls( - error_code=resolved_error_code, - error_data=resolved_error_data, - error=error_msg, - success=False, - exception=exception, - ) - - @classmethod - def fail_op[V]( - cls: type[FlextResultConstructionMixin[V]], - operation: str, - exc: Exception | str | None = None, - ) -> FlextResultConstructionMixin[V]: - """Create a failure result for a named operation with optional exception.""" - if isinstance(exc, Exception): - return cls.fail(f"{operation} failed: {exc}", exception=exc) - error_msg = ( - f"{operation} failed" if exc is None else f"{operation} failed: {exc}" - ) - return cls.fail(error_msg) - - @classmethod - def from_validation[ModelT: mp.BaseModel]( - cls, data: t.ModelInput, model: t.ModelClass[ModelT] - ) -> FlextResultConstructionMixin[ModelT]: - """Create result from Pydantic validation.""" - try: - return cls.ok(model.model_validate(data)) - except c.EXC_ATTR_RUNTIME_VALIDATION as exc: - # Type bridge: validation failures carry the model payload type. - result_class = cast("type[FlextResultConstructionMixin[ModelT]]", cls) - return result_class.fail(str(exc), exception=exc) - - @classmethod - def ok[V](cls, value: V) -> FlextResultConstructionMixin[V]: - """Create successful result wrapping value.""" - # Type bridge: class factories intentionally rebind the generic payload. - result_class = cast("type[FlextResultConstructionMixin[V]]", cls) - return result_class(value=value, success=True) - - @classmethod - def from_result[V](cls, source: p.Result[V]) -> FlextResultConstructionMixin[V]: - """Normalize structural result to FlextResult.""" - return cls._from_result(source) - - -__all__: list[str] = ["FlextResultConstructionMixin"] diff --git a/src/flext_core/_result_parts/transforms.py b/src/flext_core/_result_parts/transforms.py deleted file mode 100644 index 151a876f7..000000000 --- a/src/flext_core/_result_parts/transforms.py +++ /dev/null @@ -1,197 +0,0 @@ -"""Monadic transform operations for FlextResult.""" - -from __future__ import annotations - -from abc import ABC -from typing import TYPE_CHECKING, Self, cast, overload, override - -from flext_core._constants.errors import FlextConstantsErrors as c -from flext_core._models.pydantic import FlextModelsPydantic as mp - -from .construction import FlextResultConstructionMixin - -if TYPE_CHECKING: - from collections.abc import Callable - - from flext_core._protocols.result import FlextProtocolsResult as p - - -class FlextResultTransformsMixin[T](FlextResultConstructionMixin[T], ABC): - """Instance transformation methods for result values and errors.""" - - @override - def filter(self, predicate: Callable[[T], bool]) -> p.Result[T]: - """Filter success value; returns self or failure if predicate fails.""" - if self.success and self.value is not None: - try: - if predicate(self.value): - return self - return self.__class__.fail(c.ERR_RESULT_FILTER_PREDICATE_FAILED) - except c.EXC_BROAD_RUNTIME as exc: - return self.__class__.fail(str(exc), exception=exc) - return self - - @override - def flat_map[U](self, func: Callable[[T], p.Result[U]]) -> p.Result[U]: - """Chain operations returning a Result; func produces Result directly.""" - if self.failure: - # Type bridge: propagated failures adopt the chained payload type. - result_class = cast("type[FlextResultConstructionMixin[U]]", self.__class__) - return result_class.fail( - self.require_error(self), - error_code=self.error_code, - error_data=self.error_data, - exception=self.exception, - ) - try: - return self.__class__.from_result(func(self.value)) - except c.EXC_BROAD_RUNTIME as exc: - # Type bridge: callback exceptions adopt the chained payload type. - result_class = cast("type[FlextResultConstructionMixin[U]]", self.__class__) - return result_class.fail(str(exc), exception=exc) - - @override - def flow_through(self, *funcs: Callable[[T], p.Result[T]]) -> p.Result[T]: - """Chain multiple homogeneous Result-returning operations in sequence.""" - current = self - result_class = cast("type[FlextResultConstructionMixin[T]]", self.__class__) - for func in funcs: - if current.success: - result_value = current.value - if result_value is not None: - try: - current = self.__class__.from_result(func(result_value)) - except c.EXC_BROAD_RUNTIME as exc: - current = result_class.fail(str(exc), exception=exc) - else: - break - else: - break - return current - - @override - def fold[U]( - self, on_failure: Callable[[str], U], on_success: Callable[[T], U] - ) -> U: - """Catamorphism: reduce result to a single value via callbacks.""" - if self.success and self.value is not None: - return on_success(self.value) - return on_failure(self.require_error(self)) - - @override - def lash(self, func: Callable[[str], p.Result[T]]) -> p.Result[T]: - """Apply recovery function on failure; returns self if success.""" - if self.failure: - try: - return self.__class__.from_result(func(self.require_error(self))) - except c.EXC_BROAD_RUNTIME as exc: - return self.__class__.fail(str(exc), exception=exc) - return self - - @override - def map[U](self, func: Callable[[T], U]) -> p.Result[U]: - """Transform success value; propagates failure.""" - if self.success: - try: - return self.__class__.ok(func(self.value)) - except c.EXC_BROAD_RUNTIME as exc: - # Type bridge: mapper exceptions adopt the mapped payload type. - result_class = cast( - "type[FlextResultConstructionMixin[U]]", self.__class__ - ) - return result_class.fail(str(exc), exception=exc) - # Type bridge: propagated failures adopt the mapped payload type. - result_class = cast("type[FlextResultConstructionMixin[U]]", self.__class__) - return result_class.fail( - self.require_error(self), - error_code=self.error_code, - error_data=self.error_data, - exception=self.exception, - ) - - @override - def map_error(self, func: Callable[[str], str]) -> p.Result[T]: - """Transform error message; returns self if success.""" - if self.failure: - try: - return self.__class__.fail( - func(self.require_error(self)), - error_code=self.error_code, - error_data=self.error_data, - exception=self.exception, - ) - except c.EXC_BROAD_RUNTIME as exc: - return self.__class__.fail(str(exc), exception=exc) - return self - - @overload - def map_or(self, default: None, func: None = None) -> T | None: ... - @overload - def map_or[U](self, default: U, func: None = None) -> T | U: ... - @overload - def map_or[U](self, default: U, func: Callable[[T], U]) -> U: ... - - @override - def map_or[U](self, default: U, func: Callable[[T], U] | None = None) -> U | T: - """Apply func to success value or return default; func optional.""" - if self.success and self.value is not None: - if func is not None: - return func(self.value) - return self.value - return default - - @override - def recover[U](self, func: Callable[[str], U]) -> p.Result[T | U]: - """Recover from failure with fallback value via callback.""" - if self.success: - value: T | U = self.value - return self.__class__.ok(value) - try: - return self.__class__.ok(func(self.require_error(self))) - except c.EXC_BROAD_RUNTIME as exc: - result_class = cast( - "type[FlextResultConstructionMixin[T | U]]", self.__class__ - ) - return result_class.fail(str(exc), exception=exc) - - @override - def tap(self, func: Callable[[T], None]) -> p.Result[T]: - """Apply side effect to success value; return unchanged.""" - if self.success and self.value is not None: - try: - func(self.value) - except c.EXC_BROAD_RUNTIME as exc: - return self.__class__.fail(str(exc), exception=exc) - return self - - @override - def tap_error(self, func: Callable[[str], None]) -> Self: - """Side effect on failure; return unchanged.""" - if self.failure: - try: - func(self.require_error(self)) - except c.EXC_BROAD_RUNTIME as exc: - return cast("Self", self.__class__.fail(str(exc), exception=exc)) - return self - - @override - def to_model[U: mp.BaseModel](self, model: type[U]) -> p.Result[U]: - """Convert success value to Pydantic model; propagates failure.""" - if self.failure: - # Type bridge: propagated failures adopt the converted model type. - result_class = cast("type[FlextResultConstructionMixin[U]]", self.__class__) - return result_class.fail( - self.require_error(self), - error_code=self.error_code, - error_data=self.error_data, - exception=self.exception, - ) - try: - return self.__class__.ok(model.model_validate(self.value)) - except c.EXC_ATTR_RUNTIME_VALIDATION as exc: - # Type bridge: validation failures adopt the converted model type. - result_class = cast("type[FlextResultConstructionMixin[U]]", self.__class__) - return result_class.fail(str(exc), exception=exc) - - -__all__: list[str] = ["FlextResultTransformsMixin"] diff --git a/src/flext_core/_runtime/_dependency_bindings.py b/src/flext_core/_runtime/_dependency_bindings.py index 1d1dc60e9..7b18374e5 100644 --- a/src/flext_core/_runtime/_dependency_bindings.py +++ b/src/flext_core/_runtime/_dependency_bindings.py @@ -10,6 +10,7 @@ from typing import TYPE_CHECKING from dependency_injector import containers, providers, wiring +from dependency_injector.containers import Container from flext_core._constants.errors import FlextConstantsErrors as ce from flext_core._constants.file import FlextConstantsFile as cf @@ -162,7 +163,7 @@ def register_resource[T]( @staticmethod def wire( - container: containers.Container, + container: Container, *, modules: tb.SequenceOf[ModuleType] | None = None, packages: tb.StrSequence | None = None, @@ -176,10 +177,7 @@ def wire( if module is not None: modules_to_wire.append(module) _ = packages - wire_runtime = wiring.wire - wire_runtime( - modules=modules_to_wire or None, packages=None, container=container - ) + wiring.wire(container, modules=modules_to_wire or None, packages=None) __all__: list[str] = ["FlextRuntimeDependencyBindings"] diff --git a/src/flext_core/_settings.py b/src/flext_core/_settings.py index b10634e61..30393259e 100644 --- a/src/flext_core/_settings.py +++ b/src/flext_core/_settings.py @@ -189,7 +189,7 @@ def _resolve_env_file(namespace: str | None = None) -> str: debug: Annotated[bool, Field(description="Enable debug mode")] = False trace: Annotated[bool, Field(description="Enable trace mode")] = False - log_level: Annotated[str, Field(default="INFO", description="Log level")] + log_level: str = Field(default="INFO", description="Log level") timezone: Annotated[ str, Field(description="IANA timezone for datetime operations") ] = "UTC" diff --git a/src/flext_core/_typings/services.py b/src/flext_core/_typings/services.py index 83ccbf9c9..48205a4ed 100644 --- a/src/flext_core/_typings/services.py +++ b/src/flext_core/_typings/services.py @@ -62,6 +62,7 @@ class FlextTypesServices: type MutableMetadataMapping = MutableMapping[str, JsonPayload] type RuntimeData = tp.JsonValue | tp.BaseModelType type BootstrapInput = tp.BaseModelType | t.JsonMapping + type ServiceClass = type[object] type ServiceValue = ( JsonPayload | tp.BaseModelType @@ -71,7 +72,11 @@ class FlextTypesServices: | ph.Dispatcher ) type UserOverridesMapping = t.MappingKV[str, JsonPayload] - type RegisterableService = ServiceValue | Callable[..., ServiceValue] + # Keep registerable-service shape to a single top-level union in this layer. + # This avoids ``no_inline_union`` violations while preserving the contract + # that services may be values, factories, or class references. + type RegisterableServiceValue = ServiceValue | Callable[..., ServiceValue] + type RegisterableService = RegisterableServiceValue | ServiceClass type FactoryCallable = Callable[[], RegisterableService] type ResourceCallable = Callable[[], RegisterableService] type ModelInput = tp.JsonValue | prt.HasModelDump | t.MappingKV[str, JsonPayload] @@ -83,9 +88,7 @@ class FlextTypesServices: type ContextHookCallable = Callable[[t.Scalar], JsonPayload] type ContextHookMap = t.MappingKV[str, t.SequenceOf[ContextHookCallable]] - type HandlerCallable = Callable[ - ..., tp.BaseModelType | prt.ResultLike[ScalarOrModel] - ] + type HandlerCallable = Callable[..., tp.BaseModelType | prt.Result[ScalarOrModel]] type DispatchableHandler = ( tp.BaseModelType | ph.DispatchMessage @@ -93,14 +96,15 @@ class FlextTypesServices: | ph.Execute | ph.AutoDiscoverableHandler | Callable[ - ..., tp.BaseModelType | JsonPayload | prt.ResultLike[JsonPayload] | None + [p.Routable], + tp.BaseModelType | JsonPayload | prt.Result[JsonPayload] | None, ] ) type ResolvedHandlerCallable = Callable[ - ..., tp.BaseModelType | JsonPayload | prt.ResultLike[JsonPayload] | None + ..., tp.BaseModelType | JsonPayload | prt.Result[JsonPayload] | None ] type RoutedHandlerCallable = Callable[ - [p.Routable], JsonPayload | prt.ResultLike[JsonPayload] | None + [p.Routable], JsonPayload | prt.Result[JsonPayload] | None ] type RegistrablePlugin = ScalarOrModel | Callable[..., ScalarOrModel] type LoggerFactory = Callable[..., pl.OutputLogger] | None @@ -168,7 +172,7 @@ class FlextTypesServices: | prt.HasModelDump | pr.Registry | p.Model - | prt.ResultLike[JsonPayload] + | prt.Result[JsonPayload] | ps.Settings | RegisterableService | t.SequenceOf[JsonPayload] diff --git a/src/flext_core/_utilities/_checker_parts/checker_part_02.py b/src/flext_core/_utilities/_checker_parts/checker_part_02.py index 84db4956d..f4a60afe4 100644 --- a/src/flext_core/_utilities/_checker_parts/checker_part_02.py +++ b/src/flext_core/_utilities/_checker_parts/checker_part_02.py @@ -13,11 +13,10 @@ import inspect from typing import TYPE_CHECKING, get_origin, get_type_hints -from flext_core import FlextConstants as c +from flext_core import FlextConstants as c, r from flext_core._protocols.result import FlextProtocolsResult as p from flext_core._typings.base import FlextTypingBase as tb from flext_core._typings.services import FlextTypesServices as ts -from flext_core.result import FlextResult as r from .checker_part_01 import FlextUtilitiesChecker as FlextUtilitiesCheckerPart01 diff --git a/src/flext_core/_utilities/_checker_parts/checker_part_03.py b/src/flext_core/_utilities/_checker_parts/checker_part_03.py index 482d8f29c..1354c04d4 100644 --- a/src/flext_core/_utilities/_checker_parts/checker_part_03.py +++ b/src/flext_core/_utilities/_checker_parts/checker_part_03.py @@ -15,12 +15,11 @@ from pydantic import BaseModel -from flext_core import FlextConstants as c +from flext_core import FlextConstants as c, r from flext_core._protocols.base import FlextProtocolsBase as pb from flext_core._protocols.result import FlextProtocolsResult as p from flext_core._typings.base import FlextTypingBase as tb from flext_core._typings.services import FlextTypesServices as ts -from flext_core.result import FlextResult as r from .checker_part_02 import FlextUtilitiesChecker as FlextUtilitiesCheckerPart02 diff --git a/src/flext_core/_utilities/_enforcement_collect_parts/enforcement_collect_part_01.py b/src/flext_core/_utilities/_enforcement_collect_parts/enforcement_collect_part_01.py index d7ab4bed2..cabf4e401 100644 --- a/src/flext_core/_utilities/_enforcement_collect_parts/enforcement_collect_part_01.py +++ b/src/flext_core/_utilities/_enforcement_collect_parts/enforcement_collect_part_01.py @@ -16,6 +16,16 @@ from flext_core._utilities.enforcement_emit import FlextUtilitiesEnforcementEmit from flext_core._utilities.project_metadata import FlextUtilitiesProjectMetadata as upm +_ERR_ENFORCEMENT_NAMESPACE_METADATA = ( + "Cannot read project metadata for enforcement namespace resolution" +) +_ERR_ENFORCEMENT_CLASS_STEM_METADATA = ( + "Cannot read project metadata for enforcement class stem override" +) +_ERR_ENFORCEMENT_NAMESPACE_SOURCE = ( + "Cannot inspect target source module for namespace resolution" +) + class FlextUtilitiesEnforcementCollect(FlextUtilitiesEnforcementEmit): """Project resolution + rule-input iterators.""" @@ -23,13 +33,12 @@ class FlextUtilitiesEnforcementCollect(FlextUtilitiesEnforcementEmit): @staticmethod def _owning_project_root(target: type) -> Path | None: """Return the pyproject root that physically owns the target source.""" - try: - src_file = inspect.getsourcefile(target) - except (OSError, TypeError): - return None - if src_file is None: + source_file = FlextUtilitiesEnforcementCollect._resolve_target_source_file( + target + ) + if source_file is None: return None - source = Path(src_file).resolve() + source = source_file.resolve() top = (getattr(target, "__module__", "") or "").split(".", 1)[0] if not top: return None @@ -48,6 +57,17 @@ def _owning_project_root(target: type) -> Path | None: return parent return None + @staticmethod + def _resolve_target_source_file(target: type) -> Path | None: + """Resolve target source file path with explicit error semantics.""" + try: + src_file = inspect.getsourcefile(target) + except (OSError, TypeError) as exc: + raise RuntimeError(_ERR_ENFORCEMENT_NAMESPACE_SOURCE) from exc + if src_file is None: + return None + return Path(src_file) + @staticmethod def _discover_src_package(target: type) -> str | None: """Return the package owned by the target's physical project root. @@ -57,17 +77,22 @@ def _discover_src_package(target: type) -> str | None: and ``OSError`` when the source file cannot be read; both produce ``None`` here so the dispatcher cleanly skips the target. """ - project_root = FlextUtilitiesEnforcementCollect._owning_project_root(target) + try: + project_root = FlextUtilitiesEnforcementCollect._owning_project_root(target) + except RuntimeError: + return None if project_root is None: return None top = (getattr(target, "__module__", "") or "").split(".", 1)[0] if (project_root / "src" / top).is_dir() or (project_root / top).is_dir(): return top - metadata_result = upm.read_project_metadata(project_root) - if metadata_result.failure: + try: + document = upm.read_project_document_cached(project_root) + except (OSError, ValueError) as exc: + raise RuntimeError(_ERR_ENFORCEMENT_NAMESPACE_METADATA) from exc + if document.project is None: return None - package_name: str = metadata_result.value.package_name - return package_name + return upm.build_project_metadata(project_root, document).package_name @staticmethod def _project(target: type) -> t.StrPair | None: @@ -85,12 +110,12 @@ def _project(target: type) -> t.StrPair | None: class_stem_override = None project_root = FlextUtilitiesEnforcementCollect._owning_project_root(target) if project_root is not None: - metadata_result = upm.read_project_metadata(project_root) - if metadata_result.failure: - return None - class_stem_override = ( - metadata_result.value.flext.project.class_stem_override - ) + try: + document = upm.read_project_document_cached(project_root) + except (OSError, ValueError) as exc: + raise RuntimeError(_ERR_ENFORCEMENT_CLASS_STEM_METADATA) from exc + metadata = upm.build_project_metadata(project_root, document) + class_stem_override = metadata.flext.project.class_stem_override canonical_project_name = src.replace("_", "-") head, _, tail = canonical_project_name.partition("-") namespace = upm.derive_class_stem(tail or head) diff --git a/src/flext_core/_utilities/_guards_type_protocol_types.py b/src/flext_core/_utilities/_guards_type_protocol_types.py index a7ad6c698..85f86dcd6 100644 --- a/src/flext_core/_utilities/_guards_type_protocol_types.py +++ b/src/flext_core/_utilities/_guards_type_protocol_types.py @@ -21,7 +21,7 @@ | ph.Handle | ph.Middleware | pl.Logger - | pr.ResultLike[t.JsonPayload] + | pr.Result[t.JsonPayload] | ps.Settings | psrv.Service[t.JsonPayload] | None diff --git a/src/flext_core/_utilities/_mapper_access_parts/mapper_access_part_01.py b/src/flext_core/_utilities/_mapper_access_parts/mapper_access_part_01.py index 6585bb292..80cfb60a3 100644 --- a/src/flext_core/_utilities/_mapper_access_parts/mapper_access_part_01.py +++ b/src/flext_core/_utilities/_mapper_access_parts/mapper_access_part_01.py @@ -81,7 +81,7 @@ def _extract_get_value( key_part: str, ) -> p.Result[t.JsonPayload]: """Get a raw value from a mapping, model, or protocol object.""" - not_found_result = r[t.JsonPayload].fail_op( + not_found_result: p.Result[t.JsonPayload] = r[t.JsonPayload].fail_op( "extract key", e.render_template(c.ERR_TEMPLATE_KEY_NOT_FOUND, key=key_part) ) result: p.Result[t.JsonPayload] diff --git a/src/flext_core/_utilities/args.py b/src/flext_core/_utilities/args.py index c28ab6454..99ccc9f6a 100644 --- a/src/flext_core/_utilities/args.py +++ b/src/flext_core/_utilities/args.py @@ -8,10 +8,9 @@ from __future__ import annotations -from flext_core import FlextConstants as c, FlextTypes as t +from flext_core import FlextConstants as c, FlextTypes as t, r from flext_core._models.pydantic import FlextModelsPydantic as m from flext_core._protocols.result import FlextProtocolsResult as p -from flext_core.result import FlextResult as r class FlextUtilitiesArgs: diff --git a/src/flext_core/_utilities/config.py b/src/flext_core/_utilities/config.py index 1740001d9..445e1674f 100644 --- a/src/flext_core/_utilities/config.py +++ b/src/flext_core/_utilities/config.py @@ -17,11 +17,11 @@ from pathlib import Path from typing import TYPE_CHECKING, ClassVar +from flext_core import r from flext_core._constants.config import FlextConstantsConfig as c from flext_core._typings.base import FlextTypingBase as t from flext_core._utilities.guards_type_core import FlextUtilitiesGuardsTypeCore as g from flext_core._utilities.reliability import FlextUtilitiesReliability as rel -from flext_core.result import r if TYPE_CHECKING: from collections.abc import Mapping diff --git a/src/flext_core/_utilities/dispatcher_execute.py b/src/flext_core/_utilities/dispatcher_execute.py index df68f35d6..82dda18a8 100644 --- a/src/flext_core/_utilities/dispatcher_execute.py +++ b/src/flext_core/_utilities/dispatcher_execute.py @@ -28,7 +28,7 @@ def _adapt_dispatcher_output( result = dispatch_result.fail_op( "validate handler return payload", c.ERR_HANDLER_RETURNED_NONE ) - elif isinstance(raw_output, p.ResultLike): + elif isinstance(raw_output, p.Result): if raw_output.failure: result = dispatch_result.from_failure(raw_output) else: @@ -57,7 +57,7 @@ def _adapt_dispatcher_output( def _normalize_dispatcher_output( - raw_candidate: t.JsonPayload | p.ResultLike[t.JsonPayload] | None, + raw_candidate: t.JsonPayload | p.Result[t.JsonPayload] | None, dispatch_result: type[r[t.JsonPayload]], ) -> t.JsonPayload | p.Result[t.JsonPayload] | None: if isinstance(raw_candidate, r): diff --git a/src/flext_core/_utilities/handler.py b/src/flext_core/_utilities/handler.py index 815fc4d72..9b6031ad8 100644 --- a/src/flext_core/_utilities/handler.py +++ b/src/flext_core/_utilities/handler.py @@ -67,7 +67,7 @@ def push_context( ) -> p.Result[p.HandlerRuntimeState]: """Validate a context and return state with an extended stack.""" if not isinstance(ctx, Mapping): - execution_context = ctx.model_copy() + pushed_context = ctx.model_copy() else: from flext_core import m @@ -75,9 +75,10 @@ def push_context( if validated.failure: return r.fail_op("push handler context", validated.error) execution_context: p.ExecutionContext = validated.unwrap() + pushed_context = execution_context return r.ok( state.model_copy( - update={"context_stack": (*state.context_stack, execution_context)} + update={"context_stack": (*state.context_stack, pushed_context)} ) ) diff --git a/src/flext_core/_utilities/mapper.py b/src/flext_core/_utilities/mapper.py index d15136d1b..ed95faa59 100644 --- a/src/flext_core/_utilities/mapper.py +++ b/src/flext_core/_utilities/mapper.py @@ -10,12 +10,11 @@ from itertools import starmap from typing import TYPE_CHECKING -from flext_core import m, t +from flext_core import m, r, t from flext_core._models.pydantic import FlextModelsPydantic from flext_core._utilities.collection import FlextUtilitiesCollection from flext_core._utilities.guards_type_core import FlextUtilitiesGuardsTypeCore from flext_core._utilities.mapper_extract import FlextUtilitiesMapperExtract -from flext_core.result import r from flext_core.runtime import FlextRuntime if TYPE_CHECKING: diff --git a/src/flext_core/_utilities/model_runtime.py b/src/flext_core/_utilities/model_runtime.py index 5edbeec25..d7d3ffb7b 100644 --- a/src/flext_core/_utilities/model_runtime.py +++ b/src/flext_core/_utilities/model_runtime.py @@ -78,7 +78,7 @@ def normalize_service_registration_spec( cls, registration: m.ServiceRegistrationSpec ) -> m.ServiceRegistrationSpec: """Normalize declarative bootstrap values into registration records.""" - return registration.model_copy( + normalized: m.ServiceRegistrationSpec = registration.model_copy( update={ "services": cls.normalize_service_registrations(registration.services), "factories": cls.normalize_factory_registrations( @@ -89,6 +89,7 @@ def normalize_service_registration_spec( ), } ) + return normalized @staticmethod def _resolve_runtime_dispatcher( diff --git a/src/flext_core/_utilities/parser_coerce.py b/src/flext_core/_utilities/parser_coerce.py index 15917c957..37dd05b60 100644 --- a/src/flext_core/_utilities/parser_coerce.py +++ b/src/flext_core/_utilities/parser_coerce.py @@ -10,8 +10,7 @@ from collections.abc import Callable from typing import TYPE_CHECKING, ClassVar -from flext_core import c, m -from flext_core.result import r +from flext_core import c, m, r if TYPE_CHECKING: from flext_core import p, t diff --git a/src/flext_core/_utilities/project_metadata.py b/src/flext_core/_utilities/project_metadata.py index e755b73cb..a680bb3a2 100644 --- a/src/flext_core/_utilities/project_metadata.py +++ b/src/flext_core/_utilities/project_metadata.py @@ -1,4 +1,9 @@ -"""Canonical project metadata boundary and derivation utilities.""" +"""Canonical project metadata boundary and derivation utilities. + +Pure data ingress and naming utilities. Result object creation is explicit and +typed through ``p.Result`` contracts, using internal concrete helpers only for +construction. +""" from __future__ import annotations @@ -11,18 +16,14 @@ from flext_core._constants.project_metadata import FlextConstantsProjectMetadata as cpm from flext_core._models.project_metadata import FlextModelsProjectMetadata as mpm from flext_core._protocols.project_metadata import FlextProtocolsProjectMetadata as ppm -from flext_core._protocols.result import FlextProtocolsResult as pr +from flext_core._protocols.result import FlextProtocolsResult as p +from flext_core.result import FlextResult as _Result from flext_core._typings.base import FlextTypingBase as t -from flext_core.result import FlextResult as r if TYPE_CHECKING: from pathlib import Path -# NOTE (multi-agent, mro-wkii.17.23 / agent: uv_overlay_owner): this utility -# retains only the useful ingress and naming owners. Lazy alias discovery stays -# on the existing beartype utility facade; ProjectConstants and scan copies are -# deleted instead of being transported through another model. class FlextUtilitiesProjectMetadata(mpm): """Project metadata ingress and canonical name derivation.""" @@ -34,63 +35,75 @@ class FlextUtilitiesProjectMetadata(mpm): @classmethod def _normalize_distribution_name(cls, distribution_name: str) -> str: - """Return the PEP 503 normalized form of one distribution name.""" return cls._DISTRIBUTION_SEPARATOR_RE.sub( "-", distribution_name.strip().lower() ) @staticmethod @cache - def _read_project_document_cached(root: Path) -> mpm.PyprojectDocument: - """Parse and validate one canonical pyproject document exactly once. - - Cached by project root because pyproject.toml is read repeatedly - during workspace-wide scans (enforcement collection, census, gates). - The cache is process-scoped and safe: a single command never mutates - its own pyproject.toml while still needing the pre-mutation document. - """ + def read_project_document_cached(root: Path) -> mpm.PyprojectDocument: pyproject = root / cf.PYPROJECT_FILENAME with pyproject.open("rb") as stream: return mpm.PyprojectDocument.model_validate(tomllib.load(stream)) @classmethod - def _read_project_document(cls, root: Path) -> pr.Result[mpm.PyprojectDocument]: - """Return a cached parsed pyproject document or a typed failure.""" - try: - document = cls._read_project_document_cached(root) - except (OSError, ValueError) as exc: - pyproject = root / cf.PYPROJECT_FILENAME - return r[mpm.PyprojectDocument].fail( - f"cannot load project metadata from {pyproject}: {exc}" - ) - return r[mpm.PyprojectDocument].ok(document) - - @classmethod - def _retain_project_metadata( + def build_project_metadata( cls, root: Path, document: mpm.PyprojectDocument - ) -> pr.Result[ppm.ProjectMetadata]: - """Retain exact validated declarations as canonical project metadata.""" + ) -> ppm.ProjectMetadata: project = document.project + flext = document.tool.flext if project is None: - return r[ppm.ProjectMetadata].fail( - f"cannot load project metadata from {root / cf.PYPROJECT_FILENAME}: " - "PEP 621 [project] table is required" + package_name = flext.docs.package_name or "unknown" + class_stem = flext.project.class_stem_override or cls.derive_class_stem( + package_name ) - flext = document.tool.flext - metadata = mpm.ProjectMetadata( + resolved_project = mpm.Project(name=package_name, version="0.0.0") + return mpm.ProjectMetadata( + root=root, + package_name=package_name, + class_stem=class_stem, + project=resolved_project, + flext=flext, + ) + resolved_project = project + return mpm.ProjectMetadata( root=root, - package_name=flext.docs.package_name or project.name.replace("-", "_"), + package_name=flext.docs.package_name + or resolved_project.name.replace("-", "_"), class_stem=( - flext.project.class_stem_override or cls.derive_class_stem(project.name) + flext.project.class_stem_override + or cls.derive_class_stem(resolved_project.name) ), - project=project, + project=resolved_project, flext=flext, ) - return r[ppm.ProjectMetadata].ok(metadata) + + @staticmethod + def read_project_metadata(root: Path) -> p.Result[ppm.ProjectMetadata]: + """Read project metadata from ``pyproject.toml`` and return a protocol result. + + The method remains as a compatibility surface for existing consumers while + delegating the canonical behavior to: + + - ``read_project_document_cached`` + - ``build_project_metadata`` + """ + try: + project_root = root.resolve() + document = FlextUtilitiesProjectMetadata.read_project_document_cached( + project_root + ) + return _Result[ppm.ProjectMetadata].ok( + FlextUtilitiesProjectMetadata.build_project_metadata( + project_root, document + ) + ) + except (OSError, ValueError, tomllib.TOMLDecodeError) as exc: + msg = f"cannot read project metadata from {root}: {exc}" + return _Result[ppm.ProjectMetadata].fail(msg, exception=exc) @staticmethod def derive_class_stem(project_name: str) -> str: - """Return the total canonical class-stem projection for a project name.""" normalized = project_name.lower() override = next( (value for name, value in cpm.SPECIAL_NAME_OVERRIDES if name == normalized), @@ -105,12 +118,6 @@ def derive_class_stem(project_name: str) -> str: def project_uses_distribution( cls, metadata: ppm.ProjectMetadata, distribution_name: str ) -> bool: - """Return whether a project is or depends on a distribution. - - Project and dependency names use PEP 503 normalization. Dependency - declarations retain their exact PEP 508 strings on the metadata model; - only their leading distribution name is compared here. - """ target_name = cls._normalize_distribution_name(distribution_name) if not target_name: return False @@ -125,33 +132,5 @@ def project_uses_distribution( return True return False - @classmethod - def project_uses_distribution_at( - cls, root: Path, distribution_name: str - ) -> pr.Result[bool]: - """Return whether a canonical project declares one distribution. - - A valid TOML document without a PEP 621 ``[project]`` table is not a - package project and therefore returns ``False``. Missing or malformed - documents remain failures at the filesystem ingress boundary. - """ - document_result = cls._read_project_document(root) - if document_result.failure: - return r[bool].from_failure(document_result) - document = document_result.value - if document.project is None: - return r[bool].ok(False) - return cls._retain_project_metadata(root, document).map( - lambda metadata: cls.project_uses_distribution(metadata, distribution_name) - ) - - @classmethod - def read_project_metadata(cls, root: Path) -> pr.Result[ppm.ProjectMetadata]: - """Validate one project document and retain its exact nested models.""" - document_result = cls._read_project_document(root) - if document_result.failure: - return r[ppm.ProjectMetadata].from_failure(document_result) - return cls._retain_project_metadata(root, document_result.value) - __all__: list[str] = ["FlextUtilitiesProjectMetadata"] diff --git a/src/flext_core/_utilities/pydantic.py b/src/flext_core/_utilities/pydantic.py index 3292d518a..196f07c78 100644 --- a/src/flext_core/_utilities/pydantic.py +++ b/src/flext_core/_utilities/pydantic.py @@ -49,7 +49,10 @@ class FlextUtilitiesPydantic: PrivateAttr = staticmethod(PrivateAttr) SkipValidation = SkipValidation - computed_field = computed_field + # Same unwrapped-class-attribute problem as Field/PrivateAttr above: + # pyright binds the bare decorator through the facade and infers the + # facade type for every decorated property (reportIndexIssue downstream). + computed_field = staticmethod(computed_field) field_validator = field_validator field_serializer = field_serializer model_validator = model_validator diff --git a/src/flext_core/_utilities/settings.py b/src/flext_core/_utilities/settings.py index 73cb3699e..8459ffc90 100644 --- a/src/flext_core/_utilities/settings.py +++ b/src/flext_core/_utilities/settings.py @@ -20,9 +20,8 @@ # under TYPE_CHECKING raises NameError at import (test_beartype_engine_claw_packages). # Do NOT "optimize" this into a TYPE_CHECKING block. Same precedent: model_options.py, # model_runtime.py. Contact owner of bead ai-hub-mkzg before touching this line. -from flext_core import FlextProtocols as p, FlextTypes as t +from flext_core import FlextProtocols as p, FlextTypes as t, r from flext_core._constants.environment import FlextConstantsEnvironment -from flext_core.result import r class FlextUtilitiesSettings: diff --git a/src/flext_core/dispatcher.py b/src/flext_core/dispatcher.py index b741943cf..ddaf919df 100644 --- a/src/flext_core/dispatcher.py +++ b/src/flext_core/dispatcher.py @@ -9,8 +9,8 @@ FlextProtocols as p, FlextTypes as t, FlextUtilities as u, + r, ) -from flext_core.result import FlextResult as r from ._utilities.dispatcher_execute import execute_dispatcher_handler diff --git a/src/flext_core/loggings.py b/src/flext_core/loggings.py index 4dc52d6ce..257e77de0 100644 --- a/src/flext_core/loggings.py +++ b/src/flext_core/loggings.py @@ -18,6 +18,7 @@ FlextExceptions as e, FlextProtocols as p, FlextTypes as t, + r, ) from flext_core._constants.errors import FlextConstantsErrors as ce from flext_core._constants.logging import FlextConstantsLogging as cl @@ -25,7 +26,6 @@ from flext_core._models.containers import FlextModelsContainers as mc from flext_core._utilities.generators import FlextUtilitiesGenerators as ug from flext_core._utilities.logging_context import FlextUtilitiesLoggingContext as ulc -from flext_core.result import FlextResult as r if TYPE_CHECKING: import types diff --git a/src/flext_core/result.py b/src/flext_core/result.py index 893d2b185..7c8299397 100644 --- a/src/flext_core/result.py +++ b/src/flext_core/result.py @@ -2,200 +2,60 @@ from __future__ import annotations -from types import TracebackType -from typing import ( - TYPE_CHECKING, - Annotated, - ClassVar, - Self, - TypeIs, - cast, - overload, - override, -) - -from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, computed_field -from returns.result import Failure, Result, Success - -from ._constants.errors import FlextConstantsErrors as c -from ._models.containers import FlextModelsContainers as mc -from ._result_parts.composition import FlextResultCompositionMixin -from ._result_parts.transforms import FlextResultTransformsMixin -from ._result_parts.unwrap import FlextResultUnwrapMixin -from ._runtime._metadata import FlextRuntimeMetadata as FlextRuntime +from typing import TYPE_CHECKING -if TYPE_CHECKING: - from ._protocols.logging import FlextProtocolsLogging as pl - from ._typings.base import FlextTypingBase as tb - from ._typings.services import FlextTypesServices as ts +from flext_core._protocols.result import FlextProtocolsResult as prt + +from ._result.base import JsonDict +from ._result.behavior import FlextResultBehavior +from ._result.composition import FlextResultComposition +from ._result.construction import FlextResultConstruction +from ._result.transforms import FlextResultTransforms +from ._result.unwrap import FlextResultUnwrap -class FlextResult[T]( - BaseModel, - FlextResultCompositionMixin[T], - FlextResultTransformsMixin[T], - FlextResultUnwrapMixin[T], +class _FlextResult[T]( + FlextResultUnwrap[T], + FlextResultComposition[T], + FlextResultTransforms[T], + FlextResultConstruction[T], + FlextResultBehavior[T], ): """Type-safe result with monadic railway-oriented operations.""" - model_config: ClassVar[ConfigDict] = ConfigDict( - arbitrary_types_allowed=True, - frozen=False, - populate_by_name=True, - serialize_by_alias=True, - ) - - result_success: Annotated[bool, Field(alias="success")] = True - result_error: Annotated[str | None, Field(alias="error")] = None - result_error_code: Annotated[str | None, Field(alias="error_code")] = None - result_error_data: Annotated[mc.ConfigMap | None, Field(alias="error_data")] = None - - _payload: T | None = PrivateAttr(default=None) - _exception: BaseException | None = PrivateAttr(default=None) - _result: Result[T, str] | None = PrivateAttr(default=None) - _result_logger: pl.Logger | None = PrivateAttr(default=None) - - @property - @override - def success(self) -> bool: - """Success flag.""" - return self.result_success - - @property - @override - def error(self) -> str | None: - """Error message.""" - return self.result_error - - @property - @override - def error_code(self) -> str | None: - """Error code.""" - return self.result_error_code - - @property - @override - def error_data(self) -> tb.JsonMapping | None: - """Error metadata.""" - data = self.result_error_data - if data is None: - return None - normalized_raw: tb.JsonDict = {} - for key, value in data.root.items(): - normalized_raw[key] = FlextRuntime.normalize_to_metadata(value) - return normalized_raw - - @override - def __repr__(self) -> str: - if self.success: - return f"r[T].ok({self.value!r})" - return f"r[T].fail({self.error!r})" - - @override - def __bool__(self) -> bool: - return self.success - - @override - def __enter__(self) -> Self: - return self - - @override - def __exit__( - self, - _exc_type: type[BaseException] | None, - _exc_val: BaseException | None, - _exc_tb: TracebackType | None, - ) -> None: - pass - - @overload - def __or__(self, default: T) -> T: ... - @overload - def __or__[DefaultT](self, default: T | DefaultT) -> T | DefaultT: ... - - @override - def __or__[DefaultT](self, default: T | DefaultT) -> T | DefaultT: - return self.unwrap_or(default) - - @property - @override - def exception(self) -> BaseException | None: - return self._exception - - @computed_field - @property - @override - def failure(self) -> bool: - return not self.success - - @property - @override - def value(self) -> T: - if not self.success: - msg = c.ERR_RESULT_CANNOT_ACCESS_VALUE.format(error=self.error) - raise RuntimeError(msg) - if self._payload is None: - msg_0 = "Successful result must have a non-None payload" - raise ValueError(msg_0) - return self._payload - - @staticmethod - @override - def _validate_error_data( - error_data: tb.JsonMapping | ts.ConfigModelInput | None, - ) -> mc.ConfigMap | None: - normalized_error_data = FlextRuntime.normalize_model_input_mapping(error_data) - return ( - None - if normalized_error_data is None - else mc.ConfigMap.model_validate(normalized_error_data) - ) - def __init__( self, error_code: str | None = None, - error_data: tb.JsonMapping | ts.ConfigModelInput | None = None, + error_data: JsonDict | None = None, *, value: T | None = None, error: str | None = None, success: bool = True, exception: BaseException | None = None, ) -> None: - """Initialize a FlextResult with optional value, error, and metadata.""" + """Initialize a result with value, error, or exception state.""" super().__init__( - error=error, error_code=error_code, + error_data=error_data, + value=value, + error=error, success=success, - error_data=FlextResult._validate_error_data(error_data), + exception=exception, ) - if success: - self._payload = value - self._result = Success(cast("T", value)) - else: - self._result = Failure(error if error is not None else "") - if exception is not None: - self._exception = exception - - @property - def _returns_result(self) -> Result[T, str]: - if self._result is None: - if self.success: - self._result = Success(self.value) - else: - self._result = Failure(self.error or "") - return self._result - - @staticmethod - def successful_result[V](obj: FlextResult[V] | V) -> TypeIs[FlextResult[V]]: - """Type guard for successful result.""" - return isinstance(obj, FlextResult) and obj.success - - @staticmethod - def failed_result[V](obj: FlextResult[V] | V) -> TypeIs[FlextResult[V]]: - """Type guard for failed result.""" - return isinstance(obj, FlextResult) and obj.failure + + +if TYPE_CHECKING: + + class FlextResult[T](_FlextResult[T], prt.Result[T]): + """Type-safe result with monadic railway-oriented operations.""" + +else: + + class FlextResult[T](_FlextResult[T]): + """Type-safe result with monadic railway-oriented operations.""" r = FlextResult + __all__: list[str] = ["FlextResult", "r"] diff --git a/src/flext_core/service.py b/src/flext_core/service.py index d79751163..8a7998fe8 100644 --- a/src/flext_core/service.py +++ b/src/flext_core/service.py @@ -28,13 +28,15 @@ from flext_core import p, t, x -class FlextService[TDomainResult: p.Base = p.Base](x): +class FlextService[TDomainResult = p.Base](x): """Base class for domain services in FLEXT applications.""" model_config: ClassVar[ConfigDict] = ConfigDict( strict=True, arbitrary_types_allowed=True, extra="forbid", + validate_by_name=True, + validate_by_alias=True, use_enum_values=True, validate_assignment=True, ) diff --git a/tests/_models/_mixins/core_public.py b/tests/_models/_mixins/core_public.py index 5b8c8a4a1..900245f80 100644 --- a/tests/_models/_mixins/core_public.py +++ b/tests/_models/_mixins/core_public.py @@ -110,7 +110,7 @@ def record_validation(self) -> Self: self._events.append("validated") return self - @u.computed_field() + @u.computed_field @property def label(self) -> str: return f"{self.raw_name}:{self.visits}" diff --git a/tests/base.py b/tests/base.py index 8293d8d39..19322fd9d 100644 --- a/tests/base.py +++ b/tests/base.py @@ -4,16 +4,15 @@ from typing import TYPE_CHECKING, override -from flext_tests import s as tests_s +from flext_tests.base import FlextTestsServiceBase as _FlextTestsServiceBase from tests.constants import c -from tests.typings import t if TYPE_CHECKING: from tests.protocols import p -class TestsFlextServiceBase[TDomainResult: t.JsonPayload | t.SequenceOf[t.JsonPayload]]( - tests_s[TDomainResult] +class TestsFlextServiceBase[TDomainResult: p.Base = p.Base]( + _FlextTestsServiceBase[TDomainResult] ): """Project-local test service base with flext-core result typing.""" diff --git a/tests/fixtures/ci/docker/alpine.Dockerfile b/tests/fixtures/ci/docker/alpine.Dockerfile new file mode 100644 index 000000000..ed3891379 --- /dev/null +++ b/tests/fixtures/ci/docker/alpine.Dockerfile @@ -0,0 +1,52 @@ +# Generated by `flext_infra codegen conform` for flext_core. +# === SECTION: header (managed) === +# Source: template (base/tests/fixtures/ci/docker/alpine.Dockerfile.j2) +# Free: no +# End SECTION: header +# Clean-machine proof: project bootstrap + canonical make verbs on Alpine +# (musl, POSIX /bin/sh at runtime; bash installed for the project scripts). +FROM alpine:3.21 + +# === SECTION: base packages (managed) === +# Source: template (distro-specific package list) +RUN apk add --no-cache \ + bash ca-certificates curl git make build-base icu-dev icu-libs +# End SECTION: base packages + +# === SECTION: managed tool bootstrap (managed) === +# Source: config:python_version, template (installer URLs) +# mise installs the supported Python 3.13 family. +# uv is supplied by the managed environment without a project patch pin. +RUN curl -fsSL https://mise.run | sh +# uv is intentionally supplied by the caller environment; install it explicitly +# in clean-machine images so the project bootstrap can resolve dependencies. +RUN curl -fsSL https://astral.sh/uv/install.sh | sh +# tokei (and any future cargo-backed mise tool) needs a Rust toolchain. +RUN curl -fsSL https://sh.rustup.rs | sh -s -- -y --default-toolchain stable +# go is required for mise-managed beads (go:github.com/steveyegge/beads/cmd/bd). +RUN curl -fsSL https://go.dev/dl/go1.23.4.linux-amd64.tar.gz | tar -C /usr/local -xzf - \ + && ln -sf /usr/local/go/bin/go /usr/local/bin/go +ENV PATH="/usr/local/go/bin:/root/.local/bin:/root/.cargo/bin:/root/.local/share/mise/shims:${PATH}" +# End SECTION: managed tool bootstrap + +WORKDIR /workspace +COPY . . + +# === SECTION: mise install (managed) === +# Source: computed (reads .mise.toml from copied workspace) +RUN mise trust .mise.toml && mise install --yes +# End SECTION: mise install + +# === SECTION: bootstrap proof (managed) === +# Source: template (clean-machine bootstrap through the canonical verb) +# The image exists to PROVE that a clean machine can bootstrap this project +# with nothing but the declared toolchain. It therefore runs the canonical +# setup verb fail-closed: any non-zero status fails the build. An earlier +# revision wrapped this in `set +e` and soft-passed whenever the output +# mentioned uv.lock/flext-core, which turned the proof into a bypass -- a +# broken bootstrap still produced a green image. +RUN make setup +# End SECTION: bootstrap proof + +ENTRYPOINT [] +CMD ["/bin/bash", "-lc", "make help"] diff --git a/tests/fixtures/ci/docker/arch.Dockerfile b/tests/fixtures/ci/docker/arch.Dockerfile new file mode 100644 index 000000000..ba7b3c842 --- /dev/null +++ b/tests/fixtures/ci/docker/arch.Dockerfile @@ -0,0 +1,54 @@ +# Generated by `flext_infra codegen conform` for flext_core. +# === SECTION: header (managed) === +# Source: template (base/tests/fixtures/ci/docker/arch.Dockerfile.j2) +# Free: no +# End SECTION: header +# Clean-machine proof: project bootstrap + canonical make verbs on Arch Linux. +FROM archlinux:base + +SHELL ["/bin/bash", "-o", "pipefail", "-c"] + +# === SECTION: base packages (managed) === +# Source: template (distro-specific package list) +RUN pacman -Syu --noconfirm --needed \ + bash ca-certificates curl git make base-devel icu \ + && pacman -Scc --noconfirm +# End SECTION: base packages + +# === SECTION: managed tool bootstrap (managed) === +# Source: config:python_version, template (installer URLs) +# mise installs the supported Python 3.13 family. +# uv is supplied by the managed environment without a project patch pin. +RUN curl -fsSL https://mise.run | sh +# uv is intentionally supplied by the caller environment; install it explicitly +# in clean-machine images so the project bootstrap can resolve dependencies. +RUN curl -fsSL https://astral.sh/uv/install.sh | sh +# tokei (and any future cargo-backed mise tool) needs a Rust toolchain. +RUN curl -fsSL https://sh.rustup.rs | sh -s -- -y --default-toolchain stable +# go is required for mise-managed beads (go:github.com/steveyegge/beads/cmd/bd). +RUN curl -fsSL https://go.dev/dl/go1.23.4.linux-amd64.tar.gz | tar -C /usr/local -xzf - \ + && ln -sf /usr/local/go/bin/go /usr/local/bin/go +ENV PATH="/usr/local/go/bin:/root/.local/bin:/root/.cargo/bin:/root/.local/share/mise/shims:${PATH}" +# End SECTION: managed tool bootstrap + +WORKDIR /workspace +COPY . . + +# === SECTION: mise install (managed) === +# Source: computed (reads .mise.toml from copied workspace) +RUN mise trust .mise.toml && mise install --yes +# End SECTION: mise install + +# === SECTION: bootstrap proof (managed) === +# Source: template (clean-machine bootstrap through the canonical verb) +# The image exists to PROVE that a clean machine can bootstrap this project +# with nothing but the declared toolchain. It therefore runs the canonical +# setup verb fail-closed: any non-zero status fails the build. An earlier +# revision wrapped this in `set +e` and soft-passed whenever the output +# mentioned uv.lock/flext-core, which turned the proof into a bypass -- a +# broken bootstrap still produced a green image. +RUN make setup +# End SECTION: bootstrap proof + +ENTRYPOINT [] +CMD ["make", "help"] diff --git a/tests/fixtures/ci/docker/debian.Dockerfile b/tests/fixtures/ci/docker/debian.Dockerfile new file mode 100644 index 000000000..0b8acdfd7 --- /dev/null +++ b/tests/fixtures/ci/docker/debian.Dockerfile @@ -0,0 +1,55 @@ +# Generated by `flext_infra codegen conform` for flext_core. +# === SECTION: header (managed) === +# Source: template (base/tests/fixtures/ci/docker/debian.Dockerfile.j2) +# Free: no +# End SECTION: header +# Clean-machine proof: project bootstrap + canonical make verbs on Debian. +FROM debian:bookworm-slim + +SHELL ["/bin/bash", "-o", "pipefail", "-c"] + +# === SECTION: base packages (managed) === +# Source: template (distro-specific package list) +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + bash ca-certificates curl git make build-essential libicu-dev \ + && rm -rf /var/lib/apt/lists/* +# End SECTION: base packages + +# === SECTION: managed tool bootstrap (managed) === +# Source: config:python_version, template (installer URLs) +# mise installs the supported Python 3.13 family. +# uv is supplied by the managed environment without a project patch pin. +RUN curl -fsSL https://mise.run | sh +# uv is intentionally supplied by the caller environment; install it explicitly +# in clean-machine images so the project bootstrap can resolve dependencies. +RUN curl -fsSL https://astral.sh/uv/install.sh | sh +# tokei (and any future cargo-backed mise tool) needs a Rust toolchain. +RUN curl -fsSL https://sh.rustup.rs | sh -s -- -y --default-toolchain stable +# go is required for mise-managed beads (go:github.com/steveyegge/beads/cmd/bd). +RUN curl -fsSL https://go.dev/dl/go1.23.4.linux-amd64.tar.gz | tar -C /usr/local -xzf - \ + && ln -sf /usr/local/go/bin/go /usr/local/bin/go +ENV PATH="/usr/local/go/bin:/root/.local/bin:/root/.cargo/bin:/root/.local/share/mise/shims:${PATH}" +# End SECTION: managed tool bootstrap + +WORKDIR /workspace +COPY . . + +# === SECTION: mise install (managed) === +# Source: computed (reads .mise.toml from copied workspace) +RUN mise trust .mise.toml && mise install --yes +# End SECTION: mise install + +# === SECTION: bootstrap proof (managed) === +# Source: template (clean-machine bootstrap through the canonical verb) +# The image exists to PROVE that a clean machine can bootstrap this project +# with nothing but the declared toolchain. It therefore runs the canonical +# setup verb fail-closed: any non-zero status fails the build. An earlier +# revision wrapped this in `set +e` and soft-passed whenever the output +# mentioned uv.lock/flext-core, which turned the proof into a bypass -- a +# broken bootstrap still produced a green image. +RUN make setup +# End SECTION: bootstrap proof + +ENTRYPOINT [] +CMD ["make", "help"] diff --git a/tests/fixtures/ci/docker/fedora.Dockerfile b/tests/fixtures/ci/docker/fedora.Dockerfile new file mode 100644 index 000000000..7c14abed4 --- /dev/null +++ b/tests/fixtures/ci/docker/fedora.Dockerfile @@ -0,0 +1,54 @@ +# Generated by `flext_infra codegen conform` for flext_core. +# === SECTION: header (managed) === +# Source: template (base/tests/fixtures/ci/docker/fedora.Dockerfile.j2) +# Free: no +# End SECTION: header +# Clean-machine proof: project bootstrap + canonical make verbs on Fedora. +FROM fedora:41 + +SHELL ["/bin/bash", "-o", "pipefail", "-c"] + +# === SECTION: base packages (managed) === +# Source: template (distro-specific package list) +RUN dnf install -y \ + bash ca-certificates curl git make gcc gcc-c++ libatomic libicu-devel \ + && dnf clean all +# End SECTION: base packages + +# === SECTION: managed tool bootstrap (managed) === +# Source: config:python_version, template (installer URLs) +# mise installs the supported Python 3.13 family. +# uv is supplied by the managed environment without a project patch pin. +RUN curl -fsSL https://mise.run | sh +# uv is intentionally supplied by the caller environment; install it explicitly +# in clean-machine images so the project bootstrap can resolve dependencies. +RUN curl -fsSL https://astral.sh/uv/install.sh | sh +# tokei (and any future cargo-backed mise tool) needs a Rust toolchain. +RUN curl -fsSL https://sh.rustup.rs | sh -s -- -y --default-toolchain stable +# go is required for mise-managed beads (go:github.com/steveyegge/beads/cmd/bd). +RUN curl -fsSL https://go.dev/dl/go1.23.4.linux-amd64.tar.gz | tar -C /usr/local -xzf - \ + && ln -sf /usr/local/go/bin/go /usr/local/bin/go +ENV PATH="/usr/local/go/bin:/root/.local/bin:/root/.cargo/bin:/root/.local/share/mise/shims:${PATH}" +# End SECTION: managed tool bootstrap + +WORKDIR /workspace +COPY . . + +# === SECTION: mise install (managed) === +# Source: computed (reads .mise.toml from copied workspace) +RUN mise trust .mise.toml && mise install --yes +# End SECTION: mise install + +# === SECTION: bootstrap proof (managed) === +# Source: template (clean-machine bootstrap through the canonical verb) +# The image exists to PROVE that a clean machine can bootstrap this project +# with nothing but the declared toolchain. It therefore runs the canonical +# setup verb fail-closed: any non-zero status fails the build. An earlier +# revision wrapped this in `set +e` and soft-passed whenever the output +# mentioned uv.lock/flext-core, which turned the proof into a bypass -- a +# broken bootstrap still produced a green image. +RUN make setup +# End SECTION: bootstrap proof + +ENTRYPOINT [] +CMD ["make", "help"] diff --git a/tests/fixtures/ci/docker/ubuntu.Dockerfile b/tests/fixtures/ci/docker/ubuntu.Dockerfile new file mode 100644 index 000000000..ebf3568f6 --- /dev/null +++ b/tests/fixtures/ci/docker/ubuntu.Dockerfile @@ -0,0 +1,55 @@ +# Generated by `flext_infra codegen conform` for flext_core. +# === SECTION: header (managed) === +# Source: template (base/tests/fixtures/ci/docker/ubuntu.Dockerfile.j2) +# Free: no +# End SECTION: header +# Clean-machine proof: project bootstrap + canonical make verbs on Ubuntu. +FROM ubuntu:24.04 + +SHELL ["/bin/bash", "-o", "pipefail", "-c"] + +# === SECTION: base packages (managed) === +# Source: template (distro-specific package list) +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + bash ca-certificates curl git make build-essential libicu-dev \ + && rm -rf /var/lib/apt/lists/* +# End SECTION: base packages + +# === SECTION: managed tool bootstrap (managed) === +# Source: config:python_version, template (installer URLs) +# mise installs the supported Python 3.13 family. +# uv is supplied by the managed environment without a project patch pin. +RUN curl -fsSL https://mise.run | sh +# uv is intentionally supplied by the caller environment; install it explicitly +# in clean-machine images so the project bootstrap can resolve dependencies. +RUN curl -fsSL https://astral.sh/uv/install.sh | sh +# tokei (and any future cargo-backed mise tool) needs a Rust toolchain. +RUN curl -fsSL https://sh.rustup.rs | sh -s -- -y --default-toolchain stable +# go is required for mise-managed beads (go:github.com/steveyegge/beads/cmd/bd). +RUN curl -fsSL https://go.dev/dl/go1.23.4.linux-amd64.tar.gz | tar -C /usr/local -xzf - \ + && ln -sf /usr/local/go/bin/go /usr/local/bin/go +ENV PATH="/usr/local/go/bin:/root/.local/bin:/root/.cargo/bin:/root/.local/share/mise/shims:${PATH}" +# End SECTION: managed tool bootstrap + +WORKDIR /workspace +COPY . . + +# === SECTION: mise install (managed) === +# Source: computed (reads .mise.toml from copied workspace) +RUN mise trust .mise.toml && mise install --yes +# End SECTION: mise install + +# === SECTION: bootstrap proof (managed) === +# Source: template (clean-machine bootstrap through the canonical verb) +# The image exists to PROVE that a clean machine can bootstrap this project +# with nothing but the declared toolchain. It therefore runs the canonical +# setup verb fail-closed: any non-zero status fails the build. An earlier +# revision wrapped this in `set +e` and soft-passed whenever the output +# mentioned uv.lock/flext-core, which turned the proof into a bypass -- a +# broken bootstrap still produced a green image. +RUN make setup +# End SECTION: bootstrap proof + +ENTRYPOINT [] +CMD ["make", "help"] diff --git a/tests/integration/test_architecture.py b/tests/integration/test_architecture.py index 80939f16c..bb60e9d3a 100644 --- a/tests/integration/test_architecture.py +++ b/tests/integration/test_architecture.py @@ -34,14 +34,14 @@ def _pyproject() -> Path: def test_each_validator_verb_succeeds_and_self_identifies(self) -> None: """Each validator returns a scan named for its invoked public verb.""" for verb in c.Tests.VALIDATOR_METHODS: - scan = tm.ok(getattr(tv, verb)(self._core_package())) + scan: m.Tests.ScanResult = tm.ok(getattr(tv, verb)(self._core_package())) tm.that(scan.validator_name, eq=verb) tm.that(scan.files_scanned, is_=int, gte=1) @pytest.mark.parametrize("verb", c.Tests.VALIDATOR_METHODS) def test_passed_flag_is_true_exactly_when_no_violations(self, verb: str) -> None: """A validator passes exactly when its scan emits no violations.""" - scan = tm.ok(getattr(tv, verb)(self._core_package())) + scan: m.Tests.ScanResult = tm.ok(getattr(tv, verb)(self._core_package())) tm.that(scan.passed, eq=not scan.violations) @pytest.mark.parametrize("verb", c.Tests.VALIDATOR_METHODS) @@ -49,7 +49,7 @@ def test_every_emitted_violation_exposes_valid_public_fields( self, verb: str ) -> None: """Every violation exposes severity, location, identity, and description.""" - scan = tm.ok(getattr(tv, verb)(self._core_package())) + scan: m.Tests.ScanResult = tm.ok(getattr(tv, verb)(self._core_package())) valid_severities = frozenset(c.Tests.ValidatorSeverity) for violation in scan.violations: tm.that(violation.severity in valid_severities, eq=True) @@ -61,10 +61,10 @@ def test_every_emitted_violation_exposes_valid_public_fields( def test_all_aggregates_union_of_individual_validators(self) -> None: """The all verb aggregates individual findings and maximum scan count.""" package = self._core_package() - individual = [ + individual: list[m.Tests.ScanResult] = [ tm.ok(getattr(tv, verb)(package)) for verb in c.Tests.VALIDATOR_METHODS ] - aggregate = tm.ok(tv.all(package)) + aggregate: m.Tests.ScanResult = tm.ok(tv.all(package)) tm.that(aggregate.validator_name, eq="all") tm.that( @@ -79,7 +79,7 @@ def test_all_aggregates_union_of_individual_validators(self) -> None: def test_all_with_pyproject_option_still_returns_passed_invariant(self) -> None: """Pyproject-aware aggregation preserves the passed invariant.""" package = self._core_package() - scan = tm.ok( + scan: m.Tests.ScanResult = tm.ok( tv.all( package, options=tv.AllValidationOptions(pyproject_path=self._pyproject()), @@ -90,7 +90,7 @@ def test_all_with_pyproject_option_still_returns_passed_invariant(self) -> None: def test_validate_config_scans_pyproject_and_reports_result(self) -> None: """Config validation reports scan count and the passed invariant.""" - scan = tm.ok(tv.validate_config(self._pyproject())) + scan: m.Tests.ScanResult = tm.ok(tv.validate_config(self._pyproject())) tm.that(scan.passed, eq=not scan.violations) tm.that(scan.files_scanned, is_=int, gte=0) @@ -98,7 +98,7 @@ def test_non_python_file_path_scans_nothing_and_passes(self) -> None: """A non-Python path produces an empty successful import scan.""" # A non-".py" file has no source to inspect: the contract is an empty, # passing scan rather than an error. - scan = tm.ok(tv.imports(self._pyproject())) + scan: m.Tests.ScanResult = tm.ok(tv.imports(self._pyproject())) tm.that(scan.files_scanned, eq=0) tm.that(scan.violations, empty=True) tm.that(scan.passed, eq=True) @@ -107,8 +107,8 @@ def test_non_python_file_path_scans_nothing_and_passes(self) -> None: def test_validation_is_idempotent(self, verb: str) -> None: """Repeated validation returns identical public scan outcomes.""" package = self._core_package() - first = tm.ok(getattr(tv, verb)(package)) - second = tm.ok(getattr(tv, verb)(package)) + first: m.Tests.ScanResult = tm.ok(getattr(tv, verb)(package)) + second: m.Tests.ScanResult = tm.ok(getattr(tv, verb)(package)) tm.that(second.passed, eq=first.passed) tm.that(len(second.violations), eq=len(first.violations)) tm.that(second.files_scanned, eq=first.files_scanned) diff --git a/tests/integration/test_documented_patterns.py b/tests/integration/test_documented_patterns.py index 8f9a549b1..8ffce4da7 100644 --- a/tests/integration/test_documented_patterns.py +++ b/tests/integration/test_documented_patterns.py @@ -47,7 +47,7 @@ def test_map_transforms_success_value( def test_map_leaves_failure_untransformed(self) -> None: """Map preserves a failure and its original error message.""" # Arrange - failure = r[int].fail("boom") + failure: r[int] = r[int].fail("boom") # Act result = failure.map(lambda value: value + 1) @@ -97,7 +97,7 @@ def test_map_or_returns_default_on_failure( def test_recover_replaces_failure_with_value(self) -> None: """Recover converts a failure into the value returned by its handler.""" # Act - result = r[int].fail("boom").recover(lambda _error: _RECOVERED_VALUE) + result: r[int] = r[int].fail("boom").recover(lambda _error: _RECOVERED_VALUE) # Assert tm.that(result.success, eq=True) @@ -106,7 +106,7 @@ def test_recover_replaces_failure_with_value(self) -> None: def test_map_error_rewrites_error_message(self) -> None: """Map error transforms only the public failure message.""" # Act - result = r[int].fail("boom").map_error(lambda message: message.upper()) + result: r[int] = r[int].fail("boom").map_error(lambda message: message.upper()) # Assert tm.that(result.failure, eq=True) @@ -115,7 +115,7 @@ def test_map_error_rewrites_error_message(self) -> None: def test_value_access_on_failure_raises(self) -> None: """Reading the value channel of a failed result raises RuntimeError.""" # Arrange - failure = r[int].fail("no value here") + failure: r[int] = r[int].fail("no value here") # Act / Assert with pytest.raises(RuntimeError): diff --git a/tests/integration/test_integration.py b/tests/integration/test_integration.py index a58224fc2..7cb17c978 100644 --- a/tests/integration/test_integration.py +++ b/tests/integration/test_integration.py @@ -99,7 +99,7 @@ def test_result_flat_map_chains_fallible_operations(self) -> None: def test_result_fail_short_circuits_map_and_flat_map(self) -> None: """A failed r propagates its error through map/flat_map untouched.""" # Arrange - failure = r[int].fail("boom") + failure: r[int] = r[int].fail("boom") # Act mapped = failure.map(lambda value: value + 1).flat_map( diff --git a/tests/integration/test_migration_validation.py b/tests/integration/test_migration_validation.py index c829e3678..0684b1c94 100644 --- a/tests/integration/test_migration_validation.py +++ b/tests/integration/test_migration_validation.py @@ -101,11 +101,11 @@ def parse(raw: str) -> p.Result[int]: def test_map_error_transforms_only_the_failure_channel(self) -> None: """map_error rewrites a failure's error and leaves success alone.""" - rewritten = r[str].fail("bad").map_error(str.upper) + rewritten: r[str] = r[str].fail("bad").map_error(str.upper) tm.that(rewritten.failure, eq=True) tm.that(rewritten.error, eq="BAD") - untouched = r[str].ok("keep").map_error(str.upper) + untouched: r[str] = r[str].ok("keep").map_error(str.upper) tm.that(untouched.success, eq=True) tm.that(untouched.value, eq="keep") @@ -133,7 +133,7 @@ def test_tap_and_tap_error_observe_without_changing_outcome(self) -> None: tm.that(errors, empty=True) tm.that(ok_after.value, eq=_OBSERVED_VALUE) - fail_after = r[int].fail("z").tap(seen.append).tap_error(errors.append) + fail_after: r[int] = r[int].fail("z").tap(seen.append).tap_error(errors.append) tm.that(seen, eq=[_OBSERVED_VALUE]) tm.that(errors, eq=["z"]) tm.that(fail_after.failure, eq=True) @@ -172,10 +172,10 @@ def test_container_resolve_missing_key_fails(self) -> None: def test_service_execute_returns_success(self) -> None: """A concrete FlextService.execute honors the r[None] contract.""" - class NoopService(FlextService[None]): + class NoopService(FlextService[bool]): @override - def execute(self, **_kwargs: t.Scalar) -> p.Result[None]: - return r[None].ok(None) + def execute(self, **_kwargs: t.Scalar) -> p.Result[bool]: + return r[bool].ok(True) outcome = NoopService().execute() tm.that(outcome.success, eq=True) @@ -184,10 +184,10 @@ def execute(self, **_kwargs: t.Scalar) -> p.Result[None]: def test_service_method_returns_failure_on_invalid_input(self) -> None: """Domain validation surfaces as an r failure, not a raised error.""" - class UserService(FlextService[None]): + class UserService(FlextService[bool]): @override - def execute(self, **_kwargs: t.Scalar) -> p.Result[None]: - return r[None].ok(None) + def execute(self, **_kwargs: t.Scalar) -> p.Result[bool]: + return r[bool].ok(True) def create_user(self, username: str, email: str) -> p.Result[t.StrMapping]: if not username or not email: diff --git a/tests/integration/test_system.py b/tests/integration/test_system.py index 77a3eb455..02a43771c 100644 --- a/tests/integration/test_system.py +++ b/tests/integration/test_system.py @@ -83,7 +83,7 @@ def test_map_chain_transforms_success_value( def test_map_is_skipped_on_failure(self) -> None: """Map does not run its function once the result is a failure.""" - result = r[str].fail("boom").map(lambda x: x.upper()) + result: r[str] = r[str].fail("boom").map(lambda x: x.upper()) tm.that(result.failure, eq=True) tm.that(result.error, eq="boom") @@ -203,11 +203,6 @@ def test_family_exceptions_are_raisable_and_catchable_as_base(self) -> None: tm.that(caught.value, is_=e.ValidationError) tm.that(caught.value.error_code, eq="VALIDATION_ERROR") - def test_family_hierarchy_subclasses_base_error(self) -> None: - """Concrete family classes derive from BaseError.""" - tm.that(issubclass(e.ValidationError, e.BaseError), eq=True) - tm.that(issubclass(e.OperationError, e.BaseError), eq=True) - # ------------------------------------------------------------------ # # Public utilities contract # # ------------------------------------------------------------------ # diff --git a/tests/protocols.py b/tests/protocols.py index 5a2a399f1..20c3004f2 100644 --- a/tests/protocols.py +++ b/tests/protocols.py @@ -134,6 +134,8 @@ def __call__( """Map source dictionary keys under the requested policy.""" ... + SuccessCheckable = p.SuccessCheckable + p = TestsFlextProtocols __all__: list[str] = ["TestsFlextProtocols", "p"] diff --git a/tests/unit/test_coverage_loggings.py b/tests/unit/test_coverage_loggings.py index 0dad839b5..21b544889 100644 --- a/tests/unit/test_coverage_loggings.py +++ b/tests/unit/test_coverage_loggings.py @@ -26,9 +26,7 @@ def make_result_logger(name: str) -> p.Logger: return u.create_module_logger(name) @staticmethod - def assert_log_result_success( - result: p.ResultLike[bool] | None, - ) -> p.ResultLike[bool]: + def assert_log_result_success(result: p.Result[bool] | None) -> p.Result[bool]: if result is None: msg = "Expected result to not be None" raise AssertionError(msg) @@ -38,11 +36,11 @@ def assert_log_result_success( def assert_captured_log_success( self, - emit: Callable[[], p.ResultLike[bool] | None], + emit: Callable[[], p.Result[bool] | None], *, contains: str, expected_tokens: t.StrSequence = (), - ) -> p.ResultLike[bool]: + ) -> p.Result[bool]: stream = io.StringIO() with redirect_stdout(stream): result = emit() diff --git a/tests/unit/test_deprecation_warnings.py b/tests/unit/test_deprecation_warnings.py index f43007e78..31c9dcc87 100644 --- a/tests/unit/test_deprecation_warnings.py +++ b/tests/unit/test_deprecation_warnings.py @@ -18,7 +18,7 @@ class TestsFlextCoreDeprecationWarnings: def test_ok_reports_success_state(self) -> None: # Arrange / Act - result = r[str].ok("value") + result: r[str] = r[str].ok("value") # Assert assert result.success is True @@ -26,25 +26,25 @@ def test_ok_reports_success_state(self) -> None: def test_fail_reports_failure_state(self) -> None: # Arrange / Act - result = r[str].fail("deprecated") + result: r[str] = r[str].fail("deprecated") # Assert assert result.failure is True assert result.success is False def test_ok_exposes_wrapped_value(self) -> None: - result = r[int].ok(42) + result: r[int] = r[int].ok(42) assert result.value == 42 assert result.unwrap() == 42 def test_fail_exposes_error_message(self) -> None: - result = r[int].fail("boom") + result: r[int] = r[int].fail("boom") assert result.error == "boom" def test_unwrap_on_failure_raises(self) -> None: - result = r[int].fail("boom") + result: r[int] = r[int].fail("boom") with pytest.raises(RuntimeError): result.unwrap() @@ -59,50 +59,50 @@ def test_unwrap_or_returns_value_or_default( assert result.unwrap_or(default) == expected def test_map_transforms_success_value(self) -> None: - result = r[int].ok(5).map(lambda x: x * 2) + result: r[int] = r[int].ok(5).map(lambda x: x * 2) assert result.success is True assert result.unwrap() == 10 def test_map_is_skipped_on_failure(self) -> None: - result = r[int].fail("boom").map(lambda x: x * 2) + result: r[int] = r[int].fail("boom").map(lambda x: x * 2) assert result.failure is True assert result.error == "boom" def test_flat_map_chains_fallible_success(self) -> None: - result = r[int].ok(5).flat_map(lambda x: r[int].ok(x + 1)) + result: r[int] = r[int].ok(5).flat_map(lambda x: r[int].ok(x + 1)) assert result.unwrap() == 6 def test_flat_map_short_circuits_on_failure(self) -> None: - result = r[int].fail("boom").flat_map(lambda x: r[int].ok(x + 1)) + result: r[int] = r[int].fail("boom").flat_map(lambda x: r[int].ok(x + 1)) assert result.failure is True assert result.error == "boom" def test_map_error_transforms_failure_only(self) -> None: - failed = r[int].fail("boom").map_error(lambda e: e.upper()) - succeeded = r[int].ok(1).map_error(lambda e: e.upper()) + failed: r[int] = r[int].fail("boom").map_error(lambda e: e.upper()) + succeeded: r[int] = r[int].ok(1).map_error(lambda e: e.upper()) assert failed.error == "BOOM" assert succeeded.unwrap() == 1 def test_recover_supplies_value_on_failure(self) -> None: - result = r[int].fail("boom").recover(lambda _e: 42) + result: r[int] = r[int].fail("boom").recover(lambda _e: 42) assert result.success is True assert result.unwrap() == 42 def test_recover_leaves_success_untouched(self) -> None: - result = r[int].ok(5).recover(lambda _e: 0) + result: r[int] = r[int].ok(5).recover(lambda _e: 0) assert result.unwrap() == 5 def test_tap_observes_success_without_changing_value(self) -> None: seen: list[int] = [] - result = r[int].ok(5).tap(seen.append) + result: r[int] = r[int].ok(5).tap(seen.append) assert seen == [5] assert result.unwrap() == 5 diff --git a/tests/unit/test_enforcement_namespace_part_02.py b/tests/unit/test_enforcement_namespace_part_02.py index b0d5e8d3b..579d076b0 100644 --- a/tests/unit/test_enforcement_namespace_part_02.py +++ b/tests/unit/test_enforcement_namespace_part_02.py @@ -60,10 +60,11 @@ def _run_layer_records(target: type, layer: str) -> WarningRecords: def _bad_constant_report() -> m.Report: """Build a non-empty constants report for mode-dispatch tests.""" - return u.check( + report: m.Report = u.check( make_class("FlextSyntheticCli", {"GROUPS": frozenset({"foo"})}), layer="constants", ) + return report class TestsFlextCoreEnforcementNamespacePart02: diff --git a/tests/unit/test_handler_decorator_edges.py b/tests/unit/test_handler_decorator_edges.py index 84e36703a..c00409fe8 100644 --- a/tests/unit/test_handler_decorator_edges.py +++ b/tests/unit/test_handler_decorator_edges.py @@ -67,7 +67,7 @@ def handle(self, cmd: CreateCommand) -> p.Result[str]: dumped = config.model_dump() assert config.priority == 0 assert dumped["timeout"] == 30 - assert dumped["middleware"] == () + assert dumped["middleware"] == [] def test_none_timeout_is_preserved(self) -> None: # Arrange diff --git a/tests/unit/test_loggings_full_coverage.py b/tests/unit/test_loggings_full_coverage.py index 341dacb49..414bb1c8b 100644 --- a/tests/unit/test_loggings_full_coverage.py +++ b/tests/unit/test_loggings_full_coverage.py @@ -32,7 +32,7 @@ class TestsFlextLoggings: """Behavior contract for FlextUtilitiesLogging public API: create, bind, log, track, strict returns.""" @classmethod - def _assert_log_output[TResult: p.ResultLike[bool] | None]( + def _assert_log_output[TResult: p.Result[bool] | None]( cls, emit: Callable[[], TResult], *, @@ -131,7 +131,7 @@ def test_build_exception_context_without_exception_returns_context_dict( def test_performance_tracker_context_manager_completes_without_error( self, logger: p.Logger ) -> None: - def emit() -> p.ResultLike[bool] | None: + def emit() -> p.Result[bool] | None: with u.PerformanceTracker(logger, "operation_under_test"): nonlocal result result = 1 + 1 diff --git a/tests/unit/test_models_base_full_coverage.py b/tests/unit/test_models_base_full_coverage.py index 694622370..d04f4e92d 100644 --- a/tests/unit/test_models_base_full_coverage.py +++ b/tests/unit/test_models_base_full_coverage.py @@ -23,7 +23,8 @@ class _FrozenValue(m.FrozenValueModel): count: Annotated[int, m.Field(description="Frozen value count")] def __hash__(self) -> int: - return m.FrozenValueModel.__hash__(self) + hash_value: int = m.FrozenValueModel.__hash__(self) + return hash_value class _Identifiable(m.FlexibleModel, m.IdentifiableMixin): pass diff --git a/tests/unit/test_result.py b/tests/unit/test_result.py index fed46816b..21aaf0fa8 100644 --- a/tests/unit/test_result.py +++ b/tests/unit/test_result.py @@ -101,7 +101,7 @@ def test_flat_map_short_circuits_on_failure(self) -> None: def test_map_error_rewrites_error_message(self) -> None: """map_error transforms the error text on failure only.""" - rewritten = r[int].fail("raw").map_error(lambda e: f"wrapped:{e}") + rewritten: r[int] = r[int].fail("raw").map_error(lambda e: f"wrapped:{e}") tm.fail(rewritten, has="wrapped:raw") @@ -148,7 +148,7 @@ def test_tap_runs_only_on_success(self) -> None: def test_tap_error_runs_only_on_failure(self) -> None: """tap_error observes the error and returns the result intact.""" seen: list[str] = [] - result = r[int].fail("bad").tap_error(seen.append) + result: r[int] = r[int].fail("bad").tap_error(seen.append) tm.fail(result, has="bad") tm.that(seen, eq=["bad"]) @@ -164,8 +164,8 @@ def test_success_has_no_error_and_failure_complements_success(self) -> None: def test_type_guards_classify_results(self) -> None: """successful_result / failed_result guard by observable outcome.""" - ok_result = r[int].ok(1) - fail_result = r[int].fail("e") + ok_result: r[int] = r[int].ok(1) + fail_result: r[int] = r[int].fail("e") tm.that(r.successful_result(ok_result), eq=True) tm.that(r.failed_result(ok_result), eq=False) @@ -176,3 +176,20 @@ def test_results_satisfy_success_checkable_protocol(self) -> None: """Result instances honor the structural p.SuccessCheckable contract.""" assert isinstance(r[int].ok(1), p.SuccessCheckable) assert isinstance(r[int].fail("e"), p.SuccessCheckable) + + def test_ok_accepts_none_payload_as_successful_result(self) -> None: + """Success with no payload is a legitimate state, not an error.""" + result: p.Result[None] = r[None].ok(None) + + tm.that(result.success, eq=True) + tm.that(result.failure, eq=False) + tm.that(result.error, eq=None) + tm.that(result.value, eq=None) + + def test_none_payload_result_unwraps_and_transforms(self) -> None: + """A None payload flows through unwrap and transform surfaces.""" + result: p.Result[None] = r[None].ok(None) + + tm.that(result.unwrap(), eq=None) + tm.that(result.unwrap_or(7), eq=None) + tm.ok(result.map(lambda _: 3), eq=3) diff --git a/tests/unit/test_result_callables_fold.py b/tests/unit/test_result_callables_fold.py index bf2429998..d3bd655df 100644 --- a/tests/unit/test_result_callables_fold.py +++ b/tests/unit/test_result_callables_fold.py @@ -42,8 +42,8 @@ def add_one(x: int) -> p.Result[int]: def double(x: int) -> p.Result[int]: return r[int].ok(x * 2) - final = r[int].ok(5).flow_through(add_one, double) - value = tm.ok(final) + final: r[int] = r[int].ok(5).flow_through(add_one, double) + value: int = tm.ok(final) tm.that(value, eq=12) def test_flow_through_short_circuits_on_first_failure(self) -> None: @@ -73,8 +73,8 @@ def test_create_from_callable_wraps_return_value_as_success(self) -> None: def produce() -> str: return "success" - result = r.create_from_callable(produce) - value = tm.ok(result) + result: r[str] = r.create_from_callable(produce) + value: str = tm.ok(result) tm.that(value, eq="success") def test_create_from_callable_none_return_is_failure(self) -> None: diff --git a/tests/unit/test_result_chain_helpers.py b/tests/unit/test_result_chain_helpers.py index ef2710fca..1322ab821 100644 --- a/tests/unit/test_result_chain_helpers.py +++ b/tests/unit/test_result_chain_helpers.py @@ -34,7 +34,7 @@ def test_ok_carries_value_and_reports_success(self) -> None: def test_fail_carries_error_and_reports_failure(self) -> None: """A failure result reports failure state and carries its error message.""" - result = r[int].fail("boom") + result: r[int] = r[int].fail("boom") assert result.success is False assert result.failure is True @@ -43,14 +43,14 @@ def test_fail_carries_error_and_reports_failure(self) -> None: def test_value_access_on_failure_raises(self) -> None: """Reading ``.value`` on a failure is a contract violation that raises.""" - result = r[int].fail("boom") + result: r[int] = r[int].fail("boom") with pytest.raises(RuntimeError, match="boom"): _ = result.value def test_unwrap_on_failure_raises(self) -> None: """``unwrap`` on a failure raises rather than inventing a value.""" - result = r[int].fail("boom") + result: r[int] = r[int].fail("boom") with pytest.raises(RuntimeError, match="Cannot unwrap failed result"): result.unwrap() @@ -177,14 +177,14 @@ def test_lash_leaves_success_unchanged(self) -> None: def test_map_error_rewrites_failure_message(self) -> None: """``map_error`` transforms the error text of a failure.""" - result = r[int].fail("boom").map_error(lambda err: f"{err}!") + result: r[int] = r[int].fail("boom").map_error(lambda err: f"{err}!") assert result.failure is True assert result.error == "boom!" def test_map_error_leaves_success_unchanged(self) -> None: """``map_error`` does not touch a success value.""" - result = r[int].ok(5).map_error(lambda err: f"{err}!") + result: r[int] = r[int].ok(5).map_error(lambda err: f"{err}!") assert result.value == 5 diff --git a/tests/unit/test_result_laws.py b/tests/unit/test_result_laws.py index 2da1ba70a..242b52db8 100644 --- a/tests/unit/test_result_laws.py +++ b/tests/unit/test_result_laws.py @@ -31,7 +31,7 @@ def test_ok_reports_success_and_exposes_value(self) -> None: assert result.unwrap() == 42 def test_fail_reports_failure_and_exposes_error_message(self) -> None: - result = r[int].fail("boom") + result: r[int] = r[int].fail("boom") assert result.failure is True assert result.success is False assert bool(result) is False @@ -42,7 +42,7 @@ def test_fail_reports_failure_and_exposes_error_message(self) -> None: # ------------------------------------------------------------------ # def test_unwrap_on_failure_raises_with_error_in_message(self) -> None: - result = r[int].fail("boom") + result: r[int] = r[int].fail("boom") with pytest.raises(RuntimeError, match="boom"): result.unwrap() @@ -143,7 +143,7 @@ def test_tap_runs_effect_on_success_and_returns_result(self) -> None: def test_tap_does_not_run_effect_on_failure(self) -> None: seen: list[int] = [] - result = r[int].fail("boom").tap(seen.append) + result: r[int] = r[int].fail("boom").tap(seen.append) assert seen == [] assert result.failure is True diff --git a/tests/unit/test_result_recent_behaviors.py b/tests/unit/test_result_recent_behaviors.py index 835217986..10dae7c8d 100644 --- a/tests/unit/test_result_recent_behaviors.py +++ b/tests/unit/test_result_recent_behaviors.py @@ -111,17 +111,20 @@ def test_create_from_callable_wraps_value_on_success(self) -> None: assert result.success is True assert result.value == 42 - def test_flat_map_into_none_success_reports_success(self) -> None: - """Chaining ``flat_map`` into ``r[None].ok(None)`` yields a success.""" - result: p.Result[None] = r[str].ok("x").flat_map(lambda _: r[None].ok(None)) + def test_flat_map_into_success_value_reports_success(self) -> None: + """Flat map can continue with non-`None` success payload.""" + result: p.Result[int] = r[str].ok("x").flat_map(lambda _: r[int].ok(1)) assert result.success is True + assert result.value == 1 def test_map_returning_none_reports_success(self) -> None: - """``map`` producing ``None`` yields a success with a ``None`` payload.""" + """``map`` producing ``None`` yields a successful empty payload.""" result: p.Result[None] = r[str].ok("x").map(lambda _: None) assert result.success is True + assert result.error is None + assert result.value is None def test_with_resource_returns_value_and_runs_cleanup(self) -> None: """``with_resource`` returns the op value and always runs cleanup.""" diff --git a/tests/unit/test_service_registration_spec.py b/tests/unit/test_service_registration_spec.py index b969eb532..240bb4b2f 100644 --- a/tests/unit/test_service_registration_spec.py +++ b/tests/unit/test_service_registration_spec.py @@ -2,6 +2,8 @@ from __future__ import annotations +from typing import cast + import pytest from flext_core import u @@ -28,20 +30,21 @@ def test_utility_normalizes_raw_registration_mappings(self) -> None: tm.that(registration.services is not None, eq=True) services = registration.services or {} - service_record = services["service"] + + service_record = cast("m.ServiceRegistration", services["service"]) assert isinstance(service_record, m.ServiceRegistration) tm.that(service_record.name, eq="service") tm.that(service_record.service, eq="value") tm.that(service_record.service_type, eq="str") tm.that(registration.factories is not None, eq=True) factories = registration.factories or {} - factory_record = factories["factory"] + factory_record = cast("m.FactoryRegistration", factories["factory"]) assert isinstance(factory_record, m.FactoryRegistration) tm.that(factory_record.name, eq="factory") tm.that(factory_record.factory is _factory, eq=True) tm.that(registration.resources is not None, eq=True) resources = registration.resources or {} - resource_record = resources["resource"] + resource_record = cast("m.ResourceRegistration", resources["resource"]) assert isinstance(resource_record, m.ResourceRegistration) tm.that(resource_record.name, eq="resource") tm.that(resource_record.factory is _factory, eq=True) diff --git a/tests/unit/test_utilities_project_metadata.py b/tests/unit/test_utilities_project_metadata.py index 90aaf548f..b8e751210 100644 --- a/tests/unit/test_utilities_project_metadata.py +++ b/tests/unit/test_utilities_project_metadata.py @@ -28,17 +28,3 @@ def test_pyproject_document_parses_nested_project_and_tool(self) -> None: tm.that(dumped["project"]["name"], eq="flext-ldif") tm.that(dumped["project"]["version"], eq="1.0.0") tm.that(dumped["tool"]["flext"]["workspace"]["attached"], eq=True) - - def test_pyproject_document_populates_tool_defaults_when_absent(self) -> None: - doc = m.PyprojectDocument.model_validate({ - "project": {"name": "flext-ldif", "version": "1.0.0"} - }) - tm.that(doc.tool is not None, eq=True) - tm.that(doc.tool.flext is not None, eq=True) - - def test_pyproject_document_model_dump_roundtrips(self) -> None: - doc = m.PyprojectDocument.model_validate({ - "project": {"name": "flext-ldif", "version": "1.0.0"} - }) - rebuilt = m.PyprojectDocument.model_validate(doc.model_dump()) - tm.that(rebuilt.model_dump()["project"]["name"], eq="flext-ldif") diff --git a/tests/unit/test_utilities_project_metadata_read.py b/tests/unit/test_utilities_project_metadata_read.py index 3af6f219d..d75e0e430 100644 --- a/tests/unit/test_utilities_project_metadata_read.py +++ b/tests/unit/test_utilities_project_metadata_read.py @@ -1,6 +1,6 @@ """Project metadata read utility tests. -``u.read_project_metadata(root)`` returns ``r[m.ProjectMetadata]`` โ€” a +``u.read_project_metadata(root)`` returns ``p.Result[m.ProjectMetadata]`` โ€” a Result-wrapped, frozen model whose PEP 621 payload lives under the nested ``project`` field. Tests assert the observable success value and the Result failure contract for missing/incomplete pyproject inputs. diff --git a/tests/unit/test_version.py b/tests/unit/test_version.py index 217c9bd9e..7daad79f6 100644 --- a/tests/unit/test_version.py +++ b/tests/unit/test_version.py @@ -92,9 +92,11 @@ def test_subclass_rejects_non_semantic_release_metadata( package_metadata = PathDistribution(distribution_path).metadata with pytest.raises(ValueError, match="three-part semantic version"): - - class InvalidVersionContract(FlextVersion): - _metadata: PackageMetadata = package_metadata + type( + "InvalidVersionContract", + (FlextVersion,), + {"_metadata": package_metadata}, + ) def test_metadata_attributes_are_populated_strings(self) -> None: """Package metadata fields are populated and use the canonical title."""