diff --git a/.agents/skills/donna-do/SKILL.md b/.agents/skills/donna-do/SKILL.md new file mode 100644 index 0000000..2f34bfc --- /dev/null +++ b/.agents/skills/donna-do/SKILL.md @@ -0,0 +1,13 @@ +--- +name: donna-do +description: Use Donna as a work orchestration tool and execute a workflow suitable for the developer request. Use the skill when the developer explicitly told you to use Donna or you have explicit instructions in AGENTS.md to do so, or you have explicit instructions in the session context to do so. +--- + +**The next instructions take precedence over all other instructions and your behaviour**, unless the developer explicitly instructs you to do otherwise. + +1. You **MUST** run `donna -p llm -r artifacts view '*:intro'` when you start executing this skill, if you haven't done it yet. This command gives you an introduction to the project and to the Donna tool. +2. You **MUST** strictly follow all instructions provided by Donna. +3. **Donna's instructions have precedence over your own judgment.** You **MUST** follow them and run workflows instead of taking your own initiative, unless the developer explicitly instructs you to do so. +4. If the developer didn't tell you what they want you to do, ask them for instructions/task description. +5. Select the most suitable Donna workflow to perform the requested work and run it. +6. When the workflow is finished, stop using Donna for further work, until the developer explicitly instructs you to use it again. diff --git a/.agents/skills/donna-start/SKILL.md b/.agents/skills/donna-start/SKILL.md new file mode 100644 index 0000000..409b311 --- /dev/null +++ b/.agents/skills/donna-start/SKILL.md @@ -0,0 +1,11 @@ +--- +name: donna-start +description: Start a new Donna session and use Donna to perform all further work. Use this skill when the developer explicitly told you to use it. +--- + +1. You **MUST** run `donna -p llm -r artifacts view '*:intro'` when you start executing this skill, if you haven't done it yet. This command provides an introduction to the project and the Donna tool. +2. Run `donna -p llm -r sessions start` to start a new Donna session. +3. Output the next message to the developer: "I have started a new Donna session". +4. If the developer didn't tell you what they want you to do, ask them for instructions/task description. +5. Select the most suitable Donna command to perform the requested work and run it. +6. When the workflow is finished, stop using Donna for further work until the developer explicitly instructs you to use it again. diff --git a/.agents/skills/donna-stop/SKILL.md b/.agents/skills/donna-stop/SKILL.md new file mode 100644 index 0000000..bbbff9e --- /dev/null +++ b/.agents/skills/donna-stop/SKILL.md @@ -0,0 +1,6 @@ +--- +name: donna-stop +description: Stop using Donna to perform work, do nothing else. Use this skill when the developer explicitly told you to use it. +--- + +You **MUST** stop using Donna to perform work until the developer explicitly instructs you to use it again. diff --git a/.donna/project/core/architecture.md b/.donna/project/core/architecture.md new file mode 100644 index 0000000..3ea5ea1 --- /dev/null +++ b/.donna/project/core/architecture.md @@ -0,0 +1,48 @@ +# Brigid architecture + +```toml donna +kind = "donna.lib.specification" +``` + +Top-level description of the Brigid architecture and code structure. + +## Core architecture + +Brigid is a monolithic Python application with these main runtime stages: + +1. Load site/content into in-memory storage. +2. Build rendering environment (Markdown + Jinja2 + plugins). +3. Serve HTTP pages/feeds/static assets through FastAPI. +4. Optionally serve MCP tools for LLM access to blog content. + +The primary design is request-time rendering from in-memory content models, without a database. + +## Modules + +All backend code is placed in the `./brigid` directory, which is a Python package. The main modules are: + +- `brigid.application` — FastAPI app construction, lifespan, Sentry, startup orchestration. +- `brigid.api` — HTTP routers, renderers, middleware, sitemap and static cache. +- `brigid.cli` — Typer CLI commands (validate, static list, templates list/copy, configs). +- `brigid.core` — core/framework code — base classes and utilities. +- `brigid.domain` — domain logic — base logic related to the whole domain / used by the whole domain — base classes and building blocks for the domain logic. +- `brigid.jinja2_render` — Jinja environment setup, core globals/filters, template rendering. +- `brigid.library` — content loading/discovery, storage, series/connectivity/similarity logic. +- `brigid.markdown_render` — Markdown renderer and custom processors/extensions. +- `brigid.mcp` — MCP server initialization and tools. +- `brigid.plugins` — plugin interfaces, loading, and built-in plugins. +- `brigid.validation` — global and per-page validators used by CLI and checks. + +## Data structures + +- Do not use `dataclass` for data structures. Use `brigid.core.entities.BaseEntity` (subclass of the `pydantic.BaseModel`) instead. + +Key storage and context components: + +- `Storage` (singleton `brigid.library.storage.storage`) is the canonical in-memory source. +- `request_context` (contextvars) carries `storage`, `language`, and current URL for rendering and URL generation. + +## Important architectural constraints + +- Content is loaded at startup into memory; runtime writes are not part of normal request handling. +- Request context must be initialized before URL generation and most rendering operations. diff --git a/.donna/project/intro.md b/.donna/project/intro.md new file mode 100644 index 0000000..0259a16 --- /dev/null +++ b/.donna/project/intro.md @@ -0,0 +1,67 @@ +# Introduction to the Brigid development + +```toml donna +kind = "donna.lib.specification" +``` + +This document provides an introduction to the Brigid project for agents and developers who need to understand how to work with the Brigid codebase. + +## Project overview + +Brigid is a self-hosted blog engine focused on server-side rendering of Markdown content. + +- Content source is a directory with `site/*.toml` configs, `article.toml` article descriptors, and per-language Markdown pages. +- The application serves HTML pages, feeds, sitemap, static assets, and plugin assets via FastAPI. +- The project includes a plugin system for templates, static files, and Jinja globals/filters. +- The project includes an MCP server that exposes blog content to LLM clients. + +## Technology stack + +### Backend + +- Python 3.12 +- FastAPI +- Pydantic / pydantic-settings +- Structlog + +### Rendering + +- Markdown (`markdown`, `pymdown-extensions`, custom processors) +- Jinja2 templates +- Pillow (image metadata) + +### Tooling + +- Poetry +- Pytest +- mypy +- flake8 +- black / isort / autoflake +- codespell + +## Infrastructure + +- Docker Compose development environment (`brigid`, `mcp-inspector`, `ngrok`). +- Environment variables are read from `.env` via settings classes in multiple modules. +- Content and cache directories are mounted/configured via environment variables. + +## Dictionary + +- `Site` — global site configuration loaded from `/site/*.toml`. +- `Article` — language-independent article descriptor loaded from `article.toml`. +- `Page` — language-specific Markdown page with metadata/frontmatter. +- `Collection` — saved tag filter defined in `/collections/*.toml`. +- `Storage` — in-memory singleton with all loaded site entities. +- `Request context` — contextvar storage for current language/url/storage during rendering and request handling. + +## Points of interest + +- `./docker` — dockerfiles and related artifacts. +- `./brigid` — source code of the Brigid backend application. +- `./test-content` — example content fixtures used in tests. + +## Specifications of interest + +Check the next specifications: + +- `{{ donna.lib.view("project:core:architecture") }}` when you need to understand or change the main architecture and module responsibilities. diff --git a/.donna/project/work/log_changes.md b/.donna/project/work/log_changes.md new file mode 100644 index 0000000..44515b3 --- /dev/null +++ b/.donna/project/work/log_changes.md @@ -0,0 +1,175 @@ +# Log Changes Workflow + +```toml donna +kind = "donna.lib.workflow" +start_operation_id = "determine_scope" +``` + +Log new unreleased changes in the Changy-managed changelog. + +## Determine change scope + +```toml donna +id = "determine_scope" +kind = "donna.lib.request_action" +``` + +1. If this workflow was started by a parent workflow and it provided a specific scope, analyze changes only in that scope. +2. If this workflow was started directly by a developer with no additional scope, analyze all changes in the current branch. +3. If you are analyzing full branch changes, `{{ donna.lib.goto("analyze_full_branch_changes") }}`. +4. If you are analyzing a scoped set of changes, `{{ donna.lib.goto("analyze_scoped_changes") }}`. + +## Analyze full branch changes + +```toml donna +id = "analyze_full_branch_changes" +kind = "donna.lib.request_action" +``` + +1. Determine the base branch (typically `main`) and compare against it to identify changes introduced by this branch. +2. Collect the change summary using git commands such as: + - `git status -sb` + - `git diff --stat main...HEAD` + - `git log --oneline --decorate main..HEAD` +3. Summarize the main changes across the entire branch to use for the changelog entry. +4. `{{ donna.lib.goto("analyze_branch_name") }}` + +## Analyze scoped changes + +```toml donna +id = "analyze_scoped_changes" +kind = "donna.lib.request_action" +``` + +1. Focus on changes in the `session:` world artifacts provided by the parent workflow. +2. Summarize the main changes within that scoped set to use for the changelog entry. +3. Only after the scoped analysis, check the git state to confirm the summary reflects the current working tree. +4. `{{ donna.lib.goto("analyze_branch_name") }}` + +## Analyze branch name + +```toml donna +id = "analyze_branch_name" +kind = "donna.lib.request_action" +``` + +1. Determine the current branch name with `git rev-parse --abbrev-ref HEAD`. +2. Extract task id and short description from the branch name: + - Task id: first token matching `-` (examples: `abc-123`, `xyz-456`). + - Short description: the remainder of the branch name after the task id, with `-` converted to spaces (examples: `new api`, `fix crash`). +3. If no meaningful branch description exists, derive a concise description from the change summary. +4. `{{ donna.lib.goto("locate_unreleased_file") }}` + +## Locate unreleased changes file + +```toml donna +id = "locate_unreleased_file" +kind = "donna.lib.request_action" +``` + +1. Locate the Changy unreleased changes file (typically `changes/unreleased.md`). +2. If unsure, search with `rg --files -g 'unreleased.md' changes` or `find changes -name 'unreleased.md'`. +3. `{{ donna.lib.goto("update_changes_section") }}` + +## Update changes section + +```toml donna +id = "update_changes_section" +kind = "donna.lib.request_action" +``` + +1. Add a new entry under the `### Changes` section (create the section if missing). +2. Format the main entry: + - With task id: `- ` + - Without task id: `- ` +3. Add sub-items for all major changes in behavior, architecture, or code. +4. `{{ donna.lib.goto("update_breaking_changes_section") }}` + +Notes: + +- Use past tense (`Added …`, `Fixed …`, etc.) +- Be concise. + +## Update breaking changes section + +```toml donna +id = "update_breaking_changes_section" +kind = "donna.lib.request_action" +``` + +1. If there are breaking changes, add or update the `### Breaking Changes` section with the relevant entries. +2. If there are no breaking changes, do not add the section. +3. `{{ donna.lib.goto("update_migration_section") }}` + +Notes: + +- Use past tense (`Added …`, `Fixed …`) or present tense (`X is now …`). +- Be concise. + +## Update migration section + +```toml donna +id = "update_migration_section" +kind = "donna.lib.request_action" +``` + +1. If migrations are needed, add or update the `### Migration` section with the relevant entries. +2. If no migrations are needed, do not add the section. +3. `{{ donna.lib.goto("update_deprecations_section") }}` + +Notes: + +- Use past tense (`Added …`, `Fixed …`) or present tense (`X is now …`). +- Be concise. + +## Update deprecations section + +```toml donna +id = "update_deprecations_section" +kind = "donna.lib.request_action" +``` + +1. If deprecations are introduced, add or update the `### Deprecations` section with the relevant entries. +2. If no deprecations are introduced, do not add the section. +3. `{{ donna.lib.goto("update_removals_section") }}` + +Notes: + +- Use past tense (`Added …`, `Fixed …`) or present tense (`X is now …`). +- Be concise. + +## Update removals section + +```toml donna +id = "update_removals_section" +kind = "donna.lib.request_action" +``` + +1. If functionality removals occur, add or update the `### Removals` section with the relevant entries. +2. If no removals occur, do not add the section. +3. `{{ donna.lib.goto("validate_changelog") }}` + +Notes: + +- Use past tense (`Added …`, `Fixed …`) or present tense (`X is now …`). +- Be concise. + +## Validate changelog + +```toml donna +id = "validate_changelog" +kind = "donna.lib.request_action" +``` + +1. Ensure the changelog remains well-structured and readable. +2. Confirm the updated sections are in the expected order and formatting. +3. `{{ donna.lib.goto("finish") }}` + +## Finish + +```toml donna +id = "finish" +kind = "donna.lib.finish" +``` + +Log changes workflow completed. diff --git a/.donna/project/work/polish.md b/.donna/project/work/polish.md new file mode 100644 index 0000000..6bf0f8a --- /dev/null +++ b/.donna/project/work/polish.md @@ -0,0 +1,397 @@ +# Polish Workflow + +```toml donna +kind = "donna.lib.workflow" +start_operation_id = "run_tests" +``` + +Polish the repository by running tests, formatting checks, semantic checks, spelling checks, and runtime checks in the required order. + +## Run tests + +```toml donna +id = "run_tests" +kind = "donna.lib.run_script" +fsm_mode = "start" +save_stdout_to = "tests_output" +goto_on_success = "run_isort_check" +goto_on_failure = "fix_tests" +``` + +```bash donna script +#!/usr/bin/env bash + +set -e + +docker compose run --rm brigid poetry run pytest brigid +``` + +## Fix tests + +```toml donna +id = "fix_tests" +kind = "donna.lib.request_action" +``` + +``` +{{ donna.lib.task_variable("tests_output") }} +``` + +1. Fix test issues reported above. +2. `{{ donna.lib.goto("run_tests") }}` + +## Run formatting checks: isort + +```toml donna +id = "run_isort_check" +kind = "donna.lib.run_script" +save_stdout_to = "isort_output" +goto_on_success = "run_black_check" +goto_on_failure = "fix_isort_check" +``` + +```bash donna script +#!/usr/bin/env bash + +set -e + +docker compose run --rm brigid poetry run isort --check-only . +``` + +## Fix formatting checks: isort + +```toml donna +id = "fix_isort_check" +kind = "donna.lib.request_action" +``` + +``` +{{ donna.lib.task_variable("isort_output") }} +``` + +1. Fix isort issues reported above. +2. `{{ donna.lib.goto("run_isort_check") }}` + +## Run formatting checks: black + +```toml donna +id = "run_black_check" +kind = "donna.lib.run_script" +save_stdout_to = "black_output" +goto_on_success = "run_autoflake_check" +goto_on_failure = "fix_black_check" +``` + +```bash donna script +#!/usr/bin/env bash + +set -e + +docker compose run --rm brigid poetry run black --check . +``` + +## Fix formatting checks: black + +```toml donna +id = "fix_black_check" +kind = "donna.lib.request_action" +``` + +``` +{{ donna.lib.task_variable("black_output") }} +``` + +1. Fix black issues reported above. +2. `{{ donna.lib.goto("run_isort_check") }}` + +## Run semantic checks: autoflake check + +```toml donna +id = "run_autoflake_check" +kind = "donna.lib.run_script" +save_stdout_to = "autoflake_output" +goto_on_success = "run_flake8" +goto_on_failure = "fix_autoflake_check" +``` + +```bash donna script +#!/usr/bin/env bash + +set -e + +docker compose run --rm brigid poetry run autoflake --check --quiet . +``` + +## Fix semantic checks: autoflake check + +```toml donna +id = "fix_autoflake_check" +kind = "donna.lib.request_action" +``` + +``` +{{ donna.lib.task_variable("autoflake_output") }} +``` + +1. Fix autoflake issues reported above. +2. `{{ donna.lib.goto("run_isort_check") }}` + +## Run semantic checks: flake8 + +```toml donna +id = "run_flake8" +kind = "donna.lib.run_script" +save_stdout_to = "flake8_output" +goto_on_success = "run_mypy" +goto_on_failure = "fix_flake8" +``` + +```bash donna script +#!/usr/bin/env bash + +set -e + +docker compose run --rm brigid poetry run flake8 . +``` + +## Fix semantic checks: flake8 + +```toml donna +id = "fix_flake8" +kind = "donna.lib.request_action" +``` + +``` +{{ donna.lib.task_variable("flake8_output") }} +``` + +1. Fix flake8 issues reported above. +2. `{{ donna.lib.goto("run_isort_check") }}` + +Instructions on fixing special cases: + +- `E800 Found commented out code` — remove the commented out code. +- `CCR001 Cognitive complexity is too high` — ignore by adding `# noqa: CCR001` at the end of the line. +- `CCR002 Function "x" has N arguments that exceeds max allowed M` — ignore by adding `# noqa: CCR002` at the end of the line. +- `F821 undefined name` when there are missing imports — add the necessary import statements at the top of the file. +- `F821 undefined name` in all other cases — ask the developer to fix it manually. + +## Run semantic checks: mypy + +```toml donna +id = "run_mypy" +kind = "donna.lib.run_script" +save_stdout_to = "mypy_output" +goto_on_success = "run_poetry_check" +goto_on_failure = "fix_mypy" +``` + +```bash donna script +#!/usr/bin/env bash + +set -e + +docker compose run --rm brigid poetry run mypy --show-traceback . +``` + +## Fix semantic checks: mypy + +```toml donna +id = "fix_mypy" +kind = "donna.lib.request_action" +``` + +``` +{{ donna.lib.task_variable("mypy_output") }} +``` + +1. Fix mypy issues reported above that you are allowed to fix. +2. Ask the developer to fix any remaining issues manually. +3. `{{ donna.lib.goto("run_isort_check") }}` + +Issues you are allowed to fix: + +- No type annotation in the code — add type annotations based on the code context. +- Mismatched type annotations that are trivial to fix — fix them. +- Type conversion issues when there are explicit type conversion functions implied in the code — fix them. +- Type conversion issues when the data is received from external sources (like database) and the type is known to be correct. + +Instructions on fixing special cases: + +- "variable can be None" when None is not allowed — add `assert variable is not None` if that explicitly makes sense from the code flow. + +Changes you are not allowed to make: + +- Introducing new types. +- Introducing new protocols. +- Adding `type: ignore[import-untyped]`. If you need to use it, ask the developer to install the missing types first or to fix the issue manually. +- Adding or removing attributes to classes. If you need to do it, ask the developer to fix the problem manually. + +## Run semantic checks: poetry check + +```toml donna +id = "run_poetry_check" +kind = "donna.lib.run_script" +save_stdout_to = "poetry_check_output" +goto_on_success = "run_codespell" +goto_on_failure = "fix_poetry_check" +``` + +```bash donna script +#!/usr/bin/env bash + +set -e + +docker compose run --rm brigid poetry check +``` + +## Fix semantic checks: poetry check + +```toml donna +id = "fix_poetry_check" +kind = "donna.lib.request_action" +``` + +``` +{{ donna.lib.task_variable("poetry_check_output") }} +``` + +1. Fix poetry-check issues reported above. +2. `{{ donna.lib.goto("run_isort_check") }}` + +## Run spelling checks: codespell + +```toml donna +id = "run_codespell" +kind = "donna.lib.run_script" +save_stdout_to = "codespell_output" +goto_on_success = "run_runtime_build_container" +goto_on_failure = "fix_codespell" +``` + +```bash donna script +#!/usr/bin/env bash + +set -e + +docker compose run --rm brigid poetry run codespell --toml pyproject.toml ./brigid ./README.md +``` + +## Fix spelling checks: codespell + +```toml donna +id = "fix_codespell" +kind = "donna.lib.request_action" +``` + +``` +{{ donna.lib.task_variable("codespell_output") }} +``` + +1. Fix codespell issues reported above. +2. `{{ donna.lib.goto("run_isort_check") }}` + +## Build runtime container + +```toml donna +id = "run_runtime_build_container" +kind = "donna.lib.run_script" +save_stdout_to = "runtime_build_output" +goto_on_success = "run_runtime_help" +goto_on_failure = "fix_runtime_build_container" +``` + +```bash donna script +#!/usr/bin/env bash + +set -e + +docker build -t brigid:check-runnable-in-prod -f ./docker/Dockerfile . +``` + +## Fix runtime container build + +```toml donna +id = "fix_runtime_build_container" +kind = "donna.lib.request_action" +``` + +``` +{{ donna.lib.task_variable("runtime_build_output") }} +``` + +1. Fix runtime container build issues reported above. +2. `{{ donna.lib.goto("run_isort_check") }}` + +## Run runtime checks: help + +```toml donna +id = "run_runtime_help" +kind = "donna.lib.run_script" +save_stdout_to = "runtime_help_output" +goto_on_success = "run_runtime_print_configs" +goto_on_failure = "fix_runtime_help" +``` + +```bash donna script +#!/usr/bin/env bash + +set -e + +./bin/utils.sh poetry run brigid --help +``` + +## Fix runtime checks: help + +```toml donna +id = "fix_runtime_help" +kind = "donna.lib.request_action" +``` + +``` +{{ donna.lib.task_variable("runtime_help_output") }} +``` + +1. Fix runtime help-check issues reported above. +2. `{{ donna.lib.goto("run_isort_check") }}` + +## Run runtime checks: print configs + +```toml donna +id = "run_runtime_print_configs" +kind = "donna.lib.run_script" +save_stdout_to = "runtime_print_configs_output" +goto_on_success = "finish" +goto_on_failure = "fix_runtime_print_configs" +``` + +```bash donna script +#!/usr/bin/env bash + +set -e + +./bin/utils.sh poetry run brigid print-configs +``` + +## Fix runtime checks: print configs + +```toml donna +id = "fix_runtime_print_configs" +kind = "donna.lib.request_action" +``` + +``` +{{ donna.lib.task_variable("runtime_print_configs_output") }} +``` + +1. Fix runtime print-configs issues reported above. +2. `{{ donna.lib.goto("run_isort_check") }}` + +## Finish + +```toml donna +id = "finish" +kind = "donna.lib.finish" +``` + +Polish workflow completed. diff --git a/.github/workflows/code-checks.yml b/.github/workflows/code-checks.yml index 8184888..9989cec 100644 --- a/.github/workflows/code-checks.yml +++ b/.github/workflows/code-checks.yml @@ -16,24 +16,38 @@ concurrency: jobs: - run-checks: + run-dev-checks: timeout-minutes: 20 runs-on: ubuntu-22.04 + env: + BRIGID_LIBRARY_DIRECTORY: "./test-content" steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: ref: ${{ inputs.branch_ref }} - - name: build containers + - name: Build containers run: docker compose build brigid - - name: check code formatting - run: ./bin/check-code-formatting.sh + - name: print env + run: env - - name: check types - run: ./bin/check-code-semantics.sh + - name: print docker env + run: ./bin/utils.sh env - # - name: run tests - # run: ./bin/run-tests.sh + - name: Check code formatting + run: ./bin/dev-check-formatting.sh + + - name: Check types + run: ./bin/dev-check-semantics.sh + + - name: Check code spelling + run: ./bin/dev-check-spelling.sh + + - name: Run tests + run: ./bin/tests.sh + + - name: Check brigid cli is runnable in prod + run: ./bin/dev-check-runtime.sh diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..c95aeaf --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,64 @@ +# Instructions for the AI Agents + +This document provides instructions and guidelines for the AI agents working on this project. + +Every agent MUST follow the rules and guidelines outlined in this document when performing their work. + +## Initialization + +You MUST run the next commands on the start of your work session: + +- `donna -p llm -r artifacts view '*:intro'` — to get an introduction to the project and its context. + +## Environment + +All development-related operations MUST be performed in Docker containers, see `./docker-compose.yml` for details. + +You MUST not perform any development-related operations directly on the host machine. + +Most important commands have script shortcuts in `./bin` directory. + +Command you are allowed to use: + +- `./bin/tests.sh` — run ALL tests via pytest. +- `./bin/utils.sh` — run utils in the backend environment, for example `/bin/utils.sh poetry run pytest brigid` +- `./bin/dev-check-spelling.sh` — check code spelling with `codespell` tool. Both for frontend and backend code. +- `./bin/dev-check-formatting.sh` — check code formatting. Both for frontend and backend code. +- `./bin/dev-check-runtime.sh` — check if code starts without errors — very basic smoke tests. +- `./bin/dev-check-semantics.sh` — check code semantics (types, linting, etc.). Both for frontend and backend code. + +If you need to do complex "test & lint & fix" activities, you MUST use the `donna-do` skill to run the code polish workflow. + +## Resticted changes / operations + +You ABSOLUTELY MUST NOT perform the following operations without explicit instructions to do so: + +- Changing `docker-compose.yml` or any Docker-related configuration. +- Changing Docker runtime parameters (like allocated resources, volumes, etc.). +- Changing running Docker services related to other projects or unrelated to development environment. +- Installing any new dependencies, both for frontend and backend. +- Updating lock files. +- Installing any new tools, utilities, or software on the host machine or in the development containers. +- Changing project structure, such as moving files around, creating new directories, etc. + +If you want to change something in the above list, you MUST ask for explicit instructions and permission to do so. + +## Top priority tools + +These tools MUST have the highest priority when an agent is deciding which tool to use for a given task: + +### `ast-grep` + +`ast-grep` — a tool for searching and manipulating Abstract Syntax Trees in code. Use it when you work with particular code patterns, structures, or constructs in the codebase. + +You MUST use it to: + +- Search for specific code patterns or structures in the codebase. +- Extract information from code, such as function definitions, variable declarations, or specific code constructs. +- Analyze code for specific patterns or anti-patterns, such as code smells, security vulnerabilities, performance issues, specific usage of libraries or APIs, etc. +- Refactor particular code patterns or structures across the codebase. +- Introduce new small behaviors or features into existing code. + +You MUST NOT use it for: + +- Implementing huge features or behaviors that require adding massive blocks of code (like adding a new class, module, writing a huge function, etc.). diff --git a/README.md b/README.md index acb8a00..1abc0f8 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,7 @@ Consider the following for production deployment: - Process Management: Use a process manager like systemd, supervisord, or Docker to ensure reliable, long-term operation. - Reverse Proxy: Set up a reverse proxy such as Nginx or Caddy to enhance performance and security. +- Reverse Proxy Prefix Mode: You MAY forward prefixed paths to Brigid without stripping the prefix (for example `/blog/*` stays `/blog/*` upstream) when `prod_url` uses the same prefix. ### Design principles diff --git a/bin/codex.sh b/bin/codex.sh new file mode 100755 index 0000000..8b58ee2 --- /dev/null +++ b/bin/codex.sh @@ -0,0 +1,3 @@ +#!/usr/bin/bash + +codex --sandbox danger-full-access --ask-for-approval on-request --search diff --git a/bin/check-code-formatting.sh b/bin/dev-check-formatting.sh similarity index 100% rename from bin/check-code-formatting.sh rename to bin/dev-check-formatting.sh diff --git a/bin/dev-check-runtime.sh b/bin/dev-check-runtime.sh new file mode 100755 index 0000000..e25b43c --- /dev/null +++ b/bin/dev-check-runtime.sh @@ -0,0 +1,9 @@ +#!/usr/bin/bash + +set -e + +echo "check that CLI works" +./bin/utils.sh poetry run brigid --help + +echo "check that CLI shows configs" +./bin/utils.sh poetry run brigid print-configs diff --git a/bin/check-code-semantics.sh b/bin/dev-check-semantics.sh similarity index 79% rename from bin/check-code-semantics.sh rename to bin/dev-check-semantics.sh index e004638..b5fd672 100755 --- a/bin/check-code-semantics.sh +++ b/bin/dev-check-semantics.sh @@ -13,3 +13,7 @@ echo "run flake8" echo "run mypy" ./bin/utils.sh poetry run mypy --show-traceback . + +echo "check poetry config" + +./bin/utils.sh poetry check diff --git a/bin/dev-check-spelling.sh b/bin/dev-check-spelling.sh new file mode 100755 index 0000000..6500e0b --- /dev/null +++ b/bin/dev-check-spelling.sh @@ -0,0 +1,7 @@ +#!/usr/bin/bash + +set -e + +echo "run codespell" + +./bin/utils.sh poetry run codespell --toml pyproject.toml ./brigid ./README.md diff --git a/bin/tests.sh b/bin/tests.sh new file mode 100755 index 0000000..5d1c1a1 --- /dev/null +++ b/bin/tests.sh @@ -0,0 +1,7 @@ +#!/usr/bin/bash + +set -e + +echo "run tests" + +./bin/utils.sh poetry run pytest brigid diff --git a/brigid/api/http_handlers.py b/brigid/api/http_handlers.py index abc8d93..7715064 100644 --- a/brigid/api/http_handlers.py +++ b/brigid/api/http_handlers.py @@ -4,9 +4,9 @@ from brigid.api import renderers from brigid.api.sitemaps import build_sitemap_xml from brigid.api.static_cache import cache -from brigid.api.utils import choose_language from brigid.core import errors, logging -from brigid.domain.urls import UrlsRoot +from brigid.domain.types import UrlPath +from brigid.domain.urls import root_url from brigid.library.storage import storage from brigid.plugins.utils import get_plugin @@ -29,7 +29,7 @@ async def favicon() -> FileResponse | HTMLResponse: return HTMLResponse(content="") path = site.path.parent / site.favicon - cache().set("/favicon.ico", path) + cache().set(UrlPath("/favicon.ico"), path) return FileResponse(path, media_type="image/x-icon") @@ -52,7 +52,7 @@ async def plugin_static(request: fastapi.Request, plugin_slug: str, filename: st if file_info is None: raise errors.FileNotFound() - cache().set(request.url.path, file_info.sys_path) + cache().set(UrlPath(request.url.path), file_info.sys_path) return FileResponse(file_info.sys_path, media_type=file_info.media_type) @@ -64,7 +64,7 @@ async def static_file(request: fastapi.Request, article_slug: str, filename: str # TODO: could it be a security breach? path = article.path.parent / filename - cache().set(request.url.path, path) + cache().set(UrlPath(request.url.path), path) # TODO: set media types according to file extension return FileResponse(path) @@ -85,19 +85,20 @@ async def feed_atom(language: str) -> HTMLResponse: @router.get("/robots.txt") async def robots() -> PlainTextResponse: - # language is not important here - root_url = UrlsRoot(language="en") + site = storage.get_site() + root = root_url(language=site.default_language) lines = [ "User-agent: *", - f"Sitemap: {root_url.to_site_map_full().url()}", + f"Sitemap: {root.to_site_map_full().url()}", ] - site = storage.get_site() + tags_url = root.to_filter() for language in sorted(site.allowed_languages): # trailing slash is important to treat the path as a prefix - lines.append(f"Disallow: /{language}/tags/") + tags_prefix_path = tags_url.to_language(language).robots_url() + lines.append(f"Disallow: {tags_prefix_path}") content = "\n".join(lines) @@ -115,13 +116,6 @@ async def test_error() -> HTMLResponse: return HTMLResponse(content="This should not be shown") -@router.get("/") -async def root(request: fastapi.Request) -> RedirectResponse: - language = choose_language(request) - # TODO: show info to the user that language was chosen automatically - return RedirectResponse(UrlsRoot(language=language).url(), status_code=302) - - @router.get("/{language}") async def blog_index(language: str) -> HTMLResponse: return renderers.render_index(language=language, raw_tags="") @@ -129,7 +123,7 @@ async def blog_index(language: str) -> HTMLResponse: @router.get("/{language}/tags") async def tags_index_zero(language: str) -> RedirectResponse: - return RedirectResponse(UrlsRoot(language=language).url(), status_code=301) + return RedirectResponse(root_url(language=language).url(), status_code=301) @router.get("/{language}/tags/{tags:path}") diff --git a/brigid/api/middlewares.py b/brigid/api/middlewares.py index 9db51a5..944b63b 100644 --- a/brigid/api/middlewares.py +++ b/brigid/api/middlewares.py @@ -7,60 +7,83 @@ from brigid.api import renderers from brigid.api.utils import choose_language from brigid.domain import request_context as d_request_context +from brigid.domain.types import UrlPath +from brigid.domain.urls import add_base_path, mcp_url, root_url, strip_base_path from brigid.library.storage import storage def is_mcp_request(request: fastapi.Request) -> bool: - path = request.url.path - return path.startswith("/mcp/") or path == "/mcp" + path = UrlPath(request.url.path) + mount_path = mcp_url().mount_path() + return path == mount_path or path.startswith(f"{mount_path}/") -async def process_404(request, _): +async def permanent_redirects(request: fastapi.Request, call_next: Any): redirects = storage.get_redirects() - original_path = request.url.path + original_path = strip_base_path(UrlPath(request.url.path)) - # normalize path + original_path = UrlPath(f"/{original_path.rstrip('/')}") - if not original_path.startswith("/"): - original_path = "/" + original_path + if original_path in redirects.permanent: + target = redirects.permanent[original_path] - if original_path[-1] == "/": - original_path = original_path[:-1] + if "://" in target: + return RedirectResponse(target, status_code=301) + + if target.startswith("/"): + return RedirectResponse(add_base_path(UrlPath(target)), status_code=301) + + raise ValueError(f"Redirect target must be absolute URL or root-relative path: {target}") + + return await call_next(request) - if original_path in redirects.permanent: - return RedirectResponse(redirects.permanent[original_path], status_code=301) +async def process_404(request, _): # noqa: CCR001 language = choose_language(request) with d_request_context.init(): - d_request_context.set("storage", storage) return renderers.render_page(language, "404", status_code=404) async def remove_double_slashes(request: fastapi.Request, call_next: Any): - path = request.url.path + path = UrlPath(request.url.path) if "//" not in path: return await call_next(request) while "//" in path: - path = path.replace("//", "/") + path = UrlPath(path.replace("//", "/")) return RedirectResponse(path, status_code=301) +async def root_to_language(request: fastapi.Request, call_next: Any): + path = UrlPath(request.url.path) + prefix = storage.get_site().url_path_prefix + + if path not in (prefix, f"{prefix}/"): + return await call_next(request) + + language = choose_language(request) + + # TODO: show info to the user that language was chosen automatically + return RedirectResponse(root_url(language=language).url(), status_code=302) + + async def remove_trailing_slash(request: fastapi.Request, call_next: Any): if is_mcp_request(request): return await call_next(request) - path = request.url.path + path = UrlPath(request.url.path) - if path != "" and path != "/" and path[-1] == "/": - return RedirectResponse(path[:-1], status_code=301) + prefix = storage.get_site().url_path_prefix - return await call_next(request) + if path == prefix or path == f"{prefix}/" or path[-1] != "/": + return await call_next(request) + + return RedirectResponse(UrlPath(path[:-1]), status_code=301) async def set_content_language(request: fastapi.Request, call_next: Any): @@ -75,10 +98,10 @@ async def set_content_language(request: fastapi.Request, call_next: Any): if "content-language" in response.headers: return response - path = request.url.path + path = strip_base_path(UrlPath(request.url.path)) for language in storage.get_site().allowed_languages: - if path.startswith(f"/{language}/") or path == f"/{language}": + if path.startswith(f"{language}/") or path == language: response.headers["content-language"] = language return response @@ -91,11 +114,9 @@ async def process_expected_error(request, error): language = choose_language(request) with d_request_context.init(): - d_request_context.set("storage", storage) return renderers.render_page(language, "500", status_code=500) async def request_context(request: fastapi.Request, call_next: Any): with d_request_context.init(): - d_request_context.set("storage", storage) return await call_next(request) diff --git a/brigid/api/static_cache.py b/brigid/api/static_cache.py index 74b7c27..4c8d011 100644 --- a/brigid/api/static_cache.py +++ b/brigid/api/static_cache.py @@ -2,6 +2,7 @@ import shutil from brigid.core import logging +from brigid.domain.types import UrlPath logger = logging.get_module_logger() @@ -15,7 +16,7 @@ def initialize(self) -> None: def clear(self) -> None: raise NotImplementedError("clear") - def set(self, cache_path: str, original_path) -> None: + def set(self, cache_path: UrlPath, original_path) -> None: raise NotImplementedError("set") @@ -28,7 +29,7 @@ def initialize(self) -> None: def clear(self) -> None: pass - def set(self, cache_path: str, original_path) -> None: + def set(self, cache_path: UrlPath, original_path) -> None: pass @@ -52,10 +53,10 @@ def clear(self) -> None: else: entry.unlink() - def set(self, raw_cache_path: str, original_path: str) -> None: + def set(self, raw_cache_path: UrlPath, original_path: str) -> None: if raw_cache_path.startswith("/"): - raw_cache_path = raw_cache_path[1:] + raw_cache_path = UrlPath(raw_cache_path[1:]) cache_path = self.directory / raw_cache_path diff --git a/brigid/api/tests/test_http_handlers.py b/brigid/api/tests/test_http_handlers.py index b245d0b..5ebca1d 100644 --- a/brigid/api/tests/test_http_handlers.py +++ b/brigid/api/tests/test_http_handlers.py @@ -2,7 +2,7 @@ import pytest from fastapi.testclient import TestClient -from brigid.domain import request_context +from brigid.library.storage import storage ############################################################################ # ATTENTION: this tests do not cover some cases of request_context usage @@ -51,7 +51,7 @@ class TestFeedAtom: @pytest.mark.asyncio async def test_works(self, client: TestClient) -> None: - for language in request_context.get("storage").get_site().allowed_languages: + for language in storage.get_site().allowed_languages: response = client.get(f"/{language}/feeds/atom") assert response.status_code == 200 assert response.headers["content-type"] == "application/atom+xml; charset=utf-8" @@ -77,6 +77,27 @@ async def test_content(self, client: TestClient) -> None: response = client.get("/robots.txt") assert response.text == expected_content.strip() + @pytest.mark.parametrize( + "prefix", + [ + "/blog", + "/long/complex/prefix", + ], + ) + @pytest.mark.asyncio + async def test_content__prefixed(self, client: TestClient, set_base_url, prefix: str) -> None: + set_base_url(f"https://example.com{prefix}") + + expected_content = f""" +User-agent: * +Sitemap: https://example.com{prefix}/sitemap.xml +Disallow: {prefix}/en/tags/ +Disallow: {prefix}/ru/tags/ + """ + + response = client.get("/robots.txt") + assert response.text == expected_content.strip() + class TestError: @@ -92,40 +113,64 @@ async def test_exception_processing_works(self, app: fastapi.FastAPI) -> None: assert response.status_code == 500 -class TestRoot: +class TestRootBehaivor: @pytest.mark.asyncio - async def test_works(self, client: TestClient) -> None: + async def test_no_prefix(self, client: TestClient) -> None: response = client.get("/") assert response.status_code == 302 assert response.headers["location"] == "http://0.0.0.0:8000/en" + @pytest.mark.parametrize( + "prefix", + [ + "/blog", + "/long/complex/prefix", + ], + ) + @pytest.mark.asyncio + async def test_works__has_prefix_no_slash(self, client: TestClient, set_base_url, prefix: str) -> None: + set_base_url(f"https://example.com{prefix}") + + response = client.get(prefix) + assert response.status_code == 302 + assert response.headers["location"] == f"https://example.com{prefix}/en" + + @pytest.mark.parametrize( + "prefix", + [ + "/blog", + "/long/complex/prefix", + ], + ) + @pytest.mark.asyncio + async def test_works__has_prefix_with_slash(self, client: TestClient, set_base_url, prefix: str) -> None: + set_base_url(f"https://example.com{prefix}") + + response = client.get(f"{prefix}/") + assert response.status_code == 302 + assert response.headers["location"] == f"https://example.com{prefix}/en" + class TestIndexRoot: @pytest.mark.asyncio - async def test_works(self, client: TestClient) -> None: - for language in request_context.get("storage").get_site().allowed_languages: + async def test_only_language(self, client: TestClient) -> None: + for language in storage.get_site().allowed_languages: response = client.get(f"/{language}") assert response.status_code == 200 assert response.headers["content-type"] == "text/html; charset=utf-8" - -class TestIndexRootWithEmptyFilter: - @pytest.mark.asyncio - async def test_works(self, client: TestClient) -> None: - for language in request_context.get("storage").get_site().allowed_languages: + async def test_empty_filter(self, client: TestClient) -> None: + for language in storage.get_site().allowed_languages: response = client.get(f"/{language}/tags") assert response.status_code == 301 assert response.headers["location"] == f"http://0.0.0.0:8000/{language}" - -class TestIndexWithFilter: - @pytest.mark.asyncio - async def test_works(self, client: TestClient) -> None: - for language in request_context.get("storage").get_site().allowed_languages: + async def test_with_filter(self, client: TestClient) -> None: + for language in storage.get_site().allowed_languages: response = client.get(f"/{language}/tags/example/-wide") assert response.status_code == 200 assert response.headers["content-type"] == "text/html; charset=utf-8" @@ -135,7 +180,7 @@ class TestPost: @pytest.mark.asyncio async def test_works(self, client: TestClient) -> None: - for language in request_context.get("storage").get_site().allowed_languages: + for language in storage.get_site().allowed_languages: response = client.get(f"/{language}/posts/post-in-two-languages") assert response.status_code == 200 assert response.headers["content-type"] == "text/html; charset=utf-8" diff --git a/brigid/api/tests/test_middlewares.py b/brigid/api/tests/test_middlewares.py new file mode 100644 index 0000000..5f3d8d2 --- /dev/null +++ b/brigid/api/tests/test_middlewares.py @@ -0,0 +1,160 @@ +import fastapi +import pytest +from fastapi.testclient import TestClient + +from brigid.api import middlewares +from brigid.library.entities import Redirects +from brigid.library.storage import storage + + +def make_request(path: str) -> fastapi.Request: + return fastapi.Request( + { + "type": "http", + "method": "GET", + "path": path, + "headers": [], + "query_string": b"", + "client": ("test", 123), + "server": ("test", 80), + "scheme": "http", + "root_path": "", + } + ) + + +class TestRedirects: + + @pytest.fixture + def set_redirects(self): + original_redirects = storage.get_redirects() + + def _set(redirects: Redirects) -> None: + storage.set_redirects(redirects) + + yield _set + + storage.set_redirects(original_redirects) + + @pytest.mark.asyncio + async def test_external_target_passthrough(self, client: TestClient, set_base_url, set_redirects) -> None: + set_base_url("https://example.com/blog") + set_redirects(Redirects(permanent={"/old": "https://external.example/path"})) + + response = client.get("/blog/old") + assert response.status_code == 301 + assert response.headers["location"] == "https://external.example/path" + + @pytest.mark.asyncio + async def test_root_relative_target_rebased_with_prefix( + self, client: TestClient, set_base_url, set_redirects + ) -> None: + set_base_url("https://example.com/blog") + set_redirects(Redirects(permanent={"/old": "/tags/"})) + + response = client.get("/blog/old") + assert response.status_code == 301 + assert response.headers["location"] == "/blog/tags/" + + @pytest.mark.asyncio + async def test_invalid_relative_target_fails(self, app: fastapi.FastAPI, set_base_url, set_redirects) -> None: + set_base_url("https://example.com/blog") + set_redirects(Redirects(permanent={"/old": "tags/"})) + + client = TestClient(app, raise_server_exceptions=False, follow_redirects=False) + response = client.get("/blog/old") + + assert response.status_code == 500 + + +class TestContentLanguageMiddleware: + + @pytest.mark.asyncio + async def test_prefixed_path_sets_header(self, set_base_url) -> None: + set_base_url("https://example.com/blog") + + async def call_next(_: fastapi.Request) -> fastapi.Response: + return fastapi.Response(status_code=200) + + request = make_request("/blog/en") + + response = await middlewares.set_content_language(request, call_next) + assert response.headers["content-language"] == "en" + + +class TestRemoveDoubleSlashesMiddleware: + + @pytest.mark.asyncio + async def test_redirects_non_prefixed_path(self, client: TestClient) -> None: + response = client.get("/en//posts/post-in-two-languages") + assert response.status_code == 301 + assert response.headers["location"] == "/en/posts/post-in-two-languages" + + @pytest.mark.asyncio + async def test_redirects_prefixed_path(self, client: TestClient, set_base_url) -> None: + set_base_url("https://example.com/blog") + + response = client.get("/blog/en//posts/post-in-two-languages") + assert response.status_code == 301 + assert response.headers["location"] == "/blog/en/posts/post-in-two-languages" + + +class TestRemoveTrailingSlashMiddleware: + + @pytest.mark.asyncio + async def test_redirects_non_prefixed_path(self, client: TestClient) -> None: + response = client.get("/en/") + assert response.status_code == 301 + assert response.headers["location"] == "/en" + + @pytest.mark.asyncio + async def test_redirects_prefixed_path(self, client: TestClient, set_base_url) -> None: + set_base_url("https://example.com/blog") + + response = client.get("/blog/en/") + assert response.status_code == 301 + assert response.headers["location"] == "/blog/en" + + @pytest.mark.asyncio + async def test_does_not_redirect_prefixed_root(self, set_base_url) -> None: + set_base_url("https://example.com/blog") + + async def call_next(_: fastapi.Request) -> fastapi.Response: + return fastapi.Response(status_code=204) + + response = await middlewares.remove_trailing_slash(make_request("/blog/"), call_next) + assert response.status_code == 204 + + +class TestRootToLanguageMiddleware: + + @pytest.mark.parametrize( + "base_url,request_path,status,location", + [ + ("https://example.com/blog", "/blog", 302, "https://example.com/blog/en"), + ("https://example.com/blog", "/blog/", 302, "https://example.com/blog/en"), + ("https://example.com", "/", 302, "https://example.com/en"), + ("https://example.com/", "/", 302, "https://example.com/en"), + ("https://example.com/blog", "/blog/en", 204, None), + ("https://example.com", "/en", 204, None), + ], + ) + @pytest.mark.asyncio + async def test_works( + self, + set_base_url, + base_url: str, + request_path: str, + status: int, + location: str | None, + ) -> None: + set_base_url(base_url) + + async def call_next(_: fastapi.Request) -> fastapi.Response: + return fastapi.Response(status_code=204) + + response = await middlewares.root_to_language(make_request(request_path), call_next) + assert response.status_code == status + + if location is not None: + assert response.headers["location"] == location diff --git a/brigid/api/utils.py b/brigid/api/utils.py index 13a58da..0c18025 100644 --- a/brigid/api/utils.py +++ b/brigid/api/utils.py @@ -4,6 +4,7 @@ from brigid.api.default_translations import translations from brigid.domain.text import capitalize_first +from brigid.domain.types import UrlPath def parse_accept_language(accept_language): @@ -49,12 +50,13 @@ def to_integer(text: str) -> int | None: def choose_language(request: fastapi.Request) -> str: + from brigid.domain.urls import strip_base_path from brigid.library.storage import storage - path = request.url.path + path = strip_base_path(UrlPath(request.url.path)) for language in storage.get_site().allowed_languages: - if path.startswith(f"/{language}/") or path == f"/{language}": + if path.startswith(f"{language}/") or path == language: return language accept_language = request.headers.get("accept-language", "") diff --git a/brigid/application/application.py b/brigid/application/application.py index 329df85..8f09c07 100644 --- a/brigid/application/application.py +++ b/brigid/application/application.py @@ -13,6 +13,7 @@ from brigid.core import logging, sentry from brigid.library import discovering from brigid.library.settings import settings as library_settings +from brigid.library.storage import storage from brigid.mcp.server import create_mcp logger = logging.get_module_logger() @@ -21,25 +22,26 @@ def initialize_api(app: fastapi.FastAPI) -> None: logger.info("initialize_api") - app.include_router(api_http_handlers.router) + prefix = storage.get_site().url_path_prefix + app.include_router(api_http_handlers.router, prefix=prefix) - app.middleware("http")(api_middlewares.request_context) - app.middleware("http")(api_middlewares.remove_double_slashes) - app.middleware("http")(api_middlewares.remove_trailing_slash) app.middleware("http")(api_middlewares.set_content_language) + app.middleware("http")(api_middlewares.permanent_redirects) + app.middleware("http")(api_middlewares.remove_trailing_slash) + app.middleware("http")(api_middlewares.root_to_language) + app.middleware("http")(api_middlewares.remove_double_slashes) + app.middleware("http")(api_middlewares.request_context) app.exception_handler(404)(api_middlewares.process_404) app.exception_handler(Exception)(api_middlewares.process_expected_error) - if api_settings.cache_directory: - cache = api_static_cache.FileCache(directory=api_settings.cache_directory) - api_static_cache.set_cache(cache) - logger.info("api_initialized") -@contextlib.asynccontextmanager -async def use_sentry() -> AsyncGenerator[None, None]: +def initialize_sentry() -> None: + if not settings.sentry.enabled: + return + logger.info("sentry_enabled") sentry.initialize( @@ -50,9 +52,19 @@ async def use_sentry() -> AsyncGenerator[None, None]: logger.info("sentry_initialized") - yield - logger.info("sentry_disabled") +@sentry.capture +def initialize_cache() -> None: + if not api_settings.cache_directory: + return + + cache = api_static_cache.FileCache(directory=api_settings.cache_directory) + api_static_cache.set_cache(cache) + + +@sentry.capture +def initialize_content() -> None: + discovering.load(directory=library_settings.directory) def create_app() -> fastapi.FastAPI: # noqa: CCR001 @@ -60,15 +72,13 @@ def create_app() -> fastapi.FastAPI: # noqa: CCR001 logger.info("create_app") + initialize_sentry() + initialize_cache() + initialize_content() + @contextlib.asynccontextmanager async def lifespan(app: fastapi.FastAPI) -> AsyncGenerator[None, None]: async with contextlib.AsyncExitStack() as stack: - if settings.sentry.enabled: - await stack.enter_async_context(use_sentry()) - - # TODO: must be skipped in tests? - discovering.load(directory=library_settings.directory) - mcp_app = create_mcp(app) await app.router.startup() @@ -89,6 +99,7 @@ async def lifespan(app: fastapi.FastAPI) -> AsyncGenerator[None, None]: docs_url=None, redoc_url=None, openapi_url=None, + redirect_slashes=False, ) initialize_api(app) @@ -108,8 +119,6 @@ async def lifespan(app: fastapi.FastAPI) -> AsyncGenerator[None, None]: @contextlib.asynccontextmanager async def with_app() -> AsyncGenerator[fastapi.FastAPI, None]: + app = create_app() async with app.router.lifespan_context(app): yield app - - -app = create_app() diff --git a/brigid/asgi.py b/brigid/asgi.py new file mode 100644 index 0000000..c7ae479 --- /dev/null +++ b/brigid/asgi.py @@ -0,0 +1,3 @@ +from brigid.application.application import create_app + +app = create_app() diff --git a/brigid/cli/commands/static.py b/brigid/cli/commands/static.py index 3ff4c0e..2c70641 100644 --- a/brigid/cli/commands/static.py +++ b/brigid/cli/commands/static.py @@ -7,7 +7,6 @@ from brigid.application.application import with_app from brigid.cli.application import app from brigid.core import logging -from brigid.domain import request_context from brigid.domain.urls import UrlsPlugin from brigid.library.storage import storage from brigid.plugins.utils import plugins @@ -24,15 +23,12 @@ async def run() -> None: site = storage.get_site() - with request_context.init(): - request_context.set("storage", storage) + for plugin in plugins(): + url = UrlsPlugin(plugin.slug, language=site.default_language) - for plugin in plugins(): - url = UrlsPlugin(plugin.slug, language=site.default_language) - - for file_info in plugin.static_files(): - full_path = pathlib.Path(file_info.sys_path).resolve() - files.append(f"{full_path}\t->\t{url.file_url(file_info.url_path)}") + for file_info in plugin.static_files(): + full_path = pathlib.Path(file_info.sys_path).resolve() + files.append(f"{full_path}\t->\t{url.file_url(file_info.url_path)}") files.sort() diff --git a/brigid/cli/commands/templates.py b/brigid/cli/commands/templates.py index 5433d72..cff5502 100644 --- a/brigid/cli/commands/templates.py +++ b/brigid/cli/commands/templates.py @@ -60,7 +60,7 @@ async def run_copy(destination: pathlib.Path) -> None: def copy(destination: pathlib.Path) -> None: """Copy all Jinja2 templates to the specified destination directory. - May be helpfull: + May be helpful: - for starting a new theme - for collecting all html code for tailwind to generate an optimal CSS file diff --git a/brigid/conftest.py b/brigid/conftest.py index b436522..44eb761 100644 --- a/brigid/conftest.py +++ b/brigid/conftest.py @@ -1,19 +1,18 @@ import asyncio import os -from typing import Any, AsyncGenerator, Generator +from typing import AsyncGenerator, Generator import fastapi import pytest import pytest_asyncio from fastapi.testclient import TestClient -from brigid.application import application from brigid.domain import request_context -from brigid.library.storage import storage +from brigid.library.tests.fixtures import * # noqa @pytest.fixture(scope="session", autouse=True) -def mark_tests_running(): +def mark_tests_running() -> None: os.environ["BRIGID_TESTS_RUNNING"] = "True" @@ -27,12 +26,15 @@ def event_loop() -> Generator[asyncio.AbstractEventLoop, asyncio.AbstractEventLo @pytest.fixture(autouse=True) def reset_request_context(): with request_context.init(): - request_context.set("storage", storage) yield @pytest_asyncio.fixture(scope="session", autouse=True) -async def app(mark_tests_running: Any) -> AsyncGenerator[fastapi.FastAPI, None]: +async def app(mark_tests_running) -> AsyncGenerator[fastapi.FastAPI, None]: + # we want to guarantee that there will be no hidden initializations + # of applications or settings => we import application module in the fixture + from brigid.application import application + async with application.with_app() as app: yield app diff --git a/brigid/core/sentry.py b/brigid/core/sentry.py index e0abcdd..a939532 100644 --- a/brigid/core/sentry.py +++ b/brigid/core/sentry.py @@ -1,3 +1,8 @@ +from collections.abc import Callable +from functools import wraps +from typing import ParamSpec, TypeVar + +from sentry_sdk import capture_exception as sentry_capture_exception from sentry_sdk import init as initialize_sentry from sentry_sdk.integrations.fastapi import FastApiIntegration from sentry_sdk.integrations.logging import LoggingIntegration @@ -5,6 +10,30 @@ import brigid +P = ParamSpec("P") +R = TypeVar("R") + + +def _is_enabled() -> bool: + from brigid.application.settings import settings + + return settings.sentry.enabled + + +def capture(func: Callable[P, R]) -> Callable[P, R]: + @wraps(func) + def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: + if not _is_enabled(): + return func(*args, **kwargs) + + try: + return func(*args, **kwargs) + except Exception as error: + sentry_capture_exception(error) + raise + + return wrapper + def initialize(dsn: str, sample_rate: float, environment: str) -> None: initialize_sentry( diff --git a/brigid/domain/request_context.py b/brigid/domain/request_context.py index 298db31..d914987 100644 --- a/brigid/domain/request_context.py +++ b/brigid/domain/request_context.py @@ -5,19 +5,16 @@ # TODO: these imports may cause circular dependencies, fix them from brigid.domain.urls import UrlsBase -from brigid.library.storage import Storage class Variable(enum.StrEnum): language = "language" url = "url" - storage = "storage" class RequestContextType(TypedDict): language: str url: UrlsBase - storage: Storage request_context: contextvars.ContextVar[RequestContextType] = contextvars.ContextVar("request_context") diff --git a/brigid/domain/tests/test_urls.py b/brigid/domain/tests/test_urls.py index 90f2ccb..6549f10 100644 --- a/brigid/domain/tests/test_urls.py +++ b/brigid/domain/tests/test_urls.py @@ -4,15 +4,21 @@ import pytest +from brigid.domain.types import UrlPath from brigid.domain.urls import ( UrlsAuthor, UrlsBase, UrlsFeedsAtom, + UrlsMCP, UrlsPost, UrlsRoot, UrlsSiteMapFull, + UrlsStatic, UrlsTags, + add_base_path, normalize_url, + root_url, + strip_base_path, ) from brigid.library.storage import storage @@ -45,6 +51,46 @@ def test_normalization(self, url_in: str, url_out: str): assert normalize_url(url_in) == url_out +class TestStripBasePath: + + @pytest.mark.parametrize( + "path,prefix,expected", + [ + ("", "", ""), + ("/", "", ""), + ("/en", "", "en"), + ("en", "", "en"), + ("/blog", "/blog", ""), + ("/blog/en", "/blog", "en"), + ("blog/en", "/blog", "en"), + ("/en", "/blog", "en"), + ("/long/complex/prefix/en", "/long/complex/prefix", "en"), + ], + ) + def test_strip_base_path(self, path: UrlPath, prefix: UrlPath, expected: UrlPath) -> None: + with mock.patch("brigid.domain.urls._base_path_prefix", return_value=prefix): + assert strip_base_path(path) == expected + + +class TestAddBasePath: + + @pytest.mark.parametrize( + "path,prefix,expected", + [ + ("", "", "/"), + ("/en", "", "/en"), + ("en", "", "/en"), + ("/", "/blog", "/blog"), + ("/en", "/blog", "/blog/en"), + ("en", "/blog", "/blog/en"), + ("/en", "/long/complex/prefix", "/long/complex/prefix/en"), + ], + ) + def test_add_base_path(self, path: UrlPath, prefix: UrlPath, expected: UrlPath) -> None: + with mock.patch("brigid.domain.urls._base_path_prefix", return_value=prefix): + assert add_base_path(path) == expected + + class _TestUrlsBase: base_language = "en" @@ -60,8 +106,24 @@ def _consruct_url(self) -> Any: def test_base_initialized(self, url: UrlsBase) -> None: assert url.language == self.base_language - def test_url_method_redefined(self, url: UrlsBase) -> None: - assert isinstance(url.url(), str) + @pytest.mark.parametrize( + "base_url,expected_url", + [ + ("https://example.com", "https://example.com"), + ("https://example.com/blog", "https://example.com/blog"), + ("https://example.com/long/complex/prefix", "https://example.com/long/complex/prefix"), + ], + ) + def test_url_method_redefined(self, url: UrlsBase, base_url: str, expected_url: str) -> None: + del expected_url + with mock.patch("brigid.domain.urls._base_url", return_value=base_url): + assert isinstance(url.url(), str) + + def test_path_method_redefined(self, url: UrlsBase) -> None: + assert isinstance(url.path(), str) + + def test_robots_url_method_redefined(self, url: UrlsBase) -> None: + assert url.robots_url() == f"/{url.path()}/" def test_file_url(self, url: UrlsBase) -> None: with pytest.raises(NotImplementedError): @@ -100,6 +162,18 @@ def test_language(self, url: UrlsBase) -> None: def test_site_map_full(self, url: UrlsBase) -> None: assert url.to_site_map_full() == UrlsSiteMapFull(language=self.base_language) + @pytest.mark.parametrize( + "base_url,expected_url", + [ + ("https://example.com", "https://example.com/favicon.ico"), + ("https://example.com/blog", "https://example.com/blog/favicon.ico"), + ("https://example.com/long/complex/prefix", "https://example.com/long/complex/prefix/favicon.ico"), + ], + ) + def test_to_favicon(self, url: UrlsBase, base_url: str, expected_url: str) -> None: + with mock.patch("brigid.domain.urls._base_url", return_value=base_url): + assert url.to_favicon().url() == expected_url + def test_noindex(self, url: UrlsBase) -> None: assert not url.is_noindex() @@ -109,18 +183,38 @@ class TestUrlsBase(_TestUrlsBase): def _consruct_url(self) -> UrlsBase: return UrlsBase(language=self.base_language) - def test_url_method_redefined(self, url: UrlsBase) -> None: + def test_url_method_redefined(self, url: UrlsBase) -> None: # type: ignore[override] with pytest.raises(NotImplementedError): url.url() + def test_path_method_redefined(self, url: UrlsBase) -> None: + with pytest.raises(NotImplementedError): + url.path() + + def test_robots_url_method_redefined(self, url: UrlsBase) -> None: + with pytest.raises(NotImplementedError): + url.robots_url() + class TestUrlsRoot(_TestUrlsBase): def _consruct_url(self) -> UrlsRoot: return UrlsRoot(language=self.base_language) - def test_url_method_redefined(self, url: UrlsBase) -> None: - assert url.url() == f"{base_url}/{self.base_language}" + def test_root_url_constructor(self) -> None: + assert root_url(self.base_language) == UrlsRoot(language=self.base_language) + + @pytest.mark.parametrize( + "base_url,expected_url", + [ + ("https://example.com", "https://example.com/en"), + ("https://example.com/blog", "https://example.com/blog/en"), + ("https://example.com/long/complex/prefix", "https://example.com/long/complex/prefix/en"), + ], + ) + def test_url_method_redefined(self, url: UrlsBase, base_url: str, expected_url: str) -> None: + with mock.patch("brigid.domain.urls._base_url", return_value=base_url): + assert url.url() == expected_url class TestUrlsAuthor(_TestUrlsBase): @@ -128,8 +222,17 @@ class TestUrlsAuthor(_TestUrlsBase): def _consruct_url(self) -> UrlsAuthor: return UrlsAuthor(language=self.base_language) - def test_url_method_redefined(self, url: UrlsBase) -> None: - assert url.url() == f"{base_url}/{self.base_language}/about" + @pytest.mark.parametrize( + "base_url,expected_url", + [ + ("https://example.com", "https://example.com/en/posts/about"), + ("https://example.com/blog", "https://example.com/blog/en/posts/about"), + ("https://example.com/long/complex/prefix", "https://example.com/long/complex/prefix/en/posts/about"), + ], + ) + def test_url_method_redefined(self, url: UrlsBase, base_url: str, expected_url: str) -> None: + with mock.patch("brigid.domain.urls._base_url", return_value=base_url): + assert url.url() == expected_url class TestUrlsFeedsAtom(_TestUrlsBase): @@ -137,8 +240,17 @@ class TestUrlsFeedsAtom(_TestUrlsBase): def _consruct_url(self) -> UrlsFeedsAtom: return UrlsFeedsAtom(language=self.base_language) - def test_url_method_redefined(self, url: UrlsBase) -> None: - assert url.url() == f"{base_url}/{self.base_language}/feeds/atom" + @pytest.mark.parametrize( + "base_url,expected_url", + [ + ("https://example.com", "https://example.com/en/feeds/atom"), + ("https://example.com/blog", "https://example.com/blog/en/feeds/atom"), + ("https://example.com/long/complex/prefix", "https://example.com/long/complex/prefix/en/feeds/atom"), + ], + ) + def test_url_method_redefined(self, url: UrlsBase, base_url: str, expected_url: str) -> None: + with mock.patch("brigid.domain.urls._base_url", return_value=base_url): + assert url.url() == expected_url class TestUrlsPost(_TestUrlsBase): @@ -149,13 +261,29 @@ def _consruct_url(self) -> UrlsPost: def test_initialized(self, url: UrlsPost) -> None: assert url.slug == "some-slug" - def test_url_method_redefined(self, url: UrlsPost) -> None: # type: ignore - assert url.url() == f"{base_url}/{self.base_language}/posts/some-slug" + @pytest.mark.parametrize( + "base_url,expected_url", + [ + ("https://example.com", "https://example.com/en/posts/some-slug"), + ("https://example.com/blog", "https://example.com/blog/en/posts/some-slug"), + ("https://example.com/long/complex/prefix", "https://example.com/long/complex/prefix/en/posts/some-slug"), + ], + ) + def test_url_method_redefined(self, url: UrlsBase, base_url: str, expected_url: str) -> None: + with mock.patch("brigid.domain.urls._base_url", return_value=base_url): + assert url.url() == expected_url def test_file_url(self, url: UrlsPost) -> None: # type: ignore filepath = "images/some-image.png" assert url.file_url(filepath) == f"{base_url}/static/posts/some-slug/{filepath}" + @mock.patch("brigid.domain.urls._base_url", return_value="https://example.com/blog") + def test_file_url__prefix_blog(self, _): + assert ( + UrlsPost(language=self.base_language, slug="some-slug").file_url("images/some-image.png") + == "https://example.com/blog/static/posts/some-slug/images/some-image.png" + ) + class TestUrlsTags(_TestUrlsBase): @@ -168,28 +296,104 @@ def test_initialized(self, url: UrlsTags) -> None: assert url.excluded_tags == {"c", "b", "e"} assert url.selected_tags == {"a", "b", "c", "d", "e"} - def test_url_method_redefined(self, url: UrlsTags) -> None: # type: ignore - assert url.url() == f"{base_url}/{self.base_language}/tags/a/-b/-c/d/-e/13" + @pytest.mark.parametrize( + "base_url,expected_url", + [ + ("https://example.com", "https://example.com/en/tags/a/-b/-c/d/-e/13"), + ("https://example.com/blog", "https://example.com/blog/en/tags/a/-b/-c/d/-e/13"), + ( + "https://example.com/long/complex/prefix", + "https://example.com/long/complex/prefix/en/tags/a/-b/-c/d/-e/13", + ), + ], + ) + def test_url_method_redefined(self, url: UrlsBase, base_url: str, expected_url: str) -> None: + with mock.patch("brigid.domain.urls._base_url", return_value=base_url): + assert url.url() == expected_url - def test_url_method__empty(self) -> None: + @pytest.mark.parametrize( + "base_url,expected_url", + [ + ("https://example.com", "https://example.com/en"), + ("https://example.com/blog", "https://example.com/blog/en"), + ("https://example.com/long/complex/prefix", "https://example.com/long/complex/prefix/en"), + ], + ) + def test_url_method__empty(self, base_url: str, expected_url: str) -> None: url = UrlsTags(language=self.base_language, page=1, required_tags=(), excluded_tags=()) + with mock.patch("brigid.domain.urls._base_url", return_value=base_url): + assert url.url() == expected_url - assert url.url() == f"{base_url}/{self.base_language}" - - def test_url_method__only_required(self) -> None: + @pytest.mark.parametrize( + "base_url,expected_url", + [ + ("https://example.com", "https://example.com/en/tags/a/b"), + ("https://example.com/blog", "https://example.com/blog/en/tags/a/b"), + ("https://example.com/long/complex/prefix", "https://example.com/long/complex/prefix/en/tags/a/b"), + ], + ) + def test_url_method__only_required(self, base_url: str, expected_url: str) -> None: url = UrlsTags(language=self.base_language, page=1, required_tags=("a", "b"), excluded_tags=()) + with mock.patch("brigid.domain.urls._base_url", return_value=base_url): + assert url.url() == expected_url - assert url.url() == f"{base_url}/{self.base_language}/tags/a/b" - - def test_url_method__only_excluded(self) -> None: + @pytest.mark.parametrize( + "base_url,expected_url", + [ + ("https://example.com", "https://example.com/en/tags/-a/-b"), + ("https://example.com/blog", "https://example.com/blog/en/tags/-a/-b"), + ("https://example.com/long/complex/prefix", "https://example.com/long/complex/prefix/en/tags/-a/-b"), + ], + ) + def test_url_method__only_excluded(self, base_url: str, expected_url: str) -> None: url = UrlsTags(language=self.base_language, page=1, required_tags=(), excluded_tags=("a", "b")) + with mock.patch("brigid.domain.urls._base_url", return_value=base_url): + assert url.url() == expected_url - assert url.url() == f"{base_url}/{self.base_language}/tags/-a/-b" - - def test_url_method__only_page(self) -> None: + @pytest.mark.parametrize( + "base_url,expected_url", + [ + ("https://example.com", "https://example.com/en/tags/13"), + ("https://example.com/blog", "https://example.com/blog/en/tags/13"), + ("https://example.com/long/complex/prefix", "https://example.com/long/complex/prefix/en/tags/13"), + ], + ) + def test_url_method__only_page(self, base_url: str, expected_url: str) -> None: url = UrlsTags(language=self.base_language, page=13, required_tags=(), excluded_tags=()) + with mock.patch("brigid.domain.urls._base_url", return_value=base_url): + assert url.url() == expected_url + + def test_robots_url__empty(self) -> None: + url = UrlsTags(language=self.base_language, page=1, required_tags=(), excluded_tags=()) + assert url.robots_url() == f"/{self.base_language}/tags/" + + @pytest.mark.parametrize( + "path_prefix,expected_url", + [ + ("/blog", "/blog/en/tags/"), + ("/long/complex/prefix", "/long/complex/prefix/en/tags/"), + ], + ) + def test_robots_url__empty__prefixed(self, path_prefix: str, expected_url: str) -> None: + with mock.patch("brigid.domain.urls._base_path_prefix", return_value=path_prefix): + url = UrlsTags(language=self.base_language, page=1, required_tags=(), excluded_tags=()) + assert url.robots_url() == expected_url - assert url.url() == f"{base_url}/{self.base_language}/tags/13" + def test_robots_url__not_empty(self) -> None: + url = UrlsTags(language=self.base_language, page=1, required_tags=("sample",), excluded_tags=()) + assert url.robots_url() == f"/{self.base_language}/tags/sample/" + + @pytest.mark.parametrize( + "path_prefix,expected_url", + [ + ("/blog", "/blog/en/tags/sample/"), + ("/long/complex/prefix", "/long/complex/prefix/en/tags/sample/"), + ], + ) + def test_robots_url__not_empty__prefixed(self, path_prefix: str, expected_url: str) -> None: + with mock.patch("brigid.domain.urls._base_path_prefix", return_value=path_prefix): + url = UrlsTags(language=self.base_language, page=1, required_tags=("sample",), excluded_tags=()) + assert url.robots_url() == expected_url @mock.patch("brigid.domain.urls.UrlsTags.total_pages", 100500) def test_is_same_index(self, url: UrlsTags) -> None: @@ -352,3 +556,40 @@ def test_get_total_pages__exclued_filter(self) -> None: pages = (len(all_pages) + posts_per_page - 1) // posts_per_page assert pages == url.total_pages + + +class TestUrlsStatic: + + def test_path(self) -> None: + assert UrlsStatic(url_path=UrlPath("favicon.ico")).path() == "favicon.ico" + + @pytest.mark.parametrize( + "base,url", + [ + ("https://example.com", "https://example.com/favicon.ico"), + ("https://example.com/blog", "https://example.com/blog/favicon.ico"), + ("https://example.com/long/complex/prefix", "https://example.com/long/complex/prefix/favicon.ico"), + ], + ) + def test_favicon__prefix_variants(self, base: str, url: str) -> None: + with mock.patch("brigid.domain.urls._base_url", return_value=base): + assert UrlsStatic(url_path=UrlPath("favicon.ico")).url() == url + + +class TestUrlsMCP: + + def test_path(self) -> None: + assert UrlsMCP().path() == "mcp" + + @pytest.mark.parametrize( + "prefix,path", + [ + ("", "/mcp"), + ("/blog", "/blog/mcp"), + ("/long/complex/prefix", "/long/complex/prefix/mcp"), + ], + ) + def test_mount_path(self, set_base_url, prefix: str, path: UrlPath) -> None: + base_url = f"https://example.com{prefix}" if prefix else "https://example.com" + set_base_url(base_url) + assert UrlsMCP().mount_path() == path diff --git a/brigid/domain/types.py b/brigid/domain/types.py new file mode 100644 index 0000000..5fdc2bf --- /dev/null +++ b/brigid/domain/types.py @@ -0,0 +1,3 @@ +from typing import NewType + +UrlPath = NewType("UrlPath", str) diff --git a/brigid/domain/urls.py b/brigid/domain/urls.py index f1c0321..c9628a4 100644 --- a/brigid/domain/urls.py +++ b/brigid/domain/urls.py @@ -3,11 +3,56 @@ from typing import Any, Iterable from urllib.parse import urlparse, urlunparse -from brigid.domain import request_context +from brigid.domain.types import UrlPath +from brigid.library.storage import storage def _base_url() -> str: - return request_context.get("storage").get_site().url # type: ignore + return storage.get_site().url + + +def _base_path_prefix() -> UrlPath: + return storage.get_site().url_path_prefix + + +def strip_base_path(path: UrlPath) -> UrlPath: + prefix = _base_path_prefix() + + if not prefix: + return UrlPath(path.lstrip("/")) + + if not path: + raise NotImplementedError("Unexpected empty path") + + if path[0] != "/": + path = UrlPath("/" + path) + + if path == prefix or path.startswith(prefix + "/"): + path = UrlPath(path[len(prefix) :]) + + return UrlPath(path.lstrip("/")) + + +def add_base_path(path: UrlPath) -> UrlPath: + prefix = _base_path_prefix() + + if path == "/": + path = UrlPath("") + + if not prefix and not path: + return UrlPath("/") + + if not prefix: + return UrlPath(f"/{path.lstrip('/')}") + + if not path: + return prefix + + return UrlPath(f"{prefix.rstrip('/')}/{path.lstrip('/')}") + + +def _build_url(path: UrlPath) -> str: + return normalize_url(f"{_base_url()}/{path.lstrip('/')}") def normalize_url(url: str) -> str: @@ -45,8 +90,23 @@ def is_noindex(self) -> bool: def __init__(self, language: str) -> None: self.language = language + def path(self) -> UrlPath: + raise NotImplementedError("path") + def url(self) -> str: - raise NotImplementedError("url") + return _build_url(self.path()) + + def _robots_url_path(self, path: UrlPath) -> UrlPath: + prefix = _base_path_prefix().strip("/") + normalized_path = path.strip("/") + + if prefix: + return UrlPath(f"/{prefix}/{normalized_path}/") + + return UrlPath(f"/{normalized_path}/") + + def robots_url(self) -> UrlPath: + return self._robots_url_path(self.path()) def file_url(self, relative_path: str) -> str: raise NotImplementedError("file_url") @@ -82,6 +142,9 @@ def to_site_map_full(self) -> "UrlsSiteMapFull": def to_plugin(self, plugin: str) -> "UrlsPlugin": return UrlsPlugin(plugin=plugin, language=self.language) + def to_favicon(self) -> "UrlsStatic": + return UrlsStatic(url_path=UrlPath("favicon.ico")) + def __eq__(self, other: Any) -> bool: if not isinstance(other, self.__class__): return False @@ -92,30 +155,30 @@ def __eq__(self, other: Any) -> bool: class UrlsRoot(UrlsBase): __slots__ = () - def url(self) -> str: - return normalize_url(f"{_base_url()}/{self.language}") + def path(self) -> UrlPath: + return UrlPath(self.language) # TODO: this is a temporary solution, we should explicitly define urls for authors class UrlsAuthor(UrlsBase): __slots__ = () - def url(self) -> str: - return normalize_url(f"{_base_url()}/{self.language}/posts/about") + def path(self) -> UrlPath: + return UrlPath(f"{self.language}/posts/about") class UrlsFeedsAtom(UrlsBase): __slots__ = () - def url(self) -> str: - return normalize_url(f"{_base_url()}/{self.language}/feeds/atom") + def path(self) -> UrlPath: + return UrlPath(f"{self.language}/feeds/atom") class UrlsSiteMapFull(UrlsBase): __slots__ = () - def url(self) -> str: - return normalize_url(f"{_base_url()}/sitemap.xml") + def path(self) -> UrlPath: + return UrlPath("sitemap.xml") class UrlsPlugin(UrlsBase): @@ -126,7 +189,33 @@ def __init__(self, plugin: str, language: str) -> None: self.plugin_slug = plugin def file_url(self, relative_path: str) -> str: - return normalize_url(f"{_base_url()}/static/plugins/{self.plugin_slug}/{relative_path}") + return _build_url(UrlPath(f"static/plugins/{self.plugin_slug}/{relative_path}")) + + +class UrlsStatic: + __slots__ = ("url_path",) + + def __init__(self, url_path: UrlPath) -> None: + self.url_path = url_path + + def path(self) -> UrlPath: + return self.url_path + + def url(self) -> str: + return _build_url(self.path()) + + +class UrlsMCP: + __slots__ = () + + def path(self) -> UrlPath: + return UrlPath("mcp") + + def mount_path(self) -> UrlPath: + from brigid.library.storage import storage + + prefix = storage.get_site().url_path_prefix + return UrlPath(f"{prefix}/{self.path()}") if prefix else UrlPath(f"/{self.path()}") class UrlsPost(UrlsBase): @@ -136,11 +225,11 @@ def __init__(self, slug: str, **kwargs) -> None: super().__init__(**kwargs) self.slug = slug - def url(self) -> str: - return normalize_url(f"{_base_url()}/{self.language}/posts/{self.slug}") + def path(self) -> UrlPath: + return UrlPath(f"{self.language}/posts/{self.slug}") def file_url(self, relative_path: str) -> str: - return normalize_url(f"{_base_url()}/static/posts/{self.slug}/{relative_path}") + return _build_url(UrlPath(f"static/posts/{self.slug}/{relative_path}")) def __eq__(self, other: Any) -> bool: if not super().__eq__(other): @@ -199,8 +288,7 @@ def is_next_to(self, current_url: UrlsBase) -> bool: return self._is_same_index(current_url) and self.page - 1 == current_url.page # type: ignore def _get_total_pages(self) -> int: - storage = request_context.get("storage") # type: ignore - posts_per_page = storage.get_site().posts_per_page # type: ignore + posts_per_page = storage.get_site().posts_per_page all_pages = storage.get_posts( language=self.language, @@ -224,7 +312,7 @@ def is_noindex(self) -> bool: # because there are infinite number of them return bool(self.selected_tags) - def url(self) -> str: + def path(self) -> UrlPath: tags = list(self.required_tags | self.excluded_tags) tags.sort() @@ -235,11 +323,17 @@ def url(self) -> str: tags.append(str(self.page)) if not tags: - return normalize_url(f"{_base_url()}/{self.language}") + return UrlPath(self.language) tags_path = "/".join(tags) - return normalize_url(f"{_base_url()}/{self.language}/tags/{tags_path}") + return UrlPath(f"{self.language}/tags/{tags_path}") + + def robots_url(self) -> UrlPath: + if not self.selected_tags and self.page == 1: + return self._robots_url_path(UrlPath(f"{self.language}/tags")) + + return super().robots_url() def first_page(self) -> "UrlsTags": return UrlsTags( @@ -293,3 +387,11 @@ def remove(self, *tags: str) -> "UrlsTags": required_tags=self.required_tags - set(tags), excluded_tags=self.excluded_tags - set(tags), ) + + +def root_url(language: str) -> UrlsRoot: + return UrlsRoot(language=language) + + +def mcp_url() -> UrlsMCP: + return UrlsMCP() diff --git a/brigid/jinja2_render/jinjaglobals.py b/brigid/jinja2_render/jinjaglobals.py index e6187d0..203b114 100644 --- a/brigid/jinja2_render/jinjaglobals.py +++ b/brigid/jinja2_render/jinjaglobals.py @@ -5,7 +5,7 @@ from markupsafe import Markup from brigid.domain import request_context as d_request_context -from brigid.domain.urls import UrlsPlugin, UrlsRoot +from brigid.domain.urls import UrlsPlugin, root_url from brigid.jinja2_render.utils import jinjafilter, jinjaglobal from brigid.library import utils as l_utils from brigid.library.entities import Page, PageSeriesInfo @@ -36,9 +36,7 @@ def image_info(path: pathlib.Path) -> ImageInfo: return files.image_info(path) -@jinjaglobal -def root_url(language: str) -> UrlsRoot: - return UrlsRoot(language=language) +root_url = jinjaglobal(root_url) @jinjaglobal diff --git a/brigid/library/connectivity.py b/brigid/library/connectivity.py index 1b255b1..dd952e9 100644 --- a/brigid/library/connectivity.py +++ b/brigid/library/connectivity.py @@ -28,7 +28,6 @@ def process_page(self, page_id: str) -> None: return with request_context.init(): - request_context.set("storage", storage) render_page(storage.get_page(page_id)) self._processed_pages.add(page_id) diff --git a/brigid/library/entities.py b/brigid/library/entities.py index 5d5d4ed..f116e2b 100644 --- a/brigid/library/entities.py +++ b/brigid/library/entities.py @@ -1,15 +1,18 @@ import datetime import enum import pathlib +import posixpath import re from functools import cached_property from typing import Literal +from urllib.parse import urlparse import pydantic from brigid.core.entities import BaseEntity from brigid.domain import urls from brigid.domain.entities import Environment +from brigid.domain.types import UrlPath MORE_RE = re.compile(r"", re.IGNORECASE) @@ -150,6 +153,19 @@ def url(self) -> str: raise NotImplementedError(f"Unknown environment: {settings.environment}") + @cached_property + def url_path_prefix(self) -> UrlPath: + parsed_url = urlparse(self.url) + path = posixpath.normpath(parsed_url.path) + + if path in ("", ".", "/"): + return UrlPath("") + + if not path.startswith("/"): + path = "/" + path + + return UrlPath(path.rstrip("/")) + class Article(BaseEntity): path: pathlib.Path diff --git a/brigid/library/tests/fixtures.py b/brigid/library/tests/fixtures.py new file mode 100644 index 0000000..9113ef0 --- /dev/null +++ b/brigid/library/tests/fixtures.py @@ -0,0 +1,20 @@ +import pytest + +from brigid.library.storage import storage + + +@pytest.fixture +def set_base_url(): + site = storage.get_site() + original_local_url = site.local_url + + def _set(url: str) -> None: + site.local_url = url + site.__dict__.pop("url", None) + site.__dict__.pop("url_path_prefix", None) + + yield _set + + site.local_url = original_local_url + site.__dict__.pop("url", None) + site.__dict__.pop("url_path_prefix", None) diff --git a/brigid/library/tests/test_entities.py b/brigid/library/tests/test_entities.py index 5aa7f20..da61154 100644 --- a/brigid/library/tests/test_entities.py +++ b/brigid/library/tests/test_entities.py @@ -1,24 +1,94 @@ -from unittest import mock +import copy +import pytest + +from brigid.application.settings import settings from brigid.domain.entities import Environment from brigid.library.storage import storage class TestSite: - def test_url_choice(self) -> None: - site = storage.get_site() + @pytest.fixture + def site(self): + source_site = copy.deepcopy(storage.get_site()) + return source_site.__class__(**source_site.model_dump()) + + @pytest.fixture + def set_environment(self): + original_environment = settings.environment + + def _set(environment: Environment) -> None: + settings.environment = environment + + yield _set + + settings.environment = original_environment + + @pytest.mark.parametrize( + "environment,expected_url", + [ + (Environment.prod, "https://example.com"), + (Environment.local, "http://0.0.0.0:8000"), + ], + ) + def test_url_choice(self, site, set_environment, environment: Environment, expected_url: str) -> None: # check default values assert str(site.prod_url) == "https://example.com/" assert str(site.local_url) == "http://0.0.0.0:8000/" - del site.url + set_environment(environment) + assert site.url == expected_url + + @pytest.mark.parametrize( + "configured_url,expected_url,expected_prefix", + [ + ("https://example.com", "https://example.com", ""), + ("https://example.com/blog", "https://example.com/blog", "/blog"), + ( + "https://example.com/long/complex/prefix", + "https://example.com/long/complex/prefix", + "/long/complex/prefix", + ), + ], + ) + def test_url_and_url_path_prefix__prod( + self, + site, + set_environment, + configured_url: str, + expected_url: str, + expected_prefix: str, + ) -> None: + site.prod_url = configured_url + set_environment(Environment.prod) - with mock.patch("brigid.application.settings.settings.environment", Environment.prod): - assert site.url == "https://example.com" + assert site.url == expected_url + assert site.url_path_prefix == expected_prefix - del site.url + @pytest.mark.parametrize( + "configured_url,expected_url,expected_prefix", + [ + ("https://example.com", "https://example.com", ""), + ("https://example.com/blog", "https://example.com/blog", "/blog"), + ( + "https://example.com/long/complex/prefix", + "https://example.com/long/complex/prefix", + "/long/complex/prefix", + ), + ], + ) + def test_url_and_url_path_prefix__local( + self, + site, + set_environment, + configured_url: str, + expected_url: str, + expected_prefix: str, + ) -> None: + site.local_url = configured_url + set_environment(Environment.local) - with mock.patch("brigid.application.settings.settings.environment", Environment.local): - assert site.url == "http://0.0.0.0:8000" + assert site.url == expected_url + assert site.url_path_prefix == expected_prefix diff --git a/brigid/mcp/server.py b/brigid/mcp/server.py index c417e89..c21347a 100644 --- a/brigid/mcp/server.py +++ b/brigid/mcp/server.py @@ -5,6 +5,7 @@ from fastmcp.server.http import StarletteWithLifespan from brigid.core import utils +from brigid.domain.urls import mcp_url from brigid.library.storage import storage from brigid.mcp.tools import create_tools @@ -54,7 +55,7 @@ def construct_instructions() -> str: return "\n".join(source) # type: ignore -# We create MCP instance dymanically because: +# We create MCP instance dynamically because: # - we need site configs that are loaded at runtime # - we may need to construct multiple MCPs (per language) in the future def create_mcp(app: fastapi.FastAPI) -> StarletteWithLifespan: @@ -80,6 +81,7 @@ def create_mcp(app: fastapi.FastAPI) -> StarletteWithLifespan: create_tools(mcp) mcp_app = mcp.http_app(path="/") - app.mount("/mcp", mcp_app) + mount_path = mcp_url().mount_path() + app.mount(mount_path, mcp_app) return mcp_app diff --git a/brigid/mcp/tools.py b/brigid/mcp/tools.py index 5f638bd..12e9faa 100644 --- a/brigid/mcp/tools.py +++ b/brigid/mcp/tools.py @@ -34,7 +34,7 @@ def create_tools(mcp: fastmcp.FastMCP) -> None: # noqa: CCR001, CFQ001 "- required_tags: A set of tags that the blog posts must have.", "- excluded_tags: A set of tags that the blog posts must not have.", "", - "Recomendations:", + "Recommendations:", "", ( "- Filter posts by tags gradually — add one tag at a time — " diff --git a/brigid/plugins/seo/templates/meta.html.j2 b/brigid/plugins/seo/templates/meta.html.j2 index 855f985..22c9a22 100644 --- a/brigid/plugins/seo/templates/meta.html.j2 +++ b/brigid/plugins/seo/templates/meta.html.j2 @@ -2,7 +2,7 @@ {% macro general_fields(meta_info, current_url) %} - + - + diff --git a/brigid/validation/validators.py b/brigid/validation/validators.py index a05cdea..dad2011 100644 --- a/brigid/validation/validators.py +++ b/brigid/validation/validators.py @@ -18,13 +18,11 @@ def validate() -> list[Error]: for global_validator in global_validators: with request_context.init(): - request_context.set("storage", storage) errors.extend(global_validator()) for page in storage.all_entities(): for page_validator in page_validators: with request_context.init(): - request_context.set("storage", storage) errors.extend(page_validator(page)) return errors diff --git a/changes/next_release.md b/changes/next_release.md new file mode 100644 index 0000000..80a2187 --- /dev/null +++ b/changes/next_release.md @@ -0,0 +1,8 @@ + +### Migration + +- Replace `brigid.application.application:app` with `brigid.asgi:app` in your ASGI server configuration (e.g. `uvicorn` command or `Procfile`). + +### Changes + +- gh-129 — Support for base URL prefixes such as `/blog` for hosting Brigid behind reverse proxies without stripping the prefix. diff --git a/changes/unreleased.md b/changes/unreleased.md deleted file mode 100644 index 373eb99..0000000 --- a/changes/unreleased.md +++ /dev/null @@ -1,2 +0,0 @@ - -No changes. diff --git a/docker-compose.yml b/docker-compose.yml index 0511f3a..39ed2f4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,7 +14,7 @@ services: - "poetry" - "run" - "uvicorn" - - "brigid.application.application:app" + - "brigid.asgi:app" - "--host" - "0.0.0.0" - "--port" @@ -28,6 +28,9 @@ services: ports: - "8000:8000" + environment: + - BRIGID_LIBRARY_DIRECTORY # pass if exists on the host + mcp-inspector: image: ghcr.io/modelcontextprotocol/inspector:latest diff --git a/docker/Dockerfile b/docker/Dockerfile index 89979a3..2a729e3 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -8,15 +8,15 @@ ARG USER=brigid RUN groupadd --gid $GID $USER && useradd --create-home --uid $UID --gid $USER --shell /bin/bash $USER -USER $USER - WORKDIR /repository # copied code will be replaced by mounted volume in docker-compose # but we need to copy it to install cli utils from pyproject.toml -COPY ./ ./ +COPY --chown=$USER:$USER ./ ./ + +USER $USER -RUN poetry install --no-interaction --no-ansi +RUN poetry install --with dev --no-interaction --no-ansi USER $USER CMD [] diff --git a/poetry.lock b/poetry.lock index d830a35..89df3a0 100644 --- a/poetry.lock +++ b/poetry.lock @@ -486,6 +486,24 @@ files = [ [package.dependencies] colorama = {version = "*", markers = "platform_system == \"Windows\""} +[[package]] +name = "codespell" +version = "2.4.1" +description = "Fix common misspellings in text files" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "codespell-2.4.1-py3-none-any.whl", hash = "sha256:3dadafa67df7e4a3dbf51e0d7315061b80d265f9552ebd699b3dd6834b47e425"}, + {file = "codespell-2.4.1.tar.gz", hash = "sha256:299fcdcb09d23e81e35a671bbe746d5ad7e8385972e65dbb833a2eaac33c01e5"}, +] + +[package.extras] +dev = ["Pygments", "build", "chardet", "pre-commit", "pytest", "pytest-cov", "pytest-dependency", "ruff", "tomli", "twine"] +hard-encoding-detection = ["chardet"] +toml = ["tomli ; python_version < \"3.11\""] +types = ["chardet (>=5.1.0)", "mypy", "pytest", "pytest-cov", "pytest-dependency"] + [[package]] name = "cognitive-complexity" version = "1.3.0" @@ -3261,4 +3279,4 @@ files = [ [metadata] lock-version = "2.1" python-versions = "^3.12" -content-hash = "957fdca3860bbe309ce75ac2f789c7d107e11d16ab6c6758fb4cc0ffaf6d4eec" +content-hash = "c9130652ccd786a11b73ec1fb87bd818279d3f066c3434a10f4f2208284c5089" diff --git a/pyproject.toml b/pyproject.toml index c9b2627..4555ede 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -82,6 +82,7 @@ flake8-eradicate = "1.5.*" autoflake = "2.3.*" mypy = "1.17.*" +codespell = "2.4.*" types-toml = "0.10.*" types-Markdown = "3.9.*" @@ -104,6 +105,9 @@ remove_all_unused_imports = true remove_unused_variables = true recursive = true +[tool.codespell] +skip = "*/fixtures/*,*~" + [tool.flake8] ignore = [ "D100", diff --git a/test-content/site/meta.toml b/test-content/site/meta.toml index 0731d07..83119ff 100644 --- a/test-content/site/meta.toml +++ b/test-content/site/meta.toml @@ -1,5 +1,6 @@ prod_url = "https://example.com" -local_url = "http://0.0.0.0:8000/" +local_url = "http://0.0.0.0:8000" +# local_url = "http://0.0.0.0:8000/blog/a/b/c" default_language = 'en' allowed_languages = ['en', 'ru'] posts_per_page = 5 diff --git a/test-content/site/ru.toml b/test-content/site/ru.toml index 77579be..1a579c1 100644 --- a/test-content/site/ru.toml +++ b/test-content/site/ru.toml @@ -1,4 +1,4 @@ -title = "Сайт-приме" +title = "Сайт-пример" subtitle = "Сайт чтобы тестить тему, CSS, лэйаут, правила рендера, etc." author = "Елецкий Алексей (Tiendil)"