diff --git a/.github/workflows/publish-smartem-workspace.yml b/.github/workflows/publish-smartem-workspace.yml new file mode 100644 index 0000000..67bea26 --- /dev/null +++ b/.github/workflows/publish-smartem-workspace.yml @@ -0,0 +1,124 @@ +name: Publish smartem-workspace to PyPI + +on: + push: + branches: [main] + tags: ['smartem-workspace-v*'] + paths: + - 'packages/smartem-workspace/**' + - '.github/workflows/publish-smartem-workspace.yml' + pull_request: + paths: + - 'packages/smartem-workspace/**' + - '.github/workflows/publish-smartem-workspace.yml' + workflow_dispatch: + +permissions: + contents: read + +jobs: + test: + name: Test smartem-workspace + runs-on: ubuntu-latest + defaults: + run: + working-directory: packages/smartem-workspace + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + + - name: Set up Python + run: uv python install 3.11 + + - name: Install dependencies + run: uv sync --all-extras + + - name: Run tests + run: uv run pytest -v + + lint: + name: Lint smartem-workspace + runs-on: ubuntu-latest + defaults: + run: + working-directory: packages/smartem-workspace + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + + - name: Set up Python + run: uv python install 3.11 + + - name: Install dependencies + run: uv sync --all-extras + + - name: Ruff check + run: uv run ruff check . + + - name: Ruff format check + run: uv run ruff format --check . + + build: + name: Build smartem-workspace package + runs-on: ubuntu-latest + needs: [test, lint] + defaults: + run: + working-directory: packages/smartem-workspace + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + + - name: Set up Python + run: uv python install 3.11 + + - name: Build package + run: uv build + + - name: Check package metadata + run: uvx twine check dist/* + + - name: List build artifacts + run: ls -lh dist/ + + - name: Upload build artifacts + uses: actions/upload-artifact@v4 + with: + name: dist + path: packages/smartem-workspace/dist/* + retention-days: 7 + + publish-pypi: + name: Publish to PyPI + runs-on: ubuntu-latest + needs: [build] + if: startsWith(github.ref, 'refs/tags/smartem-workspace-v') + environment: + name: pypi + url: https://pypi.org/p/smartem-workspace + permissions: + id-token: write + + steps: + - name: Download build artifacts + uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/README.md b/README.md index 91ceb78..9fa0d8c 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,20 @@ smartem-devtools/ ## Quick Start +### smartem-workspace CLI + +Automated workspace setup for SmartEM development: + +```bash +# Set up complete development environment +uvx smartem-workspace init --preset smartem-core + +# Or just this repository and docs +uvx smartem-workspace init --preset minimal +``` + +See [smartem-workspace documentation](packages/smartem-workspace/README.md) for details. + ### Developer WebUI ```bash @@ -105,7 +119,7 @@ ADRs document significant technical decisions: | ADR | Title | |-----|-------| | 0001 | Record architecture decisions | -| 0002 | Switched to python-copier-template | +| 0002 | Switched to python-copier-template (superseded by ADR-0011) | | 0003 | Message queue message grouping | | 0004 | Zocalo dependency-free | | 0005 | detect-secrets for secret scanning | @@ -113,6 +127,7 @@ ADRs document significant technical decisions: | 0007 | Eliminate SmartEM API circular dependency | | 0008 | Backend to agent communication architecture | | 0009 | Commit generated route tree (smartem-frontend) | +| 0011 | Remove python-copier-template | ## Contributing diff --git a/core/repos-and-refs.ts b/core/repos-and-refs.ts index a1997b0..bfe2736 100644 --- a/core/repos-and-refs.ts +++ b/core/repos-and-refs.ts @@ -1,7 +1,13 @@ /** * Repository definitions and external references for SmartEM ecosystem. + * + * Source of truth: repos.json + * This file re-exports the JSON data with TypeScript types for type safety. */ +import reposConfig from './repos.json' + +// Re-export types for consumers export interface RepoUrls { https: string ssh: string @@ -11,6 +17,9 @@ export interface Repository { name: string description: string urls: RepoUrls + tags?: string[] + ownership?: 'full' | 'reference-only' + required?: boolean } export interface OrgRepos { @@ -29,277 +38,57 @@ export interface ReposAndRefsConfig { repositories: OrgRepos[] } -const diamondLightSourceRepos: Repository[] = [ - { - name: 'smartem-decisions', - description: 'Central system controller - backbone, messaging router, persistence, auth', - urls: { - https: 'https://github.com/DiamondLightSource/smartem-decisions.git', - ssh: 'git@github.com:DiamondLightSource/smartem-decisions.git', - }, - }, - { - name: 'smartem-frontend', - description: 'Web UI for SmartEM - user-facing view of acquisition sessions and ML decisions', - urls: { - https: 'https://github.com/DiamondLightSource/smartem-frontend.git', - ssh: 'git@github.com:DiamondLightSource/smartem-frontend.git', - }, - }, - { - name: 'smartem-devtools', - description: 'Developer tooling, documentation, and workspace configuration', - urls: { - https: 'https://github.com/DiamondLightSource/smartem-devtools.git', - ssh: 'git@github.com:DiamondLightSource/smartem-devtools.git', - }, - }, - { - name: 'fandanGO-cryoem-dls', - description: 'DLS facility plugin for FandanGO - bridges SmartEM to ARIA', - urls: { - https: 'https://github.com/DiamondLightSource/fandanGO-cryoem-dls.git', - ssh: 'git@github.com:DiamondLightSource/fandanGO-cryoem-dls.git', - }, - }, - { - name: 'cryoem-services', - description: 'Processing execution layer for cryo-EM data pipelines (reference-only)', - urls: { - https: 'https://github.com/DiamondLightSource/cryoem-services.git', - ssh: 'git@github.com:DiamondLightSource/cryoem-services.git', - }, - }, -] - -const gitlabAriaPHPRepos: Repository[] = [ - { - name: 'data-deposition-api', - description: 'ARIA GraphQL/REST API for metadata deposition (primary)', - urls: { - https: 'https://gitlab.com/aria-php/data-deposition-api.git', - ssh: 'git@gitlab.com:aria-php/data-deposition-api.git', - }, - }, - { - name: 'aria-graphql-client', - description: 'PHP library for communicating with ARIA GraphQL API', - urls: { - https: 'https://gitlab.com/aria-php/aria-graphql-client.git', - ssh: 'git@gitlab.com:aria-php/aria-graphql-client.git', - }, - }, - { - name: 'aria-elasticsearch-client', - description: 'Elasticsearch client for ARIA search records', - urls: { - https: 'https://gitlab.com/aria-php/aria-elasticsearch-client.git', - ssh: 'git@gitlab.com:aria-php/aria-elasticsearch-client.git', - }, - }, - { - name: 'aria-rest', - description: 'REST API framework for defining versioned APIs', - urls: { - https: 'https://gitlab.com/aria-php/aria-rest.git', - ssh: 'git@gitlab.com:aria-php/aria-rest.git', - }, - }, - { - name: 'aria-storage-interface', - description: 'Storage provider interface', - urls: { - https: 'https://gitlab.com/aria-php/aria-storage-interface.git', - ssh: 'git@gitlab.com:aria-php/aria-storage-interface.git', - }, - }, - { - name: 'aria-webhooks', - description: 'Standard webhook payload format for ARIA platform', - urls: { - https: 'https://gitlab.com/aria-php/aria-webhooks.git', - ssh: 'git@gitlab.com:aria-php/aria-webhooks.git', - }, - }, - { - name: 'aria-incoming-email', - description: 'Incoming email message routing', - urls: { - https: 'https://gitlab.com/aria-php/aria-incoming-email.git', - ssh: 'git@gitlab.com:aria-php/aria-incoming-email.git', - }, - }, - { - name: 'aria-mailer', - description: 'Email wrapper (PHPMailer + Swiftmailer)', - urls: { - https: 'https://gitlab.com/aria-php/aria-mailer.git', - ssh: 'git@gitlab.com:aria-php/aria-mailer.git', - }, - }, - { - name: 'aria-mailgun-webhooks', - description: 'Mailgun webhook event parser', - urls: { - https: 'https://gitlab.com/aria-php/aria-mailgun-webhooks.git', - ssh: 'git@gitlab.com:aria-php/aria-mailgun-webhooks.git', - }, - }, - { - name: 'aria-invite-users', - description: 'User invitation framework', - urls: { - https: 'https://gitlab.com/aria-php/aria-invite-users.git', - ssh: 'git@gitlab.com:aria-php/aria-invite-users.git', - }, - }, - { - name: 'aria-data-subscription', - description: 'Data source subscription framework for feeds', - urls: { - https: 'https://gitlab.com/aria-php/aria-data-subscription.git', - ssh: 'git@gitlab.com:aria-php/aria-data-subscription.git', - }, - }, - { - name: 'aria-stats', - description: 'Performance statistics monitoring', - urls: { - https: 'https://gitlab.com/aria-php/aria-stats.git', - ssh: 'git@gitlab.com:aria-php/aria-stats.git', - }, - }, - { - name: 'aria-site-logger', - description: 'Monolog plugin for ARIA site logging', - urls: { - https: 'https://gitlab.com/aria-php/aria-site-logger.git', - ssh: 'git@gitlab.com:aria-php/aria-site-logger.git', - }, - }, - { - name: 'aria-service-ai', - description: 'Service AI library', - urls: { - https: 'https://gitlab.com/aria-php/aria-service-ai.git', - ssh: 'git@gitlab.com:aria-php/aria-service-ai.git', - }, - }, - { - name: 'keycloak-api', - description: 'PHP bindings for Keycloak Account API', - urls: { - https: 'https://gitlab.com/aria-php/keycloak-api.git', - ssh: 'git@gitlab.com:aria-php/keycloak-api.git', - }, - }, - { - name: 'doi-package', - description: 'DOI microservice client', - urls: { - https: 'https://gitlab.com/aria-php/doi-package.git', - ssh: 'git@gitlab.com:aria-php/doi-package.git', - }, - }, - { - name: 'molgenis-php-client', - description: 'PHP client for Molgenis database', - urls: { - https: 'https://gitlab.com/aria-php/molgenis-php-client.git', - ssh: 'git@gitlab.com:aria-php/molgenis-php-client.git', - }, - }, - { - name: 'shibboleth-idp-dockerized', - description: 'Dockerized Shibboleth IdP (identity federation)', - urls: { - https: 'https://gitlab.com/aria-php/shibboleth-idp-dockerized.git', - ssh: 'git@gitlab.com:aria-php/shibboleth-idp-dockerized.git', - }, - }, - { - name: 'rtd-compiler', - description: 'ReadTheDocs compiler for ARIA documentation', - urls: { - https: 'https://gitlab.com/aria-php/rtd-compiler.git', - ssh: 'git@gitlab.com:aria-php/rtd-compiler.git', - }, - }, -] - -const fragmentScreenRepos: Repository[] = [ - { - name: 'fandanGO-core', - description: 'Plugin framework foundation', - urls: { - https: 'https://github.com/FragmentScreen/fandanGO-core.git', - ssh: 'git@github.com:FragmentScreen/fandanGO-core.git', - }, - }, - { - name: 'fandanGO-aria', - description: 'ARIA integration - auth, token management, metadata submission', - urls: { - https: 'https://github.com/FragmentScreen/fandanGO-aria.git', - ssh: 'git@github.com:FragmentScreen/fandanGO-aria.git', - }, - }, - { - name: 'fandanGO-cryoem-cnb', - description: 'CNB-CSIC Madrid cryo-EM plugin (peer reference)', - urls: { - https: 'https://github.com/FragmentScreen/fandanGO-cryoem-cnb.git', - ssh: 'git@github.com:FragmentScreen/fandanGO-cryoem-cnb.git', - }, - }, - { - name: 'fandanGO-nmr-cerm', - description: 'CERM Florence NMR plugin (peer reference)', - urls: { - https: 'https://github.com/FragmentScreen/fandanGO-nmr-cerm.git', - ssh: 'git@github.com:FragmentScreen/fandanGO-nmr-cerm.git', - }, - }, - { - name: 'fandanGO-nmr-guf', - description: 'GUF Frankfurt NMR plugin (peer reference)', - urls: { - https: 'https://github.com/FragmentScreen/fandanGO-nmr-guf.git', - ssh: 'git@github.com:FragmentScreen/fandanGO-nmr-guf.git', - }, - }, - { - name: 'Samples', - description: 'Sample metadata/datasets for community reference', - urls: { - https: 'https://github.com/FragmentScreen/Samples.git', - ssh: 'git@github.com:FragmentScreen/Samples.git', - }, - }, -] +// Transform JSON structure to match existing interface for backward compatibility +const diamondLightSourceOrg = reposConfig.organizations.find( + (org) => org.name === 'DiamondLightSource', +) +const fragmentScreenOrg = reposConfig.organizations.find((org) => org.name === 'FragmentScreen') +const ariaPHPOrg = reposConfig.organizations.find((org) => org.name === 'aria-php') export const reposAndRefsConfig: ReposAndRefsConfig = { links: { - docs: 'https://diamondlightsource.github.io/smartem-decisions/', - projectBoard: 'https://github.com/orgs/DiamondLightSource/projects/51/views/1', + docs: reposConfig.links.docs, + projectBoard: reposConfig.links.projectBoard, }, repositories: [ { org: 'DiamondLightSource', - orgUrl: 'https://github.com/DiamondLightSource', - repos: diamondLightSourceRepos, + orgUrl: diamondLightSourceOrg?.url ?? 'https://github.com/DiamondLightSource', + repos: (diamondLightSourceOrg?.repos ?? []).map((repo) => ({ + name: repo.name, + description: repo.description, + urls: repo.urls, + tags: repo.tags, + ownership: repo.ownership, + required: repo.required, + })), }, { org: 'FragmentScreen', - orgUrl: 'https://github.com/FragmentScreen', - repos: fragmentScreenRepos, + orgUrl: fragmentScreenOrg?.url ?? 'https://github.com/FragmentScreen', + repos: (fragmentScreenOrg?.repos ?? []).map((repo) => ({ + name: repo.name, + description: repo.description, + urls: repo.urls, + tags: repo.tags, + ownership: repo.ownership, + })), }, { org: 'aria-php', - orgUrl: 'https://gitlab.com/aria-php', - repos: gitlabAriaPHPRepos, + orgUrl: ariaPHPOrg?.url ?? 'https://gitlab.com/aria-php', + repos: (ariaPHPOrg?.repos ?? []).map((repo) => ({ + name: repo.name, + description: repo.description, + urls: repo.urls, + tags: repo.tags, + ownership: repo.ownership, + })), }, ], } +// Export the full config for workspace setup tools +export { reposConfig } + export default reposAndRefsConfig diff --git a/core/repos.json b/core/repos.json new file mode 100644 index 0000000..3f5c570 --- /dev/null +++ b/core/repos.json @@ -0,0 +1,403 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "version": "1.0.0", + "links": { + "docs": "https://diamondlightsource.github.io/smartem-decisions/", + "projectBoard": "https://github.com/orgs/DiamondLightSource/projects/51/views/1" + }, + "presets": { + "smartem-core": { + "description": "Core SmartEM development repos", + "repos": [ + "DiamondLightSource/smartem-decisions", + "DiamondLightSource/smartem-frontend", + "DiamondLightSource/smartem-devtools" + ] + }, + "full": { + "description": "All repos including ARIA reference", + "repos": ["*"] + }, + "aria-reference": { + "description": "ARIA ecosystem repos for reference", + "repos": [ + "DiamondLightSource/fandanGO-cryoem-dls", + "FragmentScreen/*", + "aria-php/data-deposition-api" + ] + }, + "minimal": { + "description": "Just smartem-devtools (workspace setup only)", + "repos": ["DiamondLightSource/smartem-devtools"] + } + }, + "organizations": [ + { + "name": "DiamondLightSource", + "displayName": "Diamond Light Source", + "url": "https://github.com/DiamondLightSource", + "provider": "github", + "repos": [ + { + "name": "smartem-decisions", + "description": "Central system controller - backbone, messaging router, persistence, auth", + "urls": { + "https": "https://github.com/DiamondLightSource/smartem-decisions.git", + "ssh": "git@github.com:DiamondLightSource/smartem-decisions.git" + }, + "tags": ["core", "python", "backend"], + "ownership": "full" + }, + { + "name": "smartem-frontend", + "description": "Web UI for SmartEM - user-facing view of acquisition sessions and ML decisions", + "urls": { + "https": "https://github.com/DiamondLightSource/smartem-frontend.git", + "ssh": "git@github.com:DiamondLightSource/smartem-frontend.git" + }, + "tags": ["core", "typescript", "frontend"], + "ownership": "full" + }, + { + "name": "smartem-devtools", + "description": "Developer tooling, documentation, and workspace configuration", + "urls": { + "https": "https://github.com/DiamondLightSource/smartem-devtools.git", + "ssh": "git@github.com:DiamondLightSource/smartem-devtools.git" + }, + "tags": ["core", "tooling"], + "ownership": "full", + "required": true + }, + { + "name": "fandanGO-cryoem-dls", + "description": "DLS facility plugin for FandanGO - bridges SmartEM to ARIA", + "urls": { + "https": "https://github.com/DiamondLightSource/fandanGO-cryoem-dls.git", + "ssh": "git@github.com:DiamondLightSource/fandanGO-cryoem-dls.git" + }, + "tags": ["aria", "python"], + "ownership": "full" + }, + { + "name": "cryoem-services", + "description": "Processing execution layer for cryo-EM data pipelines (reference-only)", + "urls": { + "https": "https://github.com/DiamondLightSource/cryoem-services.git", + "ssh": "git@github.com:DiamondLightSource/cryoem-services.git" + }, + "tags": ["reference", "python"], + "ownership": "reference-only" + } + ] + }, + { + "name": "FragmentScreen", + "displayName": "FragmentScreen", + "url": "https://github.com/FragmentScreen", + "provider": "github", + "repos": [ + { + "name": "fandanGO-core", + "description": "Plugin framework foundation", + "urls": { + "https": "https://github.com/FragmentScreen/fandanGO-core.git", + "ssh": "git@github.com:FragmentScreen/fandanGO-core.git" + }, + "tags": ["aria", "python", "framework"], + "ownership": "reference-only" + }, + { + "name": "fandanGO-aria", + "description": "ARIA integration - auth, token management, metadata submission", + "urls": { + "https": "https://github.com/FragmentScreen/fandanGO-aria.git", + "ssh": "git@github.com:FragmentScreen/fandanGO-aria.git" + }, + "tags": ["aria", "python"], + "ownership": "reference-only" + }, + { + "name": "fandanGO-cryoem-cnb", + "description": "CNB-CSIC Madrid cryo-EM plugin (peer reference)", + "urls": { + "https": "https://github.com/FragmentScreen/fandanGO-cryoem-cnb.git", + "ssh": "git@github.com:FragmentScreen/fandanGO-cryoem-cnb.git" + }, + "tags": ["aria", "python", "peer"], + "ownership": "reference-only" + }, + { + "name": "fandanGO-nmr-cerm", + "description": "CERM Florence NMR plugin (peer reference)", + "urls": { + "https": "https://github.com/FragmentScreen/fandanGO-nmr-cerm.git", + "ssh": "git@github.com:FragmentScreen/fandanGO-nmr-cerm.git" + }, + "tags": ["aria", "python", "peer"], + "ownership": "reference-only" + }, + { + "name": "fandanGO-nmr-guf", + "description": "GUF Frankfurt NMR plugin (peer reference)", + "urls": { + "https": "https://github.com/FragmentScreen/fandanGO-nmr-guf.git", + "ssh": "git@github.com:FragmentScreen/fandanGO-nmr-guf.git" + }, + "tags": ["aria", "python", "peer"], + "ownership": "reference-only" + }, + { + "name": "Samples", + "description": "Sample metadata/datasets for community reference", + "urls": { + "https": "https://github.com/FragmentScreen/Samples.git", + "ssh": "git@github.com:FragmentScreen/Samples.git" + }, + "tags": ["aria", "data"], + "ownership": "reference-only" + } + ] + }, + { + "name": "aria-php", + "displayName": "ARIA PHP (GitLab)", + "url": "https://gitlab.com/aria-php", + "provider": "gitlab", + "localDir": "GitlabAriaPHP", + "repos": [ + { + "name": "data-deposition-api", + "description": "ARIA GraphQL/REST API for metadata deposition (primary)", + "urls": { + "https": "https://gitlab.com/aria-php/data-deposition-api.git", + "ssh": "git@gitlab.com:aria-php/data-deposition-api.git" + }, + "tags": ["aria", "php", "api"], + "ownership": "reference-only" + }, + { + "name": "aria-graphql-client", + "description": "PHP library for communicating with ARIA GraphQL API", + "urls": { + "https": "https://gitlab.com/aria-php/aria-graphql-client.git", + "ssh": "git@gitlab.com:aria-php/aria-graphql-client.git" + }, + "tags": ["aria", "php", "library"], + "ownership": "reference-only" + }, + { + "name": "aria-elasticsearch-client", + "description": "Elasticsearch client for ARIA search records", + "urls": { + "https": "https://gitlab.com/aria-php/aria-elasticsearch-client.git", + "ssh": "git@gitlab.com:aria-php/aria-elasticsearch-client.git" + }, + "tags": ["aria", "php", "library"], + "ownership": "reference-only" + }, + { + "name": "aria-rest", + "description": "REST API framework for defining versioned APIs", + "urls": { + "https": "https://gitlab.com/aria-php/aria-rest.git", + "ssh": "git@gitlab.com:aria-php/aria-rest.git" + }, + "tags": ["aria", "php", "framework"], + "ownership": "reference-only" + }, + { + "name": "aria-storage-interface", + "description": "Storage provider interface", + "urls": { + "https": "https://gitlab.com/aria-php/aria-storage-interface.git", + "ssh": "git@gitlab.com:aria-php/aria-storage-interface.git" + }, + "tags": ["aria", "php", "library"], + "ownership": "reference-only" + }, + { + "name": "aria-webhooks", + "description": "Standard webhook payload format for ARIA platform", + "urls": { + "https": "https://gitlab.com/aria-php/aria-webhooks.git", + "ssh": "git@gitlab.com:aria-php/aria-webhooks.git" + }, + "tags": ["aria", "php", "library"], + "ownership": "reference-only" + }, + { + "name": "aria-incoming-email", + "description": "Incoming email message routing", + "urls": { + "https": "https://gitlab.com/aria-php/aria-incoming-email.git", + "ssh": "git@gitlab.com:aria-php/aria-incoming-email.git" + }, + "tags": ["aria", "php", "library"], + "ownership": "reference-only" + }, + { + "name": "aria-mailer", + "description": "Email wrapper (PHPMailer + Swiftmailer)", + "urls": { + "https": "https://gitlab.com/aria-php/aria-mailer.git", + "ssh": "git@gitlab.com:aria-php/aria-mailer.git" + }, + "tags": ["aria", "php", "library"], + "ownership": "reference-only" + }, + { + "name": "aria-mailgun-webhooks", + "description": "Mailgun webhook event parser", + "urls": { + "https": "https://gitlab.com/aria-php/aria-mailgun-webhooks.git", + "ssh": "git@gitlab.com:aria-php/aria-mailgun-webhooks.git" + }, + "tags": ["aria", "php", "library"], + "ownership": "reference-only" + }, + { + "name": "aria-invite-users", + "description": "User invitation framework", + "urls": { + "https": "https://gitlab.com/aria-php/aria-invite-users.git", + "ssh": "git@gitlab.com:aria-php/aria-invite-users.git" + }, + "tags": ["aria", "php", "library"], + "ownership": "reference-only" + }, + { + "name": "aria-data-subscription", + "description": "Data source subscription framework for feeds", + "urls": { + "https": "https://gitlab.com/aria-php/aria-data-subscription.git", + "ssh": "git@gitlab.com:aria-php/aria-data-subscription.git" + }, + "tags": ["aria", "php", "library"], + "ownership": "reference-only" + }, + { + "name": "aria-stats", + "description": "Performance statistics monitoring", + "urls": { + "https": "https://gitlab.com/aria-php/aria-stats.git", + "ssh": "git@gitlab.com:aria-php/aria-stats.git" + }, + "tags": ["aria", "php", "library"], + "ownership": "reference-only" + }, + { + "name": "aria-site-logger", + "description": "Monolog plugin for ARIA site logging", + "urls": { + "https": "https://gitlab.com/aria-php/aria-site-logger.git", + "ssh": "git@gitlab.com:aria-php/aria-site-logger.git" + }, + "tags": ["aria", "php", "library"], + "ownership": "reference-only" + }, + { + "name": "aria-service-ai", + "description": "Service AI library", + "urls": { + "https": "https://gitlab.com/aria-php/aria-service-ai.git", + "ssh": "git@gitlab.com:aria-php/aria-service-ai.git" + }, + "tags": ["aria", "php", "library"], + "ownership": "reference-only" + }, + { + "name": "keycloak-api", + "description": "PHP bindings for Keycloak Account API", + "urls": { + "https": "https://gitlab.com/aria-php/keycloak-api.git", + "ssh": "git@gitlab.com:aria-php/keycloak-api.git" + }, + "tags": ["aria", "php", "integration"], + "ownership": "reference-only" + }, + { + "name": "doi-package", + "description": "DOI microservice client", + "urls": { + "https": "https://gitlab.com/aria-php/doi-package.git", + "ssh": "git@gitlab.com:aria-php/doi-package.git" + }, + "tags": ["aria", "php", "integration"], + "ownership": "reference-only" + }, + { + "name": "molgenis-php-client", + "description": "PHP client for Molgenis database", + "urls": { + "https": "https://gitlab.com/aria-php/molgenis-php-client.git", + "ssh": "git@gitlab.com:aria-php/molgenis-php-client.git" + }, + "tags": ["aria", "php", "integration"], + "ownership": "reference-only" + }, + { + "name": "shibboleth-idp-dockerized", + "description": "Dockerized Shibboleth IdP (identity federation)", + "urls": { + "https": "https://gitlab.com/aria-php/shibboleth-idp-dockerized.git", + "ssh": "git@gitlab.com:aria-php/shibboleth-idp-dockerized.git" + }, + "tags": ["aria", "infrastructure"], + "ownership": "reference-only" + }, + { + "name": "rtd-compiler", + "description": "ReadTheDocs compiler for ARIA documentation", + "urls": { + "https": "https://gitlab.com/aria-php/rtd-compiler.git", + "ssh": "git@gitlab.com:aria-php/rtd-compiler.git" + }, + "tags": ["aria", "infrastructure"], + "ownership": "reference-only" + } + ] + } + ], + "claudeConfig": { + "skills": [ + { "name": "database-admin", "path": "shared/skills/database-admin" }, + { "name": "devops", "path": "shared/skills/devops" }, + { "name": "technical-writer", "path": "shared/skills/technical-writer" }, + { "name": "git", "path": "shared/skills/git" }, + { "name": "github", "path": "shared/skills/github" }, + { "name": "ascii-art", "path": "shared/skills/ascii-art" }, + { "name": "playwright-skill", "path": "smartem-frontend/skills/playwright-skill" } + ], + "defaultPermissions": { + "allow": [ + "Bash(git:*)", + "Bash(ls:*)", + "Bash(cat:*)", + "WebSearch", + "mcp__serena__*" + ] + } + }, + "serenaConfig": { + "languages": ["typescript", "python"], + "encoding": "utf-8", + "ignoreAllFilesInGitignore": true, + "projectName": "smartem-workspace" + }, + "mcpConfig": { + "serena": { + "command": "uvx", + "args": [ + "--from", + "git+https://github.com/oraios/serena", + "serena", + "start-mcp-server", + "--context", + "ide-assistant", + "--project", + "${PWD}" + ] + } + } +} diff --git a/docs/explanations/decisions/0002-switched-to-python-copier-template.md b/docs/explanations/decisions/0002-switched-to-python-copier-template.md index 31039f6..44878ed 100644 --- a/docs/explanations/decisions/0002-switched-to-python-copier-template.md +++ b/docs/explanations/decisions/0002-switched-to-python-copier-template.md @@ -2,7 +2,9 @@ ## Status -Accepted +Superseded by ADR-0011 + +> **Note**: This decision was reversed on 2026-01-05. See [ADR-0011](0011-remove-python-copier-template.md) for details. ## Context diff --git a/docs/explanations/decisions/0011-remove-python-copier-template.md b/docs/explanations/decisions/0011-remove-python-copier-template.md new file mode 100644 index 0000000..c1ba9a5 --- /dev/null +++ b/docs/explanations/decisions/0011-remove-python-copier-template.md @@ -0,0 +1,55 @@ +# 11. Remove python-copier-template + +## Status + +Accepted + +## Context + +In ADR-0002, we adopted the [python-copier-template](https://github.com/DiamondLightSource/python-copier-template) to ensure consistency in developer environments and package management. + +Since that decision, the smartem-decisions project has evolved significantly: + +1. **Project maturity**: The project has grown from a single-package PoC to a multi-package monorepo with custom requirements that diverge from standard DLS Python projects +2. **Custom tooling needs**: Our development workflow now requires tooling configurations specific to our architecture (multi-package structure, agent deployment, Kubernetes manifests, RabbitMQ integration) +3. **Template update friction**: The copier template's update mechanism became a maintenance burden rather than a benefit, as most updates were not relevant to our custom structure +4. **Duplicated documentation**: The template's contribution guidelines and developer documentation conflicted with our own evolving practices documented in smartem-devtools + +## Decision + +We have removed the python-copier-template dependency and scaffolding from smartem-decisions. + +The following were removed in commit f95b1de (2026-01-05): +- `.copier-answers.yml` configuration file +- Copier dependency from `pyproject.toml` +- Template-generated sections from `.github/CONTRIBUTING.md` + +We retain the tooling standards established by the template (pyright, ruff, pre-commit) but now manage their configuration directly. + +## Consequences + +### Positive + +- **Reduced maintenance burden**: No need to resolve conflicts when updating from the template +- **Custom workflows**: Freedom to evolve tooling and structure to match our specific needs +- **Simplified onboarding**: Developer documentation is now solely in smartem-devtools, not split between template and repo +- **Clearer ownership**: All configuration is explicitly managed by the team + +### Negative + +- **Manual updates**: We no longer automatically receive updates to best practices from the template +- **Divergence risk**: May drift from DLS Python conventions over time +- **Responsibility**: Must actively maintain tooling standards ourselves + +### Mitigations + +- Continue following DLS Python best practices where applicable +- Reference the copier template repo for inspiration when updating tooling +- Document our standards explicitly in smartem-devtools +- Maintain pre-commit hooks to enforce code quality standards + +## References + +- ADR-0002: Adopt python-copier-template (superseded by this decision) +- Removal commit: f95b1dea1479d8d845f5cfd605084c201f459020 +- DLS python-copier-template: https://github.com/DiamondLightSource/python-copier-template diff --git a/docs/explanations/smartem-workspace-developer-guide.md b/docs/explanations/smartem-workspace-developer-guide.md new file mode 100644 index 0000000..0ca4f2b --- /dev/null +++ b/docs/explanations/smartem-workspace-developer-guide.md @@ -0,0 +1,1613 @@ +# Developer Guide: smartem-workspace + +This guide is for developers who want to contribute to or modify the `smartem-workspace` package. + +## Table of Contents + +- [Architecture Overview](#architecture-overview) +- [Package Structure](#package-structure) +- [Core Components](#core-components) +- [Configuration System](#configuration-system) +- [Development Setup](#development-setup) +- [Testing Strategy](#testing-strategy) +- [CI/CD Pipeline](#cicd-pipeline) +- [Contributing Guidelines](#contributing-guidelines) +- [Troubleshooting Development](#troubleshooting-development) +- [API Reference](#api-reference) +- [Extending the Tool](#extending-the-tool) + +## Architecture Overview + +### Design Philosophy + +`smartem-workspace` follows these principles: + +1. **Network-first configuration** - Fetch latest repository metadata from GitHub, fall back to bundled config +2. **Zero permanent installation** - Designed for `uvx` (run without install) +3. **Idempotent operations** - Safe to re-run without side effects +4. **Progressive enhancement** - Core functionality works offline with graceful degradation +5. **Explicit over implicit** - Clear user prompts, no hidden magic +6. **Fail fast** - Validate early, provide actionable error messages + +### System Design + +``` +┌─────────────────────────────────────────────────────────────┐ +│ User Invocation │ +│ uvx smartem-workspace init │ +└──────────────────────────┬──────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ CLI Entry Point │ +│ cli.py (Typer app) │ +│ • Parse arguments │ +│ • Validate options │ +│ • Route to command handler │ +└──────────────────────────┬──────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ Configuration Loading │ +│ config/loader.py │ +│ 1. Try: Fetch from GitHub (main branch) │ +│ 2. Fallback: Use bundled repos.json │ +│ 3. Validate with Pydantic schemas │ +└──────────────────────────┬──────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ Interactive Prompts (if enabled) │ +│ interactive/prompts.py │ +│ • Preset or custom selection │ +│ • Repository selection (custom mode) │ +│ • Target directory confirmation │ +└──────────────────────────┬──────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ Setup Orchestration │ +│ setup/bootstrap.py │ +│ • Create workspace directory structure │ +│ • Clone repositories (parallel or serial) │ +│ • Setup Claude Code configuration │ +│ • Setup Serena MCP server │ +│ • Create workspace files (CLAUDE.md, etc.) │ +└──────────────────────────┬──────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ Completion Report │ +│ • Summary of cloned repositories │ +│ • Total workspace size │ +│ • Next steps for user │ +└─────────────────────────────────────────────────────────────┘ +``` + +### Data Flow + +#### Configuration Loading Flow + +``` +User runs command + │ + ▼ +Load configuration (config/loader.py) + │ + ├─► Try: httpx.get(GITHUB_RAW_URL/repos.json) + │ │ + │ ├─► Success: Parse JSON → Validate with Pydantic + │ │ │ + │ │ └─► Return RepoConfig + │ │ + │ └─► Failure: Log warning + │ │ + │ ▼ + └─► Fallback: Load bundled repos.json (in wheel) + │ + └─► Parse JSON → Validate with Pydantic → Return RepoConfig +``` + +#### Repository Cloning Flow + +``` +For each repository in selection: + │ + ▼ +Determine clone URL (HTTPS or SSH) + │ + ▼ +Check if target directory exists + │ + ├─► Exists: Skip (already cloned) + │ + └─► Not exists: + │ + ▼ + Create parent directory + │ + ▼ + Run: git clone + │ + ├─► Success: Update progress indicator + │ + └─► Failure: Log error, continue with next repo + (or abort if critical) +``` + +### Error Handling Strategy + +1. **Validation errors**: Fail immediately with clear message (e.g., invalid preset name) +2. **Network errors**: Degrade gracefully (use bundled config) +3. **Git errors**: Log and continue for individual repos, fail if all repos fail +4. **Permission errors**: Fail immediately with actionable guidance +5. **User cancellation**: Clean up partial state, exit gracefully + +## Package Structure + +``` +packages/smartem-workspace/ +├── smartem_workspace/ # Main package +│ ├── __init__.py # Package metadata (__version__) +│ ├── __main__.py # Entry point for python -m +│ ├── cli.py # Typer CLI application +│ │ +│ ├── config/ # Configuration system +│ │ ├── __init__.py # Public exports +│ │ ├── loader.py # Network-first config fetcher +│ │ ├── schema.py # Pydantic models +│ │ └── repos.json # Bundled fallback config +│ │ +│ ├── interactive/ # User interaction +│ │ ├── __init__.py # Public exports +│ │ └── prompts.py # Rich prompts and menus +│ │ +│ ├── setup/ # Setup orchestration +│ │ ├── __init__.py # Public exports +│ │ ├── bootstrap.py # Main orchestrator +│ │ ├── repos.py # Git cloning logic +│ │ ├── claude.py # Claude Code setup +│ │ ├── serena.py # Serena MCP setup +│ │ └── workspace.py # Directory structure creation +│ │ +│ └── utils/ # Shared utilities +│ ├── __init__.py # Public exports +│ ├── git.py # Git operations +│ └── paths.py # Path helpers and validation +│ +├── tests/ # Unit tests +│ ├── __init__.py +│ ├── test_config.py # Configuration system tests +│ └── test_utils.py # Utility function tests +│ +├── docs/ # Developer documentation +│ +├── dist/ # Build artifacts (generated) +│ ├── smartem_workspace-*.whl # Wheel distribution +│ └── smartem_workspace-*.tar.gz # Source distribution +│ +├── pyproject.toml # Package metadata and build config +├── README.md # User-facing documentation +└── uv.lock # Locked dependencies +``` + +### Module Responsibilities + +| Module | Purpose | Key Functions | +|--------|---------|---------------| +| `cli.py` | CLI entry point | `app()`, `init()`, `sync()`, `status()`, `add()` | +| `config/loader.py` | Load configuration | `load_config()`, `fetch_remote_config()`, `load_bundled_config()` | +| `config/schema.py` | Data models | `RepoConfig`, `Repository`, `ClaudeConfig`, `Preset` | +| `interactive/prompts.py` | User prompts | `select_preset()`, `select_repositories()`, `confirm_setup()` | +| `setup/bootstrap.py` | Orchestration | `bootstrap()`, `run_setup()` | +| `setup/repos.py` | Git operations | `clone_repositories()`, `clone_single_repo()` | +| `setup/claude.py` | Claude Code | `setup_claude_config()`, `create_symlinks()` | +| `setup/serena.py` | Serena MCP | `setup_serena()`, `configure_mcp()` | +| `setup/workspace.py` | Workspace files | `create_workspace_structure()`, `create_claude_md()` | +| `utils/git.py` | Git helpers | `get_clone_url()`, `run_git_command()` | +| `utils/paths.py` | Path utilities | `resolve_workspace_path()`, `ensure_directory()` | + +## Core Components + +### CLI Interface (cli.py) + +Built with [Typer](https://typer.tiangolo.com/) for type-safe CLI parsing. + +#### Key Design Decisions + +- **Rich output**: Uses `rich` library for beautiful terminal output +- **Subcommands**: Each command (`init`, `sync`, `status`, `add`) is a separate function +- **Type safety**: All arguments are type-hinted, Typer validates at runtime +- **Help text**: Extensive help strings auto-generate documentation + +#### Command Structure + +```python +import typer +from typing_extensions import Annotated + +app = typer.Typer( + name="smartem-workspace", + help="CLI tool to automate SmartEM multi-repo workspace setup", + add_completion=False, +) + +@app.command() +def init( + path: Annotated[Path, typer.Option(help="Target directory")] = Path.cwd(), + preset: Annotated[str, typer.Option(help="Preset name")] = None, + no_interactive: Annotated[bool, typer.Option(help="Skip prompts")] = False, + ssh: Annotated[bool, typer.Option(help="Use SSH URLs")] = False, + skip_claude: Annotated[bool, typer.Option(help="Skip Claude Code")] = False, + skip_serena: Annotated[bool, typer.Option(help="Skip Serena MCP")] = False, +) -> None: + """Initialize a new SmartEM workspace.""" + # Implementation +``` + +#### Exit Codes + +| Code | Meaning | +|------|---------| +| 0 | Success | +| 1 | General error (validation, network, etc.) | +| 2 | User cancelled | +| 3 | Configuration error | +| 4 | Git operation failed | + +### Configuration System (config/) + +#### Network-First Loading Strategy + +The configuration system prioritises fresh data from GitHub: + +**Why Network-First?** +- Users get latest repository metadata without updating the package +- New repositories can be added without releasing new version +- Offline fallback ensures reliability + +**Implementation (config/loader.py):** + +```python +import httpx +from pathlib import Path +import json + +GITHUB_RAW_URL = "https://raw.githubusercontent.com/DiamondLightSource/smartem-devtools/main/packages/smartem-workspace/smartem_workspace/config/repos.json" + +def load_config() -> RepoConfig: + """Load configuration with network-first strategy.""" + try: + config_dict = fetch_remote_config() + return RepoConfig.model_validate(config_dict) + except Exception as e: + logger.warning(f"Failed to fetch remote config: {e}. Using bundled config.") + return load_bundled_config() + +def fetch_remote_config() -> dict: + """Fetch configuration from GitHub.""" + response = httpx.get(GITHUB_RAW_URL, timeout=10.0, follow_redirects=True) + response.raise_for_status() + return response.json() + +def load_bundled_config() -> RepoConfig: + """Load bundled configuration as fallback.""" + bundled_path = Path(__file__).parent / "repos.json" + with bundled_path.open() as f: + config_dict = json.load(f) + return RepoConfig.model_validate(config_dict) +``` + +#### Pydantic Models (config/schema.py) + +All configuration is validated with Pydantic for type safety and data integrity. + +**Key Models:** + +```python +from pydantic import BaseModel, HttpUrl, Field +from typing import List, Dict, Literal + +class Repository(BaseModel): + """Repository metadata.""" + id: str = Field(..., description="Unique identifier") + org: str = Field(..., description="GitHub organisation") + name: str = Field(..., description="Repository name") + url: HttpUrl = Field(..., description="Clone URL (HTTPS)") + description: str = Field(..., description="Short description") + ownership: Literal["full", "reference", "mirror"] = Field(..., description="Ownership level") + deliverable: str = Field(..., description="Work package deliverable") + work_package: str | None = Field(None, description="ERIC work package") + +class ClaudeConfigItem(BaseModel): + """Claude Code configuration item (skill, agent, setting).""" + id: str = Field(..., description="Unique identifier") + type: Literal["skill", "agent", "setting"] = Field(..., description="Config type") + path: str = Field(..., description="Source path in repository") + target_path: str = Field(..., description="Target path in workspace") + +class Preset(BaseModel): + """Predefined repository collection.""" + name: str = Field(..., description="Display name") + description: str = Field(..., description="Short description") + repositories: List[str] = Field(..., description="Repository IDs") + +class RepoConfig(BaseModel): + """Root configuration model.""" + version: str = Field(..., description="Config schema version") + repositories: List[Repository] = Field(..., description="All repositories") + claude_config: List[ClaudeConfigItem] = Field(..., description="Claude Code items") + presets: Dict[str, Preset] = Field(..., description="Presets by name") +``` + +**Validation Benefits:** +- Type errors caught at config load time +- Auto-generated JSON schema for documentation +- Clear error messages for invalid config +- IDE autocomplete for config access + +#### Configuration File (repos.json) + +JSON file defining all repositories and presets: + +```json +{ + "version": "1.0", + "repositories": [ + { + "id": "smartem-decisions", + "org": "DiamondLightSource", + "name": "smartem-decisions", + "url": "https://github.com/DiamondLightSource/smartem-decisions", + "description": "Backend (FastAPI, PostgreSQL, RabbitMQ)", + "ownership": "full", + "deliverable": "SmartEM", + "work_package": "WP5" + } + ], + "claude_config": [ + { + "id": "database-admin", + "type": "skill", + "path": "claude-code/shared/skills/database-admin", + "target_path": ".claude/skills/database-admin" + } + ], + "presets": { + "smartem-core": { + "name": "SmartEM Core", + "description": "Core development repositories", + "repositories": ["smartem-devtools", "smartem-decisions", "smartem-frontend"] + } + } +} +``` + +### Setup Orchestration (setup/bootstrap.py) + +Coordinates all setup steps in the correct order. + +**Orchestration Flow:** + +```python +def bootstrap(config: BootstrapConfig) -> None: + """Main orchestration function.""" + # 1. Validate inputs + validate_configuration(config) + + # 2. Create workspace structure + create_workspace_structure(config.workspace_path) + + # 3. Clone repositories + if config.repositories: + clone_repositories(config.repositories, config.workspace_path, config.use_ssh) + + # 4. Setup Claude Code (unless skipped) + if not config.skip_claude: + setup_claude_config(config.workspace_path, config.claude_config) + + # 5. Setup Serena MCP (unless skipped) + if not config.skip_serena: + setup_serena(config.workspace_path) + + # 6. Create workspace files + create_workspace_files(config.workspace_path) + + # 7. Display completion summary + show_completion_summary(config) +``` + +**Error Recovery:** + +If a step fails: +1. Log detailed error with context +2. Attempt to clean up partial state (optional) +3. Provide actionable guidance for user +4. Exit with appropriate code + +**Design Consideration:** No automatic rollback. Partial setup is often useful for debugging. + +### Git Operations (setup/repos.py) + +Handles repository cloning with progress indicators. + +**Key Functions:** + +```python +def clone_repositories( + repos: List[Repository], + workspace_path: Path, + use_ssh: bool = False, +) -> None: + """Clone all repositories with progress tracking.""" + console = Console() + + with Progress() as progress: + task = progress.add_task(f"Cloning {len(repos)} repositories...", total=len(repos)) + + for repo in repos: + clone_single_repo(repo, workspace_path, use_ssh) + progress.update(task, advance=1) + +def clone_single_repo( + repo: Repository, + workspace_path: Path, + use_ssh: bool = False, +) -> None: + """Clone a single repository.""" + target_path = workspace_path / "repos" / repo.org / repo.name + + # Skip if already exists + if target_path.exists(): + logger.info(f"Skipping {repo.name} (already exists)") + return + + # Ensure parent directory exists + target_path.parent.mkdir(parents=True, exist_ok=True) + + # Determine clone URL + clone_url = get_clone_url(repo, use_ssh) + + # Run git clone + try: + subprocess.run( + ["git", "clone", clone_url, str(target_path)], + check=True, + capture_output=True, + text=True, + ) + logger.info(f"Cloned {repo.name} successfully") + except subprocess.CalledProcessError as e: + logger.error(f"Failed to clone {repo.name}: {e.stderr}") + raise +``` + +**Design Decisions:** +- **Skip existing**: Idempotent - safe to re-run +- **Create parents**: `mkdir -p` behaviour +- **Capture output**: Don't spam user with git output +- **Progress bar**: Rich progress indicator for better UX + +### Claude Code Setup (setup/claude.py) + +Creates `.claude/` directory with skills, settings, and permissions. + +**Setup Steps:** + +1. Create `.claude/` directory structure +2. Symlink skills from `claude-config/shared/skills/` +3. Copy settings and permissions JSON files +4. Create `CLAUDE.md` workspace overview + +**Symlink Creation:** + +Fixed in commit `2823f1e` to use absolute paths: + +```python +import os + +def create_skill_symlinks(workspace_path: Path, skills: List[ClaudeConfigItem]) -> None: + """Create symlinks for Claude Code skills.""" + skills_dir = workspace_path / ".claude" / "skills" + skills_dir.mkdir(parents=True, exist_ok=True) + + for skill in skills: + source = workspace_path / skill.path # Absolute path + target = workspace_path / skill.target_path # Absolute path + + # Use os.symlink with absolute paths (works on all platforms) + if not target.exists(): + os.symlink(str(source), str(target)) +``` + +**Why Symlinks?** +- Avoid duplication (skills are versioned in repositories) +- Easy updates (edit once, reflected everywhere) +- Mirrors workspace structure (clear relationship) + +### Interactive Prompts (interactive/prompts.py) + +Rich interactive menus for user input. + +**Preset Selection:** + +```python +from rich.prompt import Prompt +from rich.console import Console + +def select_preset(presets: Dict[str, Preset]) -> str | None: + """Interactive preset selection.""" + console = Console() + + console.print("\n[bold]Select a preset or choose custom:[/bold]\n") + + options = list(presets.keys()) + ["custom"] + for i, option in enumerate(options, 1): + if option == "custom": + console.print(f" {i}. [yellow]custom[/yellow] - Select repositories manually") + else: + preset = presets[option] + console.print(f" {i}. [cyan]{option}[/cyan] - {preset.description}") + + choice = Prompt.ask( + "\nChoice", + choices=[str(i) for i in range(1, len(options) + 1)], + default="2", # smartem-core + ) + + selected = options[int(choice) - 1] + return None if selected == "custom" else selected +``` + +**Repository Selection (Custom Mode):** + +Uses `rich.prompt.Confirm` for each repository group: + +```python +def select_repositories(config: RepoConfig) -> List[str]: + """Interactive repository selection.""" + selected = [] + + # Group by organisation + by_org = group_repositories_by_org(config.repositories) + + for org, repos in by_org.items(): + console.print(f"\n[bold]{org}[/bold]") + + for repo in repos: + include = Confirm.ask( + f" Include {repo.name}? ({repo.description})", + default=False, + ) + if include: + selected.append(repo.id) + + return selected +``` + +## Configuration System + +### repos.json Schema + +Full schema with all fields explained: + +```json +{ + "version": "1.0", + "repositories": [ + { + "id": "unique-identifier", + "org": "GitHubOrganisation", + "name": "repository-name", + "url": "https://github.com/Organisation/repository-name", + "description": "Short description for selection menu", + "ownership": "full | reference | mirror", + "deliverable": "SmartEM | ARIA | Devtools", + "work_package": "WP5 | WP4 | null" + } + ], + "claude_config": [ + { + "id": "config-item-id", + "type": "skill | agent | setting", + "path": "claude-code/path/to/source", + "target_path": ".claude/path/to/target" + } + ], + "presets": { + "preset-name": { + "name": "Display Name", + "description": "Description shown in selection", + "repositories": ["repo-id-1", "repo-id-2"] + } + } +} +``` + +### Field Definitions + +| Field | Type | Description | +|-------|------|-------------| +| `version` | string | Config schema version (for future migrations) | +| `repositories[].id` | string | Unique identifier (used in presets) | +| `repositories[].org` | string | GitHub organisation or GitLab group | +| `repositories[].name` | string | Repository name (matches GitHub) | +| `repositories[].url` | URL | HTTPS clone URL | +| `repositories[].description` | string | Shown in selection menu | +| `repositories[].ownership` | enum | `full` (can edit), `reference` (read-only), `mirror` (DLS mirror of external) | +| `repositories[].deliverable` | string | ERIC work package deliverable | +| `repositories[].work_package` | string | ERIC WP number (or null) | +| `claude_config[].id` | string | Unique identifier | +| `claude_config[].type` | enum | `skill`, `agent`, or `setting` | +| `claude_config[].path` | string | Source path in repository | +| `claude_config[].target_path` | string | Target path in workspace | +| `presets.*.name` | string | Display name for UI | +| `presets.*.description` | string | Short description | +| `presets.*.repositories` | array | Repository IDs to include | + +### Preset Definitions + +Current presets in repos.json: + +```json +{ + "presets": { + "minimal": { + "name": "Minimal", + "description": "Just smartem-devtools (13 MB, 1 repo)", + "repositories": ["smartem-devtools"] + }, + "smartem-core": { + "name": "SmartEM Core", + "description": "Core development repos (31 MB, 3 repos)", + "repositories": ["smartem-devtools", "smartem-decisions", "smartem-frontend"] + }, + "aria-reference": { + "name": "ARIA Reference", + "description": "ARIA ecosystem (100 MB, 20+ repos)", + "repositories": [ + "smartem-devtools", + "fandanGO-core", + "fandanGO-aria", + "fandanGO-cryoem-dls", + "data-deposition-api", + "aria-graphql-client" + ] + }, + "full": { + "name": "Full", + "description": "Complete ecosystem (150 MB, 30+ repos)", + "repositories": ["all-repository-ids"] + } + } +} +``` + +## Development Setup + +### Prerequisites + +- **Python 3.11+**: Check with `python --version` +- **uv**: Install with `curl -LsSf https://astral.sh/uv/install.sh | sh` +- **Git**: For version control + +### Local Development Environment + +```bash +# Clone the repository +git clone https://github.com/DiamondLightSource/smartem-devtools +cd smartem-devtools/packages/smartem-workspace + +# Create virtual environment and install dependencies +uv sync --all-extras + +# Activate virtual environment +source .venv/bin/activate # Linux/macOS +# or +.venv\Scripts\activate # Windows + +# Verify installation +uv run python -c "import smartem_workspace; print(smartem_workspace.__version__)" +``` + +### Running from Source + +```bash +# Run module directly +uv run python -m smartem_workspace init --help + +# Or use the CLI entry point +uv run smartem-workspace init --help + +# For development, add --reload for auto-restart on changes (if using FastAPI CLI pattern) +# (Not applicable for this CLI, but shows the pattern) +``` + +### Building Locally + +```bash +# Build wheel and source distribution +uv build + +# Output in dist/ +ls -lh dist/ +# smartem_workspace-0.1.0-py3-none-any.whl +# smartem_workspace-0.1.0.tar.gz + +# Inspect wheel contents +unzip -l dist/smartem_workspace-0.1.0-py3-none-any.whl +``` + +### Testing Local Build + +```bash +# Test with uvx from local wheel +uvx --from ./dist/smartem_workspace-0.1.0-py3-none-any.whl smartem-workspace init --help + +# Test installation in isolated environment +cd /tmp +python -m venv test-env +source test-env/bin/activate +pip install /path/to/smartem-devtools/packages/smartem-workspace/dist/smartem_workspace-0.1.0-py3-none-any.whl +smartem-workspace --version +deactivate +rm -rf test-env +``` + +### Code Quality Tools + +```bash +# Run linter +uv run ruff check . + +# Auto-fix linting issues +uv run ruff check --fix . + +# Check formatting +uv run ruff format --check . + +# Auto-format code +uv run ruff format . + +# Run all checks (add to pre-commit) +uv run ruff check . && uv run ruff format --check . +``` + +### Development Workflow + +1. **Create feature branch** + ```bash + git checkout -b feature/my-new-feature + ``` + +2. **Make changes with tests** + ```bash + # Edit code + vim smartem_workspace/cli.py + + # Add test + vim tests/test_cli.py + + # Run tests + uv run pytest -v + ``` + +3. **Check code quality** + ```bash + uv run ruff check . + uv run ruff format . + ``` + +4. **Build and test locally** + ```bash + uv build + uvx --from ./dist/smartem_workspace-0.1.0-py3-none-any.whl smartem-workspace init --preset minimal + ``` + +5. **Commit with conventional commit message** + ```bash + git add . + git commit -m "feat: add support for custom preset files" + ``` + +6. **Push and create PR** + ```bash + git push origin feature/my-new-feature + gh pr create --title "Add custom preset file support" --body "..." + ``` + +## Testing Strategy + +### Unit Tests + +Located in `tests/`, run with pytest: + +```bash +# Run all tests +uv run pytest + +# Run with coverage +uv run pytest --cov=smartem_workspace --cov-report=html + +# Run specific test file +uv run pytest tests/test_config.py + +# Run specific test +uv run pytest tests/test_config.py::test_load_bundled_config + +# Verbose output +uv run pytest -v + +# Very verbose (show print statements) +uv run pytest -vv -s +``` + +### Test Structure + +```python +# tests/test_config.py + +import pytest +from smartem_workspace.config import load_config, load_bundled_config +from smartem_workspace.config.schema import RepoConfig + +def test_load_bundled_config(): + """Test bundled configuration loads correctly.""" + config = load_bundled_config() + + assert isinstance(config, RepoConfig) + assert config.version == "1.0" + assert len(config.repositories) > 0 + assert "smartem-devtools" in [r.id for r in config.repositories] + +def test_preset_validation(): + """Test preset contains valid repository IDs.""" + config = load_bundled_config() + + repo_ids = {r.id for r in config.repositories} + + for preset_name, preset in config.presets.items(): + for repo_id in preset.repositories: + assert repo_id in repo_ids, f"Preset '{preset_name}' references unknown repo '{repo_id}'" + +@pytest.fixture +def temp_workspace(tmp_path): + """Fixture providing temporary workspace directory.""" + workspace = tmp_path / "test-workspace" + workspace.mkdir() + return workspace + +def test_workspace_creation(temp_workspace): + """Test workspace directory structure creation.""" + from smartem_workspace.setup.workspace import create_workspace_structure + + create_workspace_structure(temp_workspace) + + assert (temp_workspace / "repos").is_dir() + assert (temp_workspace / "claude-config").is_dir() + assert (temp_workspace / "tmp").is_dir() + assert (temp_workspace / "testdata").is_dir() +``` + +### Integration Tests + +Manual integration tests (documented, not automated): + +**Test 1: Minimal Preset** +```bash +cd /tmp/test-minimal +uvx smartem-workspace init --preset minimal --no-interactive +# Verify: 1 repo cloned, ~13 MB, Claude Code setup +``` + +**Test 2: smartem-core Preset** +```bash +cd /tmp/test-core +uvx smartem-workspace init --preset smartem-core --no-interactive +# Verify: 3 repos cloned, ~31 MB, skills symlinked +``` + +**Test 3: SSH URLs** +```bash +cd /tmp/test-ssh +uvx smartem-workspace init --preset minimal --ssh --no-interactive +# Verify: Repos cloned via SSH (git@github.com:...) +``` + +**Test 4: Error Recovery** +```bash +cd /tmp/test-error +# Create directory first to test error handling +mkdir -p repos/DiamondLightSource/smartem-devtools +uvx smartem-workspace init --preset minimal --no-interactive +# Verify: Skips existing repo, continues with others +``` + +### Test Coverage + +Current coverage (as of v0.1.0): + +| Module | Coverage | Notes | +|--------|----------|-------| +| `cli.py` | Manual | Tested via integration tests | +| `config/loader.py` | 85% | Network fetching not mocked | +| `config/schema.py` | 100% | Pydantic validation tests | +| `setup/bootstrap.py` | Manual | Orchestration tested end-to-end | +| `setup/repos.py` | Manual | Git operations tested manually | +| `setup/claude.py` | Manual | Symlink creation tested manually | +| `utils/git.py` | 70% | Helper functions unit tested | +| `utils/paths.py` | 90% | Path utilities unit tested | + +**Goal**: Achieve 80%+ coverage with automated integration tests in CI. + +### Adding New Tests + +1. **Create test file**: `tests/test_.py` +2. **Import module**: `from smartem_workspace. import ` +3. **Write test**: Use `pytest` conventions, descriptive names +4. **Use fixtures**: For temporary directories, config mocking, etc. +5. **Run test**: `uv run pytest tests/test_.py -v` +6. **Check coverage**: `uv run pytest --cov=smartem_workspace` + +## CI/CD Pipeline + +### GitHub Actions Workflow + +File: `.github/workflows/publish-smartem-workspace.yml` + +Automated testing, building, version bumping, and publishing to PyPI. + +### Workflow Jobs + +#### Job 1: Test + +Runs unit tests with coverage reporting: + +```yaml +test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: astral-sh/setup-uv@v5 + - run: uv python install 3.11 + - run: uv sync --all-extras + - run: uv run pytest -v --cov=smartem_workspace --cov-report=xml + - uses: codecov/codecov-action@v5 +``` + +#### Job 2: Lint + +Checks code quality: + +```yaml +lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: astral-sh/setup-uv@v5 + - run: uv python install 3.11 + - run: uv sync --all-extras + - run: uv run ruff check . + - run: uv run ruff format --check . +``` + +#### Job 3: Build + +Creates wheel and sdist: + +```yaml +build: + runs-on: ubuntu-latest + needs: [test, lint] + steps: + - uses: actions/checkout@v6 + - uses: astral-sh/setup-uv@v5 + - run: uv build + - uses: actions/upload-artifact@v4 + with: + name: dist + path: packages/smartem-workspace/dist/* +``` + +#### Job 4: Version Bump + +Automated versioning with commitizen (main branch only): + +```yaml +version-bump: + runs-on: ubuntu-latest + needs: [test, lint] + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 # Full history for commitizen + - uses: astral-sh/setup-uv@v5 + - run: uv pip install commitizen + - run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + - run: cz bump --yes || echo "No version bump needed" + - run: git push --follow-tags +``` + +#### Job 5: Publish to TestPyPI + +Continuous deployment to TestPyPI (main branch): + +```yaml +publish-testpypi: + runs-on: ubuntu-latest + needs: [build, version-bump] + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + environment: + name: testpypi + url: https://test.pypi.org/p/smartem-workspace + steps: + - uses: actions/download-artifact@v4 + - run: uv pip install twine + - run: twine upload --repository testpypi dist/* + env: + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ secrets.TEST_PYPI_API_TOKEN }} +``` + +#### Job 6: Publish to PyPI + +Production deployment (release tags only): + +```yaml +publish-pypi: + runs-on: ubuntu-latest + needs: [build] + if: github.event_name == 'release' && startsWith(github.ref, 'refs/tags/smartem-workspace-v') + environment: + name: pypi + url: https://pypi.org/p/smartem-workspace + steps: + - uses: actions/download-artifact@v4 + - run: uv pip install twine + - run: twine upload dist/* + env: + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} + - run: | + sleep 60 # Wait for PyPI propagation + uvx smartem-workspace --version +``` + +### Triggers + +| Event | Condition | Jobs Run | +|-------|-----------|----------| +| **Pull Request** | `packages/smartem-workspace/**` changes | test, lint, build | +| **Push to main** | `packages/smartem-workspace/**` changes | test, lint, build, version-bump, publish-testpypi | +| **Release tag** | Tag: `smartem-workspace-v*` | test, lint, build, publish-pypi | +| **Manual** | workflow_dispatch | test, lint, build | + +### Change Detection + +Workflow only runs when smartem-workspace package changes: + +```yaml +on: + push: + branches: [main] + paths: + - 'packages/smartem-workspace/**' + - '.github/workflows/publish-smartem-workspace.yml' +``` + +This prevents unnecessary runs when other parts of the monorepo change. + +### Version Bumping Strategy + +Uses conventional commits with commitizen: + +| Commit Prefix | Version Bump | Example | +|---------------|--------------|---------| +| `feat:` | Minor | 0.1.0 → 0.2.0 | +| `fix:` | Patch | 0.1.0 → 0.1.1 | +| `BREAKING CHANGE:` | Major | 0.1.0 → 1.0.0 | +| `docs:`, `chore:`, etc. | None | 0.1.0 → 0.1.0 | + +**Example commit messages:** + +```bash +# Patch bump (0.1.0 → 0.1.1) +git commit -m "fix: resolve symlink creation on Windows" + +# Minor bump (0.1.0 → 0.2.0) +git commit -m "feat: add support for custom preset files" + +# Major bump (0.1.0 → 1.0.0) +git commit -m "feat: redesign configuration system + +BREAKING CHANGE: repos.json schema changed, old configs incompatible" +``` + +### Environments + +GitHub Environments configured for publishing: + +**testpypi** +- URL: https://test.pypi.org/p/smartem-workspace +- Secret: `TEST_PYPI_API_TOKEN` +- Protection: None (auto-deploy on main) + +**pypi** +- URL: https://pypi.org/p/smartem-workspace +- Secret: `PYPI_API_TOKEN` +- Protection: Require manual approval (optional) + +## Contributing Guidelines + +### Code Standards + +- **Line length**: 120 characters (configured in ruff) +- **Type hints**: Required for all function signatures +- **Docstrings**: Google style for public functions +- **No emojis**: Windows compatibility (avoid Unicode in code/comments) +- **British English**: For documentation (e.g., "organise", not "organize") + +### Commit Message Format + +Follow [Conventional Commits](https://www.conventionalcommits.org/): + +``` +: + +[optional body] + +[optional footer] +``` + +**Types:** +- `feat`: New feature +- `fix`: Bug fix +- `docs`: Documentation changes +- `chore`: Maintenance tasks +- `test`: Test additions/changes +- `refactor`: Code restructuring +- `ci`: CI/CD changes + +**Examples:** + +```bash +feat: add support for shallow clones + +Add --shallow flag to init command for faster cloning of large repositories. + +Closes #42 +``` + +```bash +fix: resolve symlink creation on Windows + +Use os.symlink() with absolute paths instead of Path.symlink_to() which +creates relative symlinks that resolve incorrectly on Windows. + +Fixes #38 +``` + +### PR Workflow + +1. **Fork or branch**: Create feature branch from `main` +2. **Implement changes**: Code + tests + documentation +3. **Run checks**: `uv run pytest && uv run ruff check .` +4. **Commit**: Use conventional commit format +5. **Push**: `git push origin feature/my-feature` +6. **Create PR**: Use GitHub PR template +7. **Address feedback**: Respond to review comments +8. **Merge**: Squash and merge to main + +### Code Review Checklist + +- [ ] Tests added for new functionality +- [ ] Documentation updated (README, user guide, or developer guide) +- [ ] Code follows style guide (ruff passes) +- [ ] Type hints on all functions +- [ ] No emojis in code +- [ ] Commit messages follow conventional format +- [ ] PR description explains the why, not just the what + +### Release Process + +1. **Merge PRs to main**: CI auto-bumps version based on commits +2. **Verify TestPyPI**: Check https://test.pypi.org/project/smartem-workspace/ +3. **Create release**: Tag format: `smartem-workspace-v` + ```bash + git tag smartem-workspace-v0.2.0 + git push origin smartem-workspace-v0.2.0 + ``` +4. **CI publishes to PyPI**: Automatic on tag push +5. **Verify production**: `uvx smartem-workspace@0.2.0 --version` +6. **Create GitHub Release**: Add release notes + +## Troubleshooting Development + +### Import Errors + +**Error:** `ModuleNotFoundError: No module named 'smartem_workspace'` + +**Solutions:** +```bash +# Ensure virtual environment is activated +source .venv/bin/activate + +# Re-sync dependencies +uv sync --all-extras + +# Check installed packages +uv pip list | grep smartem +``` + +### Test Failures + +**Error:** Tests fail locally but pass in CI + +**Common causes:** +- Python version mismatch (CI uses 3.11) +- Missing dependencies (run `uv sync --all-extras`) +- Environment-specific paths + +**Debug:** +```bash +# Run with verbose output +uv run pytest -vv -s + +# Run specific failing test +uv run pytest tests/test_config.py::test_load_bundled_config -vv + +# Check Python version +python --version + +# Recreate venv from scratch +rm -rf .venv +uv sync --all-extras +``` + +### Build Issues + +**Error:** `ModuleNotFoundError` when testing wheel + +**Cause:** Missing files in wheel (not included by hatchling) + +**Solution:** +```bash +# Inspect wheel contents +unzip -l dist/smartem_workspace-0.1.0-py3-none-any.whl + +# Check pyproject.toml [tool.hatch.build.targets.wheel] +# Ensure all necessary files are included +``` + +**Error:** Wheel includes unwanted files + +**Solution:** +```bash +# Add to .gitignore and rebuild +echo "unwanted_dir/" >> .gitignore +rm -rf dist/ +uv build +``` + +### CI/CD Issues + +**Error:** `TWINE_PASSWORD` secret not found + +**Cause:** GitHub Secret not configured or workflow doesn't have access + +**Solution:** +1. Go to repository settings → Secrets and variables → Actions +2. Add `PYPI_API_TOKEN` and `TEST_PYPI_API_TOKEN` +3. Ensure environment names match (pypi, testpypi) + +**Error:** Version bump fails + +**Cause:** No conventional commits since last tag, or git config issues + +**Solution:** +```bash +# Check recent commits +git log --oneline -5 + +# Ensure at least one feat/fix commit exists +git commit --allow-empty -m "feat: trigger version bump" + +# Check git config +git config user.name +git config user.email +``` + +## API Reference + +### CLI Commands + +#### init + +```python +def init( + path: Path = Path.cwd(), + preset: str | None = None, + no_interactive: bool = False, + ssh: bool = False, + skip_claude: bool = False, + skip_serena: bool = False, +) -> None: + """Initialize a new SmartEM workspace. + + Args: + path: Target directory for workspace + preset: Preset name (minimal, smartem-core, aria-reference, full) + no_interactive: Skip all prompts (requires preset) + ssh: Use SSH URLs instead of HTTPS + skip_claude: Skip Claude Code configuration + skip_serena: Skip Serena MCP setup + + Raises: + typer.Exit: On validation error or user cancellation + """ +``` + +#### sync + +```python +def sync(path: Path = Path.cwd()) -> None: + """Sync all repositories in workspace (git pull). + + Args: + path: Workspace root directory + + Raises: + typer.Exit: On git operation failure + """ +``` + +#### status + +```python +def status(path: Path = Path.cwd()) -> None: + """Show git status for all repositories. + + Args: + path: Workspace root directory + + Displays: + - Current branch + - Uncommitted changes + - Commits ahead/behind remote + """ +``` + +#### add + +```python +def add( + repository: str, + path: Path = Path.cwd(), + ssh: bool = False, +) -> None: + """Add a single repository to existing workspace. + + Args: + repository: Repository in format "org/name" + path: Workspace root directory + ssh: Use SSH URL instead of HTTPS + + Raises: + typer.Exit: On invalid repository or clone failure + """ +``` + +### Configuration API + +#### load_config + +```python +def load_config() -> RepoConfig: + """Load configuration with network-first strategy. + + Returns: + RepoConfig: Validated configuration + + Raises: + ValidationError: If config schema is invalid + """ +``` + +#### load_bundled_config + +```python +def load_bundled_config() -> RepoConfig: + """Load bundled configuration from package. + + Returns: + RepoConfig: Validated bundled configuration + + Raises: + FileNotFoundError: If repos.json not in package + ValidationError: If config schema is invalid + """ +``` + +### Setup API + +#### bootstrap + +```python +def bootstrap(config: BootstrapConfig) -> None: + """Main orchestration function for workspace setup. + + Args: + config: Bootstrap configuration + + Raises: + SetupError: On critical setup failure + """ +``` + +#### clone_repositories + +```python +def clone_repositories( + repos: List[Repository], + workspace_path: Path, + use_ssh: bool = False, +) -> None: + """Clone all repositories with progress tracking. + + Args: + repos: List of repositories to clone + workspace_path: Workspace root directory + use_ssh: Use SSH URLs instead of HTTPS + + Raises: + GitError: On clone failure + """ +``` + +## Extending the Tool + +### Adding New Presets + +Edit `smartem_workspace/config/repos.json`: + +```json +{ + "presets": { + "my-preset": { + "name": "My Custom Preset", + "description": "Repositories for my use case", + "repositories": ["smartem-decisions", "smartem-frontend"] + } + } +} +``` + +Changes take effect immediately (network-first loading). + +### Adding New Repositories + +Add to `repositories` array in repos.json: + +```json +{ + "repositories": [ + { + "id": "new-repo", + "org": "DiamondLightSource", + "name": "new-repo", + "url": "https://github.com/DiamondLightSource/new-repo", + "description": "Short description", + "ownership": "full", + "deliverable": "SmartEM", + "work_package": "WP5" + } + ] +} +``` + +### Adding New CLI Commands + +Add to `smartem_workspace/cli.py`: + +```python +@app.command() +def my_command( + arg: Annotated[str, typer.Argument(help="My argument")], + option: Annotated[bool, typer.Option(help="My option")] = False, +) -> None: + """Description of my command.""" + # Implementation + console = Console() + console.print(f"Running my command with {arg}") +``` + +### Adding New Setup Steps + +1. Create module: `smartem_workspace/setup/my_step.py` +2. Implement setup function: + ```python + def setup_my_feature(workspace_path: Path) -> None: + """Setup my feature.""" + # Implementation + ``` +3. Add to bootstrap flow in `setup/bootstrap.py`: + ```python + def bootstrap(config: BootstrapConfig) -> None: + # ... existing steps + + if not config.skip_my_feature: + setup_my_feature(config.workspace_path) + ``` +4. Add CLI option in `cli.py`: + ```python + skip_my_feature: Annotated[bool, typer.Option(...)] = False + ``` +5. Add tests in `tests/test_my_step.py` + +### Plugin System (Future) + +Planned architecture for extensible setup steps: + +```python +# smartem_workspace/plugins/base.py +class SetupPlugin: + """Base class for setup plugins.""" + + def __init__(self, workspace_path: Path): + self.workspace_path = workspace_path + + def setup(self) -> None: + """Run setup.""" + raise NotImplementedError + + def teardown(self) -> None: + """Cleanup setup.""" + pass + +# Example plugin +class MyPlugin(SetupPlugin): + def setup(self) -> None: + # Custom setup logic + pass +``` + +Register plugins in config: + +```json +{ + "plugins": [ + { + "name": "my-plugin", + "module": "my_plugin.setup", + "class": "MySetupPlugin", + "enabled": true + } + ] +} +``` + +--- + +## Summary + +This developer guide covers: + +- ✅ Architecture and design philosophy +- ✅ Package structure and module responsibilities +- ✅ Core components deep dive +- ✅ Configuration system +- ✅ Development setup and workflow +- ✅ Testing strategy +- ✅ CI/CD pipeline +- ✅ Contributing guidelines +- ✅ API reference +- ✅ Extension points + +For user-facing documentation, see the [Setup SmartEM Workspace](../../docs/how-to/setup-smartem-workspace.md) guide. + +For PyPI token setup, see [Publishing to PyPI](../how-to/publish-smartem-workspace-to-pypi.md). + +For questions or contributions, open an issue at https://github.com/DiamondLightSource/smartem-devtools/issues. diff --git a/docs/how-to/publish-smartem-workspace-to-pypi.md b/docs/how-to/publish-smartem-workspace-to-pypi.md new file mode 100644 index 0000000..59b0d8b --- /dev/null +++ b/docs/how-to/publish-smartem-workspace-to-pypi.md @@ -0,0 +1,810 @@ +# PyPI Token Setup for CI/CD + +This guide explains how to set up PyPI and TestPyPI accounts and tokens for automated package publishing via GitHub Actions. + +## Table of Contents + +- [Overview](#overview) +- [Prerequisites](#prerequisites) +- [Step 1: Create PyPI Accounts](#step-1-create-pypi-accounts) +- [Step 2: Generate API Tokens](#step-2-generate-api-tokens) +- [Step 3: Configure GitHub Secrets](#step-3-configure-github-secrets) +- [Step 4: First Manual Publish](#step-4-first-manual-publish-recommended) +- [Step 5: Update Token Scope](#step-5-update-token-scope-security-best-practice) +- [Token Security Best Practices](#token-security-best-practices) +- [Troubleshooting](#troubleshooting) +- [CI/CD Workflow Reference](#cicd-workflow-reference) +- [Monitoring & Maintenance](#monitoring--maintenance) +- [Emergency Procedures](#emergency-procedures) + +## Overview + +`smartem-workspace` uses GitHub Actions to automatically publish packages to PyPI and TestPyPI. This requires: + +1. **PyPI account** - For production releases +2. **TestPyPI account** - For testing releases +3. **API tokens** - For authentication from GitHub Actions +4. **GitHub Secrets** - Secure storage of tokens + +This guide walks through the complete setup process. + +## Prerequisites + +Before starting, ensure you have: + +- **GitHub organisation admin access** - To add repository secrets +- **Team email access** - For PyPI account registration (e.g., smartem@diamond.ac.uk) +- **2FA app** - PyPI requires two-factor authentication (e.g., Google Authenticator, Authy) + +## Step 1: Create PyPI Accounts + +### Production PyPI Account + +1. Visit https://pypi.org/account/register/ + +2. Fill in registration form: + - **Username**: Choose organisational username (e.g., `diamondlightsource` or `smartem-team`) + - **Email**: Use team email (smartem@diamond.ac.uk) + - **Password**: Strong password (store in team password manager) + +3. Verify email address: + - Check team inbox for verification email + - Click verification link + +4. **Enable 2FA (Required for Publishing)**: + - Go to Account Settings → Two-factor authentication + - Scan QR code with authenticator app + - Enter 6-digit code to verify + - **Save recovery codes** in secure location (password manager) + +### TestPyPI Account (Staging Environment) + +1. Visit https://test.pypi.org/account/register/ + +2. Repeat registration process: + - Use **same email** as production account + - Use **different password** (or same via password manager) + - **Important**: TestPyPI is a separate system - account doesn't sync with production + +3. Verify email (separate verification email) + +4. Enable 2FA (same process as production) + +**Why TestPyPI?** +- Test package publishing before production +- Verify metadata, dependencies, installation +- Safe environment for CI/CD testing + +## Step 2: Generate API Tokens + +### Production PyPI Token + +1. **Log in to PyPI**: https://pypi.org + +2. **Navigate to API tokens**: + - Click username (top right) → Account Settings + - Scroll to "API tokens" section + - Click "Add API token" + +3. **Create token**: + - **Token name**: `GitHub Actions - smartem-devtools` + - **Scope**: + - For **first publish**: Select "Entire account (all projects)" + - After first publish: Select "Project: smartem-workspace" (more secure - see Step 5) + +4. **Copy token immediately**: + ``` + pypi-AgEIcHlwaS5vcmc... + ``` + - **Warning**: Token shown only once. If you lose it, generate a new one. + - **Store securely**: Paste into password manager or directly into GitHub Secrets + +### TestPyPI Token + +1. **Log in to TestPyPI**: https://test.pypi.org + +2. **Navigate to API tokens**: Same process as production + +3. **Create token**: + - **Token name**: `GitHub Actions - smartem-devtools (Test)` + - **Scope**: "Entire account (all projects)" (TestPyPI can stay account-scoped) + +4. **Copy token**: + ``` + pypi-AgENdGVzdC5weXBpLm9yZw... + ``` + - Store separately from production token + +**Security Note**: Never commit tokens to git or share via email/chat. + +## Step 3: Configure GitHub Secrets + +### Add Secrets to Repository + +1. **Navigate to repository secrets**: + - Go to https://github.com/DiamondLightSource/smartem-devtools/settings/secrets/actions + - Requires admin access + +2. **Add production token**: + - Click "New repository secret" + - **Name**: `PYPI_API_TOKEN` (exact name - workflow expects this) + - **Secret**: Paste production token (`pypi-AgEIcHlwaS5vcmc...`) + - Click "Add secret" + +3. **Add test token**: + - Click "New repository secret" + - **Name**: `TEST_PYPI_API_TOKEN` + - **Secret**: Paste TestPyPI token (`pypi-AgENdGVzdC5weXBpLm9yZw...`) + - Click "Add secret" + +### Verify Secrets + +Secrets should now appear in repository settings: + +``` +Repository secrets + PYPI_API_TOKEN Updated 5 minutes ago + TEST_PYPI_API_TOKEN Updated 5 minutes ago +``` + +**Security**: Secrets are encrypted. Even admins can't view values after creation. + +## Step 4: First Manual Publish (Recommended) + +### Why Manual First Publish? + +1. **Establishes package on PyPI** - Package name reserved +2. **Allows project-scoped token** - More secure than account-scoped +3. **Verifies metadata** - Catch issues before automation +4. **Tests process** - Ensures build and upload work + +### Manual Publish Steps + +#### Build Package + +```bash +cd smartem-devtools/packages/smartem-workspace + +# Build wheel and source distribution +uv build + +# Verify build artifacts +ls -lh dist/ +# smartem_workspace-0.1.0-py3-none-any.whl (20 KB) +# smartem_workspace-0.1.0.tar.gz (48 KB) +``` + +#### Install Twine + +```bash +# In package directory +uv pip install twine +``` + +Twine is the official PyPI upload tool. + +#### Upload to TestPyPI First + +Always test with TestPyPI before production: + +```bash +# Upload to TestPyPI +twine upload --repository testpypi dist/* + +# Prompts: +# Username: __token__ +# Password: + +# Output: +Uploading distributions to https://test.pypi.org/legacy/ +Uploading smartem_workspace-0.1.0-py3-none-any.whl +100% ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 20.0/20.0 kB • 00:00 +Uploading smartem_workspace-0.1.0.tar.gz +100% ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 48.0/48.0 kB • 00:00 + +View at: +https://test.pypi.org/project/smartem-workspace/0.1.0/ +``` + +#### Verify TestPyPI Installation + +Test installation from TestPyPI: + +```bash +# Install in isolated environment +uvx --index-url https://test.pypi.org/simple/ smartem-workspace --version + +# Or create test environment +cd /tmp +python -m venv test-env +source test-env/bin/activate +pip install --index-url https://test.pypi.org/simple/ smartem-workspace +smartem-workspace --version +deactivate +rm -rf test-env +``` + +**If this fails**, fix issues before proceeding to production PyPI. + +#### Upload to Production PyPI + +Once TestPyPI installation succeeds: + +```bash +# Upload to production PyPI +twine upload dist/* + +# Prompts: +# Username: __token__ +# Password: + +# Output: +Uploading distributions to https://upload.pypi.org/legacy/ +Uploading smartem_workspace-0.1.0-py3-none-any.whl +100% ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 20.0/20.0 kB • 00:00 +Uploading smartem_workspace-0.1.0.tar.gz +100% ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 48.0/48.0 kB • 00:00 + +View at: +https://pypi.org/project/smartem-workspace/0.1.0/ +``` + +#### Verify Production Installation + +Test installation from production PyPI: + +```bash +# Wait 60 seconds for PyPI to propagate +sleep 60 + +# Test installation +uvx smartem-workspace --version +# smartem-workspace 0.1.0 + +# Test functionality +cd /tmp/test-workspace +uvx smartem-workspace init --preset minimal --no-interactive +``` + +**Success!** Package is now live on PyPI. + +## Step 5: Update Token Scope (Security Best Practice) + +After first publish, restrict token to project-only access. + +### Why Project-Scoped Tokens? + +- **Least privilege principle** - Token can only publish to smartem-workspace +- **Reduced blast radius** - If token leaks, attacker can't publish to other projects +- **Recommended by PyPI** - Best practice for production tokens + +### Update Production Token + +1. **Delete account-scoped token**: + - Go to https://pypi.org → Account Settings → API tokens + - Find `GitHub Actions - smartem-devtools` + - Click "Remove" → Confirm + +2. **Create new project-scoped token**: + - Click "Add API token" + - **Token name**: `GitHub Actions - smartem-devtools` + - **Scope**: Select "Project: smartem-workspace" + - **Copy new token** + +3. **Update GitHub Secret**: + - Go to repository secrets + - Click "PYPI_API_TOKEN" → "Update" + - Paste new project-scoped token + - Click "Update secret" + +### Verify New Token + +Test that new token works: + +```bash +# Bump version (if needed) +# Edit pyproject.toml: version = "0.1.1" + +# Rebuild +uv build + +# Upload with new token +twine upload dist/* +# Should succeed with project-scoped token +``` + +**TestPyPI token** can remain account-scoped (it's a test environment). + +## Token Security Best Practices + +### Do's ✅ + +1. **Use project-scoped tokens** when possible +2. **Enable 2FA** on PyPI accounts +3. **Rotate tokens periodically** (every 6-12 months) +4. **Use separate tokens** for test vs production +5. **Document token creation date** (in password manager or team wiki) +6. **Store tokens in GitHub Secrets only** (never in code) +7. **Use environment protection** for production deployments +8. **Monitor PyPI project** for unexpected releases + +### Don'ts ❌ + +1. **Never commit tokens to git** (even in `.env` files) +2. **Don't share tokens via email/chat** (use secure password manager) +3. **Don't use account-scoped tokens long-term** (switch to project-scoped after first publish) +4. **Don't reuse tokens across projects** (each project gets its own token) +5. **Don't disable 2FA** (required for publishing) +6. **Don't store tokens unencrypted** (use password manager) + +### Token Rotation Schedule + +| Token Type | Rotation Frequency | Trigger | +|------------|-------------------|---------| +| Production PyPI | Annually | Calendar reminder | +| TestPyPI | As needed | No strict schedule | +| After leak | Immediately | Security incident | + +## Troubleshooting + +### Common Errors + +#### "Invalid credentials" + +**Error:** +``` +HTTP Error 403: Invalid or non-existent authentication information. +``` + +**Causes:** +- Token doesn't start with `pypi-` +- Token has expired (PyPI tokens don't expire, but can be revoked) +- 2FA not enabled on account +- Wrong PyPI instance (using production token on TestPyPI or vice versa) + +**Solutions:** +1. Verify token format: `pypi-AgEIcHlwaS5vcmc...` +2. Check 2FA is enabled: https://pypi.org/manage/account/ +3. Verify token scope allows publishing +4. Regenerate token if needed + +#### "Package already exists" + +**Error:** +``` +HTTP Error 400: File already exists. +``` + +**Cause:** Trying to upload same version twice (PyPI doesn't allow overwrites) + +**Solutions:** + +1. **Bump version** in `pyproject.toml`: + ```toml + version = "0.1.1" # was 0.1.0 + ``` + +2. **Rebuild**: + ```bash + rm -rf dist/ + uv build + ``` + +3. **Upload new version**: + ```bash + twine upload dist/* + ``` + +**Note**: CI handles version bumping automatically with commitizen. + +#### "Insufficient permissions" + +**Error:** +``` +HTTP Error 403: The credential associated with user '' is not allowed to upload to project 'smartem-workspace'. +``` + +**Causes:** +- Token scope too narrow (doesn't include this project) +- Token revoked +- Account no longer has project permissions + +**Solutions:** + +1. **Verify token scope**: + - Go to https://pypi.org → Account Settings → API tokens + - Check scope includes "Project: smartem-workspace" + +2. **Regenerate token with correct scope**: + - Delete existing token + - Create new token with "Project: smartem-workspace" scope + - Update GitHub Secret + +#### GitHub Actions: "Secret not found" + +**Error:** +``` +Error: Input required and not supplied: PYPI_API_TOKEN +``` + +**Causes:** +- Secret name mismatch (case-sensitive) +- Secret not added to repository +- Workflow running on forked repository (secrets don't sync to forks) + +**Solutions:** + +1. **Verify secret name** in workflow file: + ```yaml + TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} # Exact name + ``` + +2. **Check secret exists**: + - Go to repository settings → Secrets + - Verify `PYPI_API_TOKEN` is listed + +3. **Add secret** if missing (see Step 3) + +#### GitHub Actions: Version bump fails + +**Error:** +``` +No version bump needed +``` + +**Cause:** No conventional commits since last tag, or all commits are `chore`/`docs` (don't trigger bumps) + +**Solutions:** + +1. **Check recent commits**: + ```bash + git log --oneline -10 + ``` + +2. **Ensure at least one bumpable commit**: + - `feat:` → minor bump + - `fix:` → patch bump + - `BREAKING CHANGE:` → major bump + +3. **Manually trigger bump** (if needed): + ```bash + git commit --allow-empty -m "feat: trigger version bump for release" + git push + ``` + +### Network Issues + +#### Timeout during upload + +**Error:** +``` +ConnectionError: Failed to upload after 3 attempts +``` + +**Solutions:** +1. Check internet connection +2. Retry upload +3. Check PyPI status: https://status.python.org/ +4. Try from different network + +#### Slow upload + +**Symptom:** Upload takes very long + +**Solutions:** +1. Check file sizes: `ls -lh dist/` +2. Ensure only necessary files in wheel +3. Use wired connection instead of Wi-Fi + +## CI/CD Workflow Reference + +### Environment Variables + +The GitHub Actions workflow uses these secrets: + +```yaml +env: + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} # Production + # or + TWINE_PASSWORD: ${{ secrets.TEST_PYPI_API_TOKEN }} # TestPyPI +``` + +### Workflow Triggers + +| Event | Target | Token Used | +|-------|--------|------------| +| **Push to main** (with package changes) | TestPyPI | `TEST_PYPI_API_TOKEN` | +| **Release tag** (`smartem-workspace-v*`) | PyPI | `PYPI_API_TOKEN` | +| **Pull request** | No publish | N/A | +| **Manual** (workflow_dispatch) | No publish | N/A | + +### Publishing Flow + +#### TestPyPI (Continuous Deployment) + +Triggered on every push to `main` with package changes: + +``` +git push origin main + ↓ +GitHub Actions runs + ↓ +Test + Lint + Build jobs + ↓ +Version bump (if feat/fix commits) + ↓ +Upload to TestPyPI + ↓ +Verify: https://test.pypi.org/project/smartem-workspace/ +``` + +#### PyPI (Release Deployment) + +Triggered on release tag: + +``` +git tag smartem-workspace-v0.2.0 +git push origin smartem-workspace-v0.2.0 + ↓ +GitHub Actions runs + ↓ +Test + Lint + Build jobs + ↓ +Upload to PyPI + ↓ +Verify installation: uvx smartem-workspace@0.2.0 + ↓ +Create GitHub Release (manual) +``` + +### Environments + +Configure GitHub Environments for additional protection: + +**testpypi** (Optional) +- URL: https://test.pypi.org/p/smartem-workspace +- Reviewers: None (auto-deploy) +- Secrets: Inherits `TEST_PYPI_API_TOKEN` + +**pypi** (Recommended) +- URL: https://pypi.org/p/smartem-workspace +- Reviewers: Maintainers (require approval before publish) +- Secrets: Inherits `PYPI_API_TOKEN` + +To configure: +1. Go to repository settings → Environments +2. Click "New environment" +3. Name: `pypi` +4. Add protection rules (optional): + - Required reviewers: @maintainer1, @maintainer2 + - Wait timer: 0 minutes +5. Save + +## Monitoring & Maintenance + +### Check Package Status + +**Production PyPI:** +- Project page: https://pypi.org/project/smartem-workspace/ +- Statistics: https://pypistats.org/packages/smartem-workspace + +**TestPyPI:** +- Project page: https://test.pypi.org/project/smartem-workspace/ + +### Download Statistics + +View on PyPI project page: +- Total downloads +- Downloads by version +- Downloads by Python version +- Downloads by system + +Use for: +- Measuring adoption +- Identifying popular versions +- Planning deprecations + +### Monitor for Issues + +**Set up alerts for:** +- Failed CI/CD runs (GitHub Actions notifications) +- PyPI security advisories (email from PyPI) +- Dependency vulnerabilities (Dependabot) + +**Regularly check:** +- GitHub issues mentioning installation problems +- PyPI project "Report a problem" section + +### Token Rotation Schedule + +| Task | Frequency | Next Due | +|------|-----------|----------| +| Review tokens | Every 6 months | [Date] | +| Rotate production token | Annually | [Date] | +| Rotate test token | As needed | N/A | +| Audit GitHub Secrets | Quarterly | [Date] | + +Add to team calendar with reminders. + +## Emergency Procedures + +### Token Compromise + +If a token is leaked (committed to git, shared publicly, etc.): + +**Immediate Actions:** + +1. **Revoke token on PyPI**: + - Go to https://pypi.org → Account Settings → API tokens + - Find compromised token + - Click "Remove" → Confirm + +2. **Generate new token**: + - Follow Step 2 instructions + - Use different token name to distinguish + +3. **Update GitHub Secret**: + - Go to repository secrets + - Update `PYPI_API_TOKEN` with new token + - Document incident in team wiki + +4. **Review recent publishes**: + - Check https://pypi.org/project/smartem-workspace/#history + - Verify no unauthorised releases + +5. **Notify team**: + - Email: smartem@diamond.ac.uk + - Subject: "PyPI token compromised and rotated" + - Include actions taken + +**Preventive Measures:** + +- Use git-secrets or pre-commit hooks to prevent commits with tokens +- Never paste tokens in Slack/Teams +- Use password manager for sharing within team +- Regularly audit repository for secrets + +### Accidental Bad Release + +If a broken version is published to PyPI: + +**Option 1: Yank Release (Recommended)** + +Yanking prevents new installations but doesn't delete: + +```bash +# Install twine if needed +uv pip install twine + +# Yank the bad version +twine upload --yank "Broken release, use 0.2.1 instead" \ + --repository pypi \ + --username __token__ \ + --password $PYPI_API_TOKEN + +# Or via PyPI web UI: +# 1. Go to https://pypi.org/project/smartem-workspace/ +# 2. Click on bad version +# 3. Options → Yank release +# 4. Provide reason: "Broken installation, use 0.2.1" +``` + +**What yanking does:** +- Hides version from PyPI index +- Prevents `pip install smartem-workspace` from selecting it +- Still allows `pip install smartem-workspace==0.2.0` (if someone really wants it) +- Doesn't break existing installations + +**Option 2: Publish Fixed Version** + +Quickly release a patch: + +```bash +# Fix the issue in code +vim smartem_workspace/cli.py + +# Bump version +# Edit pyproject.toml: version = "0.2.1" + +# Rebuild and publish +uv build +twine upload dist/* + +# Yank broken version (optional but recommended) +# (See Option 1) +``` + +**Option 3: Delete Version (Not Recommended)** + +PyPI allows deletion within 72 hours, but: +- ❌ Breaks existing installations +- ❌ Can't reuse version number +- ❌ Creates confusion + +Only use for critical security issues. + +### Security Incident Response + +If a security vulnerability is found: + +1. **Assess severity** (use CVSS scoring) +2. **Develop fix** in private branch +3. **Coordinate disclosure**: + - Notify PyPI security team: security@pypi.org + - Notify users via GitHub Security Advisory +4. **Release patched version** +5. **Yank vulnerable versions** +6. **Publish security advisory** + +## Contact & Support + +### PyPI Support + +- **Documentation**: https://pypi.org/help/ +- **Support**: https://pypi.org/help/#support +- **Security**: security@pypi.org + +### GitHub Actions Support + +- **Documentation**: https://docs.github.com/actions +- **Community Forum**: https://github.community/ +- **Repository Issues**: https://github.com/DiamondLightSource/smartem-devtools/issues + +### SmartEM Team + +- **Email**: smartem@diamond.ac.uk +- **GitHub Issues**: https://github.com/DiamondLightSource/smartem-devtools/issues +- **Discussions**: https://github.com/DiamondLightSource/smartem-devtools/discussions + +--- + +## Quick Reference + +### Token Setup Checklist + +- [ ] Create PyPI account with 2FA +- [ ] Create TestPyPI account with 2FA +- [ ] Generate PyPI token (account-scoped initially) +- [ ] Generate TestPyPI token +- [ ] Add `PYPI_API_TOKEN` to GitHub Secrets +- [ ] Add `TEST_PYPI_API_TOKEN` to GitHub Secrets +- [ ] First manual publish to TestPyPI +- [ ] First manual publish to PyPI +- [ ] Update PyPI token to project-scoped +- [ ] Update GitHub Secret with new token +- [ ] Test CI/CD publish to TestPyPI +- [ ] Test CI/CD publish to PyPI (release tag) +- [ ] Document tokens in team password manager +- [ ] Schedule token rotation reminders + +### Useful Commands + +```bash +# Build package +uv build + +# Upload to TestPyPI +twine upload --repository testpypi dist/* + +# Upload to PyPI +twine upload dist/* + +# Test installation from TestPyPI +uvx --index-url https://test.pypi.org/simple/ smartem-workspace --version + +# Test installation from PyPI +uvx smartem-workspace --version + +# Yank release +twine upload --yank "Reason" dist/* +``` + +### Token Format + +``` +Production: pypi-AgEIcHlwaS5vcmc... +TestPyPI: pypi-AgENdGVzdC5weXBpLm9yZw... +``` + +Always starts with `pypi-`. diff --git a/docs/how-to/setup-smartem-workspace.md b/docs/how-to/setup-smartem-workspace.md new file mode 100644 index 0000000..6ca90c7 --- /dev/null +++ b/docs/how-to/setup-smartem-workspace.md @@ -0,0 +1,909 @@ +# Setup SmartEM Workspace + +This guide explains how to use `smartem-workspace` to set up a complete SmartEM development environment. + +## Overview + +`smartem-workspace` is a command-line tool that automates the setup of a multi-repository workspace for SmartEM development. It handles repository cloning, Claude Code configuration, Serena MCP setup, and workspace structure creation. + +### What Gets Set Up + +When you run `smartem-workspace init`, the tool creates: + +1. **Repository clones** - Organised by GitHub organisation (DiamondLightSource, FragmentScreen, GitlabAriaPHP) +2. **Claude Code configuration** - Skills, agents, settings, and permissions for AI-assisted development +3. **Serena MCP server** - Semantic code navigation and symbol search +4. **Workspace structure** - CLAUDE.md, tmp/, testdata/ directories +5. **Development tools** - Pre-configured for the SmartEM ecosystem + +### When to Use This Tool + +Use `smartem-workspace` when you need to: + +- Set up a new SmartEM development environment +- Clone multiple related repositories at once +- Configure Claude Code for SmartEM development +- Standardise workspace layout across team members +- Quickly bootstrap a dev environment on a new machine + +### Prerequisites + +Before using `smartem-workspace`, ensure you have: + +- **Python 3.11 or later** - Check with `python --version` or `python3 --version` +- **Git** - For repository cloning +- **uv or uvx** - Modern Python package installer ([install guide](https://docs.astral.sh/uv/)) +- **Internet connection** - For cloning repositories and fetching configuration +- **GitHub access** - Public repos work without authentication; private repos need credentials + +### Installation Methods + +#### Method 1: Run Directly with uvx (Recommended) + +No installation needed. `uvx` downloads and runs the tool in an isolated environment: + +```bash +uvx smartem-workspace init +``` + +This is the recommended method because: +- No permanent installation clutters your system +- Always uses the latest version from PyPI +- Isolated environment prevents dependency conflicts +- Works immediately without setup + +#### Method 2: Install Globally with uv + +Install once, use repeatedly: + +```bash +uv tool install smartem-workspace +smartem-workspace init +``` + +Use this method if: +- You'll run the tool frequently +- You want tab completion for the command +- You prefer traditional installed tools + +#### Method 3: Install with pipx + +Alternative to uv for those who prefer pipx: + +```bash +pipx install smartem-workspace +smartem-workspace init +``` + +### Troubleshooting Installation + +#### "uvx: command not found" + +Install uv first: + +```bash +curl -LsSf https://astral.sh/uv/install.sh | sh +source ~/.bashrc # or ~/.zshrc +``` + +#### "No module named smartem_workspace" + +Ensure you're using the correct command: +- With uvx: `uvx smartem-workspace init` (not `python -m`) +- With installed tool: `smartem-workspace init` + +#### "Cannot find package smartem-workspace on PyPI" + +The package might not be published yet. Contact the SmartEM team or use a development build. + +## Quick Start + +The simplest way to get started: + +```bash +# Interactive setup in current directory +uvx smartem-workspace init +``` + +This launches an interactive wizard that: +1. Asks which preset you want (or custom selection) +2. Confirms the target directory +3. Shows what will be cloned +4. Clones repositories with progress indicators +5. Sets up Claude Code configuration +6. Sets up Serena MCP server +7. Creates workspace structure + +### Non-Interactive Quick Start + +For automated setups or scripts: + +```bash +# Use smartem-core preset without prompts +uvx smartem-workspace init --preset smartem-core --no-interactive +``` + +## Presets Explained + +Presets are predefined collections of repositories optimised for different use cases. + +### minimal + +**Size**: ~13 MB +**Repositories**: 1 +**Use Case**: Workspace setup and documentation only + +Contains only `smartem-devtools` repository. Use this if you: +- Want to explore the documentation +- Need workspace configuration without code repositories +- Are setting up a minimal environment for testing + +**Included:** +- `DiamondLightSource/smartem-devtools` + +### smartem-core + +**Size**: ~31 MB +**Repositories**: 3 +**Use Case**: Core SmartEM development + +Contains the essential repositories for SmartEM development. Use this if you: +- Work on SmartEM backend or frontend +- Need the core system for development and testing +- Want a lightweight but functional development environment + +**Included:** +- `DiamondLightSource/smartem-devtools` - Developer tooling and documentation +- `DiamondLightSource/smartem-decisions` - Backend (FastAPI, PostgreSQL, RabbitMQ) +- `DiamondLightSource/smartem-frontend` - Web UI (React, TanStack Router) + +### aria-reference + +**Size**: ~100 MB +**Repositories**: 20+ +**Use Case**: ARIA ecosystem exploration and integration work + +Contains ARIA backend and FandanGO plugin repositories. Use this if you: +- Work on FandanGO-cryoem-dls (ARIA deposition) +- Need to understand ARIA integration +- Are developing facility plugins + +**Included:** +- All `FragmentScreen/fandanGO-*` repositories +- All `GitlabAriaPHP/aria-*` repositories (PHP libraries) +- Peer facility plugins (CNB, CERM, GUF) + +### full + +**Size**: ~150 MB +**Repositories**: 30+ +**Use Case**: Complete ecosystem development + +Contains all repositories across all organisations. Use this if you: +- Work across multiple SmartEM components +- Need complete context for architectural work +- Are doing cross-repository refactoring +- Want all reference code available + +**Included:** +- All DiamondLightSource repositories +- All FragmentScreen repositories +- All GitlabAriaPHP repositories + +## Configuration Options + +### --path: Target Directory + +Specify where to create the workspace: + +```bash +# Create in ~/dev/smartem +uvx smartem-workspace init --path ~/dev/smartem + +# Create in specific project directory +uvx smartem-workspace init --path /projects/cryo-em/smartem +``` + +**Default**: Current directory (`.`) + +**Behaviour**: +- Creates directory if it doesn't exist +- Fails if directory exists and is not empty (safety measure) +- Creates `repos/`, `claude-config/`, `tmp/`, `testdata/` subdirectories + +### --preset: Skip Repository Selection + +Use a predefined preset instead of interactive selection: + +```bash +# Core development repositories +uvx smartem-workspace init --preset smartem-core + +# Complete ecosystem +uvx smartem-workspace init --preset full + +# Minimal setup +uvx smartem-workspace init --preset minimal + +# ARIA reference +uvx smartem-workspace init --preset aria-reference +``` + +**Default**: None (interactive selection) + +**Behaviour**: +- Skips repository selection prompts +- Shows summary of what will be cloned +- Still prompts for confirmation unless `--no-interactive` is used + +### --no-interactive: Fully Automated Mode + +Skip all prompts for scripted/automated setups: + +```bash +# Completely non-interactive +uvx smartem-workspace init --preset smartem-core --no-interactive +``` + +**Requirements**: +- Must specify `--preset` (no default) +- Uses defaults for all other options +- Fails immediately on any error + +**Use Cases**: +- CI/CD pipelines +- Docker image builds +- Automated testing environments +- Team onboarding scripts + +### --ssh: Use SSH URLs for Git + +Clone repositories using SSH instead of HTTPS: + +```bash +uvx smartem-workspace init --preset smartem-core --ssh +``` + +**Default**: HTTPS + +**When to use SSH**: +- You have SSH keys configured with GitHub +- You need write access to repositories +- Your network blocks HTTPS Git traffic +- You prefer SSH authentication + +**Requirements**: +- SSH key added to GitHub account +- SSH agent running with key loaded +- Network allows SSH (port 22) to github.com + +**URL Examples**: +- HTTPS: `https://github.com/DiamondLightSource/smartem-decisions.git` +- SSH: `git@github.com:DiamondLightSource/smartem-decisions.git` + +### --skip-claude: Skip Claude Code Setup + +Don't configure Claude Code: + +```bash +uvx smartem-workspace init --preset smartem-core --skip-claude +``` + +**Use Cases**: +- You don't use Claude Code +- You want to configure Claude Code manually +- Testing workspace setup without AI tools + +**What gets skipped**: +- `.claude/` directory creation +- Skills symlinking +- Settings and permissions configuration +- CLAUDE.md creation + +### --skip-serena: Skip Serena MCP Setup + +Don't configure Serena MCP server: + +```bash +uvx smartem-workspace init --preset smartem-core --skip-serena +``` + +**Use Cases**: +- You don't use Serena for code navigation +- You prefer other code navigation tools +- Minimal setup for resource-constrained environments + +**What gets skipped**: +- Serena MCP server configuration +- Symbol index setup + +## Interactive Walkthrough + +When you run `smartem-workspace init` without `--no-interactive`, you'll go through this flow: + +### Step 1: Preset or Custom Selection + +``` +? Select a preset or choose custom: + ○ minimal - Just smartem-devtools (13 MB, 1 repo) + ○ smartem-core - Core development repos (31 MB, 3 repos) + ○ aria-reference - ARIA ecosystem (100 MB, 20+ repos) + ○ full - Complete ecosystem (150 MB, 30+ repos) + ● custom - Select repositories manually +``` + +**Preset**: Proceed to confirmation +**Custom**: Continue to repository selection + +### Step 2: Repository Selection (Custom Only) + +``` +? Select repositories to clone: + + DiamondLightSource + ☑ smartem-devtools - Developer tooling and documentation + ☑ smartem-decisions - Backend (FastAPI, PostgreSQL, RabbitMQ) + ☑ smartem-frontend - Web UI (React, TanStack Router) + ☐ cryoem-services - Data processing pipelines + + FragmentScreen + ☐ fandanGO-core - Plugin framework foundation + ☐ fandanGO-aria - ARIA integration + ☐ fandanGO-cryoem-dls - DLS facility plugin + + [Space to toggle, Enter to continue] +``` + +### Step 3: Target Directory Confirmation + +``` +? Create workspace in: /home/user/dev/smartem + + This will create: + - repos/DiamondLightSource/ + - repos/FragmentScreen/ + - claude-config/ + - tmp/ + - testdata/ + - CLAUDE.md + +? Continue? (Y/n) +``` + +### Step 4: Cloning Progress + +``` +Cloning repositories... + +✓ DiamondLightSource/smartem-devtools [1/3] + → repos/DiamondLightSource/smartem-devtools + +⣾ DiamondLightSource/smartem-decisions [2/3] + Cloning... 50% +``` + +### Step 5: Configuration Setup + +``` +Setting up Claude Code... +✓ Created .claude/ directory +✓ Linked 7 skills +✓ Configured settings and permissions +✓ Created CLAUDE.md + +Setting up Serena MCP... +✓ Configured Serena server +✓ Indexed repositories +``` + +### Step 6: Completion Summary + +``` +✓ Workspace setup complete! + +Summary: + Repositories cloned: 3 + Total size: 31 MB + Location: /home/user/dev/smartem + +Next steps: + 1. cd /home/user/dev/smartem + 2. Review CLAUDE.md for workspace overview + 3. See docs/how-to/ for development guides + +Happy coding! +``` + +## What Gets Set Up + +### Repository Structure + +After running `smartem-workspace init`, your workspace will have this structure: + +``` +/home/user/dev/smartem/ +├── repos/ # All repository clones +│ ├── DiamondLightSource/ # DLS GitHub organisation +│ │ ├── smartem-devtools/ # Developer tooling +│ │ ├── smartem-decisions/ # Backend +│ │ └── smartem-frontend/ # Web UI +│ ├── FragmentScreen/ # FragmentScreen GitHub organisation +│ │ ├── fandanGO-core/ # Plugin framework +│ │ └── fandanGO-aria/ # ARIA integration +│ └── GitlabAriaPHP/ # ARIA PHP ecosystem (GitLab) +│ ├── data-deposition-api/ # ARIA GraphQL API +│ └── aria-graphql-client/ # PHP client library +├── claude-config/ # Claude Code configuration +│ ├── shared/ # Cross-repo config +│ │ └── skills/ # Shared skills +│ ├── smartem-decisions/ # Repo-specific config +│ │ ├── agents/ # Custom agents +│ │ └── REPO-GUIDELINES.md # Development standards +│ └── ARCHITECTURE.md # System architecture +├── .claude/ # Claude Code runtime +│ ├── skills/ # Symlinked skills +│ ├── settings.json # IDE settings +│ └── permissions.json # Security permissions +├── tmp/ # Scratchpad directory +│ ├── logs/ # Application logs +│ └── simulations/ # Test data outputs +├── testdata/ # Test datasets (not in git) +│ └── epu-output/ # Sample EPU output +├── CLAUDE.md # Workspace overview for AI +└── README.md # Getting started guide +``` + +### Claude Code Configuration + +The tool sets up Claude Code with: + +#### Skills (7 total) +- **database-admin** - PostgreSQL and Alembic migrations +- **devops** - Kubernetes, Docker, CI/CD +- **technical-writer** - Documentation and ADRs +- **git** - Git operations and workflows +- **github** - GitHub PRs, issues, workflows +- **ascii-art** - Diagram generation +- **playwright-skill** - Browser automation testing + +#### Settings +- Python type checking enabled +- Line length: 120 characters +- British English for documentation +- No emojis in code (Windows compatibility) + +#### Permissions +- Read access to all repository files +- Write access to appropriate directories +- Execute access for development scripts + +### Serena MCP Server + +Enables semantic code navigation: +- Symbol search across all repositories +- Find references and definitions +- Jump to implementation +- Documentation lookup + +### Workspace Files + +#### CLAUDE.md + +AI-readable workspace overview containing: +- Repository structure and ownership +- Vocabulary and terminology +- Code navigation guide +- Development conventions +- Architecture overview + +#### tmp/ + +Scratchpad directory for: +- E2E simulation outputs +- EPU output directories +- Application logs +- Volume mounts for containers +- Temporary build artifacts + +#### testdata/ + +Test datasets for development: +- EPU output samples +- Microscope session data +- Reference images +- Not version controlled (read-only reference) + +## Post-Setup Steps + +After workspace initialization completes: + +### 1. Navigate to Workspace + +```bash +cd /path/to/workspace +``` + +### 2. Verify Repository Clones + +```bash +ls -la repos/DiamondLightSource/ +# Should show: smartem-devtools, smartem-decisions, smartem-frontend (for smartem-core preset) +``` + +### 3. Check Git Status + +```bash +cd repos/DiamondLightSource/smartem-decisions +git status +git branch +``` + +All repositories are cloned on the `main` branch by default. + +### 4. Review Workspace Overview + +```bash +cat CLAUDE.md +``` + +Read this file to understand: +- What each repository does +- Ownership and edit permissions +- Development workflows +- Architecture and dependencies + +### 5. Explore Documentation + +Documentation is in `repos/DiamondLightSource/smartem-devtools/docs/`: + +```bash +cd repos/DiamondLightSource/smartem-devtools +ls docs/how-to/ +``` + +Key guides: +- `run-backend.md` - Running the SmartEM backend +- `run-e2e-dev-simulation.md` - End-to-end testing +- `database-migrations.md` - Database schema changes +- `deploy-kubernetes.md` - Kubernetes deployment + +### 6. Set Up Development Environment + +For smartem-decisions (backend): + +```bash +cd repos/DiamondLightSource/smartem-decisions +uv sync --all-extras +source .venv/bin/activate +./tools/k8s/dev-k8s.sh up # Starts PostgreSQL, RabbitMQ +``` + +For smartem-frontend: + +```bash +cd repos/DiamondLightSource/smartem-frontend +npm install +npm run dev +``` + +### 7. Verify Claude Code Setup + +If using Claude Code: + +```bash +ls -la .claude/skills/ +# Should show symlinks to all 7 skills +``` + +Test Claude Code can access skills and repository context. + +### 8. Run Tests + +Verify repositories are working: + +```bash +# Backend tests +cd repos/DiamondLightSource/smartem-decisions +uv run pytest + +# Frontend tests +cd repos/DiamondLightSource/smartem-frontend +npm test +``` + +## Advanced Usage + +### Adding Repositories After Init + +To add a single repository to an existing workspace: + +```bash +smartem-workspace add DiamondLightSource/cryoem-services +``` + +This clones the repository into the appropriate organisation directory. + +### Removing Repositories + +Simply delete the directory: + +```bash +rm -rf repos/DiamondLightSource/cryoem-services +``` + +The tool doesn't manage repository removal (standard `rm` is safer). + +### Syncing Existing Repositories + +Pull latest changes for all repositories: + +```bash +smartem-workspace sync +``` + +This runs `git pull` on each repository. Uncommitted changes are preserved. + +### Checking Workspace Status + +See git status for all repositories: + +```bash +smartem-workspace status +``` + +Shows: +- Current branch +- Uncommitted changes +- Commits ahead/behind remote + +### Custom Presets + +Currently, custom presets are defined in the package configuration. To request a new preset, open an issue at: + +https://github.com/DiamondLightSource/smartem-devtools/issues + +Future versions may support user-defined preset files. + +### Integration with Existing Workflows + +#### Docker Containers + +Mount the workspace as a volume: + +```bash +docker run -v ~/dev/smartem:/workspace -w /workspace python:3.12 +``` + +#### VS Code Workspaces + +Create a multi-root workspace file (`.code-workspace`) to open all repositories: + +```json +{ + "folders": [ + { "path": "repos/DiamondLightSource/smartem-decisions" }, + { "path": "repos/DiamondLightSource/smartem-frontend" }, + { "path": "repos/DiamondLightSource/smartem-devtools" } + ] +} +``` + +#### CI/CD Pipelines + +Use `--no-interactive` for automated environments: + +```yaml +- name: Setup SmartEM workspace + run: uvx smartem-workspace init --preset smartem-core --no-interactive --path /workspace +``` + +## Troubleshooting + +### Common Errors + +#### "Directory not empty" + +**Error:** +``` +Error: Target directory /home/user/dev/smartem is not empty +``` + +**Cause**: Safety check prevents overwriting existing files + +**Solutions**: +1. Use a different directory: `--path ~/dev/smartem-new` +2. Remove existing directory: `rm -rf ~/dev/smartem` +3. Use existing workspace: `cd ~/dev/smartem && smartem-workspace sync` + +#### "Repository already exists" + +**Error:** +``` +Error: Repository repos/DiamondLightSource/smartem-decisions already exists +``` + +**Cause**: Partial previous setup or manual clone + +**Solutions**: +1. Remove existing repository: `rm -rf repos/DiamondLightSource/smartem-decisions` +2. Skip this repository in custom selection +3. Use a fresh directory + +#### "Git clone failed: authentication required" + +**Error:** +``` +Error: Could not clone DiamondLightSource/smartem-decisions +Permission denied (publickey) +``` + +**Cause**: Trying to use SSH without configured keys, or private repository without credentials + +**Solutions**: +1. Use HTTPS instead: Remove `--ssh` flag +2. Configure SSH keys: + ```bash + ssh-keygen -t ed25519 -C "your_email@example.com" + ssh-add ~/.ssh/id_ed25519 + # Add key to GitHub: Settings → SSH and GPG keys + ``` +3. Configure Git credentials for HTTPS: + ```bash + git config --global credential.helper store + # Next git operation will prompt for credentials + ``` + +#### "Network error: Failed to fetch configuration" + +**Error:** +``` +Error: Failed to fetch repository configuration from GitHub +Falling back to bundled configuration +``` + +**Cause**: Network issues or GitHub API rate limit + +**Impact**: Tool uses bundled configuration instead (may be slightly outdated) + +**Solutions**: +1. Check internet connection +2. Wait for GitHub API rate limit reset (1 hour) +3. Configure GitHub token to increase rate limit: + ```bash + export GITHUB_TOKEN=ghp_your_token_here + ``` +4. Continue with bundled config (usually fine) + +#### "Symlink creation failed" + +**Error:** +``` +Error: Failed to create symlink .claude/skills/database-admin +``` + +**Cause**: Windows without developer mode, or permission issues + +**Solutions**: +1. **Windows**: Enable Developer Mode (Settings → Update & Security → For developers) +2. **Windows**: Run as Administrator +3. **Linux/macOS**: Check directory permissions +4. Skip Claude Code setup: `--skip-claude` + +### Performance Issues + +#### Slow Cloning + +**Symptoms**: Repository cloning takes very long + +**Causes**: +- Large repository size +- Slow network connection +- GitHub rate limiting + +**Solutions**: +1. Use wired connection instead of Wi-Fi +2. Choose smaller preset (`minimal` or `smartem-core` instead of `full`) +3. Clone repositories incrementally with `smartem-workspace add` +4. Check network speed: `curl -o /dev/null https://github.com/DiamondLightSource/smartem-devtools/archive/refs/heads/main.zip` + +#### Disk Space + +**Symptoms**: Setup fails partway through + +**Cause**: Insufficient disk space + +**Solutions**: +1. Check available space: `df -h` +2. Free up space or use different partition +3. Use smaller preset +4. Use shallow clones (future feature) + +### Permission Issues + +#### Cannot Write to Directory + +**Error:** +``` +Error: Permission denied: '/opt/smartem' +``` + +**Solutions**: +1. Use directory in your home folder: `--path ~/dev/smartem` +2. Change directory ownership: `sudo chown -R $USER /opt/smartem` +3. Use sudo (not recommended): `sudo uvx smartem-workspace init` + +#### Git Permission Denied + +**Error:** +``` +Permission denied (publickey) +``` + +See "Git clone failed: authentication required" above. + +### Configuration Issues + +#### Skills Not Found + +**Symptoms**: Claude Code can't find skills + +**Diagnosis**: +```bash +ls -la .claude/skills/ +# Check if symlinks are broken (red in ls output) +``` + +**Solutions**: +1. Re-run setup: `uvx smartem-workspace init --preset smartem-core` +2. Manually fix symlinks: + ```bash + cd .claude/skills + ln -sf ../../claude-config/shared/skills/database-admin database-admin + ``` + +#### Serena MCP Not Working + +**Symptoms**: Symbol search doesn't work + +**Solutions**: +1. Check Serena configuration exists +2. Re-index repositories (Serena documentation) +3. Verify MCP server is running + +## Getting Help + +### Documentation + +- **SmartEM Devtools**: https://diamondlightsource.github.io/smartem-devtools/ +- **API Documentation**: https://diamondlightsource.github.io/smartem-devtools/reference/ +- **How-to Guides**: `repos/DiamondLightSource/smartem-devtools/docs/how-to/` + +### Community + +- **GitHub Issues**: https://github.com/DiamondLightSource/smartem-devtools/issues +- **Discussions**: https://github.com/DiamondLightSource/smartem-devtools/discussions + +### Contact + +- **Email**: smartem@diamond.ac.uk +- **Bug Reports**: https://github.com/DiamondLightSource/smartem-devtools/issues/new + +## Next Steps + +After setting up your workspace: + +1. **Read CLAUDE.md** for workspace overview and conventions +2. **Explore docs/how-to/** for development guides +3. **Run the backend** with `./tools/k8s/dev-k8s.sh up` +4. **Run the frontend** with `npm run dev` +5. **Run tests** to verify everything works +6. **Join discussions** on GitHub for questions + +For contributing to SmartEM: + +1. Read `.github/CONTRIBUTING.md` in smartem-devtools +2. Review `claude-config/smartem-decisions/REPO-GUIDELINES.md` for code standards +3. Check open issues for good first contributions +4. Set up pre-commit hooks with Lefthook + +Happy developing! diff --git a/packages/smartem-workspace/README.md b/packages/smartem-workspace/README.md new file mode 100644 index 0000000..7ce5b42 --- /dev/null +++ b/packages/smartem-workspace/README.md @@ -0,0 +1,130 @@ +# smartem-workspace + +[![PyPI version](https://badge.fury.io/py/smartem-workspace.svg)](https://pypi.org/project/smartem-workspace/) +[![Python Versions](https://img.shields.io/pypi/pyversions/smartem-workspace.svg)](https://pypi.org/project/smartem-workspace/) +[![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) +[![CI](https://github.com/DiamondLightSource/smartem-devtools/actions/workflows/publish-smartem-workspace.yml/badge.svg)](https://github.com/DiamondLightSource/smartem-devtools/actions/workflows/publish-smartem-workspace.yml) + +CLI tool to automate SmartEM multi-repo workspace setup. + +## Installation + +```bash +# Run directly with uvx (recommended) +uvx smartem-workspace init + +# Or install globally +uv tool install smartem-workspace +``` + +## Usage + +### Initialize a new workspace + +```bash +# Interactive setup in current directory +smartem-workspace init + +# Specify target directory +smartem-workspace init --path ~/dev/smartem + +# Use a preset (skip repo selection) +smartem-workspace init --preset smartem-core + +# Non-interactive with preset +smartem-workspace init --preset full --no-interactive +``` + +### Available presets + +| Preset | Description | +|--------|-------------| +| `smartem-core` | Core SmartEM repos (decisions, frontend, devtools) | +| `full` | All 30+ repos including ARIA reference | +| `aria-reference` | ARIA ecosystem repos for reference | +| `minimal` | Just smartem-devtools (workspace setup only) | + +### Other commands + +```bash +# Sync existing repos (git pull) +smartem-workspace sync + +# Show workspace status +smartem-workspace status + +# Add a single repo +smartem-workspace add DiamondLightSource/smartem-frontend +``` + +### Options + +``` +--path PATH Target directory (default: current directory) +--preset NAME Use preset: smartem-core, full, aria-reference, minimal +--no-interactive Skip prompts, use preset only +--ssh Use SSH URLs (default: HTTPS) +--skip-claude Skip Claude Code setup +--skip-serena Skip Serena MCP setup +``` + +## What it sets up + +1. **Repository clones** - Organized by organization (DiamondLightSource, FragmentScreen, GitlabAriaPHP) +2. **Claude Code configuration** - Skills, settings, permissions +3. **Serena MCP server** - Semantic code navigation +4. **Workspace structure** - CLAUDE.md, tmp/, testdata/ directories + +## Documentation + +- **User Guide**: [Setup SmartEM Workspace](https://diamondlightsource.github.io/smartem-devtools/how-to/setup-smartem-workspace.html) +- **Developer Guide**: [Contributing to smartem-workspace](https://diamondlightsource.github.io/smartem-devtools/explanations/smartem-workspace-developer-guide.html) +- **PyPI Setup**: [Publishing to PyPI](https://diamondlightsource.github.io/smartem-devtools/how-to/publish-smartem-workspace-to-pypi.html) +- **API Documentation**: [SmartEM Devtools Docs](https://diamondlightsource.github.io/smartem-devtools/) + +## Development + +```bash +cd packages/smartem-workspace + +# Install dev dependencies +uv sync --all-extras + +# Run tests +uv run pytest + +# Run linter +uv run ruff check . + +# Build package +uv build +``` + +See [Developer Guide](../../docs/explanations/smartem-workspace-developer-guide.md) for detailed development instructions. + +## Releasing + +Releases are published to PyPI via GitHub Actions using [Trusted Publishers](https://docs.pypi.org/trusted-publishers/). + +```bash +# 1. Update version in pyproject.toml +# 2. Commit the change +git commit -am "chore: release smartem-workspace vX.Y.Z" + +# 3. Create and push a version tag +git tag smartem-workspace-vX.Y.Z +git push origin main --tags +``` + +The CI workflow runs tests, builds the package, and publishes to PyPI automatically on tag push. + +## Links + +- **PyPI**: https://pypi.org/project/smartem-workspace/ +- **Repository**: https://github.com/DiamondLightSource/smartem-devtools +- **Issues**: https://github.com/DiamondLightSource/smartem-devtools/issues +- **Changelog**: [GitHub Releases](https://github.com/DiamondLightSource/smartem-devtools/releases?q=smartem-workspace) + +## License + +Apache-2.0 diff --git a/packages/smartem-workspace/pyproject.toml b/packages/smartem-workspace/pyproject.toml new file mode 100644 index 0000000..08b2726 --- /dev/null +++ b/packages/smartem-workspace/pyproject.toml @@ -0,0 +1,62 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "smartem-workspace" +version = "0.1.0" +description = "CLI tool to automate SmartEM multi-repo workspace setup" +readme = "README.md" +license = "Apache-2.0" +requires-python = ">=3.11" +authors = [ + { name = "Diamond Light Source", email = "smartem@diamond.ac.uk" }, +] +keywords = ["smartem", "workspace", "development", "cryo-em"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Environment :: Console", + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Software Development :: Build Tools", +] +dependencies = [ + "typer>=0.12.0", + "rich>=13.0.0", + "httpx>=0.27.0", + "pydantic>=2.0.0", + "pyyaml>=6.0.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0.0", + "pytest-cov>=4.0.0", + "ruff>=0.4.0", +] + +[project.scripts] +smartem-workspace = "smartem_workspace.cli:app" + +[project.urls] +Homepage = "https://github.com/DiamondLightSource/smartem-devtools" +Documentation = "https://diamondlightsource.github.io/smartem-decisions/" +Repository = "https://github.com/DiamondLightSource/smartem-devtools" +Issues = "https://github.com/DiamondLightSource/smartem-devtools/issues" + +[tool.hatch.build.targets.wheel] +packages = ["smartem_workspace"] + +[tool.ruff] +line-length = 120 +target-version = "py311" + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B", "SIM"] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/packages/smartem-workspace/smartem_workspace/__init__.py b/packages/smartem-workspace/smartem_workspace/__init__.py new file mode 100644 index 0000000..7bfc2e7 --- /dev/null +++ b/packages/smartem-workspace/smartem_workspace/__init__.py @@ -0,0 +1,3 @@ +"""SmartEM workspace setup CLI tool.""" + +__version__ = "0.1.0" diff --git a/packages/smartem-workspace/smartem_workspace/__main__.py b/packages/smartem-workspace/smartem_workspace/__main__.py new file mode 100644 index 0000000..54d91bf --- /dev/null +++ b/packages/smartem-workspace/smartem_workspace/__main__.py @@ -0,0 +1,6 @@ +"""Entry point for running as python -m smartem_workspace.""" + +from smartem_workspace.cli import app + +if __name__ == "__main__": + app() diff --git a/packages/smartem-workspace/smartem_workspace/cli.py b/packages/smartem-workspace/smartem_workspace/cli.py new file mode 100644 index 0000000..f53b76e --- /dev/null +++ b/packages/smartem-workspace/smartem_workspace/cli.py @@ -0,0 +1,93 @@ +"""CLI commands for smartem-workspace.""" + +from pathlib import Path +from typing import Annotated + +import typer +from rich.console import Console + +from smartem_workspace.config.loader import load_config +from smartem_workspace.setup.bootstrap import bootstrap_workspace + +app = typer.Typer( + name="smartem-workspace", + help="CLI tool to automate SmartEM multi-repo workspace setup", + no_args_is_help=True, +) +console = Console() + + +@app.command() +def init( + path: Annotated[ + Path | None, + typer.Option("--path", "-p", help="Target directory for workspace"), + ] = None, + preset: Annotated[ + str | None, + typer.Option("--preset", help="Use preset: smartem-core, full, aria-reference, minimal"), + ] = None, + interactive: Annotated[ + bool, + typer.Option("--interactive/--no-interactive", help="Enable/disable interactive prompts"), + ] = True, + ssh: Annotated[ + bool, + typer.Option("--ssh", help="Use SSH URLs instead of HTTPS"), + ] = False, + skip_claude: Annotated[ + bool, + typer.Option("--skip-claude", help="Skip Claude Code setup"), + ] = False, + skip_serena: Annotated[ + bool, + typer.Option("--skip-serena", help="Skip Serena MCP setup"), + ] = False, +) -> None: + """Initialize a new SmartEM workspace.""" + workspace_path = path or Path.cwd() + + console.print("[bold blue]SmartEM Workspace Setup[/bold blue]") + console.print(f"Target: {workspace_path.absolute()}") + + config = load_config() + if config is None: + console.print("[red]Failed to load configuration[/red]") + raise typer.Exit(1) + + bootstrap_workspace( + config=config, + workspace_path=workspace_path, + preset=preset, + interactive=interactive, + use_ssh=ssh, + skip_claude=skip_claude, + skip_serena=skip_serena, + ) + + +@app.command() +def sync() -> None: + """Sync existing repos (git pull).""" + console.print("[yellow]Not implemented yet[/yellow]") + raise typer.Exit(1) + + +@app.command() +def status() -> None: + """Show workspace status.""" + console.print("[yellow]Not implemented yet[/yellow]") + raise typer.Exit(1) + + +@app.command() +def add( + repo: Annotated[str, typer.Argument(help="Repository to add (e.g., DiamondLightSource/smartem-frontend)")], +) -> None: + """Add a single repository to the workspace.""" + console.print(f"[yellow]Not implemented yet: {repo}[/yellow]") + raise typer.Exit(1) + + +if __name__ == "__main__": + app() diff --git a/packages/smartem-workspace/smartem_workspace/config/__init__.py b/packages/smartem-workspace/smartem_workspace/config/__init__.py new file mode 100644 index 0000000..b62c241 --- /dev/null +++ b/packages/smartem-workspace/smartem_workspace/config/__init__.py @@ -0,0 +1,23 @@ +"""Configuration loading and schema definitions.""" + +from smartem_workspace.config.loader import load_config +from smartem_workspace.config.schema import ( + ClaudeConfig, + McpConfig, + Organization, + Preset, + ReposConfig, + Repository, + SerenaConfig, +) + +__all__ = [ + "ClaudeConfig", + "McpConfig", + "Organization", + "Preset", + "Repository", + "ReposConfig", + "SerenaConfig", + "load_config", +] diff --git a/packages/smartem-workspace/smartem_workspace/config/loader.py b/packages/smartem-workspace/smartem_workspace/config/loader.py new file mode 100644 index 0000000..07d6dfc --- /dev/null +++ b/packages/smartem-workspace/smartem_workspace/config/loader.py @@ -0,0 +1,88 @@ +"""Configuration loading with network-first, bundled fallback strategy.""" + +import json +from importlib import resources +from pathlib import Path + +import httpx +from rich.console import Console + +from smartem_workspace.config.schema import ReposConfig + +GITHUB_RAW_URL = "https://raw.githubusercontent.com/DiamondLightSource/smartem-devtools/main/core/repos.json" +REQUEST_TIMEOUT = 10.0 + +console = Console() + + +def load_from_network() -> dict | None: + """Attempt to load config from GitHub.""" + try: + with httpx.Client(timeout=REQUEST_TIMEOUT) as client: + response = client.get(GITHUB_RAW_URL) + response.raise_for_status() + return response.json() + except httpx.HTTPError as e: + console.print(f"[dim]Network fetch failed: {e}[/dim]") + return None + except json.JSONDecodeError as e: + console.print(f"[dim]Invalid JSON from network: {e}[/dim]") + return None + + +def load_from_bundled() -> dict | None: + """Load bundled fallback config.""" + try: + config_path = resources.files("smartem_workspace.config").joinpath("repos.json") + with resources.as_file(config_path) as path: + if path.exists(): + return json.loads(path.read_text()) + except Exception as e: + console.print(f"[dim]Bundled config load failed: {e}[/dim]") + + return None + + +def load_from_file(path: Path) -> dict | None: + """Load config from a local file path.""" + try: + return json.loads(path.read_text()) + except Exception as e: + console.print(f"[dim]File load failed: {e}[/dim]") + return None + + +def load_config(local_path: Path | None = None) -> ReposConfig | None: + """ + Load workspace configuration. + + Strategy: + 1. If local_path provided, use that + 2. Try network (GitHub raw) + 3. Fall back to bundled config + + Returns: + ReposConfig if successful, None otherwise + """ + config_dict: dict | None = None + + if local_path: + console.print(f"[dim]Loading config from: {local_path}[/dim]") + config_dict = load_from_file(local_path) + else: + console.print("[dim]Fetching latest config from GitHub...[/dim]") + config_dict = load_from_network() + + if config_dict is None: + console.print("[dim]Using bundled fallback config[/dim]") + config_dict = load_from_bundled() + + if config_dict is None: + console.print("[red]Failed to load configuration from any source[/red]") + return None + + try: + return ReposConfig.model_validate(config_dict) + except Exception as e: + console.print(f"[red]Configuration validation failed: {e}[/red]") + return None diff --git a/packages/smartem-workspace/smartem_workspace/config/repos.json b/packages/smartem-workspace/smartem_workspace/config/repos.json new file mode 100644 index 0000000..01d8e95 --- /dev/null +++ b/packages/smartem-workspace/smartem_workspace/config/repos.json @@ -0,0 +1,403 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "version": "1.0.0", + "links": { + "docs": "https://diamondlightsource.github.io/smartem-decisions/", + "projectBoard": "https://github.com/orgs/DiamondLightSource/projects/51/views/1" + }, + "presets": { + "smartem-core": { + "description": "Core SmartEM development repos", + "repos": [ + "DiamondLightSource/smartem-decisions", + "DiamondLightSource/smartem-frontend", + "DiamondLightSource/smartem-devtools" + ] + }, + "full": { + "description": "All repos including ARIA reference", + "repos": ["*"] + }, + "aria-reference": { + "description": "ARIA ecosystem repos for reference", + "repos": [ + "DiamondLightSource/fandanGO-cryoem-dls", + "FragmentScreen/*", + "aria-php/data-deposition-api" + ] + }, + "minimal": { + "description": "Just smartem-devtools (workspace setup only)", + "repos": ["DiamondLightSource/smartem-devtools"] + } + }, + "organizations": [ + { + "name": "DiamondLightSource", + "displayName": "Diamond Light Source", + "url": "https://github.com/DiamondLightSource", + "provider": "github", + "repos": [ + { + "name": "smartem-decisions", + "description": "Central system controller - backbone, messaging router, persistence, auth", + "urls": { + "https": "https://github.com/DiamondLightSource/smartem-decisions.git", + "ssh": "git@github.com:DiamondLightSource/smartem-decisions.git" + }, + "tags": ["core", "python", "backend"], + "ownership": "full" + }, + { + "name": "smartem-frontend", + "description": "Web UI for SmartEM - user-facing view of acquisition sessions and ML decisions", + "urls": { + "https": "https://github.com/DiamondLightSource/smartem-frontend.git", + "ssh": "git@github.com:DiamondLightSource/smartem-frontend.git" + }, + "tags": ["core", "typescript", "frontend"], + "ownership": "full" + }, + { + "name": "smartem-devtools", + "description": "Developer tooling, documentation, and workspace configuration", + "urls": { + "https": "https://github.com/DiamondLightSource/smartem-devtools.git", + "ssh": "git@github.com:DiamondLightSource/smartem-devtools.git" + }, + "tags": ["core", "tooling"], + "ownership": "full", + "required": true + }, + { + "name": "fandanGO-cryoem-dls", + "description": "DLS facility plugin for FandanGO - bridges SmartEM to ARIA", + "urls": { + "https": "https://github.com/DiamondLightSource/fandanGO-cryoem-dls.git", + "ssh": "git@github.com:DiamondLightSource/fandanGO-cryoem-dls.git" + }, + "tags": ["aria", "python"], + "ownership": "full" + }, + { + "name": "cryoem-services", + "description": "Processing execution layer for cryo-EM data pipelines (reference-only)", + "urls": { + "https": "https://github.com/DiamondLightSource/cryoem-services.git", + "ssh": "git@github.com:DiamondLightSource/cryoem-services.git" + }, + "tags": ["reference", "python"], + "ownership": "reference-only" + } + ] + }, + { + "name": "FragmentScreen", + "displayName": "FragmentScreen", + "url": "https://github.com/FragmentScreen", + "provider": "github", + "repos": [ + { + "name": "fandanGO-core", + "description": "Plugin framework foundation", + "urls": { + "https": "https://github.com/FragmentScreen/fandanGO-core.git", + "ssh": "git@github.com:FragmentScreen/fandanGO-core.git" + }, + "tags": ["aria", "python", "framework"], + "ownership": "reference-only" + }, + { + "name": "fandanGO-aria", + "description": "ARIA integration - auth, token management, metadata submission", + "urls": { + "https": "https://github.com/FragmentScreen/fandanGO-aria.git", + "ssh": "git@github.com:FragmentScreen/fandanGO-aria.git" + }, + "tags": ["aria", "python"], + "ownership": "reference-only" + }, + { + "name": "fandanGO-cryoem-cnb", + "description": "CNB-CSIC Madrid cryo-EM plugin (peer reference)", + "urls": { + "https": "https://github.com/FragmentScreen/fandanGO-cryoem-cnb.git", + "ssh": "git@github.com:FragmentScreen/fandanGO-cryoem-cnb.git" + }, + "tags": ["aria", "python", "peer"], + "ownership": "reference-only" + }, + { + "name": "fandanGO-nmr-cerm", + "description": "CERM Florence NMR plugin (peer reference)", + "urls": { + "https": "https://github.com/FragmentScreen/fandanGO-nmr-cerm.git", + "ssh": "git@github.com:FragmentScreen/fandanGO-nmr-cerm.git" + }, + "tags": ["aria", "python", "peer"], + "ownership": "reference-only" + }, + { + "name": "fandanGO-nmr-guf", + "description": "GUF Frankfurt NMR plugin (peer reference)", + "urls": { + "https": "https://github.com/FragmentScreen/fandanGO-nmr-guf.git", + "ssh": "git@github.com:FragmentScreen/fandanGO-nmr-guf.git" + }, + "tags": ["aria", "python", "peer"], + "ownership": "reference-only" + }, + { + "name": "Samples", + "description": "Sample metadata/datasets for community reference", + "urls": { + "https": "https://github.com/FragmentScreen/Samples.git", + "ssh": "git@github.com:FragmentScreen/Samples.git" + }, + "tags": ["aria", "data"], + "ownership": "reference-only" + } + ] + }, + { + "name": "aria-php", + "displayName": "ARIA PHP (GitLab)", + "url": "https://gitlab.com/aria-php", + "provider": "gitlab", + "localDir": "GitlabAriaPHP", + "repos": [ + { + "name": "data-deposition-api", + "description": "ARIA GraphQL/REST API for metadata deposition (primary)", + "urls": { + "https": "https://gitlab.com/aria-php/data-deposition-api.git", + "ssh": "git@gitlab.com:aria-php/data-deposition-api.git" + }, + "tags": ["aria", "php", "api"], + "ownership": "reference-only" + }, + { + "name": "aria-graphql-client", + "description": "PHP library for communicating with ARIA GraphQL API", + "urls": { + "https": "https://gitlab.com/aria-php/aria-graphql-client.git", + "ssh": "git@gitlab.com:aria-php/aria-graphql-client.git" + }, + "tags": ["aria", "php", "library"], + "ownership": "reference-only" + }, + { + "name": "aria-elasticsearch-client", + "description": "Elasticsearch client for ARIA search records", + "urls": { + "https": "https://gitlab.com/aria-php/aria-elasticsearch-client.git", + "ssh": "git@gitlab.com:aria-php/aria-elasticsearch-client.git" + }, + "tags": ["aria", "php", "library"], + "ownership": "reference-only" + }, + { + "name": "aria-rest", + "description": "REST API framework for defining versioned APIs", + "urls": { + "https": "https://gitlab.com/aria-php/aria-rest.git", + "ssh": "git@gitlab.com:aria-php/aria-rest.git" + }, + "tags": ["aria", "php", "framework"], + "ownership": "reference-only" + }, + { + "name": "aria-storage-interface", + "description": "Storage provider interface", + "urls": { + "https": "https://gitlab.com/aria-php/aria-storage-interface.git", + "ssh": "git@gitlab.com:aria-php/aria-storage-interface.git" + }, + "tags": ["aria", "php", "library"], + "ownership": "reference-only" + }, + { + "name": "aria-webhooks", + "description": "Standard webhook payload format for ARIA platform", + "urls": { + "https": "https://gitlab.com/aria-php/aria-webhooks.git", + "ssh": "git@gitlab.com:aria-php/aria-webhooks.git" + }, + "tags": ["aria", "php", "library"], + "ownership": "reference-only" + }, + { + "name": "aria-incoming-email", + "description": "Incoming email message routing", + "urls": { + "https": "https://gitlab.com/aria-php/aria-incoming-email.git", + "ssh": "git@gitlab.com:aria-php/aria-incoming-email.git" + }, + "tags": ["aria", "php", "library"], + "ownership": "reference-only" + }, + { + "name": "aria-mailer", + "description": "Email wrapper (PHPMailer + Swiftmailer)", + "urls": { + "https": "https://gitlab.com/aria-php/aria-mailer.git", + "ssh": "git@gitlab.com:aria-php/aria-mailer.git" + }, + "tags": ["aria", "php", "library"], + "ownership": "reference-only" + }, + { + "name": "aria-mailgun-webhooks", + "description": "Mailgun webhook event parser", + "urls": { + "https": "https://gitlab.com/aria-php/aria-mailgun-webhooks.git", + "ssh": "git@gitlab.com:aria-php/aria-mailgun-webhooks.git" + }, + "tags": ["aria", "php", "library"], + "ownership": "reference-only" + }, + { + "name": "aria-invite-users", + "description": "User invitation framework", + "urls": { + "https": "https://gitlab.com/aria-php/aria-invite-users.git", + "ssh": "git@gitlab.com:aria-php/aria-invite-users.git" + }, + "tags": ["aria", "php", "library"], + "ownership": "reference-only" + }, + { + "name": "aria-data-subscription", + "description": "Data source subscription framework for feeds", + "urls": { + "https": "https://gitlab.com/aria-php/aria-data-subscription.git", + "ssh": "git@gitlab.com:aria-php/aria-data-subscription.git" + }, + "tags": ["aria", "php", "library"], + "ownership": "reference-only" + }, + { + "name": "aria-stats", + "description": "Performance statistics monitoring", + "urls": { + "https": "https://gitlab.com/aria-php/aria-stats.git", + "ssh": "git@gitlab.com:aria-php/aria-stats.git" + }, + "tags": ["aria", "php", "library"], + "ownership": "reference-only" + }, + { + "name": "aria-site-logger", + "description": "Monolog plugin for ARIA site logging", + "urls": { + "https": "https://gitlab.com/aria-php/aria-site-logger.git", + "ssh": "git@gitlab.com:aria-php/aria-site-logger.git" + }, + "tags": ["aria", "php", "library"], + "ownership": "reference-only" + }, + { + "name": "aria-service-ai", + "description": "Service AI library", + "urls": { + "https": "https://gitlab.com/aria-php/aria-service-ai.git", + "ssh": "git@gitlab.com:aria-php/aria-service-ai.git" + }, + "tags": ["aria", "php", "library"], + "ownership": "reference-only" + }, + { + "name": "keycloak-api", + "description": "PHP bindings for Keycloak Account API", + "urls": { + "https": "https://gitlab.com/aria-php/keycloak-api.git", + "ssh": "git@gitlab.com:aria-php/keycloak-api.git" + }, + "tags": ["aria", "php", "integration"], + "ownership": "reference-only" + }, + { + "name": "doi-package", + "description": "DOI microservice client", + "urls": { + "https": "https://gitlab.com/aria-php/doi-package.git", + "ssh": "git@gitlab.com:aria-php/doi-package.git" + }, + "tags": ["aria", "php", "integration"], + "ownership": "reference-only" + }, + { + "name": "molgenis-php-client", + "description": "PHP client for Molgenis database", + "urls": { + "https": "https://gitlab.com/aria-php/molgenis-php-client.git", + "ssh": "git@gitlab.com:aria-php/molgenis-php-client.git" + }, + "tags": ["aria", "php", "integration"], + "ownership": "reference-only" + }, + { + "name": "shibboleth-idp-dockerized", + "description": "Dockerized Shibboleth IdP (identity federation)", + "urls": { + "https": "https://gitlab.com/aria-php/shibboleth-idp-dockerized.git", + "ssh": "git@gitlab.com:aria-php/shibboleth-idp-dockerized.git" + }, + "tags": ["aria", "infrastructure"], + "ownership": "reference-only" + }, + { + "name": "rtd-compiler", + "description": "ReadTheDocs compiler for ARIA documentation", + "urls": { + "https": "https://gitlab.com/aria-php/rtd-compiler.git", + "ssh": "git@gitlab.com:aria-php/rtd-compiler.git" + }, + "tags": ["aria", "infrastructure"], + "ownership": "reference-only" + } + ] + } + ], + "claudeConfig": { + "skills": [ + { "name": "database-admin", "path": "claude-code/shared/skills/database-admin" }, + { "name": "devops", "path": "claude-code/shared/skills/devops" }, + { "name": "technical-writer", "path": "claude-code/shared/skills/technical-writer" }, + { "name": "git", "path": "claude-code/shared/skills/git" }, + { "name": "github", "path": "claude-code/shared/skills/github" }, + { "name": "ascii-art", "path": "claude-code/shared/skills/ascii-art" }, + { "name": "playwright-skill", "path": "claude-code/smartem-frontend/skills/playwright-skill" } + ], + "defaultPermissions": { + "allow": [ + "Bash(git:*)", + "Bash(ls:*)", + "Bash(cat:*)", + "WebSearch", + "mcp__serena__*" + ] + } + }, + "serenaConfig": { + "languages": ["typescript", "python"], + "encoding": "utf-8", + "ignoreAllFilesInGitignore": true, + "projectName": "smartem-workspace" + }, + "mcpConfig": { + "serena": { + "command": "uvx", + "args": [ + "--from", + "git+https://github.com/oraios/serena", + "serena", + "start-mcp-server", + "--context", + "ide-assistant", + "--project", + "${PWD}" + ] + } + } +} diff --git a/packages/smartem-workspace/smartem_workspace/config/schema.py b/packages/smartem-workspace/smartem_workspace/config/schema.py new file mode 100644 index 0000000..ed59b0f --- /dev/null +++ b/packages/smartem-workspace/smartem_workspace/config/schema.py @@ -0,0 +1,159 @@ +"""Pydantic models for workspace configuration.""" + +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field + + +class RepoUrls(BaseModel): + """Repository clone URLs.""" + + https: str + ssh: str + + +class Repository(BaseModel): + """Repository definition.""" + + name: str + description: str + urls: RepoUrls + tags: list[str] = Field(default_factory=list) + ownership: Literal["full", "reference-only"] | None = None + required: bool = False + + +class Organization(BaseModel): + """Organization containing repositories.""" + + model_config = ConfigDict(populate_by_name=True) + + name: str + displayName: str = Field(alias="displayName") + url: str + provider: Literal["github", "gitlab"] + localDir: str | None = Field(default=None, alias="localDir") + repos: list[Repository] + + +class Preset(BaseModel): + """Preset repository selection.""" + + description: str + repos: list[str] + + +class ExternalLinks(BaseModel): + """External documentation and project links.""" + + model_config = ConfigDict(populate_by_name=True) + + docs: str + projectBoard: str = Field(alias="projectBoard") + + +class SkillDefinition(BaseModel): + """Claude Code skill definition.""" + + name: str + path: str + + +class DefaultPermissions(BaseModel): + """Default Claude Code permissions.""" + + allow: list[str] + + +class ClaudeConfig(BaseModel): + """Claude Code configuration.""" + + model_config = ConfigDict(populate_by_name=True) + + skills: list[SkillDefinition] + defaultPermissions: DefaultPermissions = Field(alias="defaultPermissions") + + +class SerenaConfig(BaseModel): + """Serena MCP server configuration.""" + + model_config = ConfigDict(populate_by_name=True) + + languages: list[str] + encoding: str = "utf-8" + ignoreAllFilesInGitignore: bool = Field(default=True, alias="ignoreAllFilesInGitignore") + projectName: str = Field(alias="projectName") + + +class McpServerConfig(BaseModel): + """MCP server command configuration.""" + + command: str + args: list[str] + + +class McpConfig(BaseModel): + """MCP servers configuration.""" + + serena: McpServerConfig + + +class ReposConfig(BaseModel): + """Root configuration schema.""" + + model_config = ConfigDict(populate_by_name=True) + + version: str = "1.0.0" + links: ExternalLinks + presets: dict[str, Preset] + organizations: list[Organization] + claudeConfig: ClaudeConfig = Field(alias="claudeConfig") + serenaConfig: SerenaConfig = Field(alias="serenaConfig") + mcpConfig: McpConfig = Field(alias="mcpConfig") + + def get_preset(self, name: str) -> Preset | None: + """Get a preset by name.""" + return self.presets.get(name) + + def get_organization(self, name: str) -> Organization | None: + """Get an organization by name.""" + for org in self.organizations: + if org.name == name: + return org + return None + + def get_all_repos(self) -> list[tuple[Organization, Repository]]: + """Get all repositories with their organizations.""" + result = [] + for org in self.organizations: + for repo in org.repos: + result.append((org, repo)) + return result + + def resolve_preset(self, preset_name: str) -> list[tuple[Organization, Repository]]: + """Resolve a preset to a list of (org, repo) tuples.""" + preset = self.get_preset(preset_name) + if not preset: + return [] + + result = [] + for pattern in preset.repos: + if pattern == "*": + return self.get_all_repos() + + if "/" in pattern: + org_name, repo_pattern = pattern.split("/", 1) + org = self.get_organization(org_name) + if not org: + continue + + if repo_pattern == "*": + for repo in org.repos: + result.append((org, repo)) + else: + for repo in org.repos: + if repo.name == repo_pattern: + result.append((org, repo)) + break + + return result diff --git a/packages/smartem-workspace/smartem_workspace/interactive/__init__.py b/packages/smartem-workspace/smartem_workspace/interactive/__init__.py new file mode 100644 index 0000000..278301b --- /dev/null +++ b/packages/smartem-workspace/smartem_workspace/interactive/__init__.py @@ -0,0 +1,13 @@ +"""Interactive prompts for workspace setup.""" + +from smartem_workspace.interactive.prompts import ( + confirm, + select_preset, + select_repos, +) + +__all__ = [ + "confirm", + "select_preset", + "select_repos", +] diff --git a/packages/smartem-workspace/smartem_workspace/interactive/prompts.py b/packages/smartem-workspace/smartem_workspace/interactive/prompts.py new file mode 100644 index 0000000..7b7c86c --- /dev/null +++ b/packages/smartem-workspace/smartem_workspace/interactive/prompts.py @@ -0,0 +1,126 @@ +"""Rich-based interactive prompts for repo selection.""" + +from rich.console import Console +from rich.panel import Panel +from rich.prompt import Confirm, Prompt +from rich.table import Table + +from smartem_workspace.config.schema import Organization, ReposConfig, Repository + +console = Console() + + +def select_preset(config: ReposConfig) -> str | None: + """ + Prompt user to select a preset or custom selection. + + Returns: + Preset name or None for custom selection + """ + console.print() + console.print("[bold]Available presets:[/bold]") + + table = Table(show_header=True, header_style="bold cyan") + table.add_column("Option", style="cyan", width=6) + table.add_column("Preset", style="green") + table.add_column("Description") + table.add_column("Repos", justify="right") + + preset_names = list(config.presets.keys()) + for i, name in enumerate(preset_names, 1): + preset = config.presets[name] + repo_count = len(preset.repos) if preset.repos != ["*"] else "all" + table.add_row(str(i), name, preset.description, str(repo_count)) + + table.add_row(str(len(preset_names) + 1), "custom", "Select repos interactively", "-") + + console.print(table) + console.print() + + choice = Prompt.ask( + "Select option", + choices=[str(i) for i in range(1, len(preset_names) + 2)], + default="1", + ) + + choice_idx = int(choice) - 1 + if choice_idx < len(preset_names): + return preset_names[choice_idx] + return None + + +def select_repos(config: ReposConfig) -> list[tuple[Organization, Repository]]: + """ + Interactively select repositories, grouped by organization. + + Returns: + List of (organization, repository) tuples + """ + selected: list[tuple[Organization, Repository]] = [] + + for org in config.organizations: + console.print() + console.print(Panel(f"[bold]{org.displayName}[/bold] ({org.url})", style="blue")) + + table = Table(show_header=True, header_style="bold") + table.add_column("#", style="cyan", width=4) + table.add_column("Repository", style="green") + table.add_column("Description") + table.add_column("Tags") + table.add_column("Ownership") + + for i, repo in enumerate(org.repos, 1): + tags = ", ".join(repo.tags) if repo.tags else "-" + ownership = repo.ownership or "full" + table.add_row(str(i), repo.name, repo.description, tags, ownership) + + console.print(table) + + if Confirm.ask(f"Include repos from {org.displayName}?", default=True): + repo_input = Prompt.ask( + "Enter repo numbers (comma-separated) or 'all'", + default="all", + ) + + if repo_input.lower() == "all": + for repo in org.repos: + selected.append((org, repo)) + else: + try: + indices = [int(x.strip()) - 1 for x in repo_input.split(",")] + for idx in indices: + if 0 <= idx < len(org.repos): + selected.append((org, org.repos[idx])) + except ValueError: + console.print("[yellow]Invalid input, skipping organization[/yellow]") + + return selected + + +def confirm(message: str, default: bool = True) -> bool: + """Simple confirmation prompt.""" + return Confirm.ask(message, default=default) + + +def display_selection_summary(repos: list[tuple[Organization, Repository]]) -> None: + """Display a summary of selected repositories.""" + if not repos: + console.print("[yellow]No repositories selected[/yellow]") + return + + console.print() + console.print("[bold]Selected repositories:[/bold]") + + by_org: dict[str, list[Repository]] = {} + for org, repo in repos: + if org.name not in by_org: + by_org[org.name] = [] + by_org[org.name].append(repo) + + for org_name, org_repos in by_org.items(): + console.print(f" [cyan]{org_name}[/cyan]: {len(org_repos)} repos") + for repo in org_repos: + console.print(f" - {repo.name}") + + console.print() + console.print(f"[bold]Total: {len(repos)} repositories[/bold]") diff --git a/packages/smartem-workspace/smartem_workspace/setup/__init__.py b/packages/smartem-workspace/smartem_workspace/setup/__init__.py new file mode 100644 index 0000000..6f918bd --- /dev/null +++ b/packages/smartem-workspace/smartem_workspace/setup/__init__.py @@ -0,0 +1,5 @@ +"""Setup modules for workspace initialization.""" + +from smartem_workspace.setup.bootstrap import bootstrap_workspace + +__all__ = ["bootstrap_workspace"] diff --git a/packages/smartem-workspace/smartem_workspace/setup/bootstrap.py b/packages/smartem-workspace/smartem_workspace/setup/bootstrap.py new file mode 100644 index 0000000..b238822 --- /dev/null +++ b/packages/smartem-workspace/smartem_workspace/setup/bootstrap.py @@ -0,0 +1,120 @@ +"""Main bootstrap orchestration for workspace setup.""" + +from pathlib import Path + +from rich.console import Console + +from smartem_workspace.config.schema import Organization, ReposConfig, Repository +from smartem_workspace.interactive.prompts import ( + confirm, + display_selection_summary, + select_preset, + select_repos, +) +from smartem_workspace.setup.claude import setup_claude_config +from smartem_workspace.setup.repos import clone_repos +from smartem_workspace.setup.serena import setup_serena_config +from smartem_workspace.setup.workspace import display_next_steps, setup_workspace_structure + +console = Console() + + +def ensure_devtools_in_selection( + repos: list[tuple[Organization, Repository]], + config: ReposConfig, +) -> list[tuple[Organization, Repository]]: + """Ensure smartem-devtools is in the selection (required).""" + has_devtools = any(org.name == "DiamondLightSource" and repo.name == "smartem-devtools" for org, repo in repos) + + if not has_devtools: + dls_org = config.get_organization("DiamondLightSource") + if dls_org: + for repo in dls_org.repos: + if repo.name == "smartem-devtools": + repos.insert(0, (dls_org, repo)) + console.print("[dim]Added smartem-devtools (required)[/dim]") + break + + return repos + + +def bootstrap_workspace( + config: ReposConfig, + workspace_path: Path, + preset: str | None = None, + interactive: bool = True, + use_ssh: bool = False, + skip_claude: bool = False, + skip_serena: bool = False, +) -> bool: + """ + Main bootstrap function to set up a SmartEM workspace. + + Args: + config: Loaded workspace configuration + workspace_path: Target directory for workspace + preset: Preset name (if provided, skips repo selection) + interactive: Enable interactive prompts + use_ssh: Use SSH URLs for cloning + skip_claude: Skip Claude Code setup + skip_serena: Skip Serena MCP setup + + Returns: + True if setup completed successfully + """ + console.print() + console.print(f"[bold]Workspace path:[/bold] {workspace_path.absolute()}") + + workspace_path.mkdir(parents=True, exist_ok=True) + + selected_repos: list[tuple[Organization, Repository]] = [] + + if preset: + selected_repos = config.resolve_preset(preset) + if not selected_repos: + console.print(f"[red]Unknown preset: {preset}[/red]") + console.print(f"Available presets: {', '.join(config.presets.keys())}") + return False + console.print(f"[dim]Using preset: {preset}[/dim]") + elif interactive: + preset_choice = select_preset(config) + selected_repos = config.resolve_preset(preset_choice) if preset_choice else select_repos(config) + else: + console.print("[red]No preset specified and interactive mode disabled[/red]") + return False + + selected_repos = ensure_devtools_in_selection(selected_repos, config) + + display_selection_summary(selected_repos) + + if interactive and not confirm("Proceed with setup?"): + console.print("[yellow]Setup cancelled[/yellow]") + return False + + if not setup_workspace_structure(workspace_path): + console.print("[red]Failed to create workspace structure[/red]") + return False + + success, failed = clone_repos( + repos=selected_repos, + workspace_path=workspace_path, + use_ssh=use_ssh, + devtools_first=True, + ) + + console.print() + console.print(f"[bold]Clone results:[/bold] {success} succeeded, {failed} failed") + + if failed > 0 and interactive and not confirm("Some repos failed to clone. Continue with setup?"): + return False + + if not skip_claude: + setup_claude_config(config, workspace_path) + + if not skip_serena: + project_name = workspace_path.name or "smartem-workspace" + setup_serena_config(config, workspace_path, project_name) + + display_next_steps(workspace_path) + + return True diff --git a/packages/smartem-workspace/smartem_workspace/setup/claude.py b/packages/smartem-workspace/smartem_workspace/setup/claude.py new file mode 100644 index 0000000..39563af --- /dev/null +++ b/packages/smartem-workspace/smartem_workspace/setup/claude.py @@ -0,0 +1,94 @@ +"""Claude Code configuration setup.""" + +import json +import os +from pathlib import Path + +from rich.console import Console + +from smartem_workspace.config.schema import ReposConfig + +console = Console() + + +def setup_claude_config( + config: ReposConfig, + workspace_path: Path, +) -> bool: + """ + Set up Claude Code configuration. + + Creates: + - claude-config/ symlink to smartem-devtools/claude-code + - .claude/skills/ symlinks to skills + - .claude/settings.local.json + + Returns: + True if successful + """ + console.print() + console.print("[bold]Setting up Claude Code configuration...[/bold]") + + devtools_path = workspace_path / "repos" / "DiamondLightSource" / "smartem-devtools" + if not devtools_path.exists(): + console.print("[red]smartem-devtools not found. Clone it first.[/red]") + return False + + claude_config_source = devtools_path / "claude-code" + if not claude_config_source.exists(): + console.print(f"[yellow]claude-code directory not found in {devtools_path}[/yellow]") + + claude_config_link = workspace_path / "claude-config" + if not claude_config_link.exists() and claude_config_source.exists(): + try: + os.symlink(str(claude_config_source.resolve()), str(claude_config_link)) + console.print(f" [green]Created symlink: claude-config -> {claude_config_source}[/green]") + except OSError as e: + console.print(f" [yellow]Could not create claude-config symlink: {e}[/yellow]") + + claude_dir = workspace_path / ".claude" + claude_dir.mkdir(parents=True, exist_ok=True) + + skills_dir = claude_dir / "skills" + skills_dir.mkdir(parents=True, exist_ok=True) + + for skill in config.claudeConfig.skills: + skill_source = devtools_path / skill.path + skill_link = skills_dir / skill.name + + if skill_link.exists(): + console.print(f" [dim]Skill {skill.name} already linked[/dim]") + continue + + if skill_source.exists(): + try: + os.symlink(str(skill_source.resolve()), str(skill_link)) + console.print(f" [green]Linked skill: {skill.name}[/green]") + except OSError as e: + console.print(f" [yellow]Could not link skill {skill.name}: {e}[/yellow]") + else: + console.print(f" [dim]Skill source not found: {skill_source}[/dim]") + + settings_path = claude_dir / "settings.local.json" + if not settings_path.exists(): + settings = { + "permissions": config.claudeConfig.defaultPermissions.model_dump(), + } + settings_path.write_text(json.dumps(settings, indent=2)) + console.print(f" [green]Created {settings_path.name}[/green]") + else: + console.print(f" [dim]{settings_path.name} already exists[/dim]") + + claude_md_source = devtools_path / "claude-code" / "CLAUDE.md" + claude_md_target = workspace_path / "CLAUDE.md" + + if claude_md_source.exists() and not claude_md_target.exists(): + import shutil + + shutil.copy(claude_md_source, claude_md_target) + console.print(" [green]Copied CLAUDE.md[/green]") + elif claude_md_target.exists(): + console.print(" [dim]CLAUDE.md already exists[/dim]") + + console.print("[green]Claude Code configuration complete[/green]") + return True diff --git a/packages/smartem-workspace/smartem_workspace/setup/repos.py b/packages/smartem-workspace/smartem_workspace/setup/repos.py new file mode 100644 index 0000000..cfd0ce8 --- /dev/null +++ b/packages/smartem-workspace/smartem_workspace/setup/repos.py @@ -0,0 +1,184 @@ +"""Repository cloning operations.""" + +import subprocess +from pathlib import Path + +from rich.console import Console +from rich.progress import Progress, SpinnerColumn, TextColumn + +from smartem_workspace.config.schema import Organization, Repository + +console = Console() + + +def get_repo_url(repo: Repository, use_ssh: bool) -> str: + """Get the clone URL based on preference.""" + return repo.urls.ssh if use_ssh else repo.urls.https + + +def get_local_dir(org: Organization) -> str: + """Get the local directory name for an organization.""" + return org.localDir if org.localDir else org.name + + +def clone_repo( + repo: Repository, + org: Organization, + repos_dir: Path, + use_ssh: bool = False, +) -> bool: + """ + Clone a single repository. + + Returns: + True if successful or already exists, False on error + """ + org_dir = repos_dir / get_local_dir(org) + org_dir.mkdir(parents=True, exist_ok=True) + + repo_path = org_dir / repo.name + + if repo_path.exists(): + console.print(f" [dim]Skipping {repo.name} (already exists)[/dim]") + return True + + url = get_repo_url(repo, use_ssh) + + try: + result = subprocess.run( + ["git", "clone", url, str(repo_path)], + capture_output=True, + text=True, + timeout=300, + ) + if result.returncode != 0: + console.print(f" [red]Failed to clone {repo.name}: {result.stderr}[/red]") + return False + console.print(f" [green]Cloned {repo.name}[/green]") + return True + except subprocess.TimeoutExpired: + console.print(f" [red]Timeout cloning {repo.name}[/red]") + return False + except FileNotFoundError: + console.print("[red]Git not found. Please install git and try again.[/red]") + return False + + +def clone_repos( + repos: list[tuple[Organization, Repository]], + workspace_path: Path, + use_ssh: bool = False, + devtools_first: bool = True, +) -> tuple[int, int]: + """ + Clone multiple repositories. + + Args: + repos: List of (org, repo) tuples to clone + workspace_path: Root workspace directory + use_ssh: Use SSH URLs instead of HTTPS + devtools_first: Clone smartem-devtools first (required for config) + + Returns: + Tuple of (success_count, failure_count) + """ + repos_dir = workspace_path / "repos" + repos_dir.mkdir(parents=True, exist_ok=True) + + success = 0 + failed = 0 + + if devtools_first: + devtools = None + remaining = [] + for org, repo in repos: + if org.name == "DiamondLightSource" and repo.name == "smartem-devtools": + devtools = (org, repo) + else: + remaining.append((org, repo)) + + if devtools: + console.print() + console.print("[bold]Cloning smartem-devtools (required)...[/bold]") + org, repo = devtools + if clone_repo(repo, org, repos_dir, use_ssh): + success += 1 + else: + failed += 1 + console.print("[red]Failed to clone smartem-devtools. Cannot continue.[/red]") + return success, failed + len(remaining) + + repos = remaining + + console.print() + console.print("[bold]Cloning repositories...[/bold]") + + with Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + console=console, + ) as progress: + task = progress.add_task("Cloning...", total=len(repos)) + + for org, repo in repos: + progress.update(task, description=f"Cloning {org.name}/{repo.name}...") + if clone_repo(repo, org, repos_dir, use_ssh): + success += 1 + else: + failed += 1 + progress.advance(task) + + return success, failed + + +def pull_repo(repo_path: Path) -> bool: + """ + Pull latest changes for a repository. + + Returns: + True if successful, False on error + """ + try: + result = subprocess.run( + ["git", "-C", str(repo_path), "pull", "--ff-only"], + capture_output=True, + text=True, + timeout=120, + ) + return result.returncode == 0 + except (subprocess.TimeoutExpired, FileNotFoundError): + return False + + +def get_repo_status(repo_path: Path) -> dict | None: + """ + Get status information for a repository. + + Returns: + Dict with status info or None if not a git repo + """ + if not (repo_path / ".git").exists(): + return None + + try: + branch_result = subprocess.run( + ["git", "-C", str(repo_path), "branch", "--show-current"], + capture_output=True, + text=True, + ) + branch = branch_result.stdout.strip() if branch_result.returncode == 0 else "unknown" + + status_result = subprocess.run( + ["git", "-C", str(repo_path), "status", "--porcelain"], + capture_output=True, + text=True, + ) + has_changes = bool(status_result.stdout.strip()) + + return { + "branch": branch, + "has_changes": has_changes, + "path": str(repo_path), + } + except Exception: + return None diff --git a/packages/smartem-workspace/smartem_workspace/setup/serena.py b/packages/smartem-workspace/smartem_workspace/setup/serena.py new file mode 100644 index 0000000..de0a599 --- /dev/null +++ b/packages/smartem-workspace/smartem_workspace/setup/serena.py @@ -0,0 +1,68 @@ +"""Serena MCP server configuration setup.""" + +import json +from pathlib import Path + +import yaml +from rich.console import Console + +from smartem_workspace.config.schema import ReposConfig + +console = Console() + + +def setup_serena_config( + config: ReposConfig, + workspace_path: Path, + project_name: str = "smartem-workspace", +) -> bool: + """ + Set up Serena MCP server configuration. + + Creates: + - .serena/project.yml + - .mcp.json + + Returns: + True if successful + """ + console.print() + console.print("[bold]Setting up Serena MCP configuration...[/bold]") + + serena_dir = workspace_path / ".serena" + serena_dir.mkdir(parents=True, exist_ok=True) + + project_yml_path = serena_dir / "project.yml" + if not project_yml_path.exists(): + serena_config = { + "languages": config.serenaConfig.languages, + "encoding": config.serenaConfig.encoding, + "ignore_all_files_in_gitignore": config.serenaConfig.ignoreAllFilesInGitignore, + "project_name": project_name, + } + + with open(project_yml_path, "w") as f: + yaml.dump(serena_config, f, default_flow_style=False) + + console.print(f" [green]Created {project_yml_path.name}[/green]") + else: + console.print(f" [dim]{project_yml_path.name} already exists[/dim]") + + mcp_json_path = workspace_path / ".mcp.json" + if not mcp_json_path.exists(): + mcp_config = { + "mcpServers": { + "serena": { + "command": config.mcpConfig.serena.command, + "args": [arg.replace("${PWD}", str(workspace_path)) for arg in config.mcpConfig.serena.args], + } + } + } + + mcp_json_path.write_text(json.dumps(mcp_config, indent=2)) + console.print(f" [green]Created {mcp_json_path.name}[/green]") + else: + console.print(f" [dim]{mcp_json_path.name} already exists[/dim]") + + console.print("[green]Serena configuration complete[/green]") + return True diff --git a/packages/smartem-workspace/smartem_workspace/setup/workspace.py b/packages/smartem-workspace/smartem_workspace/setup/workspace.py new file mode 100644 index 0000000..6d6e818 --- /dev/null +++ b/packages/smartem-workspace/smartem_workspace/setup/workspace.py @@ -0,0 +1,77 @@ +"""Workspace directory structure setup.""" + +from pathlib import Path + +from rich.console import Console + +console = Console() + + +def setup_workspace_structure(workspace_path: Path) -> bool: + """ + Set up the basic workspace directory structure. + + Creates: + - repos/ + - tmp/ + - testdata/ + + Returns: + True if successful + """ + console.print() + console.print("[bold]Setting up workspace structure...[/bold]") + + directories = [ + "repos", + "tmp", + "testdata", + ] + + for dir_name in directories: + dir_path = workspace_path / dir_name + if not dir_path.exists(): + dir_path.mkdir(parents=True, exist_ok=True) + console.print(f" [green]Created {dir_name}/[/green]") + else: + console.print(f" [dim]{dir_name}/ already exists[/dim]") + + gitignore_path = workspace_path / ".gitignore" + if not gitignore_path.exists(): + gitignore_content = """# Workspace directories (not versioned) +tmp/ +testdata/ + +# IDE +.idea/ +.vscode/ + +# OS +.DS_Store +Thumbs.db + +# Local Claude settings +.claude/settings.local.json +""" + gitignore_path.write_text(gitignore_content) + console.print(" [green]Created .gitignore[/green]") + else: + console.print(" [dim].gitignore already exists[/dim]") + + console.print("[green]Workspace structure complete[/green]") + return True + + +def display_next_steps(workspace_path: Path) -> None: + """Display post-setup instructions.""" + console.print() + console.print("[bold green]Workspace setup complete![/bold green]") + console.print() + console.print("[bold]Next steps:[/bold]") + console.print(f" 1. cd {workspace_path}") + console.print(" 2. Open in your IDE (e.g., code .)") + console.print(" 3. If using Claude Code, it will auto-detect the configuration") + console.print() + console.print("[bold]Useful links:[/bold]") + console.print(" - Docs: https://diamondlightsource.github.io/smartem-decisions/") + console.print(" - Project Board: https://github.com/orgs/DiamondLightSource/projects/51/views/1") diff --git a/packages/smartem-workspace/smartem_workspace/utils/__init__.py b/packages/smartem-workspace/smartem_workspace/utils/__init__.py new file mode 100644 index 0000000..79bebe5 --- /dev/null +++ b/packages/smartem-workspace/smartem_workspace/utils/__init__.py @@ -0,0 +1,11 @@ +"""Utility functions for smartem-workspace.""" + +from smartem_workspace.utils.git import check_git_available, run_git_command +from smartem_workspace.utils.paths import ensure_directory, resolve_workspace_path + +__all__ = [ + "check_git_available", + "ensure_directory", + "resolve_workspace_path", + "run_git_command", +] diff --git a/packages/smartem-workspace/smartem_workspace/utils/git.py b/packages/smartem-workspace/smartem_workspace/utils/git.py new file mode 100644 index 0000000..ddb5ce6 --- /dev/null +++ b/packages/smartem-workspace/smartem_workspace/utils/git.py @@ -0,0 +1,70 @@ +"""Git utility functions.""" + +import subprocess +from pathlib import Path + + +def check_git_available() -> bool: + """Check if git is available in PATH.""" + try: + result = subprocess.run( + ["git", "--version"], + capture_output=True, + text=True, + ) + return result.returncode == 0 + except FileNotFoundError: + return False + + +def run_git_command( + args: list[str], + cwd: Path | None = None, + timeout: int = 120, +) -> tuple[int, str, str]: + """ + Run a git command and return the result. + + Args: + args: Git command arguments (without 'git' prefix) + cwd: Working directory for the command + timeout: Timeout in seconds + + Returns: + Tuple of (return_code, stdout, stderr) + """ + try: + result = subprocess.run( + ["git", *args], + cwd=cwd, + capture_output=True, + text=True, + timeout=timeout, + ) + return result.returncode, result.stdout, result.stderr + except subprocess.TimeoutExpired: + return -1, "", "Command timed out" + except FileNotFoundError: + return -1, "", "Git not found" + + +def get_current_branch(repo_path: Path) -> str | None: + """Get the current branch name of a repository.""" + returncode, stdout, _ = run_git_command( + ["branch", "--show-current"], + cwd=repo_path, + ) + if returncode == 0: + return stdout.strip() + return None + + +def has_uncommitted_changes(repo_path: Path) -> bool: + """Check if a repository has uncommitted changes.""" + returncode, stdout, _ = run_git_command( + ["status", "--porcelain"], + cwd=repo_path, + ) + if returncode == 0: + return bool(stdout.strip()) + return False diff --git a/packages/smartem-workspace/smartem_workspace/utils/paths.py b/packages/smartem-workspace/smartem_workspace/utils/paths.py new file mode 100644 index 0000000..9b27c01 --- /dev/null +++ b/packages/smartem-workspace/smartem_workspace/utils/paths.py @@ -0,0 +1,65 @@ +"""Path utility functions.""" + +from pathlib import Path + + +def resolve_workspace_path(path: Path | str | None = None) -> Path: + """ + Resolve and validate the workspace path. + + Args: + path: Target path (defaults to current directory) + + Returns: + Resolved absolute path + """ + if path is None: + return Path.cwd().resolve() + + resolved = Path(path).expanduser().resolve() + return resolved + + +def ensure_directory(path: Path) -> Path: + """ + Ensure a directory exists, creating it if necessary. + + Args: + path: Directory path + + Returns: + The path (for chaining) + """ + path.mkdir(parents=True, exist_ok=True) + return path + + +def is_git_repository(path: Path) -> bool: + """Check if a path is a git repository.""" + return (path / ".git").exists() + + +def find_workspace_root(start_path: Path | None = None) -> Path | None: + """ + Find the workspace root by looking for marker files. + + Looks for .mcp.json or CLAUDE.md as workspace indicators. + + Args: + start_path: Starting directory (defaults to cwd) + + Returns: + Workspace root path or None if not found + """ + path = start_path or Path.cwd() + path = path.resolve() + + markers = [".mcp.json", "CLAUDE.md", ".serena"] + + while path != path.parent: + for marker in markers: + if (path / marker).exists(): + return path + path = path.parent + + return None diff --git a/packages/smartem-workspace/tests/__init__.py b/packages/smartem-workspace/tests/__init__.py new file mode 100644 index 0000000..d5c14c7 --- /dev/null +++ b/packages/smartem-workspace/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for smartem-workspace.""" diff --git a/packages/smartem-workspace/tests/test_config.py b/packages/smartem-workspace/tests/test_config.py new file mode 100644 index 0000000..91130f1 --- /dev/null +++ b/packages/smartem-workspace/tests/test_config.py @@ -0,0 +1,112 @@ +"""Tests for configuration loading and schema.""" + +import json +from pathlib import Path + +from smartem_workspace.config.schema import ReposConfig + + +def get_bundled_config_path() -> Path: + """Get path to the bundled repos.json.""" + return Path(__file__).parent.parent / "smartem_workspace" / "config" / "repos.json" + + +class TestReposConfig: + """Tests for ReposConfig schema.""" + + def test_bundled_config_exists(self) -> None: + """Bundled config file should exist.""" + config_path = get_bundled_config_path() + assert config_path.exists(), f"Bundled config not found at {config_path}" + + def test_bundled_config_valid_json(self) -> None: + """Bundled config should be valid JSON.""" + config_path = get_bundled_config_path() + with open(config_path) as f: + data = json.load(f) + assert isinstance(data, dict) + + def test_bundled_config_validates(self) -> None: + """Bundled config should pass Pydantic validation.""" + config_path = get_bundled_config_path() + with open(config_path) as f: + data = json.load(f) + config = ReposConfig.model_validate(data) + assert config.version == "1.0.0" + + def test_config_has_organizations(self) -> None: + """Config should have organizations defined.""" + config_path = get_bundled_config_path() + with open(config_path) as f: + data = json.load(f) + config = ReposConfig.model_validate(data) + assert len(config.organizations) > 0 + + def test_config_has_presets(self) -> None: + """Config should have presets defined.""" + config_path = get_bundled_config_path() + with open(config_path) as f: + data = json.load(f) + config = ReposConfig.model_validate(data) + assert "smartem-core" in config.presets + assert "full" in config.presets + assert "minimal" in config.presets + + def test_resolve_smartem_core_preset(self) -> None: + """smartem-core preset should resolve to expected repos.""" + config_path = get_bundled_config_path() + with open(config_path) as f: + data = json.load(f) + config = ReposConfig.model_validate(data) + + repos = config.resolve_preset("smartem-core") + repo_names = [repo.name for _, repo in repos] + + assert "smartem-decisions" in repo_names + assert "smartem-frontend" in repo_names + assert "smartem-devtools" in repo_names + + def test_resolve_minimal_preset(self) -> None: + """minimal preset should resolve to just smartem-devtools.""" + config_path = get_bundled_config_path() + with open(config_path) as f: + data = json.load(f) + config = ReposConfig.model_validate(data) + + repos = config.resolve_preset("minimal") + assert len(repos) == 1 + assert repos[0][1].name == "smartem-devtools" + + def test_get_organization(self) -> None: + """Should be able to get organization by name.""" + config_path = get_bundled_config_path() + with open(config_path) as f: + data = json.load(f) + config = ReposConfig.model_validate(data) + + dls = config.get_organization("DiamondLightSource") + assert dls is not None + assert dls.provider == "github" + + aria = config.get_organization("aria-php") + assert aria is not None + assert aria.provider == "gitlab" + + def test_unknown_organization_returns_none(self) -> None: + """Unknown organization should return None.""" + config_path = get_bundled_config_path() + with open(config_path) as f: + data = json.load(f) + config = ReposConfig.model_validate(data) + + assert config.get_organization("NonExistent") is None + + def test_unknown_preset_returns_empty(self) -> None: + """Unknown preset should return empty list.""" + config_path = get_bundled_config_path() + with open(config_path) as f: + data = json.load(f) + config = ReposConfig.model_validate(data) + + repos = config.resolve_preset("nonexistent") + assert repos == [] diff --git a/packages/smartem-workspace/tests/test_utils.py b/packages/smartem-workspace/tests/test_utils.py new file mode 100644 index 0000000..6c104c2 --- /dev/null +++ b/packages/smartem-workspace/tests/test_utils.py @@ -0,0 +1,54 @@ +"""Tests for utility functions.""" + +from pathlib import Path + +from smartem_workspace.utils.git import check_git_available +from smartem_workspace.utils.paths import ensure_directory, resolve_workspace_path + + +class TestGitUtils: + """Tests for git utility functions.""" + + def test_git_available(self) -> None: + """Git should be available on the system.""" + assert check_git_available() is True + + +class TestPathUtils: + """Tests for path utility functions.""" + + def test_resolve_workspace_path_none(self) -> None: + """None should resolve to current directory.""" + result = resolve_workspace_path(None) + assert result == Path.cwd().resolve() + + def test_resolve_workspace_path_string(self) -> None: + """String path should be resolved.""" + result = resolve_workspace_path("/tmp") + assert result == Path("/tmp") + + def test_resolve_workspace_path_expands_tilde(self) -> None: + """Tilde should be expanded.""" + result = resolve_workspace_path("~/test") + assert "~" not in str(result) + + def test_ensure_directory(self, tmp_path: Path) -> None: + """ensure_directory should create missing directories.""" + test_dir = tmp_path / "a" / "b" / "c" + assert not test_dir.exists() + + result = ensure_directory(test_dir) + + assert test_dir.exists() + assert test_dir.is_dir() + assert result == test_dir + + def test_ensure_directory_existing(self, tmp_path: Path) -> None: + """ensure_directory should not fail on existing directory.""" + test_dir = tmp_path / "existing" + test_dir.mkdir() + + result = ensure_directory(test_dir) + + assert test_dir.exists() + assert result == test_dir diff --git a/packages/smartem-workspace/uv.lock b/packages/smartem-workspace/uv.lock new file mode 100644 index 0000000..5961ccd --- /dev/null +++ b/packages/smartem-workspace/uv.lock @@ -0,0 +1,612 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/16/ce/8a777047513153587e5434fd752e89334ac33e379aa3497db860eeb60377/anyio-4.12.0.tar.gz", hash = "sha256:73c693b567b0c55130c104d0b43a9baf3aa6a31fc6110116509f27bf75e21ec0", size = 228266, upload-time = "2025-11-28T23:37:38.911Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/9c/36c5c37947ebfb8c7f22e0eb6e4d188ee2d53aa3880f3f2744fb894f0cb1/anyio-4.12.0-py3-none-any.whl", hash = "sha256:dad2376a628f98eeca4881fc56cd06affd18f659b17a747d3ff0307ced94b1bb", size = 113362, upload-time = "2025-11-28T23:36:57.897Z" }, +] + +[[package]] +name = "certifi" +version = "2025.11.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/8c/58f469717fa48465e4a50c014a0400602d3c437d7c0c468e17ada824da3a/certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316", size = 160538, upload-time = "2025-11-12T02:54:51.517Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438, upload-time = "2025-11-12T02:54:49.735Z" }, +] + +[[package]] +name = "click" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/45/2c665ca77ec32ad67e25c77daf1cee28ee4558f3bc571cdbaf88a00b9f23/coverage-7.13.0.tar.gz", hash = "sha256:a394aa27f2d7ff9bc04cf703817773a59ad6dfbd577032e690f961d2460ee936", size = 820905, upload-time = "2025-12-08T13:14:38.055Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/dc/888bf90d8b1c3d0b4020a40e52b9f80957d75785931ec66c7dfaccc11c7d/coverage-7.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0dfa3855031070058add1a59fdfda0192fd3e8f97e7c81de0596c145dea51820", size = 218104, upload-time = "2025-12-08T13:12:33.333Z" }, + { url = "https://files.pythonhosted.org/packages/8d/ea/069d51372ad9c380214e86717e40d1a743713a2af191cfba30a0911b0a4a/coverage-7.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4fdb6f54f38e334db97f72fa0c701e66d8479af0bc3f9bfb5b90f1c30f54500f", size = 218606, upload-time = "2025-12-08T13:12:34.498Z" }, + { url = "https://files.pythonhosted.org/packages/68/09/77b1c3a66c2aa91141b6c4471af98e5b1ed9b9e6d17255da5eb7992299e3/coverage-7.13.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7e442c013447d1d8d195be62852270b78b6e255b79b8675bad8479641e21fd96", size = 248999, upload-time = "2025-12-08T13:12:36.02Z" }, + { url = "https://files.pythonhosted.org/packages/0a/32/2e2f96e9d5691eaf1181d9040f850b8b7ce165ea10810fd8e2afa534cef7/coverage-7.13.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1ed5630d946859de835a85e9a43b721123a8a44ec26e2830b296d478c7fd4259", size = 250925, upload-time = "2025-12-08T13:12:37.221Z" }, + { url = "https://files.pythonhosted.org/packages/7b/45/b88ddac1d7978859b9a39a8a50ab323186148f1d64bc068f86fc77706321/coverage-7.13.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f15a931a668e58087bc39d05d2b4bf4b14ff2875b49c994bbdb1c2217a8daeb", size = 253032, upload-time = "2025-12-08T13:12:38.763Z" }, + { url = "https://files.pythonhosted.org/packages/71/cb/e15513f94c69d4820a34b6bf3d2b1f9f8755fa6021be97c7065442d7d653/coverage-7.13.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:30a3a201a127ea57f7e14ba43c93c9c4be8b7d17a26e03bb49e6966d019eede9", size = 249134, upload-time = "2025-12-08T13:12:40.382Z" }, + { url = "https://files.pythonhosted.org/packages/09/61/d960ff7dc9e902af3310ce632a875aaa7860f36d2bc8fc8b37ee7c1b82a5/coverage-7.13.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7a485ff48fbd231efa32d58f479befce52dcb6bfb2a88bb7bf9a0b89b1bc8030", size = 250731, upload-time = "2025-12-08T13:12:41.992Z" }, + { url = "https://files.pythonhosted.org/packages/98/34/c7c72821794afc7c7c2da1db8f00c2c98353078aa7fb6b5ff36aac834b52/coverage-7.13.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:22486cdafba4f9e471c816a2a5745337742a617fef68e890d8baf9f3036d7833", size = 248795, upload-time = "2025-12-08T13:12:43.331Z" }, + { url = "https://files.pythonhosted.org/packages/0a/5b/e0f07107987a43b2def9aa041c614ddb38064cbf294a71ef8c67d43a0cdd/coverage-7.13.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:263c3dbccc78e2e331e59e90115941b5f53e85cfcc6b3b2fbff1fd4e3d2c6ea8", size = 248514, upload-time = "2025-12-08T13:12:44.546Z" }, + { url = "https://files.pythonhosted.org/packages/71/c2/c949c5d3b5e9fc6dd79e1b73cdb86a59ef14f3709b1d72bf7668ae12e000/coverage-7.13.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e5330fa0cc1f5c3c4c3bb8e101b742025933e7848989370a1d4c8c5e401ea753", size = 249424, upload-time = "2025-12-08T13:12:45.759Z" }, + { url = "https://files.pythonhosted.org/packages/11/f1/bbc009abd6537cec0dffb2cc08c17a7f03de74c970e6302db4342a6e05af/coverage-7.13.0-cp311-cp311-win32.whl", hash = "sha256:0f4872f5d6c54419c94c25dd6ae1d015deeb337d06e448cd890a1e89a8ee7f3b", size = 220597, upload-time = "2025-12-08T13:12:47.378Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f6/d9977f2fb51c10fbaed0718ce3d0a8541185290b981f73b1d27276c12d91/coverage-7.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:51a202e0f80f241ccb68e3e26e19ab5b3bf0f813314f2c967642f13ebcf1ddfe", size = 221536, upload-time = "2025-12-08T13:12:48.7Z" }, + { url = "https://files.pythonhosted.org/packages/be/ad/3fcf43fd96fb43e337a3073dea63ff148dcc5c41ba7a14d4c7d34efb2216/coverage-7.13.0-cp311-cp311-win_arm64.whl", hash = "sha256:d2a9d7f1c11487b1c69367ab3ac2d81b9b3721f097aa409a3191c3e90f8f3dd7", size = 220206, upload-time = "2025-12-08T13:12:50.365Z" }, + { url = "https://files.pythonhosted.org/packages/9b/f1/2619559f17f31ba00fc40908efd1fbf1d0a5536eb75dc8341e7d660a08de/coverage-7.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0b3d67d31383c4c68e19a88e28fc4c2e29517580f1b0ebec4a069d502ce1e0bf", size = 218274, upload-time = "2025-12-08T13:12:52.095Z" }, + { url = "https://files.pythonhosted.org/packages/2b/11/30d71ae5d6e949ff93b2a79a2c1b4822e00423116c5c6edfaeef37301396/coverage-7.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:581f086833d24a22c89ae0fe2142cfaa1c92c930adf637ddf122d55083fb5a0f", size = 218638, upload-time = "2025-12-08T13:12:53.418Z" }, + { url = "https://files.pythonhosted.org/packages/79/c2/fce80fc6ded8d77e53207489d6065d0fed75db8951457f9213776615e0f5/coverage-7.13.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0a3a30f0e257df382f5f9534d4ce3d4cf06eafaf5192beb1a7bd066cb10e78fb", size = 250129, upload-time = "2025-12-08T13:12:54.744Z" }, + { url = "https://files.pythonhosted.org/packages/5b/b6/51b5d1eb6fcbb9a1d5d6984e26cbe09018475c2922d554fd724dd0f056ee/coverage-7.13.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:583221913fbc8f53b88c42e8dbb8fca1d0f2e597cb190ce45916662b8b9d9621", size = 252885, upload-time = "2025-12-08T13:12:56.401Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f8/972a5affea41de798691ab15d023d3530f9f56a72e12e243f35031846ff7/coverage-7.13.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f5d9bd30756fff3e7216491a0d6d520c448d5124d3d8e8f56446d6412499e74", size = 253974, upload-time = "2025-12-08T13:12:57.718Z" }, + { url = "https://files.pythonhosted.org/packages/8a/56/116513aee860b2c7968aa3506b0f59b22a959261d1dbf3aea7b4450a7520/coverage-7.13.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a23e5a1f8b982d56fa64f8e442e037f6ce29322f1f9e6c2344cd9e9f4407ee57", size = 250538, upload-time = "2025-12-08T13:12:59.254Z" }, + { url = "https://files.pythonhosted.org/packages/d6/75/074476d64248fbadf16dfafbf93fdcede389ec821f74ca858d7c87d2a98c/coverage-7.13.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9b01c22bc74a7fb44066aaf765224c0d933ddf1f5047d6cdfe4795504a4493f8", size = 251912, upload-time = "2025-12-08T13:13:00.604Z" }, + { url = "https://files.pythonhosted.org/packages/f2/d2/aa4f8acd1f7c06024705c12609d8698c51b27e4d635d717cd1934c9668e2/coverage-7.13.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:898cce66d0836973f48dda4e3514d863d70142bdf6dfab932b9b6a90ea5b222d", size = 250054, upload-time = "2025-12-08T13:13:01.892Z" }, + { url = "https://files.pythonhosted.org/packages/19/98/8df9e1af6a493b03694a1e8070e024e7d2cdc77adedc225a35e616d505de/coverage-7.13.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:3ab483ea0e251b5790c2aac03acde31bff0c736bf8a86829b89382b407cd1c3b", size = 249619, upload-time = "2025-12-08T13:13:03.236Z" }, + { url = "https://files.pythonhosted.org/packages/d8/71/f8679231f3353018ca66ef647fa6fe7b77e6bff7845be54ab84f86233363/coverage-7.13.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1d84e91521c5e4cb6602fe11ece3e1de03b2760e14ae4fcf1a4b56fa3c801fcd", size = 251496, upload-time = "2025-12-08T13:13:04.511Z" }, + { url = "https://files.pythonhosted.org/packages/04/86/9cb406388034eaf3c606c22094edbbb82eea1fa9d20c0e9efadff20d0733/coverage-7.13.0-cp312-cp312-win32.whl", hash = "sha256:193c3887285eec1dbdb3f2bd7fbc351d570ca9c02ca756c3afbc71b3c98af6ef", size = 220808, upload-time = "2025-12-08T13:13:06.422Z" }, + { url = "https://files.pythonhosted.org/packages/1c/59/af483673df6455795daf5f447c2f81a3d2fcfc893a22b8ace983791f6f34/coverage-7.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:4f3e223b2b2db5e0db0c2b97286aba0036ca000f06aca9b12112eaa9af3d92ae", size = 221616, upload-time = "2025-12-08T13:13:07.95Z" }, + { url = "https://files.pythonhosted.org/packages/64/b0/959d582572b30a6830398c60dd419c1965ca4b5fb38ac6b7093a0d50ca8d/coverage-7.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:086cede306d96202e15a4b77ace8472e39d9f4e5f9fd92dd4fecdfb2313b2080", size = 220261, upload-time = "2025-12-08T13:13:09.581Z" }, + { url = "https://files.pythonhosted.org/packages/7c/cc/bce226595eb3bf7d13ccffe154c3c487a22222d87ff018525ab4dd2e9542/coverage-7.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:28ee1c96109974af104028a8ef57cec21447d42d0e937c0275329272e370ebcf", size = 218297, upload-time = "2025-12-08T13:13:10.977Z" }, + { url = "https://files.pythonhosted.org/packages/3b/9f/73c4d34600aae03447dff3d7ad1d0ac649856bfb87d1ca7d681cfc913f9e/coverage-7.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d1e97353dcc5587b85986cda4ff3ec98081d7e84dd95e8b2a6d59820f0545f8a", size = 218673, upload-time = "2025-12-08T13:13:12.562Z" }, + { url = "https://files.pythonhosted.org/packages/63/ab/8fa097db361a1e8586535ae5073559e6229596b3489ec3ef2f5b38df8cb2/coverage-7.13.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:99acd4dfdfeb58e1937629eb1ab6ab0899b131f183ee5f23e0b5da5cba2fec74", size = 249652, upload-time = "2025-12-08T13:13:13.909Z" }, + { url = "https://files.pythonhosted.org/packages/90/3a/9bfd4de2ff191feb37ef9465855ca56a6f2f30a3bca172e474130731ac3d/coverage-7.13.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ff45e0cd8451e293b63ced93161e189780baf444119391b3e7d25315060368a6", size = 252251, upload-time = "2025-12-08T13:13:15.553Z" }, + { url = "https://files.pythonhosted.org/packages/df/61/b5d8105f016e1b5874af0d7c67542da780ccd4a5f2244a433d3e20ceb1ad/coverage-7.13.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f4f72a85316d8e13234cafe0a9f81b40418ad7a082792fa4165bd7d45d96066b", size = 253492, upload-time = "2025-12-08T13:13:16.849Z" }, + { url = "https://files.pythonhosted.org/packages/f3/b8/0fad449981803cc47a4694768b99823fb23632150743f9c83af329bb6090/coverage-7.13.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:11c21557d0e0a5a38632cbbaca5f008723b26a89d70db6315523df6df77d6232", size = 249850, upload-time = "2025-12-08T13:13:18.142Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e9/8d68337c3125014d918cf4327d5257553a710a2995a6a6de2ac77e5aa429/coverage-7.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76541dc8d53715fb4f7a3a06b34b0dc6846e3c69bc6204c55653a85dd6220971", size = 251633, upload-time = "2025-12-08T13:13:19.56Z" }, + { url = "https://files.pythonhosted.org/packages/55/14/d4112ab26b3a1bc4b3c1295d8452dcf399ed25be4cf649002fb3e64b2d93/coverage-7.13.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6e9e451dee940a86789134b6b0ffbe31c454ade3b849bb8a9d2cca2541a8e91d", size = 249586, upload-time = "2025-12-08T13:13:20.883Z" }, + { url = "https://files.pythonhosted.org/packages/2c/a9/22b0000186db663b0d82f86c2f1028099ae9ac202491685051e2a11a5218/coverage-7.13.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5c67dace46f361125e6b9cace8fe0b729ed8479f47e70c89b838d319375c8137", size = 249412, upload-time = "2025-12-08T13:13:22.22Z" }, + { url = "https://files.pythonhosted.org/packages/a1/2e/42d8e0d9e7527fba439acdc6ed24a2b97613b1dc85849b1dd935c2cffef0/coverage-7.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f59883c643cb19630500f57016f76cfdcd6845ca8c5b5ea1f6e17f74c8e5f511", size = 251191, upload-time = "2025-12-08T13:13:23.899Z" }, + { url = "https://files.pythonhosted.org/packages/a4/af/8c7af92b1377fd8860536aadd58745119252aaaa71a5213e5a8e8007a9f5/coverage-7.13.0-cp313-cp313-win32.whl", hash = "sha256:58632b187be6f0be500f553be41e277712baa278147ecb7559983c6d9faf7ae1", size = 220829, upload-time = "2025-12-08T13:13:25.182Z" }, + { url = "https://files.pythonhosted.org/packages/58/f9/725e8bf16f343d33cbe076c75dc8370262e194ff10072c0608b8e5cf33a3/coverage-7.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:73419b89f812f498aca53f757dd834919b48ce4799f9d5cad33ca0ae442bdb1a", size = 221640, upload-time = "2025-12-08T13:13:26.836Z" }, + { url = "https://files.pythonhosted.org/packages/8a/ff/e98311000aa6933cc79274e2b6b94a2fe0fe3434fca778eba82003675496/coverage-7.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:eb76670874fdd6091eedcc856128ee48c41a9bbbb9c3f1c7c3cf169290e3ffd6", size = 220269, upload-time = "2025-12-08T13:13:28.116Z" }, + { url = "https://files.pythonhosted.org/packages/cf/cf/bbaa2e1275b300343ea865f7d424cc0a2e2a1df6925a070b2b2d5d765330/coverage-7.13.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6e63ccc6e0ad8986386461c3c4b737540f20426e7ec932f42e030320896c311a", size = 218990, upload-time = "2025-12-08T13:13:29.463Z" }, + { url = "https://files.pythonhosted.org/packages/21/1d/82f0b3323b3d149d7672e7744c116e9c170f4957e0c42572f0366dbb4477/coverage-7.13.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:494f5459ffa1bd45e18558cd98710c36c0b8fbfa82a5eabcbe671d80ecffbfe8", size = 219340, upload-time = "2025-12-08T13:13:31.524Z" }, + { url = "https://files.pythonhosted.org/packages/fb/e3/fe3fd4702a3832a255f4d43013eacb0ef5fc155a5960ea9269d8696db28b/coverage-7.13.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:06cac81bf10f74034e055e903f5f946e3e26fc51c09fc9f584e4a1605d977053", size = 260638, upload-time = "2025-12-08T13:13:32.965Z" }, + { url = "https://files.pythonhosted.org/packages/ad/01/63186cb000307f2b4da463f72af9b85d380236965574c78e7e27680a2593/coverage-7.13.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f2ffc92b46ed6e6760f1d47a71e56b5664781bc68986dbd1836b2b70c0ce2071", size = 262705, upload-time = "2025-12-08T13:13:34.378Z" }, + { url = "https://files.pythonhosted.org/packages/7c/a1/c0dacef0cc865f2455d59eed3548573ce47ed603205ffd0735d1d78b5906/coverage-7.13.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0602f701057c6823e5db1b74530ce85f17c3c5be5c85fc042ac939cbd909426e", size = 265125, upload-time = "2025-12-08T13:13:35.73Z" }, + { url = "https://files.pythonhosted.org/packages/ef/92/82b99223628b61300bd382c205795533bed021505eab6dd86e11fb5d7925/coverage-7.13.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:25dc33618d45456ccb1d37bce44bc78cf269909aa14c4db2e03d63146a8a1493", size = 259844, upload-time = "2025-12-08T13:13:37.69Z" }, + { url = "https://files.pythonhosted.org/packages/cf/2c/89b0291ae4e6cd59ef042708e1c438e2290f8c31959a20055d8768349ee2/coverage-7.13.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:71936a8b3b977ddd0b694c28c6a34f4fff2e9dd201969a4ff5d5fc7742d614b0", size = 262700, upload-time = "2025-12-08T13:13:39.525Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f9/a5f992efae1996245e796bae34ceb942b05db275e4b34222a9a40b9fbd3b/coverage-7.13.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:936bc20503ce24770c71938d1369461f0c5320830800933bc3956e2a4ded930e", size = 260321, upload-time = "2025-12-08T13:13:41.172Z" }, + { url = "https://files.pythonhosted.org/packages/4c/89/a29f5d98c64fedbe32e2ac3c227fbf78edc01cc7572eee17d61024d89889/coverage-7.13.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:af0a583efaacc52ae2521f8d7910aff65cdb093091d76291ac5820d5e947fc1c", size = 259222, upload-time = "2025-12-08T13:13:43.282Z" }, + { url = "https://files.pythonhosted.org/packages/b3/c3/940fe447aae302a6701ee51e53af7e08b86ff6eed7631e5740c157ee22b9/coverage-7.13.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f1c23e24a7000da892a312fb17e33c5f94f8b001de44b7cf8ba2e36fbd15859e", size = 261411, upload-time = "2025-12-08T13:13:44.72Z" }, + { url = "https://files.pythonhosted.org/packages/eb/31/12a4aec689cb942a89129587860ed4d0fd522d5fda81237147fde554b8ae/coverage-7.13.0-cp313-cp313t-win32.whl", hash = "sha256:5f8a0297355e652001015e93be345ee54393e45dc3050af4a0475c5a2b767d46", size = 221505, upload-time = "2025-12-08T13:13:46.332Z" }, + { url = "https://files.pythonhosted.org/packages/65/8c/3b5fe3259d863572d2b0827642c50c3855d26b3aefe80bdc9eba1f0af3b0/coverage-7.13.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6abb3a4c52f05e08460bd9acf04fec027f8718ecaa0d09c40ffbc3fbd70ecc39", size = 222569, upload-time = "2025-12-08T13:13:47.79Z" }, + { url = "https://files.pythonhosted.org/packages/b0/39/f71fa8316a96ac72fc3908839df651e8eccee650001a17f2c78cdb355624/coverage-7.13.0-cp313-cp313t-win_arm64.whl", hash = "sha256:3ad968d1e3aa6ce5be295ab5fe3ae1bf5bb4769d0f98a80a0252d543a2ef2e9e", size = 220841, upload-time = "2025-12-08T13:13:49.243Z" }, + { url = "https://files.pythonhosted.org/packages/f8/4b/9b54bedda55421449811dcd5263a2798a63f48896c24dfb92b0f1b0845bd/coverage-7.13.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:453b7ec753cf5e4356e14fe858064e5520c460d3bbbcb9c35e55c0d21155c256", size = 218343, upload-time = "2025-12-08T13:13:50.811Z" }, + { url = "https://files.pythonhosted.org/packages/59/df/c3a1f34d4bba2e592c8979f924da4d3d4598b0df2392fbddb7761258e3dc/coverage-7.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:af827b7cbb303e1befa6c4f94fd2bf72f108089cfa0f8abab8f4ca553cf5ca5a", size = 218672, upload-time = "2025-12-08T13:13:52.284Z" }, + { url = "https://files.pythonhosted.org/packages/07/62/eec0659e47857698645ff4e6ad02e30186eb8afd65214fd43f02a76537cb/coverage-7.13.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9987a9e4f8197a1000280f7cc089e3ea2c8b3c0a64d750537809879a7b4ceaf9", size = 249715, upload-time = "2025-12-08T13:13:53.791Z" }, + { url = "https://files.pythonhosted.org/packages/23/2d/3c7ff8b2e0e634c1f58d095f071f52ed3c23ff25be524b0ccae8b71f99f8/coverage-7.13.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3188936845cd0cb114fa6a51842a304cdbac2958145d03be2377ec41eb285d19", size = 252225, upload-time = "2025-12-08T13:13:55.274Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ac/fb03b469d20e9c9a81093575003f959cf91a4a517b783aab090e4538764b/coverage-7.13.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2bdb3babb74079f021696cb46b8bb5f5661165c385d3a238712b031a12355be", size = 253559, upload-time = "2025-12-08T13:13:57.161Z" }, + { url = "https://files.pythonhosted.org/packages/29/62/14afa9e792383c66cc0a3b872a06ded6e4ed1079c7d35de274f11d27064e/coverage-7.13.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7464663eaca6adba4175f6c19354feea61ebbdd735563a03d1e472c7072d27bb", size = 249724, upload-time = "2025-12-08T13:13:58.692Z" }, + { url = "https://files.pythonhosted.org/packages/31/b7/333f3dab2939070613696ab3ee91738950f0467778c6e5a5052e840646b7/coverage-7.13.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8069e831f205d2ff1f3d355e82f511eb7c5522d7d413f5db5756b772ec8697f8", size = 251582, upload-time = "2025-12-08T13:14:00.642Z" }, + { url = "https://files.pythonhosted.org/packages/81/cb/69162bda9381f39b2287265d7e29ee770f7c27c19f470164350a38318764/coverage-7.13.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:6fb2d5d272341565f08e962cce14cdf843a08ac43bd621783527adb06b089c4b", size = 249538, upload-time = "2025-12-08T13:14:02.556Z" }, + { url = "https://files.pythonhosted.org/packages/e0/76/350387b56a30f4970abe32b90b2a434f87d29f8b7d4ae40d2e8a85aacfb3/coverage-7.13.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5e70f92ef89bac1ac8a99b3324923b4749f008fdbd7aa9cb35e01d7a284a04f9", size = 249349, upload-time = "2025-12-08T13:14:04.015Z" }, + { url = "https://files.pythonhosted.org/packages/86/0d/7f6c42b8d59f4c7e43ea3059f573c0dcfed98ba46eb43c68c69e52ae095c/coverage-7.13.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4b5de7d4583e60d5fd246dd57fcd3a8aa23c6e118a8c72b38adf666ba8e7e927", size = 251011, upload-time = "2025-12-08T13:14:05.505Z" }, + { url = "https://files.pythonhosted.org/packages/d7/f1/4bb2dff379721bb0b5c649d5c5eaf438462cad824acf32eb1b7ca0c7078e/coverage-7.13.0-cp314-cp314-win32.whl", hash = "sha256:a6c6e16b663be828a8f0b6c5027d36471d4a9f90d28444aa4ced4d48d7d6ae8f", size = 221091, upload-time = "2025-12-08T13:14:07.127Z" }, + { url = "https://files.pythonhosted.org/packages/ba/44/c239da52f373ce379c194b0ee3bcc121020e397242b85f99e0afc8615066/coverage-7.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:0900872f2fdb3ee5646b557918d02279dc3af3dfb39029ac4e945458b13f73bc", size = 221904, upload-time = "2025-12-08T13:14:08.542Z" }, + { url = "https://files.pythonhosted.org/packages/89/1f/b9f04016d2a29c2e4a0307baefefad1a4ec5724946a2b3e482690486cade/coverage-7.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:3a10260e6a152e5f03f26db4a407c4c62d3830b9af9b7c0450b183615f05d43b", size = 220480, upload-time = "2025-12-08T13:14:10.958Z" }, + { url = "https://files.pythonhosted.org/packages/16/d4/364a1439766c8e8647860584171c36010ca3226e6e45b1753b1b249c5161/coverage-7.13.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9097818b6cc1cfb5f174e3263eba4a62a17683bcfe5c4b5d07f4c97fa51fbf28", size = 219074, upload-time = "2025-12-08T13:14:13.345Z" }, + { url = "https://files.pythonhosted.org/packages/ce/f4/71ba8be63351e099911051b2089662c03d5671437a0ec2171823c8e03bec/coverage-7.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0018f73dfb4301a89292c73be6ba5f58722ff79f51593352759c1790ded1cabe", size = 219342, upload-time = "2025-12-08T13:14:15.02Z" }, + { url = "https://files.pythonhosted.org/packages/5e/25/127d8ed03d7711a387d96f132589057213e3aef7475afdaa303412463f22/coverage-7.13.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:166ad2a22ee770f5656e1257703139d3533b4a0b6909af67c6b4a3adc1c98657", size = 260713, upload-time = "2025-12-08T13:14:16.907Z" }, + { url = "https://files.pythonhosted.org/packages/fd/db/559fbb6def07d25b2243663b46ba9eb5a3c6586c0c6f4e62980a68f0ee1c/coverage-7.13.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f6aaef16d65d1787280943f1c8718dc32e9cf141014e4634d64446702d26e0ff", size = 262825, upload-time = "2025-12-08T13:14:18.68Z" }, + { url = "https://files.pythonhosted.org/packages/37/99/6ee5bf7eff884766edb43bd8736b5e1c5144d0fe47498c3779326fe75a35/coverage-7.13.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e999e2dcc094002d6e2c7bbc1fb85b58ba4f465a760a8014d97619330cdbbbf3", size = 265233, upload-time = "2025-12-08T13:14:20.55Z" }, + { url = "https://files.pythonhosted.org/packages/d8/90/92f18fe0356ea69e1f98f688ed80cec39f44e9f09a1f26a1bbf017cc67f2/coverage-7.13.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:00c3d22cf6fb1cf3bf662aaaa4e563be8243a5ed2630339069799835a9cc7f9b", size = 259779, upload-time = "2025-12-08T13:14:22.367Z" }, + { url = "https://files.pythonhosted.org/packages/90/5d/b312a8b45b37a42ea7d27d7d3ff98ade3a6c892dd48d1d503e773503373f/coverage-7.13.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22ccfe8d9bb0d6134892cbe1262493a8c70d736b9df930f3f3afae0fe3ac924d", size = 262700, upload-time = "2025-12-08T13:14:24.309Z" }, + { url = "https://files.pythonhosted.org/packages/63/f8/b1d0de5c39351eb71c366f872376d09386640840a2e09b0d03973d791e20/coverage-7.13.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:9372dff5ea15930fea0445eaf37bbbafbc771a49e70c0aeed8b4e2c2614cc00e", size = 260302, upload-time = "2025-12-08T13:14:26.068Z" }, + { url = "https://files.pythonhosted.org/packages/aa/7c/d42f4435bc40c55558b3109a39e2d456cddcec37434f62a1f1230991667a/coverage-7.13.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:69ac2c492918c2461bc6ace42d0479638e60719f2a4ef3f0815fa2df88e9f940", size = 259136, upload-time = "2025-12-08T13:14:27.604Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d3/23413241dc04d47cfe19b9a65b32a2edd67ecd0b817400c2843ebc58c847/coverage-7.13.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:739c6c051a7540608d097b8e13c76cfa85263ced467168dc6b477bae3df7d0e2", size = 261467, upload-time = "2025-12-08T13:14:29.09Z" }, + { url = "https://files.pythonhosted.org/packages/13/e6/6e063174500eee216b96272c0d1847bf215926786f85c2bd024cf4d02d2f/coverage-7.13.0-cp314-cp314t-win32.whl", hash = "sha256:fe81055d8c6c9de76d60c94ddea73c290b416e061d40d542b24a5871bad498b7", size = 221875, upload-time = "2025-12-08T13:14:31.106Z" }, + { url = "https://files.pythonhosted.org/packages/3b/46/f4fb293e4cbe3620e3ac2a3e8fd566ed33affb5861a9b20e3dd6c1896cbc/coverage-7.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:445badb539005283825959ac9fa4a28f712c214b65af3a2c464f1adc90f5fcbc", size = 222982, upload-time = "2025-12-08T13:14:33.1Z" }, + { url = "https://files.pythonhosted.org/packages/68/62/5b3b9018215ed9733fbd1ae3b2ed75c5de62c3b55377a52cae732e1b7805/coverage-7.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:de7f6748b890708578fc4b7bb967d810aeb6fcc9bff4bb77dbca77dab2f9df6a", size = 221016, upload-time = "2025-12-08T13:14:34.601Z" }, + { url = "https://files.pythonhosted.org/packages/8d/4c/1968f32fb9a2604645827e11ff84a31e59d532e01995f904723b4f5328b3/coverage-7.13.0-py3-none-any.whl", hash = "sha256:850d2998f380b1e266459ca5b47bc9e7daf9af1d070f66317972f382d46f1904", size = 210068, upload-time = "2025-12-08T13:14:36.236Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" }, + { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" }, + { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" }, + { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" }, + { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" }, + { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" }, + { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" }, + { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" }, + { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" }, + { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, + { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" }, + { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" }, + { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" }, + { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" }, + { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" }, + { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" }, + { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "rich" +version = "14.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fb/d2/8920e102050a0de7bfabeb4c4614a49248cf8d5d7a8d01885fbb24dc767a/rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4", size = 219990, upload-time = "2025-10-09T14:16:53.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd", size = 243393, upload-time = "2025-10-09T14:16:51.245Z" }, +] + +[[package]] +name = "ruff" +version = "0.14.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/1b/ab712a9d5044435be8e9a2beb17cbfa4c241aa9b5e4413febac2a8b79ef2/ruff-0.14.9.tar.gz", hash = "sha256:35f85b25dd586381c0cc053f48826109384c81c00ad7ef1bd977bfcc28119d5b", size = 5809165, upload-time = "2025-12-11T21:39:47.381Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/1c/d1b1bba22cffec02351c78ab9ed4f7d7391876e12720298448b29b7229c1/ruff-0.14.9-py3-none-linux_armv6l.whl", hash = "sha256:f1ec5de1ce150ca6e43691f4a9ef5c04574ad9ca35c8b3b0e18877314aba7e75", size = 13576541, upload-time = "2025-12-11T21:39:14.806Z" }, + { url = "https://files.pythonhosted.org/packages/94/ab/ffe580e6ea1fca67f6337b0af59fc7e683344a43642d2d55d251ff83ceae/ruff-0.14.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ed9d7417a299fc6030b4f26333bf1117ed82a61ea91238558c0268c14e00d0c2", size = 13779363, upload-time = "2025-12-11T21:39:20.29Z" }, + { url = "https://files.pythonhosted.org/packages/7d/f8/2be49047f929d6965401855461e697ab185e1a6a683d914c5c19c7962d9e/ruff-0.14.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d5dc3473c3f0e4a1008d0ef1d75cee24a48e254c8bed3a7afdd2b4392657ed2c", size = 12925292, upload-time = "2025-12-11T21:39:38.757Z" }, + { url = "https://files.pythonhosted.org/packages/9e/e9/08840ff5127916bb989c86f18924fd568938b06f58b60e206176f327c0fe/ruff-0.14.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84bf7c698fc8f3cb8278830fb6b5a47f9bcc1ed8cb4f689b9dd02698fa840697", size = 13362894, upload-time = "2025-12-11T21:39:02.524Z" }, + { url = "https://files.pythonhosted.org/packages/31/1c/5b4e8e7750613ef43390bb58658eaf1d862c0cc3352d139cd718a2cea164/ruff-0.14.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:aa733093d1f9d88a5d98988d8834ef5d6f9828d03743bf5e338bf980a19fce27", size = 13311482, upload-time = "2025-12-11T21:39:17.51Z" }, + { url = "https://files.pythonhosted.org/packages/5b/3a/459dce7a8cb35ba1ea3e9c88f19077667a7977234f3b5ab197fad240b404/ruff-0.14.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6a1cfb04eda979b20c8c19550c8b5f498df64ff8da151283311ce3199e8b3648", size = 14016100, upload-time = "2025-12-11T21:39:41.948Z" }, + { url = "https://files.pythonhosted.org/packages/a6/31/f064f4ec32524f9956a0890fc6a944e5cf06c63c554e39957d208c0ffc45/ruff-0.14.9-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:1e5cb521e5ccf0008bd74d5595a4580313844a42b9103b7388eca5a12c970743", size = 15477729, upload-time = "2025-12-11T21:39:23.279Z" }, + { url = "https://files.pythonhosted.org/packages/7a/6d/f364252aad36ccd443494bc5f02e41bf677f964b58902a17c0b16c53d890/ruff-0.14.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cd429a8926be6bba4befa8cdcf3f4dd2591c413ea5066b1e99155ed245ae42bb", size = 15122386, upload-time = "2025-12-11T21:39:33.125Z" }, + { url = "https://files.pythonhosted.org/packages/20/02/e848787912d16209aba2799a4d5a1775660b6a3d0ab3944a4ccc13e64a02/ruff-0.14.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ab208c1b7a492e37caeaf290b1378148f75e13c2225af5d44628b95fd7834273", size = 14497124, upload-time = "2025-12-11T21:38:59.33Z" }, + { url = "https://files.pythonhosted.org/packages/f3/51/0489a6a5595b7760b5dbac0dd82852b510326e7d88d51dbffcd2e07e3ff3/ruff-0.14.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:72034534e5b11e8a593f517b2f2f2b273eb68a30978c6a2d40473ad0aaa4cb4a", size = 14195343, upload-time = "2025-12-11T21:39:44.866Z" }, + { url = "https://files.pythonhosted.org/packages/f6/53/3bb8d2fa73e4c2f80acc65213ee0830fa0c49c6479313f7a68a00f39e208/ruff-0.14.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:712ff04f44663f1b90a1195f51525836e3413c8a773574a7b7775554269c30ed", size = 14346425, upload-time = "2025-12-11T21:39:05.927Z" }, + { url = "https://files.pythonhosted.org/packages/ad/04/bdb1d0ab876372da3e983896481760867fc84f969c5c09d428e8f01b557f/ruff-0.14.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a111fee1db6f1d5d5810245295527cda1d367c5aa8f42e0fca9a78ede9b4498b", size = 13258768, upload-time = "2025-12-11T21:39:08.691Z" }, + { url = "https://files.pythonhosted.org/packages/40/d9/8bf8e1e41a311afd2abc8ad12be1b6c6c8b925506d9069b67bb5e9a04af3/ruff-0.14.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8769efc71558fecc25eb295ddec7d1030d41a51e9dcf127cbd63ec517f22d567", size = 13326939, upload-time = "2025-12-11T21:39:53.842Z" }, + { url = "https://files.pythonhosted.org/packages/f4/56/a213fa9edb6dd849f1cfbc236206ead10913693c72a67fb7ddc1833bf95d/ruff-0.14.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:347e3bf16197e8a2de17940cd75fd6491e25c0aa7edf7d61aa03f146a1aa885a", size = 13578888, upload-time = "2025-12-11T21:39:35.988Z" }, + { url = "https://files.pythonhosted.org/packages/33/09/6a4a67ffa4abae6bf44c972a4521337ffce9cbc7808faadede754ef7a79c/ruff-0.14.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:7715d14e5bccf5b660f54516558aa94781d3eb0838f8e706fb60e3ff6eff03a8", size = 14314473, upload-time = "2025-12-11T21:39:50.78Z" }, + { url = "https://files.pythonhosted.org/packages/12/0d/15cc82da5d83f27a3c6b04f3a232d61bc8c50d38a6cd8da79228e5f8b8d6/ruff-0.14.9-py3-none-win32.whl", hash = "sha256:df0937f30aaabe83da172adaf8937003ff28172f59ca9f17883b4213783df197", size = 13202651, upload-time = "2025-12-11T21:39:26.628Z" }, + { url = "https://files.pythonhosted.org/packages/32/f7/c78b060388eefe0304d9d42e68fab8cffd049128ec466456cef9b8d4f06f/ruff-0.14.9-py3-none-win_amd64.whl", hash = "sha256:c0b53a10e61df15a42ed711ec0bda0c582039cf6c754c49c020084c55b5b0bc2", size = 14702079, upload-time = "2025-12-11T21:39:11.954Z" }, + { url = "https://files.pythonhosted.org/packages/26/09/7a9520315decd2334afa65ed258fed438f070e31f05a2e43dd480a5e5911/ruff-0.14.9-py3-none-win_arm64.whl", hash = "sha256:8e821c366517a074046d92f0e9213ed1c13dbc5b37a7fc20b07f79b64d62cc84", size = 13744730, upload-time = "2025-12-11T21:39:29.659Z" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "smartem-workspace" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "httpx" }, + { name = "pydantic" }, + { name = "pyyaml" }, + { name = "rich" }, + { name = "typer" }, +] + +[package.optional-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "httpx", specifier = ">=0.27.0" }, + { name = "pydantic", specifier = ">=2.0.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" }, + { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.0.0" }, + { name = "pyyaml", specifier = ">=6.0.0" }, + { name = "rich", specifier = ">=13.0.0" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.4.0" }, + { name = "typer", specifier = ">=0.12.0" }, +] +provides-extras = ["dev"] + +[[package]] +name = "tomli" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/ed/3f73f72945444548f33eba9a87fc7a6e969915e7b1acc8260b30e1f76a2f/tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549", size = 17392, upload-time = "2025-10-08T22:01:47.119Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/2e/299f62b401438d5fe1624119c723f5d877acc86a4c2492da405626665f12/tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45", size = 153236, upload-time = "2025-10-08T22:01:00.137Z" }, + { url = "https://files.pythonhosted.org/packages/86/7f/d8fffe6a7aefdb61bced88fcb5e280cfd71e08939da5894161bd71bea022/tomli-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba", size = 148084, upload-time = "2025-10-08T22:01:01.63Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/24935fb6a2ee63e86d80e4d3b58b222dafaf438c416752c8b58537c8b89a/tomli-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf", size = 234832, upload-time = "2025-10-08T22:01:02.543Z" }, + { url = "https://files.pythonhosted.org/packages/89/da/75dfd804fc11e6612846758a23f13271b76d577e299592b4371a4ca4cd09/tomli-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441", size = 242052, upload-time = "2025-10-08T22:01:03.836Z" }, + { url = "https://files.pythonhosted.org/packages/70/8c/f48ac899f7b3ca7eb13af73bacbc93aec37f9c954df3c08ad96991c8c373/tomli-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845", size = 239555, upload-time = "2025-10-08T22:01:04.834Z" }, + { url = "https://files.pythonhosted.org/packages/ba/28/72f8afd73f1d0e7829bfc093f4cb98ce0a40ffc0cc997009ee1ed94ba705/tomli-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c", size = 245128, upload-time = "2025-10-08T22:01:05.84Z" }, + { url = "https://files.pythonhosted.org/packages/b6/eb/a7679c8ac85208706d27436e8d421dfa39d4c914dcf5fa8083a9305f58d9/tomli-2.3.0-cp311-cp311-win32.whl", hash = "sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456", size = 96445, upload-time = "2025-10-08T22:01:06.896Z" }, + { url = "https://files.pythonhosted.org/packages/0a/fe/3d3420c4cb1ad9cb462fb52967080575f15898da97e21cb6f1361d505383/tomli-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be", size = 107165, upload-time = "2025-10-08T22:01:08.107Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b7/40f36368fcabc518bb11c8f06379a0fd631985046c038aca08c6d6a43c6e/tomli-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac", size = 154891, upload-time = "2025-10-08T22:01:09.082Z" }, + { url = "https://files.pythonhosted.org/packages/f9/3f/d9dd692199e3b3aab2e4e4dd948abd0f790d9ded8cd10cbaae276a898434/tomli-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22", size = 148796, upload-time = "2025-10-08T22:01:10.266Z" }, + { url = "https://files.pythonhosted.org/packages/60/83/59bff4996c2cf9f9387a0f5a3394629c7efa5ef16142076a23a90f1955fa/tomli-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f", size = 242121, upload-time = "2025-10-08T22:01:11.332Z" }, + { url = "https://files.pythonhosted.org/packages/45/e5/7c5119ff39de8693d6baab6c0b6dcb556d192c165596e9fc231ea1052041/tomli-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52", size = 250070, upload-time = "2025-10-08T22:01:12.498Z" }, + { url = "https://files.pythonhosted.org/packages/45/12/ad5126d3a278f27e6701abde51d342aa78d06e27ce2bb596a01f7709a5a2/tomli-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8", size = 245859, upload-time = "2025-10-08T22:01:13.551Z" }, + { url = "https://files.pythonhosted.org/packages/fb/a1/4d6865da6a71c603cfe6ad0e6556c73c76548557a8d658f9e3b142df245f/tomli-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6", size = 250296, upload-time = "2025-10-08T22:01:14.614Z" }, + { url = "https://files.pythonhosted.org/packages/a0/b7/a7a7042715d55c9ba6e8b196d65d2cb662578b4d8cd17d882d45322b0d78/tomli-2.3.0-cp312-cp312-win32.whl", hash = "sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876", size = 97124, upload-time = "2025-10-08T22:01:15.629Z" }, + { url = "https://files.pythonhosted.org/packages/06/1e/f22f100db15a68b520664eb3328fb0ae4e90530887928558112c8d1f4515/tomli-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878", size = 107698, upload-time = "2025-10-08T22:01:16.51Z" }, + { url = "https://files.pythonhosted.org/packages/89/48/06ee6eabe4fdd9ecd48bf488f4ac783844fd777f547b8d1b61c11939974e/tomli-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b", size = 154819, upload-time = "2025-10-08T22:01:17.964Z" }, + { url = "https://files.pythonhosted.org/packages/f1/01/88793757d54d8937015c75dcdfb673c65471945f6be98e6a0410fba167ed/tomli-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae", size = 148766, upload-time = "2025-10-08T22:01:18.959Z" }, + { url = "https://files.pythonhosted.org/packages/42/17/5e2c956f0144b812e7e107f94f1cc54af734eb17b5191c0bbfb72de5e93e/tomli-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b", size = 240771, upload-time = "2025-10-08T22:01:20.106Z" }, + { url = "https://files.pythonhosted.org/packages/d5/f4/0fbd014909748706c01d16824eadb0307115f9562a15cbb012cd9b3512c5/tomli-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf", size = 248586, upload-time = "2025-10-08T22:01:21.164Z" }, + { url = "https://files.pythonhosted.org/packages/30/77/fed85e114bde5e81ecf9bc5da0cc69f2914b38f4708c80ae67d0c10180c5/tomli-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f", size = 244792, upload-time = "2025-10-08T22:01:22.417Z" }, + { url = "https://files.pythonhosted.org/packages/55/92/afed3d497f7c186dc71e6ee6d4fcb0acfa5f7d0a1a2878f8beae379ae0cc/tomli-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05", size = 248909, upload-time = "2025-10-08T22:01:23.859Z" }, + { url = "https://files.pythonhosted.org/packages/f8/84/ef50c51b5a9472e7265ce1ffc7f24cd4023d289e109f669bdb1553f6a7c2/tomli-2.3.0-cp313-cp313-win32.whl", hash = "sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606", size = 96946, upload-time = "2025-10-08T22:01:24.893Z" }, + { url = "https://files.pythonhosted.org/packages/b2/b7/718cd1da0884f281f95ccfa3a6cc572d30053cba64603f79d431d3c9b61b/tomli-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999", size = 107705, upload-time = "2025-10-08T22:01:26.153Z" }, + { url = "https://files.pythonhosted.org/packages/19/94/aeafa14a52e16163008060506fcb6aa1949d13548d13752171a755c65611/tomli-2.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e", size = 154244, upload-time = "2025-10-08T22:01:27.06Z" }, + { url = "https://files.pythonhosted.org/packages/db/e4/1e58409aa78eefa47ccd19779fc6f36787edbe7d4cd330eeeedb33a4515b/tomli-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3", size = 148637, upload-time = "2025-10-08T22:01:28.059Z" }, + { url = "https://files.pythonhosted.org/packages/26/b6/d1eccb62f665e44359226811064596dd6a366ea1f985839c566cd61525ae/tomli-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc", size = 241925, upload-time = "2025-10-08T22:01:29.066Z" }, + { url = "https://files.pythonhosted.org/packages/70/91/7cdab9a03e6d3d2bb11beae108da5bdc1c34bdeb06e21163482544ddcc90/tomli-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0", size = 249045, upload-time = "2025-10-08T22:01:31.98Z" }, + { url = "https://files.pythonhosted.org/packages/15/1b/8c26874ed1f6e4f1fcfeb868db8a794cbe9f227299402db58cfcc858766c/tomli-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879", size = 245835, upload-time = "2025-10-08T22:01:32.989Z" }, + { url = "https://files.pythonhosted.org/packages/fd/42/8e3c6a9a4b1a1360c1a2a39f0b972cef2cc9ebd56025168c4137192a9321/tomli-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005", size = 253109, upload-time = "2025-10-08T22:01:34.052Z" }, + { url = "https://files.pythonhosted.org/packages/22/0c/b4da635000a71b5f80130937eeac12e686eefb376b8dee113b4a582bba42/tomli-2.3.0-cp314-cp314-win32.whl", hash = "sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463", size = 97930, upload-time = "2025-10-08T22:01:35.082Z" }, + { url = "https://files.pythonhosted.org/packages/b9/74/cb1abc870a418ae99cd5c9547d6bce30701a954e0e721821df483ef7223c/tomli-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8", size = 107964, upload-time = "2025-10-08T22:01:36.057Z" }, + { url = "https://files.pythonhosted.org/packages/54/78/5c46fff6432a712af9f792944f4fcd7067d8823157949f4e40c56b8b3c83/tomli-2.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77", size = 163065, upload-time = "2025-10-08T22:01:37.27Z" }, + { url = "https://files.pythonhosted.org/packages/39/67/f85d9bd23182f45eca8939cd2bc7050e1f90c41f4a2ecbbd5963a1d1c486/tomli-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf", size = 159088, upload-time = "2025-10-08T22:01:38.235Z" }, + { url = "https://files.pythonhosted.org/packages/26/5a/4b546a0405b9cc0659b399f12b6adb750757baf04250b148d3c5059fc4eb/tomli-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530", size = 268193, upload-time = "2025-10-08T22:01:39.712Z" }, + { url = "https://files.pythonhosted.org/packages/42/4f/2c12a72ae22cf7b59a7fe75b3465b7aba40ea9145d026ba41cb382075b0e/tomli-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b", size = 275488, upload-time = "2025-10-08T22:01:40.773Z" }, + { url = "https://files.pythonhosted.org/packages/92/04/a038d65dbe160c3aa5a624e93ad98111090f6804027d474ba9c37c8ae186/tomli-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67", size = 272669, upload-time = "2025-10-08T22:01:41.824Z" }, + { url = "https://files.pythonhosted.org/packages/be/2f/8b7c60a9d1612a7cbc39ffcca4f21a73bf368a80fc25bccf8253e2563267/tomli-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f", size = 279709, upload-time = "2025-10-08T22:01:43.177Z" }, + { url = "https://files.pythonhosted.org/packages/7e/46/cc36c679f09f27ded940281c38607716c86cf8ba4a518d524e349c8b4874/tomli-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0", size = 107563, upload-time = "2025-10-08T22:01:44.233Z" }, + { url = "https://files.pythonhosted.org/packages/84/ff/426ca8683cf7b753614480484f6437f568fd2fda2edbdf57a2d3d8b27a0b/tomli-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba", size = 119756, upload-time = "2025-10-08T22:01:45.234Z" }, + { url = "https://files.pythonhosted.org/packages/77/b8/0135fadc89e73be292b473cb820b4f5a08197779206b33191e801feeae40/tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b", size = 14408, upload-time = "2025-10-08T22:01:46.04Z" }, +] + +[[package]] +name = "typer" +version = "0.20.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8f/28/7c85c8032b91dbe79725b6f17d2fffc595dff06a35c7a30a37bef73a1ab4/typer-0.20.0.tar.gz", hash = "sha256:1aaf6494031793e4876fb0bacfa6a912b551cf43c1e63c800df8b1a866720c37", size = 106492, upload-time = "2025-10-20T17:03:49.445Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/64/7713ffe4b5983314e9d436a90d5bd4f63b6054e2aca783a3cfc44cb95bbf/typer-0.20.0-py3-none-any.whl", hash = "sha256:5b463df6793ec1dca6213a3cf4c0f03bc6e322ac5e16e13ddd622a889489784a", size = 47028, upload-time = "2025-10-20T17:03:47.617Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] diff --git a/webui/scripts/prebuild.ts b/webui/scripts/prebuild.ts index 8a257a1..57bad5f 100644 --- a/webui/scripts/prebuild.ts +++ b/webui/scripts/prebuild.ts @@ -7,12 +7,7 @@ const projectRoot = resolve(__dirname, '..') const coreDir = resolve(projectRoot, '..', 'core') const destFile = resolve(projectRoot, 'src', 'config', 'webui-app-contents.ts') -const configFiles = [ - 'github-tags-config.ts', - 'repos-and-refs.ts', - 'microscope-list.ts', - 'webui-config.ts', -] +const configFiles = ['github-tags-config.ts', 'repos.json', 'microscope-list.ts', 'webui-config.ts'] for (const file of configFiles) { const path = resolve(coreDir, file) @@ -23,16 +18,100 @@ for (const file of configFiles) { } const githubTagsContent = readFileSync(resolve(coreDir, 'github-tags-config.ts'), 'utf-8') -const reposAndRefsContent = readFileSync(resolve(coreDir, 'repos-and-refs.ts'), 'utf-8') +const reposJsonContent = readFileSync(resolve(coreDir, 'repos.json'), 'utf-8') const microscopeListContent = readFileSync(resolve(coreDir, 'microscope-list.ts'), 'utf-8') const webuiConfigContent = readFileSync(resolve(coreDir, 'webui-config.ts'), 'utf-8') +const reposJson = JSON.parse(reposJsonContent) + +function generateReposAndRefsFromJson(): string { + const diamondLightSourceOrg = reposJson.organizations.find( + (org: { name: string }) => org.name === 'DiamondLightSource' + ) + const fragmentScreenOrg = reposJson.organizations.find( + (org: { name: string }) => org.name === 'FragmentScreen' + ) + const ariaPHPOrg = reposJson.organizations.find( + (org: { name: string }) => org.name === 'aria-php' + ) + + const mapRepo = (repo: { + name: string + description: string + urls: { https: string; ssh: string } + tags?: string[] + ownership?: string + required?: boolean + }) => ({ + name: repo.name, + description: repo.description, + urls: repo.urls, + tags: repo.tags, + ownership: repo.ownership, + required: repo.required, + }) + + return `interface RepoUrls { + https: string + ssh: string +} + +interface Repository { + name: string + description: string + urls: RepoUrls + tags?: string[] + ownership?: 'full' | 'reference-only' + required?: boolean +} + +interface OrgRepos { + org: string + orgUrl: string + repos: Repository[] +} + +interface ExternalLinks { + docs: string + projectBoard: string +} + +interface ReposAndRefsConfig { + links: ExternalLinks + repositories: OrgRepos[] +} + +const reposAndRefsConfig: ReposAndRefsConfig = { + links: { + docs: ${JSON.stringify(reposJson.links.docs)}, + projectBoard: ${JSON.stringify(reposJson.links.projectBoard)}, + }, + repositories: [ + { + org: 'DiamondLightSource', + orgUrl: ${JSON.stringify(diamondLightSourceOrg?.url ?? 'https://github.com/DiamondLightSource')}, + repos: ${JSON.stringify((diamondLightSourceOrg?.repos ?? []).map(mapRepo), null, 8).replace(/\n/g, '\n ')}, + }, + { + org: 'FragmentScreen', + orgUrl: ${JSON.stringify(fragmentScreenOrg?.url ?? 'https://github.com/FragmentScreen')}, + repos: ${JSON.stringify((fragmentScreenOrg?.repos ?? []).map(mapRepo), null, 8).replace(/\n/g, '\n ')}, + }, + { + org: 'aria-php', + orgUrl: ${JSON.stringify(ariaPHPOrg?.url ?? 'https://gitlab.com/aria-php')}, + repos: ${JSON.stringify((ariaPHPOrg?.repos ?? []).map(mapRepo), null, 8).replace(/\n/g, '\n ')}, + }, + ], +}` +} + const aggregatedContent = `/** * AUTO-GENERATED FILE - DO NOT EDIT DIRECTLY * * This file is generated by scripts/prebuild.ts from: * - core/github-tags-config.ts - * - core/repos-and-refs.ts + * - core/repos.json (source of truth for repository definitions) * - core/microscope-list.ts * - core/webui-config.ts * @@ -46,10 +125,10 @@ const aggregatedContent = `/** ${stripExportsAndComments(githubTagsContent, 'GitHubTagsConfig', 'githubTagsConfig')} // ============================================================================= -// Repos and Refs +// Repos and Refs (generated from repos.json) // ============================================================================= -${stripExportsAndComments(reposAndRefsContent, 'ReposAndRefsConfig', 'reposAndRefsConfig')} +${generateReposAndRefsFromJson()} // ============================================================================= // Microscope List diff --git a/webui/src/config/webui-app-contents.ts b/webui/src/config/webui-app-contents.ts index e570757..9876b36 100644 --- a/webui/src/config/webui-app-contents.ts +++ b/webui/src/config/webui-app-contents.ts @@ -3,7 +3,7 @@ * * This file is generated by scripts/prebuild.ts from: * - core/github-tags-config.ts - * - core/repos-and-refs.ts + * - core/repos.json (source of truth for repository definitions) * - core/microscope-list.ts * - core/webui-config.ts * @@ -66,7 +66,7 @@ const githubTagsConfig: GitHubTagsConfig = { } // ============================================================================= -// Repos and Refs +// Repos and Refs (generated from repos.json) // ============================================================================= interface RepoUrls { @@ -78,6 +78,9 @@ interface Repository { name: string description: string urls: RepoUrls + tags?: string[] + ownership?: 'full' | 'reference-only' + required?: boolean } interface OrgRepos { @@ -96,275 +99,443 @@ interface ReposAndRefsConfig { repositories: OrgRepos[] } -const diamondLightSourceRepos: Repository[] = [ - { - name: 'smartem-decisions', - description: 'Central system controller - backbone, messaging router, persistence, auth', - urls: { - https: 'https://github.com/DiamondLightSource/smartem-decisions.git', - ssh: 'git@github.com:DiamondLightSource/smartem-decisions.git', - }, - }, - { - name: 'smartem-frontend', - description: 'Web UI for SmartEM - user-facing view of acquisition sessions and ML decisions', - urls: { - https: 'https://github.com/DiamondLightSource/smartem-frontend.git', - ssh: 'git@github.com:DiamondLightSource/smartem-frontend.git', - }, - }, - { - name: 'smartem-devtools', - description: 'Developer tooling, documentation, and workspace configuration', - urls: { - https: 'https://github.com/DiamondLightSource/smartem-devtools.git', - ssh: 'git@github.com:DiamondLightSource/smartem-devtools.git', - }, - }, - { - name: 'fandanGO-cryoem-dls', - description: 'DLS facility plugin for FandanGO - bridges SmartEM to ARIA', - urls: { - https: 'https://github.com/DiamondLightSource/fandanGO-cryoem-dls.git', - ssh: 'git@github.com:DiamondLightSource/fandanGO-cryoem-dls.git', - }, - }, - { - name: 'cryoem-services', - description: 'Processing execution layer for cryo-EM data pipelines (reference-only)', - urls: { - https: 'https://github.com/DiamondLightSource/cryoem-services.git', - ssh: 'git@github.com:DiamondLightSource/cryoem-services.git', - }, - }, -] - -const gitlabAriaPHPRepos: Repository[] = [ - { - name: 'data-deposition-api', - description: 'ARIA GraphQL/REST API for metadata deposition (primary)', - urls: { - https: 'https://gitlab.com/aria-php/data-deposition-api.git', - ssh: 'git@gitlab.com:aria-php/data-deposition-api.git', - }, - }, - { - name: 'aria-graphql-client', - description: 'PHP library for communicating with ARIA GraphQL API', - urls: { - https: 'https://gitlab.com/aria-php/aria-graphql-client.git', - ssh: 'git@gitlab.com:aria-php/aria-graphql-client.git', - }, - }, - { - name: 'aria-elasticsearch-client', - description: 'Elasticsearch client for ARIA search records', - urls: { - https: 'https://gitlab.com/aria-php/aria-elasticsearch-client.git', - ssh: 'git@gitlab.com:aria-php/aria-elasticsearch-client.git', - }, - }, - { - name: 'aria-rest', - description: 'REST API framework for defining versioned APIs', - urls: { - https: 'https://gitlab.com/aria-php/aria-rest.git', - ssh: 'git@gitlab.com:aria-php/aria-rest.git', - }, - }, - { - name: 'aria-storage-interface', - description: 'Storage provider interface', - urls: { - https: 'https://gitlab.com/aria-php/aria-storage-interface.git', - ssh: 'git@gitlab.com:aria-php/aria-storage-interface.git', - }, - }, - { - name: 'aria-webhooks', - description: 'Standard webhook payload format for ARIA platform', - urls: { - https: 'https://gitlab.com/aria-php/aria-webhooks.git', - ssh: 'git@gitlab.com:aria-php/aria-webhooks.git', - }, - }, - { - name: 'aria-incoming-email', - description: 'Incoming email message routing', - urls: { - https: 'https://gitlab.com/aria-php/aria-incoming-email.git', - ssh: 'git@gitlab.com:aria-php/aria-incoming-email.git', - }, - }, - { - name: 'aria-mailer', - description: 'Email wrapper (PHPMailer + Swiftmailer)', - urls: { - https: 'https://gitlab.com/aria-php/aria-mailer.git', - ssh: 'git@gitlab.com:aria-php/aria-mailer.git', - }, - }, - { - name: 'aria-mailgun-webhooks', - description: 'Mailgun webhook event parser', - urls: { - https: 'https://gitlab.com/aria-php/aria-mailgun-webhooks.git', - ssh: 'git@gitlab.com:aria-php/aria-mailgun-webhooks.git', - }, - }, - { - name: 'aria-invite-users', - description: 'User invitation framework', - urls: { - https: 'https://gitlab.com/aria-php/aria-invite-users.git', - ssh: 'git@gitlab.com:aria-php/aria-invite-users.git', - }, - }, - { - name: 'aria-data-subscription', - description: 'Data source subscription framework for feeds', - urls: { - https: 'https://gitlab.com/aria-php/aria-data-subscription.git', - ssh: 'git@gitlab.com:aria-php/aria-data-subscription.git', - }, - }, - { - name: 'aria-stats', - description: 'Performance statistics monitoring', - urls: { - https: 'https://gitlab.com/aria-php/aria-stats.git', - ssh: 'git@gitlab.com:aria-php/aria-stats.git', - }, - }, - { - name: 'aria-site-logger', - description: 'Monolog plugin for ARIA site logging', - urls: { - https: 'https://gitlab.com/aria-php/aria-site-logger.git', - ssh: 'git@gitlab.com:aria-php/aria-site-logger.git', - }, - }, - { - name: 'aria-service-ai', - description: 'Service AI library', - urls: { - https: 'https://gitlab.com/aria-php/aria-service-ai.git', - ssh: 'git@gitlab.com:aria-php/aria-service-ai.git', - }, - }, - { - name: 'keycloak-api', - description: 'PHP bindings for Keycloak Account API', - urls: { - https: 'https://gitlab.com/aria-php/keycloak-api.git', - ssh: 'git@gitlab.com:aria-php/keycloak-api.git', - }, - }, - { - name: 'doi-package', - description: 'DOI microservice client', - urls: { - https: 'https://gitlab.com/aria-php/doi-package.git', - ssh: 'git@gitlab.com:aria-php/doi-package.git', - }, - }, - { - name: 'molgenis-php-client', - description: 'PHP client for Molgenis database', - urls: { - https: 'https://gitlab.com/aria-php/molgenis-php-client.git', - ssh: 'git@gitlab.com:aria-php/molgenis-php-client.git', - }, - }, - { - name: 'shibboleth-idp-dockerized', - description: 'Dockerized Shibboleth IdP (identity federation)', - urls: { - https: 'https://gitlab.com/aria-php/shibboleth-idp-dockerized.git', - ssh: 'git@gitlab.com:aria-php/shibboleth-idp-dockerized.git', - }, - }, - { - name: 'rtd-compiler', - description: 'ReadTheDocs compiler for ARIA documentation', - urls: { - https: 'https://gitlab.com/aria-php/rtd-compiler.git', - ssh: 'git@gitlab.com:aria-php/rtd-compiler.git', - }, - }, -] - -const fragmentScreenRepos: Repository[] = [ - { - name: 'fandanGO-core', - description: 'Plugin framework foundation', - urls: { - https: 'https://github.com/FragmentScreen/fandanGO-core.git', - ssh: 'git@github.com:FragmentScreen/fandanGO-core.git', - }, - }, - { - name: 'fandanGO-aria', - description: 'ARIA integration - auth, token management, metadata submission', - urls: { - https: 'https://github.com/FragmentScreen/fandanGO-aria.git', - ssh: 'git@github.com:FragmentScreen/fandanGO-aria.git', - }, - }, - { - name: 'fandanGO-cryoem-cnb', - description: 'CNB-CSIC Madrid cryo-EM plugin (peer reference)', - urls: { - https: 'https://github.com/FragmentScreen/fandanGO-cryoem-cnb.git', - ssh: 'git@github.com:FragmentScreen/fandanGO-cryoem-cnb.git', - }, - }, - { - name: 'fandanGO-nmr-cerm', - description: 'CERM Florence NMR plugin (peer reference)', - urls: { - https: 'https://github.com/FragmentScreen/fandanGO-nmr-cerm.git', - ssh: 'git@github.com:FragmentScreen/fandanGO-nmr-cerm.git', - }, - }, - { - name: 'fandanGO-nmr-guf', - description: 'GUF Frankfurt NMR plugin (peer reference)', - urls: { - https: 'https://github.com/FragmentScreen/fandanGO-nmr-guf.git', - ssh: 'git@github.com:FragmentScreen/fandanGO-nmr-guf.git', - }, - }, - { - name: 'Samples', - description: 'Sample metadata/datasets for community reference', - urls: { - https: 'https://github.com/FragmentScreen/Samples.git', - ssh: 'git@github.com:FragmentScreen/Samples.git', - }, - }, -] - const reposAndRefsConfig: ReposAndRefsConfig = { links: { - docs: 'https://diamondlightsource.github.io/smartem-decisions/', - projectBoard: 'https://github.com/orgs/DiamondLightSource/projects/51/views/1', + docs: "https://diamondlightsource.github.io/smartem-decisions/", + projectBoard: "https://github.com/orgs/DiamondLightSource/projects/51/views/1", }, repositories: [ { org: 'DiamondLightSource', - orgUrl: 'https://github.com/DiamondLightSource', - repos: diamondLightSourceRepos, + orgUrl: "https://github.com/DiamondLightSource", + repos: [ + { + "name": "smartem-decisions", + "description": "Central system controller - backbone, messaging router, persistence, auth", + "urls": { + "https": "https://github.com/DiamondLightSource/smartem-decisions.git", + "ssh": "git@github.com:DiamondLightSource/smartem-decisions.git" + }, + "tags": [ + "core", + "python", + "backend" + ], + "ownership": "full" + }, + { + "name": "smartem-frontend", + "description": "Web UI for SmartEM - user-facing view of acquisition sessions and ML decisions", + "urls": { + "https": "https://github.com/DiamondLightSource/smartem-frontend.git", + "ssh": "git@github.com:DiamondLightSource/smartem-frontend.git" + }, + "tags": [ + "core", + "typescript", + "frontend" + ], + "ownership": "full" + }, + { + "name": "smartem-devtools", + "description": "Developer tooling, documentation, and workspace configuration", + "urls": { + "https": "https://github.com/DiamondLightSource/smartem-devtools.git", + "ssh": "git@github.com:DiamondLightSource/smartem-devtools.git" + }, + "tags": [ + "core", + "tooling" + ], + "ownership": "full", + "required": true + }, + { + "name": "fandanGO-cryoem-dls", + "description": "DLS facility plugin for FandanGO - bridges SmartEM to ARIA", + "urls": { + "https": "https://github.com/DiamondLightSource/fandanGO-cryoem-dls.git", + "ssh": "git@github.com:DiamondLightSource/fandanGO-cryoem-dls.git" + }, + "tags": [ + "aria", + "python" + ], + "ownership": "full" + }, + { + "name": "cryoem-services", + "description": "Processing execution layer for cryo-EM data pipelines (reference-only)", + "urls": { + "https": "https://github.com/DiamondLightSource/cryoem-services.git", + "ssh": "git@github.com:DiamondLightSource/cryoem-services.git" + }, + "tags": [ + "reference", + "python" + ], + "ownership": "reference-only" + } + ], }, { org: 'FragmentScreen', - orgUrl: 'https://github.com/FragmentScreen', - repos: fragmentScreenRepos, + orgUrl: "https://github.com/FragmentScreen", + repos: [ + { + "name": "fandanGO-core", + "description": "Plugin framework foundation", + "urls": { + "https": "https://github.com/FragmentScreen/fandanGO-core.git", + "ssh": "git@github.com:FragmentScreen/fandanGO-core.git" + }, + "tags": [ + "aria", + "python", + "framework" + ], + "ownership": "reference-only" + }, + { + "name": "fandanGO-aria", + "description": "ARIA integration - auth, token management, metadata submission", + "urls": { + "https": "https://github.com/FragmentScreen/fandanGO-aria.git", + "ssh": "git@github.com:FragmentScreen/fandanGO-aria.git" + }, + "tags": [ + "aria", + "python" + ], + "ownership": "reference-only" + }, + { + "name": "fandanGO-cryoem-cnb", + "description": "CNB-CSIC Madrid cryo-EM plugin (peer reference)", + "urls": { + "https": "https://github.com/FragmentScreen/fandanGO-cryoem-cnb.git", + "ssh": "git@github.com:FragmentScreen/fandanGO-cryoem-cnb.git" + }, + "tags": [ + "aria", + "python", + "peer" + ], + "ownership": "reference-only" + }, + { + "name": "fandanGO-nmr-cerm", + "description": "CERM Florence NMR plugin (peer reference)", + "urls": { + "https": "https://github.com/FragmentScreen/fandanGO-nmr-cerm.git", + "ssh": "git@github.com:FragmentScreen/fandanGO-nmr-cerm.git" + }, + "tags": [ + "aria", + "python", + "peer" + ], + "ownership": "reference-only" + }, + { + "name": "fandanGO-nmr-guf", + "description": "GUF Frankfurt NMR plugin (peer reference)", + "urls": { + "https": "https://github.com/FragmentScreen/fandanGO-nmr-guf.git", + "ssh": "git@github.com:FragmentScreen/fandanGO-nmr-guf.git" + }, + "tags": [ + "aria", + "python", + "peer" + ], + "ownership": "reference-only" + }, + { + "name": "Samples", + "description": "Sample metadata/datasets for community reference", + "urls": { + "https": "https://github.com/FragmentScreen/Samples.git", + "ssh": "git@github.com:FragmentScreen/Samples.git" + }, + "tags": [ + "aria", + "data" + ], + "ownership": "reference-only" + } + ], }, { org: 'aria-php', - orgUrl: 'https://gitlab.com/aria-php', - repos: gitlabAriaPHPRepos, + orgUrl: "https://gitlab.com/aria-php", + repos: [ + { + "name": "data-deposition-api", + "description": "ARIA GraphQL/REST API for metadata deposition (primary)", + "urls": { + "https": "https://gitlab.com/aria-php/data-deposition-api.git", + "ssh": "git@gitlab.com:aria-php/data-deposition-api.git" + }, + "tags": [ + "aria", + "php", + "api" + ], + "ownership": "reference-only" + }, + { + "name": "aria-graphql-client", + "description": "PHP library for communicating with ARIA GraphQL API", + "urls": { + "https": "https://gitlab.com/aria-php/aria-graphql-client.git", + "ssh": "git@gitlab.com:aria-php/aria-graphql-client.git" + }, + "tags": [ + "aria", + "php", + "library" + ], + "ownership": "reference-only" + }, + { + "name": "aria-elasticsearch-client", + "description": "Elasticsearch client for ARIA search records", + "urls": { + "https": "https://gitlab.com/aria-php/aria-elasticsearch-client.git", + "ssh": "git@gitlab.com:aria-php/aria-elasticsearch-client.git" + }, + "tags": [ + "aria", + "php", + "library" + ], + "ownership": "reference-only" + }, + { + "name": "aria-rest", + "description": "REST API framework for defining versioned APIs", + "urls": { + "https": "https://gitlab.com/aria-php/aria-rest.git", + "ssh": "git@gitlab.com:aria-php/aria-rest.git" + }, + "tags": [ + "aria", + "php", + "framework" + ], + "ownership": "reference-only" + }, + { + "name": "aria-storage-interface", + "description": "Storage provider interface", + "urls": { + "https": "https://gitlab.com/aria-php/aria-storage-interface.git", + "ssh": "git@gitlab.com:aria-php/aria-storage-interface.git" + }, + "tags": [ + "aria", + "php", + "library" + ], + "ownership": "reference-only" + }, + { + "name": "aria-webhooks", + "description": "Standard webhook payload format for ARIA platform", + "urls": { + "https": "https://gitlab.com/aria-php/aria-webhooks.git", + "ssh": "git@gitlab.com:aria-php/aria-webhooks.git" + }, + "tags": [ + "aria", + "php", + "library" + ], + "ownership": "reference-only" + }, + { + "name": "aria-incoming-email", + "description": "Incoming email message routing", + "urls": { + "https": "https://gitlab.com/aria-php/aria-incoming-email.git", + "ssh": "git@gitlab.com:aria-php/aria-incoming-email.git" + }, + "tags": [ + "aria", + "php", + "library" + ], + "ownership": "reference-only" + }, + { + "name": "aria-mailer", + "description": "Email wrapper (PHPMailer + Swiftmailer)", + "urls": { + "https": "https://gitlab.com/aria-php/aria-mailer.git", + "ssh": "git@gitlab.com:aria-php/aria-mailer.git" + }, + "tags": [ + "aria", + "php", + "library" + ], + "ownership": "reference-only" + }, + { + "name": "aria-mailgun-webhooks", + "description": "Mailgun webhook event parser", + "urls": { + "https": "https://gitlab.com/aria-php/aria-mailgun-webhooks.git", + "ssh": "git@gitlab.com:aria-php/aria-mailgun-webhooks.git" + }, + "tags": [ + "aria", + "php", + "library" + ], + "ownership": "reference-only" + }, + { + "name": "aria-invite-users", + "description": "User invitation framework", + "urls": { + "https": "https://gitlab.com/aria-php/aria-invite-users.git", + "ssh": "git@gitlab.com:aria-php/aria-invite-users.git" + }, + "tags": [ + "aria", + "php", + "library" + ], + "ownership": "reference-only" + }, + { + "name": "aria-data-subscription", + "description": "Data source subscription framework for feeds", + "urls": { + "https": "https://gitlab.com/aria-php/aria-data-subscription.git", + "ssh": "git@gitlab.com:aria-php/aria-data-subscription.git" + }, + "tags": [ + "aria", + "php", + "library" + ], + "ownership": "reference-only" + }, + { + "name": "aria-stats", + "description": "Performance statistics monitoring", + "urls": { + "https": "https://gitlab.com/aria-php/aria-stats.git", + "ssh": "git@gitlab.com:aria-php/aria-stats.git" + }, + "tags": [ + "aria", + "php", + "library" + ], + "ownership": "reference-only" + }, + { + "name": "aria-site-logger", + "description": "Monolog plugin for ARIA site logging", + "urls": { + "https": "https://gitlab.com/aria-php/aria-site-logger.git", + "ssh": "git@gitlab.com:aria-php/aria-site-logger.git" + }, + "tags": [ + "aria", + "php", + "library" + ], + "ownership": "reference-only" + }, + { + "name": "aria-service-ai", + "description": "Service AI library", + "urls": { + "https": "https://gitlab.com/aria-php/aria-service-ai.git", + "ssh": "git@gitlab.com:aria-php/aria-service-ai.git" + }, + "tags": [ + "aria", + "php", + "library" + ], + "ownership": "reference-only" + }, + { + "name": "keycloak-api", + "description": "PHP bindings for Keycloak Account API", + "urls": { + "https": "https://gitlab.com/aria-php/keycloak-api.git", + "ssh": "git@gitlab.com:aria-php/keycloak-api.git" + }, + "tags": [ + "aria", + "php", + "integration" + ], + "ownership": "reference-only" + }, + { + "name": "doi-package", + "description": "DOI microservice client", + "urls": { + "https": "https://gitlab.com/aria-php/doi-package.git", + "ssh": "git@gitlab.com:aria-php/doi-package.git" + }, + "tags": [ + "aria", + "php", + "integration" + ], + "ownership": "reference-only" + }, + { + "name": "molgenis-php-client", + "description": "PHP client for Molgenis database", + "urls": { + "https": "https://gitlab.com/aria-php/molgenis-php-client.git", + "ssh": "git@gitlab.com:aria-php/molgenis-php-client.git" + }, + "tags": [ + "aria", + "php", + "integration" + ], + "ownership": "reference-only" + }, + { + "name": "shibboleth-idp-dockerized", + "description": "Dockerized Shibboleth IdP (identity federation)", + "urls": { + "https": "https://gitlab.com/aria-php/shibboleth-idp-dockerized.git", + "ssh": "git@gitlab.com:aria-php/shibboleth-idp-dockerized.git" + }, + "tags": [ + "aria", + "infrastructure" + ], + "ownership": "reference-only" + }, + { + "name": "rtd-compiler", + "description": "ReadTheDocs compiler for ARIA documentation", + "urls": { + "https": "https://gitlab.com/aria-php/rtd-compiler.git", + "ssh": "git@gitlab.com:aria-php/rtd-compiler.git" + }, + "tags": [ + "aria", + "infrastructure" + ], + "ownership": "reference-only" + } + ], }, ], } diff --git a/webui/src/docs/explanations/decisions.mdx b/webui/src/docs/explanations/decisions.mdx index 867b56b..75b127d 100644 --- a/webui/src/docs/explanations/decisions.mdx +++ b/webui/src/docs/explanations/decisions.mdx @@ -5,7 +5,7 @@ Architectural decisions are made throughout a project's lifetime. As a way of ke ## ADRs - [ADR-0001: Record Architecture Decisions](/docs/explanations/decisions/0001-record-architecture-decisions) -- [ADR-0002: Switched to Python Copier Template](/docs/explanations/decisions/0002-switched-to-python-copier-template) +- [ADR-0002: Switched to Python Copier Template (superseded by ADR-0011)](/docs/explanations/decisions/0002-switched-to-python-copier-template) - [ADR-0003: Message Queue Message Grouping](/docs/explanations/decisions/0003-message-queue-message-grouping) - [ADR-0004: Zocalo Dependency-Free](/docs/explanations/decisions/0004-zocalo-dependency-free) - [ADR-0005: Detect Secrets for Secret Scanning](/docs/explanations/decisions/0005-detect-secrets-for-secret-scanning) @@ -13,5 +13,7 @@ Architectural decisions are made throughout a project's lifetime. As a way of ke - [ADR-0007: Eliminate SmartEM API Circular Dependency](/docs/explanations/decisions/0007-eliminate-smartem-api-circular-dependency) - [ADR-0008: Backend to Agent Communication Architecture](/docs/explanations/decisions/0008-backend-to-agent-communication-architecture) - [ADR-0009: Commit Generated Route Tree](/docs/explanations/decisions/0009-commit-generated-route-tree) +- [ADR-0010: Use Shiki for Syntax Highlighting](/docs/explanations/decisions/0010-shiki-syntax-highlighting) +- [ADR-0011: Remove Python Copier Template](/docs/explanations/decisions/0011-remove-python-copier-template) For more information on ADRs see this [blog by Michael Nygard](http://thinkrelevance.com/blog/2011/11/15/documenting-architecture-decisions). diff --git a/webui/src/docs/explanations/decisions/0002-switched-to-python-copier-template.mdx b/webui/src/docs/explanations/decisions/0002-switched-to-python-copier-template.mdx index 31039f6..c430699 100644 --- a/webui/src/docs/explanations/decisions/0002-switched-to-python-copier-template.mdx +++ b/webui/src/docs/explanations/decisions/0002-switched-to-python-copier-template.mdx @@ -2,7 +2,9 @@ ## Status -Accepted +Superseded by ADR-0011 + +> **Note**: This decision was reversed on 2026-01-05. See [ADR-0011](/docs/explanations/decisions/0011-remove-python-copier-template) for details. ## Context diff --git a/webui/src/docs/explanations/decisions/0011-remove-python-copier-template.mdx b/webui/src/docs/explanations/decisions/0011-remove-python-copier-template.mdx new file mode 100644 index 0000000..c1ba9a5 --- /dev/null +++ b/webui/src/docs/explanations/decisions/0011-remove-python-copier-template.mdx @@ -0,0 +1,55 @@ +# 11. Remove python-copier-template + +## Status + +Accepted + +## Context + +In ADR-0002, we adopted the [python-copier-template](https://github.com/DiamondLightSource/python-copier-template) to ensure consistency in developer environments and package management. + +Since that decision, the smartem-decisions project has evolved significantly: + +1. **Project maturity**: The project has grown from a single-package PoC to a multi-package monorepo with custom requirements that diverge from standard DLS Python projects +2. **Custom tooling needs**: Our development workflow now requires tooling configurations specific to our architecture (multi-package structure, agent deployment, Kubernetes manifests, RabbitMQ integration) +3. **Template update friction**: The copier template's update mechanism became a maintenance burden rather than a benefit, as most updates were not relevant to our custom structure +4. **Duplicated documentation**: The template's contribution guidelines and developer documentation conflicted with our own evolving practices documented in smartem-devtools + +## Decision + +We have removed the python-copier-template dependency and scaffolding from smartem-decisions. + +The following were removed in commit f95b1de (2026-01-05): +- `.copier-answers.yml` configuration file +- Copier dependency from `pyproject.toml` +- Template-generated sections from `.github/CONTRIBUTING.md` + +We retain the tooling standards established by the template (pyright, ruff, pre-commit) but now manage their configuration directly. + +## Consequences + +### Positive + +- **Reduced maintenance burden**: No need to resolve conflicts when updating from the template +- **Custom workflows**: Freedom to evolve tooling and structure to match our specific needs +- **Simplified onboarding**: Developer documentation is now solely in smartem-devtools, not split between template and repo +- **Clearer ownership**: All configuration is explicitly managed by the team + +### Negative + +- **Manual updates**: We no longer automatically receive updates to best practices from the template +- **Divergence risk**: May drift from DLS Python conventions over time +- **Responsibility**: Must actively maintain tooling standards ourselves + +### Mitigations + +- Continue following DLS Python best practices where applicable +- Reference the copier template repo for inspiration when updating tooling +- Document our standards explicitly in smartem-devtools +- Maintain pre-commit hooks to enforce code quality standards + +## References + +- ADR-0002: Adopt python-copier-template (superseded by this decision) +- Removal commit: f95b1dea1479d8d845f5cfd605084c201f459020 +- DLS python-copier-template: https://github.com/DiamondLightSource/python-copier-template diff --git a/webui/src/docs/navigation.ts b/webui/src/docs/navigation.ts index a766dc0..8dc2545 100644 --- a/webui/src/docs/navigation.ts +++ b/webui/src/docs/navigation.ts @@ -59,7 +59,7 @@ export const docsNavigation: NavItem[] = [ href: '/docs/explanations/decisions/0001-record-architecture-decisions', }, { - title: 'ADR-0002: Copier Template', + title: 'ADR-0002: Copier Template (superseded)', href: '/docs/explanations/decisions/0002-switched-to-python-copier-template', }, { @@ -94,6 +94,10 @@ export const docsNavigation: NavItem[] = [ title: 'ADR-0010: Shiki Highlighting', href: '/docs/explanations/decisions/0010-shiki-syntax-highlighting', }, + { + title: 'ADR-0011: Remove Copier Template', + href: '/docs/explanations/decisions/0011-remove-python-copier-template', + }, ], }, ],