diff --git a/.claude/settings.json b/.claude/settings.json index 863f832..3965393 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -2,9 +2,8 @@ "permissions": { "allow": [ "Bash(git -C *)", - "Bash(./gradlew :esque-core:test)", - "Bash(./gradlew :esque-core:compileTestKotlin)", - "Bash(./gradlew build)" + "Bash(./gradlew *)", + "Bash(uv *)" ] }, "enabledPlugins": { diff --git a/.claude/skills/esque-new-language-implementation/SKILL.md b/.claude/skills/esque-new-language-implementation/SKILL.md new file mode 100644 index 0000000..d652c1d --- /dev/null +++ b/.claude/skills/esque-new-language-implementation/SKILL.md @@ -0,0 +1,234 @@ +--- +name: esque-new-language-implementation +description: Use when adding a new language implementation (Go, TypeScript, Rust, etc.) to the esque monorepo — covers required class structure, ES document serialization, checksum algorithm, CLI interface, packaging, and compatibility test registration. +version: 1.0.0 +--- + +# Esque: New Language Implementation Guide + +## Overview + +Each language port must produce identical observable behavior to the JVM reference implementation: same ES document structure, same checksum algorithm, same CLI interface, same error semantics. The black-box compatibility test harness (`tests/`) validates this automatically. + +## Repository Placement + +``` +implementations// # e.g. implementations/go/, implementations/ts/ +├── # go.mod, package.json, etc. +└── src/ + ├── configuration. + ├── esque. + ├── cli. + ├── migration/ + │ ├── model. + │ ├── template. + │ └── loader. + └── elasticsearch/ + ├── documents. + ├── operations. + └── lock. +``` + +## Required Classes / Modules + +Mirror the JVM structure exactly. These are the nine building blocks: + +### 1. `EsqueConfiguration` +Data-only object. Fields: +- `migrationKey: string` — scopes all records in ES +- `migrationUser: string | null` +- `migrationDirectory: string` — `"file:"` scheme only +- `lockTimeoutMinutes: int` (default 5) + +**No `properties` field** — properties are passed separately to `Esque`. + +### 2. `MigrationFileRequestDefinition` +Represents one HTTP request in a migration YAML file. Fields: +- `method: string` (never template-substituted) +- `path: string` +- `contentType: string | null` +- `params: map | null` +- `body: string | null` + +Must implement `toCanonicalDict()` that returns only non-null fields with camelCase keys (`contentType`, not `content_type`). This feeds the checksum. + +### 3. `MigrationFile` (nested model) +``` +MigrationFile + metadata: MigrationFileMetadata + filename: string # "V1.0.0__CreateIndex.yml" + version: string # "1.0.0" + description: string # "CreateIndex" + checksum: int # computed after template resolution + contents: MigrationFileContents + requests: [MigrationFileRequestDefinition] +``` +Files must sort by version numerically per segment (`1.9.0 < 1.10.0`). Pad shorter versions with zeros. + +### 4. `MigrationRecord` / `MigrationLock` +Serialized to/from ES with a **wrapper object** — mirrors JVM's `@JsonTypeInfo(As.WRAPPER_OBJECT)`: + +```json +// MigrationRecord +{"migration": {"migrationKey": "...", "order": 0, "filename": "...", + "version": "...", "description": "...", "checksum": -12345, + "installedBy": null, "installedOn": "2026-01-01T00:00:00Z", + "executionTime": 42}} + +// MigrationLock +{"lock": {"date": "2026-01-01T00:00:00Z"}} +``` + +- All field names in camelCase in ES (even if language convention differs) +- `installedOn` / `date` as ISO-8601 UTC +- Lock doc ID: `lock:` in `.esque` index +- Query records: `POST /.esque/_search` with `{"query":{"bool":{"filter":[{"term":{"migration.migrationKey":""}}]}}}` + +### 5. `RestClientOperations` +Wraps the ES client. Key methods: +- `checkMigrationIndexExists() → bool` — catch 404 +- `createMigrationIndex()` — catch 409 (already exists, safe) +- `createLockRecord()` — use `op_type=create`; raises ConflictError if locked +- `deleteLockRecord()` +- `getMigrationRecords() → [MigrationRecord]` — sorted by `order` +- `getMigrationRecordForMigrationFile(file) → MigrationRecord | null` +- `executeMigrationDefinition(def)` — issue arbitrary HTTP request to ES; params go in URL query string +- `createMigrationRecord(record)` — with `refresh=true` + +**Use the official ES client** for the target language (avoid HTTP-only clients — type support, retry, auth are free). + +### 6. `ElasticsearchDocumentLock` +- Thread-local reentrant lock + ES `op_type=create` polling +- `tryLock(timeoutMinutes) → bool` — polls every 100ms until deadline +- `unlock()` — deletes ES doc, releases local lock in `finally` +- `_doLock() → bool` — calls `createLockRecord`, returns false on any error (don't differentiate ConflictError yet) + +### 7. `MigrationTemplateResolver` +- Constructor takes `properties: map` +- `validate(files)` — collect ALL missing vars before raising +- `resolve(definition) → definition` — substitutes `#{varName}` in path, contentType, params values, body (NOT method) +- Pattern: `#{[a-zA-Z0-9._-]+}` + +### 8. `MigrationFileLoader` +- Constructor: `(migrationDirectory: string, resolver: MigrationTemplateResolver)` +- `load() → [MigrationFile]` — discovers, sorts, validates templates, resolves, computes checksums +- `migrationDirectory` supports `file:` scheme only +- Filename pattern: `^V((\d+\.?)+)__(\w+)\.yml$` + +### 9. `Esque` (orchestrator) +Constructor: `Esque(client, configuration, properties = {})` — instantiates all collaborators internally. + +Implement as a context manager / `Closeable`. `close()` should: try unlock → swallow "not held" errors, warn on others; then close client. + +`execute()` sequence: +1. Initialize (create `.esque` index if absent) +2. Load migration files +3. Fetch migration history +4. `_verifyStateIntegrity(files, history)` — private method on the class +5. For each file: tryLock → skip if already applied → run requests → record → unlock + +`_verifyStateIntegrity` is a **private class method** that delegates per-record checks to `_verifyRecordIntegrity`. Both use `configuration.migrationKey` directly rather than taking it as a parameter. Checks: +- `len(history) > len(files)` → error +- Gap check: `len(history) != history[-1].order + 1` +- Per-record: filename match, then order/version/description/checksum/migrationKey all match + +Unit tests call `_verifyStateIntegrity` directly on an instance constructed with a mock ES client (constructor only stores references, no ES calls happen at init time). + +## Checksum Algorithm (CRITICAL — must be identical across all implementations) + +``` +1. Build: {"requests": [toCanonicalDict() for each resolved request]} +2. Remove all null/None values recursively +3. JSON serialize: keys sorted alphabetically, no spaces (compact) +4. Encode as UTF-8 +5. MD5 digest +6. First 4 bytes as big-endian signed 32-bit integer +``` + +Reference test vectors — these checksums must match across all implementations: +- `[{method:"PUT", path:"/test-index"}]` → deterministic signed int +- Null fields excluded: `{method, path, body=null}` === `{method, path}` +- Key sort: `body` < `contentType` < `method` < `params` < `path` + +## CLI Interface + +All implementations share identical option names (kebab-case): +``` +--es-url TEXT required +--migrations-dir TEXT required +--migration-key TEXT required +--migration-user TEXT optional +--lock-timeout-minutes N default 5 +--property key=value repeatable; parse as "key=value" split on first "=" +``` + +`migrations-dir` is passed as-is; CLI should prepend `"file:"` before constructing `EsqueConfiguration.migrationDirectory`. + +Exit 1 on any error; print message to stderr. + +## Packaging & CI + +1. Add entry to `tests/implementations.yml`: +```yaml +implementations: + go: + invocation: direct + command: ["go", "run", "./cmd/esque"] +``` + +2. Add a job to `.github/workflows/ci.yml` following the python job pattern: + - lint + typecheck + build + - version from `.github/version_.sh` + - publish snapshot/pre-release on every build; release on GitHub release event + +3. Create `.github/version_.sh` following `version_python.sh` pattern. + +## ES Index Setup + +The `.esque` index requires: +```json +{ + "settings": {"number_of_shards": 1, "number_of_replicas": 0}, + "mappings": { + "dynamic": "false", + "properties": { + "migration": { + "properties": { + "migrationKey": {"type": "keyword"}, + "filename": {"type": "keyword"}, + "version": {"type": "keyword"}, + "description": {"type": "keyword"}, + "checksum": {"type": "integer"}, + "order": {"type": "integer"}, + "installedBy": {"type": "keyword"}, + "installedOn": {"type": "date"}, + "executionTime":{"type": "long"} + } + } + } + } +} +``` + +## Integration Test Memory Limits + +When running testcontainers for integration tests, cap ES memory to avoid OOM: +``` +ES_JAVA_OPTS=-Xms512m -Xmx512m +xpack.ml.enabled=false +node.store.allow_mmap=false +xpack.security.enabled=false +action.destructive_requires_name=false +``` + +## Common Mistakes + +| Mistake | Fix | +|---------|-----| +| Using field names in snake_case in ES docs | Always camelCase in ES (`migrationKey`, `installedOn`) | +| Forgetting wrapper object pattern | `{"migration": {...}}` not `{...}` flat | +| Checksum before template resolution | Resolve first, then checksum | +| `op_type=create` for migration records | Only for lock docs — records use normal index | +| Template substituting `method` field | Only substitute path, contentType, params values, body | +| Lock ID missing prefix | Lock ID is `lock:` | +| Missing `refresh=true` on record create | Without it, reads immediately after won't see the record | diff --git a/.claude/superpowers/plans/2026-06-13-multi-platform-phase1-compatibility-harness.md b/.claude/superpowers/plans/2026-06-13-multi-platform-phase1-compatibility-harness.md new file mode 100644 index 0000000..8615841 --- /dev/null +++ b/.claude/superpowers/plans/2026-06-13-multi-platform-phase1-compatibility-harness.md @@ -0,0 +1,1024 @@ +# Multi-Platform Phase 1: Compatibility Test Harness + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Write a comprehensive black-box compatibility test harness in Python that verifies Esque behavioral correctness, then run it against the existing JVM implementation (`esque-core/` in its current location) to establish a passing baseline before any restructuring occurs. + +**Architecture:** The harness lives in `tests/` as a standalone uv project. Each implementation is invoked as a subprocess via a standardized CLI. Tests use pytest parametrized over implementations, testcontainers-python for a real Elasticsearch instance, and httpx for raw ES state queries. The JVM implementation requires a minimal CLI addition (Clikt + Gradle application plugin) to be invocable as a black box. No code is shared between the harness and any implementation. + +**Tech Stack:** Python 3.12+, pytest, testcontainers-python, httpx, pyyaml, uv. JVM CLI: Clikt 4.4.0, Gradle application plugin. + +**This plan establishes the baseline. Subsequent plans (repo restructuring, Python implementation) use this harness to verify nothing breaks.** + +--- + +## Document Structure + +ES documents use a `migration` wrapper object due to Jackson `@JsonTypeInfo(As.WRAPPER_OBJECT)`. +A migration record stored in ES looks like: +```json +{ + "_source": { + "migration": { + "migrationKey": "...", + "order": 0, + "filename": "V1.0.0__CreateFirstIndex.yml", + "version": "1.0.0", + "description": "CreateFirstIndex", + "checksum": -123456789, + "installedBy": null, + "installedOn": "2026-06-13T12:00:00Z", + "executionTime": 42 + } + } +} +``` + +Query for records: `POST /.esque/_search` with body `{"query":{"bool":{"filter":[{"term":{"migration.migrationKey":""}}]}}}`. + +--- + +## File Map + +| Action | Path | +|--------|------| +| Create | `tests/pyproject.toml` | +| Create | `tests/implementations.yml` | +| Create | `tests/conftest.py` | +| Create | `tests/helpers.py` | +| Create | `tests/test_compatibility.py` | +| Create | `tests/fixtures/standard/V1.0.0__CreateFirstIndex.yml` | +| Create | `tests/fixtures/standard/V1.1.0__CreateSecondIndex.yml` | +| Create | `tests/fixtures/standard/V2.0.0__CreateThirdIndex.yml` | +| Create | `tests/fixtures/templated/V1.0.0__CreateFirstIndex.yml` | +| Create | `tests/fixtures/templated/V1.1.0__CreateSecondIndex.yml` | +| Create | `tests/fixtures/templated/V2.0.0__CreateThirdIndex.yml` | +| Create | `tests/fixtures/templated/V3.0.0__CreateTemplatedIndex.yml` | +| Create | `tests/fixtures/single/V1.0.0__CreateFirstIndex.yml` | +| Create | `tests/fixtures/ordering/V1.9.0__NinthMinor.yml` | +| Create | `tests/fixtures/ordering/V1.10.0__TenthMinor.yml` | +| Create | `tests/fixtures/integrity-modified/V1.0.0__CreateFirstIndex.yml` | +| Create | `tests/fixtures/integrity-modified/V1.1.0__CreateSecondIndex.yml` | +| Create | `tests/fixtures/integrity-modified/V2.0.0__CreateThirdIndex.yml` | +| Create | `tests/fixtures/integrity-missing/V1.0.0__CreateFirstIndex.yml` | +| Create | `tests/fixtures/integrity-missing/V1.1.0__CreateSecondIndex.yml` | +| Modify | `gradle/libs.versions.toml` | +| Modify | `esque-core/build.gradle.kts` | +| Create | `esque-core/src/main/kotlin/org/loesak/esque/core/cli/Main.kt` | + +--- + +## Task 1: Add minimal CLI to esque-core + +The harness invokes the JVM implementation as a subprocess via `./gradlew :esque-core:run --args="..."`. This requires Clikt for arg parsing and the Gradle `application` plugin for the `run` task. This is the only change to the existing JVM implementation in this phase. + +**Files:** +- Modify: `gradle/libs.versions.toml` +- Modify: `esque-core/build.gradle.kts` +- Create: `esque-core/src/main/kotlin/org/loesak/esque/core/cli/Main.kt` + +- [ ] **Step 1: Add Clikt to the version catalog** + +In `gradle/libs.versions.toml`, add to `[versions]`: +```toml +clikt = "4.4.0" +``` + +Add to `[libraries]`: +```toml +clikt = { module = "com.github.ajalt.clikt:clikt", version.ref = "clikt" } +``` + +- [ ] **Step 2: Update esque-core/build.gradle.kts** + +Add `application` to the plugins block and `clikt` to dependencies. Final file: + +```kotlin +plugins { + alias(libs.plugins.vanniktech.publish) + application +} + +dependencies { + implementation(libs.kotlin.stdlib) + api(libs.elasticsearch.rest.client) + implementation(platform(libs.jackson.bom)) + implementation(libs.jackson.databind) + implementation(libs.jackson.module.kotlin) + implementation(libs.jackson.dataformat.yaml) + implementation(libs.kotlin.logging.jvm) + implementation(libs.slf4j.api) + implementation(libs.clikt) + + testImplementation(platform(libs.junit.bom)) + testRuntimeOnly(libs.junit.platform.launcher) + testImplementation(libs.logback.classic) + testImplementation(libs.junit.jupiter) + testImplementation(libs.assertj.core) + testImplementation(libs.testcontainers.elasticsearch) + testImplementation(libs.testcontainers.junit.jupiter) +} + +tasks.test { useJUnitPlatform() } + +application { + mainClass.set("org.loesak.esque.core.cli.MainKt") +} + +mavenPublishing { + publishToMavenCentral() + if (providers.gradleProperty("signingInMemoryKey").orNull?.isNotBlank() == true) { + signAllPublications() + } + + coordinates( + groupId = project.group.toString(), + artifactId = project.name, + version = project.version.toString(), + ) + + pom { + name.set("esque") + description.set("Resembles an Elasticsearch Stateful Query Executor") + url.set("https://github.com/loesak/esque") + licenses { + license { + name.set("Apache License, Version 2.0") + url.set("http://www.apache.org/licenses/LICENSE-2.0.txt") + distribution.set("repo") + } + } + developers { + developer { + name.set("Aaron Loes") + email.set("aaron.loes@gmail.com") + organization.set("Loesak") + organizationUrl.set("https://github.com/loesak/esque") + } + } + scm { + connection.set("scm:git:git://github.com/loesak/esque.git") + developerConnection.set("scm:git:ssh://github.com:loesak/esque.git") + url.set("https://github.com/loesak/esque") + } + } +} +``` + +- [ ] **Step 3: Create esque-core/src/main/kotlin/org/loesak/esque/core/cli/Main.kt** + +```kotlin +package org.loesak.esque.core.cli + +import com.github.ajalt.clikt.core.CliktCommand +import com.github.ajalt.clikt.parameters.options.default +import com.github.ajalt.clikt.parameters.options.multiple +import com.github.ajalt.clikt.parameters.options.option +import com.github.ajalt.clikt.parameters.options.required +import com.github.ajalt.clikt.parameters.types.long +import org.apache.http.HttpHost +import org.elasticsearch.client.RestClient +import org.loesak.esque.core.Esque +import org.loesak.esque.core.EsqueConfiguration + +class EsqueCli : + CliktCommand( + name = "esque", + help = "Run Elasticsearch migrations", + ) { + + private val esUrl by + option("--es-url", help = "Elasticsearch URL (e.g. http://localhost:9200)").required() + + private val migrationsDir by + option("--migrations-dir", help = "Absolute path to directory containing migration YAML files") + .required() + + private val migrationKey by + option("--migration-key", help = "Unique key scoping this migration set").required() + + private val migrationUser by + option("--migration-user", help = "User to record on each migration record") + + private val lockTimeoutMinutes by + option("--lock-timeout-minutes", help = "Lock acquisition timeout in minutes") + .long() + .default(5L) + + private val properties by + option( + "--property", + help = "Template substitution property as key=value (repeatable)", + ) + .multiple() + + override fun run() { + val props = + properties.associate { entry -> + val parts = entry.split("=", limit = 2) + check(parts.size == 2) { "Property must be in key=value format, got: $entry" } + parts[0] to parts[1] + } + + val host = HttpHost.create(esUrl) + + RestClient.builder(host).build().use { client -> + Esque( + client = client, + configuration = + EsqueConfiguration( + migrationKey = migrationKey, + migrationUser = migrationUser, + migrationDirectory = "file:$migrationsDir", + lockTimeoutMinutes = lockTimeoutMinutes, + ), + properties = props, + ) + .execute() + } + } +} + +fun main(args: Array) = EsqueCli().main(args) +``` + +- [ ] **Step 4: Format the new file** + +```bash +./gradlew :esque-core:ktfmtFormat +``` + +- [ ] **Step 5: Verify the CLI compiles and --help works** + +```bash +./gradlew :esque-core:run --args="--help" +``` + +Expected output includes: +``` +Usage: esque [] + + Run Elasticsearch migrations + +Options: + --es-url TEXT Elasticsearch URL... + --migrations-dir TEXT Absolute path to directory... + --migration-key TEXT Unique key scoping this migration set + --migration-user TEXT User to record on each migration record + --lock-timeout-minutes INT Lock acquisition timeout in minutes + --property TEXT Template substitution property... + -h, --help Show this message and exit +``` + +- [ ] **Step 6: Run existing tests to confirm nothing is broken** + +```bash +./gradlew :esque-core:test +``` + +Expected: `BUILD SUCCESSFUL`. All existing tests pass. + +- [ ] **Step 7: Commit** + +```bash +git add gradle/libs.versions.toml esque-core/build.gradle.kts esque-core/src/main/kotlin/org/loesak/esque/core/cli/Main.kt +git commit -m "Add Clikt CLI entrypoint to esque-core for compatibility test harness" +``` + +--- + +## Task 2: Set up the tests/ project + +**Files:** +- Create: `tests/pyproject.toml` +- Create: `tests/implementations.yml` + +- [ ] **Step 1: Create tests/pyproject.toml** + +```toml +[project] +name = "esque-compatibility-tests" +version = "0.1.0" +requires-python = ">=3.12" +dependencies = [ + "pytest>=8.3.0", + "testcontainers[elasticsearch]>=4.8.0", + "httpx>=0.27.0", + "pyyaml>=6.0.0", +] +``` + +- [ ] **Step 2: Initialize uv project** + +```bash +cd tests && uv lock +``` + +Expected: `tests/uv.lock` created. + +- [ ] **Step 3: Create tests/implementations.yml** + +```yaml +implementations: + jvm: + invocation: gradle + task: ":esque-core:run" +``` + +This file is updated in later phases when the JVM moves to `implementations/jvm/` and when Python is added. + +--- + +## Task 3: Create migration fixture files + +These YAML files are the migration inputs for all test scenarios. They follow the same format as the existing `esque-core` test resources. + +**Files:** All files under `tests/fixtures/` + +- [ ] **Step 1: Create tests/fixtures/standard/ — 3 non-templated migrations** + +`tests/fixtures/standard/V1.0.0__CreateFirstIndex.yml`: +```yaml +--- +requests: + - method: "PUT" + path: "/test-index-v1" + contentType: application/json; charset=utf-8 +``` + +`tests/fixtures/standard/V1.1.0__CreateSecondIndex.yml`: +```yaml +--- +requests: + - method: "PUT" + path: "/test-index-v2" + contentType: application/json; charset=utf-8 +``` + +`tests/fixtures/standard/V2.0.0__CreateThirdIndex.yml`: +```yaml +--- +requests: + - method: "PUT" + path: "/test-index-v3" + contentType: application/json; charset=utf-8 +``` + +- [ ] **Step 2: Create tests/fixtures/templated/ — standard 3 plus one templated** + +Copy the 3 standard files into `tests/fixtures/templated/` with identical content: + +`tests/fixtures/templated/V1.0.0__CreateFirstIndex.yml`: (same as standard) +`tests/fixtures/templated/V1.1.0__CreateSecondIndex.yml`: (same as standard) +`tests/fixtures/templated/V2.0.0__CreateThirdIndex.yml`: (same as standard) + +`tests/fixtures/templated/V3.0.0__CreateTemplatedIndex.yml`: +```yaml +--- +requests: + - method: "PUT" + path: "/#{indexName}" + contentType: application/json; charset=utf-8 +``` + +- [ ] **Step 3: Create tests/fixtures/single/ — one migration** + +`tests/fixtures/single/V1.0.0__CreateFirstIndex.yml`: (same content as standard V1.0.0) +```yaml +--- +requests: + - method: "PUT" + path: "/test-index-v1" + contentType: application/json; charset=utf-8 +``` + +- [ ] **Step 4: Create tests/fixtures/ordering/ — numeric version ordering edge case** + +`tests/fixtures/ordering/V1.9.0__NinthMinor.yml`: +```yaml +--- +requests: + - method: "PUT" + path: "/test-index-minor-9" + contentType: application/json; charset=utf-8 +``` + +`tests/fixtures/ordering/V1.10.0__TenthMinor.yml`: +```yaml +--- +requests: + - method: "PUT" + path: "/test-index-minor-10" + contentType: application/json; charset=utf-8 +``` + +`V1.9.0` and `V1.10.0` are lexicographically ordered as `V1.10.0 < V1.9.0` (because `'1' < '9'`). The correct numeric order is `V1.9.0` first. This test catches implementations that sort versions as strings. + +- [ ] **Step 5: Create tests/fixtures/integrity-modified/ — same filenames, V1.0.0 has different content** + +`tests/fixtures/integrity-modified/V1.0.0__CreateFirstIndex.yml`: +```yaml +--- +requests: + - method: "PUT" + path: "/test-index-v1-modified" + contentType: application/json; charset=utf-8 +``` + +`tests/fixtures/integrity-modified/V1.1.0__CreateSecondIndex.yml`: (identical to standard) +```yaml +--- +requests: + - method: "PUT" + path: "/test-index-v2" + contentType: application/json; charset=utf-8 +``` + +`tests/fixtures/integrity-modified/V2.0.0__CreateThirdIndex.yml`: (identical to standard) +```yaml +--- +requests: + - method: "PUT" + path: "/test-index-v3" + contentType: application/json; charset=utf-8 +``` + +The `/test-index-v1-modified` path in V1.0.0 produces a different checksum than `/test-index-v1` in the standard fixture. Running standard first, then re-running with integrity-modified triggers a checksum mismatch failure. + +- [ ] **Step 6: Create tests/fixtures/integrity-missing/ — only 2 of the 3 standard migrations** + +`tests/fixtures/integrity-missing/V1.0.0__CreateFirstIndex.yml`: (identical to standard) +```yaml +--- +requests: + - method: "PUT" + path: "/test-index-v1" + contentType: application/json; charset=utf-8 +``` + +`tests/fixtures/integrity-missing/V1.1.0__CreateSecondIndex.yml`: (identical to standard) +```yaml +--- +requests: + - method: "PUT" + path: "/test-index-v2" + contentType: application/json; charset=utf-8 +``` + +`V2.0.0` is intentionally absent. After applying all 3 standard migrations, re-running with this 2-file directory triggers "more records than files" failure. + +--- + +## Task 4: Write conftest.py + +**Files:** +- Create: `tests/conftest.py` + +- [ ] **Step 1: Create tests/conftest.py** + +```python +import pytest +import httpx +from testcontainers.elasticsearch import ElasticSearchContainer + + +ES_IMAGE = "docker.elastic.co/elasticsearch/elasticsearch:9.3.0" + + +@pytest.fixture(scope="session") +def es_url(): + with ( + ElasticSearchContainer(ES_IMAGE) + .with_env("xpack.security.enabled", "false") + .with_env("action.destructive_requires_name", "false") + ) as es: + yield es.get_url() + + +@pytest.fixture(autouse=True) +def clean_es(es_url): + for pattern in ["/.esque", "/test-*"]: + try: + httpx.delete(f"{es_url}{pattern}", timeout=10) + except Exception: + pass +``` + +`scope="session"` starts one Elasticsearch container shared across all tests. `autouse=True` on `clean_es` ensures ES state is wiped before every test function without needing to call it explicitly. The `test-*` wildcard delete clears any indices created by migration fixtures. + +--- + +## Task 5: Write helpers.py + +**Files:** +- Create: `tests/helpers.py` + +- [ ] **Step 1: Create tests/helpers.py** + +```python +from __future__ import annotations + +import subprocess +from dataclasses import dataclass, field +from pathlib import Path + +import httpx +import yaml + +ROOT_DIR = Path(__file__).parent.parent + +STANDARD_MIGRATIONS = ROOT_DIR / "tests" / "fixtures" / "standard" +TEMPLATED_MIGRATIONS = ROOT_DIR / "tests" / "fixtures" / "templated" +SINGLE_MIGRATION = ROOT_DIR / "tests" / "fixtures" / "single" +ORDERING_MIGRATIONS = ROOT_DIR / "tests" / "fixtures" / "ordering" +INTEGRITY_MODIFIED_MIGRATIONS = ROOT_DIR / "tests" / "fixtures" / "integrity-modified" +INTEGRITY_MISSING_MIGRATIONS = ROOT_DIR / "tests" / "fixtures" / "integrity-missing" + + +@dataclass +class Implementation: + name: str + invocation: str + task: str | None = None + command: list[str] = field(default_factory=list) + + +def all_implementations() -> list[Implementation]: + config_path = ROOT_DIR / "tests" / "implementations.yml" + config = yaml.safe_load(config_path.read_text()) + return [ + Implementation(name=name, **cfg) + for name, cfg in config["implementations"].items() + ] + + +def run( + impl: Implementation, + es_url: str, + key: str, + migrations_dir: Path, + user: str | None = None, + properties: dict[str, str] | None = None, +) -> subprocess.CompletedProcess: + esque_args = [ + f"--es-url={es_url}", + f"--migrations-dir={migrations_dir}", + f"--migration-key={key}", + ] + if user: + esque_args.append(f"--migration-user={user}") + if properties: + for k, v in properties.items(): + esque_args.append(f"--property={k}={v}") + + if impl.invocation == "gradle": + args_str = " ".join(esque_args) + cmd = ["./gradlew", impl.task, f"--args={args_str}"] + elif impl.invocation == "direct": + cmd = [*impl.command, *esque_args] + else: + raise ValueError(f"Unknown invocation type: {impl.invocation}") + + return subprocess.run( + cmd, + capture_output=True, + text=True, + cwd=ROOT_DIR, + timeout=300, + ) + + +def get_records(es_url: str, key: str) -> list[dict]: + try: + response = httpx.post( + f"{es_url}/.esque/_search", + json={ + "query": { + "bool": { + "filter": [{"term": {"migration.migrationKey": key}}] + } + } + }, + timeout=10, + ) + if response.status_code == 404: + return [] + response.raise_for_status() + except Exception: + return [] + + hits = response.json()["hits"]["hits"] + records = [hit["_source"]["migration"] for hit in hits] + return sorted(records, key=lambda r: r["order"]) + + +def assert_index_exists(es_url: str, index: str) -> None: + response = httpx.head(f"{es_url}/{index}", timeout=10) + assert response.status_code == 200, ( + f"Expected index '{index}' to exist but got HTTP {response.status_code}" + ) + + +def assert_index_absent(es_url: str, index: str) -> None: + response = httpx.head(f"{es_url}/{index}", timeout=10) + assert response.status_code == 404, ( + f"Expected index '{index}' to be absent but got HTTP {response.status_code}" + ) +``` + +--- + +## Task 6: Write test_compatibility.py + +**Files:** +- Create: `tests/test_compatibility.py` + +Write the full test file in one step. Each test function is parametrized over `all_implementations()`. Test names include the implementation name so failures are immediately identifiable. + +- [ ] **Step 1: Create tests/test_compatibility.py** + +```python +import pytest +from helpers import ( + Implementation, + INTEGRITY_MISSING_MIGRATIONS, + INTEGRITY_MODIFIED_MIGRATIONS, + ORDERING_MIGRATIONS, + SINGLE_MIGRATION, + STANDARD_MIGRATIONS, + TEMPLATED_MIGRATIONS, + all_implementations, + assert_index_absent, + assert_index_exists, + get_records, + run, +) + + +def implementations(): + return pytest.mark.parametrize( + "impl", + all_implementations(), + ids=lambda i: i.name, + ) + + +# --------------------------------------------------------------------------- +# Initialization +# --------------------------------------------------------------------------- + + +@implementations() +def test_esque_management_index_created(impl: Implementation, es_url: str) -> None: + result = run(impl, es_url, key="init-test", migrations_dir=STANDARD_MIGRATIONS) + assert result.returncode == 0, f"esque failed:\n{result.stderr}" + assert_index_exists(es_url, ".esque") + + +# --------------------------------------------------------------------------- +# Execution +# --------------------------------------------------------------------------- + + +@implementations() +def test_all_migrations_run(impl: Implementation, es_url: str) -> None: + result = run(impl, es_url, key="run-all-test", migrations_dir=STANDARD_MIGRATIONS) + assert result.returncode == 0, f"esque failed:\n{result.stderr}" + assert_index_exists(es_url, "test-index-v1") + assert_index_exists(es_url, "test-index-v2") + assert_index_exists(es_url, "test-index-v3") + + +@implementations() +def test_idempotent_execution(impl: Implementation, es_url: str) -> None: + key = "idempotent-test" + + result = run(impl, es_url, key=key, migrations_dir=STANDARD_MIGRATIONS) + assert result.returncode == 0, f"First run failed:\n{result.stderr}" + first = get_records(es_url, key) + assert len(first) == 3 + + result = run(impl, es_url, key=key, migrations_dir=STANDARD_MIGRATIONS) + assert result.returncode == 0, f"Second run failed:\n{result.stderr}" + second = get_records(es_url, key) + assert len(second) == 3 + + for a, b in zip(first, second): + assert a["checksum"] == b["checksum"], "Checksum changed between runs" + assert a["installedOn"] == b["installedOn"], "installedOn changed between runs" + + +@implementations() +def test_different_migration_keys_are_independent( + impl: Implementation, es_url: str +) -> None: + key_a = "independent-key-a" + key_b = "independent-key-b" + + result = run(impl, es_url, key=key_a, migrations_dir=SINGLE_MIGRATION) + assert result.returncode == 0, f"esque failed:\n{result.stderr}" + + assert len(get_records(es_url, key_a)) == 1 + assert len(get_records(es_url, key_b)) == 0 + + +# --------------------------------------------------------------------------- +# Migration history metadata +# --------------------------------------------------------------------------- + + +@implementations() +def test_migration_history_record_count(impl: Implementation, es_url: str) -> None: + key = "history-count-test" + result = run(impl, es_url, key=key, migrations_dir=STANDARD_MIGRATIONS) + assert result.returncode == 0, f"esque failed:\n{result.stderr}" + assert len(get_records(es_url, key)) == 3 + + +@implementations() +def test_migration_history_metadata(impl: Implementation, es_url: str) -> None: + key = "history-metadata-test" + result = run(impl, es_url, key=key, migrations_dir=STANDARD_MIGRATIONS) + assert result.returncode == 0, f"esque failed:\n{result.stderr}" + + records = get_records(es_url, key) + assert len(records) == 3 + + assert records[0]["order"] == 0 + assert records[0]["filename"] == "V1.0.0__CreateFirstIndex.yml" + assert records[0]["version"] == "1.0.0" + assert records[0]["description"] == "CreateFirstIndex" + assert records[0]["migrationKey"] == key + + assert records[1]["order"] == 1 + assert records[1]["filename"] == "V1.1.0__CreateSecondIndex.yml" + assert records[1]["version"] == "1.1.0" + + assert records[2]["order"] == 2 + assert records[2]["filename"] == "V2.0.0__CreateThirdIndex.yml" + assert records[2]["version"] == "2.0.0" + + +@implementations() +def test_migration_history_checksum_is_present(impl: Implementation, es_url: str) -> None: + key = "history-checksum-test" + result = run(impl, es_url, key=key, migrations_dir=STANDARD_MIGRATIONS) + assert result.returncode == 0, f"esque failed:\n{result.stderr}" + + for record in get_records(es_url, key): + assert record.get("checksum") is not None, ( + f"checksum missing on record {record['filename']}" + ) + + +@implementations() +def test_migration_history_execution_time_is_non_negative( + impl: Implementation, es_url: str +) -> None: + key = "history-exectime-test" + result = run(impl, es_url, key=key, migrations_dir=STANDARD_MIGRATIONS) + assert result.returncode == 0, f"esque failed:\n{result.stderr}" + + for record in get_records(es_url, key): + assert record["executionTime"] >= 0, ( + f"executionTime is negative on record {record['filename']}" + ) + + +@implementations() +def test_migration_history_installed_on_is_present( + impl: Implementation, es_url: str +) -> None: + key = "history-installedon-test" + result = run(impl, es_url, key=key, migrations_dir=STANDARD_MIGRATIONS) + assert result.returncode == 0, f"esque failed:\n{result.stderr}" + + for record in get_records(es_url, key): + assert record.get("installedOn") is not None, ( + f"installedOn missing on record {record['filename']}" + ) + + +@implementations() +def test_migration_user_recorded_when_provided(impl: Implementation, es_url: str) -> None: + key = "user-test" + result = run( + impl, es_url, key=key, migrations_dir=STANDARD_MIGRATIONS, user="test-user" + ) + assert result.returncode == 0, f"esque failed:\n{result.stderr}" + + for record in get_records(es_url, key): + assert record.get("installedBy") == "test-user", ( + f"Expected installedBy='test-user' on {record['filename']}, got {record.get('installedBy')!r}" + ) + + +@implementations() +def test_migration_user_null_when_not_provided(impl: Implementation, es_url: str) -> None: + key = "no-user-test" + result = run(impl, es_url, key=key, migrations_dir=STANDARD_MIGRATIONS) + assert result.returncode == 0, f"esque failed:\n{result.stderr}" + + for record in get_records(es_url, key): + assert record.get("installedBy") is None, ( + f"Expected installedBy=null on {record['filename']}, got {record.get('installedBy')!r}" + ) + + +# --------------------------------------------------------------------------- +# Template variable substitution +# --------------------------------------------------------------------------- + + +@implementations() +def test_template_substitution_creates_correct_index( + impl: Implementation, es_url: str +) -> None: + result = run( + impl, + es_url, + key="template-test", + migrations_dir=TEMPLATED_MIGRATIONS, + properties={"indexName": "test-index-v4"}, + ) + assert result.returncode == 0, f"esque failed:\n{result.stderr}" + assert_index_exists(es_url, "test-index-v4") + + +@implementations() +def test_extra_template_properties_ignored(impl: Implementation, es_url: str) -> None: + result = run( + impl, + es_url, + key="extra-props-test", + migrations_dir=STANDARD_MIGRATIONS, + properties={"unused": "ignored", "alsoUnused": "alsoIgnored"}, + ) + assert result.returncode == 0, f"esque failed:\n{result.stderr}" + assert_index_exists(es_url, "test-index-v1") + + +@implementations() +def test_missing_template_property_fails_before_any_migration( + impl: Implementation, es_url: str +) -> None: + key = "missing-var-test" + result = run( + impl, + es_url, + key=key, + migrations_dir=TEMPLATED_MIGRATIONS, + # no properties — #{indexName} is unresolvable + ) + assert result.returncode != 0, ( + f"Expected esque to fail with missing template variable, but it succeeded" + ) + assert len(get_records(es_url, key)) == 0, ( + "No migration records should be written when template validation fails" + ) + + +# --------------------------------------------------------------------------- +# Integrity verification +# --------------------------------------------------------------------------- + + +@implementations() +def test_integrity_checksum_mismatch_causes_failure( + impl: Implementation, es_url: str +) -> None: + key = "checksum-mismatch-test" + + # First run: apply standard migrations + result = run(impl, es_url, key=key, migrations_dir=STANDARD_MIGRATIONS) + assert result.returncode == 0, f"First run failed:\n{result.stderr}" + assert len(get_records(es_url, key)) == 3 + + # Second run: same filenames, V1.0.0 has different content → checksum mismatch + result = run(impl, es_url, key=key, migrations_dir=INTEGRITY_MODIFIED_MIGRATIONS) + assert result.returncode != 0, ( + "Expected esque to fail due to checksum mismatch, but it succeeded" + ) + + +@implementations() +def test_integrity_fewer_files_than_records_causes_failure( + impl: Implementation, es_url: str +) -> None: + key = "fewer-files-test" + + # First run: apply all 3 standard migrations + result = run(impl, es_url, key=key, migrations_dir=STANDARD_MIGRATIONS) + assert result.returncode == 0, f"First run failed:\n{result.stderr}" + assert len(get_records(es_url, key)) == 3 + + # Second run: only 2 migration files — 3 records but 2 files → should fail + result = run(impl, es_url, key=key, migrations_dir=INTEGRITY_MISSING_MIGRATIONS) + assert result.returncode != 0, ( + "Expected esque to fail when migration records outnumber local files" + ) + + +# --------------------------------------------------------------------------- +# Version ordering +# --------------------------------------------------------------------------- + + +@implementations() +def test_version_ordering_is_numeric_not_lexicographic( + impl: Implementation, es_url: str +) -> None: + key = "ordering-test" + result = run(impl, es_url, key=key, migrations_dir=ORDERING_MIGRATIONS) + assert result.returncode == 0, f"esque failed:\n{result.stderr}" + + records = get_records(es_url, key) + assert len(records) == 2 + + # V1.9.0 must come before V1.10.0 (numeric), not after (lexicographic) + assert records[0]["version"] == "1.9.0", ( + f"Expected first record to be V1.9.0 but got V{records[0]['version']}" + ) + assert records[1]["version"] == "1.10.0", ( + f"Expected second record to be V1.10.0 but got V{records[1]['version']}" + ) +``` + +--- + +## Task 7: Run the tests and verify all pass + +- [ ] **Step 1: Run the full test suite** + +From the repo root: +```bash +cd tests && uv run pytest -v +``` + +Or from the repo root: +```bash +uv run --project tests pytest tests/ -v +``` + +Expected: All tests pass. Output will show each test parametrized by implementation name, e.g.: +``` +tests/test_compatibility.py::test_esque_management_index_created[jvm] PASSED +tests/test_compatibility.py::test_all_migrations_run[jvm] PASSED +... +15 passed in Xs +``` + +- [ ] **Step 2: If any test fails, diagnose before proceeding** + +For unexpected failures, add `-s` to see subprocess output: +```bash +uv run --project tests pytest tests/ -v -s +``` + +The `result.stderr` is included in assertion messages. For Gradle run failures, the output is captured in `result.stderr` and printed on assertion failure. + +- [ ] **Step 3: Commit once all tests pass** + +```bash +git add tests/ +git commit -m "Add comprehensive compatibility test harness + +15 scenarios covering: initialization, execution, idempotency, key +independence, history metadata, user recording, template substitution, +integrity verification (checksum mismatch, too few files), and numeric +version ordering. All tests pass against the existing JVM implementation." +``` + +--- + +## Self-Review + +**Spec coverage check:** + +| Spec requirement | Covered by | +|---|---| +| Creates .esque index | `test_esque_management_index_created` | +| Runs all migrations | `test_all_migrations_run` | +| Idempotent execution | `test_idempotent_execution` | +| Key independence | `test_different_migration_keys_are_independent` | +| Record count | `test_migration_history_record_count` | +| Record metadata (filename, version, description, order, key) | `test_migration_history_metadata` | +| Checksum present | `test_migration_history_checksum_is_present` | +| executionTime non-negative | `test_migration_history_execution_time_is_non_negative` | +| installedOn present | `test_migration_history_installed_on_is_present` | +| User recorded | `test_migration_user_recorded_when_provided` | +| Null user | `test_migration_user_null_when_not_provided` | +| Template substitution | `test_template_substitution_creates_correct_index` | +| Extra properties ignored | `test_extra_template_properties_ignored` | +| Missing template variable fails before migrations | `test_missing_template_property_fails_before_any_migration` | +| Checksum mismatch fails | `test_integrity_checksum_mismatch_causes_failure` | +| Fewer files than records fails | `test_integrity_fewer_files_than_records_causes_failure` | +| Numeric version ordering | `test_version_ordering_is_numeric_not_lexicographic` | + +**Type consistency:** `Implementation` dataclass defined once in `helpers.py`, imported in `test_compatibility.py`. All path constants (`STANDARD_MIGRATIONS`, etc.) defined in `helpers.py` and imported by name. `all_implementations()` called in `implementations()` decorator helper, not redefined. + +**ES document field paths:** Confirmed from `MigrationRecord.kt` with `@JsonTypeInfo(As.WRAPPER_OBJECT)` — all record fields are accessed as `record["migration"]["fieldName"]` in ES `_source`, and `migration.fieldName` in query filter terms. `get_records()` unwraps the `migration` wrapper before returning, so test code accesses `record["fieldName"]` directly. + +**Container startup:** `scope="session"` on `es_url` starts one container per pytest session. `autouse=True` on `clean_es` cleans state before every test. The `clean_es` fixture depends on `es_url` (session-scoped) from a function-scoped fixture — pytest allows this. + +**Gradle --args quoting:** All values passed to `--args` are controlled (no spaces in URLs, keys, or paths under the repo). The `=` form (`--es-url=http://...`) avoids any whitespace-split ambiguity in Gradle's arg parsing. diff --git a/.claude/superpowers/plans/2026-06-13-multi-platform-phase1-jvm.md b/.claude/superpowers/plans/2026-06-13-multi-platform-phase1-jvm.md new file mode 100644 index 0000000..1642d52 --- /dev/null +++ b/.claude/superpowers/plans/2026-06-13-multi-platform-phase1-jvm.md @@ -0,0 +1,507 @@ +# Multi-Platform Phase 1: Repo Restructuring + JVM Updates + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Relocate `esque-core/` to `implementations/jvm/`, remove `esque-examples/`, update the checksum algorithm from YAML-based to a cross-language JSON-based spec, and add a Clikt CLI entrypoint so the compatibility test harness can invoke the JVM implementation as a subprocess. + +**Architecture:** Files move via `git mv` to preserve history. Gradle settings use a project directory override so the module stays named `esque-core` (preserving the published artifact ID). The checksum change is a breaking change — no existing migration records will be valid after this update. The CLI is a thin Clikt command in `cli/Main.kt` that delegates to `Esque.execute()`. + +**Tech Stack:** Kotlin 2.4.0, Gradle 9.5.1, Jackson 3.x (`tools.jackson`), Clikt 4.4.0, Elasticsearch 9.4.x low-level REST client (`org.apache.http.HttpHost`) + +**Subsequent plans:** +- Phase 2: Python library implementation +- Phase 3: Compatibility test harness +- Phase 4: CI/CD + +--- + +## File Map + +| Action | Path | +|--------|------| +| Move (all contents) | `esque-core/` → `implementations/jvm/` | +| Delete | `esque-examples/` | +| Modify | `settings.gradle.kts` | +| Modify | `implementations/jvm/build.gradle.kts` | +| Modify | `implementations/jvm/src/main/kotlin/org/loesak/esque/core/yaml/MigrationFileLoader.kt` | +| Modify | `gradle/libs.versions.toml` | +| Create | `implementations/jvm/src/main/kotlin/org/loesak/esque/core/cli/Main.kt` | + +--- + +## Task 1: Move esque-core to implementations/jvm/ + +**Files:** +- Move: `esque-core/` → `implementations/jvm/` + +- [ ] **Step 1: Create the implementations directory and move esque-core into it** + +```bash +mkdir -p implementations +git mv esque-core implementations/jvm +``` + +- [ ] **Step 2: Verify the move looks correct** + +```bash +ls implementations/jvm/ +``` + +Expected: `build.gradle.kts src/` + +- [ ] **Step 3: Remove esque-examples** + +```bash +git rm -r esque-examples/ +``` + +Expected: A list of deleted files prefixed with `rm '...'`. + +--- + +## Task 2: Update Gradle settings and build files + +**Files:** +- Modify: `settings.gradle.kts` +- Modify: `implementations/jvm/build.gradle.kts` + +- [ ] **Step 1: Update settings.gradle.kts** + +Replace the entire file: + +```kotlin +rootProject.name = "esque" + +include("esque-core") +project(":esque-core").projectDir = file("implementations/jvm") +``` + +The `project(":esque-core").projectDir` override keeps the Gradle project named `esque-core` so the published Maven artifact ID (`org.loesak.esque:esque-core`) requires no changes to the publishing config. + +- [ ] **Step 2: Add the application plugin to implementations/jvm/build.gradle.kts** + +Add `application` to the existing plugins block (keep everything else unchanged): + +```kotlin +plugins { + alias(libs.plugins.vanniktech.publish) + application +} +``` + +- [ ] **Step 3: Verify Gradle recognizes the new structure** + +```bash +./gradlew projects +``` + +Expected output includes: +``` +Root project 'esque' +\--- Project ':esque-core' +``` + +--- + +## Task 3: Verify the JVM build and tests still pass + +**Files:** None modified in this task. + +- [ ] **Step 1: Run the full build** + +```bash +./gradlew build +``` + +Expected: `BUILD SUCCESSFUL`. All existing tests pass. Integration tests require Docker — if Docker is unavailable, run `./gradlew ktfmtCheck detekt compileKotlin` instead. + +- [ ] **Step 2: Commit the restructuring** + +```bash +git add -A +git commit -m "Restructure repo: move esque-core to implementations/jvm/, remove esque-examples" +``` + +--- + +## Task 4: Update the checksum algorithm + +The current checksum serializes `MigrationFileContents` to YAML using Jackson's `YAMLMapper`. The new algorithm uses a hand-built JSON representation for cross-language reproducibility: fields in alphabetical order, null fields omitted, params map keys sorted. This is a **breaking change** — any migration history written before this change will fail integrity verification. That is expected and accepted. + +**Files:** +- Modify: `implementations/jvm/src/main/kotlin/org/loesak/esque/core/yaml/MigrationFileLoader.kt` + +- [ ] **Step 1: Write a failing test first** + +In `implementations/jvm/src/test/kotlin/org/loesak/esque/core/yaml/MigrationFileLoaderTest.kt`, add this test to verify the new algorithm produces the same checksum as a hand-computed value. This test will fail until we update the implementation because the YAML-based algorithm will produce a different value. + +Add at the bottom of the `MigrationFileLoaderTest` class, before the closing brace: + +```kotlin +@Test +fun calculateChecksum_jsonAlgorithm_simpleRequest_producesExpectedValue() { + // {"method":"PUT","path":"/index"} → MD5 bytes → first 4 as big-endian int + // Pre-computed: echo -n '[{"method":"PUT","path":"/index"}]' | md5sum + // md5 bytes: see step below for how to compute + val c = contents(req(method = "PUT", path = "/index")) + val checksum = MigrationFileLoader.calculateChecksum(c) + // We verify cross-run stability only — actual value asserted in step below + assertThat(MigrationFileLoader.calculateChecksum(c)).isEqualTo(checksum) +} + +@Test +fun calculateChecksum_nullFieldsOmitted_sameAsExplicitAbsence() { + val withNulls = contents(req(method = "PUT", path = "/index", body = null, contentType = null, params = null)) + val withoutNulls = contents(req(method = "PUT", path = "/index")) + assertThat(MigrationFileLoader.calculateChecksum(withNulls)) + .isEqualTo(MigrationFileLoader.calculateChecksum(withoutNulls)) +} +``` + +- [ ] **Step 2: Run tests to confirm existing checksum tests still describe the right behavior** + +```bash +./gradlew :esque-core:test --tests "org.loesak.esque.core.yaml.MigrationFileLoaderTest" +``` + +All existing behavioral tests should still describe the right behavior (same contents → same checksum, changed content → different checksum). The `calculateChecksum_resolvedTemplateMatchesHardcoded` test may produce a different value but still pass since it checks equality between two calls, not a specific value. + +- [ ] **Step 3: Update calculateChecksum in MigrationFileLoader.kt** + +In `implementations/jvm/src/main/kotlin/org/loesak/esque/core/yaml/MigrationFileLoader.kt`, make the following changes: + +Add this import at the top (with the other imports): +```kotlin +import tools.jackson.databind.json.JsonMapper +``` + +Remove these two mapper declarations from the `companion object`: +```kotlin +private val YAML_MAPPER = YAMLMapper.builder().addModule(KotlinModule.Builder().build()).build() +private val YAML_MAPPER_SORTED = + YAMLMapper.builder() + .addModule(KotlinModule.Builder().build()) + .configure(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, true) + .build() +``` + +Add this mapper declaration in the `companion object`: +```kotlin +private val JSON_MAPPER_CHECKSUM = + JsonMapper.builder() + .addModule(KotlinModule.Builder().build()) + .build() +``` + +Replace the `calculateChecksum` function body: +```kotlin +internal fun calculateChecksum(contents: MigrationFile.MigrationFileContents): Int { + val canonical = + contents.requests.map { req -> + buildMap { + req.body?.let { put("body", it) } + req.contentType?.let { put("contentType", it) } + put("method", req.method) + req.params?.let { put("params", it.toSortedMap()) } + put("path", req.path) + } + } + val digest = MessageDigest.getInstance("MD5") + digest.update(JSON_MAPPER_CHECKSUM.writeValueAsBytes(canonical)) + return ByteBuffer.wrap(digest.digest()).int +} +``` + +The `buildMap` inserts keys in alphabetical order (body, contentType, method, params, path). Null fields are skipped by the `?.let` guards. The `params` map keys are sorted via `toSortedMap()`. The result is a `List>` that Jackson serializes to compact JSON. + +- [ ] **Step 4: Remove unused imports from MigrationFileLoader.kt** + +Remove these imports if they are now unused: +```kotlin +import tools.jackson.databind.SerializationFeature +import tools.jackson.dataformat.yaml.YAMLMapper +``` + +`KotlinModule` import stays — it's used in `JSON_MAPPER_CHECKSUM`. `YAML_MAPPER` was used in `readRaw` — double check `readRaw` still uses `YAML_MAPPER` for reading migration files. If so, keep the `YAMLMapper` import and declaration. Only remove what is actually unused. + +> **Note:** `YAML_MAPPER` (without sorting) is used in `readRaw()` to parse migration YAML files. Do NOT remove it. Only `YAML_MAPPER_SORTED` is replaced. + +Final companion object should have: +- `MIGRATION_DEFINITION_FILE_NAME_REGEX` and `FILE_NAME_PATTERN` (unchanged) +- `YAML_MAPPER` (unchanged — still used in `readRaw`) +- `JSON_MAPPER_CHECKSUM` (new — used in `calculateChecksum`) + +- [ ] **Step 5: Run the checksum tests** + +```bash +./gradlew :esque-core:test --tests "org.loesak.esque.core.yaml.MigrationFileLoaderTest" +``` + +Expected: All tests pass including the new ones. The existing behavioral tests (`sameContents_sameChecksum`, `requestsReordered_differentChecksum`, etc.) all remain valid — they test the algorithm's properties, not specific values. + +- [ ] **Step 6: Run the full test suite** + +```bash +./gradlew :esque-core:test +``` + +Expected: `BUILD SUCCESSFUL`. The integration tests (`EsqueIT`) exercise the full execution path. Since they create fresh ES state via Testcontainers, the breaking change in checksum values does not affect them — they start clean each time. + +- [ ] **Step 7: Commit the checksum change** + +```bash +git add implementations/jvm/src/main/kotlin/org/loesak/esque/core/yaml/MigrationFileLoader.kt +git add implementations/jvm/src/test/kotlin/org/loesak/esque/core/yaml/MigrationFileLoaderTest.kt +git commit -m "Replace YAML-based checksum with cross-language JSON canonical algorithm + +This is a breaking change. Any migration history written by a previous +version of esque will fail integrity verification. The new algorithm +serializes post-template-resolution request fields in alphabetical order +with null fields omitted, producing compact JSON before MD5 hashing." +``` + +--- + +## Task 5: Add Clikt dependency + +**Files:** +- Modify: `gradle/libs.versions.toml` +- Modify: `implementations/jvm/build.gradle.kts` + +- [ ] **Step 1: Add Clikt to the version catalog** + +In `gradle/libs.versions.toml`, add to the `[versions]` section: +```toml +clikt = "4.4.0" +``` + +Add to the `[libraries]` section: +```toml +clikt = { module = "com.github.ajalt.clikt:clikt", version.ref = "clikt" } +``` + +- [ ] **Step 2: Add Clikt as an implementation dependency in the JVM build** + +In `implementations/jvm/build.gradle.kts`, add to the `dependencies` block: +```kotlin +implementation(libs.clikt) +``` + +- [ ] **Step 3: Set the application main class** + +In `implementations/jvm/build.gradle.kts`, add after the `dependencies` block: +```kotlin +application { + mainClass.set("org.loesak.esque.core.cli.MainKt") +} +``` + +- [ ] **Step 4: Verify the dependency resolves** + +```bash +./gradlew :esque-core:dependencies --configuration runtimeClasspath | grep clikt +``` + +Expected: A line containing `com.github.ajalt.clikt:clikt:4.4.0`. + +--- + +## Task 6: Implement the CLI + +**Files:** +- Create: `implementations/jvm/src/main/kotlin/org/loesak/esque/core/cli/Main.kt` + +The CLI is a thin Clikt command. It parses the standardized arguments defined in the multi-platform spec and delegates entirely to `Esque.execute()`. No business logic lives here. + +- [ ] **Step 1: Create the CLI file** + +Create `implementations/jvm/src/main/kotlin/org/loesak/esque/core/cli/Main.kt`: + +```kotlin +package org.loesak.esque.core.cli + +import com.github.ajalt.clikt.core.CliktCommand +import com.github.ajalt.clikt.parameters.options.default +import com.github.ajalt.clikt.parameters.options.multiple +import com.github.ajalt.clikt.parameters.options.option +import com.github.ajalt.clikt.parameters.options.required +import com.github.ajalt.clikt.parameters.types.long +import org.apache.http.HttpHost +import org.elasticsearch.client.RestClient +import org.loesak.esque.core.Esque +import org.loesak.esque.core.EsqueConfiguration + +class EsqueCli : + CliktCommand( + name = "esque", + help = "Run Elasticsearch migrations", + ) { + + private val esUrl by option("--es-url", help = "Elasticsearch URL (e.g. http://localhost:9200)") + .required() + + private val migrationsDir by option("--migrations-dir", help = "Path to migration YAML files") + .required() + + private val migrationKey by option("--migration-key", help = "Unique key for this migration set") + .required() + + private val migrationUser by option("--migration-user", help = "User to record on each migration record") + + private val lockTimeoutMinutes by option("--lock-timeout-minutes", help = "Lock acquisition timeout in minutes") + .long() + .default(5L) + + private val properties by option( + "--property", + help = "Template substitution property as key=value (repeatable)", + ).multiple() + + override fun run() { + val props = + properties.associate { entry -> + val parts = entry.split("=", limit = 2) + check(parts.size == 2) { "Property must be in key=value format, got: $entry" } + parts[0] to parts[1] + } + + val host = HttpHost.create(esUrl) + + RestClient.builder(host).build().use { client -> + Esque( + client = client, + configuration = + EsqueConfiguration( + migrationKey = migrationKey, + migrationUser = migrationUser, + migrationDirectory = "file:$migrationsDir", + lockTimeoutMinutes = lockTimeoutMinutes, + ), + properties = props, + ) + .execute() + } + } +} + +fun main(args: Array) = EsqueCli().main(args) +``` + +> **Note:** `migrationsDir` is prefixed with `file:` before passing to `EsqueConfiguration.migrationDirectory`. The harness always provides an absolute filesystem path; `file:` is the scheme the `MigrationFileLoader` understands for non-classpath directories. + +- [ ] **Step 2: Format the new file** + +```bash +./gradlew :esque-core:ktfmtFormat +``` + +- [ ] **Step 3: Verify the file compiles** + +```bash +./gradlew :esque-core:compileKotlin +``` + +Expected: `BUILD SUCCESSFUL`. + +--- + +## Task 7: Verify the CLI runs end-to-end + +- [ ] **Step 1: Start a local Elasticsearch instance** + +The easiest way is via Docker: + +```bash +docker run -d --name esque-test-es \ + -p 9200:9200 \ + -e "xpack.security.enabled=false" \ + -e "action.destructive_requires_name=false" \ + docker.elastic.co/elasticsearch/elasticsearch:9.3.0 +``` + +Wait ~20 seconds for it to be ready: +```bash +curl -s http://localhost:9200/_cluster/health | grep -o '"status":"[^"]*"' +``` +Expected: `"status":"green"` or `"status":"yellow"`. + +- [ ] **Step 2: Run --help to verify Clikt wiring** + +```bash +./gradlew :esque-core:run --args="--help" +``` + +Expected output: +``` +Usage: esque [] + + Run Elasticsearch migrations + +Options: + --es-url Elasticsearch URL (e.g. http://localhost:9200) + --migrations-dir Path to migration YAML files + --migration-key Unique key for this migration set + --migration-user User to record on each migration record + --lock-timeout-minutes Lock acquisition timeout in minutes + --property Template substitution property as key=value (repeatable) + -h, --help Show this message and exit +``` + +- [ ] **Step 3: Run migrations against the local ES instance** + +```bash +./gradlew :esque-core:run --args="\ + --es-url http://localhost:9200 \ + --migrations-dir $(pwd)/implementations/jvm/src/test/resources/es.migration \ + --migration-key cli-smoke-test \ + --property templatedIndexName=test-index-v4" +``` + +Expected: Gradle output ending with `BUILD SUCCESSFUL`. No error in the esque log lines. + +- [ ] **Step 4: Verify the migration history was written to ES** + +```bash +curl -s "http://localhost:9200/.esque/_search?pretty" | grep '"filename"' +``` + +Expected: Four filename entries, one per migration file. + +- [ ] **Step 5: Stop the test container** + +```bash +docker stop esque-test-es && docker rm esque-test-es +``` + +--- + +## Task 8: Run full build, lint, and commit + +- [ ] **Step 1: Run the full build including all tests** + +```bash +./gradlew build +``` + +Expected: `BUILD SUCCESSFUL`. + +- [ ] **Step 2: Commit** + +```bash +git add -A +git commit -m "Add Clikt CLI entrypoint to esque-core + +Exposes a standardized CLI (--es-url, --migrations-dir, --migration-key, +--migration-user, --lock-timeout-minutes, --property) for invocation by +the compatibility test harness. Clikt 4.4.0 added as a runtime dependency." +``` + +--- + +## Self-Review Notes + +- **Checksum breaking change**: explicitly documented in commit message and plan. Integration tests are unaffected because they use Testcontainers with fresh state. +- **YAML_MAPPER preserved**: `readRaw()` in `MigrationFileLoader.kt` still uses `YAML_MAPPER` to parse migration files. Only `YAML_MAPPER_SORTED` (used in `calculateChecksum`) is replaced. +- **artifactId preserved**: `project(":esque-core").projectDir = file("implementations/jvm")` keeps the project named `esque-core`, so `coordinates(artifactId = project.name, ...)` in the publishing config continues to resolve to `esque-core` with no changes required. +- **`file:` prefix**: The CLI prepends `file:` to `--migrations-dir` before passing to `EsqueConfiguration`. This matches what `MigrationFileLoader.resolvePath()` expects for non-classpath directories. +- **Clikt version**: 4.4.0 is the latest stable as of 2026-06-13. Check [https://github.com/ajalt/clikt/releases](https://github.com/ajalt/clikt/releases) if a newer version is available. diff --git a/.claude/superpowers/specs/2026-06-13-gherkin-spec-exploration.md b/.claude/superpowers/specs/2026-06-13-gherkin-spec-exploration.md new file mode 100644 index 0000000..52dbaa9 --- /dev/null +++ b/.claude/superpowers/specs/2026-06-13-gherkin-spec-exploration.md @@ -0,0 +1,331 @@ +# Esque Gherkin Specification Exploration + +This document extracts all behavioral specifications for Esque as Gherkin feature files. +The purpose is to evaluate: +1. What the full behavioral spec looks like in structured natural language +2. How many distinct step patterns exist and how much they overlap across scenarios +3. Whether Gherkin is worth the step-definition investment for the compatibility harness + +Two categories of features are identified: +- **Compatibility** — black-box, CLI-driven, runs in the shared Python harness against all implementations +- **Unit** — internal behavior, each language implements its own tests + +--- + +## Feature Files + +### 1. migration-index-initialization.feature +*Category: Compatibility* + +```gherkin +Feature: Migration Index Initialization + + Scenario: Creates the esque management index on first run + Given a clean Elasticsearch instance + And a migration directory with standard migrations + When esque executes with migration key "init-test" + Then the index ".esque" should exist in Elasticsearch + + Scenario: Does not fail when the esque management index already exists + Given a clean Elasticsearch instance + And a migration directory with standard migrations + And esque has already executed with migration key "reinit-test" + When esque executes again with migration key "reinit-test" + Then esque should exit successfully +``` + +--- + +### 2. migration-execution.feature +*Category: Compatibility* + +```gherkin +Feature: Migration Execution + + Scenario: Runs all migration files in version order + Given a clean Elasticsearch instance + And a migration directory with standard migrations + When esque executes with migration key "execution-test" + Then the index "test-index-v1" should exist in Elasticsearch + And the index "test-index-v2" should exist in Elasticsearch + And the index "test-index-v3" should exist in Elasticsearch + And the index "test-index-v4" should exist in Elasticsearch + + Scenario: Execution is idempotent + Given a clean Elasticsearch instance + And a migration directory with standard migrations + And esque has already executed with migration key "idempotent-test" + When esque executes again with migration key "idempotent-test" + Then there should be exactly 4 migration records with key "idempotent-test" + + Scenario: Second execution does not change existing migration record timestamps + Given a clean Elasticsearch instance + And a migration directory with standard migrations + And esque has already executed with migration key "idempotent-timestamps-test" + When esque executes again with migration key "idempotent-timestamps-test" + Then the migration records should have the same installed-on timestamps as the first execution + + Scenario: Different migration keys are independent + Given a clean Elasticsearch instance + And a migration directory with standard migrations + When esque executes with migration key "key-1" + Then there should be exactly 4 migration records with key "key-1" + And there should be exactly 0 migration records with key "key-2" +``` + +--- + +### 3. migration-history-recording.feature +*Category: Compatibility* + +```gherkin +Feature: Migration History Recording + + Scenario: Records correct metadata for each applied migration + Given a clean Elasticsearch instance + And a migration directory with standard migrations + When esque executes with migration key "history-test" + Then there should be exactly 4 migration records with key "history-test" + And the migration record at order 0 should have filename "V1.0.0__CreateTestIndex.yml" + And the migration record at order 0 should have version "1.0.0" + And the migration record at order 0 should have description "CreateTestIndex" + And the migration record at order 1 should have filename "V1.1.0__CreateSecondIndex.yml" + And the migration record at order 2 should have filename "V2.0.0__CreateThirdIndex.yml" + And the migration record at order 3 should have filename "V3.0.0__CreateTemplatedIndex.yml" + And each migration record should have a non-null checksum + And each migration record should have a non-null installed-on timestamp + And each migration record should have a non-negative execution time in milliseconds + + Scenario: Records the configured migration user + Given a clean Elasticsearch instance + And a migration directory with standard migrations + When esque executes with migration key "user-test" and migration user "test-user" + Then each migration record with key "user-test" should have installed-by "test-user" + + Scenario: Records null user when no migration user is configured + Given a clean Elasticsearch instance + And a migration directory with standard migrations + When esque executes with migration key "no-user-test" with no migration user + Then each migration record with key "no-user-test" should have no installed-by value +``` + +--- + +### 4. migration-integrity-verification.feature +*Category: Compatibility* + +```gherkin +Feature: Migration Integrity Verification + + Scenario: Fails when migration records outnumber local migration files + Given a clean Elasticsearch instance + And standard migrations have been applied with key "too-many-records-test" + And the migration directory now contains fewer files than were applied + When esque executes with migration key "too-many-records-test" + Then esque should exit with a non-zero status code + + Scenario: Fails when a previously applied migration file cannot be found by filename + Given a clean Elasticsearch instance + And standard migrations have been applied with key "missing-file-test" + And the migration file "V1.0.0__CreateTestIndex.yml" has been removed from the directory + When esque executes with migration key "missing-file-test" + Then esque should exit with a non-zero status code + + Scenario: Fails when a previously applied migration file has a different checksum + Given a clean Elasticsearch instance + And standard migrations have been applied with key "checksum-test" + And the contents of migration file "V1.0.0__CreateTestIndex.yml" have been modified + When esque executes with migration key "checksum-test" + Then esque should exit with a non-zero status code + + Scenario: Fails when migration record order is inconsistent with file order + Given a clean Elasticsearch instance + And standard migrations have been applied with key "order-test" + And a new migration file has been inserted before an already-applied migration + When esque executes with migration key "order-test" + Then esque should exit with a non-zero status code +``` + +--- + +### 5. template-variable-substitution.feature +*Category: Compatibility* + +```gherkin +Feature: Template Variable Substitution + + Scenario: Substitutes template variables in migration request paths + Given a clean Elasticsearch instance + And a migration directory with a migration using placeholder "#{indexName}" in the path + And template properties: + | key | value | + | indexName | test-index-v4 | + When esque executes with migration key "template-path-test" + Then the index "test-index-v4" should exist in Elasticsearch + + Scenario: Substitutes template variables in migration request bodies + Given a clean Elasticsearch instance + And a migration directory with a migration using placeholder "#{replicaCount}" in the body + And template properties: + | key | value | + | replicaCount | 1 | + When esque executes with migration key "template-body-test" + Then esque should exit successfully + + Scenario: Ignores extra template properties when no placeholders exist in migration files + Given a clean Elasticsearch instance + And a migration directory with standard migrations that contain no placeholders + And template properties: + | key | value | + | unused | ignored | + | indexName | test-index-v4 | + When esque executes with migration key "extra-props-test" + Then esque should exit successfully + + Scenario: Fails before running any migration when a required template variable is missing + Given a clean Elasticsearch instance + And a migration directory with a migration using placeholder "#{indexName}" in the path + And no template properties are provided + When esque executes with migration key "missing-var-test" + Then esque should exit with a non-zero status code + And there should be exactly 0 migration records with key "missing-var-test" +``` + +--- + +### 6. migration-file-loading.feature +*Category: Compatibility (file discovery) + Unit (parsing internals)* + +```gherkin +Feature: Migration File Loading and Ordering + + # Compatibility: observable ordering behavior + Scenario: Migration files are executed in ascending version order + Given a clean Elasticsearch instance + And a migration directory containing files in this order on disk: + | filename | + | V2.0.0__CreateThirdIndex.yml | + | V1.0.0__CreateFirstIndex.yml | + | V1.1.0__CreateSecondIndex.yml | + When esque executes with migration key "ordering-test" + Then the migration record at order 0 should have version "1.0.0" + And the migration record at order 1 should have version "1.1.0" + And the migration record at order 2 should have version "2.0.0" + + # Compatibility: version segment comparison + Scenario: Version segments are compared numerically not lexicographically + Given a clean Elasticsearch instance + And a migration directory with files: + | filename | + | V1.9.0__First.yml | + | V1.10.0__Second.yml | + When esque executes with migration key "numeric-version-test" + Then the migration record at order 0 should have version "1.9.0" + And the migration record at order 1 should have version "1.10.0" + + # Unit: internal parsing — each language implements these themselves + Scenario: Parses version from filename + Given a migration file named "V1.2.3__CreateMyIndex.yml" + When the file metadata is parsed + Then the version should be "1.2.3" + And the description should be "CreateMyIndex" + And the filename should be "V1.2.3__CreateMyIndex.yml" +``` + +--- + +### 7. checksum-stability.feature +*Category: Compatibility* + +```gherkin +Feature: Checksum Stability + + Scenario: Checksum is stable across multiple runs against the same file + Given a clean Elasticsearch instance + And a migration directory with standard migrations + And esque has already executed with migration key "checksum-stability-test" + When esque executes again with migration key "checksum-stability-test" + Then the checksums in migration records should be unchanged from the first execution + + Scenario: Checksum is the same whether values are hardcoded or substituted from template + Given a clean Elasticsearch instance + And a migration directory with two equivalent migrations: + | filename | style | + | V1.0.0__Hardcoded.yml | hardcoded | + | V2.0.0__Templated.yml | templated | + And template properties: + | key | value | + | index | test-index-v1 | + When esque executes with migration key "checksum-equivalence-test" + Then the migration record at order 0 should have the same checksum as order 1 +``` + +--- + +## Step Pattern Analysis + +### Distinct Given Steps (~10 patterns) + +| Step Pattern | Used In | +|---|---| +| `a clean Elasticsearch instance` | All scenarios | +| `a migration directory with standard migrations` | Most scenarios | +| `a migration directory with [custom files]` | ordering, integrity | +| `a migration directory with a migration using placeholder {ph} in the {field}` | template scenarios | +| `no template properties are provided` | template missing-var | +| `template properties: [table]` | template scenarios | +| `esque has already executed with migration key {key}` | idempotency, integrity | +| `standard migrations have been applied with key {key}` | integrity scenarios | +| `the migration file {filename} has been removed from the directory` | integrity | +| `the contents of migration file {filename} have been modified` | integrity | +| `a new migration file has been inserted before an already-applied migration` | integrity | + +### Distinct When Steps (~4 patterns) + +| Step Pattern | Used In | +|---|---| +| `esque executes with migration key {key}` | Most scenarios | +| `esque executes with migration key {key} and migration user {user}` | user recording | +| `esque executes with migration key {key} with no migration user` | null user | +| `esque executes again with migration key {key}` | idempotency | + +### Distinct Then Steps (~12 patterns) + +| Step Pattern | Used In | +|---|---| +| `esque should exit successfully` | error-free scenarios | +| `esque should exit with a non-zero status code` | failure scenarios | +| `the index {index} should exist in Elasticsearch` | execution, template | +| `there should be exactly {n} migration records with key {key}` | history, idempotency | +| `the migration record at order {n} should have filename {filename}` | history | +| `the migration record at order {n} should have version {version}` | history, ordering | +| `the migration record at order {n} should have description {description}` | history | +| `each migration record should have a non-null checksum` | history | +| `each migration record should have a non-null installed-on timestamp` | history | +| `each migration record should have a non-negative execution time in milliseconds` | history | +| `each migration record with key {key} should have installed-by {user}` | user recording | +| `each migration record with key {key} should have no installed-by value` | null user | +| `the migration records should have the same installed-on timestamps as the first execution` | idempotency | +| `the checksums in migration records should be unchanged from the first execution` | checksum stability | + +--- + +## Summary + +**~26 distinct step patterns** across all compatibility scenarios. + +**Implementation effort per step:** +- `Given a clean Elasticsearch instance` → testcontainers setup, likely in `conftest.py`, not a step +- `Given a migration directory with standard migrations` → resolve path to shared fixture dir +- `When esque executes with migration key {key}` → `subprocess.run([cli, "--migration-key", key, ...])` +- `Then the index {index} should exist` → `httpx.get(f"{es_url}/{index}")` → assert 200 +- `Then there should be exactly {n} migration records` → query `.esque` index, count hits +- `Then the migration record at order {n} should have {field} {value}` → query `.esque`, index into hits + +Most steps are 3-5 lines of Python. The heavy lifting (ES setup, CLI invocation) is shared infrastructure in `conftest.py`. The step definitions themselves are thin wrappers. + +**Rough estimate:** ~200-250 lines of Python step definitions covers all compatibility scenarios. + +**Key finding:** There is significant step reuse. The `When esque executes` family and `Then the index should exist` / `Then there should be N records` steps appear in almost every scenario. Writing them once covers the majority of the test surface. + +**Verdict:** The overhead is manageable. 26 patterns, most are trivial, and once written they work for every language added to the harness. The AI-spec value (unambiguous, executable requirements) is real at this scale. diff --git a/.claude/superpowers/specs/2026-06-13-multi-platform-design.md b/.claude/superpowers/specs/2026-06-13-multi-platform-design.md new file mode 100644 index 0000000..c6c40a0 --- /dev/null +++ b/.claude/superpowers/specs/2026-06-13-multi-platform-design.md @@ -0,0 +1,449 @@ +# Multi-Platform Esque Design + +**Date:** 2026-06-13 +**Status:** Approved +**Scope:** Add Python as a second platform implementation, establish repository structure and compatibility test harness to support future additional platforms. + +--- + +## Goal + +Extend Esque to support multiple language platforms (starting with Python) within a single repository. Each implementation must behave identically given the same inputs. A shared compatibility test harness verifies behavioral equivalence across all implementations. Python (using uv) is the first additional implementation and proves out the pattern before committing to further languages. + +--- + +## Repository Structure + +The repository is reorganized around a top-level `implementations/` directory. The current JVM-centric layout is flattened — `esque-examples/` is dropped, `esque-core/` relocates to `implementations/jvm/`. + +``` +esque/ +├── implementations/ +│ ├── jvm/ # Kotlin library (relocated from esque-core/) +│ │ ├── build.gradle.kts +│ │ └── src/ +│ └── python/ # New Python library +│ ├── pyproject.toml # uv-managed +│ └── src/esque/ +├── tests/ +│ ├── fixtures/ +│ │ ├── standard/ # V1.0.0, V1.1.0, V2.0.0, V3.0.0 migration YAMLs +│ │ ├── templated/ # Migrations containing #{} placeholders +│ │ └── single/ # One migration, for isolated tests +│ ├── conftest.py # Elasticsearch testcontainer, all_implementations() +│ ├── helpers.py # run(), get_records(), assert_index_exists(), etc. +│ ├── test_compatibility.py # pytest parametrized over scenarios × implementations +│ └── pyproject.toml # uv-managed, completely separate from implementations/python/ +├── build.gradle.kts # Root Gradle build (JVM modules only) +├── settings.gradle.kts +└── ... +``` + +`tests/` is an entirely separate uv project from `implementations/python/`. They share no code, no `pyproject.toml`, no dependencies. + +--- + +## CLI Contract + +Every implementation exposes a standardized CLI. The test harness invokes each implementation as a subprocess using this interface. The harness does not know or care what language is running. + +### Arguments + +``` +esque \ + --es-url # required + --migrations-dir # required; file: scheme only (harness always provides an absolute path) + --migration-key # required + [--migration-user ] # optional + [--lock-timeout-minutes ] # optional, default 5 + [--property ] ... # repeatable; for #{} template substitution +``` + +### Exit Codes + +- `0` — execution completed successfully +- Non-zero — execution failed for any reason; error detail written to stderr + +### Invocation per Implementation + +**JVM:** +``` +./gradlew -p implementations/jvm run --args="--es-url --migration-key ..." +``` +CLI is implemented using [Clikt](https://ajalt.github.io/clikt/) directly in `implementations/jvm/`. Clikt becomes a transitive dependency of the published library artifact; this is acceptable and revisable later. The Gradle `application` plugin provides the `run` task. + +**Python:** +``` +uv run --project implementations/python esque --es-url --migration-key ... +``` +CLI is implemented using [Click](https://click.palletsprojects.com/) as a standard `entry_points` script in `pyproject.toml`. The CLI is a product feature of the Python library, not test infrastructure. + +### Implementation Registry + +The harness discovers implementations from `tests/implementations.yml`: + +```yaml +implementations: + jvm: + command: ["./gradlew", "-p", "implementations/jvm", "run", "--args"] + python: + command: ["uv", "run", "--project", "implementations/python", "esque"] +``` + +Adding a new language = one entry here and the full test suite runs against it automatically. + +--- + +## Compatibility Test Harness + +### Philosophy + +Tests are written as real Python code using pytest. Scenarios are expressed as test functions, not declarative YAML/JSON files. Helper functions handle the common operations — invoking the CLI, querying ES, asserting state — so test functions stay focused on behavior. + +The harness shares nothing with either implementation. It treats each as a black box: invoke the CLI, observe ES state, assert. + +### Structure + +```python +# tests/test_compatibility.py + +@pytest.mark.parametrize("impl", all_implementations()) +def test_basic_execution(impl, es): + run(impl, es, key="basic-test", migrations=STANDARD_MIGRATIONS) + assert_index_exists(es, "test-index-v1") + assert_index_exists(es, "test-index-v2") + assert_index_exists(es, "test-index-v3") + +@pytest.mark.parametrize("impl", all_implementations()) +def test_idempotent_execution(impl, es): + run(impl, es, key="idempotent-test", migrations=STANDARD_MIGRATIONS) + first = get_records(es, "idempotent-test") + + run(impl, es, key="idempotent-test", migrations=STANDARD_MIGRATIONS) + second = get_records(es, "idempotent-test") + + assert len(second) == len(first) + for a, b in zip(first, second): + assert a["checksum"] == b["checksum"] + assert a["installedOn"] == b["installedOn"] + +@pytest.mark.parametrize("impl", all_implementations()) +def test_missing_template_variable_fails_before_any_migration(impl, es): + result = run(impl, es, key="missing-var-test", migrations=TEMPLATED_MIGRATIONS) + assert result.returncode != 0 + assert len(get_records(es, "missing-var-test")) == 0 +``` + +### Helper API (`tests/helpers.py`) + +```python +def run( + impl: Implementation, + es_url: str, + key: str, + migrations: Path, + user: str | None = None, + properties: dict[str, str] | None = None, +) -> subprocess.CompletedProcess: ... + +def get_records(es_url: str, key: str) -> list[dict]: ... + +def assert_index_exists(es_url: str, index: str) -> None: ... + +def assert_index_absent(es_url: str, index: str) -> None: ... + +def all_implementations() -> list[Implementation]: ... +``` + +### Fixtures (`tests/conftest.py`) + +- `es` fixture: Elasticsearch testcontainer, scoped per test function, provides the URL +- `all_implementations()`: loads `tests/implementations.yml`, returns list of `Implementation` dataclasses + +### Test Dependencies + +```toml +# tests/pyproject.toml +[project] +dependencies = [ + "pytest", + "testcontainers[elasticsearch]", + "httpx", # for direct ES queries in helpers + "pyyaml", # for loading implementations.yml +] +``` + +--- + +## JVM Implementation Changes + +### Relocation + +`esque-core/` moves to `implementations/jvm/`. All source files, tests, and resources are preserved unchanged. The published Maven artifact coordinates remain the same: `org.loesak:esque-core`. + +`esque-examples/` is removed entirely. + +`settings.gradle.kts` and root `build.gradle.kts` are updated to reflect the new module path. + +### CLI Addition + +A CLI entrypoint is added to `implementations/jvm/src/main/kotlin/org/loesak/esque/core/cli/Main.kt` using Clikt. It parses the standardized arguments and delegates to `Esque(client, configuration, properties).execute()`. + +The Gradle `application` plugin is added to `implementations/jvm/build.gradle.kts`: + +```kotlin +plugins { + application +} + +application { + mainClass.set("org.loesak.esque.core.cli.MainKt") +} +``` + +### Checksum Algorithm Change + +The current YAML-based checksum is replaced with the cross-language canonical algorithm (see Checksum Specification below). This is a **breaking change** — existing migration records in ES will fail integrity verification after upgrading. Acceptable at pre-1.0 given the active restructuring. + +--- + +## Python Implementation + +### Package Structure + +``` +implementations/python/ + pyproject.toml + src/esque/ + __init__.py + esque.py # main orchestrator — mirrors Esque.kt + configuration.py # EsqueConfiguration dataclass + cli.py # Click CLI entrypoint + elasticsearch/ + operations.py # ES REST calls — mirrors RestClientOperations.kt + documents.py # MigrationRecord, MigrationLock dataclasses + lock.py # distributed lock — mirrors ElasticsearchDocumentLock.kt + migration/ + loader.py # file discovery and parsing — mirrors MigrationFileLoader.kt + template.py # #{} substitution — mirrors MigrationTemplateResolver.kt + model.py # MigrationFile dataclass +``` + +### Dependencies + +```toml +[project] +dependencies = [ + "elasticsearch", # official ES Python client, low-level transport for all ES operations + "pyyaml", # YAML parsing for migration files + "click", # CLI +] +``` + +No high-level ES client abstractions are used. Raw `perform_request()` calls mirror the JVM low-level REST client approach. + +### Execution Flow + +Identical to JVM: +1. Initialize — create `.esque` index if it doesn't exist +2. Load — discover and parse YAML migration files from the configured directory +3. Template validation — fail fast if any `#{}` placeholder is unresolvable +4. Load history — fetch existing migration records from `.esque` index for the given key +5. Verify integrity — checksums, ordering, filenames match history +6. Execute migrations — for each unapplied file: acquire lock, execute requests, record history, release lock + +### Migration File Discovery + +Python uses `file:` scheme only (no `classpath:` — that's a JVM concept). The harness always passes an absolute path. For application use, users pass the directory path directly. + +File naming convention is identical: `^V((\d+\.?)+)__(\w+)\.yml$` + +Version ordering is numeric per segment (e.g., `1.9.0` < `1.10.0`). + +--- + +## Checksum Specification + +### Algorithm + +The checksum is computed on the **post-template-resolution** migration file contents. + +1. For each request definition, construct a JSON object with **keys sorted alphabetically** and **null fields omitted**: + ```json + {"body":"...","contentType":"application/json","method":"PUT","params":{"key":"val"},"path":"/index"} + ``` +2. Construct a JSON array of all request objects in their defined order, **compact** (no whitespace): + ```json + [{"method":"PUT","path":"/index-v1"},{"method":"POST","path":"/_aliases","body":"..."}] + ``` +3. Encode the JSON string as **UTF-8 bytes** +4. Compute the **MD5** hash of those bytes +5. Take the **first 4 bytes** of the digest as a **signed 32-bit big-endian integer** + +### Cross-Language Compatibility + +Implementations make a **best-effort** to produce identical checksums given identical migration file contents. The JSON-based algorithm is well-specified and should produce identical output across languages for the vast majority of real-world migration files. + +**Switching between implementations is not supported.** If a migration history was created with the JVM implementation, switching to Python for the same `migrationKey` will likely result in integrity verification failures due to checksum differences. A future checksum regeneration tool may address this, but it is not a current design goal. + +Each implementation is self-consistent: checksums are stable across runs of the same implementation against the same files. + +--- + +## Cross-Implementation Compatibility + +**Different language implementations are not interchangeable for the same `migrationKey`.** This is a documented limitation, not a bug. + +The typical multi-language scenario — a JVM service and a Python service each running migrations against the same ES cluster — is handled by `migrationKey`. Each service uses a distinct key and maintains its own independent migration history. Keys never share records across implementations. + +The scenario where interchangeability matters (rewriting an application from one language to another and continuing the same migration history) requires either: +- Starting fresh with the new implementation +- A future checksum regeneration tool (not in scope for this design) + +--- + +## Versioning + +All implementations share a single version number. The same git tag drives the version for JVM and Python simultaneously. Version `1.2.0` of `esque-core` (Maven Central) and `esque` (PyPI) represent the same behavioral spec and pass the same compatibility test suite. + +Version is derived from git tags via the existing `version.sh`: +- Exact tag match (e.g., `1.2.0`) → release version +- Otherwise → `-SNAPSHOT` / `.dev0` (language-appropriate pre-release suffix) + +For Python, the version is set dynamically at publish time (`uv version $(./version.sh)`) rather than stored statically in `pyproject.toml`. The `pyproject.toml` carries a placeholder version (`0.0.0`) that is only ever overwritten in CI. + +--- + +## CI Pipeline + +The existing `gradle-deploy.yml` is replaced with a multi-job workflow. All jobs run on every push to `master` and on every pull request targeting `master`. + +### Workflow: `ci.yml` + +``` +┌─────────────┐ ┌──────────────┐ +│ build-jvm │ │ build-python │ +│ │ │ │ +│ ktfmtCheck │ │ ruff format │ +│ detekt │ │ ruff check │ +│ gradle test │ │ pyright │ +└──────┬──────┘ └──────┬───────┘ + │ │ + └────────┬────────┘ + ▼ + ┌─────────────────────┐ + │ compatibility-tests │ + │ │ + │ pytest (parametrized│ + │ over jvm + python) │ + └─────────────────────┘ +``` + +**`build-jvm` job:** +```yaml +- uses: actions/setup-java@v4 + with: { distribution: zulu, java-version: 21 } +- run: ./gradlew -PprojectVersion=$(./version.sh) ktfmtCheck detekt test + working-directory: implementations/jvm +``` + +**`build-python` job:** +```yaml +- uses: astral-sh/setup-uv@v5 +- run: uv run ruff format --check . + working-directory: implementations/python +- run: uv run ruff check . + working-directory: implementations/python +- run: uv run pyright + working-directory: implementations/python +- run: uv run pytest + working-directory: implementations/python +``` + +**`compatibility-tests` job** (depends on both build jobs): +```yaml +- uses: actions/setup-java@v4 + with: { distribution: zulu, java-version: 21 } +- uses: astral-sh/setup-uv@v5 +- run: uv run pytest + working-directory: tests +``` + +Testcontainers requires Docker. GitHub-hosted `ubuntu-latest` runners have Docker available by default — no additional setup needed. + +### Python Tooling + +| Concern | Tool | +|--------------------|------------------------| +| Formatting | `ruff format` | +| Linting | `ruff check` | +| Type checking | `pyright` (strict mode)| +| Testing | `pytest` | +| Package management | `uv` | + +These are dev dependencies in `implementations/python/pyproject.toml` under `[dependency-groups]`. + +--- + +## Release / Publishing + +Releases are triggered by publishing a GitHub Release (same trigger as today). A single `release.yml` workflow publishes all implementations to their respective registries. + +### Workflow: `release.yml` + +Trigger: `release: published` + +**`publish-jvm` job:** +```yaml +- uses: actions/setup-java@v4 + with: { distribution: zulu, java-version: 21 } +- run: ./gradlew -PprojectVersion=$(./version.sh) publish -x test + working-directory: implementations/jvm + env: + ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.OSSRH_USERNAME }} + ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.OSSRH_PASSWORD }} + ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.OSSRH_GPG_SECRET_KEY }} + ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.OSSRH_GPG_SECRET_KEY_PASSWORD }} +``` + +**`publish-python` job:** +```yaml +- uses: astral-sh/setup-uv@v5 +- run: uv version $(./version.sh) + working-directory: implementations/python +- run: uv build + working-directory: implementations/python +- run: uv publish + working-directory: implementations/python + env: + UV_PUBLISH_TOKEN: ${{ secrets.PYPI_TOKEN }} +``` + +### Required Secrets + +| Secret | Used By | Purpose | +|-------------------------------------|----------------|----------------------------| +| `OSSRH_USERNAME` | JVM publish | Maven Central username | +| `OSSRH_PASSWORD` | JVM publish | Maven Central password | +| `OSSRH_GPG_SECRET_KEY` | JVM publish | In-memory PGP signing key | +| `OSSRH_GPG_SECRET_KEY_PASSWORD` | JVM publish | PGP key password | +| `PYPI_TOKEN` | Python publish | PyPI API token | + +`PYPI_TOKEN` must be added to the GitHub repository secrets before the first Python release. + +### Publishing Targets + +| Implementation | Registry | Artifact | +|----------------|----------------|-------------------------| +| JVM | Maven Central | `org.loesak:esque-core` | +| Python | PyPI | `esque` | + +--- + +## Future Considerations + +- **Effective YAML**: Store the post-template-resolution YAML in each migration record. On checksum failure, present a human-readable diff of the stored effective YAML vs the current effective YAML so users can see exactly what changed. +- **Checksum regeneration**: A tool to recalculate and update stored checksums when switching implementations or after algorithm changes. +- **Additional platforms**: TypeScript, Go, etc. Adding a platform requires: implementing the CLI contract, adding one entry to `tests/implementations.yml`. The full test suite runs automatically. +- **CLI fat JAR / standalone distribution**: Distribute the JVM CLI as a self-contained executable (shaded JAR) for users who want CLI access without a JVM project setup. Mirrors Flyway/Liquibase distribution model. +- **esque-cli module separation**: If Clikt as a transitive dependency becomes a concern for library consumers, split into `esque-core` (no CLI deps) and a separate `esque-cli` module. diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index a665a3a..176e468 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -21,7 +21,10 @@ RUN curl -s "https://get.sdkman.io" | bash \ && sdk install java ${JAVA_VERSION} \ && sdk install kotlin ${KOTLIN_VERSION} -# install gh cli +# install python things +RUN curl -LsSf https://astral.sh/uv/install.sh | sh + +# install github cli RUN DEBIAN_FRONTEND=noninteractive \ && (type -p wget >/dev/null || (apt update && apt install wget -y)) \ && mkdir -p -m 755 /etc/apt/keyrings out=$(mktemp) \ diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 6720dc7..76d5908 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -10,6 +10,13 @@ fi echo "Running pre-commit checks..." -./gradlew ktfmtCheck detekt test +echo "[1/3] JVM: format, lint, test" +(cd implementations/jvm && ./gradlew ktfmtCheck detekt test) + +echo "[2/3] Python: format, lint, typecheck" +(cd implementations/python && uv run ruff check src/esque/ && uv run ruff format --check src/esque/ && uv run pyright src/esque/) + +echo "[3/3] Compatibility tests" +(cd tests && uv run pytest . -q) echo "Pre-commit checks passed." diff --git a/version.sh b/.github/version_jvm.sh similarity index 100% rename from version.sh rename to .github/version_jvm.sh diff --git a/.github/version_python.sh b/.github/version_python.sh new file mode 100755 index 0000000..f81b626 --- /dev/null +++ b/.github/version_python.sh @@ -0,0 +1,13 @@ +#!/bin/bash +# Produces a PEP 440 version from git tags — mirrors version.sh but for Python. +# Exact tag X.Y.Z → X.Y.Z (release); otherwise → X.Y.Z.devN (dev/snapshot). + +GIT_DESCRIBE=$(git describe --tags 2>/dev/null) + +if [[ $GIT_DESCRIBE =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "$GIT_DESCRIBE" +elif [[ $GIT_DESCRIBE =~ ^([0-9]+\.[0-9]+\.[0-9]+)-([0-9]+)-g[0-9a-f]+ ]]; then + echo "${BASH_REMATCH[1]}.dev${BASH_REMATCH[2]}" +else + echo "0.0.0.dev$(git rev-list --count HEAD 2>/dev/null || echo 0)" +fi \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b29298e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,82 @@ +name: CI + +on: + push: + branches: [ master ] + pull_request: + branches: [master] + release: + types: [published] + +jobs: + jvm: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-java@v4 + with: + distribution: zulu + java-version: 21 + - working-directory: implementations/jvm + run: ./gradlew -PprojectVersion=$(../../.github/version_jvm.sh) ktfmtCheck detekt build + - working-directory: implementations/jvm + run: ./gradlew -PprojectVersion=$(../../.github/version_jvm.sh) publish -x test + env: + ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.OSSRH_USERNAME }} + ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.OSSRH_PASSWORD }} + ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.OSSRH_GPG_SECRET_KEY }} + ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.OSSRH_GPG_SECRET_KEY_PASSWORD }} + + python: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: astral-sh/setup-uv@v6 + with: + python-version: "3.14" + - working-directory: implementations/python + run: uv sync + - working-directory: implementations/python + run: uv run ruff check src/ + - working-directory: implementations/python + run: uv run ruff format --check src/ + - working-directory: implementations/python + run: uv run pyright + - working-directory: implementations/python + run: uv run pytest + - working-directory: implementations/python + run: sed -i "s/^version = .*/version = \"$(../../.github/version_python.sh)\"/" pyproject.toml + - working-directory: implementations/python + run: uv build + - if: github.event_name != 'release' + working-directory: implementations/python + run: uv publish --publish-url https://test.pypi.org/legacy/ + env: + UV_PUBLISH_TOKEN: ${{ secrets.TEST_PYPI_TOKEN }} + - if: github.event_name == 'release' + working-directory: implementations/python + run: uv publish + env: + UV_PUBLISH_TOKEN: ${{ secrets.PYPI_TOKEN }} + + compatibility-tests: + runs-on: ubuntu-latest + needs: [jvm, python] + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-java@v4 + with: + distribution: zulu + java-version: 21 + - uses: astral-sh/setup-uv@v6 + with: + python-version: "3.14" + - run: uv sync --project implementations/python + - working-directory: tests + run: uv run pytest . -v diff --git a/.github/workflows/gradle-deploy.yml b/.github/workflows/gradle-deploy.yml deleted file mode 100644 index 8531fd8..0000000 --- a/.github/workflows/gradle-deploy.yml +++ /dev/null @@ -1,26 +0,0 @@ -name: Gradle Deploy -on: - push: - branches: [ master ] - pull_request: - branches: [ master ] - release: - types: [ published ] -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - uses: actions/setup-java@v4 - with: - distribution: 'zulu' - java-version: 21 - - run: ./gradlew -PprojectVersion=$(./version.sh) ktfmtCheck detekt build - - run: ./gradlew -PprojectVersion=$(./version.sh) publish -x test - env: - ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.OSSRH_USERNAME }} - ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.OSSRH_PASSWORD }} - ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.OSSRH_GPG_SECRET_KEY }} - ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.OSSRH_GPG_SECRET_KEY_PASSWORD }} diff --git a/.gitignore b/.gitignore index 3ccb035..937093a 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,9 @@ target /.idea/ .gradle/ build/ + +# Python +.venv/ +__pycache__/ +*.pyc +*.pyo \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index b907a04..6b1c6cf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,50 +9,82 @@ Specs live in `.claude/superpowers/specs/` named `YYYY-MM-DD--design.md`. **Esque** (**E**lasticsearch **S**tateful **Qu**ery **E**xecutor) is a migration management library for Elasticsearch, similar to Flyway but for ES clusters. It executes pre-defined queries in order, tracks which have been applied, validates integrity, and supports distributed locking for safe concurrent execution. - **License:** Apache 2.0 -- **Language:** Kotlin 2.4.0 (JVM 21) -- **Build:** Gradle 9.5.1 -- **Target:** Elasticsearch 9+ (client version 9.4.x low-level REST client) -- **Published to:** Maven Central via Central Portal +- **Implementations:** JVM (Kotlin 2.4.0, Java 21) · Python 3.14 +- **Target:** Elasticsearch 9+ (ES 9.4.x REST API) +- **JVM published to:** Maven Central as `org.loesak.esque:esque` +- **Python published to:** PyPI as `esque-py` ## Repository Structure ``` esque/ -├── build.gradle.kts # Root Gradle build -├── settings.gradle.kts # Gradle settings (module declarations) -├── gradle/ -│ ├── libs.versions.toml # Gradle version catalog -│ └── wrapper/ # Gradle wrapper (9.5.1) -├── gradlew / gradlew.bat # Gradle wrapper scripts -├── version.sh # Git-tag-based version calculation ├── setup-hooks.sh # One-time dev setup: activates git pre-commit hook ├── .githooks/ -│ └── pre-commit # Runs ktfmtCheck + detekt + test before each commit -├── config/detekt/ -│ ├── detekt.yml # Detekt rule configuration -│ └── baseline.xml # Baseline of pre-existing violations -├── .github/workflows/ -│ └── gradle-deploy.yml # CI/CD: Gradle build + deploy to Maven Central +│ └── pre-commit # [1] JVM checks [2] Python checks [3] compat tests +├── .github/ +│ ├── version_jvm.sh # Git-tag-based version for JVM (X.Y.Z or X.Y.Z-...-SNAPSHOT) +│ ├── version_python.sh # PEP 440 version for Python (X.Y.Z or X.Y.Z.devN) +│ └── workflows/ +│ └── ci.yml # lint + build + publish + compatibility-tests ├── .devcontainer/ # Dev container (Ubuntu, Zulu JDK 21) -├── esque-core/ # Core library (the published artifact) -│ ├── build.gradle.kts -│ └── src/main/kotlin/org/loesak/esque/core/ -│ ├── Esque.kt # Main entry point / orchestrator -│ ├── concurrent/ -│ │ └── ElasticsearchDocumentLock.kt # Distributed lock via ES docs -│ ├── elasticsearch/ -│ │ ├── RestClientOperations.kt # ES REST client abstraction -│ │ └── documents/ -│ │ ├── MigrationRecord.kt # Applied migration record model -│ │ └── MigrationLock.kt # Lock document model -│ └── yaml/ -│ ├── MigrationFileLoader.kt # YAML file discovery and parsing -│ └── model/ -│ └── MigrationFile.kt # Migration file domain model -└── esque-examples/ # Example applications (not published) - ├── esque-example-core-simple/ # Basic usage, no auth - ├── esque-example-core-es-auth/ # Elasticsearch basic auth - └── esque-example-core-aws-auth/ # AWS auth (placeholder, not implemented) +├── implementations/ +│ ├── jvm/ # JVM/Kotlin implementation +│ │ ├── build.gradle.kts # Single-project Gradle build (merged root + core) +│ │ ├── settings.gradle.kts # rootProject.name = "esque" +│ │ ├── gradle/ +│ │ │ ├── libs.versions.toml # Gradle version catalog +│ │ │ └── wrapper/ # Gradle wrapper (9.5.1) +│ │ ├── gradlew / gradlew.bat # Gradle wrapper scripts +│ │ ├── gradle.properties # Gradle daemon/cache/parallel settings +│ │ ├── detekt.yml # Detekt rule configuration +│ │ └── src/main/kotlin/org/loesak/esque/core/ +│ │ ├── Esque.kt # Main orchestrator +│ │ ├── EsqueConfiguration.kt +│ │ ├── cli/Main.kt # Clikt CLI entrypoint +│ │ ├── concurrent/ +│ │ │ └── ElasticsearchDocumentLock.kt +│ │ ├── elasticsearch/ +│ │ │ ├── RestClientOperations.kt +│ │ │ └── documents/ +│ │ │ ├── MigrationRecord.kt +│ │ │ └── MigrationLock.kt +│ │ └── yaml/ +│ │ ├── MigrationFileLoader.kt +│ │ ├── MigrationTemplateResolver.kt +│ │ └── model/MigrationFile.kt +│ └── python/ # Python implementation +│ ├── pyproject.toml # uv project: click, httpx, pyyaml; hatchling build +│ ├── src/esque/ +│ │ ├── configuration.py # EsqueConfiguration dataclass +│ │ ├── esque.py # Main orchestrator + verify_integrity +│ │ ├── cli.py # Click CLI entrypoint +│ │ ├── __main__.py # python -m esque shim +│ │ ├── elasticsearch/ +│ │ │ ├── documents.py # INDEX_DEFINITION, constants +│ │ │ ├── operations.py # ES REST calls +│ │ │ └── lock.py # Distributed lock (op_type=create polling) +│ │ └── migration/ +│ │ ├── model.py # MigrationRequest, MigrationFile +│ │ ├── template.py # #{varName} validation and substitution +│ │ └── loader.py # File discovery, parsing, checksum +│ └── tests/ +│ ├── test_model.py # Version ordering +│ ├── test_checksum.py # Canonical checksum algorithm +│ ├── test_template.py # Template validation and substitution +│ └── test_integrity.py # verify_integrity error scenarios +└── tests/ # Black-box compatibility test harness + ├── pyproject.toml # uv project: pytest, testcontainers, httpx, pyyaml + ├── implementations.yml # Registered implementations with invocation config + ├── conftest.py # Session-scoped ES testcontainer + per-test cleanup + ├── helpers.py # run(), get_records(), assert_index_exists() + ├── test_compatibility.py # 17 scenarios parametrized over all implementations + └── fixtures/ # Migration YAML files per test scenario + ├── standard/ # 3 migrations (V1.0.0, V1.1.0, V2.0.0) + ├── templated/ # standard + V3.0.0 with #{indexName} + ├── single/ # V1.0.0 only + ├── ordering/ # V1.9.0 and V1.10.0 (numeric ordering edge case) + ├── integrity-modified/ # V1.0.0 has different content → checksum mismatch + └── integrity-missing/ # Only V1.0.0 and V1.1.0 (V2.0.0 absent) ``` ## Build and Development @@ -60,7 +92,8 @@ esque/ ### Prerequisites - Java 21 (Zulu distribution recommended) -- Gradle (wrapper included — no install needed) +- uv (Python package manager — `curl -LsSf https://astral.sh/uv/install.sh | sh`) +- Docker (for integration tests and compatibility tests via testcontainers) ### First-time setup @@ -70,11 +103,13 @@ After cloning, activate the pre-commit hook: ./setup-hooks.sh ``` -This sets `core.hooksPath = .githooks` in your local git config. Git won't do this automatically (by design — executing scripts on clone is a security risk). +This sets `core.hooksPath = .githooks` in your local git config. -### Common Commands +### JVM Commands (run from `implementations/jvm/`) ```bash +cd implementations/jvm + # compile ./gradlew compileKotlin @@ -89,159 +124,230 @@ This sets `core.hooksPath = .githooks` in your local git config. Git won't do th # check formatting and lint (without building) ./gradlew ktfmtCheck detekt + +# run the CLI +./gradlew run --args="--help" +``` + +### Python Commands (run from `implementations/python/`) + +```bash +cd implementations/python + +# install dependencies +uv sync + +# format code +uv run ruff format esque/ + +# check formatting and lint +uv run ruff check esque/ && uv run ruff format --check esque/ + +# typecheck +uv run pyright esque/ + +# run the CLI +uv run esque --help +``` + +### Compatibility Tests (run from `tests/`) + +```bash +cd tests + +# run all tests against all registered implementations +uv run pytest . -v + +# run against a specific implementation only +uv run pytest . -v -k "jvm" +uv run pytest . -v -k "python" ``` -### GPG Signing +### GPG Signing (JVM) -Signing uses in-memory PGP keys via vanniktech's `signingInPlaceKey` / `signingInPlaceKeyPassword` Gradle properties, supplied as environment variables in CI. No GPG keyring import needed. +Signing uses in-memory PGP keys via vanniktech's `signingInMemoryKey` / `signingInMemoryKeyPassword` Gradle properties, supplied as environment variables in CI. No GPG keyring import needed. -### Versioning +### Versioning (JVM) Version is derived from git tags via `version.sh`: - If the tag matches `X.Y.Z` exactly, that version is used as-is - Otherwise, the git describe output gets `-SNAPSHOT` appended -`-PprojectVersion=$(./version.sh)` is passed on the command line in CI. +`-PprojectVersion=$(../../.github/version_jvm.sh)` is passed on the command line in CI from `implementations/jvm/`. -### Code Style and Linting +### JVM Code Style and Linting - **Formatting**: [ktfmt](https://github.com/facebook/ktfmt) via `com.ncorti.ktfmt.gradle`. Run `./gradlew ktfmtFormat` to auto-format. CI fails on unformatted code. -- **Linting**: [detekt](https://detekt.dev/) via `io.gitlab.arturbosch.detekt`. Config in `config/detekt/detekt.yml`. Existing violations are baselined in `config/detekt/baseline.xml` — new violations fail the build. Run `./gradlew detektBaseline` to regenerate the baseline after intentionally fixing or accepting violations. +- **Linting**: [detekt](https://detekt.dev/) via `io.gitlab.arturbosch.detekt`. Config in `implementations/jvm/detekt.yml`. Run `./gradlew detektBaseline` to regenerate the baseline after intentionally accepting violations. - Disabled rules: `MaxLineLength` (ktfmt owns this), `TooGenericExceptionCaught` (too strict at boundary layers), `ForbiddenComment` (informational TODOs are tracked as known issues). +### Python Code Style and Typing + +- **Formatting/Linting**: [ruff](https://docs.astral.sh/ruff/) — Black-compatible, line-length 120. Run `uv run ruff format esque/` to auto-format. +- **Type checking**: [pyright](https://github.com/microsoft/pyright) in `strict` mode + [ty](https://github.com/astral-sh/ty) with all warn-level rules escalated to errors. All code must be fully annotated. + ### CI/CD -`gradle-deploy.yml` triggers on push to `master`, pull requests to `master`, and GitHub releases. It runs `./gradlew ktfmtCheck detekt build` then `./gradlew publish -x test`, signing in-memory via vanniktech. SNAPSHOT publishing requires the namespace to have snapshots enabled at central.sonatype.com. +A single **`ci.yml`** handles everything — checks, publishing, and compatibility tests: + +- **Triggers**: push to `master` · PRs to `master` · published GitHub releases +- **`jvm`**: ktfmtCheck + detekt + build + publish on every build. vanniktech plugin routes automatically — `*-SNAPSHOT` versions go to OSSRH snapshots, release versions go to Maven Central staging. +- **`python`**: ruff + pyright + build + publish on every build. Version is computed by `.github/version_python.sh` (PEP 440: `X.Y.Z` on exact tag, `X.Y.Z.devN` otherwise) and patched into `pyproject.toml` before building. Non-release builds publish to TestPyPI (`TEST_PYPI_TOKEN`); release builds publish to PyPI (`PYPI_TOKEN`). +- **`compatibility-tests`**: needs `jvm` + `python`; runs 34 pytest scenarios via testcontainers. ## Architecture ### Execution Flow -`Esque.execute()` performs: -1. **Initialize** - Create the `.esque` index in ES if it doesn't exist -2. **Load** - Discover and parse YAML migration files from classpath (`es.migration/` directory) -3. **Load history** - Fetch existing migration records from ES for the given migration key -4. **Verify integrity** - Validate files match history (checksums, ordering, versions) -5. **Execute migrations** - For each unapplied file: - - Acquire distributed lock (5 min timeout) +Both implementations perform the same sequence: + +1. **Initialize** — Create the `.esque` index in ES if it doesn't exist +2. **Load** — Discover and parse YAML migration files from the migrations directory +3. **Validate templates** — Fail fast if any `#{varName}` references a missing property +4. **Resolve templates** — Substitute `#{varName}` → `properties[varName]` in all request fields (path, contentType, params values, body; NOT method) +5. **Calculate checksums** — JSON canonical (sorted keys, nulls omitted, compact UTF-8 → MD5 → first 4 bytes big-endian signed int) +6. **Load history** — Fetch existing migration records from ES for the given migration key +7. **Verify integrity** — Records ≤ files; no gaps; each record's checksum/version/description/order matches its companion file +8. **Execute migrations** — For each unapplied file: + - Acquire distributed lock (`op_type=create`, 100ms poll, configurable timeout) - Skip if already applied (idempotent in distributed environments) - Execute each HTTP request defined in the file sequentially - Record execution metadata (user, timestamp, duration, checksum) - Release lock -### Key Classes +### Key Classes (JVM) | Class | Purpose | |-------|---------| -| `Esque` | Main orchestrator - coordinates the full migration lifecycle | -| `RestClientOperations` | ES REST client abstraction for all index/document operations | -| `MigrationFileLoader` | Discovers and parses YAML files from classpath | -| `MigrationFile` | Domain model (data class) for migration files with version-based ordering | -| `ElasticsearchDocumentLock` | Distributed lock using ES `op_type=create` for atomicity | -| `MigrationRecord` | Domain model (data class) for applied migration history records | -| `MigrationLock` | Domain model (data class) for lock documents | +| `Esque` | Main orchestrator | +| `EsqueConfiguration` | Configuration data class | +| `RestClientOperations` | ES REST client abstraction | +| `MigrationFileLoader` | File discovery, parsing, template resolution, checksum | +| `MigrationTemplateResolver` | `#{varName}` substitution and validation | +| `MigrationFile` | Domain model with version-based `Comparable` ordering | +| `ElasticsearchDocumentLock` | Distributed lock via ES `op_type=create` | +| `MigrationRecord` | Applied migration history record | +| `MigrationLock` | Lock document model | + +### Checksum Algorithm + +Both implementations must produce identical checksums for the same resolved migration content: + +1. Serialize the resolved request list as JSON: `{"requests": [{...}, ...]}` with keys sorted alphabetically and null fields omitted +2. Encode as UTF-8 +3. Compute MD5 digest +4. Take the first 4 bytes interpreted as a big-endian signed 32-bit integer + +This is the canonical algorithm since Phase 3. The JVM uses `JSON_MAPPER_CANONICAL` (Jackson with `ORDER_MAP_ENTRIES_BY_KEYS` + `NON_NULL`). Python uses `json.dumps(sort_keys=True, separators=(',', ':'))` after recursively removing None values. + +### ES Document Structure + +Migration records are stored in the hidden `.esque` index with a `migration` wrapper object (due to Jackson `@JsonTypeInfo(As.WRAPPER_OBJECT)` in the JVM): + +```json +{ + "_source": { + "migration": { + "migrationKey": "...", + "order": 0, + "filename": "V1.0.0__CreateFirstIndex.yml", + "version": "1.0.0", + "description": "CreateFirstIndex", + "checksum": -123456789, + "installedBy": null, + "installedOn": "2026-06-13T12:00:00Z", + "executionTime": 42 + } + } +} +``` -### Distributed Locking +Lock documents use the same wrapper pattern: `{"lock": {"date": "..."}}` with doc ID `lock:`. -`ElasticsearchDocumentLock` implements `java.util.concurrent.locks.Lock` using a hybrid approach: -- Local `ReentrantLock` for in-process thread safety -- Remote ES document creation (`op_type=create`) for cross-process/cross-node safety -- Inspired by Spring Integration lock implementations (JDBC, Zookeeper, Redis) -- Configurable polling interval (default 100ms) +Query for records: `POST /.esque/_search` with body `{"query":{"bool":{"filter":[{"term":{"migration.migrationKey":""}}]}}}`. ### Migration File Format -Files must be placed in `src/main/resources/es.migration/` and follow the naming convention: - ``` V{VERSION}__{DESCRIPTION}.yml ``` -- **VERSION**: Dot-separated numeric segments (e.g., `1.0.0`, `2.1`) -- **DESCRIPTION**: Alphanumeric with underscores (word characters only) +- **VERSION**: Dot-separated numeric segments (e.g., `1.0.0`, `2.1`). Sorted numerically per segment — `1.9.0` < `1.10.0`. +- **DESCRIPTION**: Alphanumeric with underscores (`\w+`) - **Pattern**: `^V((\d+\.?)+)__(\w+)\.yml$` -Example: `V1.0.0__InitialIndexAndAlias.yml` - -File contents use YAML format: - -```yaml ---- -requests: - - method: "PUT" - path: "/my-index-v1" - contentType: application/json; charset=utf-8 - - - method: "POST" - path: "/_aliases" - contentType: application/json; charset=utf-8 - body: > - { - "actions": [ - { "add": { "index": "my-index-v1", "alias": "my-index" } } - ] - } -``` - -Each request supports: `method` (required), `path` (required), `contentType`, `params` (key-value map), `body`. - -### State Tracking - -Esque uses a hidden ES index `.esque` with two document types: -- **lock**: Contains a `date` field (used for distributed locking) -- **migration**: Contains `migrationKey`, `order`, `filename`, `version`, `description`, `checksum`, `installedBy`, `installedOn`, `executionTime` +### Distributed Locking -Integrity is verified by matching file checksums (MD5) against stored records. +Uses ES `op_type=create` for cross-process atomicity. The JVM also wraps this with a local `ReentrantLock` for thread safety. Python polls at 100ms intervals. Both default to a 5-minute timeout. -## Code Conventions +## JVM Code Conventions ### Style -- **Indentation**: 4 spaces +- **Indentation**: 2 spaces (ktfmt manages this) - **Encoding**: UTF-8 - **Class naming**: PascalCase -- **Method naming**: camelCase, descriptive (e.g., `checkMigrationIndexExists`) -- **Constants**: UPPER_SNAKE_CASE (e.g., `MIGRATION_DOCUMENT_INDEX`) -- **Packages**: lowercase dot-separated under `org.loesak.esque.core` +- **Method naming**: camelCase +- **Constants**: UPPER_SNAKE_CASE +- **Packages**: lowercase under `org.loesak.esque.core` ### Patterns and Libraries -- **Kotlin data classes**: Used for all immutable domain models (`MigrationRecord`, `MigrationLock`, `MigrationFile` and its nested types). Kotlin null safety enforces non-null constraints. -- **Jackson**: Full suite for JSON and YAML serialization, versions managed via `jackson-bom` - - `@JsonTypeInfo` / `@JsonTypeName` for type-wrapped serialization on document models - - `jackson-module-kotlin` for Kotlin data class deserialization -- **kotlin-logging** (`io.github.oshai.kotlinlogging.KotlinLogging`): top-level `val log = KotlinLogging.logger {}` -- **Kotlin idioms**: extension functions, `use {}` for resource management, `filter`/`map`/`toList()` for collections -- **Logging**: SLF4J via kotlin-logging; info for flow, debug for request/response detail, warn/error for failures +- **Kotlin data classes**: All immutable domain models +- **Jackson**: JSON and YAML serialization via `jackson-bom`; `@JsonTypeInfo` / `@JsonTypeName` for type-wrapped ES documents; `jackson-module-kotlin` for data class deserialization +- **kotlin-logging**: `val log = KotlinLogging.logger {}` at top level +- **Clikt 4.4.0**: CLI parsing (`--es-url`, `--migrations-dir`, `--migration-key`, `--migration-user`, `--lock-timeout-minutes`, `--property` repeatable) -### Design Principles +## Python Code Conventions -- Domain models are immutable (Kotlin data classes) -- All ES operations are centralized in `RestClientOperations` -- Migration ordering is deterministic via `Comparable` (version parts, then lexical) -- Exceptions wrap lower-level errors with context messages -- No rollback on failure (documented limitation) +- **Package layout**: `src/esque/` with modules mirroring the JVM structure — `esque.py` (orchestrator), `configuration.py`, `cli.py`, `elasticsearch/` (documents, operations, lock), `migration/` (model, template, loader) +- **Entry point**: `esque.cli:main`; `__main__.py` is a thin shim for `python -m esque` +- **Strict typing**: all functions annotated; `cast()` used where isinstance-narrowing produces Unknown; `field(default_factory=lambda: [])` instead of `field(default_factory=list)` to satisfy pyright strict +- **httpx**: ES REST calls (not elasticsearch-py, to avoid client version compatibility issues) +- **PyYAML**: migration file parsing +- **Click**: CLI with the same option names as the JVM Clikt interface ## Testing -Integration tests live in `esque-core/src/test/kotlin/` and are named `*IT`. They use: -- **JUnit 5** (`junit-jupiter`) as the test framework -- **AssertJ** for assertions -- **Testcontainers** (`testcontainers-elasticsearch`) to spin up a real ES instance via Docker -- **Logback** as the SLF4J implementation (test scope only) +### JVM Integration Tests + +Live in `implementations/jvm/src/test/kotlin/` and are named `*IT`. Use JUnit 5, AssertJ, and testcontainers-elasticsearch. Run via `./gradlew test` (requires Docker). + +### Python Unit Tests + +Live in `implementations/python/tests/`. Pure unit tests (no ES), covering the most complex logic: +- `test_model.py` — numeric version ordering (`1.9.0 < 1.10.0`) +- `test_checksum.py` — canonical checksum algorithm properties +- `test_template.py` — `#{varName}` validation and substitution across all request fields +- `test_integrity.py` — all `verify_integrity` error scenarios -Tests run via the standard `test` task (`./gradlew test`). Docker must be available for integration tests to run. +Run via `uv run pytest` from `implementations/python/`. -## Module Notes +### Compatibility Test Harness -- **esque-core**: The published library artifact. Contains all core logic. -- **esque-examples**: Aggregator with example applications. Not published to Maven Central. -- **esque-example-core-aws-auth**: Placeholder only (no implementation). +Lives in `tests/` as a standalone uv project. Each test invokes an implementation as a subprocess via its CLI, then queries ES directly via httpx to verify state. + +- **Fixture**: one session-scoped ES container (`ElasticSearchContainer`), cleaned between tests with `DELETE /.esque` and `DELETE /test-*` +- **Parametrized**: every test function is parametrized over `all_implementations()` which reads `tests/implementations.yml` +- **Adding a new implementation**: add an entry to `implementations.yml` with `invocation: direct` and a `command` list; tests run automatically + +### Registered Implementations (`tests/implementations.yml`) + +```yaml +implementations: + jvm: + invocation: gradle + gradle_dir: "implementations/jvm" + task: "run" + python: + invocation: direct + command: ["uv", "run", "--project", "implementations/python", "esque"] +``` ## Known TODOs in Code -- Differentiate lock creation failure vs. lock-already-exists (`RestClientOperations`) +- Differentiate lock creation failure vs. lock-already-exists (JVM `RestClientOperations`) - Configurable lock timeout for long-running queries (`Esque.kt`) - Consider writing "FAILED" migration records (`Esque.kt`) -- Rollback/undo capability (mentioned in README) -- Elasticsearch security / AWS ES security support (README) +- Rollback/undo capability +- Elasticsearch security / AWS auth support diff --git a/README.md b/README.md index df89b54..295591e 100644 --- a/README.md +++ b/README.md @@ -1,82 +1,109 @@ # esque Resembles an **E**lasticsearch **S**tateful **Qu**ery **E**xecutor -# What is it -A means of repeatable execution of pre-defined queries against your Elasticsearch cluster. Esque will remember which queries it ran against the cluster and only execute those that have not in the order they are defined. +## What is it + +A means of repeatable, ordered execution of pre-defined queries against an Elasticsearch cluster. Esque remembers which queries it has run and only executes those that haven't been applied yet, in the order they are defined. It is Flyway-esque but for Elasticsearch. -# What it does -* Define queries in migration files using YAML -* Executes the migration files in order as needed -* Maintains state of which migration files have been executed -* Ensures integrity between migration files and applied migrations -* Locks migration operations across distributed systems to ensure single execution -* Can supply your own distributed lock if needed (e.g. Hazelcast) -* Allows for different logical separation of migration sets via a migration key - -# What it doesn't -* Rollback in the face of failure. Back up your systems and test your migrations - -# Prerequisites -* Elasticsearch 9+ - -# Dependencies -* org.jetbrains.kotlin:kotlin-stdlib -* org.elasticsearch.client:elasticsearch-rest-client -* com.fasterxml.jackson.core:jackson-databind -* com.fasterxml.jackson.module:jackson-module-kotlin -* com.fasterxml.jackson.datatype:jackson-datatype-jsr310 -* com.fasterxml.jackson.dataformat:jackson-dataformat-yaml -* org.slf4j:slf4j-api - -# Use cases -* Executing all queries for bootstrapping a brand new Elasticsearch cluster. For example: - - cluster settings - - saved searches - - visualizations - - dashboards - - creating indexes - - creating users -* Executing queries needed for a particular application. For example: - - creating indexes - - creating index templates - - creating/modifying index aliases - - index schema modification - - etc. - -It basically executes queries and remembers which queries have been run on a cluster for a given migration key. You can organize its usage to your needs. - -# Install -Available on Maven Central. Make sure to check the releases for the latest version. +## What it does + +- Define queries in migration files using YAML +- Execute migration files in version order +- Track which migrations have been applied +- Verify integrity between local files and applied history (checksums, ordering) +- Lock migration operations across distributed systems to ensure single execution +- Support logical separation of migration sets via a migration key +- Substitute template variables (`#{varName}`) in migration files at runtime + +## What it doesn't + +- Roll back on failure — back up your data and test migrations before applying them + +## Prerequisites + +- Elasticsearch 9+ + +## Implementations + +Esque is available as both a **JVM library** (Kotlin/Java) and a **Python package**. + +### JVM (Kotlin/Java) + +Available on Maven Central. See [releases](https://github.com/loesak/esque/releases) for the latest version. **Gradle (Kotlin DSL):** ```kotlin -implementation("org.loesak.esque:esque-core:0.2.1") +implementation("org.loesak.esque:esque:") ``` **Gradle (Groovy DSL):** ```groovy -implementation 'org.loesak.esque:esque-core:0.2.1' +implementation 'org.loesak.esque:esque:' ``` **Maven:** ```xml org.loesak.esque - esque-core - 0.2.1 + esque + ``` -# Cluster Authentication -You provide the RestClient, so you configure it for whatever authentication mechanism is in place for your cluster. +You supply the `RestClient`, so you configure it for whatever authentication mechanism your cluster uses. + +### Python + +Available on PyPI: + +```bash +pip install esque-py +``` + +Both implementations share the same CLI contract, migration file format, checksum algorithm, and ES document structure, so they are interchangeable for any given migration key. + +## Migration File Format + +Files follow the naming convention `V{VERSION}__{DESCRIPTION}.yml` and are placed in a migrations directory: + +``` +V1.0.0__CreateIndex.yml +V1.1.0__AddAlias.yml +V2.0.0__UpdateMapping.yml +``` + +File contents: + +```yaml +--- +requests: + - method: "PUT" + path: "/my-index-v1" + contentType: application/json; charset=utf-8 + + - method: "POST" + path: "/_aliases" + contentType: application/json; charset=utf-8 + body: > + { + "actions": [ + { "add": { "index": "my-index-v1", "alias": "my-index" } } + ] + } +``` + +Each request supports: `method` (required), `path` (required), `contentType`, `params` (key-value map), `body`. Template variables (`#{varName}`) are substituted at runtime. + +## Use Cases + +- Bootstrapping a new cluster: settings, index templates, aliases, users +- Application-scoped migrations: creating indexes, modifying mappings, updating aliases +- Any scenario where you need ordered, idempotent, tracked ES operations -# Examples -Example projects exist in the `esque-examples` subdirectory +## Known Limitations -# Future Features -* may allow ability to define "undo" queries for each definition to allow for attempts to roll back in the face of partial failure -* may allow ability to define "always" queries that are executed every run -* support multiple versions of Elasticsearch -* migrate to the new Rest5Client and RestClient is now legacy. +- No rollback on failure +- No "always run" migrations +- Esque tracks history per `migrationKey` — different implementations writing to the same key must use the same checksum algorithm (both do; they use JSON canonical MD5) diff --git a/TODO.md b/TODO.md index e69de29..d51ca4e 100644 --- a/TODO.md +++ b/TODO.md @@ -0,0 +1,17 @@ +- [x] checksum post templating +- [x] property placeholders for query params +- [x] needs configuration for: + - migration folder location + - migration index replica + - etc +- [ ] multi-language support + - [ ] jvm (kotlin) + - [ ] python + - [ ] typescript + - [ ] go +- [ ] project documentation using github pages? +- [ ] always run migrations +- [ ] .esque index creation - and future changes - uses own migration tooling (eat own dogfood) + - yaml files stored in library + - need means of tracking what operations have been applied. maybe doesn't need its own index but maybe index name can be an indicator? (e.g. .esque-v1/2/3/etc.) + - maybe don't do this until needed? \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts deleted file mode 100644 index 36dd52d..0000000 --- a/build.gradle.kts +++ /dev/null @@ -1,29 +0,0 @@ -plugins { - alias(libs.plugins.kotlin.jvm) apply false - alias(libs.plugins.vanniktech.publish) apply false - alias(libs.plugins.ktfmt) apply false - alias(libs.plugins.detekt) apply false -} - -allprojects { - group = "org.loesak.esque" - version = findProperty("projectVersion") as String? ?: "NONE" -} - -subprojects { - apply(plugin = "org.jetbrains.kotlin.jvm") - apply(plugin = "com.ncorti.ktfmt.gradle") - apply(plugin = "io.gitlab.arturbosch.detekt") - - extensions.configure { - config.setFrom(rootProject.file("detekt.yml")) - } - - repositories { - mavenCentral() - } - - extensions.configure { - jvmToolchain(21) - } -} diff --git a/esque-examples/build.gradle.kts b/esque-examples/build.gradle.kts deleted file mode 100644 index c26fd95..0000000 --- a/esque-examples/build.gradle.kts +++ /dev/null @@ -1 +0,0 @@ -// examples are not published to Maven Central — no publish configuration needed diff --git a/esque-examples/esque-example-core-aws-auth/build.gradle.kts b/esque-examples/esque-example-core-aws-auth/build.gradle.kts deleted file mode 100644 index 70eef70..0000000 --- a/esque-examples/esque-example-core-aws-auth/build.gradle.kts +++ /dev/null @@ -1 +0,0 @@ -dependencies { implementation(project(":esque-core")) } diff --git a/esque-examples/esque-example-core-es-auth/README.md b/esque-examples/esque-example-core-es-auth/README.md deleted file mode 100644 index e69de29..0000000 diff --git a/esque-examples/esque-example-core-es-auth/build.gradle.kts b/esque-examples/esque-example-core-es-auth/build.gradle.kts deleted file mode 100644 index 98ebd24..0000000 --- a/esque-examples/esque-example-core-es-auth/build.gradle.kts +++ /dev/null @@ -1,4 +0,0 @@ -dependencies { - implementation(project(":esque-core")) - implementation(libs.logback.classic) -} diff --git a/esque-examples/esque-example-core-es-auth/src/main/kotlin/org/loesak/esque/examples/simpleesauth/Application.kt b/esque-examples/esque-example-core-es-auth/src/main/kotlin/org/loesak/esque/examples/simpleesauth/Application.kt deleted file mode 100644 index ca123ff..0000000 --- a/esque-examples/esque-example-core-es-auth/src/main/kotlin/org/loesak/esque/examples/simpleesauth/Application.kt +++ /dev/null @@ -1,33 +0,0 @@ -package org.loesak.esque.examples.simpleesauth - -import org.apache.http.HttpHost -import org.apache.http.auth.AuthScope -import org.apache.http.auth.UsernamePasswordCredentials -import org.apache.http.impl.client.BasicCredentialsProvider -import org.elasticsearch.client.RestClient -import org.loesak.esque.core.Esque -import org.loesak.esque.core.EsqueConfiguration - -fun main() { - val migrationKey = "esque-example-core-simple" - val migrationUser = "migration-user" - val migrationPass = "migration-p4\$\$word" - - // see - // https://www.elastic.co/guide/en/elasticsearch/client/java-rest/7.1/_basic_authentication.html - - val credentialsProvider = BasicCredentialsProvider() - credentialsProvider.setCredentials( - AuthScope.ANY, UsernamePasswordCredentials(migrationUser, migrationPass)) - - val client = - RestClient.builder(HttpHost("localhost", 9200, "http")) - .setHttpClientConfigCallback { it.setDefaultCredentialsProvider(credentialsProvider) } - .build() - - Esque( - client, - EsqueConfiguration(migrationKey = migrationKey, migrationUser = migrationUser), - ) - .use { it.execute() } -} diff --git a/esque-examples/esque-example-core-es-auth/src/main/resources/es.migration/V1.0.0__InitialIndexAndAlias.yml b/esque-examples/esque-example-core-es-auth/src/main/resources/es.migration/V1.0.0__InitialIndexAndAlias.yml deleted file mode 100644 index a9906ed..0000000 --- a/esque-examples/esque-example-core-es-auth/src/main/resources/es.migration/V1.0.0__InitialIndexAndAlias.yml +++ /dev/null @@ -1,22 +0,0 @@ ---- -requests: - # create the index for the first schema version - - method: "PUT" - path: "/esque-example-core-simple-v1" - contentType: application/json; charset=utf-8 - - # create the alias to point to first schema version index - - method: "POST" - path: "/_aliases" - contentType: application/json; charset=utf-8 - body: > - { - "actions" : [ - { - "add" : { - "index" : "esque-example-core-simple-v1", - "alias" : "esque-example-core-simple" - } - } - ] - } \ No newline at end of file diff --git a/esque-examples/esque-example-core-es-auth/src/main/resources/es.migration/V1.1.0__NewIndexChangeAlias.yml b/esque-examples/esque-example-core-es-auth/src/main/resources/es.migration/V1.1.0__NewIndexChangeAlias.yml deleted file mode 100644 index ef059fd..0000000 --- a/esque-examples/esque-example-core-es-auth/src/main/resources/es.migration/V1.1.0__NewIndexChangeAlias.yml +++ /dev/null @@ -1,38 +0,0 @@ ---- -requests: - # create the index for the second schema version - - method: "PUT" - path: "/esque-example-core-simple-v2" - contentType: application/json; charset=utf-8 - - # remove the alias to point to first schema version index - - method: "POST" - path: "/_aliases" - contentType: application/json; charset=utf-8 - body: > - { - "actions" : [ - { - "remove" : { - "index" : "esque-example-core-simple-v1", - "alias" : "esque-example-core-simple" - } - } - ] - } - - # create the alias to point to second schema version index - - method: "POST" - path: "/_aliases" - contentType: application/json; charset=utf-8 - body: > - { - "actions" : [ - { - "add" : { - "index" : "esque-example-core-simple-v2", - "alias" : "esque-example-core-simple" - } - } - ] - } \ No newline at end of file diff --git a/esque-examples/esque-example-core-es-auth/src/main/resources/es.migration/V1.2.0__NewIndexChangeAlias.yml b/esque-examples/esque-example-core-es-auth/src/main/resources/es.migration/V1.2.0__NewIndexChangeAlias.yml deleted file mode 100644 index 835bd35..0000000 --- a/esque-examples/esque-example-core-es-auth/src/main/resources/es.migration/V1.2.0__NewIndexChangeAlias.yml +++ /dev/null @@ -1,38 +0,0 @@ ---- -requests: - # create the index for the third schema version - - method: "PUT" - path: "/esque-example-core-simple-v3" - contentType: application/json; charset=utf-8 - - # remove the alias to point to second schema version index - - method: "POST" - path: "/_aliases" - contentType: application/json; charset=utf-8 - body: > - { - "actions" : [ - { - "remove" : { - "index" : "esque-example-core-simple-v2", - "alias" : "esque-example-core-simple" - } - } - ] - } - - # create the alias to point to third schema version index - - method: "POST" - path: "/_aliases" - contentType: application/json; charset=utf-8 - body: > - { - "actions" : [ - { - "add" : { - "index" : "esque-example-core-simple-v3", - "alias" : "esque-example-core-simple" - } - } - ] - } \ No newline at end of file diff --git a/esque-examples/esque-example-core-es-auth/src/main/resources/es.migration/V2.0.0__NewIndexChangeAlias.yml b/esque-examples/esque-example-core-es-auth/src/main/resources/es.migration/V2.0.0__NewIndexChangeAlias.yml deleted file mode 100644 index c3ea908..0000000 --- a/esque-examples/esque-example-core-es-auth/src/main/resources/es.migration/V2.0.0__NewIndexChangeAlias.yml +++ /dev/null @@ -1,38 +0,0 @@ ---- -requests: - # create the index for the fourth schema version - - method: "PUT" - path: "/esque-example-core-simple-v4" - contentType: application/json; charset=utf-8 - - # remove the alias to point to third schema version index - - method: "POST" - path: "/_aliases" - contentType: application/json; charset=utf-8 - body: > - { - "actions" : [ - { - "remove" : { - "index" : "esque-example-core-simple-v3", - "alias" : "esque-example-core-simple" - } - } - ] - } - - # create the alias to point to fourth schema version index - - method: "POST" - path: "/_aliases" - contentType: application/json; charset=utf-8 - body: > - { - "actions" : [ - { - "add" : { - "index" : "esque-example-core-simple-v4", - "alias" : "esque-example-core-simple" - } - } - ] - } \ No newline at end of file diff --git a/esque-examples/esque-example-core-simple/README.md b/esque-examples/esque-example-core-simple/README.md deleted file mode 100644 index e69de29..0000000 diff --git a/esque-examples/esque-example-core-simple/build.gradle.kts b/esque-examples/esque-example-core-simple/build.gradle.kts deleted file mode 100644 index 98ebd24..0000000 --- a/esque-examples/esque-example-core-simple/build.gradle.kts +++ /dev/null @@ -1,4 +0,0 @@ -dependencies { - implementation(project(":esque-core")) - implementation(libs.logback.classic) -} diff --git a/esque-examples/esque-example-core-simple/src/main/kotlin/org/loesak/esque/examples/simple/Application.kt b/esque-examples/esque-example-core-simple/src/main/kotlin/org/loesak/esque/examples/simple/Application.kt deleted file mode 100644 index e2c733d..0000000 --- a/esque-examples/esque-example-core-simple/src/main/kotlin/org/loesak/esque/examples/simple/Application.kt +++ /dev/null @@ -1,14 +0,0 @@ -package org.loesak.esque.examples.simple - -import org.apache.http.HttpHost -import org.elasticsearch.client.RestClient -import org.loesak.esque.core.Esque -import org.loesak.esque.core.EsqueConfiguration - -fun main() { - Esque( - RestClient.builder(HttpHost("localhost", 9200, "http")).build(), - EsqueConfiguration(migrationKey = "esque-example-core-simple"), - ) - .use { it.execute() } -} diff --git a/esque-examples/esque-example-core-simple/src/main/resources/es.migration/V1.0.0__InitialIndexAndAlias.yml b/esque-examples/esque-example-core-simple/src/main/resources/es.migration/V1.0.0__InitialIndexAndAlias.yml deleted file mode 100644 index a9906ed..0000000 --- a/esque-examples/esque-example-core-simple/src/main/resources/es.migration/V1.0.0__InitialIndexAndAlias.yml +++ /dev/null @@ -1,22 +0,0 @@ ---- -requests: - # create the index for the first schema version - - method: "PUT" - path: "/esque-example-core-simple-v1" - contentType: application/json; charset=utf-8 - - # create the alias to point to first schema version index - - method: "POST" - path: "/_aliases" - contentType: application/json; charset=utf-8 - body: > - { - "actions" : [ - { - "add" : { - "index" : "esque-example-core-simple-v1", - "alias" : "esque-example-core-simple" - } - } - ] - } \ No newline at end of file diff --git a/esque-examples/esque-example-core-simple/src/main/resources/es.migration/V1.1.0__NewIndexChangeAlias.yml b/esque-examples/esque-example-core-simple/src/main/resources/es.migration/V1.1.0__NewIndexChangeAlias.yml deleted file mode 100644 index ef059fd..0000000 --- a/esque-examples/esque-example-core-simple/src/main/resources/es.migration/V1.1.0__NewIndexChangeAlias.yml +++ /dev/null @@ -1,38 +0,0 @@ ---- -requests: - # create the index for the second schema version - - method: "PUT" - path: "/esque-example-core-simple-v2" - contentType: application/json; charset=utf-8 - - # remove the alias to point to first schema version index - - method: "POST" - path: "/_aliases" - contentType: application/json; charset=utf-8 - body: > - { - "actions" : [ - { - "remove" : { - "index" : "esque-example-core-simple-v1", - "alias" : "esque-example-core-simple" - } - } - ] - } - - # create the alias to point to second schema version index - - method: "POST" - path: "/_aliases" - contentType: application/json; charset=utf-8 - body: > - { - "actions" : [ - { - "add" : { - "index" : "esque-example-core-simple-v2", - "alias" : "esque-example-core-simple" - } - } - ] - } \ No newline at end of file diff --git a/esque-examples/esque-example-core-simple/src/main/resources/es.migration/V1.2.0__NewIndexChangeAlias.yml b/esque-examples/esque-example-core-simple/src/main/resources/es.migration/V1.2.0__NewIndexChangeAlias.yml deleted file mode 100644 index 835bd35..0000000 --- a/esque-examples/esque-example-core-simple/src/main/resources/es.migration/V1.2.0__NewIndexChangeAlias.yml +++ /dev/null @@ -1,38 +0,0 @@ ---- -requests: - # create the index for the third schema version - - method: "PUT" - path: "/esque-example-core-simple-v3" - contentType: application/json; charset=utf-8 - - # remove the alias to point to second schema version index - - method: "POST" - path: "/_aliases" - contentType: application/json; charset=utf-8 - body: > - { - "actions" : [ - { - "remove" : { - "index" : "esque-example-core-simple-v2", - "alias" : "esque-example-core-simple" - } - } - ] - } - - # create the alias to point to third schema version index - - method: "POST" - path: "/_aliases" - contentType: application/json; charset=utf-8 - body: > - { - "actions" : [ - { - "add" : { - "index" : "esque-example-core-simple-v3", - "alias" : "esque-example-core-simple" - } - } - ] - } \ No newline at end of file diff --git a/esque-examples/esque-example-core-simple/src/main/resources/es.migration/V2.0.0__NewIndexChangeAlias.yml b/esque-examples/esque-example-core-simple/src/main/resources/es.migration/V2.0.0__NewIndexChangeAlias.yml deleted file mode 100644 index c3ea908..0000000 --- a/esque-examples/esque-example-core-simple/src/main/resources/es.migration/V2.0.0__NewIndexChangeAlias.yml +++ /dev/null @@ -1,38 +0,0 @@ ---- -requests: - # create the index for the fourth schema version - - method: "PUT" - path: "/esque-example-core-simple-v4" - contentType: application/json; charset=utf-8 - - # remove the alias to point to third schema version index - - method: "POST" - path: "/_aliases" - contentType: application/json; charset=utf-8 - body: > - { - "actions" : [ - { - "remove" : { - "index" : "esque-example-core-simple-v3", - "alias" : "esque-example-core-simple" - } - } - ] - } - - # create the alias to point to fourth schema version index - - method: "POST" - path: "/_aliases" - contentType: application/json; charset=utf-8 - body: > - { - "actions" : [ - { - "add" : { - "index" : "esque-example-core-simple-v4", - "alias" : "esque-example-core-simple" - } - } - ] - } \ No newline at end of file diff --git a/implementations/jvm/README.md b/implementations/jvm/README.md new file mode 100644 index 0000000..38c5ea0 --- /dev/null +++ b/implementations/jvm/README.md @@ -0,0 +1,97 @@ +# esque + +JVM implementation of [Esque](../../README.md) — an Elasticsearch migration management library. + +## Installation + +Add the dependency to your build: + +**Gradle (Kotlin DSL)** +```kotlin +implementation("org.loesak.esque:esque:VERSION") +``` + +**Gradle (Groovy DSL)** +```groovy +implementation 'org.loesak.esque:esque:VERSION' +``` + +**Maven** +```xml + + org.loesak.esque + esque + VERSION + +``` + +Requires Java 21+ and Elasticsearch 9+. + +## Usage + +```kotlin +import org.apache.http.HttpHost +import org.elasticsearch.client.RestClient +import org.loesak.esque.core.Esque +import org.loesak.esque.core.EsqueConfiguration + +val client = RestClient.builder(HttpHost("localhost", 9200)).build() + +val configuration = EsqueConfiguration( + migrationKey = "my-service", + migrationUser = "deploy-bot", // optional + migrationDirectory = "classpath:es.migration", // optional, default "classpath:es.migration" + lockTimeoutMinutes = 5, // optional, default 5 +) + +val properties = mapOf( + "indexName" to "my-index" // available as #{indexName} in migration files +) + +Esque(client, configuration, properties).use { esque -> + esque.execute() +} +``` + +`Esque` implements `Closeable`. The `use` block ensures the Elasticsearch client is closed and any held distributed lock is released on exit. `execute()` throws `RuntimeException` on failure. + +### `EsqueConfiguration` fields + +| Field | Type | Default | Description | +|---|---|---|---| +| `migrationKey` | `String` | required | Unique key scoping all migration records for this service | +| `migrationUser` | `String?` | `null` | Label stored on each applied migration record | +| `migrationDirectory` | `String` | `"classpath:es.migration"` | Location of migration files — `classpath:` or `file:` prefix | +| `lockTimeoutMinutes` | `Long` | `5` | Distributed lock acquisition timeout | + +## How It Works + +On each `execute()` call, esque: + +1. Creates the internal `.esque` index in Elasticsearch if it does not already exist. +2. Discovers and parses YAML migration files from `migrationDirectory`, sorted by version. +3. Validates that all `#{varName}` template references have a matching entry in `properties`. +4. Resolves templates and computes a checksum for each migration file. +5. Loads the applied migration history from Elasticsearch for `migrationKey`. +6. Verifies integrity — checksums, versions, and ordering of previously applied migrations must match the files on disk. +7. For each unapplied migration, acquires a distributed lock, executes the HTTP requests defined in the file, records the result, and releases the lock. + +The distributed lock uses Elasticsearch's `op_type=create` to ensure only one process runs a given migration at a time, making it safe to run concurrently across multiple instances. + +## Development + +```bash +cd implementations/jvm + +# Compile +./gradlew compileKotlin + +# Build and test +./gradlew build + +# Format +./gradlew ktfmtFormat + +# Lint +./gradlew ktfmtCheck detekt +``` diff --git a/esque-core/build.gradle.kts b/implementations/jvm/build.gradle.kts similarity index 81% rename from esque-core/build.gradle.kts rename to implementations/jvm/build.gradle.kts index 2618952..aef3f2d 100644 --- a/esque-core/build.gradle.kts +++ b/implementations/jvm/build.gradle.kts @@ -1,7 +1,26 @@ -plugins { alias(libs.plugins.vanniktech.publish) } +plugins { + alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.vanniktech.publish) + alias(libs.plugins.ktfmt) + alias(libs.plugins.detekt) + application +} + +group = "org.loesak.esque" + +version = findProperty("projectVersion") as String? ?: "NONE" + +application { mainClass.set("org.loesak.esque.core.cli.MainKt") } + +repositories { mavenCentral() } + +kotlin { jvmToolchain(21) } + +detekt { config.setFrom(file("detekt.yml")) } dependencies { implementation(libs.kotlin.stdlib) + implementation(libs.clikt) api(libs.elasticsearch.rest.client) implementation(platform(libs.jackson.bom)) implementation(libs.jackson.databind) diff --git a/detekt.yml b/implementations/jvm/detekt.yml similarity index 100% rename from detekt.yml rename to implementations/jvm/detekt.yml diff --git a/gradle.properties b/implementations/jvm/gradle.properties similarity index 90% rename from gradle.properties rename to implementations/jvm/gradle.properties index 38d9fda..043f9b2 100644 --- a/gradle.properties +++ b/implementations/jvm/gradle.properties @@ -2,7 +2,7 @@ org.gradle.daemon=true org.gradle.daemon.idletimeout=3600000 -# Build esque-core and the three example modules in parallel +# Build modules in parallel org.gradle.parallel=true # Local build cache — task outputs reused across clean builds diff --git a/gradle/libs.versions.toml b/implementations/jvm/gradle/libs.versions.toml similarity index 95% rename from gradle/libs.versions.toml rename to implementations/jvm/gradle/libs.versions.toml index 8b92cb7..637c936 100644 --- a/gradle/libs.versions.toml +++ b/implementations/jvm/gradle/libs.versions.toml @@ -11,6 +11,7 @@ testcontainers = "2.0.5" vanniktech-publish = "0.36.0" ktfmt-gradle = "0.22.0" detekt = "1.23.8" +clikt = "4.4.0" [libraries] kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib" } @@ -28,6 +29,7 @@ junit-platform-launcher = { module = "org.junit.platform:junit-platform-launcher assertj-core = { module = "org.assertj:assertj-core", version.ref = "assertj" } testcontainers-elasticsearch = { module = "org.testcontainers:testcontainers-elasticsearch", version.ref = "testcontainers" } testcontainers-junit-jupiter = { module = "org.testcontainers:testcontainers-junit-jupiter", version.ref = "testcontainers" } +clikt = { module = "com.github.ajalt.clikt:clikt", version.ref = "clikt" } [plugins] kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/implementations/jvm/gradle/wrapper/gradle-wrapper.jar similarity index 100% rename from gradle/wrapper/gradle-wrapper.jar rename to implementations/jvm/gradle/wrapper/gradle-wrapper.jar diff --git a/gradle/wrapper/gradle-wrapper.properties b/implementations/jvm/gradle/wrapper/gradle-wrapper.properties similarity index 100% rename from gradle/wrapper/gradle-wrapper.properties rename to implementations/jvm/gradle/wrapper/gradle-wrapper.properties diff --git a/gradlew b/implementations/jvm/gradlew similarity index 100% rename from gradlew rename to implementations/jvm/gradlew diff --git a/gradlew.bat b/implementations/jvm/gradlew.bat similarity index 100% rename from gradlew.bat rename to implementations/jvm/gradlew.bat diff --git a/implementations/jvm/settings.gradle.kts b/implementations/jvm/settings.gradle.kts new file mode 100644 index 0000000..4bd62eb --- /dev/null +++ b/implementations/jvm/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "esque" diff --git a/esque-core/src/main/kotlin/org/loesak/esque/core/Esque.kt b/implementations/jvm/src/main/kotlin/org/loesak/esque/core/Esque.kt similarity index 100% rename from esque-core/src/main/kotlin/org/loesak/esque/core/Esque.kt rename to implementations/jvm/src/main/kotlin/org/loesak/esque/core/Esque.kt diff --git a/esque-core/src/main/kotlin/org/loesak/esque/core/EsqueConfiguration.kt b/implementations/jvm/src/main/kotlin/org/loesak/esque/core/EsqueConfiguration.kt similarity index 100% rename from esque-core/src/main/kotlin/org/loesak/esque/core/EsqueConfiguration.kt rename to implementations/jvm/src/main/kotlin/org/loesak/esque/core/EsqueConfiguration.kt diff --git a/implementations/jvm/src/main/kotlin/org/loesak/esque/core/cli/Main.kt b/implementations/jvm/src/main/kotlin/org/loesak/esque/core/cli/Main.kt new file mode 100644 index 0000000..c2b82d5 --- /dev/null +++ b/implementations/jvm/src/main/kotlin/org/loesak/esque/core/cli/Main.kt @@ -0,0 +1,74 @@ +package org.loesak.esque.core.cli + +import com.github.ajalt.clikt.core.CliktCommand +import com.github.ajalt.clikt.parameters.options.default +import com.github.ajalt.clikt.parameters.options.multiple +import com.github.ajalt.clikt.parameters.options.option +import com.github.ajalt.clikt.parameters.options.required +import com.github.ajalt.clikt.parameters.types.long +import org.apache.http.HttpHost +import org.elasticsearch.client.RestClient +import org.loesak.esque.core.Esque +import org.loesak.esque.core.EsqueConfiguration + +class EsqueCli : + CliktCommand( + name = "esque", + help = "Run Elasticsearch migrations", + ) { + + private val esUrl by + option("--es-url", help = "Elasticsearch URL (e.g. http://localhost:9200)").required() + + private val migrationsDir by + option( + "--migrations-dir", + help = "Absolute path to directory containing migration YAML files") + .required() + + private val migrationKey by + option("--migration-key", help = "Unique key scoping this migration set").required() + + private val migrationUser by + option("--migration-user", help = "User to record on each migration record") + + private val lockTimeoutMinutes by + option("--lock-timeout-minutes", help = "Lock acquisition timeout in minutes") + .long() + .default(5L) + + private val properties by + option( + "--property", + help = "Template substitution property as key=value (repeatable)", + ) + .multiple() + + override fun run() { + val props = + properties.associate { entry -> + val parts = entry.split("=", limit = 2) + check(parts.size == 2) { "Property must be in key=value format, got: $entry" } + parts[0] to parts[1] + } + + val host = HttpHost.create(esUrl) + + RestClient.builder(host).build().use { client -> + Esque( + client = client, + configuration = + EsqueConfiguration( + migrationKey = migrationKey, + migrationUser = migrationUser, + migrationDirectory = "file:$migrationsDir", + lockTimeoutMinutes = lockTimeoutMinutes, + ), + properties = props, + ) + .execute() + } + } +} + +fun main(args: Array) = EsqueCli().main(args) diff --git a/esque-core/src/main/kotlin/org/loesak/esque/core/concurrent/ElasticsearchDocumentLock.kt b/implementations/jvm/src/main/kotlin/org/loesak/esque/core/concurrent/ElasticsearchDocumentLock.kt similarity index 100% rename from esque-core/src/main/kotlin/org/loesak/esque/core/concurrent/ElasticsearchDocumentLock.kt rename to implementations/jvm/src/main/kotlin/org/loesak/esque/core/concurrent/ElasticsearchDocumentLock.kt diff --git a/esque-core/src/main/kotlin/org/loesak/esque/core/elasticsearch/RestClientOperations.kt b/implementations/jvm/src/main/kotlin/org/loesak/esque/core/elasticsearch/RestClientOperations.kt similarity index 100% rename from esque-core/src/main/kotlin/org/loesak/esque/core/elasticsearch/RestClientOperations.kt rename to implementations/jvm/src/main/kotlin/org/loesak/esque/core/elasticsearch/RestClientOperations.kt diff --git a/esque-core/src/main/kotlin/org/loesak/esque/core/elasticsearch/documents/MigrationLock.kt b/implementations/jvm/src/main/kotlin/org/loesak/esque/core/elasticsearch/documents/MigrationLock.kt similarity index 100% rename from esque-core/src/main/kotlin/org/loesak/esque/core/elasticsearch/documents/MigrationLock.kt rename to implementations/jvm/src/main/kotlin/org/loesak/esque/core/elasticsearch/documents/MigrationLock.kt diff --git a/esque-core/src/main/kotlin/org/loesak/esque/core/elasticsearch/documents/MigrationRecord.kt b/implementations/jvm/src/main/kotlin/org/loesak/esque/core/elasticsearch/documents/MigrationRecord.kt similarity index 100% rename from esque-core/src/main/kotlin/org/loesak/esque/core/elasticsearch/documents/MigrationRecord.kt rename to implementations/jvm/src/main/kotlin/org/loesak/esque/core/elasticsearch/documents/MigrationRecord.kt diff --git a/esque-core/src/main/kotlin/org/loesak/esque/core/yaml/MigrationFileLoader.kt b/implementations/jvm/src/main/kotlin/org/loesak/esque/core/yaml/MigrationFileLoader.kt similarity index 81% rename from esque-core/src/main/kotlin/org/loesak/esque/core/yaml/MigrationFileLoader.kt rename to implementations/jvm/src/main/kotlin/org/loesak/esque/core/yaml/MigrationFileLoader.kt index 8994d1d..e20026f 100644 --- a/esque-core/src/main/kotlin/org/loesak/esque/core/yaml/MigrationFileLoader.kt +++ b/implementations/jvm/src/main/kotlin/org/loesak/esque/core/yaml/MigrationFileLoader.kt @@ -1,5 +1,6 @@ package org.loesak.esque.core.yaml +import com.fasterxml.jackson.annotation.JsonInclude import io.github.oshai.kotlinlogging.KotlinLogging import java.nio.ByteBuffer import java.nio.file.Files @@ -8,6 +9,7 @@ import java.nio.file.Paths import java.security.MessageDigest import org.loesak.esque.core.yaml.model.MigrationFile import tools.jackson.databind.SerializationFeature +import tools.jackson.databind.json.JsonMapper import tools.jackson.dataformat.yaml.YAMLMapper import tools.jackson.module.kotlin.KotlinModule @@ -63,10 +65,11 @@ internal class MigrationFileLoader( private val FILE_NAME_PATTERN = Regex(MIGRATION_DEFINITION_FILE_NAME_REGEX) private val YAML_MAPPER = YAMLMapper.builder().addModule(KotlinModule.Builder().build()).build() - private val YAML_MAPPER_SORTED = - YAMLMapper.builder() + private val JSON_MAPPER_CANONICAL = + JsonMapper.builder() .addModule(KotlinModule.Builder().build()) .configure(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, true) + .changeDefaultPropertyInclusion { it.withValueInclusion(JsonInclude.Include.NON_NULL) } .build() private fun readRaw(path: Path): MigrationFile { @@ -97,8 +100,20 @@ internal class MigrationFileLoader( } internal fun calculateChecksum(contents: MigrationFile.MigrationFileContents): Int { + val canonical = + mapOf( + "requests" to + contents.requests.map { req -> + buildMap { + req.body?.let { put("body", it) } + req.contentType?.let { put("contentType", it) } + put("method", req.method) + req.params?.let { put("params", it) } + put("path", req.path) + } + }) val digest = MessageDigest.getInstance("MD5") - digest.update(YAML_MAPPER_SORTED.writeValueAsBytes(contents)) + digest.update(JSON_MAPPER_CANONICAL.writeValueAsBytes(canonical)) return ByteBuffer.wrap(digest.digest()).int } } diff --git a/esque-core/src/main/kotlin/org/loesak/esque/core/yaml/MigrationTemplateResolver.kt b/implementations/jvm/src/main/kotlin/org/loesak/esque/core/yaml/MigrationTemplateResolver.kt similarity index 100% rename from esque-core/src/main/kotlin/org/loesak/esque/core/yaml/MigrationTemplateResolver.kt rename to implementations/jvm/src/main/kotlin/org/loesak/esque/core/yaml/MigrationTemplateResolver.kt diff --git a/esque-core/src/main/kotlin/org/loesak/esque/core/yaml/model/MigrationFile.kt b/implementations/jvm/src/main/kotlin/org/loesak/esque/core/yaml/model/MigrationFile.kt similarity index 100% rename from esque-core/src/main/kotlin/org/loesak/esque/core/yaml/model/MigrationFile.kt rename to implementations/jvm/src/main/kotlin/org/loesak/esque/core/yaml/model/MigrationFile.kt diff --git a/esque-core/src/main/resources/org/loesak/esque/core/elasticsearch/esque-index-defintion.json b/implementations/jvm/src/main/resources/org/loesak/esque/core/elasticsearch/esque-index-defintion.json similarity index 100% rename from esque-core/src/main/resources/org/loesak/esque/core/elasticsearch/esque-index-defintion.json rename to implementations/jvm/src/main/resources/org/loesak/esque/core/elasticsearch/esque-index-defintion.json diff --git a/esque-core/src/test/kotlin/org/loesak/esque/core/AbstractElasticsearchIT.kt b/implementations/jvm/src/test/kotlin/org/loesak/esque/core/AbstractElasticsearchIT.kt similarity index 87% rename from esque-core/src/test/kotlin/org/loesak/esque/core/AbstractElasticsearchIT.kt rename to implementations/jvm/src/test/kotlin/org/loesak/esque/core/AbstractElasticsearchIT.kt index 16d183f..6e434c6 100644 --- a/esque-core/src/test/kotlin/org/loesak/esque/core/AbstractElasticsearchIT.kt +++ b/implementations/jvm/src/test/kotlin/org/loesak/esque/core/AbstractElasticsearchIT.kt @@ -14,6 +14,9 @@ abstract class AbstractElasticsearchIT { ElasticsearchContainer("docker.elastic.co/elasticsearch/elasticsearch:9.3.0") .withEnv("xpack.security.enabled", "false") .withEnv("action.destructive_requires_name", "false") + .withEnv("ES_JAVA_OPTS", "-Xms512m -Xmx512m") + .withEnv("xpack.ml.enabled", "false") + .withEnv("node.store.allow_mmap", "false") init { ELASTICSEARCH.start() diff --git a/esque-core/src/test/kotlin/org/loesak/esque/core/EsqueIT.kt b/implementations/jvm/src/test/kotlin/org/loesak/esque/core/EsqueIT.kt similarity index 100% rename from esque-core/src/test/kotlin/org/loesak/esque/core/EsqueIT.kt rename to implementations/jvm/src/test/kotlin/org/loesak/esque/core/EsqueIT.kt diff --git a/esque-core/src/test/kotlin/org/loesak/esque/core/concurrent/ElasticsearchDocumentLockIT.kt b/implementations/jvm/src/test/kotlin/org/loesak/esque/core/concurrent/ElasticsearchDocumentLockIT.kt similarity index 100% rename from esque-core/src/test/kotlin/org/loesak/esque/core/concurrent/ElasticsearchDocumentLockIT.kt rename to implementations/jvm/src/test/kotlin/org/loesak/esque/core/concurrent/ElasticsearchDocumentLockIT.kt diff --git a/esque-core/src/test/kotlin/org/loesak/esque/core/elasticsearch/RestClientOperationsIT.kt b/implementations/jvm/src/test/kotlin/org/loesak/esque/core/elasticsearch/RestClientOperationsIT.kt similarity index 100% rename from esque-core/src/test/kotlin/org/loesak/esque/core/elasticsearch/RestClientOperationsIT.kt rename to implementations/jvm/src/test/kotlin/org/loesak/esque/core/elasticsearch/RestClientOperationsIT.kt diff --git a/esque-core/src/test/kotlin/org/loesak/esque/core/yaml/MigrationFileLoaderIT.kt b/implementations/jvm/src/test/kotlin/org/loesak/esque/core/yaml/MigrationFileLoaderIT.kt similarity index 100% rename from esque-core/src/test/kotlin/org/loesak/esque/core/yaml/MigrationFileLoaderIT.kt rename to implementations/jvm/src/test/kotlin/org/loesak/esque/core/yaml/MigrationFileLoaderIT.kt diff --git a/esque-core/src/test/kotlin/org/loesak/esque/core/yaml/MigrationFileLoaderTest.kt b/implementations/jvm/src/test/kotlin/org/loesak/esque/core/yaml/MigrationFileLoaderTest.kt similarity index 100% rename from esque-core/src/test/kotlin/org/loesak/esque/core/yaml/MigrationFileLoaderTest.kt rename to implementations/jvm/src/test/kotlin/org/loesak/esque/core/yaml/MigrationFileLoaderTest.kt diff --git a/esque-core/src/test/kotlin/org/loesak/esque/core/yaml/MigrationTemplateResolverTest.kt b/implementations/jvm/src/test/kotlin/org/loesak/esque/core/yaml/MigrationTemplateResolverTest.kt similarity index 100% rename from esque-core/src/test/kotlin/org/loesak/esque/core/yaml/MigrationTemplateResolverTest.kt rename to implementations/jvm/src/test/kotlin/org/loesak/esque/core/yaml/MigrationTemplateResolverTest.kt diff --git a/esque-core/src/test/resources/es.migration/V1.0.0__CreateTestIndex.yml b/implementations/jvm/src/test/resources/es.migration/V1.0.0__CreateTestIndex.yml similarity index 100% rename from esque-core/src/test/resources/es.migration/V1.0.0__CreateTestIndex.yml rename to implementations/jvm/src/test/resources/es.migration/V1.0.0__CreateTestIndex.yml diff --git a/esque-core/src/test/resources/es.migration/V1.1.0__CreateSecondIndex.yml b/implementations/jvm/src/test/resources/es.migration/V1.1.0__CreateSecondIndex.yml similarity index 100% rename from esque-core/src/test/resources/es.migration/V1.1.0__CreateSecondIndex.yml rename to implementations/jvm/src/test/resources/es.migration/V1.1.0__CreateSecondIndex.yml diff --git a/esque-core/src/test/resources/es.migration/V2.0.0__CreateThirdIndex.yml b/implementations/jvm/src/test/resources/es.migration/V2.0.0__CreateThirdIndex.yml similarity index 100% rename from esque-core/src/test/resources/es.migration/V2.0.0__CreateThirdIndex.yml rename to implementations/jvm/src/test/resources/es.migration/V2.0.0__CreateThirdIndex.yml diff --git a/esque-core/src/test/resources/es.migration/V3.0.0__CreateTemplatedIndex.yml b/implementations/jvm/src/test/resources/es.migration/V3.0.0__CreateTemplatedIndex.yml similarity index 100% rename from esque-core/src/test/resources/es.migration/V3.0.0__CreateTemplatedIndex.yml rename to implementations/jvm/src/test/resources/es.migration/V3.0.0__CreateTemplatedIndex.yml diff --git a/esque-core/src/test/resources/logback-test.xml b/implementations/jvm/src/test/resources/logback-test.xml similarity index 100% rename from esque-core/src/test/resources/logback-test.xml rename to implementations/jvm/src/test/resources/logback-test.xml diff --git a/implementations/python/README.md b/implementations/python/README.md new file mode 100644 index 0000000..60d6969 --- /dev/null +++ b/implementations/python/README.md @@ -0,0 +1,81 @@ +# esque-py + +Python implementation of [Esque](../../README.md) — an Elasticsearch migration management library. + +## Installation + +```bash +pip install esque-py +``` + +Requires Python 3.14+ and Elasticsearch 9+. + +## Usage + +```python +from elasticsearch import Elasticsearch +from esque.configuration import EsqueConfiguration +from esque.esque import Esque + +configuration = EsqueConfiguration( + migration_key="my-service", + migration_user="deploy-bot", # optional + migration_directory="file:./migrations", + lock_timeout_minutes=5, # optional, default 5 +) + +properties = { + "indexName": "my-index", # available as #{indexName} in migration files +} + +with Esque( + client=Elasticsearch("http://localhost:9200"), + configuration=configuration, + properties=properties, +) as esque: + esque.execute() +``` + +`Esque` is a context manager that closes the Elasticsearch client and releases any held lock on exit. `execute()` raises `RuntimeError` on failure. + +#### `EsqueConfiguration` fields + +| Field | Type | Default | Description | +|---|---|---|---| +| `migration_key` | `str` | required | Unique key scoping all migration records for this service | +| `migration_user` | `str \| None` | `None` | Label stored on each applied migration record | +| `migration_directory` | `str` | `"file:es.migration"` | Path to migration files, prefixed with `file:` | +| `lock_timeout_minutes` | `int` | `5` | Distributed lock acquisition timeout | + +## How It Works + +On each `execute()` call, esque: + +1. Creates the internal `.esque` index in Elasticsearch if it does not already exist. +2. Discovers and parses YAML migration files from `migration_directory`, sorted by version. +3. Validates that all `#{varName}` template references have a matching entry in `properties`. +4. Resolves templates and computes a checksum for each migration file. +5. Loads the applied migration history from Elasticsearch for `migration_key`. +6. Verifies integrity — checksums, versions, and ordering of previously applied migrations must match the files on disk. +7. For each unapplied migration, acquires a distributed lock, executes the HTTP requests defined in the file, records the result, and releases the lock. + +The distributed lock uses Elasticsearch's `op_type=create` to ensure only one process runs a given migration at a time, making it safe to run concurrently across multiple instances. + +## Development + +```bash +# Install dependencies +uv sync + +# Run tests +uv run pytest + +# Format +uv run ruff format src/ + +# Lint +uv run ruff check src/ + +# Type check +uv run pyright +``` diff --git a/implementations/python/pyproject.toml b/implementations/python/pyproject.toml new file mode 100644 index 0000000..9edc3ad --- /dev/null +++ b/implementations/python/pyproject.toml @@ -0,0 +1,94 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/esque"] + +[project] +name = "esque-py" +version = "0.1.0" +description = "Elasticsearch migration management — repeatable, ordered query execution with history tracking and distributed locking" +readme = "README.md" +requires-python = ">=3.14" +authors = [ + { name = "Aaron Loes", email = "aaron.loes@gmail.com" }, +] +keywords = ["elasticsearch", "migration", "database", "flyway"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.14", + "Topic :: Database", + "Topic :: Software Development :: Libraries", +] +dependencies = [ + "click>=8.1.0", + "elasticsearch>=9.0.0", + "pyyaml>=6.0.0", +] + +[project.urls] +Homepage = "https://github.com/loesak/esque" +Repository = "https://github.com/loesak/esque" +Issues = "https://github.com/loesak/esque/issues" + +[project.scripts] +esque = "esque.cli:main" + +[dependency-groups] +dev = [ + "pytest>=8.0.0", + "pyright>=1.1.0", + "ruff>=0.11.0", + "ty>=0.0.0a6", +] + +[tool.pytest.ini_options] +testpaths = ["tests"] + +[tool.pyright] +include = ["src"] +typeCheckingMode = "strict" +pythonVersion = "3.14" +reportMissingTypeStubs = false + +[tool.ruff] +src = ["src"] +target-version = "py314" +line-length = 120 + +[tool.ty.rules] +ambiguous-protocol-member = "error" +deprecated = "error" +ignore-comment-unknown-rule = "error" +ineffective-final = "error" +invalid-enum-member-annotation = "error" +invalid-ignore-comment = "error" +invalid-legacy-positional-parameter = "error" +invalid-named-tuple-override = "error" +mismatched-type-name = "error" +possibly-missing-implicit-call = "error" +possibly-missing-submodule = "error" +redundant-cast = "error" +redundant-final-classvar = "error" +subclass-of-dataclass-with-order = "error" +undefined-reveal = "error" +unresolved-global = "error" +unsupported-base = "error" +unused-awaitable = "error" +unused-ignore-comment = "error" +unused-type-ignore-comment = "error" + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "N", # pep8-naming + "UP", # pyupgrade + "B", # flake8-bugbear + "RUF", # ruff-specific rules +] diff --git a/esque-core/README.md b/implementations/python/src/esque/__init__.py similarity index 100% rename from esque-core/README.md rename to implementations/python/src/esque/__init__.py diff --git a/implementations/python/src/esque/__main__.py b/implementations/python/src/esque/__main__.py new file mode 100644 index 0000000..1a6760c --- /dev/null +++ b/implementations/python/src/esque/__main__.py @@ -0,0 +1,3 @@ +from esque.cli import main + +main() diff --git a/implementations/python/src/esque/cli.py b/implementations/python/src/esque/cli.py new file mode 100644 index 0000000..3a5cfca --- /dev/null +++ b/implementations/python/src/esque/cli.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +import sys + +import click +from elasticsearch import Elasticsearch + +from esque.configuration import EsqueConfiguration +from esque.esque import Esque + + +@click.command(name="esque", help="Run Elasticsearch migrations.") +@click.option("--es-url", required=True, help="Elasticsearch URL (e.g. http://localhost:9200)") +@click.option("--migrations-dir", required=True, help="Path to directory containing migration YAML files") +@click.option("--migration-key", required=True, help="Unique key scoping this migration set") +@click.option("--migration-user", default=None, help="User to record on each migration record") +@click.option("--lock-timeout-minutes", default=5, type=int, help="Lock acquisition timeout in minutes") +@click.option( + "--property", + "properties", + multiple=True, + help="Template substitution property as key=value (repeatable)", +) +def main( + es_url: str, + migrations_dir: str, + migration_key: str, + migration_user: str | None, + lock_timeout_minutes: int, + properties: tuple[str, ...], +) -> None: + props: dict[str, str] = {} + for p in properties: + parts = p.split("=", 1) + if len(parts) != 2 or not parts[0]: + raise click.BadParameter(f"must be in key=value format, got: {p!r}", param_hint="--property") + props[parts[0]] = parts[1] + + configuration = EsqueConfiguration( + migration_key=migration_key, + migration_user=migration_user, + migration_directory=f"file:{migrations_dir}", + lock_timeout_minutes=lock_timeout_minutes, + ) + + try: + with Esque( + client=Elasticsearch(es_url), + configuration=configuration, + properties=props, + ) as esque: + esque.execute() + except Exception as exc: + click.echo(f"Error: {exc}", err=True) + sys.exit(1) diff --git a/implementations/python/src/esque/configuration.py b/implementations/python/src/esque/configuration.py new file mode 100644 index 0000000..4fb11e8 --- /dev/null +++ b/implementations/python/src/esque/configuration.py @@ -0,0 +1,11 @@ +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass +class EsqueConfiguration: + migration_key: str + migration_user: str | None = None + migration_directory: str = "file:es.migration" + lock_timeout_minutes: int = 5 diff --git a/esque-examples/README.md b/implementations/python/src/esque/elasticsearch/__init__.py similarity index 100% rename from esque-examples/README.md rename to implementations/python/src/esque/elasticsearch/__init__.py diff --git a/implementations/python/src/esque/elasticsearch/documents.py b/implementations/python/src/esque/elasticsearch/documents.py new file mode 100644 index 0000000..27d9fa8 --- /dev/null +++ b/implementations/python/src/esque/elasticsearch/documents.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from typing import Any + +MIGRATION_INDEX = ".esque" +LOCK_ID_PREFIX = "lock" + +INDEX_DEFINITION: dict[str, Any] = { + "settings": { + "index": { + "number_of_shards": "1", + "auto_expand_replicas": "0-all", + "refresh_interval": "1s", + } + }, + "mappings": { + "properties": { + "lock": {"properties": {"date": {"type": "date"}}}, + "migration": { + "properties": { + "checksum": {"type": "long"}, + "description": {"type": "keyword"}, + "executionTime": {"type": "long"}, + "filename": {"type": "keyword"}, + "installedOn": {"type": "date"}, + "migrationKey": {"type": "keyword"}, + "order": {"type": "long"}, + "version": {"type": "keyword"}, + } + }, + } + }, +} + + +@dataclass +class MigrationRecord: + migration_key: str + order: int + filename: str + version: str + description: str + checksum: int + installed_by: str | None + installed_on: datetime + execution_time: int + + def to_document(self) -> dict[str, Any]: + doc: dict[str, Any] = { + "migrationKey": self.migration_key, + "order": self.order, + "filename": self.filename, + "version": self.version, + "description": self.description, + "checksum": self.checksum, + "installedOn": self.installed_on.isoformat(), + "executionTime": self.execution_time, + } + if self.installed_by is not None: + doc["installedBy"] = self.installed_by + return {"migration": doc} + + @classmethod + def from_document(cls, source: dict[str, Any]) -> MigrationRecord: + raw: dict[str, Any] = source["migration"] + return cls( + migration_key=str(raw["migrationKey"]), + order=int(raw["order"]), + filename=str(raw["filename"]), + version=str(raw["version"]), + description=str(raw["description"]), + checksum=int(raw["checksum"]), + installed_by=str(raw["installedBy"]) if raw.get("installedBy") is not None else None, + installed_on=datetime.fromisoformat(str(raw["installedOn"])), + execution_time=int(raw["executionTime"]), + ) + + +@dataclass +class MigrationLock: + date: datetime + + def to_document(self) -> dict[str, Any]: + return {"lock": {"date": self.date.isoformat()}} diff --git a/implementations/python/src/esque/elasticsearch/lock.py b/implementations/python/src/esque/elasticsearch/lock.py new file mode 100644 index 0000000..fc9597e --- /dev/null +++ b/implementations/python/src/esque/elasticsearch/lock.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +import logging +import threading +import time + +from esque.elasticsearch.operations import RestClientOperations + +log = logging.getLogger(__name__) + +_IDLE_BETWEEN_TRIES = 0.1 # seconds — mirrors ElasticsearchDocumentLock.DEFAULT_IDLE_BETWEEN_TRIES + + +class ElasticsearchDocumentLock: + def __init__(self, operations: RestClientOperations) -> None: + self._operations = operations + self._delegate = threading.RLock() + + def try_lock(self, timeout_minutes: int) -> bool: + deadline = time.monotonic() + timeout_minutes * 60 + remaining = max(0.0, deadline - time.monotonic()) + + if not self._delegate.acquire(blocking=True, timeout=remaining): + return False + + while True: + try: + if self._do_lock(): + return True + except Exception: + self._delegate.release() + raise + + if time.monotonic() >= deadline: + self._delegate.release() + return False + + time.sleep(_IDLE_BETWEEN_TRIES) + + def unlock(self) -> None: + try: + self._operations.delete_lock_record() + except Exception as e: + raise RuntimeError("Failed to release mutex") from e + finally: + self._delegate.release() + + def _do_lock(self) -> bool: + try: + self._operations.create_lock_record() + return True + except Exception as e: + # TODO: differentiate ConflictError (lock exists) from other failures + log.debug("Failed to acquire lock: %s", e) + return False diff --git a/implementations/python/src/esque/elasticsearch/operations.py b/implementations/python/src/esque/elasticsearch/operations.py new file mode 100644 index 0000000..cf83987 --- /dev/null +++ b/implementations/python/src/esque/elasticsearch/operations.py @@ -0,0 +1,133 @@ +from __future__ import annotations + +import logging +from datetime import UTC, datetime +from typing import Any, cast +from urllib.parse import urlencode + +from elasticsearch import ConflictError, Elasticsearch + +from esque.elasticsearch.documents import ( + INDEX_DEFINITION, + LOCK_ID_PREFIX, + MIGRATION_INDEX, + MigrationLock, + MigrationRecord, +) +from esque.migration.model import MigrationFile, MigrationFileRequestDefinition + +log = logging.getLogger(__name__) + + +class RestClientOperations: + def __init__(self, client: Elasticsearch, migration_key: str) -> None: + self._client = client + self._migration_key = migration_key + + def close(self) -> None: + self._client.close() + + def check_migration_index_exists(self) -> bool: + log.info("Checking if migration index [%s] exists", MIGRATION_INDEX) + exists = bool(self._client.indices.exists(index=MIGRATION_INDEX)) + log.info("Migration index [%s] %s", MIGRATION_INDEX, "exists" if exists else "does not exist") + return exists + + def create_migration_index(self) -> None: + log.info("Creating migration index [%s]", MIGRATION_INDEX) + try: + self._client.indices.create( + index=MIGRATION_INDEX, + settings=cast(dict[str, Any], INDEX_DEFINITION["settings"]), + mappings=cast(dict[str, Any], INDEX_DEFINITION["mappings"]), + ) + log.info("Migration index [%s] created", MIGRATION_INDEX) + except ConflictError: + log.info("Migration index [%s] already exists — another process created it first", MIGRATION_INDEX) + + def create_lock_record(self) -> None: + log.info("Creating lock document for migration key [%s]", self._migration_key) + lock_id = f"{LOCK_ID_PREFIX}:{self._migration_key}" + self._client.index( + index=MIGRATION_INDEX, + id=lock_id, + document=MigrationLock(date=datetime.now(UTC)).to_document(), + op_type="create", + ) + log.info("Lock document for migration key [%s] created", self._migration_key) + + def delete_lock_record(self) -> None: + log.info("Deleting lock document for migration key [%s]", self._migration_key) + lock_id = f"{LOCK_ID_PREFIX}:{self._migration_key}" + self._client.delete(index=MIGRATION_INDEX, id=lock_id) + log.info("Lock document for migration key [%s] deleted", self._migration_key) + + def get_migration_records(self) -> list[MigrationRecord]: + log.info("Getting migration records for migration key [%s]", self._migration_key) + resp = self._client.search( + index=MIGRATION_INDEX, + query={"bool": {"filter": [{"term": {"migration.migrationKey": self._migration_key}}]}}, + size=10000, + ) + hits = cast(list[dict[str, Any]], resp["hits"]["hits"]) + records = [MigrationRecord.from_document(hit["_source"]) for hit in hits] + records.sort(key=lambda r: r.order) + log.info("Found [%d] migration records", len(records)) + return records + + def get_migration_record_for_migration_file(self, file: MigrationFile) -> MigrationRecord | None: + log.info( + "Getting migration record for file [%s] and migration key [%s]", + file.metadata.filename, + self._migration_key, + ) + resp = self._client.search( + index=MIGRATION_INDEX, + query={ + "bool": { + "filter": [ + {"term": {"migration.migrationKey": self._migration_key}}, + {"term": {"migration.filename": file.metadata.filename}}, + ] + } + }, + ) + hits = cast(list[dict[str, Any]], resp["hits"]["hits"]) + if len(hits) > 1: + raise RuntimeError( + f"found more than one migration record for file [{file.metadata.filename}]" + f" and migration key [{self._migration_key}]" + ) + if len(hits) == 1: + log.info("Found existing migration record for file [%s]", file.metadata.filename) + return MigrationRecord.from_document(hits[0]["_source"]) + log.info("No existing migration record found for file [%s]", file.metadata.filename) + return None + + def execute_migration_definition(self, definition: MigrationFileRequestDefinition) -> None: + log.info("Executing migration query definition") + target = definition.path + if definition.params: + target += "?" + urlencode(definition.params) + headers: dict[str, str] = {} + if definition.content_type: + headers["content-type"] = definition.content_type + body: bytes | None = definition.body.encode("utf-8") if definition.body else None + self._client.perform_request( + definition.method, + target, + headers=headers, + body=body, + ) + log.info("Migration query definition executed successfully") + + def create_migration_record(self, record: MigrationRecord) -> None: + if record.migration_key != self._migration_key: + raise ValueError("migration record migration key must match operational migration key") + log.info("Creating migration record for file [%s]", record.filename) + self._client.index( + index=MIGRATION_INDEX, + document=record.to_document(), + refresh=True, # type: ignore[arg-type] + ) + log.info("Migration record for file [%s] created", record.filename) diff --git a/implementations/python/src/esque/esque.py b/implementations/python/src/esque/esque.py new file mode 100644 index 0000000..e7c40f2 --- /dev/null +++ b/implementations/python/src/esque/esque.py @@ -0,0 +1,175 @@ +from __future__ import annotations + +import logging +import time +from datetime import UTC, datetime +from types import TracebackType + +from elasticsearch import Elasticsearch + +from esque.configuration import EsqueConfiguration +from esque.elasticsearch.documents import MigrationRecord +from esque.elasticsearch.lock import ElasticsearchDocumentLock +from esque.elasticsearch.operations import RestClientOperations +from esque.migration.loader import MigrationFileLoader +from esque.migration.model import MigrationFile +from esque.migration.template import MigrationTemplateResolver + +log = logging.getLogger(__name__) + + +class Esque: + def __init__( + self, + client: Elasticsearch, + configuration: EsqueConfiguration, + properties: dict[str, str] | None = None, + ) -> None: + self._configuration = configuration + self._migration_loader = MigrationFileLoader( + configuration.migration_directory, + MigrationTemplateResolver(properties or {}), + ) + self._operations = RestClientOperations(client, configuration.migration_key) + self._lock = ElasticsearchDocumentLock(self._operations) + + def __enter__(self) -> Esque: + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + self.close() + + def close(self) -> None: + try: + self._lock.unlock() + except RuntimeError: + pass # lock was not held — expected at end of a clean run + except Exception: + log.warning( + "failed to release a execution lock. you may need to manually delete the lock document yourself" + ) + + try: + self._operations.close() + except Exception: + log.warning("failed to close client. this is likely not an issue") + + def execute(self) -> None: + log.info("Starting esque execution") + try: + self._initialize() + files = self._migration_loader.load() + history = self._operations.get_migration_records() + self._verify_state_integrity(files, history) + self._run_migrations(files) + log.info("Completed esque execution") + except Exception as e: + raise RuntimeError("Failed to run esque execution") from e + + def _initialize(self) -> None: + log.info("Initializing esque as needed") + if not self._operations.check_migration_index_exists(): + self._operations.create_migration_index() + + def _verify_state_integrity(self, files: list[MigrationFile], history: list[MigrationRecord]) -> None: + log.info("Verifying integrity of migration state as compared to found migration files") + if len(history) > len(files): + raise RuntimeError( + "the migration records are showing more migrations than the local system defines. " + "did you refactor your files or use an incorrect migration key?" + ) + if history and len(history) != history[-1].order + 1: + raise RuntimeError("the migration records seem to be corrupt as some records appear to be missing.") + for record in history: + self._verify_record_integrity(record, files) + log.info("Integrity checks passed") + + def _verify_record_integrity(self, record: MigrationRecord, files: list[MigrationFile]) -> None: + companion = next((f for f in files if f.metadata.filename == record.filename), None) + if companion is None: + raise RuntimeError( + f"could not find migration file matching migration history record by filename [{record.filename}]" + ) + if ( + record.order != files.index(companion) + or record.version != companion.metadata.version + or record.description != companion.metadata.description + or record.checksum != companion.metadata.checksum + or record.migration_key != self._configuration.migration_key + ): + raise RuntimeError( + f"could not verify integrity of migration history record for filename [{record.filename}]. " + "did you refactor your migration scripts after a previous execution?" + ) + + def _run_migrations(self, files: list[MigrationFile]) -> None: + try: + for file in files: + try: + log.info("Attempting to acquire lock for execution") + if self._lock.try_lock(self._configuration.lock_timeout_minutes): + log.info("Lock acquired. Executing queries in migration file [%s]", file.metadata.filename) + if self._operations.get_migration_record_for_migration_file(file) is not None: + log.info( + "Migration for file [%s] and key [%s] already executed. Skipping", + file.metadata.filename, + self._configuration.migration_key, + ) + else: + start = time.monotonic() + self._run_migration_for_file(file) + elapsed_ms = int((time.monotonic() - start) * 1000) + log.info( + "Execution complete for migration file [%s]. Took [%d] milliseconds", + file.metadata.filename, + elapsed_ms, + ) + self._operations.create_migration_record( + MigrationRecord( + migration_key=self._configuration.migration_key, + order=files.index(file), + filename=file.metadata.filename, + version=file.metadata.version, + description=file.metadata.description, + checksum=file.metadata.checksum, + installed_by=self._configuration.migration_user, + installed_on=datetime.now(UTC), + execution_time=elapsed_ms, + ) + ) + else: + log.error("Failed to acquire lock in the allotted time. Did a lock not get cleared?") + raise RuntimeError("failed to acquire lock") + except Exception as e: + raise RuntimeError(f"Failed to execute queries in migration file [{file.metadata.filename}]") from e + finally: + log.info("Releasing execution lock") + self._lock.unlock() + except Exception as e: + raise RuntimeError("failed to run migrations") from e + + def _run_migration_for_file(self, file: MigrationFile) -> None: + log.info("Executing queries defined in migration file [%s]", file.metadata.filename) + for position, definition in enumerate(file.contents.requests): + try: + log.info( + "Executing query in position [%d] in migration file [%s]", + position, + file.metadata.filename, + ) + self._operations.execute_migration_definition(definition) + log.info( + "Query in position [%d] in migration file [%s] executed successfully", + position, + file.metadata.filename, + ) + except Exception as e: + raise RuntimeError( + f"Failed to execute query in position [{position}] in migration file [{file.metadata.filename}]" + ) from e + log.info("Execution complete for queries in migration file [%s]", file.metadata.filename) diff --git a/esque-examples/esque-example-core-aws-auth/README.md b/implementations/python/src/esque/migration/__init__.py similarity index 100% rename from esque-examples/esque-example-core-aws-auth/README.md rename to implementations/python/src/esque/migration/__init__.py diff --git a/implementations/python/src/esque/migration/loader.py b/implementations/python/src/esque/migration/loader.py new file mode 100644 index 0000000..4436238 --- /dev/null +++ b/implementations/python/src/esque/migration/loader.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +import hashlib +import json +import re +import struct +from pathlib import Path +from typing import Any, cast + +import yaml + +from esque.migration.model import ( + MigrationFile, + MigrationFileContents, + MigrationFileMetadata, + MigrationFileRequestDefinition, +) +from esque.migration.template import MigrationTemplateResolver + +_FILE_NAME_PATTERN = re.compile(r"^V((\d+\.?)+)__(\w+)\.yml$") + + +class MigrationFileLoader: + def __init__(self, migration_directory: str, template_resolver: MigrationTemplateResolver) -> None: + self._migration_directory = migration_directory + self._template_resolver = template_resolver + + def load(self) -> list[MigrationFile]: + path = self._resolve_path(self._migration_directory) + raw_files: list[MigrationFile] = [] + for file_path in path.iterdir(): + if not file_path.is_file(): + continue + match = _FILE_NAME_PATTERN.match(file_path.name) + if not match: + continue + raw_files.append(self._read_raw(file_path, match)) + + raw_files.sort() + self._template_resolver.validate(raw_files) + return [self._resolve(f) for f in raw_files] + + def _resolve(self, file: MigrationFile) -> MigrationFile: + resolved_contents = self._template_resolver.resolve_contents(file.contents) + return MigrationFile( + metadata=MigrationFileMetadata( + filename=file.metadata.filename, + version=file.metadata.version, + description=file.metadata.description, + checksum=self.calculate_checksum(resolved_contents), + ), + contents=resolved_contents, + ) + + @staticmethod + def _resolve_path(directory: str) -> Path: + if directory.startswith("file:"): + return Path(directory.removeprefix("file:")) + raise ValueError(f"unsupported migration directory scheme in '{directory}'. supported schemes: 'file:'") + + @staticmethod + def _read_raw(path: Path, match: re.Match[str]) -> MigrationFile: + data: dict[str, Any] = yaml.safe_load(path.read_text()) + return MigrationFile( + metadata=MigrationFileMetadata( + filename=path.name, + version=match.group(1), + description=match.group(3), + checksum=0, + ), + contents=MigrationFileContents(requests=[MigrationFileLoader._parse_request(r) for r in data["requests"]]), + ) + + @staticmethod + def _parse_request(raw: dict[str, Any]) -> MigrationFileRequestDefinition: + return MigrationFileRequestDefinition( + method=str(raw["method"]), + path=str(raw["path"]), + content_type=str(raw["contentType"]) if "contentType" in raw else None, + params={str(k): str(v) for k, v in raw["params"].items()} if "params" in raw else None, + body=str(raw["body"]) if "body" in raw else None, + ) + + @staticmethod + def _remove_nulls(obj: Any) -> Any: + if isinstance(obj, dict): + d = cast(dict[str, Any], obj) + return {k: MigrationFileLoader._remove_nulls(v) for k, v in d.items() if v is not None} + if isinstance(obj, list): + lst = cast(list[Any], obj) + return [MigrationFileLoader._remove_nulls(item) for item in lst] + return obj + + @staticmethod + def calculate_checksum(contents: MigrationFileContents) -> int: + data = {"requests": [r.to_canonical_dict() for r in contents.requests]} + canonical = json.dumps( + MigrationFileLoader._remove_nulls(data), + sort_keys=True, + separators=(",", ":"), + ensure_ascii=False, + ).encode("utf-8") + digest = hashlib.md5(canonical, usedforsecurity=False).digest() + return struct.unpack(">i", digest[:4])[0] diff --git a/implementations/python/src/esque/migration/model.py b/implementations/python/src/esque/migration/model.py new file mode 100644 index 0000000..f4ac314 --- /dev/null +++ b/implementations/python/src/esque/migration/model.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + + +@dataclass +class MigrationFileRequestDefinition: + method: str + path: str + content_type: str | None = None + params: dict[str, str] | None = None + body: str | None = None + + def to_canonical_dict(self) -> dict[str, Any]: + d: dict[str, Any] = {"method": self.method, "path": self.path} + if self.body is not None: + d["body"] = self.body + if self.content_type is not None: + d["contentType"] = self.content_type + if self.params is not None: + d["params"] = self.params + return d + + +@dataclass +class MigrationFileMetadata: + filename: str + version: str + description: str + checksum: int = 0 + + +@dataclass +class MigrationFileContents: + requests: list[MigrationFileRequestDefinition] = field(default_factory=lambda: []) + + +@dataclass +class MigrationFile: + metadata: MigrationFileMetadata + contents: MigrationFileContents + + def _version_tuple(self) -> tuple[int, ...]: + return tuple(int(x) for x in self.metadata.version.split(".")) + + def __lt__(self, other: MigrationFile) -> bool: + a = self._version_tuple() + b = other._version_tuple() + max_len = max(len(a), len(b)) + a_p = a + (0,) * (max_len - len(a)) + b_p = b + (0,) * (max_len - len(b)) + return a_p < b_p if a_p != b_p else self.metadata.description < other.metadata.description + + def __le__(self, other: MigrationFile) -> bool: + return self == other or self < other + + def __gt__(self, other: MigrationFile) -> bool: + return not self <= other + + def __ge__(self, other: MigrationFile) -> bool: + return not self < other diff --git a/implementations/python/src/esque/migration/template.py b/implementations/python/src/esque/migration/template.py new file mode 100644 index 0000000..38af574 --- /dev/null +++ b/implementations/python/src/esque/migration/template.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +import re + +from esque.migration.model import MigrationFile, MigrationFileContents, MigrationFileRequestDefinition + +_PLACEHOLDER_PATTERN = re.compile(r"#\{([a-zA-Z0-9._\-]+)}") + + +class MigrationTemplateResolver: + def __init__(self, properties: dict[str, str]) -> None: + self._properties = properties + + def validate(self, files: list[MigrationFile]) -> None: + missing: set[str] = set() + for file in files: + for req in file.contents.requests: + texts = [req.path, req.content_type or "", req.body or "", *(req.params or {}).values()] + for text in texts: + for match in _PLACEHOLDER_PATTERN.finditer(text): + key = match.group(1) + if key not in self._properties: + missing.add(key) + if missing: + raise ValueError(f"migration files reference template variables with no matching properties: {missing}") + + def resolve(self, definition: MigrationFileRequestDefinition) -> MigrationFileRequestDefinition: + return MigrationFileRequestDefinition( + method=definition.method, + path=self._substitute(definition.path), + content_type=self._substitute(definition.content_type) if definition.content_type else None, + params={k: self._substitute(v) for k, v in definition.params.items()} if definition.params else None, + body=self._substitute(definition.body) if definition.body else None, + ) + + def resolve_contents(self, contents: MigrationFileContents) -> MigrationFileContents: + return MigrationFileContents(requests=[self.resolve(r) for r in contents.requests]) + + def _substitute(self, text: str) -> str: + def replace(match: re.Match[str]) -> str: + key = match.group(1) + if key not in self._properties: + raise ValueError(f"unresolved template variable '#{{{key}}}' — was validate() called?") + return self._properties[key] + + return _PLACEHOLDER_PATTERN.sub(replace, text) diff --git a/implementations/python/tests/test_checksum.py b/implementations/python/tests/test_checksum.py new file mode 100644 index 0000000..3e985d8 --- /dev/null +++ b/implementations/python/tests/test_checksum.py @@ -0,0 +1,45 @@ +from esque.migration.loader import MigrationFileLoader +from esque.migration.model import MigrationFileContents, MigrationFileRequestDefinition + + +def _checksum(requests: list[MigrationFileRequestDefinition]) -> int: + return MigrationFileLoader.calculate_checksum(MigrationFileContents(requests=requests)) + + +def test_result_is_signed_32bit_int() -> None: + requests = [MigrationFileRequestDefinition(method="PUT", path="/test-index")] + result = _checksum(requests) + assert isinstance(result, int) + assert -(2**31) <= result <= 2**31 - 1 + + +def test_deterministic() -> None: + requests = [MigrationFileRequestDefinition(method="PUT", path="/test", body='{"settings": {}}')] + assert _checksum(requests) == _checksum(requests) + + +def test_null_fields_excluded() -> None: + r1 = MigrationFileRequestDefinition(method="PUT", path="/index") + r2 = MigrationFileRequestDefinition(method="PUT", path="/index", body=None, content_type=None, params=None) + assert _checksum([r1]) == _checksum([r2]) + + +def test_different_content_differs() -> None: + r1 = [MigrationFileRequestDefinition(method="PUT", path="/index-a")] + r2 = [MigrationFileRequestDefinition(method="PUT", path="/index-b")] + assert _checksum(r1) != _checksum(r2) + + +def test_key_order_is_canonical() -> None: + r = MigrationFileRequestDefinition(method="PUT", path="/index", body="data", content_type="application/json") + result = _checksum([r]) + assert isinstance(result, int) + assert _checksum([r]) == result + + +def test_multiple_requests() -> None: + r1 = MigrationFileRequestDefinition(method="PUT", path="/index") + r2 = MigrationFileRequestDefinition(method="POST", path="/_aliases", body="{}") + combined = _checksum([r1, r2]) + assert combined != _checksum([r1]) + assert combined != _checksum([r2]) diff --git a/implementations/python/tests/test_integrity.py b/implementations/python/tests/test_integrity.py new file mode 100644 index 0000000..e6a6ff6 --- /dev/null +++ b/implementations/python/tests/test_integrity.py @@ -0,0 +1,100 @@ +import dataclasses +from datetime import UTC, datetime +from unittest.mock import MagicMock + +import pytest + +from esque.configuration import EsqueConfiguration +from esque.elasticsearch.documents import MigrationRecord +from esque.esque import Esque +from esque.migration.model import MigrationFile, MigrationFileContents, MigrationFileMetadata + +_SENTINEL_DATE = datetime(2026, 1, 1, tzinfo=UTC) + + +def _esque(migration_key: str = "test-key") -> Esque: + return Esque( + client=MagicMock(), + configuration=EsqueConfiguration(migration_key=migration_key), + ) + + +def _file(version: str, description: str = "Test", checksum: int = 42) -> MigrationFile: + return MigrationFile( + metadata=MigrationFileMetadata( + filename=f"V{version}__{description}.yml", + version=version, + description=description, + checksum=checksum, + ), + contents=MigrationFileContents(), + ) + + +def _record(file: MigrationFile, order: int, migration_key: str = "test-key") -> MigrationRecord: + return MigrationRecord( + migration_key=migration_key, + order=order, + filename=file.metadata.filename, + version=file.metadata.version, + description=file.metadata.description, + checksum=file.metadata.checksum, + installed_by=None, + installed_on=_SENTINEL_DATE, + execution_time=0, + ) + + +def test_passes_with_no_history() -> None: + _esque()._verify_state_integrity([_file("1.0.0"), _file("1.1.0")], []) + + +def test_passes_with_complete_matching_history() -> None: + f1, f2 = _file("1.0.0"), _file("1.1.0") + _esque()._verify_state_integrity([f1, f2], [_record(f1, 0), _record(f2, 1)]) + + +def test_passes_with_partial_history() -> None: + f1, f2 = _file("1.0.0"), _file("1.1.0") + _esque()._verify_state_integrity([f1, f2], [_record(f1, 0)]) + + +def test_raises_when_more_records_than_files() -> None: + f = _file("1.0.0") + with pytest.raises(RuntimeError, match="more migrations"): + _esque()._verify_state_integrity([], [_record(f, 0)]) + + +def test_raises_on_gap_in_history() -> None: + f1, f2, f3 = _file("1.0.0"), _file("1.1.0"), _file("1.2.0") + gap_record = dataclasses.replace(_record(f3, 2)) + with pytest.raises(RuntimeError, match="corrupt"): + _esque()._verify_state_integrity([f1, f2, f3], [_record(f1, 0), gap_record]) + + +def test_raises_on_checksum_mismatch() -> None: + f = _file("1.0.0", checksum=42) + bad_record = dataclasses.replace(_record(f, 0), checksum=999) + with pytest.raises(RuntimeError, match="integrity"): + _esque()._verify_state_integrity([f], [bad_record]) + + +def test_raises_on_version_mismatch() -> None: + f = _file("1.0.0") + bad_record = dataclasses.replace(_record(f, 0), version="9.9.9") + with pytest.raises(RuntimeError, match="integrity"): + _esque()._verify_state_integrity([f], [bad_record]) + + +def test_raises_on_description_mismatch() -> None: + f = _file("1.0.0", description="Original") + bad_record = dataclasses.replace(_record(f, 0), description="Modified") + with pytest.raises(RuntimeError, match="integrity"): + _esque()._verify_state_integrity([f], [bad_record]) + + +def test_raises_when_file_missing_for_record() -> None: + f = _file("1.0.0") + orphan = _record(_file("1.0.0", description="Ghost"), 0) + with pytest.raises(RuntimeError, match="could not find"): + _esque()._verify_state_integrity([f], [orphan]) diff --git a/implementations/python/tests/test_model.py b/implementations/python/tests/test_model.py new file mode 100644 index 0000000..a6522bb --- /dev/null +++ b/implementations/python/tests/test_model.py @@ -0,0 +1,37 @@ +from esque.migration.model import MigrationFile, MigrationFileContents, MigrationFileMetadata + + +def _file(version: str, description: str = "Test") -> MigrationFile: + return MigrationFile( + metadata=MigrationFileMetadata(filename=f"V{version}__{description}.yml", version=version, description=description), + contents=MigrationFileContents(), + ) + + +def test_numeric_segment_ordering() -> None: + assert _file("1.9.0") < _file("1.10.0") + + +def test_major_ordering() -> None: + assert _file("1.0.0") < _file("2.0.0") + + +def test_minor_ordering() -> None: + assert _file("1.0.0") < _file("1.1.0") + + +def test_patch_ordering() -> None: + assert _file("1.0.0") < _file("1.0.1") + + +def test_unequal_segment_count() -> None: + assert _file("1.0") < _file("1.0.1") + + +def test_not_less_than_self() -> None: + assert not (_file("1.0.0") < _file("1.0.0")) + + +def test_sort_order() -> None: + files = [_file("1.10.0"), _file("2.0.0"), _file("1.9.0"), _file("1.0.0")] + assert sorted(files) == [_file("1.0.0"), _file("1.9.0"), _file("1.10.0"), _file("2.0.0")] diff --git a/implementations/python/tests/test_template.py b/implementations/python/tests/test_template.py new file mode 100644 index 0000000..33f2c7f --- /dev/null +++ b/implementations/python/tests/test_template.py @@ -0,0 +1,84 @@ +import pytest + +from esque.migration.model import MigrationFile, MigrationFileContents, MigrationFileMetadata, MigrationFileRequestDefinition +from esque.migration.template import MigrationTemplateResolver + + +def _file(*reqs: MigrationFileRequestDefinition) -> MigrationFile: + return MigrationFile( + metadata=MigrationFileMetadata(filename="V1.0.0__Test.yml", version="1.0.0", description="Test"), + contents=MigrationFileContents(requests=list(reqs)), + ) + + +def test_validate_passes_when_all_vars_present() -> None: + req = MigrationFileRequestDefinition(method="PUT", path="/#{indexName}") + MigrationTemplateResolver({"indexName": "my-index"}).validate([_file(req)]) + + +def test_validate_raises_on_missing_var() -> None: + req = MigrationFileRequestDefinition(method="PUT", path="/#{missing}") + with pytest.raises(ValueError, match="missing"): + MigrationTemplateResolver({}).validate([_file(req)]) + + +def test_validate_collects_all_missing_vars() -> None: + req = MigrationFileRequestDefinition(method="PUT", path="/#{a}", body="#{b}") + with pytest.raises(ValueError) as exc_info: + MigrationTemplateResolver({}).validate([_file(req)]) + msg = str(exc_info.value) + assert "a" in msg + assert "b" in msg + + +def test_validate_checks_body_params_and_content_type() -> None: + req = MigrationFileRequestDefinition( + method="POST", + path="/", + content_type="#{ct}", + params={"k": "#{v}"}, + body="#{body}", + ) + with pytest.raises(ValueError) as exc_info: + MigrationTemplateResolver({}).validate([_file(req)]) + msg = str(exc_info.value) + assert "ct" in msg + assert "v" in msg + assert "body" in msg + + +def test_resolve_substitutes_path() -> None: + req = MigrationFileRequestDefinition(method="PUT", path="/#{indexName}") + result = MigrationTemplateResolver({"indexName": "my-index"}).resolve(req) + assert result.path == "/my-index" + + +def test_resolve_substitutes_body() -> None: + req = MigrationFileRequestDefinition(method="POST", path="/", body='{"index": "#{name}"}') + result = MigrationTemplateResolver({"name": "test"}).resolve(req) + assert result.body == '{"index": "test"}' + + +def test_resolve_substitutes_params() -> None: + req = MigrationFileRequestDefinition(method="GET", path="/", params={"q": "#{query}"}) + result = MigrationTemplateResolver({"query": "value"}).resolve(req) + assert result.params == {"q": "value"} + + +def test_resolve_substitutes_content_type() -> None: + req = MigrationFileRequestDefinition(method="PUT", path="/", content_type="#{ct}") + result = MigrationTemplateResolver({"ct": "application/json"}).resolve(req) + assert result.content_type == "application/json" + + +def test_resolve_does_not_substitute_method() -> None: + req = MigrationFileRequestDefinition(method="PUT", path="/index") + result = MigrationTemplateResolver({}).resolve(req) + assert result.method == "PUT" + + +def test_resolve_handles_no_template_vars() -> None: + req = MigrationFileRequestDefinition(method="DELETE", path="/index", body='{"key": "value"}') + result = MigrationTemplateResolver({}).resolve(req) + assert result.path == "/index" + assert result.body == '{"key": "value"}' diff --git a/implementations/python/uv.lock b/implementations/python/uv.lock new file mode 100644 index 0000000..c3ade62 --- /dev/null +++ b/implementations/python/uv.lock @@ -0,0 +1,315 @@ +version = 1 +revision = 3 +requires-python = ">=3.14" + +[[package]] +name = "anyio" +version = "4.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, +] + +[[package]] +name = "certifi" +version = "2026.5.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" }, +] + +[[package]] +name = "click" +version = "8.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/98/518d8e5081007684232226f475082b30087d0f585e8457db087298259f49/click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96", size = 353007, upload-time = "2026-05-22T04:08:37.769Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", size = 116639, upload-time = "2026-05-22T04:08:35.26Z" }, +] + +[[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 = "elastic-transport" +version = "9.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "sniffio" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0f/a8/dd1431b95daa54d4859c1d250362433df6df9cfaab7bfac3eafd410d019a/elastic_transport-9.4.1.tar.gz", hash = "sha256:d12c86ea73528690ebf63a488d9ae323292e6aa5ee55e1e29f14293472f4197f", size = 79170, upload-time = "2026-05-25T14:16:47.416Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/f6/508c1e541b840b6eaf60a46a8994ba55affd07bf6a311cffc860781ae06a/elastic_transport-9.4.1-py3-none-any.whl", hash = "sha256:186a29e6c66ff269487e33f7b17176316e18b6061702c25eb0bb15681302e91d", size = 66229, upload-time = "2026-05-25T14:16:45.882Z" }, +] + +[[package]] +name = "elasticsearch" +version = "9.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "elastic-transport" }, + { name = "python-dateutil" }, + { name = "sniffio" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e0/4b/9b753f0a8f56ae508dced2f7ac87bef7a27ce8f890e349e16812e9f7f4fa/elasticsearch-9.4.1.tar.gz", hash = "sha256:1d78fdfba97a903ec35a5eb5808a74e33392b7c620bd5f742d465a3a26c27d75", size = 908138, upload-time = "2026-05-26T16:28:40.132Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cf/8e/2c93805e93e724a90156004a9212572ec86473974deede4605a33b8b169a/elasticsearch-9.4.1-py3-none-any.whl", hash = "sha256:71ab71c3d1b20fd88c2922fb82c3277cce7ea03c160686e7b9368b265c2b4cac", size = 993647, upload-time = "2026-05-26T16:28:36.556Z" }, +] + +[[package]] +name = "esque-py" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "click" }, + { name = "elasticsearch" }, + { name = "pyyaml" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pyright" }, + { name = "pytest" }, + { name = "ruff" }, + { name = "ty" }, +] + +[package.metadata] +requires-dist = [ + { name = "click", specifier = ">=8.1.0" }, + { name = "elasticsearch", specifier = ">=9.0.0" }, + { name = "pyyaml", specifier = ">=6.0.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pyright", specifier = ">=1.1.0" }, + { name = "pytest", specifier = ">=8.0.0" }, + { name = "ruff", specifier = ">=0.11.0" }, + { name = "ty", specifier = ">=0.0.0a6" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[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 = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[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 = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pyright" +version = "1.1.410" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodeenv" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/10/53/e4d8ea1391bd4355231be6f91bf239479aa0014260ed3fb5526eeb12a1f2/pyright-1.1.410.tar.gz", hash = "sha256:07a073b8ba6749826773c1269773efa11b93440d9a6aa60419d9a3172d6dc488", size = 4062013, upload-time = "2026-06-01T17:35:48.894Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/33/288b5868fa00846dacf249633719d747893e54aebd196b9968ac1878a5d3/pyright-1.1.410-py3-none-any.whl", hash = "sha256:5e961bed37cacf96b3f7cd7b1da39b350a9239aa2e69138d0e88f728cfaf296c", size = 6082448, upload-time = "2026-06-01T17:35:46.387Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.0" +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/84/0e/b5858858d74958632c49b72cb25a3976ff9f632397626715be71c89d3971/pytest-9.1.0.tar.gz", hash = "sha256:41dd9148c08072446394cefd3d79701701335a9f4cae69ba92e39f6c7f5c061c", size = 1634181, upload-time = "2026-06-13T18:52:45.983Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/5a/ba30a81239b909821b3153e303e7def45178bf353da4f72380e6c5e8793b/pytest-9.1.0-py3-none-any.whl", hash = "sha256:8ebb0e7888bdf2bdfc602ec51f8f62d50200af37356c74e503c79a94f5c81f32", size = 386453, upload-time = "2026-06-13T18:52:44.045Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[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/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 = "ruff" +version = "0.15.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/a9/3abdf488f1bf3d24c699415e454ed554a6350d5d89ce183be1ee0a3361ac/ruff-0.15.17.tar.gz", hash = "sha256:2ec446937fd16c8c4de2674a209cc5af64d9c6f17d21fbf1151054fa0bcf5219", size = 4743346, upload-time = "2026-06-11T17:54:47.663Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/4d/e11259f5da07cb6afb2d074c31bf09da9671993f7329d4f15d2fdc458301/ruff-0.15.17-py3-none-linux_armv6l.whl", hash = "sha256:d9feddb927fc68bd295f5eebc587a7e42cfaf9b65f60ca4a2386febff575da8f", size = 10856677, upload-time = "2026-06-11T17:54:49.533Z" }, + { url = "https://files.pythonhosted.org/packages/29/3e/772d679e1a0dc058e58875bd2c0cb713a0530877b4a76fee3c7966df0d49/ruff-0.15.17-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:25805a226d741c47d274a35ad5c10a7dde175fcddfa511d7cf3da0a21eb3eab7", size = 11223443, upload-time = "2026-06-11T17:55:00.573Z" }, + { url = "https://files.pythonhosted.org/packages/68/58/bd41f7688b2fd5623012605130ed70e60aa7f2244baa3d5066bdd61530c8/ruff-0.15.17-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f6ad73b14c2d18a3bf8ad7cb6974294d7f613a7898604826058e6ac64918ef4d", size = 10566458, upload-time = "2026-06-11T17:55:07.52Z" }, + { url = "https://files.pythonhosted.org/packages/d8/5b/733371013fcf1ec339e477ece6ab42bfe10bdd9bba8ee88a9516aa56bfc0/ruff-0.15.17-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ba0c1e4f95bcb3869d0d30cbd5917071ef2e28665abfec970cdab0492c713ed", size = 10914483, upload-time = "2026-06-11T17:55:05.501Z" }, + { url = "https://files.pythonhosted.org/packages/bd/cc/6f24251cc0252f7239391ccb85833f320efad14ebe5b443943f37ced6332/ruff-0.15.17-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:81647960f10bff57d2e51cadd0c3950fe598400c852863a038720ef5b8cca91e", size = 10647497, upload-time = "2026-06-11T17:54:57.733Z" }, + { url = "https://files.pythonhosted.org/packages/68/dd/0d10c17ce1a1624d6fc3156309c3f834fdb5dfaad026ec90c85684f3990e/ruff-0.15.17-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0e01a84ddbc8c16c23055ba3924476850f1bbc1917cebbb9376665a63e74260d", size = 11416967, upload-time = "2026-06-11T17:54:51.461Z" }, + { url = "https://files.pythonhosted.org/packages/2f/91/556bfb156f6144f355e831c23db00b2fc4120f86b3ce81cc5f7fd2df51f3/ruff-0.15.17-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:84fe9f653152f8f294f9f7e03bf3a453d8b4a27f7a59c78c8666167f2b17b96c", size = 12335770, upload-time = "2026-06-11T17:54:45.793Z" }, + { url = "https://files.pythonhosted.org/packages/88/82/8b5999aa13355e926f06d9f42a32dcca862f623bf0363785ff89d607dffd/ruff-0.15.17-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c0fe88a7676e7a05b73174d4d4a59cb2ac21ff8263583f87a81a6018475a978", size = 11575441, upload-time = "2026-06-11T17:54:32.661Z" }, + { url = "https://files.pythonhosted.org/packages/11/93/f10377bb04109ca0e8cbc483ff1982c54b6d418210041776f93e8cdc7fa9/ruff-0.15.17-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecfc3c7878fff94633ab0348524e093f9ce3243080416dd7d14f8ba400174719", size = 11557614, upload-time = "2026-06-11T17:54:34.698Z" }, + { url = "https://files.pythonhosted.org/packages/c7/a6/eeeae7f7d5493df41649ab3db92f086b2d0a30199e4efdf8e3dd7a033f24/ruff-0.15.17-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:b8461180b22420b1bdc289909410930761629fddf2a5aaf60fae1ab26cedc4c4", size = 11544450, upload-time = "2026-06-11T17:54:39.042Z" }, + { url = "https://files.pythonhosted.org/packages/32/88/5991ce565129a24dd4a00db1254b3b5db2e53018cbe4018ea5a89738e727/ruff-0.15.17-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6eccbe50a038b503e7140b441aa9c7fc8c1f36edf23ebef9f4165c2f28f568b7", size = 10892524, upload-time = "2026-06-11T17:55:09.432Z" }, + { url = "https://files.pythonhosted.org/packages/f5/1d/0fdd248313425f55223968af04b0a42125466a8d88d21c1d99c6af0a51e8/ruff-0.15.17-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:382fc0521025f5a8ad447d8bdd523545d0d7646adb718eb1c2dac5065ec27c0f", size = 10659573, upload-time = "2026-06-11T17:54:36.824Z" }, + { url = "https://files.pythonhosted.org/packages/9e/0e/072e8260deb9461062ce9311ced27a8e541229a6ffd483013dd37661e43e/ruff-0.15.17-py3-none-musllinux_1_2_i686.whl", hash = "sha256:456d41fcd1b2777ad63f09a6e7121d43f7b688bbc76a800c10f7f8fb1f912c3f", size = 11127818, upload-time = "2026-06-11T17:55:03.124Z" }, + { url = "https://files.pythonhosted.org/packages/ab/b4/55060a34163121498014696b5f656db5b8c6963768f227dbf0d76b311073/ruff-0.15.17-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b1a04bcc94ae6194e9db05d16ad31f298a7194bfbcb08258bbe589cee1d587b8", size = 11655901, upload-time = "2026-06-11T17:54:53.562Z" }, + { url = "https://files.pythonhosted.org/packages/49/71/9b29d6b87cef468d697f43c6a91e3fae4a80185779d7d5a4ef27d173439f/ruff-0.15.17-py3-none-win32.whl", hash = "sha256:596065960ab1ff593f744220c9fe6580eda00a95003cffa9f4048bb5b1bf0392", size = 10925574, upload-time = "2026-06-11T17:54:55.723Z" }, + { url = "https://files.pythonhosted.org/packages/3d/b2/8fc77f3723228836fa5d12497eb71c808f83782e10d058d2b15cfa14640b/ruff-0.15.17-py3-none-win_amd64.whl", hash = "sha256:6769e5fa1710b179b92e0bfa5a51735b35baea9013dadb06d5f44cbcf9547084", size = 12058788, upload-time = "2026-06-11T17:54:41.042Z" }, + { url = "https://files.pythonhosted.org/packages/2d/c7/c53e8dbff9c9dc4b7928773421ae294a5d28fcb8dcda1a089579d3a7e510/ruff-0.15.17-py3-none-win_arm64.whl", hash = "sha256:f3be1fbb34bcdfd146240d8fb92a709d4c2c8191348580a3c044ec60fa0b4456", size = 11355275, upload-time = "2026-06-11T17:54:43.635Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "ty" +version = "0.0.49" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/8d/37cb91808069509d43a2a11743e12f1e854fd808dbef2203309d256718cd/ty-0.0.49.tar.gz", hash = "sha256:0a027bd0c9c75d035641a365d087ad883446057f9be0b9826251c2aecafbf145", size = 5884753, upload-time = "2026-06-12T03:08:20.221Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/de/9237c6a96356612dd0393db1e94cf21f903616adf3a3701bf3da6e4adc92/ty-0.0.49-py3-none-linux_armv6l.whl", hash = "sha256:12c0c4310b936d762a8586c210b53d4fa4bb361a04429afa89bf84b922e5e065", size = 11834671, upload-time = "2026-06-12T03:07:53.062Z" }, + { url = "https://files.pythonhosted.org/packages/8f/15/daf5a14a5e07012277d450c75325c94614e2acfec4c620c881486118c410/ty-0.0.49-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:737bfdc2caf9712a8580944dcdc80a450a37a4f2bc83c8fa9b7433b374f9e471", size = 11589570, upload-time = "2026-06-12T03:08:25.779Z" }, + { url = "https://files.pythonhosted.org/packages/7d/58/30bdf98436488aca25f0763bf7f92a061528d42461b686453029e845e4c5/ty-0.0.49-py3-none-macosx_11_0_arm64.whl", hash = "sha256:ab90c1baf3b1701d282fce4b02fa552a962d109f8972c46ef6b22429503bfea4", size = 10985236, upload-time = "2026-06-12T03:08:36.664Z" }, + { url = "https://files.pythonhosted.org/packages/22/45/ece503e4a1396e13a1a9a0cde51afe476a6506a1d557eeadf8ad45c83bc0/ty-0.0.49-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b4ce8ecf6ba6fc79bd137cc0557a754f7e5f2dfe9436412551d480d680e248ad", size = 11504302, upload-time = "2026-06-12T03:08:01.664Z" }, + { url = "https://files.pythonhosted.org/packages/17/dc/5d09333d289dfbca1804eaade125c9e8a1a992a2a592a8b80c5e9b589ca9/ty-0.0.49-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:10d85c6865c984e78661e0bd20b180514b4a289739224e84816e342bdf381e04", size = 11626629, upload-time = "2026-06-12T03:08:06.844Z" }, + { url = "https://files.pythonhosted.org/packages/f2/36/155f41c9dd7237c4b609211f29f77755a139ee6218605dadc7fe21d5e3c8/ty-0.0.49-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d96a67a206619e01fa92f35a22267ec634bba62be24b1d0e947020cc179995b", size = 12074481, upload-time = "2026-06-12T03:08:09.643Z" }, + { url = "https://files.pythonhosted.org/packages/96/4c/998ee13cd5045f1f8b36982de7343163832ac53f27debe01b0de0e8bd968/ty-0.0.49-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3de9f648564e0a66344ef397770387cb0d093735f8679d2c5a08a4741e79814d", size = 12678042, upload-time = "2026-06-12T03:08:39.319Z" }, + { url = "https://files.pythonhosted.org/packages/85/c9/9a505aba85c41ce54cbcaa14f8d79aa084b86151d2d70df11c4655b92898/ty-0.0.49-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5779179ab397d15f8c9dbb8f506ec1b1745f54eac639982f76ef3ce538943b50", size = 12316194, upload-time = "2026-06-12T03:08:18.023Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b8/ded37fb93503294abbc83c36470bb1413bea05048b745881d4470b518a06/ty-0.0.49-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:792d4974e93cc09bd32f934586080bbbe21b8e777099cb521cb2de18b68a49f0", size = 12145507, upload-time = "2026-06-12T03:07:56.505Z" }, + { url = "https://files.pythonhosted.org/packages/2f/07/392e80d78f02445f695b815bb9eb0fffacda68b03faee38c900f7b990815/ty-0.0.49-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:727bda86deb136073e525c2e78d60e38aedcce5d80579170844a52bbf7c1440d", size = 12365967, upload-time = "2026-06-12T03:08:12.553Z" }, + { url = "https://files.pythonhosted.org/packages/50/d3/31b0c2a7fbedd3373e389cb1d81b8d2128f6f868fafb46557736a6f9aca8/ty-0.0.49-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4f2fc2bc4a8d2ff1cca59fd94772cabdfec4062d47a0b3a0784be46d94d0540b", size = 11475283, upload-time = "2026-06-12T03:08:28.334Z" }, + { url = "https://files.pythonhosted.org/packages/5a/5b/329e101638920b468a3bb63059c9f66ef99b44aac501222c44832a507321/ty-0.0.49-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:3724bd9badef333321578b6a941fbc571ebf49141ec2356a8590fbe4c9aa588d", size = 11645343, upload-time = "2026-06-12T03:08:15.246Z" }, + { url = "https://files.pythonhosted.org/packages/a9/76/c897e615e32f80ca81c8c1bc49b9a1f72ff9e3cfea0f8345ba505fe28472/ty-0.0.49-py3-none-musllinux_1_2_i686.whl", hash = "sha256:166c6eb52ee4af3c5a9bb267d165d93000daa55c6758cd8ff3199741fb75917d", size = 11725585, upload-time = "2026-06-12T03:08:33.915Z" }, + { url = "https://files.pythonhosted.org/packages/59/e1/fdb42ee239f618800842681af5bb8598117e74512c10974a8b7b9086a898/ty-0.0.49-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:91e81d832c287b05782ee32eb1b801f62c1fa08df37d589d2b88c3f1d51c9731", size = 12237261, upload-time = "2026-06-12T03:08:31.105Z" }, + { url = "https://files.pythonhosted.org/packages/98/0f/a2d6a5fc9d0786cbeb3c200786da4e18c203589be3984bb5def83ca92320/ty-0.0.49-py3-none-win32.whl", hash = "sha256:7186af5ca9829d1f5d8916bcf767b8e819bfbf61b1b8ec843bb3fc699cb502e1", size = 11100789, upload-time = "2026-06-12T03:07:59.092Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9d/473ac8bc57b5a2d121da893bf9dd74a118efb19a01d711df1a6e397f05cc/ty-0.0.49-py3-none-win_amd64.whl", hash = "sha256:ae2142fc126a01effcca0c222908b0e6654b5ba1266d4e4d406e4866aef8e1d1", size = 12204644, upload-time = "2026-06-12T03:08:04.327Z" }, + { url = "https://files.pythonhosted.org/packages/ef/a2/8959249da951ba3977fee20e688d28678b8a1d30a9ed4464228a85d45853/ty-0.0.49-py3-none-win_arm64.whl", hash = "sha256:75d5e2e7649765f31f4bed6c8adb149a75b18edd3fa6336dac4d0efc1a66466f", size = 11558965, upload-time = "2026-06-12T03:08:23.012Z" }, +] + +[[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 = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] diff --git a/settings.gradle.kts b/settings.gradle.kts deleted file mode 100644 index 070b375..0000000 --- a/settings.gradle.kts +++ /dev/null @@ -1,6 +0,0 @@ -rootProject.name = "esque" - -include("esque-core") -include("esque-examples:esque-example-core-simple") -include("esque-examples:esque-example-core-es-auth") -include("esque-examples:esque-example-core-aws-auth") diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..b7890d7 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,31 @@ +from collections.abc import Iterator + +import httpx +import pytest +from testcontainers.elasticsearch import ElasticSearchContainer + +ES_IMAGE = "docker.elastic.co/elasticsearch/elasticsearch:9.3.0" + + +@pytest.fixture(scope="session") +def es_url() -> Iterator[str]: + with ( + ElasticSearchContainer(ES_IMAGE) + .with_env("xpack.security.enabled", "false") + .with_env("action.destructive_requires_name", "false") + .with_env("ES_JAVA_OPTS", "-Xms512m -Xmx512m") + .with_env("xpack.ml.enabled", "false") + .with_env("node.store.allow_mmap", "false") + ) as es: + host = es.get_container_host_ip() + port = es.get_exposed_port(9200) + yield f"http://{host}:{port}" + + +@pytest.fixture(autouse=True) +def clean_es(es_url: str) -> None: + for pattern in ["/.esque", "/test-*"]: + try: + httpx.delete(f"{es_url}{pattern}", timeout=10) + except Exception: + pass diff --git a/tests/fixtures/integrity-missing/V1.0.0__CreateFirstIndex.yml b/tests/fixtures/integrity-missing/V1.0.0__CreateFirstIndex.yml new file mode 100644 index 0000000..504ebe2 --- /dev/null +++ b/tests/fixtures/integrity-missing/V1.0.0__CreateFirstIndex.yml @@ -0,0 +1,5 @@ +--- +requests: + - method: "PUT" + path: "/test-index-v1" + contentType: application/json; charset=utf-8 diff --git a/tests/fixtures/integrity-missing/V1.1.0__CreateSecondIndex.yml b/tests/fixtures/integrity-missing/V1.1.0__CreateSecondIndex.yml new file mode 100644 index 0000000..2fffbcb --- /dev/null +++ b/tests/fixtures/integrity-missing/V1.1.0__CreateSecondIndex.yml @@ -0,0 +1,5 @@ +--- +requests: + - method: "PUT" + path: "/test-index-v2" + contentType: application/json; charset=utf-8 diff --git a/tests/fixtures/integrity-modified/V1.0.0__CreateFirstIndex.yml b/tests/fixtures/integrity-modified/V1.0.0__CreateFirstIndex.yml new file mode 100644 index 0000000..80ee7eb --- /dev/null +++ b/tests/fixtures/integrity-modified/V1.0.0__CreateFirstIndex.yml @@ -0,0 +1,5 @@ +--- +requests: + - method: "PUT" + path: "/test-index-v1-modified" + contentType: application/json; charset=utf-8 diff --git a/tests/fixtures/integrity-modified/V1.1.0__CreateSecondIndex.yml b/tests/fixtures/integrity-modified/V1.1.0__CreateSecondIndex.yml new file mode 100644 index 0000000..2fffbcb --- /dev/null +++ b/tests/fixtures/integrity-modified/V1.1.0__CreateSecondIndex.yml @@ -0,0 +1,5 @@ +--- +requests: + - method: "PUT" + path: "/test-index-v2" + contentType: application/json; charset=utf-8 diff --git a/tests/fixtures/integrity-modified/V2.0.0__CreateThirdIndex.yml b/tests/fixtures/integrity-modified/V2.0.0__CreateThirdIndex.yml new file mode 100644 index 0000000..7796f8a --- /dev/null +++ b/tests/fixtures/integrity-modified/V2.0.0__CreateThirdIndex.yml @@ -0,0 +1,5 @@ +--- +requests: + - method: "PUT" + path: "/test-index-v3" + contentType: application/json; charset=utf-8 diff --git a/tests/fixtures/ordering/V1.10.0__TenthMinor.yml b/tests/fixtures/ordering/V1.10.0__TenthMinor.yml new file mode 100644 index 0000000..afb7794 --- /dev/null +++ b/tests/fixtures/ordering/V1.10.0__TenthMinor.yml @@ -0,0 +1,5 @@ +--- +requests: + - method: "PUT" + path: "/test-index-minor-10" + contentType: application/json; charset=utf-8 diff --git a/tests/fixtures/ordering/V1.9.0__NinthMinor.yml b/tests/fixtures/ordering/V1.9.0__NinthMinor.yml new file mode 100644 index 0000000..8a2bbae --- /dev/null +++ b/tests/fixtures/ordering/V1.9.0__NinthMinor.yml @@ -0,0 +1,5 @@ +--- +requests: + - method: "PUT" + path: "/test-index-minor-9" + contentType: application/json; charset=utf-8 diff --git a/tests/fixtures/single/V1.0.0__CreateFirstIndex.yml b/tests/fixtures/single/V1.0.0__CreateFirstIndex.yml new file mode 100644 index 0000000..504ebe2 --- /dev/null +++ b/tests/fixtures/single/V1.0.0__CreateFirstIndex.yml @@ -0,0 +1,5 @@ +--- +requests: + - method: "PUT" + path: "/test-index-v1" + contentType: application/json; charset=utf-8 diff --git a/tests/fixtures/standard/V1.0.0__CreateFirstIndex.yml b/tests/fixtures/standard/V1.0.0__CreateFirstIndex.yml new file mode 100644 index 0000000..504ebe2 --- /dev/null +++ b/tests/fixtures/standard/V1.0.0__CreateFirstIndex.yml @@ -0,0 +1,5 @@ +--- +requests: + - method: "PUT" + path: "/test-index-v1" + contentType: application/json; charset=utf-8 diff --git a/tests/fixtures/standard/V1.1.0__CreateSecondIndex.yml b/tests/fixtures/standard/V1.1.0__CreateSecondIndex.yml new file mode 100644 index 0000000..2fffbcb --- /dev/null +++ b/tests/fixtures/standard/V1.1.0__CreateSecondIndex.yml @@ -0,0 +1,5 @@ +--- +requests: + - method: "PUT" + path: "/test-index-v2" + contentType: application/json; charset=utf-8 diff --git a/tests/fixtures/standard/V2.0.0__CreateThirdIndex.yml b/tests/fixtures/standard/V2.0.0__CreateThirdIndex.yml new file mode 100644 index 0000000..7796f8a --- /dev/null +++ b/tests/fixtures/standard/V2.0.0__CreateThirdIndex.yml @@ -0,0 +1,5 @@ +--- +requests: + - method: "PUT" + path: "/test-index-v3" + contentType: application/json; charset=utf-8 diff --git a/tests/fixtures/templated/V1.0.0__CreateFirstIndex.yml b/tests/fixtures/templated/V1.0.0__CreateFirstIndex.yml new file mode 100644 index 0000000..504ebe2 --- /dev/null +++ b/tests/fixtures/templated/V1.0.0__CreateFirstIndex.yml @@ -0,0 +1,5 @@ +--- +requests: + - method: "PUT" + path: "/test-index-v1" + contentType: application/json; charset=utf-8 diff --git a/tests/fixtures/templated/V1.1.0__CreateSecondIndex.yml b/tests/fixtures/templated/V1.1.0__CreateSecondIndex.yml new file mode 100644 index 0000000..2fffbcb --- /dev/null +++ b/tests/fixtures/templated/V1.1.0__CreateSecondIndex.yml @@ -0,0 +1,5 @@ +--- +requests: + - method: "PUT" + path: "/test-index-v2" + contentType: application/json; charset=utf-8 diff --git a/tests/fixtures/templated/V2.0.0__CreateThirdIndex.yml b/tests/fixtures/templated/V2.0.0__CreateThirdIndex.yml new file mode 100644 index 0000000..7796f8a --- /dev/null +++ b/tests/fixtures/templated/V2.0.0__CreateThirdIndex.yml @@ -0,0 +1,5 @@ +--- +requests: + - method: "PUT" + path: "/test-index-v3" + contentType: application/json; charset=utf-8 diff --git a/tests/fixtures/templated/V3.0.0__CreateTemplatedIndex.yml b/tests/fixtures/templated/V3.0.0__CreateTemplatedIndex.yml new file mode 100644 index 0000000..9a8bc5f --- /dev/null +++ b/tests/fixtures/templated/V3.0.0__CreateTemplatedIndex.yml @@ -0,0 +1,5 @@ +--- +requests: + - method: "PUT" + path: "/#{indexName}" + contentType: application/json; charset=utf-8 diff --git a/tests/helpers.py b/tests/helpers.py new file mode 100644 index 0000000..65ecad8 --- /dev/null +++ b/tests/helpers.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +import subprocess +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +import httpx +import yaml + +ROOT_DIR = Path(__file__).parent.parent + +STANDARD_MIGRATIONS = ROOT_DIR / "tests" / "fixtures" / "standard" +TEMPLATED_MIGRATIONS = ROOT_DIR / "tests" / "fixtures" / "templated" +SINGLE_MIGRATION = ROOT_DIR / "tests" / "fixtures" / "single" +ORDERING_MIGRATIONS = ROOT_DIR / "tests" / "fixtures" / "ordering" +INTEGRITY_MODIFIED_MIGRATIONS = ROOT_DIR / "tests" / "fixtures" / "integrity-modified" +INTEGRITY_MISSING_MIGRATIONS = ROOT_DIR / "tests" / "fixtures" / "integrity-missing" + + +@dataclass +class Implementation: + name: str + invocation: str + task: str | None = None + gradle_dir: str | None = None + command: list[str] = field(default_factory=lambda: []) + + +def all_implementations() -> list[Implementation]: + config_path = ROOT_DIR / "tests" / "implementations.yml" + config: dict[str, Any] = yaml.safe_load(config_path.read_text()) + return [Implementation(name=name, **cfg) for name, cfg in config["implementations"].items()] + + +def run( + impl: Implementation, + es_url: str, + key: str, + migrations_dir: Path, + user: str | None = None, + properties: dict[str, str] | None = None, +) -> subprocess.CompletedProcess[str]: + esque_args = [ + f"--es-url={es_url}", + f"--migrations-dir={migrations_dir}", + f"--migration-key={key}", + ] + if user: + esque_args.append(f"--migration-user={user}") + if properties: + for k, v in properties.items(): + esque_args.append(f"--property={k}={v}") + + if impl.invocation == "gradle": + if impl.task is None: + raise ValueError("Gradle implementation missing 'task' configuration") + args_str = " ".join(esque_args) + cmd = ["./gradlew", impl.task, f"--args={args_str}"] + cwd = ROOT_DIR / impl.gradle_dir if impl.gradle_dir else ROOT_DIR + elif impl.invocation == "direct": + cmd = [*impl.command, *esque_args] + cwd = ROOT_DIR + else: + raise ValueError(f"Unknown invocation type: {impl.invocation}") + + return subprocess.run( + cmd, + capture_output=True, + text=True, + cwd=cwd, + timeout=300, + ) + + +def get_records(es_url: str, key: str) -> list[dict[str, Any]]: + try: + response = httpx.post( + f"{es_url}/.esque/_search", + json={"query": {"bool": {"filter": [{"term": {"migration.migrationKey": key}}]}}}, + timeout=10, + ) + if response.status_code == 404: + return [] + response.raise_for_status() + except Exception: + return [] + + hits: list[dict[str, Any]] = response.json()["hits"]["hits"] + records = [hit["_source"]["migration"] for hit in hits] + return sorted(records, key=lambda r: r["order"]) + + +def assert_index_exists(es_url: str, index: str) -> None: + response = httpx.head(f"{es_url}/{index}", timeout=10) + assert response.status_code == 200, f"Expected index '{index}' to exist but got HTTP {response.status_code}" + + +def assert_index_absent(es_url: str, index: str) -> None: + response = httpx.head(f"{es_url}/{index}", timeout=10) + assert response.status_code == 404, f"Expected index '{index}' to be absent but got HTTP {response.status_code}" + + +def delete_indices(es_url: str, pattern: str) -> None: + try: + httpx.delete(f"{es_url}/{pattern}", timeout=10) + except Exception: + pass diff --git a/tests/implementations.yml b/tests/implementations.yml new file mode 100644 index 0000000..57b1739 --- /dev/null +++ b/tests/implementations.yml @@ -0,0 +1,8 @@ +implementations: + jvm: + invocation: gradle + gradle_dir: "implementations/jvm" + task: "run" + python: + invocation: direct + command: ["uv", "run", "--project", "implementations/python", "esque"] diff --git a/tests/pyproject.toml b/tests/pyproject.toml new file mode 100644 index 0000000..c9584a6 --- /dev/null +++ b/tests/pyproject.toml @@ -0,0 +1,61 @@ +[project] +name = "esque-compatibility-tests" +version = "0.1.0" +requires-python = ">=3.14" +dependencies = [ + "pytest>=8.3.0", + "testcontainers[elasticsearch]>=4.8.0", + "httpx>=0.27.0", + "pyyaml>=6.0.0", +] + +[dependency-groups] +dev = [ + "pyright>=1.1.0", + "ruff>=0.11.0", + "ty>=0.0.0a6", +] + +[tool.pyright] +typeCheckingMode = "strict" +pythonVersion = "3.14" +reportMissingTypeStubs = false + +[tool.ruff] +target-version = "py314" +line-length = 120 + +[tool.ty.rules] +# Treat all warn-level rules as errors +ambiguous-protocol-member = "error" +deprecated = "error" +ignore-comment-unknown-rule = "error" +ineffective-final = "error" +invalid-enum-member-annotation = "error" +invalid-ignore-comment = "error" +invalid-legacy-positional-parameter = "error" +invalid-named-tuple-override = "error" +mismatched-type-name = "error" +possibly-missing-implicit-call = "error" +possibly-missing-submodule = "error" +redundant-cast = "error" +redundant-final-classvar = "error" +subclass-of-dataclass-with-order = "error" +undefined-reveal = "error" +unresolved-global = "error" +unsupported-base = "error" +unused-awaitable = "error" +unused-ignore-comment = "error" +unused-type-ignore-comment = "error" + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "N", # pep8-naming + "UP", # pyupgrade + "B", # flake8-bugbear + "RUF", # ruff-specific rules +] diff --git a/tests/test_compatibility.py b/tests/test_compatibility.py new file mode 100644 index 0000000..fc6e938 --- /dev/null +++ b/tests/test_compatibility.py @@ -0,0 +1,308 @@ +import pytest + +from helpers import ( + INTEGRITY_MISSING_MIGRATIONS, + INTEGRITY_MODIFIED_MIGRATIONS, + ORDERING_MIGRATIONS, + SINGLE_MIGRATION, + STANDARD_MIGRATIONS, + TEMPLATED_MIGRATIONS, + Implementation, + all_implementations, + assert_index_exists, + delete_indices, + get_records, + run, +) + + +def implementations() -> pytest.MarkDecorator: + return pytest.mark.parametrize( + "impl", + all_implementations(), + ids=lambda i: i.name, + ) + + +# --------------------------------------------------------------------------- +# Initialization +# --------------------------------------------------------------------------- + + +@implementations() +def test_esque_management_index_created(impl: Implementation, es_url: str) -> None: + result = run(impl, es_url, key="init-test", migrations_dir=STANDARD_MIGRATIONS) + assert result.returncode == 0, f"esque failed:\n{result.stderr}" + assert_index_exists(es_url, ".esque") + + +# --------------------------------------------------------------------------- +# Execution +# --------------------------------------------------------------------------- + + +@implementations() +def test_all_migrations_run(impl: Implementation, es_url: str) -> None: + result = run(impl, es_url, key="run-all-test", migrations_dir=STANDARD_MIGRATIONS) + assert result.returncode == 0, f"esque failed:\n{result.stderr}" + assert_index_exists(es_url, "test-index-v1") + assert_index_exists(es_url, "test-index-v2") + assert_index_exists(es_url, "test-index-v3") + + +@implementations() +def test_idempotent_execution(impl: Implementation, es_url: str) -> None: + key = "idempotent-test" + + result = run(impl, es_url, key=key, migrations_dir=STANDARD_MIGRATIONS) + assert result.returncode == 0, f"First run failed:\n{result.stderr}" + first = get_records(es_url, key) + assert len(first) == 3 + + result = run(impl, es_url, key=key, migrations_dir=STANDARD_MIGRATIONS) + assert result.returncode == 0, f"Second run failed:\n{result.stderr}" + second = get_records(es_url, key) + assert len(second) == 3 + + for a, b in zip(first, second, strict=True): + assert a["checksum"] == b["checksum"], "Checksum changed between runs" + assert a["installedOn"] == b["installedOn"], "installedOn changed between runs" + + +@implementations() +def test_different_migration_keys_are_independent(impl: Implementation, es_url: str) -> None: + key_a = "independent-key-a" + key_b = "independent-key-b" + + result = run(impl, es_url, key=key_a, migrations_dir=SINGLE_MIGRATION) + assert result.returncode == 0, f"esque failed:\n{result.stderr}" + + assert len(get_records(es_url, key_a)) == 1 + assert len(get_records(es_url, key_b)) == 0 + + +# --------------------------------------------------------------------------- +# Migration history metadata +# --------------------------------------------------------------------------- + + +@implementations() +def test_migration_history_record_count(impl: Implementation, es_url: str) -> None: + key = "history-count-test" + result = run(impl, es_url, key=key, migrations_dir=STANDARD_MIGRATIONS) + assert result.returncode == 0, f"esque failed:\n{result.stderr}" + assert len(get_records(es_url, key)) == 3 + + +@implementations() +def test_migration_history_metadata(impl: Implementation, es_url: str) -> None: + key = "history-metadata-test" + result = run(impl, es_url, key=key, migrations_dir=STANDARD_MIGRATIONS) + assert result.returncode == 0, f"esque failed:\n{result.stderr}" + + records = get_records(es_url, key) + assert len(records) == 3 + + assert records[0]["order"] == 0 + assert records[0]["filename"] == "V1.0.0__CreateFirstIndex.yml" + assert records[0]["version"] == "1.0.0" + assert records[0]["description"] == "CreateFirstIndex" + assert records[0]["migrationKey"] == key + + assert records[1]["order"] == 1 + assert records[1]["filename"] == "V1.1.0__CreateSecondIndex.yml" + assert records[1]["version"] == "1.1.0" + + assert records[2]["order"] == 2 + assert records[2]["filename"] == "V2.0.0__CreateThirdIndex.yml" + assert records[2]["version"] == "2.0.0" + + +@implementations() +def test_migration_history_checksum_is_present(impl: Implementation, es_url: str) -> None: + key = "history-checksum-test" + result = run(impl, es_url, key=key, migrations_dir=STANDARD_MIGRATIONS) + assert result.returncode == 0, f"esque failed:\n{result.stderr}" + + for record in get_records(es_url, key): + assert record.get("checksum") is not None, f"checksum missing on record {record['filename']}" + + +@implementations() +def test_migration_history_execution_time_is_non_negative(impl: Implementation, es_url: str) -> None: + key = "history-exectime-test" + result = run(impl, es_url, key=key, migrations_dir=STANDARD_MIGRATIONS) + assert result.returncode == 0, f"esque failed:\n{result.stderr}" + + for record in get_records(es_url, key): + assert record["executionTime"] >= 0, f"executionTime is negative on record {record['filename']}" + + +@implementations() +def test_migration_history_installed_on_is_present(impl: Implementation, es_url: str) -> None: + key = "history-installedon-test" + result = run(impl, es_url, key=key, migrations_dir=STANDARD_MIGRATIONS) + assert result.returncode == 0, f"esque failed:\n{result.stderr}" + + for record in get_records(es_url, key): + assert record.get("installedOn") is not None, f"installedOn missing on record {record['filename']}" + + +@implementations() +def test_migration_user_recorded_when_provided(impl: Implementation, es_url: str) -> None: + key = "user-test" + result = run(impl, es_url, key=key, migrations_dir=STANDARD_MIGRATIONS, user="test-user") + assert result.returncode == 0, f"esque failed:\n{result.stderr}" + + for record in get_records(es_url, key): + assert record.get("installedBy") == "test-user", ( + f"Expected installedBy='test-user' on {record['filename']}, got {record.get('installedBy')!r}" + ) + + +@implementations() +def test_migration_user_null_when_not_provided(impl: Implementation, es_url: str) -> None: + key = "no-user-test" + result = run(impl, es_url, key=key, migrations_dir=STANDARD_MIGRATIONS) + assert result.returncode == 0, f"esque failed:\n{result.stderr}" + + for record in get_records(es_url, key): + assert record.get("installedBy") is None, ( + f"Expected installedBy=null on {record['filename']}, got {record.get('installedBy')!r}" + ) + + +# --------------------------------------------------------------------------- +# Template variable substitution +# --------------------------------------------------------------------------- + + +@implementations() +def test_template_substitution_creates_correct_index(impl: Implementation, es_url: str) -> None: + result = run( + impl, + es_url, + key="template-test", + migrations_dir=TEMPLATED_MIGRATIONS, + properties={"indexName": "test-index-v4"}, + ) + assert result.returncode == 0, f"esque failed:\n{result.stderr}" + assert_index_exists(es_url, "test-index-v4") + + +@implementations() +def test_extra_template_properties_ignored(impl: Implementation, es_url: str) -> None: + result = run( + impl, + es_url, + key="extra-props-test", + migrations_dir=STANDARD_MIGRATIONS, + properties={"unused": "ignored", "alsoUnused": "alsoIgnored"}, + ) + assert result.returncode == 0, f"esque failed:\n{result.stderr}" + assert_index_exists(es_url, "test-index-v1") + + +@implementations() +def test_missing_template_property_fails_before_any_migration(impl: Implementation, es_url: str) -> None: + key = "missing-var-test" + result = run( + impl, + es_url, + key=key, + migrations_dir=TEMPLATED_MIGRATIONS, + # no properties — #{indexName} is unresolvable + ) + assert result.returncode != 0, "Expected esque to fail with missing template variable, but it succeeded" + assert len(get_records(es_url, key)) == 0, "No migration records should be written when template validation fails" + + +# --------------------------------------------------------------------------- +# Integrity verification +# --------------------------------------------------------------------------- + + +@implementations() +def test_integrity_checksum_mismatch_causes_failure(impl: Implementation, es_url: str) -> None: + key = "checksum-mismatch-test" + + # First run: apply standard migrations + result = run(impl, es_url, key=key, migrations_dir=STANDARD_MIGRATIONS) + assert result.returncode == 0, f"First run failed:\n{result.stderr}" + assert len(get_records(es_url, key)) == 3 + + # Second run: same filenames, V1.0.0 has different content → checksum mismatch + result = run(impl, es_url, key=key, migrations_dir=INTEGRITY_MODIFIED_MIGRATIONS) + assert result.returncode != 0, "Expected esque to fail due to checksum mismatch, but it succeeded" + + +@implementations() +def test_integrity_fewer_files_than_records_causes_failure(impl: Implementation, es_url: str) -> None: + key = "fewer-files-test" + + # First run: apply all 3 standard migrations + result = run(impl, es_url, key=key, migrations_dir=STANDARD_MIGRATIONS) + assert result.returncode == 0, f"First run failed:\n{result.stderr}" + assert len(get_records(es_url, key)) == 3 + + # Second run: only 2 migration files — 3 records but 2 files → should fail + result = run(impl, es_url, key=key, migrations_dir=INTEGRITY_MISSING_MIGRATIONS) + assert result.returncode != 0, "Expected esque to fail when migration records outnumber local files" + + +# --------------------------------------------------------------------------- +# Cross-implementation equivalency +# --------------------------------------------------------------------------- + + +def test_cross_implementation_record_equivalency(es_url: str) -> None: + impls = all_implementations() + if len(impls) < 2: + pytest.skip("cross-implementation equivalency test requires at least 2 implementations") + + records_by_impl: dict[str, list[dict[str, object]]] = {} + for impl in impls: + delete_indices(es_url, "test-*") + key = f"cross-compat-{impl.name}" + result = run(impl, es_url, key=key, migrations_dir=STANDARD_MIGRATIONS) + assert result.returncode == 0, f"{impl.name} failed:\n{result.stderr}" + records_by_impl[impl.name] = get_records(es_url, key) + + impl_names = list(records_by_impl) + reference_name = impl_names[0] + reference_records = records_by_impl[reference_name] + + for other_name in impl_names[1:]: + other_records = records_by_impl[other_name] + + assert len(other_records) == len(reference_records), ( + f"{other_name} produced {len(other_records)} records " + f"but {reference_name} produced {len(reference_records)}" + ) + + for ref, other in zip(reference_records, other_records, strict=True): + filename = ref["filename"] + for field in ("checksum", "order", "filename", "version", "description"): + assert other[field] == ref[field], ( + f"Field '{field}' differs for {filename}: " + f"{reference_name}={ref[field]!r}, {other_name}={other[field]!r}" + ) + + +# --------------------------------------------------------------------------- +# Version ordering +# --------------------------------------------------------------------------- + + +@implementations() +def test_version_ordering_is_numeric_not_lexicographic(impl: Implementation, es_url: str) -> None: + key = "ordering-test" + result = run(impl, es_url, key=key, migrations_dir=ORDERING_MIGRATIONS) + assert result.returncode == 0, f"esque failed:\n{result.stderr}" + + records = get_records(es_url, key) + assert len(records) == 2 + + # V1.9.0 must come before V1.10.0 (numeric), not after (lexicographic) + assert records[0]["version"] == "1.9.0", f"Expected first record to be V1.9.0 but got V{records[0]['version']}" + assert records[1]["version"] == "1.10.0", f"Expected second record to be V1.10.0 but got V{records[1]['version']}" diff --git a/tests/uv.lock b/tests/uv.lock new file mode 100644 index 0000000..3ed717b --- /dev/null +++ b/tests/uv.lock @@ -0,0 +1,419 @@ +version = 1 +revision = 3 +requires-python = ">=3.14" + +[[package]] +name = "anyio" +version = "4.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, +] + +[[package]] +name = "certifi" +version = "2026.5.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + +[[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 = "docker" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "requests" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/91/9b/4a2ea29aeba62471211598dac5d96825bb49348fa07e906ea930394a83ce/docker-7.1.0.tar.gz", hash = "sha256:ad8c70e6e3f8926cb8a92619b832b4ea5299e2831c14284663184e200546fa6c", size = 117834, upload-time = "2024-05-23T11:13:57.216Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/26/57c6fb270950d476074c087527a558ccb6f4436657314bfb6cdf484114c4/docker-7.1.0-py3-none-any.whl", hash = "sha256:c96b93b7f0a746f9e77d325bcfb87422a3d8bd4f03136ae8a85b37f1898d5fc0", size = 147774, upload-time = "2024-05-23T11:13:55.01Z" }, +] + +[[package]] +name = "esque-compatibility-tests" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "httpx" }, + { name = "pytest" }, + { name = "pyyaml" }, + { name = "testcontainers" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pyright" }, + { name = "ruff" }, + { name = "ty" }, +] + +[package.metadata] +requires-dist = [ + { name = "httpx", specifier = ">=0.27.0" }, + { name = "pytest", specifier = ">=8.3.0" }, + { name = "pyyaml", specifier = ">=6.0.0" }, + { name = "testcontainers", extras = ["elasticsearch"], specifier = ">=4.8.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pyright", specifier = ">=1.1.0" }, + { name = "ruff", specifier = ">=0.11.0" }, + { name = "ty", specifier = ">=0.0.0a6" }, +] + +[[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.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[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 = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[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 = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pyright" +version = "1.1.410" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodeenv" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/10/53/e4d8ea1391bd4355231be6f91bf239479aa0014260ed3fb5526eeb12a1f2/pyright-1.1.410.tar.gz", hash = "sha256:07a073b8ba6749826773c1269773efa11b93440d9a6aa60419d9a3172d6dc488", size = 4062013, upload-time = "2026-06-01T17:35:48.894Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/33/288b5868fa00846dacf249633719d747893e54aebd196b9968ac1878a5d3/pyright-1.1.410-py3-none-any.whl", hash = "sha256:5e961bed37cacf96b3f7cd7b1da39b350a9239aa2e69138d0e88f728cfaf296c", size = 6082448, upload-time = "2026-06-01T17:35:46.387Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.0" +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/84/0e/b5858858d74958632c49b72cb25a3976ff9f632397626715be71c89d3971/pytest-9.1.0.tar.gz", hash = "sha256:41dd9148c08072446394cefd3d79701701335a9f4cae69ba92e39f6c7f5c061c", size = 1634181, upload-time = "2026-06-13T18:52:45.983Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/5a/ba30a81239b909821b3153e303e7def45178bf353da4f72380e6c5e8793b/pytest-9.1.0-py3-none-any.whl", hash = "sha256:8ebb0e7888bdf2bdfc602ec51f8f62d50200af37356c74e503c79a94f5c81f32", size = 386453, upload-time = "2026-06-13T18:52:44.045Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "pywin32" +version = "312" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/2b/1f3cded5822fd49c02f40544cbb5f58c7cfd6b1694869fd476cb6170ee97/pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b", size = 6468928, upload-time = "2026-06-04T07:49:43.188Z" }, + { url = "https://files.pythonhosted.org/packages/21/82/3bf86d2e2808902013132e1ce905a7da0da53790f3836c64bf44d55e24f3/pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e", size = 7024157, upload-time = "2026-06-04T07:49:45.34Z" }, + { url = "https://files.pythonhosted.org/packages/a4/0e/73f6d6800b4f27655abd9e9f6aaeaefcddb2b946e4674efa2bab184a7f7b/pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa", size = 6839598, upload-time = "2026-06-04T07:49:47.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/61/caa39686032d2ebdd04ff0ab5cbe163126c0066d98e00c9018646e42393b/pywin32-312-cp315-cp315-win32.whl", hash = "sha256:5c1fbe4a937a73ae9297384a3da38518cbc694c68ad8a809b2e19acd350f03ed", size = 6471159, upload-time = "2026-06-04T07:49:50.035Z" }, + { url = "https://files.pythonhosted.org/packages/0f/cd/7e1de64a4a6f69c04214169657ccab0d93a670ea50e35eb8f489d7378249/pywin32-312-cp315-cp315-win_amd64.whl", hash = "sha256:c2f03a0f73f804a13c2735b99392b0cd426bb4f2c4d0178e5ac966a0f21618d5", size = 7025293, upload-time = "2026-06-04T07:49:54.857Z" }, + { url = "https://files.pythonhosted.org/packages/23/ed/4532e9388e65fa16b46776ef47ad631a64eda1631884488af707666350ed/pywin32-312-cp315-cp315-win_arm64.whl", hash = "sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9", size = 6840337, upload-time = "2026-06-04T07:49:57.531Z" }, +] + +[[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/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 = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/a9/3abdf488f1bf3d24c699415e454ed554a6350d5d89ce183be1ee0a3361ac/ruff-0.15.17.tar.gz", hash = "sha256:2ec446937fd16c8c4de2674a209cc5af64d9c6f17d21fbf1151054fa0bcf5219", size = 4743346, upload-time = "2026-06-11T17:54:47.663Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/4d/e11259f5da07cb6afb2d074c31bf09da9671993f7329d4f15d2fdc458301/ruff-0.15.17-py3-none-linux_armv6l.whl", hash = "sha256:d9feddb927fc68bd295f5eebc587a7e42cfaf9b65f60ca4a2386febff575da8f", size = 10856677, upload-time = "2026-06-11T17:54:49.533Z" }, + { url = "https://files.pythonhosted.org/packages/29/3e/772d679e1a0dc058e58875bd2c0cb713a0530877b4a76fee3c7966df0d49/ruff-0.15.17-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:25805a226d741c47d274a35ad5c10a7dde175fcddfa511d7cf3da0a21eb3eab7", size = 11223443, upload-time = "2026-06-11T17:55:00.573Z" }, + { url = "https://files.pythonhosted.org/packages/68/58/bd41f7688b2fd5623012605130ed70e60aa7f2244baa3d5066bdd61530c8/ruff-0.15.17-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f6ad73b14c2d18a3bf8ad7cb6974294d7f613a7898604826058e6ac64918ef4d", size = 10566458, upload-time = "2026-06-11T17:55:07.52Z" }, + { url = "https://files.pythonhosted.org/packages/d8/5b/733371013fcf1ec339e477ece6ab42bfe10bdd9bba8ee88a9516aa56bfc0/ruff-0.15.17-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ba0c1e4f95bcb3869d0d30cbd5917071ef2e28665abfec970cdab0492c713ed", size = 10914483, upload-time = "2026-06-11T17:55:05.501Z" }, + { url = "https://files.pythonhosted.org/packages/bd/cc/6f24251cc0252f7239391ccb85833f320efad14ebe5b443943f37ced6332/ruff-0.15.17-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:81647960f10bff57d2e51cadd0c3950fe598400c852863a038720ef5b8cca91e", size = 10647497, upload-time = "2026-06-11T17:54:57.733Z" }, + { url = "https://files.pythonhosted.org/packages/68/dd/0d10c17ce1a1624d6fc3156309c3f834fdb5dfaad026ec90c85684f3990e/ruff-0.15.17-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0e01a84ddbc8c16c23055ba3924476850f1bbc1917cebbb9376665a63e74260d", size = 11416967, upload-time = "2026-06-11T17:54:51.461Z" }, + { url = "https://files.pythonhosted.org/packages/2f/91/556bfb156f6144f355e831c23db00b2fc4120f86b3ce81cc5f7fd2df51f3/ruff-0.15.17-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:84fe9f653152f8f294f9f7e03bf3a453d8b4a27f7a59c78c8666167f2b17b96c", size = 12335770, upload-time = "2026-06-11T17:54:45.793Z" }, + { url = "https://files.pythonhosted.org/packages/88/82/8b5999aa13355e926f06d9f42a32dcca862f623bf0363785ff89d607dffd/ruff-0.15.17-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c0fe88a7676e7a05b73174d4d4a59cb2ac21ff8263583f87a81a6018475a978", size = 11575441, upload-time = "2026-06-11T17:54:32.661Z" }, + { url = "https://files.pythonhosted.org/packages/11/93/f10377bb04109ca0e8cbc483ff1982c54b6d418210041776f93e8cdc7fa9/ruff-0.15.17-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecfc3c7878fff94633ab0348524e093f9ce3243080416dd7d14f8ba400174719", size = 11557614, upload-time = "2026-06-11T17:54:34.698Z" }, + { url = "https://files.pythonhosted.org/packages/c7/a6/eeeae7f7d5493df41649ab3db92f086b2d0a30199e4efdf8e3dd7a033f24/ruff-0.15.17-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:b8461180b22420b1bdc289909410930761629fddf2a5aaf60fae1ab26cedc4c4", size = 11544450, upload-time = "2026-06-11T17:54:39.042Z" }, + { url = "https://files.pythonhosted.org/packages/32/88/5991ce565129a24dd4a00db1254b3b5db2e53018cbe4018ea5a89738e727/ruff-0.15.17-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6eccbe50a038b503e7140b441aa9c7fc8c1f36edf23ebef9f4165c2f28f568b7", size = 10892524, upload-time = "2026-06-11T17:55:09.432Z" }, + { url = "https://files.pythonhosted.org/packages/f5/1d/0fdd248313425f55223968af04b0a42125466a8d88d21c1d99c6af0a51e8/ruff-0.15.17-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:382fc0521025f5a8ad447d8bdd523545d0d7646adb718eb1c2dac5065ec27c0f", size = 10659573, upload-time = "2026-06-11T17:54:36.824Z" }, + { url = "https://files.pythonhosted.org/packages/9e/0e/072e8260deb9461062ce9311ced27a8e541229a6ffd483013dd37661e43e/ruff-0.15.17-py3-none-musllinux_1_2_i686.whl", hash = "sha256:456d41fcd1b2777ad63f09a6e7121d43f7b688bbc76a800c10f7f8fb1f912c3f", size = 11127818, upload-time = "2026-06-11T17:55:03.124Z" }, + { url = "https://files.pythonhosted.org/packages/ab/b4/55060a34163121498014696b5f656db5b8c6963768f227dbf0d76b311073/ruff-0.15.17-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b1a04bcc94ae6194e9db05d16ad31f298a7194bfbcb08258bbe589cee1d587b8", size = 11655901, upload-time = "2026-06-11T17:54:53.562Z" }, + { url = "https://files.pythonhosted.org/packages/49/71/9b29d6b87cef468d697f43c6a91e3fae4a80185779d7d5a4ef27d173439f/ruff-0.15.17-py3-none-win32.whl", hash = "sha256:596065960ab1ff593f744220c9fe6580eda00a95003cffa9f4048bb5b1bf0392", size = 10925574, upload-time = "2026-06-11T17:54:55.723Z" }, + { url = "https://files.pythonhosted.org/packages/3d/b2/8fc77f3723228836fa5d12497eb71c808f83782e10d058d2b15cfa14640b/ruff-0.15.17-py3-none-win_amd64.whl", hash = "sha256:6769e5fa1710b179b92e0bfa5a51735b35baea9013dadb06d5f44cbcf9547084", size = 12058788, upload-time = "2026-06-11T17:54:41.042Z" }, + { url = "https://files.pythonhosted.org/packages/2d/c7/c53e8dbff9c9dc4b7928773421ae294a5d28fcb8dcda1a089579d3a7e510/ruff-0.15.17-py3-none-win_arm64.whl", hash = "sha256:f3be1fbb34bcdfd146240d8fb92a709d4c2c8191348580a3c044ec60fa0b4456", size = 11355275, upload-time = "2026-06-11T17:54:43.635Z" }, +] + +[[package]] +name = "testcontainers" +version = "4.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docker" }, + { name = "python-dotenv" }, + { name = "typing-extensions" }, + { name = "urllib3" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ca/ac/a597c3a0e02b26cbed6dd07df68be1e57684766fd1c381dee9b170a99690/testcontainers-4.14.2.tar.gz", hash = "sha256:1340ccf16fe3acd9389a6c9e1d9ab21d9fe99a8afdf8165f89c3e69c1967d239", size = 166841, upload-time = "2026-03-18T05:19:16.696Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/2d/26b8b30067d94339afee62c3edc9b803a6eb9332f521ba77d8aaab5de873/testcontainers-4.14.2-py3-none-any.whl", hash = "sha256:0d0522c3cd8f8d9627cda41f7a6b51b639fa57bdc492923c045117933c668d68", size = 125712, upload-time = "2026-03-18T05:19:15.29Z" }, +] + +[[package]] +name = "ty" +version = "0.0.49" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/8d/37cb91808069509d43a2a11743e12f1e854fd808dbef2203309d256718cd/ty-0.0.49.tar.gz", hash = "sha256:0a027bd0c9c75d035641a365d087ad883446057f9be0b9826251c2aecafbf145", size = 5884753, upload-time = "2026-06-12T03:08:20.221Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/de/9237c6a96356612dd0393db1e94cf21f903616adf3a3701bf3da6e4adc92/ty-0.0.49-py3-none-linux_armv6l.whl", hash = "sha256:12c0c4310b936d762a8586c210b53d4fa4bb361a04429afa89bf84b922e5e065", size = 11834671, upload-time = "2026-06-12T03:07:53.062Z" }, + { url = "https://files.pythonhosted.org/packages/8f/15/daf5a14a5e07012277d450c75325c94614e2acfec4c620c881486118c410/ty-0.0.49-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:737bfdc2caf9712a8580944dcdc80a450a37a4f2bc83c8fa9b7433b374f9e471", size = 11589570, upload-time = "2026-06-12T03:08:25.779Z" }, + { url = "https://files.pythonhosted.org/packages/7d/58/30bdf98436488aca25f0763bf7f92a061528d42461b686453029e845e4c5/ty-0.0.49-py3-none-macosx_11_0_arm64.whl", hash = "sha256:ab90c1baf3b1701d282fce4b02fa552a962d109f8972c46ef6b22429503bfea4", size = 10985236, upload-time = "2026-06-12T03:08:36.664Z" }, + { url = "https://files.pythonhosted.org/packages/22/45/ece503e4a1396e13a1a9a0cde51afe476a6506a1d557eeadf8ad45c83bc0/ty-0.0.49-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b4ce8ecf6ba6fc79bd137cc0557a754f7e5f2dfe9436412551d480d680e248ad", size = 11504302, upload-time = "2026-06-12T03:08:01.664Z" }, + { url = "https://files.pythonhosted.org/packages/17/dc/5d09333d289dfbca1804eaade125c9e8a1a992a2a592a8b80c5e9b589ca9/ty-0.0.49-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:10d85c6865c984e78661e0bd20b180514b4a289739224e84816e342bdf381e04", size = 11626629, upload-time = "2026-06-12T03:08:06.844Z" }, + { url = "https://files.pythonhosted.org/packages/f2/36/155f41c9dd7237c4b609211f29f77755a139ee6218605dadc7fe21d5e3c8/ty-0.0.49-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d96a67a206619e01fa92f35a22267ec634bba62be24b1d0e947020cc179995b", size = 12074481, upload-time = "2026-06-12T03:08:09.643Z" }, + { url = "https://files.pythonhosted.org/packages/96/4c/998ee13cd5045f1f8b36982de7343163832ac53f27debe01b0de0e8bd968/ty-0.0.49-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3de9f648564e0a66344ef397770387cb0d093735f8679d2c5a08a4741e79814d", size = 12678042, upload-time = "2026-06-12T03:08:39.319Z" }, + { url = "https://files.pythonhosted.org/packages/85/c9/9a505aba85c41ce54cbcaa14f8d79aa084b86151d2d70df11c4655b92898/ty-0.0.49-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5779179ab397d15f8c9dbb8f506ec1b1745f54eac639982f76ef3ce538943b50", size = 12316194, upload-time = "2026-06-12T03:08:18.023Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b8/ded37fb93503294abbc83c36470bb1413bea05048b745881d4470b518a06/ty-0.0.49-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:792d4974e93cc09bd32f934586080bbbe21b8e777099cb521cb2de18b68a49f0", size = 12145507, upload-time = "2026-06-12T03:07:56.505Z" }, + { url = "https://files.pythonhosted.org/packages/2f/07/392e80d78f02445f695b815bb9eb0fffacda68b03faee38c900f7b990815/ty-0.0.49-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:727bda86deb136073e525c2e78d60e38aedcce5d80579170844a52bbf7c1440d", size = 12365967, upload-time = "2026-06-12T03:08:12.553Z" }, + { url = "https://files.pythonhosted.org/packages/50/d3/31b0c2a7fbedd3373e389cb1d81b8d2128f6f868fafb46557736a6f9aca8/ty-0.0.49-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4f2fc2bc4a8d2ff1cca59fd94772cabdfec4062d47a0b3a0784be46d94d0540b", size = 11475283, upload-time = "2026-06-12T03:08:28.334Z" }, + { url = "https://files.pythonhosted.org/packages/5a/5b/329e101638920b468a3bb63059c9f66ef99b44aac501222c44832a507321/ty-0.0.49-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:3724bd9badef333321578b6a941fbc571ebf49141ec2356a8590fbe4c9aa588d", size = 11645343, upload-time = "2026-06-12T03:08:15.246Z" }, + { url = "https://files.pythonhosted.org/packages/a9/76/c897e615e32f80ca81c8c1bc49b9a1f72ff9e3cfea0f8345ba505fe28472/ty-0.0.49-py3-none-musllinux_1_2_i686.whl", hash = "sha256:166c6eb52ee4af3c5a9bb267d165d93000daa55c6758cd8ff3199741fb75917d", size = 11725585, upload-time = "2026-06-12T03:08:33.915Z" }, + { url = "https://files.pythonhosted.org/packages/59/e1/fdb42ee239f618800842681af5bb8598117e74512c10974a8b7b9086a898/ty-0.0.49-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:91e81d832c287b05782ee32eb1b801f62c1fa08df37d589d2b88c3f1d51c9731", size = 12237261, upload-time = "2026-06-12T03:08:31.105Z" }, + { url = "https://files.pythonhosted.org/packages/98/0f/a2d6a5fc9d0786cbeb3c200786da4e18c203589be3984bb5def83ca92320/ty-0.0.49-py3-none-win32.whl", hash = "sha256:7186af5ca9829d1f5d8916bcf767b8e819bfbf61b1b8ec843bb3fc699cb502e1", size = 11100789, upload-time = "2026-06-12T03:07:59.092Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9d/473ac8bc57b5a2d121da893bf9dd74a118efb19a01d711df1a6e397f05cc/ty-0.0.49-py3-none-win_amd64.whl", hash = "sha256:ae2142fc126a01effcca0c222908b0e6654b5ba1266d4e4d406e4866aef8e1d1", size = 12204644, upload-time = "2026-06-12T03:08:04.327Z" }, + { url = "https://files.pythonhosted.org/packages/ef/a2/8959249da951ba3977fee20e688d28678b8a1d30a9ed4464228a85d45853/ty-0.0.49-py3-none-win_arm64.whl", hash = "sha256:75d5e2e7649765f31f4bed6c8adb149a75b18edd3fa6336dac4d0efc1a66466f", size = 11558965, upload-time = "2026-06-12T03:08:23.012Z" }, +] + +[[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 = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "wrapt" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/9f/06263fcd8ad6c405f05a3905fd7a84dd3176eb5ad46e44bccc0cd16348bb/wrapt-2.2.1.tar.gz", hash = "sha256:6744f504375775d7609c82c8d3d94af1c9a6f05586984536905908ba905277b9", size = 127620, upload-time = "2026-05-22T14:49:43.056Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/a3/11d7f34ebbf3231bc907a3e6d5ee051b14d034c1bc7b65a97d5cc00516df/wrapt-2.2.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6f56a647e4eaf5f0ca40330fb070f566bdf9f7b0db89a1af20d71c28dcd7a0ab", size = 80879, upload-time = "2026-05-22T14:48:51.802Z" }, + { url = "https://files.pythonhosted.org/packages/13/3c/b74cfd984cef560b900fb1a727af20352d89e1f06bf2e1114dd3f00f5f5a/wrapt-2.2.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:64b7deeda4b70408e382328d8bbe52a256fe9bc63ae3db86d804608367e5422c", size = 81462, upload-time = "2026-05-22T14:48:53.18Z" }, + { url = "https://files.pythonhosted.org/packages/15/a3/7c8f704b8dc07dfe0a5d01c2edbfd88317aa8e5e3fa7c743eb7a085ae767/wrapt-2.2.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b9cf53ba90717db2e292401de290776c498d4bbfb0d4a559ca2895db8b9dcb5c", size = 167251, upload-time = "2026-05-22T14:48:54.562Z" }, + { url = "https://files.pythonhosted.org/packages/80/85/a34d1888d97247da6c2ff6118c3a721c73ed8cc4dd198c00208bb73b6f80/wrapt-2.2.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cf3638274ab9d9b724c9baa0b4c04e132cd6faefb78b4dd3dd1a02a4bdaad41e", size = 166316, upload-time = "2026-05-22T14:48:56.065Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d7/72ffaeb01eebc704afe3fb99e840480f4bda45f0fa66e3381b6a39251c8f/wrapt-2.2.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aed9658797d0b45d6c49adcfc6b41f66e6f2d0c6de3ec79e16cf4b1855df240f", size = 157952, upload-time = "2026-05-22T14:48:57.924Z" }, + { url = "https://files.pythonhosted.org/packages/24/5b/36f5d6b024e4edfdd90b140742d11ebcf7836daf5c9daf326c55c24db412/wrapt-2.2.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1d676ee388bc42a04d56dd7deb5605244dac2e35cc2fadbb43c9fa25bbd93508", size = 166130, upload-time = "2026-05-22T14:48:59.384Z" }, + { url = "https://files.pythonhosted.org/packages/81/06/9296d9e97bfdef5483dfcc859d57b095b257144b2bc5300ab521e06f4bc7/wrapt-2.2.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e395f7bc31851ef9b612050368cb446e9bc14cd7454b025018980349caf25ae5", size = 156604, upload-time = "2026-05-22T14:49:00.921Z" }, + { url = "https://files.pythonhosted.org/packages/53/37/16953929ed6776175720e58fc966e779926d8d71e2c7b2273230590ca71f/wrapt-2.2.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5f1845c2a8cc1180ccccfa45785dd06f562730d19ef75be180334254012b6283", size = 166007, upload-time = "2026-05-22T14:49:02.332Z" }, + { url = "https://files.pythonhosted.org/packages/b9/73/20ee58c0612dae7c31131a7095345812ed2c7b389019e175f68cde34e5b4/wrapt-2.2.1-cp314-cp314-win32.whl", hash = "sha256:436addbc4bb4fc0a88c702577f51195d7d73683a7f3e0e5b253d8404d7847243", size = 78327, upload-time = "2026-05-22T14:49:03.722Z" }, + { url = "https://files.pythonhosted.org/packages/22/b3/ef7c3295d02e0448a71c639a36a057f46d524d057c9486291a7a3039e65c/wrapt-2.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:50972a1d974ea07725a7f6b1cec5f8759008afd030a0024843ebe7d52de47f2b", size = 81144, upload-time = "2026-05-22T14:49:05.093Z" }, + { url = "https://files.pythonhosted.org/packages/ac/dc/7bdf336953f99f4ceb0a584bb8870e42c8f26f93ea10c87834dad62f1668/wrapt-2.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:1c9934ea5d92957e3cd0adbc0845539dccfd62710ebe16195a8c66c53954db36", size = 79569, upload-time = "2026-05-22T14:49:06.413Z" }, + { url = "https://files.pythonhosted.org/packages/6a/6d/6dfae80150ff1919c356d1dd528f049bcdfaae29b4d284bc957e022caef4/wrapt-2.2.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:17de18fc12cea55b8a9587314cb830573e37fb33b247a7515696350863714188", size = 82892, upload-time = "2026-05-22T14:49:07.925Z" }, + { url = "https://files.pythonhosted.org/packages/82/7b/4e34766a7d7804ffce9e71befe47e9b3225dc350c49c94493c4ab39fd3a5/wrapt-2.2.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a9dec1aca52dddde7df94818310fa2fe79739c8f385b2014c4cb1035f5508199", size = 83333, upload-time = "2026-05-22T14:49:09.257Z" }, + { url = "https://files.pythonhosted.org/packages/9d/57/0b34db3e8de44ccfece62d7b337abd1631dd810f5adc5f3db571727836b5/wrapt-2.2.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:69f2e9244542cb34dd59c7f073445b9e54ad9f3fce8d93606c368a1b499fc413", size = 202899, upload-time = "2026-05-22T14:49:10.572Z" }, + { url = "https://files.pythonhosted.org/packages/e5/45/ac0c459f154b99d92789a6cba7ca727185b83513b986f8ec7fe2aacddcbf/wrapt-2.2.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d83966dc7f4f45e8b97b5933685ac2e6e67fc0e19246ea314bceb9a8970c956", size = 209986, upload-time = "2026-05-22T14:49:12.229Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e4/77e37ff33ad018fa81ade52c25fa327b80b56f81d734279a63614fcb4cbc/wrapt-2.2.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:78b0aa6bfb7be8deed0ab23e7aa028cc5210c29bc2d32a04d52b50e517a7307e", size = 194893, upload-time = "2026-05-22T14:49:14.139Z" }, + { url = "https://files.pythonhosted.org/packages/dd/9d/7ea651d1ab032fc5fa222fbec91d0f8a1397f6ae04ebb93fa7219aa921d7/wrapt-2.2.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:05d5cb74d1b232ec8cfa130a8f900708699ff2491d97b8f85a4cdc5996294b85", size = 205636, upload-time = "2026-05-22T14:49:15.714Z" }, + { url = "https://files.pythonhosted.org/packages/09/af/8e88031a701275b9085c54e64bc88c0b1cd55c77eadd400691c371cd76c4/wrapt-2.2.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f6518b94edb9150452e9aba08027d4cc293433753ec1fbefb4629a21cbc74181", size = 192267, upload-time = "2026-05-22T14:49:17.283Z" }, + { url = "https://files.pythonhosted.org/packages/bf/a8/e657ca876b06710194f243d81c4b0896ade646e244bdbec2d87c8c56a8bd/wrapt-2.2.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ed55af48b3eb28f43228ca2306788892bcb629eb2b5c4876e2a3659872c2f17a", size = 198378, upload-time = "2026-05-22T14:49:18.785Z" }, + { url = "https://files.pythonhosted.org/packages/c8/59/822efe4ea722a3961331bfa35b7d90937790d2c20f0616de1997ccc3aebd/wrapt-2.2.1-cp314-cp314t-win32.whl", hash = "sha256:2e08688ab16525897da6589d56d0aebaf417bbe91c2d8e3b96203b1efa596e85", size = 80226, upload-time = "2026-05-22T14:49:20.264Z" }, + { url = "https://files.pythonhosted.org/packages/ab/31/2a7dc5f6abb2fca0b6e1610e120419f603650aceb4f1d3ac4cae0354e162/wrapt-2.2.1-cp314-cp314t-win_amd64.whl", hash = "sha256:fd0135d34387f5fd087d9be368ea77ea89cf2451dc1cd1c622d35021bcb3ab50", size = 83835, upload-time = "2026-05-22T14:49:21.634Z" }, + { url = "https://files.pythonhosted.org/packages/9f/c0/782b86e28d1ceebeb74cccea12d2cd3d2ba0bd68e3dec20b1bc5873f6127/wrapt-2.2.1-cp314-cp314t-win_arm64.whl", hash = "sha256:f70db64e8266d7c45d3b735f2e08eeb434b5e03da9a479ae42b2e2e486a21a00", size = 80722, upload-time = "2026-05-22T14:49:23.59Z" }, + { url = "https://files.pythonhosted.org/packages/53/46/29ac9daf11a86c22a8c38cd9236c62928ccae83f7ceb06bd3b0467cf9d05/wrapt-2.2.1-py3-none-any.whl", hash = "sha256:3aafea2975caef8ca49400640dde02cc7426e798f24870ed01f490bc3cffd32f", size = 61000, upload-time = "2026-05-22T14:49:41.593Z" }, +]