diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index ae2004a..bd0c8ee 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -17,11 +17,11 @@ jobs: actions: read contents: read security-events: write - + strategy: fail-fast: false matrix: - language: [ 'actions' ] + language: [ 'actions', 'python' ] steps: - name: Checkout repository diff --git a/.gitignore b/.gitignore index b7faf40..5ddf9d7 100644 --- a/.gitignore +++ b/.gitignore @@ -123,6 +123,8 @@ ipython_config.py # Pixi creates a virtual environment in the .pixi directory, just like venv module creates one # in the .venv directory. It is recommended not to include this directory in version control. .pixi +.uv/ +uv.lock # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm __pypackages__/ @@ -182,9 +184,9 @@ cython_debug/ .abstra/ # Visual Studio Code -# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore +# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore -# and can be added to the global gitignore or merged into this file. However, if you prefer, +# and can be added to the global gitignore or merged into this file. However, if you prefer, # you could uncomment the following to ignore the entire vscode folder # .vscode/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..34c0c85 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,75 @@ +# Portions derived from Microsoft Agent Framework (MIT license): +# https://github.com/microsoft/agent-framework +# https://github.com/microsoft/agent-framework/blob/main/LICENSE +fail_fast: true +minimum_pre_commit_version: "3.6.0" + +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: trailing-whitespace + name: Trim trailing whitespace + - id: end-of-file-fixer + name: Fix end of files + - id: mixed-line-ending + args: ["--fix=lf"] + name: Normalize line endings + - id: check-yaml + name: Check YAML files + files: "\\.ya?ml$" + # exclude: '^docs/' # example: skip generated docs + - id: check-toml + name: Check TOML files + files: "\\.toml$" + - id: check-json + name: Check JSON files + files: "\\.json$" + - id: check-ast + name: Check Python syntax + types: ["python"] + - id: debug-statements + name: Forbid debug statements + + - repo: https://github.com/asottile/pyupgrade + rev: v3.20.0 + hooks: + - id: pyupgrade + name: Upgrade Python syntax to 3.10+ + args: ["--py310-plus"] + # exclude: '^agents/.*/migrations/' # example: keep legacy auto-generated code + + - repo: local + hooks: + - id: poe-check + name: Run checks through Poe + entry: uv run poe pre-commit-check + language: system + files: "^(agents/|scripts/)" + + - repo: https://github.com/PyCQA/bandit + rev: 1.8.5 + hooks: + - id: bandit + name: Bandit security checks + args: ["-c", "pyproject.toml"] + additional_dependencies: ["bandit[toml]"] + + - repo: https://github.com/nbQA-dev/nbQA + rev: 1.9.1 + hooks: + - id: nbqa-check-ast + name: Check valid Python in notebooks + types: ["jupyter"] + files: "\\.ipynb$" + # exclude: '^docs/' + + - repo: https://github.com/astral-sh/uv-pre-commit + rev: 0.4.30 + hooks: + - id: uv-lock + name: Update uv lockfile + files: ^pyproject\.toml$ + args: ["--project", "."] + pass_filenames: false + # exclude: '^agents/' # example: skip package-level pyproject diff --git a/README.md b/README.md index ddc08a7..6f22ee6 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,188 @@ # python-agent-template -This is a template to develop agents with Microsoft Agent Framework + +Security-first template for building and shipping multiple Python agents from one monorepo, derived from the Microsoft Agent Framework. It aims to give newcomers a ready-to-run, batteries-included starting point with guardrails (typing, linting, security, CI, releases) that you can adapt to your org’s standards. + +> Disclaimer: This template is based on the Microsoft Agent Framework and provided for learning/acceleration. Evaluate and adapt to your organization’s security, compliance, and coding standards before production use. + +## What’s inside and why +- **uv + poe**: fast installs and repeatable task runner (fmt/lint/types/tests/bandit). +- **Ruff, Pyright, MyPy, Bandit, PyTest, markdown code fence lint**: code quality and security guardrails. +- **Task fan-out scripts**: run tasks across all agents or only changed agents to keep CI fast. +- **Security automation**: CodeQL scanning and Dependabot for updates; good hygiene baseline. +- **Docs generation**: experimental py2docfx workflow (disabled by default) to emit docfx YAML. +- **Licensing**: each agent can ship its own LICENSE for package publication. + +## Prerequisites +- Python 3.10–3.13 installed locally (3.13 default for `poe setup`). +- curl available to install uv, or install uv via your package manager: `curl -LsSf https://astral.sh/uv/install.sh | sh`. +- Git for cloning and hooks. + +## Getting started (root workspace) +1) Install uv: `curl -LsSf https://astral.sh/uv/install.sh | sh` +2) Install dev deps (workspace-wide): `uv run poe setup` +3) Run full quality gate: `uv run poe check` + +`poe setup` creates/refreshes `.venv`, installs all dev dependencies with uv, and installs pre-commit hooks so staged changes get checked automatically. + +Local setup quickstart: clone the repo, ensure Python 3.10–3.13 is installed, run `uv run poe setup` to create/refresh `.venv` and install hooks, then `uv run poe check` to validate the workspace. For speed, `python scripts/run_tasks_in_changed_agents.py ` narrows lint/type/test to modified agents. + +## Tasks: check vs pre-commit +- `poe check` (full suite, repo-wide): + - Ruff format (`fmt`), Ruff lint (`lint`), Pyright, MyPy, Bandit, PyTest, Markdown code fence lint. + - Runs across all agents. Use for CI and pre-merge confidence; catches issues outside your current diff. +- Pre-commit hooks (fast, staged-only): + - Ruff format+lint, scoped MyPy, Bandit (via hook), trailing whitespace/EOF fixers, markdown fence checks on staged files. + - Purpose: keep diffs clean and reduce CI churn. Because it only sees staged files, it is fast but not a substitute for `poe check`. + +Why staged-only for pre-commit: speed and focus on what you are changing. Why still run full checks in CI: to catch regressions in untouched files, ensure type/safety coverage repo-wide, and validate tests end-to-end. + +### Pre-commit details +- Install once per clone: `uv run poe pre-commit-install` (adds hooks to `.git/hooks`). +- Run manually on all files: `uv run pre-commit run --all-files` (useful before large refactors or in CI if desired). +- Hook set (from `.pre-commit-config.yaml`): Ruff format + Ruff lint, MyPy (scoped), Bandit, trailing-whitespace/EOF fixers, markdown fenced-code checker, config validators, uv lock refresher. +- If you must skip briefly, prefer `SKIP=hookname pre-commit run` instead of disabling globally. + +### Task flow diagrams + +Check (repo-wide `poe check`): + +```mermaid +flowchart LR + A[poe check] --> B[Ruff format] + B --> C[Ruff lint] + C --> D[Pyright] + D --> E[MyPy] + E --> F[Bandit] + F --> G[PyTest + coverage] + G --> H[Markdown code fence lint] +``` + +Pre-commit check task (`poe pre-commit-check`, staged-aware): + +```mermaid +flowchart LR + P[poe pre-commit-check] --> P1[Ruff format] + P1 --> P2[Ruff lint] + P2 --> P3[Pyright staged] + P3 --> P4[Markdown code fence lint] +``` + +Pre-commit hook pipeline (on `git commit`): + +```mermaid +flowchart LR + C[git commit] --> H1[pre-commit framework] + H1 --> H2[Whitespace/EOF/line endings] + H2 --> H3[Config checks YAML/TOML/JSON] + H3 --> H4[pyupgrade] + H4 --> H5[Ruff format + Ruff lint] + H5 --> H6[MyPy scoped] + H6 --> H7[Bandit] + H7 --> H8[Markdown fence check] + H8 --> H9[nbQA notebook parse] + H9 --> H10[uv-lock update if manifests change] +``` + +## Repository layout +- `agents/` — each agent as a package (e.g., `agent1/`). +- `scripts/` — task fan-out and helper scripts (e.g., run tasks across agents, check markdown code blocks). +- `.github/workflows/` — CI (checks, release, CodeQL) and automation. +- `.pre-commit-config.yaml` — local hook definitions. +- `pyproject.toml` — shared config for uv, ruff, mypy, pyright, bandit, poe tasks. +- `docs/` — output/placeholder; doc generation is experimental. + +Scripts explained (`scripts/`) +- `run_tasks_in_agents_if_exists.py`: runs a given task (fmt/lint/pyright/mypy/bandit/test) in every agent that defines it, so `poe check` can fan out safely even if some agents lack tasks. +- `run_tasks_in_changed_agents.py`: detects which agents changed relative to the target branch and runs the requested task only there; use for fast local/PR lint/type passes. +- `check_md_code_blocks.py`: validates fenced code blocks in README files; helps keep docs runnable. + +Task catalog (root `poe` tasks) +- `poe setup`: create/refresh `.venv`, install deps, install pre-commit hooks (uses `poe venv`, `install`, `pre-commit-install`). +- `poe venv`: `uv venv --clear --python `; default 3.13, override with `-p/--python`. +- `poe install`: `uv sync --all-extras --dev` (docs group excluded by default). +- `poe pre-commit-install`: install and refresh hooks. +- `poe fmt`: Ruff format. +- `poe lint`: Ruff lint. +- `poe pyright`: strict Pyright. +- `poe mypy`: strict MyPy. +- `poe bandit`: Bandit security scan (fans out to agents + scripts). +- `poe bandit-agents`: Bandit against all agents (fan-out via `run_tasks_in_agents_if_exists`). +- `poe bandit-scripts`: Bandit against the `scripts/` tree. +- `poe test`: PyTest + coverage. +- `poe markdown-code-lint`: fenced-code checks in READMEs. +- `poe check`: bundle that runs fmt, lint, pyright, mypy, bandit, test, markdown-code-lint. + +Bundled task contents (what runs where) +- `poe setup`: (1) create/refresh `.venv`, (2) `uv sync --all-extras --dev`, (3) install pre-commit hooks. Use once per clone or after Python version changes. +- `poe check`: Ruff format → Ruff lint → Pyright → MyPy → Bandit → PyTest + coverage → markdown code fence lint. Use before merge/CI to cover the full workspace. +- Pre-commit hook run (staged files only): Ruff format + Ruff lint, scoped MyPy, trailing-whitespace/EOF fixes, markdown fence checks; install with `uv run poe pre-commit-install`. Fast hygiene, not a replacement for `poe check`. + +### What Ruff, Pyright, and MyPy check + +### Detailed checks (Ruff, Pyright, MyPy) + +- Ruff + - Format: Black-like formatter, import sorting; keeps 120-col width and normalizes strings/spacing. + - Lint (selected families): pycodestyle E/W, pyflakes F (unused imports/vars, undefined names), bugbear B (risky patterns), pyupgrade UP (modern syntax), pylint PLC/PLE/PLR/PLW (naming, refactors, errors, warnings), Bandit S (security), pytest PT, return rules RET, async ASYNC, datetime TZ, string concat ISC, simplify SIM, quotes Q, exceptions TRY, todo TD/FIX, naming N, docstyle D (Google convention), import conventions ICN/I, pydantic guards PGH, debugger T100. + - Per-file relaxations: tests allow assert-raises constant (`S101`) and magic numbers (`PLR2004`); notebooks skip copyright and long-line checks. +- Pyright (strict) + - Coverage: `agents` and `scripts`, strict mode, unused imports reported; tests and venv paths excluded. + - Catches: incorrect call signatures, bad attribute access, incompatible unions/Optionals, missing/invalid imports, unreachable code, mismatched overloads, missing type annotations, and unsafe narrowing; includes `scripts` via `extraPaths` so helper scripts must stay typed. +- MyPy (strict) + - Coverage: `agents` and `scripts`, strict + pydantic plugin; disallow untyped defs/decorators, no implicit Optional, warn on return Any, show error codes. + - Catches: type mismatches, Optional misuse, protocol/interface violations, missing annotations, decorator typing gaps; pydantic plugin enforces typed fields and forbid-extra in __init__. + +Tip: Run `poe lint`/`poe pyright`/`poe mypy` individually during development; `poe check` runs them all before tests and docs lint. + +## Using this template for new agents +1) Copy `agents/agent1` to `agents/`. +2) Update metadata in `agents//pyproject.toml` (name, description, URLs, deps). +3) Implement your code under `src//` and extend `tests/`. +4) If you will publish the agent, place a `LICENSE` file in the agent directory and use `license-files = ["LICENSE"]` so wheels/sdists include it. +5) Run `uv run poe check`. + +## Virtualenv setup and cleanup +- Create fresh env and install: `uv run poe setup` (runs `poe venv` → `uv sync` → pre-commit install). Default python is 3.13; override with `-p/--python`. +- Manual fallback if needed: `uv venv --python 3.13 && uv sync --all-extras --dev`. +- Clean everything: remove `.venv` and caches with `rm -rf .venv .pytest_cache .ruff_cache .mypy_cache __pycache__ agents/**/{.pytest_cache,.ruff_cache,.mypy_cache,__pycache__}`. + +## Documentation (experimental) +- Scripts use py2docfx to emit docfx YAML into `docs/`. The docs tasks are commented out by default; install docs deps with `uv sync --group docs` if you want to experiment. Expect rough edges. + +## Tooling reference (what/where/why) + +Local + CI (from `pyproject.toml` and `.pre-commit-config.yaml`) + +| Tool / service | Where it runs | What it does | Why it matters | Docs | +| --- | --- | --- | --- | --- | +| uv | Local + CI | Fast Python installer/resolver and executor for reproducible envs and tasks. | Keeps dependency installs deterministic and quick, so developers actually run checks. | [uv docs](https://docs.astral.sh/uv/) | +| Poe the Poet | Local + CI | Task runner that fans commands to all agents and provides `poe check`/`poe pre-commit-check`. | One entry point for fmt/lint/types/tests/security, reducing configuration drift. | [Poe docs](https://poethepoet.natn.io/) | +| Ruff (format + lint) | Local, pre-commit, CI | Auto-formats and lints Python/imports/docstrings; flags dead code, unsafe patterns, and some security issues. | Removes style noise from reviews and catches correctness issues early. | [Ruff docs](https://docs.astral.sh/ruff/) | +| Pyright (strict) | Local + CI | Fast static type checker with precise inference. | Prevents type regressions and interface drift; great developer ergonomics. | [Pyright docs](https://microsoft.github.io/pyright/) | +| MyPy (strict) | Local + CI (scoped in pre-commit) | Second static type checker with a different inference engine and plugin support (pydantic). | Adds coverage where Pyright differs; reduces blind spots by double-checking types. | [MyPy docs](https://mypy.readthedocs.io/en/stable/) | +| Bandit | Local, pre-commit, CI | Security static analysis for Python. | Flags risky calls (eval, weak crypto, subprocess misuse) before merge. | [Bandit docs](https://bandit.readthedocs.io/en/latest/) | +| PyTest + pytest-cov | Local + CI | Runs tests with coverage reporting. | Proves behavior still works; coverage highlights untested risk. | [PyTest](https://docs.pytest.org/en/latest/), [pytest-cov](https://pytest-cov.readthedocs.io/en/latest/) | +| Markdown code fence lint | Local + CI | Checks fenced code blocks in project READMEs. | Prevents broken snippets and docs drift. | [scripts/check_md_code_blocks.py](scripts/check_md_code_blocks.py) | +| pre-commit framework | Local | Runs the hook set on staged files. | Automates hygiene (format, lint, security) before commits land. | [pre-commit docs](https://pre-commit.com/) | +| pre-commit-hooks bundle | Local | Trims whitespace, fixes EOF, normalizes newlines, validates YAML/TOML/JSON, AST checks, forbids debug statements. | Removes common footguns and keeps config files valid. | [pre-commit-hooks](https://github.com/pre-commit/pre-commit-hooks) | +| pyupgrade hook | Local | Rewrites Python syntax to modern 3.10+. | Eliminates legacy syntax and aligns with supported versions. | [pyupgrade](https://github.com/asottile/pyupgrade) | +| nbQA hook | Local | Validates notebook cells parse as Python. | Stops broken notebooks from entering the repo. | [nbQA docs](https://nbqa.readthedocs.io/en/latest/) | +| uv-lock hook | Local | Refreshes `uv.lock` when `pyproject.toml` changes. | Ensures lockfile matches manifests, preventing supply-chain drift. | [uv-pre-commit](https://github.com/astral-sh/uv-pre-commit) | +| Poe pre-commit-check hook | Local | Runs diff-aware fmt/lint/pyright/markdown checks via Poe. | Fast, staged-only guardrail that mirrors CI styling and type rules. | [pyproject.toml](pyproject.toml) | + +Why both Pyright and MyPy: they use different inference engines and plugin ecosystems, so running both raises signal and lowers the chance of missing type errors. + +GitHub-hosted automation (security and updates) + +| Service | What it does | Why it matters | Docs | +| --- | --- | --- | --- | +| CodeQL Analysis | Code scanning for Python and GitHub Actions code. | Finds dataflow and security issues beyond linters/typing. | [CodeQL docs](https://docs.github.com/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/about-codeql-code-scanning) | +| Dependabot | Weekly updates for pip/uv dependencies and GitHub Actions. | Shrinks vulnerability exposure windows and keeps CI runners current. | [Dependabot docs](https://docs.github.com/code-security/dependabot/dependabot-version-updates) | + +## Security and automation +- **Dependabot**: keeps pip/uv and GitHub Actions up to date. Alternatives: Renovate, Snyk, Mend. Important to run some updater to shrink vulnerability exposure windows. +- **CodeQL**: SAST/code scanning for Python and GitHub Actions. Alternatives: semgrep, commercial SAST. Important to have at least one scanner in place. +- **Branch protection/rulesets and auto-fix**: enforce required checks, signed commits, and allow trusted bots (e.g., Dependabot) to auto-merge with autofix where policy allows. + +## Copyright option +- Ruff copyright enforcement is available but disabled. If your org requires it, enable the `flake8-copyright` block in `pyproject.toml` and add headers. Leave it off to avoid breaking contributions until ready. diff --git a/agents/agent1/LICENSE b/agents/agent1/LICENSE new file mode 100644 index 0000000..ef25dd1 --- /dev/null +++ b/agents/agent1/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Pierre Malarme + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/agents/agent1/README.md b/agents/agent1/README.md new file mode 100644 index 0000000..1042f14 --- /dev/null +++ b/agents/agent1/README.md @@ -0,0 +1,19 @@ +# agent1 + +Example agent built from the python-agent-template. Use this as a starting point and replace the description/code with your own agent. + +## Quickstart +- From the repo root, set up once: `uv run poe setup` (creates/refreshes `.venv`, installs deps, installs pre-commit hooks). +- Full validation (all agents): `uv run poe check`. +- Faster, agent-scoped runs (from repo root): + - `uv run poe -C agents/agent1 fmt` + - `uv run poe -C agents/agent1 lint` + - `uv run poe -C agents/agent1 pyright` + - `uv run poe -C agents/agent1 mypy` + - `uv run poe -C agents/agent1 bandit` + - `uv run poe -C agents/agent1 test` +- To run only on agents changed by your branch: `python scripts/run_tasks_in_changed_agents.py ` + +## Anatomy +- `src/agent1/agent.py` — agent implementation. +- `tests/` — unit tests; extend with PyTest. diff --git a/agents/agent1/pyproject.toml b/agents/agent1/pyproject.toml new file mode 100644 index 0000000..5f1f184 --- /dev/null +++ b/agents/agent1/pyproject.toml @@ -0,0 +1,88 @@ +# Portions derived from Microsoft Agent Framework (MIT license): +# https://github.com/microsoft/agent-framework +# https://github.com/microsoft/agent-framework/blob/main/LICENSE + +[project] +name = "agent1" +version = "0.1.0" +description = "Example agent built from the python-agent-template." +readme = "README.md" +requires-python = ">=3.10" +dependencies = [] +license-files = ["LICENSE"] +authors = [{ name = "python-agent-template maintainers" }] +urls.homepage = "https://github.com/pmalarme/python-agent-template" +urls.source = "https://github.com/pmalarme/python-agent-template/tree/main/agents/agent1" +classifiers = [ + "License :: OSI Approved :: MIT License", + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Typing :: Typed", +] + +[project.scripts] +agent1 = "agent1.__main__:main" + +[tool.uv] +package = true +prerelease = "if-necessary-or-explicit" +environments = [ + "sys_platform == 'darwin'", + "sys_platform == 'linux'", + "sys_platform == 'win32'", +] + +[tool.uv-dynamic-versioning] +fallback-version = "0.0.0" + +[tool.pytest.ini_options] +testpaths = 'tests' +addopts = "-ra -q -r fEX" +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" +timeout = 120 + +[tool.ruff] +extend = "../../pyproject.toml" + +[tool.coverage.run] +omit = ["**/__init__.py"] + +[tool.pyright] +extends = "../../pyproject.toml" + +[tool.mypy] +python_version = "3.10" +strict = true +ignore_missing_imports = true +warn_unused_ignores = false +warn_return_any = true +warn_unused_configs = true +disallow_untyped_decorators = true +check_untyped_defs = true +disallow_untyped_defs = true +no_implicit_optional = true +show_error_codes = true + +[tool.bandit] +targets = ["src/agent1"] +exclude_dirs = ["tests"] + +[tool.poe] +executor.type = "uv" +include = "../../shared_tasks.toml" + +[tool.poe.tasks] +# Project-specific overrides to scope checks/coverage to this agent; shared tasks remain available for cross-agent runs +bandit = "uv run bandit -c pyproject.toml -r src/agent1" +mypy = "uv run mypy --config-file $POE_ROOT/pyproject.toml src" +test = "uv run pytest --cov=agent1 --cov-report=term-missing:skip-covered" + +[build-system] +requires = ["flit-core>=3.12.0,<4"] +build-backend = "flit_core.buildapi" diff --git a/agents/agent1/src/__init__.py b/agents/agent1/src/__init__.py new file mode 100644 index 0000000..6332550 --- /dev/null +++ b/agents/agent1/src/__init__.py @@ -0,0 +1,3 @@ +from agent1.agent import AgentConfig, ExampleAgent, MissingNameError + +__all__ = ["AgentConfig", "ExampleAgent", "MissingNameError"] diff --git a/agents/agent1/src/agent.py b/agents/agent1/src/agent.py new file mode 100644 index 0000000..3b4bd57 --- /dev/null +++ b/agents/agent1/src/agent.py @@ -0,0 +1,26 @@ +"""Minimal example agent to illustrate the template.""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass +class AgentConfig: + """Configuration for the example agent.""" + + greeting: str = "hello" + + +class ExampleAgent: + """Simple greeter agent.""" + + def __init__(self, config: AgentConfig | None = None) -> None: + """Initialize the agent with optional config.""" + self.config = config or AgentConfig() + + def run(self, name: str) -> str: + """Return a greeting for the provided name.""" + if not name: + raise ValueError("name must be provided") # noqa: TRY003 + return f"{self.config.greeting}, {name}!" diff --git a/agents/agent1/src/agent1/__init__.py b/agents/agent1/src/agent1/__init__.py new file mode 100644 index 0000000..61b3cdc --- /dev/null +++ b/agents/agent1/src/agent1/__init__.py @@ -0,0 +1,5 @@ +"""agent1 package exports.""" + +from .agent import AgentConfig, ExampleAgent, MissingNameError + +__all__ = ["AgentConfig", "ExampleAgent", "MissingNameError"] diff --git a/agents/agent1/src/agent1/__main__.py b/agents/agent1/src/agent1/__main__.py new file mode 100644 index 0000000..2818723 --- /dev/null +++ b/agents/agent1/src/agent1/__main__.py @@ -0,0 +1,26 @@ +"""CLI entry point for agent1.""" + +from __future__ import annotations + +import argparse +import logging + +from agent1.agent import AgentConfig, ExampleAgent + +logger = logging.getLogger(__name__) + + +def main() -> None: + """Parse arguments and emit a greeting.""" + parser = argparse.ArgumentParser(description="Run agent1 example.") + parser.add_argument("name", help="Name to greet") + parser.add_argument("--greeting", default="hello", help="Greeting prefix") + args = parser.parse_args() + + logging.basicConfig(level=logging.INFO, format="%(message)s") + agent = ExampleAgent(config=AgentConfig(greeting=args.greeting)) + logger.info(agent.run(args.name)) + + +if __name__ == "__main__": # pragma: no cover - CLI entry + main() diff --git a/agents/agent1/src/agent1/agent.py b/agents/agent1/src/agent1/agent.py new file mode 100644 index 0000000..a4eb021 --- /dev/null +++ b/agents/agent1/src/agent1/agent.py @@ -0,0 +1,34 @@ +"""Minimal example agent to illustrate the template.""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass +class AgentConfig: + """Configuration for the example agent.""" + + greeting: str = "hello" + + +class MissingNameError(ValueError): + """Raised when a name argument is missing.""" + + def __init__(self) -> None: + """Initialize the missing-name error with a default message.""" + super().__init__("name required") + + +class ExampleAgent: + """Simple greeter agent.""" + + def __init__(self, config: AgentConfig | None = None) -> None: + """Initialize the agent with optional config.""" + self.config = config or AgentConfig() + + def run(self, name: str) -> str: + """Return a greeting for the provided name.""" + if not name: + raise MissingNameError + return f"{self.config.greeting}, {name}!" diff --git a/agents/agent1/tests/__init__.py b/agents/agent1/tests/__init__.py new file mode 100644 index 0000000..a3ac15e --- /dev/null +++ b/agents/agent1/tests/__init__.py @@ -0,0 +1 @@ +"""Test package for agent1.""" diff --git a/agents/agent1/tests/test_agent.py b/agents/agent1/tests/test_agent.py new file mode 100644 index 0000000..e6555a3 --- /dev/null +++ b/agents/agent1/tests/test_agent.py @@ -0,0 +1,19 @@ +"""Tests for ExampleAgent.""" + +import pytest + +from agent1 import AgentConfig, ExampleAgent +from agent1.agent import MissingNameError + + +def test_run_greets_name() -> None: + """Agent returns greeting with provided name.""" + agent = ExampleAgent(AgentConfig(greeting="hi")) + assert agent.run("Ada") == "hi, Ada!" + + +def test_run_requires_name() -> None: + """Agent raises when name is missing.""" + agent = ExampleAgent() + with pytest.raises(MissingNameError, match="name"): + agent.run("") diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..b189e3a --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,249 @@ +# Portions derived from Microsoft Agent Framework (MIT license): +# https://github.com/microsoft/agent-framework +# https://github.com/microsoft/agent-framework/blob/main/LICENSE +[project] +name = "python-agent-template" +version = "0.1.0" +description = "Template for building Python agents with security-focused defaults." +readme = "README.md" +requires-python = ">=3.10" +license-files = ["LICENSE"] +authors = [{ name = "python-agent-template maintainers" }] +dependencies = [] +urls.homepage = "https://github.com/pmalarme/python-agent-template" +urls.source = "https://github.com/pmalarme/python-agent-template/tree/main" +classifiers = [ + "License :: OSI Approved :: MIT License", + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Typing :: Typed", +] + +[dependency-groups] +dev = [ + "uv>=0.4.30", + "poethepoet>=0.32.0", + "pre-commit>=3.7.1", + "flit>=3.12.0,<4", + "pydantic>=2.9.0", + "pydantic-settings>=2.5.0", + "ruff>=0.6.9", + "pytest>=8.3.3", + "pytest-asyncio>=0.24.0", + "pytest-cov>=6.0.0", + "pytest-xdist>=3.6.1", + "pytest-timeout>=2.3.1", + "pytest-retry>=1.6.3", + "mypy>=1.11.2", + "pyright>=1.1.390", + "bandit>=1.7.9", + "types-requests>=2.32.0.20241016", + "rich>=13.9.2", + "pygments>=2.18.0", + "tomli>=2.0.1", + "tomli-w>=1.0.0", +] +docs = [ + "py2docfx>=0.1.22.dev2259826", + "pip", +] + +[tool.uv] +package = false +prerelease = "if-necessary-or-explicit" +environments = [ + "sys_platform == 'linux'", + "sys_platform == 'darwin'", + "sys_platform == 'win32'", +] + +[tool.uv.workspace] +members = ["agents/*"] + +[tool.ruff] +line-length = 120 +target-version = "py310" +fix = true +include = ["*.py", "**/pyproject.toml", "*.ipynb"] +exclude = ["docs/*", "**/.venv/**", "**/.uv/**", "**/.git/**", "**/.cache/**"] +preview = true + +[tool.ruff.lint] +fixable = ["ALL"] +unfixable = [] +select = [ + "ASYNC", # async checks + "B", # bugbear checks + "C4", # comprehensions + "DTZ", # datetime timezone + "CPY", # copyright + "D", # pydocstyle checks + "E", # pycodestyle error checks + "ERA", # eradicate commented-out code + "F", # pyflakes checks + "FIX", # fixme checks + "I", # isort + "ICN", # import conventions + "INP", # implicit namespace package + "ISC", # implicit string concat + "N", # naming + "PGH", # pydantic guards + "Q", # flake8-quotes checks + "RET", # flake8-return + "RSE", # raise exception parentheses + "PLC", # pylint conventions + "PLE", # pylint errors + "PLR", # pylint refactors + "PLW", # pylint warnings + "RUF", # ruff-specific rules + "SIM", # simplify + "PT", # pytest style + "T20", # typing checks + "TD", # todos + "TRY", # exception handling patterns + "UP", # pyupgrade rules + "W", # pycodestyle warnings + "T100", # debugger + "S", # Bandit checks +] +ignore = [ + "D100", # allow missing docstring in public module + "D104", # allow missing docstring in public package + "D418", # allow overload to have a docstring + "TD003", # allow missing link to todo issue + "FIX002",# allow todo + "B027", # allow empty non-abstract method in ABC + "RUF067",# allow version detection in __init__.py + "CPY", # temp: skip copyright check until headers are standardized +] + +[tool.ruff.lint.per-file-ignores] +"**/tests/**" = ["S101", "PLR2004"] +"*.ipynb" = ["CPY", "E501"] + +[tool.ruff.format] +docstring-code-format = true + +[tool.ruff.lint.pydocstyle] +convention = "google" + +# Optional: enforce a project-wide copyright header. Uncomment and set notice-rgx when ready. +# [tool.ruff.lint.flake8-copyright] +# notice-rgx = "^# Copyright \(c\) Your Org\. All rights reserved\." +# min-file-size = 1 + +[tool.pytest.ini_options] +testpaths = 'agents/**/tests' +addopts = "-ra -q -r fEX" +filterwarnings = [] +timeout = 120 +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" +markers = [] + +[tool.coverage.run] +omit = ["**/__init__.py"] + +[tool.pyright] +include = ["agents", "scripts"] +exclude = ["**/tests/**", "**/.venv/**", "**/.uv/**"] +extraPaths = ["scripts"] +typeCheckingMode = "strict" +reportMissingTypeStubs = false +reportUnusedImport = true +reportUnnecessaryIsInstance = false + +[tool.mypy] +python_version = "3.10" +plugins = ["pydantic.mypy"] +strict = true +packages = ["agents", "scripts"] +ignore_missing_imports = true +warn_unused_ignores = false +warn_return_any = true +warn_unused_configs = true +disallow_untyped_decorators = true +show_error_codes = true + +[tool.pydantic-mypy] +init_forbid_extra = true +init_typed = true +warn_required_dynamic_aliases = true +warn_untyped_fields = true + +[tool.bandit] +targets = ["agents", "scripts"] +exclude_dirs = ["**/tests/**", "**/.venv/**", "**/.uv/**"] + +[tool.poe] +executor.type = "uv" + +[tool.poe.tasks] +fmt = "python scripts/run_tasks_in_agents_if_exists.py fmt" +format.ref = "fmt" +lint = "python scripts/run_tasks_in_agents_if_exists.py lint" +pyright = "python scripts/run_tasks_in_agents_if_exists.py pyright" +mypy = "python scripts/run_tasks_in_agents_if_exists.py mypy" +bandit = ["bandit-agents", "bandit-scripts"] +bandit-agents = "python scripts/run_tasks_in_agents_if_exists.py bandit" +bandit-scripts = "uv run bandit -c pyproject.toml -r scripts" +test = "python scripts/run_tasks_in_agents_if_exists.py test" +markdown-code-lint = "uv run python scripts/check_md_code_blocks.py README.md agents/**/README.md" +pre-commit-install = "uv run pre-commit install --install-hooks --overwrite" +install = "uv sync --all-packages --all-extras --dev -U --prerelease=if-necessary-or-explicit --no-group=docs" +# docs = "uv run python scripts/generate_docs.py" # disabled: experimental docs pipeline +# docs-install = "uv sync --all-packages --all-extras --dev -U --prerelease=if-necessary-or-explicit --group docs" +check = ["fmt", "lint", "pyright", "mypy", "bandit", "test", "markdown-code-lint"] +# Optional release/publish helpers (commented out by default) +# clean-dist-agents = "python scripts/run_tasks_in_agents_if_exists.py clean-dist" +# clean-dist-meta = "rm -rf dist" +# clean-dist = ["clean-dist-agents", "clean-dist-meta"] +# build-agents = "python scripts/run_tasks_in_agents_if_exists.py build" +# build-meta = "python -m flit build" +# build = ["build-agents", "build-meta"] +# publish = "uv publish" + +# Setup and Virtual Environment +[tool.poe.tasks.venv] +cmd = "uv venv --clear --python $python" +args = [{ name = "python", default = "3.13", options = ['-p', '--python'] }] + +[tool.poe.tasks.setup] +sequence = [ + { ref = "venv --python $python"}, + { ref = "install" }, + { ref = "pre-commit-install" } +] +args = [{ name = "python", default = "3.13", options = ['-p', '--python'] }] + +# Pre-commit oriented tasks (lighter, diff-aware variants) +[tool.poe.tasks.pre-commit-markdown-code-lint] +cmd = "uv run python scripts/check_md_code_blocks.py ${files} --no-glob" +args = [{ name = "files", default = ".", positional = true, multiple = true }] + +[tool.poe.tasks.pre-commit-pyright] +cmd = "uv run python scripts/run_tasks_in_changed_agents.py pyright ${files}" +args = [{ name = "files", default = ".", positional = true, multiple = true }] + +[tool.poe.tasks.pre-commit-check] +sequence = [ + { ref = "fmt" }, + { ref = "lint" }, + { ref = "pre-commit-pyright ${files}" }, + { ref = "pre-commit-markdown-code-lint ${files}" }, +] +args = [{ name = "files", default = ".", positional = true, multiple = true }] + +[[tool.uv.index]] +name = "testpypi" +url = "https://test.pypi.org/simple/" +publish-url = "https://test.pypi.org/legacy/" +explicit = true +[build-system] +requires = ["flit-core>=3.12.0,<4"] +build-backend = "flit_core.buildapi" diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scripts/check_md_code_blocks.py b/scripts/check_md_code_blocks.py new file mode 100644 index 0000000..f12c685 --- /dev/null +++ b/scripts/check_md_code_blocks.py @@ -0,0 +1,221 @@ +"""Check code blocks in Markdown files for syntax errors. + +What it does: +- Expands glob patterns (unless --no-glob) to find Markdown files. +- Extracts ```python fences and runs pyright on each block via uv; highlights failures with pygments. +- Supports --exclude patterns to skip paths and --no-glob to treat inputs literally. + +Derived from https://github.com/microsoft/agent-framework/ (MIT). +""" + +# Copyright (c) Microsoft. All rights reserved. +# Licensed under the MIT License. See: +# https://github.com/microsoft/agent-framework/blob/main/LICENSE +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +from __future__ import annotations + +import argparse +from enum import Enum +import glob +import logging +import os +import tempfile +import subprocess # nosec +from typing import Any, cast + +from pygments import highlight # type: ignore +from pygments.formatters import TerminalFormatter +from pygments.lexers import PythonLexer # type: ignore[reportUnknownVariableType] + + +logger = logging.getLogger(__name__) +logger.addHandler(logging.StreamHandler()) +logger.setLevel(logging.INFO) + + +class Colors(str, Enum): + CEND = "\33[0m" + CRED = "\33[31m" + CREDBG = "\33[41m" + CGREEN = "\33[32m" + CGREENBG = "\33[42m" + CVIOLET = "\33[35m" + CGREY = "\33[90m" + + +def with_color(text: str, color: Colors) -> str: + """Render text with ANSI color codes.""" + + return f"{color.value}{text}{Colors.CEND.value}" + + +def expand_file_patterns(patterns: list[str], skip_glob: bool = False) -> list[str]: + """Expand glob patterns to actual file paths, preserving literal paths when requested. + + Args: + patterns: Glob patterns or literal paths, depending on ``skip_glob``. + skip_glob: When True, treats patterns as literal paths and filters for .md. + + Returns: + A sorted list of unique markdown file paths. + """ + + all_files: list[str] = [] + for pattern in patterns: + if skip_glob: + if pattern.endswith(".md"): + matches = glob.glob(pattern, recursive=False) + all_files.extend(matches) + else: + matches = glob.glob(pattern, recursive=True) + all_files.extend(matches) + return sorted(set(all_files)) + + +def extract_python_code_blocks(markdown_file_path: str) -> list[tuple[str, int]]: + """Extract Python code blocks from a Markdown file, returning code and starting line numbers. + + Args: + markdown_file_path: Path to a markdown file. + + Returns: + A list of tuples ``(code_block, starting_line_number)``. + """ + + with open(markdown_file_path, encoding="utf-8") as file: + lines = file.readlines() + + code_blocks: list[tuple[str, int]] = [] + in_code_block = False + current_block: list[str] = [] + + for i, line in enumerate(lines): + if line.strip().startswith("```python"): + in_code_block = True + current_block = [] + elif line.strip().startswith("```"): + in_code_block = False + code_blocks.append(("\n".join(current_block), i - len(current_block) + 1)) + elif in_code_block: + current_block.append(line) + + return code_blocks + + +def check_code_blocks( + markdown_file_paths: list[str], + exclude_patterns: list[str] | None = None, +) -> None: + """Check Python code blocks in Markdown files by running pyright on each block. + + Args: + markdown_file_paths: Markdown files to inspect. + exclude_patterns: Optional substrings; any path containing one is skipped. + + Raises: + RuntimeError: If any checked file contains a failing Python block. + """ + + files_with_errors: list[str] = [] + exclude_patterns = exclude_patterns or [] + + for markdown_file_path in markdown_file_paths: + if any(pattern in markdown_file_path for pattern in exclude_patterns): + logger.info("Skipping %s (matches exclude pattern)", markdown_file_path) + continue + + code_blocks = extract_python_code_blocks(markdown_file_path) + had_errors = False + + for code_block, line_no in code_blocks: + markdown_file_path_with_line_no = f"{markdown_file_path}:{line_no}" + logger.info("Checking a code block in %s...", markdown_file_path_with_line_no) + + tmp_path = "" + try: + with tempfile.NamedTemporaryFile(suffix=".py", delete=False) as temp_file: + temp_file.write(code_block.encode("utf-8")) + temp_file.flush() + tmp_path = temp_file.name + + result = subprocess.run( + ["uv", "run", "pyright", tmp_path], + capture_output=True, + text=True, + cwd=".", + ) # nosec + + if result.returncode != 0: + lexer = cast(Any, PythonLexer()) + formatter = cast(Any, TerminalFormatter()) + highlighted_code: str = highlight(code_block, lexer, formatter) + logger.info( + " %s\n%s\n%s\n%s\n%s\n\n%s\n%s%s\n", + with_color("FAIL", Colors.CREDBG), + with_color("========================================================", Colors.CGREY), + with_color( + f"Error: Pyright found issues in {with_color(markdown_file_path_with_line_no, Colors.CVIOLET)}", + Colors.CRED, + ), + with_color("--------------------------------------------------------", Colors.CGREY), + highlighted_code, + with_color("pyright output:", Colors.CVIOLET), + with_color(result.stdout, Colors.CRED), + with_color("========================================================", Colors.CGREY), + ) + had_errors = True + else: + logger.info(" %s", with_color("OK", Colors.CGREENBG)) + finally: + if tmp_path: + try: + os.unlink(tmp_path) + except FileNotFoundError: + logger.debug("Temporary file %s was already removed; skipping unlink.", tmp_path) + + if had_errors: + files_with_errors.append(markdown_file_path) + + if files_with_errors: + raise RuntimeError("Syntax errors found in the following files:\n" + "\n".join(files_with_errors)) + + +def main() -> None: + """Parse CLI arguments and run pyright checks on python fences in markdown files. + + CLI: + markdown_files (list[str]): Markdown files or glob patterns. + --exclude (str, repeatable): Skip files whose path contains this substring. + --no-glob (flag): Treat inputs as literal paths (no glob expansion). + """ + + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("markdown_files", nargs="+", help="Markdown files to check (supports glob patterns).") + parser.add_argument("--exclude", action="append", help="Exclude files containing this pattern.") + parser.add_argument("--no-glob", action="store_true", help="Treat file arguments as literal paths (no glob expansion).") + args = parser.parse_args() + + expanded_files = expand_file_patterns(args.markdown_files, skip_glob=args.no_glob) + check_code_blocks(expanded_files, args.exclude) + + +if __name__ == "__main__": + main() diff --git a/scripts/generate_docs.py b/scripts/generate_docs.py new file mode 100644 index 0000000..1dea1d2 --- /dev/null +++ b/scripts/generate_docs.py @@ -0,0 +1,124 @@ +"""Generate docs using py2docfx for all agents in this workspace. + +EXPERIMENTAL: py2docfx outputs docfx YAML, not markdown; this pipeline is not finalized and +may change or be replaced. + +Path-based variant derived from the Agent Framework script: +- Collect uv workspace members under ``agents/*`` +- Build a py2docfx package manifest with ``install_type=path`` per agent +- Emit docfx-ready YAML into ``docs/`` +""" + +# Copyright (c) Microsoft. All rights reserved. +# Licensed under the MIT License. See: +# https://github.com/microsoft/agent-framework/blob/main/LICENSE +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +from __future__ import annotations + +import argparse +import asyncio +import json +import os +from pathlib import Path +from typing import Any + +import tomli +from py2docfx.__main__ import main as py2docfx_main # type: ignore[reportMissingImports] + +from utils.task_utils import discover_projects + + +def load_package_name(agent_dir: Path) -> str: + pyproject = agent_dir / "pyproject.toml" + data = tomli.loads(pyproject.read_text(encoding="utf-8")) + return data.get("project", {}).get("name", agent_dir.name) + + +def build_manifest(agent_dirs: list[Path]) -> dict[str, Any]: + """Build py2docfx package manifest using path installs for each agent.""" + + packages: list[dict[str, Any]] = [] + for agent_dir in agent_dirs: + name = load_package_name(agent_dir) + packages.append( + { + "package_info": { + "name": name, + "install_type": "source_code", + "folder": str(agent_dir.resolve()), + }, + "output_path": f"agents/{agent_dir.name}", + } + ) + + return { + "packages": packages, + "required_packages": [], + } + + +async def generate_docs(root: Path, output: Path) -> None: + """Run py2docfx with the generated manifest.""" + + agent_dirs: list[Path] = [] + for project in discover_projects(root / "pyproject.toml"): + candidate = project if project.is_absolute() else root / project + if (candidate / "pyproject.toml").exists(): + agent_dirs.append(candidate) + + extra_paths = [str(root)] + [str(agent_dir) for agent_dir in agent_dirs] + os.environ["PYTHONPATH"] = os.pathsep.join(extra_paths + [os.environ.get("PYTHONPATH", "")]) + manifest = build_manifest(agent_dirs) + + print("Discovered agents:") + for agent_dir in agent_dirs: + print(f"- {agent_dir.name}") + + output_root = output if output.is_absolute() else root / output + output_root.mkdir(parents=True, exist_ok=True) + + # py2docfx defines the output option as "-o--output-root-folder" (concatenated), so we must use that literal. + args = [ + "-o--output-root-folder", + str(output_root), + "-j", + json.dumps(manifest), + "--verbose", + ] + try: + await py2docfx_main(args) + except Exception as exc: # pragma: no cover - logging only + print(f"Error generating documentation: {exc}") + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--root", default=Path(__file__).resolve().parents[1], type=Path) + parser.add_argument("--output", default=Path("docs"), type=Path) + args = parser.parse_args() + + print(f"Current path: {args.root}") + + asyncio.run(generate_docs(args.root, args.output)) + + +if __name__ == "__main__": + main() diff --git a/scripts/run_tasks_in_agents_if_exists.py b/scripts/run_tasks_in_agents_if_exists.py new file mode 100644 index 0000000..738e855 --- /dev/null +++ b/scripts/run_tasks_in_agents_if_exists.py @@ -0,0 +1,76 @@ +"""Run a named Poe task in each agent that defines it. + +How it works: +- Discovers agents via the root uv workspace (tool.uv.workspace.members). +- For each agent, reads its pyproject (and optional tool.poe.include) to see if the task exists. +- If present, runs the task with Poe in that agent's directory; otherwise logs that it was skipped. + +Usage: + python scripts/run_tasks_in_agents_if_exists.py [agent ...] + +Derived from https://github.com/microsoft/agent-framework/ (MIT license) and adapted for agents/*. +""" + +# Copyright (c) Microsoft. All rights reserved. +# Licensed under the MIT License. See: +# https://github.com/microsoft/agent-framework/blob/main/LICENSE +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +from __future__ import annotations + +import sys +from pathlib import Path + +from poethepoet.app import PoeThePoet +from rich import print + +from utils.task_utils import discover_projects, extract_poe_tasks + + +def main() -> None: + """Run a requested Poe task in each agent that defines it. + + If agent names are provided, only those under agents/ are considered; otherwise all workspace members. + + Args: + None. Parses CLI args: ``task`` (required). + """ + pyproject_file = Path(__file__).resolve().parent.parent / "pyproject.toml" + projects = discover_projects(pyproject_file) + + if len(sys.argv) < 2: + print("Please provide a task name") + sys.exit(1) + + task_name = sys.argv[1] + for project in projects: + tasks = extract_poe_tasks(project / "pyproject.toml") + if task_name in tasks: + print(f"Running task {task_name} in {project}") + app = PoeThePoet(cwd=project) + result = app(cli_args=sys.argv[1:]) + if result: + sys.exit(result) + else: + print(f"Task {task_name} not found in {project}") + + +if __name__ == "__main__": + main() diff --git a/scripts/run_tasks_in_changed_agents.py b/scripts/run_tasks_in_changed_agents.py new file mode 100644 index 0000000..3c477b5 --- /dev/null +++ b/scripts/run_tasks_in_changed_agents.py @@ -0,0 +1,155 @@ +"""Run a Poe task only in agents that have changed files. + +How it works: +- Discovers agents via the root uv workspace (tool.uv.workspace.members). +- Determines changed files from argv (if provided) or git diff against base-ref. +- Maps changed files to agents, then runs the task via Poe in each matching agent that defines it. + +Usage: + python scripts/run_tasks_in_changed_agents.py [changed_file ...] + # When no files are passed, falls back to git diff against --base-ref (default origin/main). + +Derived from https://github.com/microsoft/agent-framework/ (MIT) and adapted for agents/*. +""" + +# Copyright (c) Microsoft. All rights reserved. +# Licensed under the MIT License. See: +# https://github.com/microsoft/agent-framework/blob/main/LICENSE +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +from __future__ import annotations + +import argparse +import subprocess # nosec B404 - use git via fixed arg lists; avoids extra deps like GitPython +import sys +from pathlib import Path + +from poethepoet.app import PoeThePoet +from rich import print + +from utils.task_utils import discover_projects, extract_poe_tasks + +ROOT = Path(__file__).resolve().parent.parent + +def git_changed_files(base_ref: str) -> list[str]: + """Get changed files via git diff, trying a couple of fallbacks. + + Args: + base_ref: The ref to diff against (e.g., origin/main). + + Returns: + A list of changed file paths (relative to repo root) or an empty list on failure. + """ + + candidates = [ + ["git", "diff", "--name-only", f"{base_ref}...HEAD", "--"], + ["git", "diff", "--name-only", "HEAD~1", "--"], + ] + for command in candidates: + try: + output = subprocess.check_output(command, cwd=ROOT, text=True) # nosec B603 - fixed args, shell=False, ref-only input + except subprocess.CalledProcessError: + continue + if output.strip(): + return output.strip().splitlines() + return [] + + +def get_changed_projects(projects: list[Path], changed_files: list[str], workspace_root: Path) -> set[Path]: + """Determine which agents have changed files by matching paths against project roots. + + Args: + projects: Candidate project paths from the uv workspace. + changed_files: Paths (relative or absolute) reported by git or provided by the user. + workspace_root: Repository root to resolve relative paths. + + Returns: + A set of project Paths that contain at least one changed file. + """ + + changed_projects: set[Path] = set() + + for file_path in changed_files: + file_path_str = str(file_path) + + abs_path = Path(file_path_str) + if not abs_path.is_absolute(): + abs_path = workspace_root / file_path_str + + for project in projects: + project_abs = workspace_root / project + try: + abs_path.relative_to(project_abs) + changed_projects.add(project) + break + except ValueError: + continue + + return changed_projects + + +def main() -> None: + """Parse args, detect changed agents, and run a Poe task where present. + + CLI: + task (str): Name of the Poe task to run in each changed agent. + files (list[str], optional): Changed file paths; if omitted, git diff is used. + --base-ref (str): Base ref for git diff fallback (default: origin/main). + """ + + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("task", help="The task name to run") + parser.add_argument("files", nargs="*", help="Changed files to determine which agents to run") + parser.add_argument("--base-ref", default="origin/main", help="Base ref for git diff when no files are provided") + args = parser.parse_args() + + pyproject_file = ROOT / "pyproject.toml" + workspace_root = pyproject_file.parent + projects = discover_projects(pyproject_file) + + changed_files = args.files + if not changed_files or changed_files == ["."]: + changed_files = git_changed_files(args.base_ref) + + if not changed_files: + print(f"[yellow]No changes detected; skipping {args.task}[/yellow]") + return + + changed_projects = get_changed_projects(projects, changed_files, workspace_root) + if not changed_projects: + print(f"[yellow]No agent projects matched the changed files; skipping {args.task}[/yellow]") + return + + print(f"[cyan]Running {args.task} in agents: {', '.join(str(p) for p in sorted(changed_projects))}[/cyan]") + + for project in sorted(changed_projects): + tasks = extract_poe_tasks(project / "pyproject.toml") + if args.task in tasks: + print(f"Running task {args.task} in {project}") + app = PoeThePoet(cwd=project) + result = app(cli_args=[args.task]) + if result: + sys.exit(result) + else: + print(f"Task {args.task} not found in {project}") + + +if __name__ == "__main__": + main() diff --git a/scripts/utils/__init__.py b/scripts/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scripts/utils/task_utils.py b/scripts/utils/task_utils.py new file mode 100644 index 0000000..5c39b8d --- /dev/null +++ b/scripts/utils/task_utils.py @@ -0,0 +1,105 @@ +"""Shared utilities for discovering uv workspace members and Poe tasks. + +Usage: +- Call ``discover_projects(root / "pyproject.toml")`` to expand uv workspace members (and excludes). +- Call ``extract_poe_tasks(project / "pyproject.toml")`` to enumerate a project's Poe tasks, + following any ``tool.poe.include`` file if present. + +Both helpers return plain Python collections and perform no IO beyond reading pyproject files. + +Derived from https://github.com/microsoft/agent-framework/ (MIT license) and adapted for agents/*. +""" + +# Copyright (c) Microsoft. All rights reserved. +# Licensed under the MIT License. See: +# https://github.com/microsoft/agent-framework/blob/main/LICENSE +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +from __future__ import annotations + +import glob +from pathlib import Path + +import tomli + + +def discover_projects(workspace_pyproject_file: Path) -> list[Path]: + """Expand uv workspace members (and excludes) into concrete project paths. + + Expects a pyproject that defines ``tool.uv.workspace.members``; returns each member as a Path, + expanding globs and removing any entries listed under ``exclude``. + + Args: + workspace_pyproject_file: Path to the root pyproject that defines the uv workspace. + + Returns: + A list of Paths to each discovered project directory. + """ + + with workspace_pyproject_file.open("rb") as f: + data = tomli.load(f) + + projects = data["tool"]["uv"]["workspace"]["members"] + exclude = data["tool"]["uv"]["workspace"].get("exclude", []) + + all_projects: list[Path] = [] + for project in projects: + if "*" in project: + globbed = glob.glob(str(project), root_dir=workspace_pyproject_file.parent) + globbed_paths = [Path(p) for p in globbed] + all_projects.extend(globbed_paths) + else: + all_projects.append(Path(project)) + + for project in exclude: + if "*" in project: + globbed = glob.glob(str(project), root_dir=workspace_pyproject_file.parent) + globbed_paths = [Path(p) for p in globbed] + all_projects = [p for p in all_projects if p not in globbed_paths] + else: + all_projects = [p for p in all_projects if p != Path(project)] + + return all_projects + + +def extract_poe_tasks(file: Path) -> set[str]: + """Collect Poe task names from a pyproject (including an included file if present). + + Reads ``tool.poe.tasks`` and, if ``tool.poe.include`` points to a file, merges tasks from there too. + + Args: + file: Path to a pyproject.toml to inspect. + + Returns: + A set of Poe task names defined across the file (and any included file). + """ + + with file.open("rb") as f: + data = tomli.load(f) + + tasks = set(data.get("tool", {}).get("poe", {}).get("tasks", {}).keys()) + + include: str | None = data.get("tool", {}).get("poe", {}).get("include", None) + if include: + include_file = file.parent / include + if include_file.exists(): + tasks = tasks.union(extract_poe_tasks(include_file)) + + return tasks diff --git a/shared_tasks.toml b/shared_tasks.toml new file mode 100644 index 0000000..dae1615 --- /dev/null +++ b/shared_tasks.toml @@ -0,0 +1,18 @@ +# Portions derived from Microsoft Agent Framework (MIT license): +# https://github.com/microsoft/agent-framework +# https://github.com/microsoft/agent-framework/blob/main/LICENSE + +[tool.poe.tasks] +fmt = "ruff format" +format.ref = "fmt" + +lint = "ruff check" +pyright = "pyright" +bandit = "sh -c 'ROOT=$(git -C \"$POE_ROOT\" rev-parse --show-toplevel); uv run bandit -c \"$ROOT/pyproject.toml\" -r \"$ROOT/agents\" \"$ROOT/scripts\"'" + +publish = "uv publish" + +clean-dist = "rm -rf dist" +build-package = "uv build" +move-dist = "sh -c 'mkdir -p ../../dist && mv dist/* ../../dist/ 2>/dev/null || true'" +build = ["build-package", "move-dist"]