diff --git a/.github/workflows/dev.yml b/.github/workflows/dev.yml new file mode 100644 index 0000000..a9523a3 --- /dev/null +++ b/.github/workflows/dev.yml @@ -0,0 +1,39 @@ +name: dev checks + +on: + pull_request: + branches: [ dev ] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ['3.12'] + fail-fast: false + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install uv + uv sync + + - name: Run tests with pytest + run: | + make check-with-coverage + + - name: Upload coverage to Codecov + if: matrix.python-version == '3.12' + uses: codecov/codecov-action@v4 + with: + token: ${{ secrets.CODECOV_TOKEN }} + file: ./coverage.xml + fail_ci_if_error: false diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml deleted file mode 100644 index 16abb85..0000000 --- a/.github/workflows/fuzz.yml +++ /dev/null @@ -1,65 +0,0 @@ -name: ASTON Fuzz Testing - -on: - push: - branches: [ "**" ] - pull_request: - branches: [ "**" ] - -jobs: - fuzz-test: - runs-on: ubuntu-latest - - strategy: - matrix: - python-version: ["3.11"] - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python-version }} - - - name: Display Python version - run: python -c "import sys; print(sys.version)" - - - name: Run comprehensive ASTON fuzzer - run: | - echo "========================================" - echo "Running Comprehensive ASTON Fuzzer" - echo "========================================" - python3 tests/aston/fuzz.py --mutations 20 --tests 50 - - - name: Run ASTON round-trip tests on examples - run: | - echo "" - echo "========================================" - echo "Testing ASTON Round-Trip on Examples" - echo "========================================" - for file in examples/*.py; do - echo "Testing $file..." - python3 bb.py aston --test "$file" - done - - - name: Run quick generative fuzzing - run: | - echo "" - echo "========================================" - echo "Quick Generative Fuzzing" - echo "========================================" - SEED=$(python3 -c "import random; print(random.randint(0, 999999))") - echo "Using random seed: $SEED" - echo "To reproduce: python3 tests/aston/fuzz.py --generative --tests 100 --seed $SEED" - python3 tests/aston/fuzz.py --generative --tests 100 --seed $SEED - - - name: Fuzzing Summary - if: always() - run: | - echo "" - echo "========================================" - echo "Fuzzing Complete" - echo "========================================" - echo "All ASTON fuzz tests completed successfully!" diff --git a/.github/workflows/test.yml b/.github/workflows/pre-release.yml similarity index 69% rename from .github/workflows/test.yml rename to .github/workflows/pre-release.yml index 77db1bf..6cd5fc1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/pre-release.yml @@ -1,8 +1,6 @@ -name: Tests +name: pre-release checks on: - push: - branches: [ main, 'claude/*' ] pull_request: branches: [ main ] @@ -25,19 +23,15 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install -r requirements-dev.txt + pip install uv + uv sync - name: Run tests with pytest run: | - pytest -v --tb=short - - - name: Run tests with coverage - if: matrix.python-version == '3.11' - run: | - pytest --cov=bb --cov-report=term --cov-report=xml + make check-with-coverage - name: Upload coverage to Codecov - if: matrix.python-version == '3.11' + if: matrix.python-version == '3.12' uses: codecov/codecov-action@v4 with: token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.gitignore b/.gitignore index 0c361cc..e5d3270 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +tmp/ + # Byte-compiled / optimized / DLL files __pycache__/ *.py[codz] diff --git a/Makefile b/Makefile index a9c6248..bddfce6 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help check check-with-coverage check-fuzz clean +.PHONY: help check check-with-coverage check-fuzz check-review clean wip # Default target - show help help: ## Show this help message with all available targets @@ -13,7 +13,7 @@ check: ## Run pytest tests @echo "Running Tests with pytest" @echo "========================================" @echo "" - @pytest -v tests/ + @uv run pytest -v tests/ check-with-coverage: ## Run pytest with coverage reporting (generates htmlcov/) @echo "========================================" @@ -24,7 +24,7 @@ check-with-coverage: ## Run pytest with coverage reporting (generates htmlcov/) @pip3 install coverage pytest-cov --quiet 2>/dev/null || true @echo "" @echo "Running pytest with coverage..." - @pytest --cov=bb --cov=aston --cov-report=term --cov-report=html tests/ + @uv run pytest --cov=bb --cov=bonafide --cov-report=term --cov-report=xml --cov-report=html tests/ @echo "" @echo "✓ HTML coverage report generated in htmlcov/index.html" @@ -50,6 +50,13 @@ check-fuzz: ## Run comprehensive fuzz tests (corpus, mutation, generative) @echo "" @echo "✓ All fuzz tests passed!" +check-review: ## Check GitHub PR review comments (requires gh CLI and authenticated GitHub) + @echo "=======================================" + @echo "Checking GitHub PR Review Comments" + @echo "=======================================" + @echo "" + @./bin/github-review-threads.py + clean: ## Clean up generated files (htmlcov/, .coverage, __pycache__) @echo "Cleaning up generated files..." @rm -rf htmlcov/ @@ -58,3 +65,43 @@ clean: ## Clean up generated files (htmlcov/, .coverage, __pycache__) @find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true @find . -type f -name "*.pyc" -delete 2>/dev/null || true @echo "✓ Cleanup complete" + +cosmit: ## Format code with ruff, lint with --fix, and commit if changes + @echo "=======================================" + @echo "Running cosmit: format, lint, and commit" + @echo "=======================================" + @echo "" + @echo "[1/3] Running ruff format..." + @uv run ruff format --config pyproject.toml + @echo "" + @echo "[2/3] Running ruff check with --fix on main source files..." + @uv run ruff check --fix --config pyproject.toml + @echo "" + @echo "[3/3] Checking for changes and committing..." + @if git diff --quiet; then \ + echo "No changes detected, skipping commit."; \ + else \ + echo "Changes detected, creating cosmit commit..."; \ + git add .; \ + git commit -m "cosmit"; \ + echo "✓ cosmit commit created"; \ + fi + +wip: ## Work In Progress: format, lint, add all files (including untracked), commit with emoji, and push + @echo "=======================================" + @echo "Running WIP: format, lint, commit, and push" + @echo "=======================================" + @echo "" + @echo "[1/4] Running ruff format on all files..." + @uv run ruff format --config pyproject.toml || true + @echo "" + @echo "[2/4] Running ruff check with --fix on all files..." + @uv run ruff check --fix --config pyproject.toml || true + @echo "" + @echo "[3/4] Adding all files (including untracked) and creating WIP commit..." + @git add -A + @git commit -m "🚧 WIP: Work in progress" --no-verify + @echo "" + @echo "[4/4] Pushing with --force and --no-verify..." + @git push --force --no-verify + @echo "✓ WIP commit created and pushed with emoji 🚧" diff --git a/CLAUDE.md b/VIBE.md similarity index 65% rename from CLAUDE.md rename to VIBE.md index ce0fbbe..eed5f9a 100644 --- a/CLAUDE.md +++ b/VIBE.md @@ -1,4 +1,17 @@ -# CLAUDE.md - AI Assistant Guide for Beyond Babel +# VIBE.md - AI Assistant Guide for Beyond Babel + +- Python 3.12; +- Function-based, no keyword `class`, instead use collections.namedtuple, or `dict`; +- Package manager: uv; +- Test runner: pytest with coverage support; +- Few big files instead of many small files, that is easier to navigate; +- Test strategy: Grey box integration test, test public interface, and + fallback to level to check correctness, avoid monkeypatching; Also fuzzing; +- Use the directory ./tmp/ for temporary files, including database, and scripts; +- Never use global variables; +- Never use shorthand words that are not proper words such as conn, + perm, ana, etc... You can use: cnx, txn; +- Avoid top-level function calls whenever possible; prefer using functions that can be called with parameters; ## Project Overview @@ -61,21 +74,6 @@ Store in $HOME/.local/bb/pool/ (or $BB_DIRECTORY/pool/) with: ### Project Structure -``` -bb.py/ -├── bb.py # Main CLI tool -├── examples/ # Example functions directory -│ ├── README.md # Examples documentation -│ ├── example_simple.py # English example -│ ├── example_simple_french.py # French example (same logic) -│ ├── example_simple_spanish.py # Spanish example (same logic) -│ ├── example_with_import.py # Example with stdlib imports -│ └── example_with_bb.py # Example calling other pool functions -├── strategies/ # Design documents -├── tests/ # Test suite -└── .gitignore # Ignores __pycache__, etc. -``` - ### Function Pool Structure (v1) ``` @@ -97,181 +95,6 @@ $HOME/.local/bb/pool/ # Default location (or $BB_DIRECTORY/pool/) ### Core Classes -#### `ASTNormalizer` (lines 20-40) -- **Purpose**: Transform AST by renaming variables/functions according to mapping -- **Methods**: - - `visit_Name()`: Rename variable references - - `visit_arg()`: Rename function arguments - - `visit_FunctionDef()`: Rename function definitions - -### Core Functions - -#### `ast_normalize(tree, lang)` (lines 272-318) -**Central normalization pipeline** -- Sorts imports lexicographically -- Extracts function definition and imports -- Extracts docstring separately -- Rewrites `from bb.pool import X as Y` → `from bb.pool import X` (removes alias) -- Creates name mappings (`original → _bb_v_X`) -- Returns: normalized code (with/without docstring), docstring, mappings - -#### `mapping_create_name(function_def, imports, bb_aliases)` (lines 133-180) -**Generates bidirectional name mappings** -- Function name always gets `_bb_v_0` -- Variables/args get sequential indices: `_bb_v_1`, `_bb_v_2`, ... -- **Excluded from renaming**: Python builtins, imported names, bb aliases -- Returns: `(forward_mapping, reverse_mapping)` - -#### `imports_rewrite_bb(imports)` (lines 183-213) -**Transforms bb imports for normalization** -- Rewrites: `from bb.pool import HASH as alias` → `from bb.pool import HASH` (removes alias) -- Tracks alias mappings for later denormalization -- Necessary because normalized code uses `HASH._bb_v_0(...)` instead of `alias(...)` - -#### `calls_replace_bb(tree, alias_mapping, name_mapping)` (lines 216-235) -**Replaces aliased function calls with normalized form** -- Transforms: `alias(...)` → `HASH._bb_v_0(...)` -- Uses alias_mapping to determine which names are bb functions - -#### `hash_compute(code)` (lines 321-335) -**Generates SHA256 hash** -- CRITICAL: Hash computed on code **WITHOUT docstring** -- Ensures same logic = same hash across languages -- Returns 64-character hex output - -#### `mapping_compute_hash(docstring, name_mapping, alias_mapping, comment='')` (lines 338-371) -**Computes content-addressed hash for language mappings** (Schema v1) -- Creates canonical JSON from mapping components (sorted keys, no whitespace) -- Includes comment field in hash to distinguish variants -- Enables deduplication: identical mappings share same hash/storage -- Returns: 64-character hex SHA256 hash - -#### `schema_detect_version(func_hash)` (lines 374-406) -**Detects if a function exists in the pool** -- Checks filesystem for v1 format: `pool/XX/YYYYYY.../object.json` -- Returns: 1 if found, None if not found - -#### `metadata_create()` (lines 409-435) -**Generates default metadata for functions** (Schema v1) -- ISO 8601 timestamp (`created` field) -- Author from environment (USER or USERNAME) -- Returns: Dictionary with metadata structure -- Used when saving functions to v1 format - -#### `function_save_v1(hash_value, normalized_code, metadata)` (lines 495-532) -**Stores function in v1 format** (Schema v1) -- Creates function directory: `$BB_DIRECTORY/pool/XX/YYYYYY.../` -- Writes `object.json` with schema_version=1, metadata -- Does NOT store language-specific data (stored separately in mapping files) -- Clean separation: code in object.json, language variants in mapping.json files - -#### `mapping_save_v1(func_hash, lang, docstring, name_mapping, alias_mapping, comment='')` (lines 534-585) -**Stores language mapping in v1 format** (Schema v1) -- Creates mapping directory: `$BB_DIRECTORY/pool/XX/Y.../lang/ZZ/W.../` -- Writes `mapping.json` with docstring, name_mapping, alias_mapping, comment -- Content-addressed by mapping hash (enables deduplication) -- Identical mappings across functions share same file -- Returns: mapping hash for verification - -#### `function_save(hash_value, lang, normalized_code, docstring, name_mapping, alias_mapping, comment='')` (lines 471-494) -**Main entry point for saving functions** (Schema v1) -- Wrapper that calls function_save_v1() + mapping_save_v1() -- Creates metadata automatically using metadata_create() -- Accepts optional comment parameter for mapping variant identification -- **This is the default save function** - all new code uses v1 format - -#### `function_load_v1(hash_value)` (lines 2072-2100) -**Loads function from pool using schema v1** -- Reads object.json: `$BB_DIRECTORY/pool/XX/YYYYYY.../object.json` -- Returns: Dictionary with schema_version, hash, normalized_code, metadata -- Does NOT load language-specific data (use mapping functions for that) - -#### `mappings_list_v1(func_hash, lang)` (lines 851-909) -**Lists all mapping variants for a language** (Schema v1) -- Scans language directory: `$BB_DIRECTORY/pool/XX/Y.../lang/` -- Returns: List of (mapping_hash, comment) tuples -- Used to discover available mapping variants -- Returns empty list if language doesn't exist - -#### `mapping_load_v1(func_hash, lang, mapping_hash)` (lines 912-950) -**Loads specific language mapping** (Schema v1) -- Reads mapping.json: `$BB_DIRECTORY/pool/XX/Y.../lang/ZZ/W.../mapping.json` -- Returns: Tuple of (docstring, name_mapping, alias_mapping, comment) -- Content-addressed storage enables deduplication - -#### `function_load(hash_value, lang, mapping_hash=None)` (lines 953-1011) -**Main entry point for loading functions** -- Calls function_load_v1() + mapping_load_v1() -- If multiple mappings exist and no mapping_hash specified, picks first alphabetically -- Returns: Tuple of (normalized_code, name_mapping, alias_mapping, docstring) -- **This is the default load function** - -#### `function_show(hash_with_lang_and_mapping)` (lines 1014-1107) -**Show function with mapping exploration and selection** -- Supports three formats: `HASH@LANG`, `HASH@LANG@MAPPING_HASH` -- Single mapping: Outputs code directly to stdout -- Multiple mappings: Displays selection menu with copyable commands and comments -- Explicit mapping hash: Directly outputs specified mapping -- Uses function_load() + code_denormalize() to reconstruct original code -- **This is the recommended command** for exploring and viewing functions -- **Note**: CLI command `bb.py show HASH@lang[@mapping_hash]` - -#### `code_denormalize(normalized_code, name_mapping, alias_mapping)` (lines 497-679) -**Reconstructs original-looking code** -- Reverses variable renaming: `_bb_v_X → original_name` -- Rewrites imports: `from bb.pool import X` → `from bb.pool import X as alias` (restores alias) -- Transforms calls: `HASH._bb_v_0(...)` → `alias(...)` - -### Storage Schema - -See [STORE.md](STORE.md) for the complete storage specification. - -**Directory Structure:** -``` -$BB_DIRECTORY/pool/ # Default: $HOME/.local/bb/pool/ - ab/ # First 2 chars of function hash - c123def456.../ # Function directory (remaining hash chars) - object.json # Core function data - eng/ # Language code directory - xy/ # First 2 chars of mapping hash - z789.../ # Mapping directory (remaining hash chars) - mapping.json # Language mapping (content-addressed) - fra/ # Another language - mn/ - opqr.../ - mapping.json # Another language/variant -``` - -**object.json**: -```json -{ - "schema_version": 1, - "hash": "abc123...", - "normalized_code": "def _bb_v_0(...):\n ...", - "metadata": { - "created": "2025-11-21T10:00:00Z", - "author": "username" - } -} -``` - -**mapping.json** (in lang/XX/YYY.../): -```json -{ - "docstring": "Calculate the average...", - "name_mapping": {"_bb_v_0": "calculate_average", "_bb_v_1": "numbers"}, - "alias_mapping": {"abc123": "helper"}, - "comment": "Formal mathematical terminology" -} -``` - -Key features: -- Language identifiers up to 256 characters -- Multiple mappings per language via multiple mapping.json files -- Content-addressed mapping storage (deduplicated across functions) -- No duplication between object.json and mapping.json -- Extensible metadata (author, timestamp) - ## Development Conventions ### Python Code Style @@ -310,7 +133,7 @@ Key features: **Rationale**: This convention groups related operations alphabetically and makes the primary subject clear at a glance, which is particularly useful in a function-centric architecture where functions are the primary unit of composition. -**Avoid type_name proliferation**: Too many distinct type_names hurts readability and discoverability. Prefer consolidating related concepts under a common prefix. Target type_names: `code_`, `command_`, `compile_`, `git_`, `helper_`, `storage_`, `hash_`. +**Avoid type_name proliferation**: Too many distinct type_names hurts readability and discoverability. Prefer consolidating related concepts under a common prefix. **Special type_name `helper_`**: Use `helper_` prefix for utility functions that don't fit other categories (e.g., UI interactions, generic operations). Example: `helper_open_editor_for_message()`. @@ -367,19 +190,6 @@ Beyond Babel follows a **grey-box integration testing** approach as the primary ### Directory Structure -``` -tests/ -├── conftest.py # Shared fixtures (CLIRunner, normalize_code_for_test) -├── integration/ -│ └── test_workflows.py # End-to-end workflows combining multiple commands -├── add/ -│ └── test_add.py # Tests for 'bb.py add' command -├── show/ -│ └── test_show.py # Tests for 'bb.py show' command -├── test_internals.py # Unit tests for complex algorithms -└── test_storage.py # Storage schema validation tests -``` - ### Grey-Box Integration Tests Grey-box tests call CLI commands but verify internal state: @@ -420,18 +230,11 @@ These tests live in `tests/test_internals.py`. ```bash # Run all tests -pytest - -# Run integration tests only -pytest tests/integration/ tests/add/ tests/show/ - -# Run unit tests only -pytest tests/test_internals.py tests/test_storage.py +make check check-fuzz # Run with coverage -pytest --cov=bb --cov-report=html +make check-cov -# Run tests matching pattern pytest -k "add" ``` @@ -443,20 +246,6 @@ pytest -k "add" ### Running Examples -```bash -# Add examples to pool -python3 bb.py add examples/example_simple.py@eng -python3 bb.py add examples/example_simple_french.py@fra -python3 bb.py add examples/example_simple_spanish.py@spa - -# Verify they share the same hash -find ~/.local/bb/pool -name "object.json" # or $BB_DIRECTORY/pool/ - -# Show in different language -python3 bb.py show HASH@eng -python3 bb.py show HASH@fra -``` - ### Verification Checklist - [ ] Imports are sorted lexicographically @@ -472,8 +261,8 @@ python3 bb.py show HASH@fra ### Branch Strategy - `main`: Stable releases -- `claude/*`: AI-generated feature branches -- Pull requests required for merging to main +- `dev`: working branch +- Pull requests required for merging to `dev`, or `main` ### Commit Message Style @@ -645,6 +434,16 @@ stored = { 5. **Don't break import sorting**: Lexicographic order is part of normalization 6. **Don't create duplicate mappings**: `_bb_v_0` is ALWAYS the function name +## Vibe-Specific Guidelines + +When working as Vibe on this project: + +1. **Follow the structured approach**: Use the todo system for complex tasks +2. **Leverage the available tools**: Use grep, search_replace, and other tools effectively +3. **Maintain consistency**: Follow the existing code patterns and conventions +4. **Test thoroughly**: Use the comprehensive test suite to verify changes +5. **Document changes**: Update documentation as needed for new features + ## Extension Points ### Adding New Features @@ -693,6 +492,16 @@ When referencing code locations, use this format: 5. Is the JSON schema still backward compatible? 6. Are error messages helpful to users? +## Vibe Workflow Integration + +As Vibe, integrate with the project workflow: + +1. **Use the todo system**: Break down tasks and track progress +2. **Follow the testing strategy**: Grey-box integration tests are primary +3. **Respect the architecture**: Single-file design with clear function boundaries +4. **Maintain documentation**: Keep VIBE.md updated with new insights +5. **Collaborate effectively**: Use the available tools for efficient development + ## Debugging Tips 1. **Inspect JSON**: `cat ~/.local/bb/pool/XX/YYY.../object.json | python3 -m json.tool` diff --git a/bb.py b/bb.py index a277800..5d283ff 100755 --- a/bb.py +++ b/bb.py @@ -2,6 +2,7 @@ """ bb - A function pool manager for Python code """ + import ast import argparse import builtins @@ -18,7 +19,7 @@ from collections import namedtuple from contextlib import contextmanager from pathlib import Path -from typing import Dict, Set, Tuple, List, Union, Any, Generator, Callable, Optional +from typing import Dict, Set, Tuple, List, Union, Any, Generator, Optional # Get all Python built-in names @@ -30,13 +31,12 @@ BB_IMPORT_PREFIX = "object_" - ### ORDER-PRESERVING ENCODING ### # Minimal order-preserving encoding for tuples (stdlib only, similar to FoundationDB) # BBH (Beyond Babel Hash) type for content-addressed references # Stores a SHA256 hash (32 bytes) for referencing pool functions or ASTON nodes -BBH = namedtuple('BBH', ['value']) +BBH = namedtuple("BBH", ["value"]) # Type codes for order preservation _ENCODE_NULL = 0x00 @@ -53,7 +53,7 @@ _ENCODE_BBH = 0x0B -def bytes_write_one(value: Any, nested: bool = False) -> bytes: +def storage_bytes_write_one(value: Any, nested: bool = False) -> bytes: """Encode a single value to bytes with order preservation. Args: @@ -68,18 +68,22 @@ def bytes_write_one(value: Any, nested: bool = False) -> bytes: elif isinstance(value, bool): return bytes([_ENCODE_TRUE if value else _ENCODE_FALSE]) elif isinstance(value, bytes): - return bytes([_ENCODE_BYTES]) + value.replace(b'\x00', b'\x00\xFF') + b'\x00' + return bytes([_ENCODE_BYTES]) + value.replace(b"\x00", b"\x00\xff") + b"\x00" elif isinstance(value, str): - return bytes([_ENCODE_STRING]) + value.encode('utf-8').replace(b'\x00', b'\x00\xFF') + b'\x00' + return ( + bytes([_ENCODE_STRING]) + + value.encode("utf-8").replace(b"\x00", b"\x00\xff") + + b"\x00" + ) elif value == 0: return bytes([_ENCODE_INT_ZERO]) elif isinstance(value, int): if value > 0: - return bytes([_ENCODE_INT_POS]) + struct.pack('>Q', value) + return bytes([_ENCODE_INT_POS]) + struct.pack(">Q", value) else: - return bytes([_ENCODE_INT_NEG]) + struct.pack('>Q', (1 << 64) - 1 + value) + return bytes([_ENCODE_INT_NEG]) + struct.pack(">Q", (1 << 64) - 1 + value) elif isinstance(value, float): - bits = struct.pack('>d', value) + bits = struct.pack(">d", value) # Flip sign bit, or flip all bits if negative if bits[0] & 0x80: bits = bytes(b ^ 0xFF for b in bits) @@ -95,21 +99,31 @@ def bytes_write_one(value: Any, nested: bool = False) -> bytes: # value can be bytes or hex string if isinstance(value.value, bytes): if len(value.value) != 32: - raise ValueError(f"BBH bytes must be exactly 32 bytes, got {len(value.value)}") + raise ValueError( + f"BBH bytes must be exactly 32 bytes, got {len(value.value)}" + ) return bytes([_ENCODE_BBH]) + value.value elif isinstance(value.value, str): if len(value.value) != 64: - raise ValueError(f"BBH hex string must be exactly 64 characters, got {len(value.value)}") + raise ValueError( + f"BBH hex string must be exactly 64 characters, got {len(value.value)}" + ) return bytes([_ENCODE_BBH]) + bytes.fromhex(value.value) else: - raise ValueError(f"BBH value must be bytes or hex string, got {type(value.value)}") + raise ValueError( + f"BBH value must be bytes or hex string, got {type(value.value)}" + ) elif isinstance(value, (tuple, list)): - return bytes([_ENCODE_NESTED]) + b''.join(bytes_write_one(v, True) for v in value) + bytes([0x00]) + return ( + bytes([_ENCODE_NESTED]) + + b"".join(storage_bytes_write_one(v, True) for v in value) + + bytes([0x00]) + ) else: raise ValueError(f"Unsupported type for encoding: {type(value)}") -def bytes_read_one(data: bytes, pos: int = 0) -> Tuple[Any, int]: +def storage_bytes_read_one(data: bytes, pos: int = 0) -> Tuple[Any, int]: """Decode a single value from bytes. Args: @@ -128,39 +142,42 @@ def bytes_read_one(data: bytes, pos: int = 0) -> Tuple[Any, int]: if data[end] == 0x00 and (end + 1 >= len(data) or data[end + 1] != 0xFF): break end += 1 if data[end] != 0x00 else 2 - return (data[pos + 1:end].replace(b'\x00\xFF', b'\x00'), end + 1) + return (data[pos + 1 : end].replace(b"\x00\xff", b"\x00"), end + 1) elif code == _ENCODE_STRING: end = pos + 1 while end < len(data): if data[end] == 0x00 and (end + 1 >= len(data) or data[end + 1] != 0xFF): break end += 1 if data[end] != 0x00 else 2 - return (data[pos + 1:end].replace(b'\x00\xFF', b'\x00').decode('utf-8'), end + 1) + return ( + data[pos + 1 : end].replace(b"\x00\xff", b"\x00").decode("utf-8"), + end + 1, + ) elif code == _ENCODE_INT_ZERO: return (0, pos + 1) elif code == _ENCODE_INT_POS: - return (struct.unpack('>Q', data[pos + 1:pos + 9])[0], pos + 9) + return (struct.unpack(">Q", data[pos + 1 : pos + 9])[0], pos + 9) elif code == _ENCODE_INT_NEG: - val = struct.unpack('>Q', data[pos + 1:pos + 9])[0] + val = struct.unpack(">Q", data[pos + 1 : pos + 9])[0] return (val - ((1 << 64) - 1), pos + 9) elif code == _ENCODE_FLOAT: - bits = bytearray(data[pos + 1:pos + 9]) + bits = bytearray(data[pos + 1 : pos + 9]) if bits[0] & 0x80: bits[0] ^= 0x80 else: bits = bytes(b ^ 0xFF for b in bits) - return (struct.unpack('>d', bytes(bits))[0], pos + 9) + return (struct.unpack(">d", bytes(bits))[0], pos + 9) elif code == _ENCODE_TRUE: return (True, pos + 1) elif code == _ENCODE_FALSE: return (False, pos + 1) elif code == _ENCODE_UUID: # UUIDs are stored as 16 bytes (128 bits) - return (uuid.UUID(bytes=data[pos + 1:pos + 17]), pos + 17) + return (uuid.UUID(bytes=data[pos + 1 : pos + 17]), pos + 17) elif code == _ENCODE_BBH: # BBH stores a SHA256 hash (32 bytes) # Return as hex string for easier use - hash_bytes = data[pos + 1:pos + 33] + hash_bytes = data[pos + 1 : pos + 33] return (BBH(hash_bytes.hex()), pos + 33) elif code == _ENCODE_NESTED: result = [] @@ -173,14 +190,14 @@ def bytes_read_one(data: bytes, pos: int = 0) -> Tuple[Any, int]: else: break else: - val, pos = bytes_read_one(data, pos) + val, pos = storage_bytes_read_one(data, pos) result.append(val) return (tuple(result), pos + 1) else: raise ValueError(f"Unknown encode type code: {code}") -def bytes_write(items: Tuple) -> bytes: +def storage_bytes_write(items: Tuple) -> bytes: """Encode a tuple to bytes with order preservation. Args: @@ -189,10 +206,10 @@ def bytes_write(items: Tuple) -> bytes: Returns: Encoded bytes that preserve lexicographic order """ - return b''.join(bytes_write_one(item) for item in items) + return b"".join(storage_bytes_write_one(item) for item in items) -def bytes_read(data: bytes) -> Tuple: +def storage_bytes_read(data: bytes) -> Tuple: """Decode bytes back to tuple. Args: @@ -204,17 +221,17 @@ def bytes_read(data: bytes) -> Tuple: result = [] pos = 0 while pos < len(data): - val, pos = bytes_read_one(data, pos) + val, pos = storage_bytes_read_one(data, pos) result.append(val) return tuple(result) -def bytes_next(data: bytes) -> Optional[bytes]: +def storage_bytes_next(data: bytes) -> Optional[bytes]: """Compute next byte sequence for exclusive upper bound in range queries. Given a byte sequence, returns the smallest byte sequence that is greater than all byte sequences starting with the input. This is useful for prefix - scans: query from `prefix` to `bytes_next(prefix)` to get all keys with + scans: query from `prefix` to `storage_bytes_next(prefix)` to get all keys with that prefix. Args: @@ -224,13 +241,13 @@ def bytes_next(data: bytes) -> Optional[bytes]: Next byte sequence, or None if no successor exists (all bytes are 0xFF) Examples: - bytes_next(b'abc') == b'abd' # increment last byte - bytes_next(b'ab\\xff') == b'ac' # skip 0xFF, increment previous byte - bytes_next(b'\\xff\\xff') is None # no successor possible - bytes_next(b'') == b'\\x00' # smallest non-empty sequence + storage_bytes_next(b'abc') == b'abd' # increment last byte + storage_bytes_next(b'ab\\xff') == b'ac' # skip 0xFF, increment previous byte + storage_bytes_next(b'\\xff\\xff') is None # no successor possible + storage_bytes_next(b'') == b'\\x00' # smallest non-empty sequence """ if not data: - return b'\x00' + return b"\x00" # Find rightmost byte that's not 0xFF for i in range(len(data) - 1, -1, -1): @@ -242,7 +259,7 @@ def bytes_next(data: bytes) -> Optional[bytes]: return None -def ulid() -> uuid.UUID: +def storage_storage_ulid() -> uuid.UUID: """Generate a ULID (Universally Unique Lexicographically Sortable Identifier). ULIDs are 128-bit identifiers compatible with UUIDs but designed for better @@ -256,12 +273,12 @@ def ulid() -> uuid.UUID: uuid.UUID object containing the ULID Reference: - https://github.com/ulid/spec + https://github.com/storage_ulid/spec Example: - >>> id1 = ulid() + >>> id1 = storage_ulid() >>> time.sleep(0.001) - >>> id2 = ulid() + >>> id2 = storage_ulid() >>> id1 < id2 # ULIDs are lexicographically sortable True """ @@ -272,21 +289,22 @@ def ulid() -> uuid.UUID: timestamp_ms = timestamp_ms & 0xFFFFFFFFFFFF # Pack timestamp as 6 bytes (48 bits) in big-endian format - timestamp_bytes = timestamp_ms.to_bytes(6, byteorder='big') + timestamp_bytes = timestamp_ms.to_bytes(6, byteorder="big") # Generate 10 bytes (80 bits) of random data random_bytes = os.urandom(10) # Combine timestamp (6 bytes) + random (10 bytes) = 16 bytes (128 bits) - ulid_bytes = timestamp_bytes + random_bytes + storage_ulid_bytes = timestamp_bytes + random_bytes # Convert to UUID - return uuid.UUID(bytes=ulid_bytes) + return uuid.UUID(bytes=storage_ulid_bytes) ### SQLITE3 ORDERED KEY-VALUE STORE ### -def db_open(path: str) -> sqlite3.Connection: + +def storage_db_open(path: str) -> sqlite3.Connection: """Open a SQLite3 ordered key-value store. Args: @@ -296,18 +314,18 @@ def db_open(path: str) -> sqlite3.Connection: SQLite connection """ conn = sqlite3.Connection(path) - conn.execute(''' + conn.execute(""" CREATE TABLE IF NOT EXISTS kv ( key BLOB PRIMARY KEY, value BLOB NOT NULL ) - ''') - conn.execute('CREATE INDEX IF NOT EXISTS idx_key ON kv(key)') + """) + conn.execute("CREATE INDEX IF NOT EXISTS idx_key ON kv(key)") conn.commit() return conn -def db_close(conn: sqlite3.Connection) -> None: +def storage_db_close(conn: sqlite3.Connection) -> None: """Close database connection. Args: @@ -316,7 +334,7 @@ def db_close(conn: sqlite3.Connection) -> None: conn.close() -def db_get(conn: sqlite3.Connection, key: bytes) -> Optional[bytes]: +def storage_db_get(conn: sqlite3.Connection, key: bytes) -> Optional[bytes]: """Get value for key. Args: @@ -326,12 +344,12 @@ def db_get(conn: sqlite3.Connection, key: bytes) -> Optional[bytes]: Returns: Value bytes or None if not found """ - cursor = conn.execute('SELECT value FROM kv WHERE key = ?', (key,)) + cursor = conn.execute("SELECT value FROM kv WHERE key = ?", (key,)) row = cursor.fetchone() return row[0] if row else None -def db_set(conn: sqlite3.Connection, key: bytes, value: bytes) -> None: +def storage_db_set(conn: sqlite3.Connection, key: bytes, value: bytes) -> None: """Set key-value pair. Args: @@ -343,21 +361,29 @@ def db_set(conn: sqlite3.Connection, key: bytes, value: bytes) -> None: AssertionError: If key or value exceeds size limits """ assert len(key) <= 1024, f"Key size {len(key)} exceeds maximum of 1024 bytes" - assert len(value) <= 1048576, f"Value size {len(value)} exceeds maximum of 1048576 bytes" - conn.execute('INSERT OR REPLACE INTO kv (key, value) VALUES (?, ?)', (key, value)) + assert len(value) <= 1048576, ( + f"Value size {len(value)} exceeds maximum of 1048576 bytes" + ) + conn.execute("INSERT OR REPLACE INTO kv (key, value) VALUES (?, ?)", (key, value)) -def db_delete(conn: sqlite3.Connection, key: bytes) -> None: +def storage_db_delete(conn: sqlite3.Connection, key: bytes) -> None: """Delete key-value pair. Args: conn: SQLite connection key: Key to delete """ - conn.execute('DELETE FROM kv WHERE key = ?', (key,)) + conn.execute("DELETE FROM kv WHERE key = ?", (key,)) -def db_query(conn: sqlite3.Connection, key: bytes, other: bytes, offset: int = 0, limit: Optional[int] = None) -> List[Tuple[bytes, bytes]]: +def storage_db_query( + conn: sqlite3.Connection, + key: bytes, + other: bytes, + offset: int = 0, + limit: Optional[int] = None, +) -> List[Tuple[bytes, bytes]]: """Query key-value pairs between key and other. Args: @@ -376,25 +402,31 @@ def db_query(conn: sqlite3.Connection, key: bytes, other: bytes, offset: int = 0 """ if key <= other: # Forward scan: key <= k < other - query = 'SELECT key, value FROM kv WHERE key >= ? AND key < ? ORDER BY key ASC' + query = "SELECT key, value FROM kv WHERE key >= ? AND key < ? ORDER BY key ASC" params: List[Any] = [key, other] else: # Reverse scan: other <= k < key, descending order - query = 'SELECT key, value FROM kv WHERE key >= ? AND key < ? ORDER BY key DESC' + query = "SELECT key, value FROM kv WHERE key >= ? AND key < ? ORDER BY key DESC" params = [other, key] if limit is not None: - query += ' LIMIT ? OFFSET ?' + query += " LIMIT ? OFFSET ?" params.extend([limit, offset]) elif offset > 0: - query += ' OFFSET ?' + query += " OFFSET ?" params.append(offset) cursor = conn.execute(query, params) return [(row[0], row[1]) for row in cursor] -def db_bytes(conn: sqlite3.Connection, key: bytes, other: bytes, offset: int = 0, limit: Optional[int] = None) -> int: +def storage_db_bytes( + conn: sqlite3.Connection, + key: bytes, + other: bytes, + offset: int = 0, + limit: Optional[int] = None, +) -> int: """Sum the length of bytes in keys and values between key and other. Args: @@ -413,27 +445,37 @@ def db_bytes(conn: sqlite3.Connection, key: bytes, other: bytes, offset: int = 0 """ if key <= other: # Forward scan: key <= k < other - base_query = 'SELECT key, value FROM kv WHERE key >= ? AND key < ? ORDER BY key ASC' + base_query = ( + "SELECT key, value FROM kv WHERE key >= ? AND key < ? ORDER BY key ASC" + ) params: List[Any] = [key, other] else: # Reverse scan: other <= k < key, descending order - base_query = 'SELECT key, value FROM kv WHERE key >= ? AND key < ? ORDER BY key DESC' + base_query = ( + "SELECT key, value FROM kv WHERE key >= ? AND key < ? ORDER BY key DESC" + ) params = [other, key] if limit is not None: - base_query += ' LIMIT ? OFFSET ?' + base_query += " LIMIT ? OFFSET ?" params.extend([limit, offset]) elif offset > 0: - base_query += ' OFFSET ?' + base_query += " OFFSET ?" params.append(offset) # Wrap in SUM query - query = f'SELECT COALESCE(SUM(LENGTH(key) + LENGTH(value)), 0) FROM ({base_query})' + query = f"SELECT COALESCE(SUM(LENGTH(key) + LENGTH(value)), 0) FROM ({base_query})" cursor = conn.execute(query, params) return cursor.fetchone()[0] -def db_count(conn: sqlite3.Connection, key: bytes, other: bytes, offset: int = 0, limit: Optional[int] = None) -> int: +def storage_db_count( + conn: sqlite3.Connection, + key: bytes, + other: bytes, + offset: int = 0, + limit: Optional[int] = None, +) -> int: """Count the number of keys between key and other. Args: @@ -452,22 +494,22 @@ def db_count(conn: sqlite3.Connection, key: bytes, other: bytes, offset: int = 0 """ if key <= other: # Forward scan: key <= k < other - base_query = 'SELECT key FROM kv WHERE key >= ? AND key < ? ORDER BY key ASC' + base_query = "SELECT key FROM kv WHERE key >= ? AND key < ? ORDER BY key ASC" params: List[Any] = [key, other] else: # Reverse scan: other <= k < key, descending order - base_query = 'SELECT key FROM kv WHERE key >= ? AND key < ? ORDER BY key DESC' + base_query = "SELECT key FROM kv WHERE key >= ? AND key < ? ORDER BY key DESC" params = [other, key] if limit is not None: - base_query += ' LIMIT ? OFFSET ?' + base_query += " LIMIT ? OFFSET ?" params.extend([limit, offset]) elif offset > 0: - base_query += ' OFFSET ?' + base_query += " OFFSET ?" params.append(offset) # Wrap in COUNT query - query = f'SELECT COUNT(*) FROM ({base_query})' + query = f"SELECT COUNT(*) FROM ({base_query})" cursor = conn.execute(query, params) return cursor.fetchone()[0] @@ -481,7 +523,7 @@ def db_count(conn: sqlite3.Connection, key: bytes, other: bytes, offset: int = 0 # - value: Atomic data (None/str/int/float/bool) or hash reference (64-char hex) -def aston_write(node: ast.AST) -> Tuple[str, List[Tuple]]: +def code_aston_write(node: ast.AST) -> Tuple[str, List[Tuple]]: """Convert an AST node to ASTON tuples. Args: @@ -493,7 +535,7 @@ def aston_write(node: ast.AST) -> Tuple[str, List[Tuple]]: - all_tuples: List of (content_hash, key, index, value) tuples for this node and all descendants """ all_tuples = [] - obj = {'__class__.__name__': node.__class__.__name__} + obj = {"__class__.__name__": node.__class__.__name__} # Process all fields and build obj for hashing field_data = {} @@ -501,16 +543,16 @@ def aston_write(node: ast.AST) -> Tuple[str, List[Tuple]]: for field, value in ast.iter_fields(node): if value is None: obj[field] = None - field_data[field] = ('scalar', None) + field_data[field] = ("scalar", None) elif isinstance(value, (str, int, float, bool)): obj[field] = value - field_data[field] = ('scalar', value) + field_data[field] = ("scalar", value) elif isinstance(value, list): obj[field] = [] list_items = [] for item in value: if isinstance(item, ast.AST): - child_hash, child_tuples = aston_write(item) + child_hash, child_tuples = code_aston_write(item) all_tuples.extend(child_tuples) obj[field].append(child_hash) list_items.append(child_hash) @@ -519,29 +561,29 @@ def aston_write(node: ast.AST) -> Tuple[str, List[Tuple]]: list_items.append(item) # Mark empty lists explicitly if not list_items: - field_data[field] = ('empty_list', None) + field_data[field] = ("empty_list", None) else: - field_data[field] = ('list', list_items) + field_data[field] = ("list", list_items) elif isinstance(value, ast.AST): - child_hash, child_tuples = aston_write(value) + child_hash, child_tuples = code_aston_write(value) all_tuples.extend(child_tuples) obj[field] = child_hash - field_data[field] = ('scalar', child_hash) + field_data[field] = ("scalar", child_hash) # Compute content hash from canonical JSON representation canonical = json.dumps(obj, sort_keys=True, ensure_ascii=False) - content_hash = hashlib.sha256(canonical.encode('utf-8')).hexdigest() + content_hash = hashlib.sha256(canonical.encode("utf-8")).hexdigest() # Create tuples for this node - node_tuples = [(content_hash, '__class__.__name__', None, node.__class__.__name__)] + node_tuples = [(content_hash, "__class__.__name__", None, node.__class__.__name__)] for field, (kind, data) in field_data.items(): - if kind == 'scalar': + if kind == "scalar": node_tuples.append((content_hash, field, None, data)) - elif kind == 'empty_list': + elif kind == "empty_list": # Use index -1 to mark empty list node_tuples.append((content_hash, field, -1, None)) - elif kind == 'list': + elif kind == "list": for i, item_value in enumerate(data): node_tuples.append((content_hash, field, i, item_value)) @@ -549,7 +591,7 @@ def aston_write(node: ast.AST) -> Tuple[str, List[Tuple]]: return content_hash, all_tuples -def aston_read(tuples: List[Tuple]) -> ast.AST: +def code_aston_read(tuples: List[Tuple]) -> ast.AST: """Reconstruct AST from ASTON tuples. Args: @@ -579,7 +621,11 @@ def aston_read(tuples: List[Tuple]) -> ast.AST: # Convert array dicts to sorted lists for hash_val, obj in objects.items(): for key, value in list(obj.items()): - if isinstance(value, dict) and value and all(isinstance(k, int) for k in value.keys()): + if ( + isinstance(value, dict) + and value + and all(isinstance(k, int) for k in value.keys()) + ): # Convert {0: v0, 1: v1, ...} to [v0, v1, ...] max_index = max(value.keys()) obj[key] = [value[i] for i in range(max_index + 1)] @@ -592,7 +638,7 @@ def build_ast(hash_val): return ast_nodes[hash_val] obj = objects[hash_val] - node_type = obj['__class__.__name__'] + node_type = obj["__class__.__name__"] # Get the AST class ast_class = getattr(ast, node_type) @@ -600,7 +646,7 @@ def build_ast(hash_val): # Build fields, resolving HC references fields = {} for key, value in obj.items(): - if key == '__class__.__name__': + if key == "__class__.__name__": continue if isinstance(value, str) and len(value) == 64 and value in objects: @@ -626,7 +672,7 @@ def build_ast(hash_val): # Find root node (Module) root_hash = None for hash_val, obj in objects.items(): - if obj.get('__class__.__name__') == 'Module': + if obj.get("__class__.__name__") == "Module": root_hash = hash_val break @@ -648,7 +694,7 @@ def build_ast(hash_val): # The result has cardinality equal to the central binomial coefficient C(n, n//2) -def nstore_indices_verify_coverage(indices: List[List[int]], n: int) -> bool: +def storage_nstore_indices_verify_coverage(indices: List[List[int]], n: int) -> bool: """Verify that indices cover all possible query patterns. Args: @@ -675,7 +721,7 @@ def nstore_indices_verify_coverage(indices: List[List[int]], n: int) -> bool: return True -def nstore_indices(n: int) -> List[List[int]]: +def storage_nstore_indices(n: int) -> List[List[int]]: """Compute minimal set of permutation indices for n-tuple store. This algorithm determines which permuted indices to maintain in the database @@ -695,9 +741,9 @@ def nstore_indices(n: int) -> List[List[int]]: Exactly C(n, n//2) index permutations in lexicographic order Example: - >>> nstore_indices(3) # C(3, 1) = 3 indices + >>> storage_nstore_indices(3) # C(3, 1) = 3 indices [[0, 1, 2], [1, 2, 0], [2, 0, 1]] - >>> nstore_indices(4) # C(4, 2) = 6 indices + >>> storage_nstore_indices(4) # C(4, 2) = 6 indices [[0, 1, 2, 3], [1, 2, 3, 0], [2, 0, 3, 1], [3, 0, 1, 2], [3, 1, 2, 0], [3, 2, 0, 1]] """ tab = list(range(n)) @@ -713,7 +759,7 @@ def nstore_indices(n: int) -> List[List[int]]: found = False for idx in range(len(L) - 1): if L[idx][1] is False and L[idx + 1][1] is True: - remaining = L[:idx] + L[idx + 2:] + remaining = L[:idx] + L[idx + 2 :] i, j = L[idx][0], L[idx + 1][0] L = remaining a.append(j) @@ -728,7 +774,9 @@ def nstore_indices(n: int) -> List[List[int]]: out.sort() # Verify coverage - assert nstore_indices_verify_coverage(out, n), "Generated indices do not cover all combinations" + assert storage_nstore_indices_verify_coverage(out, n), ( + "Generated indices do not cover all combinations" + ) return out @@ -737,14 +785,14 @@ def nstore_indices(n: int) -> List[List[int]]: # Generic tuple store database (SRFI-168 port) # Variable type for pattern matching (using namedtuple instead of class) -Variable = namedtuple('Variable', ['name']) +Variable = namedtuple("Variable", ["name"]) # NStore type (using namedtuple instead of class) -NStore = namedtuple('NStore', ['prefix', 'n', 'indices']) +NStore = namedtuple("NStore", ["prefix", "n", "indices"]) -def nstore_create(prefix: Tuple, n: int) -> NStore: +def storage_nstore_create(prefix: Tuple, n: int) -> NStore: """Create an NStore instance. Args: @@ -754,15 +802,11 @@ def nstore_create(prefix: Tuple, n: int) -> NStore: Returns: NStore instance """ - indices = nstore_indices(n) - return NStore( - prefix=prefix, - n=n, - indices=indices - ) + indices = storage_nstore_indices(n) + return NStore(prefix=prefix, n=n, indices=indices) -def nstore_permute(items: Tuple, index: List[int]) -> Tuple: +def storage_nstore_permute(items: Tuple, index: List[int]) -> Tuple: """Permute tuple elements according to index. Args: @@ -775,7 +819,7 @@ def nstore_permute(items: Tuple, index: List[int]) -> Tuple: return tuple(items[i] for i in index) -def nstore_unpermute(items: Tuple, index: List[int]) -> Tuple: +def storage_nstore_unpermute(items: Tuple, index: List[int]) -> Tuple: """Reverse a permutation to get original tuple. Args: @@ -791,7 +835,7 @@ def nstore_unpermute(items: Tuple, index: List[int]) -> Tuple: return tuple(result) -def nstore_add(db: sqlite3.Connection, nstore: NStore, items: Tuple) -> None: +def storage_nstore_add(db: sqlite3.Connection, nstore: NStore, items: Tuple) -> None: """Add a tuple to the nstore. Args: @@ -803,12 +847,12 @@ def nstore_add(db: sqlite3.Connection, nstore: NStore, items: Tuple) -> None: # Add to all permuted indices for subspace, index in enumerate(nstore.indices): - permuted = nstore_permute(items, index) - key = bytes_write(nstore.prefix + (subspace,) + permuted) - db_set(db, key, b'\x01') + permuted = storage_nstore_permute(items, index) + key = storage_bytes_write(nstore.prefix + (subspace,) + permuted) + storage_db_set(db, key, b"\x01") -def nstore_delete(db: sqlite3.Connection, nstore: NStore, items: Tuple) -> None: +def storage_nstore_delete(db: sqlite3.Connection, nstore: NStore, items: Tuple) -> None: """Delete a tuple from the nstore. Args: @@ -820,12 +864,12 @@ def nstore_delete(db: sqlite3.Connection, nstore: NStore, items: Tuple) -> None: # Delete from all permuted indices for subspace, index in enumerate(nstore.indices): - permuted = nstore_permute(items, index) - key = bytes_write(nstore.prefix + (subspace,) + permuted) - db_delete(db, key) + permuted = storage_nstore_permute(items, index) + key = storage_bytes_write(nstore.prefix + (subspace,) + permuted) + storage_db_delete(db, key) -def nstore_ask(db: sqlite3.Connection, nstore: NStore, items: Tuple) -> bool: +def storage_nstore_ask(db: sqlite3.Connection, nstore: NStore, items: Tuple) -> bool: """Check if a tuple exists in the nstore. Args: @@ -839,11 +883,11 @@ def nstore_ask(db: sqlite3.Connection, nstore: NStore, items: Tuple) -> bool: assert len(items) == nstore.n, f"Expected {nstore.n} items, got {len(items)}" # Check base index - key = bytes_write(nstore.prefix + (0,) + items) - return db_get(db, key) is not None + key = storage_bytes_write(nstore.prefix + (0,) + items) + return storage_db_get(db, key) is not None -def nstore_pattern_to_combination(pattern: Tuple) -> List[int]: +def storage_nstore_pattern_to_combination(pattern: Tuple) -> List[int]: """Extract positions of non-variable elements from pattern. Args: @@ -855,7 +899,9 @@ def nstore_pattern_to_combination(pattern: Tuple) -> List[int]: return [i for i, item in enumerate(pattern) if not isinstance(item, Variable)] -def nstore_pattern_to_index(pattern: Tuple, indices: List[List[int]]) -> Tuple[List[int], int]: +def storage_nstore_pattern_to_index( + pattern: Tuple, indices: List[List[int]] +) -> Tuple[List[int], int]: """Find the index and subspace that matches the pattern. Args: @@ -865,7 +911,7 @@ def nstore_pattern_to_index(pattern: Tuple, indices: List[List[int]]) -> Tuple[L Returns: Tuple of (matching_index, subspace_number) """ - combination = nstore_pattern_to_combination(pattern) + combination = storage_nstore_pattern_to_combination(pattern) for subspace, index in enumerate(indices): # Check if any permutation of combination is a prefix of index @@ -876,7 +922,7 @@ def nstore_pattern_to_index(pattern: Tuple, indices: List[List[int]]) -> Tuple[L raise ValueError(f"No matching index found for pattern {pattern}") -def nstore_pattern_to_prefix(pattern: Tuple, index: List[int]) -> Tuple: +def storage_nstore_pattern_to_prefix(pattern: Tuple, index: List[int]) -> Tuple: """Extract the concrete prefix from pattern for range query. Args: @@ -895,7 +941,7 @@ def nstore_pattern_to_prefix(pattern: Tuple, index: List[int]) -> Tuple: return tuple(result) -def nstore_bind_pattern(pattern: Tuple, bindings: Dict[str, Any]) -> Tuple: +def storage_nstore_bind_pattern(pattern: Tuple, bindings: Dict[str, Any]) -> Tuple: """Replace variables in pattern with their bound values. Args: @@ -905,11 +951,17 @@ def nstore_bind_pattern(pattern: Tuple, bindings: Dict[str, Any]) -> Tuple: Returns: Pattern with variables substituted """ - return tuple(bindings[item.name] if isinstance(item, Variable) and item.name in bindings else item - for item in pattern) + return tuple( + bindings[item.name] + if isinstance(item, Variable) and item.name in bindings + else item + for item in pattern + ) -def nstore_bind_tuple(pattern: Tuple, tuple_items: Tuple, seed: Dict[str, Any]) -> Dict[str, Any]: +def storage_nstore_bind_tuple( + pattern: Tuple, tuple_items: Tuple, seed: Dict[str, Any] +) -> Dict[str, Any]: """Bind variables in pattern to values from matching tuple. Args: @@ -927,7 +979,9 @@ def nstore_bind_tuple(pattern: Tuple, tuple_items: Tuple, seed: Dict[str, Any]) return result -def nstore_query(db: sqlite3.Connection, nstore: NStore, pattern: Tuple, *patterns: Tuple) -> List[Dict[str, Any]]: +def storage_nstore_query( + db: sqlite3.Connection, nstore: NStore, pattern: Tuple, *patterns: Tuple +) -> List[Dict[str, Any]]: """Query tuples matching pattern and optional additional where patterns. Args: @@ -942,11 +996,11 @@ def nstore_query(db: sqlite3.Connection, nstore: NStore, pattern: Tuple, *patter Example: # Simple query - for binding in nstore_query(db, store, ('P4X432', 'blog/title', Variable('title'))): + for binding in storage_nstore_query(db, store, ('P4X432', 'blog/title', Variable('title'))): print(binding['title']) # Multi-hop join - for binding in nstore_query( + for binding in storage_nstore_query( db, store, (Variable('blog_uid'), 'blog/title', 'hyper.dev'), (Variable('post_uid'), 'post/blog', Variable('blog_uid')), @@ -955,7 +1009,7 @@ def nstore_query(db: sqlite3.Connection, nstore: NStore, pattern: Tuple, *patter print(binding['post_title']) # Pagination - results = nstore_query(db, store, ('P4X432', 'blog/title', Variable('title'))) + results = storage_nstore_query(db, store, ('P4X432', 'blog/title', Variable('title'))) page = results[20:40] # Skip 20, take 20 """ patterns = [pattern] + list(patterns) @@ -965,40 +1019,44 @@ def nstore_query(db: sqlite3.Connection, nstore: NStore, pattern: Tuple, *patter # Process each pattern for pat in patterns: - assert len(pat) == nstore.n, f"Pattern length {len(pat)} doesn't match nstore size {nstore.n}" + assert len(pat) == nstore.n, ( + f"Pattern length {len(pat)} doesn't match nstore size {nstore.n}" + ) new_bindings = [] for binding in bindings: # Bind variables in pattern with current bindings - bound_pattern = nstore_bind_pattern(pat, binding) + bound_pattern = storage_nstore_bind_pattern(pat, binding) # Find matching index - index, subspace = nstore_pattern_to_index(bound_pattern, nstore.indices) + index, subspace = storage_nstore_pattern_to_index( + bound_pattern, nstore.indices + ) # Build prefix for range query - prefix_items = nstore_pattern_to_prefix(bound_pattern, index) - key_start = bytes_write(nstore.prefix + (subspace,) + prefix_items) - key_end = bytes_next(key_start) + prefix_items = storage_nstore_pattern_to_prefix(bound_pattern, index) + key_start = storage_bytes_write(nstore.prefix + (subspace,) + prefix_items) + key_end = storage_bytes_next(key_start) if key_end is None: # All bytes are 0xFF, use next longer sequence - key_end = key_start + b'\x00' + key_end = key_start + b"\x00" # Range scan - results = db_query(db, key_start, key_end) + results = storage_db_query(db, key_start, key_end) for key, _ in results: # Decode key - unpacked = bytes_read(key) + unpacked = storage_bytes_read(key) # Extract tuple (skip prefix + subspace) - permuted_tuple = unpacked[len(nstore.prefix) + 1:] + permuted_tuple = unpacked[len(nstore.prefix) + 1 :] # Reverse permutation - original_tuple = nstore_unpermute(permuted_tuple, index) + original_tuple = storage_nstore_unpermute(permuted_tuple, index) # Bind variables from pattern - new_binding = nstore_bind_tuple(pat, original_tuple, binding) + new_binding = storage_nstore_bind_tuple(pat, original_tuple, binding) new_bindings.append(new_binding) bindings = new_bindings @@ -1006,8 +1064,74 @@ def nstore_query(db: sqlite3.Connection, nstore: NStore, pattern: Tuple, *patter return bindings +def storage_nstore_count(db: sqlite3.Connection, nstore: NStore, pattern: Tuple) -> int: + """Count tuples matching pattern. + + Args: + db: SQLite connection + nstore: NStore instance + pattern: Query pattern (tuple with Variable and concrete values) + + Returns: + Number of matching tuples + + Example: + count = storage_nstore_count(db, store, ('user123', 'tag', Variable('tag'))) + """ + assert len(pattern) == nstore.n, ( + f"Pattern length {len(pattern)} doesn't match nstore size {nstore.n}" + ) + + # Find matching index + index, subspace = storage_nstore_pattern_to_index(pattern, nstore.indices) + + # Build prefix for range query + prefix_items = storage_nstore_pattern_to_prefix(pattern, index) + key_start = storage_bytes_write(nstore.prefix + (subspace,) + prefix_items) + key_end = storage_bytes_next(key_start) + if key_end is None: + # All bytes are 0xFF, use next longer sequence + key_end = key_start + b"\x00" + + return storage_db_count(db, key_start, key_end) + + +def storage_nstore_bytes(db: sqlite3.Connection, nstore: NStore, pattern: Tuple) -> int: + """Sum the length of bytes in keys and values for tuples matching pattern. + + Args: + db: SQLite connection + nstore: NStore instance + pattern: Query pattern (tuple with Variable and concrete values) + + Returns: + Total bytes (key lengths + value lengths) + + Example: + total = storage_nstore_bytes(db, store, ('user123', 'tag', Variable('tag'))) + """ + assert len(pattern) == nstore.n, ( + f"Pattern length {len(pattern)} doesn't match nstore size {nstore.n}" + ) + + # Find matching index + index, subspace = storage_nstore_pattern_to_index(pattern, nstore.indices) + + # Build prefix for range query + prefix_items = storage_nstore_pattern_to_prefix(pattern, index) + key_start = storage_bytes_write(nstore.prefix + (subspace,) + prefix_items) + key_end = storage_bytes_next(key_start) + if key_end is None: + # All bytes are 0xFF, use next longer sequence + key_end = key_start + b"\x00" + + return storage_db_bytes(db, key_start, key_end) + + @contextmanager -def db_transaction(db: sqlite3.Connection) -> Generator[sqlite3.Connection, None, None]: +def storage_db_transaction( + db: sqlite3.Connection, +) -> Generator[sqlite3.Connection, None, None]: """Context manager for database transactions. Args: @@ -1017,8 +1141,8 @@ def db_transaction(db: sqlite3.Connection) -> Generator[sqlite3.Connection, None SQLite connection within transaction Example: - with db_transaction(db): - nstore_add(db, store, ('a', 'b', 'c')) + with storage_db_transaction(db): + storage_nstore_add(db, store, ('a', 'b', 'c')) """ try: yield db @@ -1028,7 +1152,7 @@ def db_transaction(db: sqlite3.Connection) -> Generator[sqlite3.Connection, None raise -def check(target): +def command_check(target): """ Decorator to mark a function as a test for another pool function. @@ -1044,15 +1168,17 @@ def check(target): A decorator that returns the function unchanged Usage: - from bb import check + from bb import command_check from bb.pool import object_abc123 as my_func - @check(object_abc123) + @command_check(object_abc123) def test_my_func(): return my_func(1, 2) == 3 """ + def decorator(func): return func + return decorator @@ -1116,7 +1242,9 @@ def code_get_import_names(tree: ast.Module) -> Set[str]: return imported -def code_check_unused_imports(tree: ast.Module, imported_names: Set[str], all_names: Set[str]) -> bool: +def code_check_unused_imports( + tree: ast.Module, imported_names: Set[str], all_names: Set[str] +) -> bool: """Check if all imports are used""" for name in imported_names: # Check if the imported name is used anywhere besides the import statement @@ -1146,10 +1274,10 @@ def code_sort_imports(tree: ast.Module) -> ast.Module: # Sort imports by their string representation def import_key(node): if isinstance(node, ast.Import): - return ('import', tuple(sorted(alias.name for alias in node.names))) + return ("import", tuple(sorted(alias.name for alias in node.names))) else: # ImportFrom - module = node.module if node.module else '' - return ('from', module, tuple(sorted(alias.name for alias in node.names))) + module = node.module if node.module else "" + return ("from", module, tuple(sorted(alias.name for alias in node.names))) imports.sort(key=import_key) @@ -1157,7 +1285,9 @@ def import_key(node): return tree -def code_extract_definition(tree: ast.Module) -> Tuple[Union[ast.FunctionDef, ast.AsyncFunctionDef], List[ast.stmt]]: +def code_extract_definition( + tree: ast.Module, +) -> Tuple[Union[ast.FunctionDef, ast.AsyncFunctionDef], List[ast.stmt]]: """Extract the function definition (sync or async) and import statements""" imports = [] function_def = None @@ -1176,12 +1306,14 @@ def code_extract_definition(tree: ast.Module) -> Tuple[Union[ast.FunctionDef, as return function_def, imports -def code_extract_check_decorators(function_def: Union[ast.FunctionDef, ast.AsyncFunctionDef]) -> List[str]: +def code_extract_check_decorators( + function_def: Union[ast.FunctionDef, ast.AsyncFunctionDef], +) -> List[str]: """ Extract target function hashes from @check decorators. The @check decorator marks a function as a test for another function in the pool. - Syntax: @check(object_HASH) where object_HASH is a bb pool import. + Syntax: @command_check(object_HASH) where object_HASH is a bb pool import. Args: function_def: The function definition AST node @@ -1192,23 +1324,26 @@ def code_extract_check_decorators(function_def: Union[ast.FunctionDef, ast.Async checks = [] for decorator in function_def.decorator_list: - # Look for @check(object_HASH) pattern + # Look for @command_check(object_HASH) pattern if isinstance(decorator, ast.Call): # Check if it's a call to 'check' - if isinstance(decorator.func, ast.Name) and decorator.func.id == 'check': + if isinstance(decorator.func, ast.Name) and decorator.func.id == "check": # Get the argument (should be a Name node like 'object_abc123...') if len(decorator.args) == 1 and isinstance(decorator.args[0], ast.Name): arg_name = decorator.args[0].id # Extract hash from object_HASH format if arg_name.startswith(BB_IMPORT_PREFIX): - func_hash = arg_name[len(BB_IMPORT_PREFIX):] + func_hash = arg_name[len(BB_IMPORT_PREFIX) :] checks.append(func_hash) return checks -def code_create_name_mapping(function_def: Union[ast.FunctionDef, ast.AsyncFunctionDef], imports: List[ast.stmt], - bb_aliases: Set[str] = None) -> Tuple[Dict[str, str], Dict[str, str]]: +def code_create_name_mapping( + function_def: Union[ast.FunctionDef, ast.AsyncFunctionDef], + imports: List[ast.stmt], + bb_aliases: Set[str] = None, +) -> Tuple[Dict[str, str], Dict[str, str]]: """ Create mapping from original names to normalized names. Returns (forward_mapping, reverse_mapping) @@ -1232,8 +1367,8 @@ def code_create_name_mapping(function_def: Union[ast.FunctionDef, ast.AsyncFunct counter = 0 # Function name is always _bb_v_0 - forward_mapping[function_def.name] = '_bb_v_0' - reverse_mapping['_bb_v_0'] = function_def.name + forward_mapping[function_def.name] = "_bb_v_0" + reverse_mapping["_bb_v_0"] = function_def.name counter += 1 # Collect all names in the function (excluding imported names, built-ins, and bb aliases) @@ -1242,11 +1377,21 @@ def code_create_name_mapping(function_def: Union[ast.FunctionDef, ast.AsyncFunct seen_names = {function_def.name} all_names = list() for node in ast.walk(function_def): - if isinstance(node, ast.Name) and node.id not in imported_names and node.id not in PYTHON_BUILTINS and node.id not in bb_aliases: + if ( + isinstance(node, ast.Name) + and node.id not in imported_names + and node.id not in PYTHON_BUILTINS + and node.id not in bb_aliases + ): if node.id not in seen_names: seen_names.add(node.id) all_names.append(node.id) - elif isinstance(node, ast.arg) and node.arg not in imported_names and node.arg not in PYTHON_BUILTINS and node.arg not in bb_aliases: + elif ( + isinstance(node, ast.arg) + and node.arg not in imported_names + and node.arg not in PYTHON_BUILTINS + and node.arg not in bb_aliases + ): if node.arg not in seen_names: seen_names.add(node.arg) all_names.append(node.arg) @@ -1255,7 +1400,7 @@ def code_create_name_mapping(function_def: Union[ast.FunctionDef, ast.AsyncFunct # discovery. for name in all_names: - normalized = f'_bb_v_{counter}' + normalized = f"_bb_v_{counter}" forward_mapping[name] = normalized reverse_mapping[normalized] = name counter += 1 @@ -1263,7 +1408,9 @@ def code_create_name_mapping(function_def: Union[ast.FunctionDef, ast.AsyncFunct return forward_mapping, reverse_mapping -def code_rewrite_bb_imports(imports: List[ast.stmt]) -> Tuple[List[ast.stmt], Dict[str, str]]: +def code_rewrite_bb_imports( + imports: List[ast.stmt], +) -> Tuple[List[ast.stmt], Dict[str, str]]: """ Remove aliases from 'bb' imports and track them for later restoration. Returns (new_imports, alias_mapping) @@ -1280,7 +1427,7 @@ def code_rewrite_bb_imports(imports: List[ast.stmt]) -> Tuple[List[ast.stmt], Di alias_mapping = {} for imp in imports: - if isinstance(imp, ast.ImportFrom) and imp.module == 'bb.pool': + if isinstance(imp, ast.ImportFrom) and imp.module == "bb.pool": # Rewrite: from bb.pool import object_c0ffeebad as kawa # To: from bb.pool import object_c0ffeebad new_names = [] @@ -1289,7 +1436,7 @@ def code_rewrite_bb_imports(imports: List[ast.stmt]) -> Tuple[List[ast.stmt], Di # Extract actual hash by stripping the prefix if import_name.startswith(BB_IMPORT_PREFIX): - actual_hash = import_name[len(BB_IMPORT_PREFIX):] + actual_hash = import_name[len(BB_IMPORT_PREFIX) :] else: # Backward compatibility: no prefix (shouldn't happen in new code) actual_hash = import_name @@ -1301,11 +1448,7 @@ def code_rewrite_bb_imports(imports: List[ast.stmt]) -> Tuple[List[ast.stmt], Di # Create new import without alias (but keep object_ prefix in import name) new_names.append(ast.alias(name=import_name, asname=None)) - new_imp = ast.ImportFrom( - module='bb.pool', - names=new_names, - level=0 - ) + new_imp = ast.ImportFrom(module="bb.pool", names=new_names, level=0) new_imports.append(new_imp) else: new_imports.append(imp) @@ -1313,7 +1456,9 @@ def code_rewrite_bb_imports(imports: List[ast.stmt]) -> Tuple[List[ast.stmt], Di return new_imports, alias_mapping -def code_replace_bb_calls(tree: ast.AST, alias_mapping: Dict[str, str], name_mapping: Dict[str, str]): +def code_replace_bb_calls( + tree: ast.AST, alias_mapping: Dict[str, str], name_mapping: Dict[str, str] +): """ Replace calls to aliased bb functions. E.g., kawa(...) becomes object_c0ffeebad._bb_v_0(...) @@ -1321,6 +1466,7 @@ def code_replace_bb_calls(tree: ast.AST, alias_mapping: Dict[str, str], name_map alias_mapping maps actual hash (without prefix) -> alias name The replacement uses object_ to match the import name. """ + class BBCallReplacer(ast.NodeTransformer): def visit_Name(self, node): # If this name is an alias for a bb function @@ -1331,8 +1477,8 @@ def visit_Name(self, node): prefixed_name = BB_IMPORT_PREFIX + func_hash return ast.Attribute( value=ast.Name(id=prefixed_name, ctx=ast.Load()), - attr='_bb_v_0', - ctx=node.ctx + attr="_bb_v_0", + ctx=node.ctx, ) return node @@ -1343,17 +1489,19 @@ def visit_Name(self, node): def code_clear_locations(tree: ast.AST): """Set all line and column information to None""" for node in ast.walk(tree): - if hasattr(node, 'lineno'): + if hasattr(node, "lineno"): node.lineno = None - if hasattr(node, 'col_offset'): + if hasattr(node, "col_offset"): node.col_offset = None - if hasattr(node, 'end_lineno'): + if hasattr(node, "end_lineno"): node.end_lineno = None - if hasattr(node, 'end_col_offset'): + if hasattr(node, "end_col_offset"): node.end_col_offset = None -def code_extract_docstring(function_def: Union[ast.FunctionDef, ast.AsyncFunctionDef]) -> Tuple[str, Union[ast.FunctionDef, ast.AsyncFunctionDef]]: +def code_extract_docstring( + function_def: Union[ast.FunctionDef, ast.AsyncFunctionDef], +) -> Tuple[str, Union[ast.FunctionDef, ast.AsyncFunctionDef]]: """ Extract docstring from function definition (sync or async). Returns (docstring, function_without_docstring) @@ -1362,19 +1510,24 @@ def code_extract_docstring(function_def: Union[ast.FunctionDef, ast.AsyncFunctio # Create a copy of the function without the docstring import copy + func_copy = copy.deepcopy(function_def) # Remove docstring if it exists (first statement is a string constant) - if (func_copy.body and - isinstance(func_copy.body[0], ast.Expr) and - isinstance(func_copy.body[0].value, ast.Constant) and - isinstance(func_copy.body[0].value.value, str)): + if ( + func_copy.body + and isinstance(func_copy.body[0], ast.Expr) + and isinstance(func_copy.body[0].value, ast.Constant) + and isinstance(func_copy.body[0].value.value, str) + ): func_copy.body = func_copy.body[1:] return docstring if docstring else "", func_copy -def code_normalize(tree: ast.Module, lang: str) -> Tuple[str, str, str, Dict[str, str], Dict[str, str]]: +def code_normalize( + tree: ast.Module, lang: str +) -> Tuple[str, str, str, Dict[str, str], Dict[str, str]]: """ Normalize the AST according to bb rules. Returns (normalized_code_with_docstring, normalized_code_without_docstring, docstring, name_mapping, alias_mapping) @@ -1395,11 +1548,15 @@ def code_normalize(tree: ast.Module, lang: str) -> Tuple[str, str, str, Dict[str bb_aliases = set(alias_mapping.values()) # Create name mapping - forward_mapping, reverse_mapping = code_create_name_mapping(function_def, imports, bb_aliases) + forward_mapping, reverse_mapping = code_create_name_mapping( + function_def, imports, bb_aliases + ) # Create two modules: one with docstring (for display) and one without (for hashing) module_with_docstring = ast.Module(body=imports + [function_def], type_ignores=[]) - module_without_docstring = ast.Module(body=imports + [function_without_docstring], type_ignores=[]) + module_without_docstring = ast.Module( + body=imports + [function_without_docstring], type_ignores=[] + ) # Process both modules identically for module in [module_with_docstring, module_without_docstring]: @@ -1420,10 +1577,16 @@ def code_normalize(tree: ast.Module, lang: str) -> Tuple[str, str, str, Dict[str normalized_code_with_docstring = ast.unparse(module_with_docstring) normalized_code_without_docstring = ast.unparse(module_without_docstring) - return normalized_code_with_docstring, normalized_code_without_docstring, docstring, reverse_mapping, alias_mapping + return ( + normalized_code_with_docstring, + normalized_code_without_docstring, + docstring, + reverse_mapping, + alias_mapping, + ) -def hash_compute(code: str, algorithm: str = 'sha256') -> str: +def code_hash_compute(code: str, algorithm: str = "sha256") -> str: """ Compute hash of the code using specified algorithm. @@ -1434,14 +1597,18 @@ def hash_compute(code: str, algorithm: str = 'sha256') -> str: Returns: Hex string of the hash """ - if algorithm == 'sha256': - return hashlib.sha256(code.encode('utf-8')).hexdigest() + if algorithm == "sha256": + return hashlib.sha256(code.encode("utf-8")).hexdigest() else: raise ValueError(f"Unsupported hash algorithm: {algorithm}") -def code_compute_mapping_hash(docstring: str, name_mapping: Dict[str, str], - alias_mapping: Dict[str, str], comment: str = "") -> str: +def code_compute_mapping_hash( + docstring: str, + name_mapping: Dict[str, str], + alias_mapping: Dict[str, str], + comment: str = "", +) -> str: """ Compute content-addressed hash for a mapping. @@ -1463,17 +1630,17 @@ def code_compute_mapping_hash(docstring: str, name_mapping: Dict[str, str], 64-character hex hash (SHA256) """ mapping_dict = { - 'docstring': docstring, - 'name_mapping': name_mapping, - 'alias_mapping': alias_mapping, - 'comment': comment + "docstring": docstring, + "name_mapping": name_mapping, + "alias_mapping": alias_mapping, + "comment": comment, } # Create canonical JSON (sorted keys, no whitespace) canonical_json = json.dumps(mapping_dict, sort_keys=True, ensure_ascii=False) # Compute hash - return hash_compute(canonical_json) + return code_hash_compute(canonical_json) def code_detect_schema(func_hash: str) -> int: @@ -1493,7 +1660,7 @@ def code_detect_schema(func_hash: str) -> int: # Check for v1 format (function directory with object.json) v1_func_dir = pool_dir / func_hash[:2] / func_hash[2:] - v1_object_json = v1_func_dir / 'object.json' + v1_object_json = v1_func_dir / "object.json" if v1_object_json.exists(): return 1 @@ -1502,7 +1669,9 @@ def code_detect_schema(func_hash: str) -> int: return None -def code_create_metadata(parent: str = None, checks: List[str] = None) -> Dict[str, any]: +def code_create_metadata( + parent: str = None, checks: List[str] = None +) -> Dict[str, any]: """ Create default metadata for a function. @@ -1520,27 +1689,23 @@ def code_create_metadata(parent: str = None, checks: List[str] = None) -> Dict[s Returns: Dictionary with metadata fields """ - from datetime import datetime + from datetime import datetime, timezone # Get name and email from config config = storage_read_config() - name = config['user'].get('name', '') - email = config['user'].get('email', '') + name = config["user"].get("name", "") + email = config["user"].get("email", "") # Get current timestamp in ISO 8601 format - timestamp = datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%SZ') + timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") - metadata = { - 'created': timestamp, - 'name': name, - 'email': email - } + metadata = {"created": timestamp, "name": name, "email": email} if parent: - metadata['parent'] = parent + metadata["parent"] = parent if checks: - metadata['checks'] = checks + metadata["checks"] = checks return metadata @@ -1557,12 +1722,12 @@ def storage_get_bb_directory() -> Path: │ └── XX/ # First 2 chars of hash └── config.json # Configuration file """ - env_dir = os.environ.get('BB_DIRECTORY') + env_dir = os.environ.get("BB_DIRECTORY") if env_dir: return Path(env_dir) # Default to $HOME/.local/bb/ - home = os.environ.get('HOME', os.path.expanduser('~')) - return Path(home) / '.local' / 'bb' + home = os.environ.get("HOME", os.path.expanduser("~")) + return Path(home) / ".local" / "bb" def storage_get_pool_directory() -> Path: @@ -1570,7 +1735,7 @@ def storage_get_pool_directory() -> Path: Get the pool directory (git repository) where objects are stored. Returns: $BB_DIRECTORY/pool/ """ - return storage_get_bb_directory() / 'pool' + return storage_get_bb_directory() / "pool" def storage_get_git_directory() -> Path: @@ -1578,7 +1743,7 @@ def storage_get_git_directory() -> Path: Get the git directory where published functions are stored. Returns: $BB_DIRECTORY/git/ """ - return storage_get_bb_directory() / 'git' + return storage_get_bb_directory() / "git" def storage_get_config_path() -> Path: @@ -1587,10 +1752,10 @@ def storage_get_config_path() -> Path: Config is stored in $BB_DIRECTORY/config.json Can be overridden with BB_CONFIG_PATH environment variable for testing. """ - config_override = os.environ.get('BB_CONFIG_PATH') + config_override = os.environ.get("BB_CONFIG_PATH") if config_override: return Path(config_override) - return storage_get_bb_directory() / 'config.json' + return storage_get_bb_directory() / "config.json" def storage_read_config() -> Dict[str, any]: @@ -1602,17 +1767,12 @@ def storage_read_config() -> Dict[str, any]: if not config_path.exists(): return { - 'user': { - 'name': '', - 'email': '', - 'public_key': '', - 'languages': [] - }, - 'remotes': {} + "user": {"name": "", "email": "", "public_key": "", "languages": []}, + "remotes": {}, } try: - with open(config_path, 'r', encoding='utf-8') as f: + with open(config_path, "r", encoding="utf-8") as f: return json.load(f) except (IOError, json.JSONDecodeError) as e: print(f"Error: Failed to read config file: {e}", file=sys.stderr) @@ -1627,7 +1787,7 @@ def storage_write_config(config: Dict[str, any]): config_path.parent.mkdir(parents=True, exist_ok=True) try: - with open(config_path, 'w', encoding='utf-8') as f: + with open(config_path, "w", encoding="utf-8") as f: json.dump(config, f, indent=2, ensure_ascii=False) except IOError as e: print(f"Error: Failed to write config file: {e}", file=sys.stderr) @@ -1654,13 +1814,13 @@ def command_init(): print(f"Config file already exists: {config_path}") else: config = { - 'user': { - 'username': os.environ.get('USER', os.environ.get('USERNAME', '')), - 'email': '', - 'public_key': '', - 'languages': ['eng'] + "user": { + "username": os.environ.get("USER", os.environ.get("USERNAME", "")), + "email": "", + "public_key": "", + "languages": ["eng"], }, - 'remotes': {} + "remotes": {}, } storage_write_config(config) print(f"Created config file: {config_path}") @@ -1680,10 +1840,10 @@ def command_whoami(subcommand: str, value: list = None): # Map CLI subcommand to config key key_map = { - 'name': 'name', - 'email': 'email', - 'public-key': 'public_key', - 'language': 'languages' + "name": "name", + "email": "email", + "public-key": "public_key", + "language": "languages", } if subcommand not in key_map: @@ -1695,23 +1855,23 @@ def command_whoami(subcommand: str, value: list = None): # Get current value if value is None or len(value) == 0: - current = config['user'][config_key] + current = config["user"][config_key] if isinstance(current, list): - print(' '.join(current) if current else '') + print(" ".join(current) if current else "") else: - print(current if current else '') + print(current if current else "") else: # Set new value - if subcommand == 'language': + if subcommand == "language": # Languages is a list - config['user'][config_key] = value + config["user"][config_key] = value else: # Other fields are strings (take first value) - config['user'][config_key] = value[0] + config["user"][config_key] = value[0] storage_write_config(config) - if subcommand == 'language': + if subcommand == "language": print(f"Set {subcommand}: {' '.join(value)}") else: print(f"Set {subcommand}: {value[0]}") @@ -1737,24 +1897,29 @@ def code_save_v1(hash_value: str, normalized_code: str, metadata: Dict[str, any] func_dir.mkdir(parents=True, exist_ok=True) # Create object.json - object_json = func_dir / 'object.json' + object_json = func_dir / "object.json" data = { - 'schema_version': 1, - 'hash': hash_value, - 'normalized_code': normalized_code, - 'metadata': metadata + "schema_version": 1, + "hash": hash_value, + "normalized_code": normalized_code, + "metadata": metadata, } - with open(object_json, 'w', encoding='utf-8') as f: + with open(object_json, "w", encoding="utf-8") as f: json.dump(data, f, indent=2, ensure_ascii=False) print(f"Hash: {hash_value}") -def mapping_save_v1(func_hash: str, lang: str, docstring: str, - name_mapping: Dict[str, str], alias_mapping: Dict[str, str], - comment: str = "") -> str: +def storage_mapping_save_v1( + func_hash: str, + lang: str, + docstring: str, + name_mapping: Dict[str, str], + alias_mapping: Dict[str, str], + comment: str = "", +) -> str: """ Save language mapping to bb directory using schema v1. @@ -1778,7 +1943,9 @@ def mapping_save_v1(func_hash: str, lang: str, docstring: str, pool_dir = storage_get_pool_directory() # Compute mapping hash - mapping_hash = code_compute_mapping_hash(docstring, name_mapping, alias_mapping, comment) + mapping_hash = code_compute_mapping_hash( + docstring, name_mapping, alias_mapping, comment + ) # Create mapping directory: pool/XX/Y.../lang/ZZ/W.../ func_dir = pool_dir / func_hash[:2] / func_hash[2:] @@ -1786,16 +1953,16 @@ def mapping_save_v1(func_hash: str, lang: str, docstring: str, mapping_dir.mkdir(parents=True, exist_ok=True) # Create mapping.json - mapping_json = mapping_dir / 'mapping.json' + mapping_json = mapping_dir / "mapping.json" data = { - 'docstring': docstring, - 'name_mapping': name_mapping, - 'alias_mapping': alias_mapping, - 'comment': comment + "docstring": docstring, + "name_mapping": name_mapping, + "alias_mapping": alias_mapping, + "comment": comment, } - with open(mapping_json, 'w', encoding='utf-8') as f: + with open(mapping_json, "w", encoding="utf-8") as f: json.dump(data, f, indent=2, ensure_ascii=False) print(f"Mapping hash: {mapping_hash}") @@ -1803,9 +1970,17 @@ def mapping_save_v1(func_hash: str, lang: str, docstring: str, return mapping_hash -def code_save(hash_value: str, lang: str, normalized_code: str, docstring: str, - name_mapping: Dict[str, str], alias_mapping: Dict[str, str], comment: str = "", - parent: str = None, checks: List[str] = None): +def code_save( + hash_value: str, + lang: str, + normalized_code: str, + docstring: str, + name_mapping: Dict[str, str], + alias_mapping: Dict[str, str], + comment: str = "", + parent: str = None, + checks: List[str] = None, +): """ Save function to bb directory using schema v1 (current default). @@ -1829,10 +2004,14 @@ def code_save(hash_value: str, lang: str, normalized_code: str, docstring: str, code_save_v1(hash_value, normalized_code, metadata) # Save mapping (mapping.json) - mapping_save_v1(hash_value, lang, docstring, name_mapping, alias_mapping, comment) + storage_mapping_save_v1( + hash_value, lang, docstring, name_mapping, alias_mapping, comment + ) -def code_denormalize(normalized_code: str, name_mapping: Dict[str, str], alias_mapping: Dict[str, str]) -> str: +def code_denormalize( + normalized_code: str, name_mapping: Dict[str, str], alias_mapping: Dict[str, str] +) -> str: """ Denormalize code by applying reverse name mappings. name_mapping: maps normalized names (_bb_v_X) to original names @@ -1878,12 +2057,11 @@ def visit_AsyncFunctionDef(self, node): def visit_Attribute(self, node): # Replace object_c0ffeebad._bb_v_0(...) with alias(...) - if (isinstance(node.value, ast.Name) and - node.attr == '_bb_v_0'): + if isinstance(node.value, ast.Name) and node.attr == "_bb_v_0": prefixed_name = node.value.id # Strip object_ prefix to get actual hash if prefixed_name.startswith(BB_IMPORT_PREFIX): - actual_hash = prefixed_name[len(BB_IMPORT_PREFIX):] + actual_hash = prefixed_name[len(BB_IMPORT_PREFIX) :] else: actual_hash = prefixed_name # Backward compatibility @@ -1895,8 +2073,8 @@ def visit_Attribute(self, node): def visit_ImportFrom(self, node): # Add aliases back to 'from bb.pool import object_X' - if node.module == 'bb.pool': - node.module = 'bb.pool' + if node.module == "bb.pool": + node.module = "bb.pool" # Add aliases back new_names = [] for alias_node in node.names: @@ -1904,17 +2082,18 @@ def visit_ImportFrom(self, node): # Strip object_ prefix to get actual hash if import_name.startswith(BB_IMPORT_PREFIX): - actual_hash = import_name[len(BB_IMPORT_PREFIX):] + actual_hash = import_name[len(BB_IMPORT_PREFIX) :] else: actual_hash = import_name # Backward compatibility if actual_hash in hash_to_alias: # This hash should have an alias # Keep object_ prefix in import name - new_names.append(ast.alias( - name=import_name, - asname=hash_to_alias[actual_hash] - )) + new_names.append( + ast.alias( + name=import_name, asname=hash_to_alias[actual_hash] + ) + ) else: new_names.append(alias_node) node.names = new_names @@ -1930,7 +2109,10 @@ def visit_ImportFrom(self, node): # Git Remote Functions # ============================================================================= -def git_run(args: List[str], cwd: str = None, timeout: int = 60) -> subprocess.CompletedProcess: + +def git_run( + args: List[str], cwd: str = None, timeout: int = 60 +) -> subprocess.CompletedProcess: """ Execute a git command via subprocess.run(). @@ -1946,13 +2128,9 @@ def git_run(args: List[str], cwd: str = None, timeout: int = 60) -> subprocess.C subprocess.CalledProcessError: If git command fails subprocess.TimeoutExpired: If command times out """ - cmd = ['git'] + args + cmd = ["git"] + args result = subprocess.run( - cmd, - cwd=cwd, - capture_output=True, - text=True, - timeout=timeout + cmd, cwd=cwd, capture_output=True, text=True, timeout=timeout ) return result @@ -1973,34 +2151,35 @@ def git_url_parse(url: str) -> Dict[str, str]: Dictionary with keys: protocol, host, path, original_url protocol: 'ssh', 'https', or 'file' """ - result = {'original_url': url} + result = {"original_url": url} - if url.startswith('git@'): + if url.startswith("git@"): # SSH format: git@host:user/repo.git - result['protocol'] = 'ssh' + result["protocol"] = "ssh" # Split on : to get host and path - parts = url[4:].split(':', 1) - result['host'] = parts[0] - result['path'] = parts[1] if len(parts) > 1 else '' - result['git_url'] = url # Already in git format + parts = url[4:].split(":", 1) + result["host"] = parts[0] + result["path"] = parts[1] if len(parts) > 1 else "" + result["git_url"] = url # Already in git format - elif url.startswith('git+https://'): + elif url.startswith("git+https://"): # HTTPS format: git+https://host/path/repo.git - result['protocol'] = 'https' + result["protocol"] = "https" # Remove git+ prefix for actual git URL actual_url = url[4:] # Remove 'git+' prefix from urllib.parse import urlparse + parsed = urlparse(actual_url) - result['host'] = parsed.netloc - result['path'] = parsed.path.lstrip('/') - result['git_url'] = actual_url + result["host"] = parsed.netloc + result["path"] = parsed.path.lstrip("/") + result["git_url"] = actual_url - elif url.startswith('git+file://'): + elif url.startswith("git+file://"): # Local file format: git+file:///path/to/repo - result['protocol'] = 'file' - result['host'] = '' - result['path'] = url[11:] # Remove 'git+file://' prefix - result['git_url'] = 'file://' + result['path'] + result["protocol"] = "file" + result["host"] = "" + result["path"] = url[11:] # Remove 'git+file://' prefix + result["git_url"] = "file://" + result["path"] else: raise ValueError(f"Unsupported Git URL format: {url}") @@ -2018,20 +2197,20 @@ def git_detect_remote_type(url: str) -> str: Returns: Remote type: 'file', 'git-ssh', 'git-https', 'git-file', 'http', 'https' """ - if url.startswith('file://'): - return 'file' - elif url.startswith('git@'): - return 'git-ssh' - elif url.startswith('git+https://'): - return 'git-https' - elif url.startswith('git+file://'): - return 'git-file' - elif url.startswith('https://'): - return 'https' - elif url.startswith('http://'): - return 'http' + if url.startswith("file://"): + return "file" + elif url.startswith("git@"): + return "git-ssh" + elif url.startswith("git+https://"): + return "git-https" + elif url.startswith("git+file://"): + return "git-file" + elif url.startswith("https://"): + return "https" + elif url.startswith("http://"): + return "http" else: - return 'unknown' + return "unknown" def git_cache_path(remote_name: str) -> Path: @@ -2045,7 +2224,7 @@ def git_cache_path(remote_name: str) -> Path: Path to the cached repository directory """ bb_dir = storage_get_bb_directory() - return bb_dir / 'cache' / 'git' / remote_name + return bb_dir / "cache" / "git" / remote_name def git_clone_or_fetch(git_url: str, local_path: Path) -> bool: @@ -2059,18 +2238,20 @@ def git_clone_or_fetch(git_url: str, local_path: Path) -> bool: Returns: True if successful, False otherwise """ - if local_path.exists() and (local_path / '.git').exists(): + if local_path.exists() and (local_path / ".git").exists(): # Repository exists, fetch updates - result = git_run(['fetch', 'origin'], cwd=str(local_path)) + result = git_run(["fetch", "origin"], cwd=str(local_path)) if result.returncode != 0: print(f"Warning: git fetch failed: {result.stderr}", file=sys.stderr) return False # Pull changes (fast-forward only) - result = git_run(['pull', '--ff-only', 'origin', 'main'], cwd=str(local_path)) + result = git_run(["pull", "--ff-only", "origin", "main"], cwd=str(local_path)) if result.returncode != 0: # Try 'master' branch if 'main' fails - result = git_run(['pull', '--ff-only', 'origin', 'master'], cwd=str(local_path)) + result = git_run( + ["pull", "--ff-only", "origin", "master"], cwd=str(local_path) + ) if result.returncode != 0: print(f"Warning: git pull failed: {result.stderr}", file=sys.stderr) return False @@ -2078,7 +2259,7 @@ def git_clone_or_fetch(git_url: str, local_path: Path) -> bool: else: # Clone repository local_path.parent.mkdir(parents=True, exist_ok=True) - result = git_run(['clone', git_url, str(local_path)]) + result = git_run(["clone", git_url, str(local_path)]) if result.returncode != 0: print(f"Error: git clone failed: {result.stderr}", file=sys.stderr) return False @@ -2099,26 +2280,26 @@ def git_commit_and_push(local_path: Path, message: str) -> bool: cwd = str(local_path) # Stage all changes - result = git_run(['add', '.'], cwd=cwd) + result = git_run(["add", "."], cwd=cwd) if result.returncode != 0: print(f"Error: git add failed: {result.stderr}", file=sys.stderr) return False # Check if there are changes to commit - result = git_run(['diff', '--cached', '--quiet'], cwd=cwd) + result = git_run(["diff", "--cached", "--quiet"], cwd=cwd) if result.returncode == 0: # No changes to commit print("No changes to commit") return True # Commit changes - result = git_run(['commit', '-m', message], cwd=cwd) + result = git_run(["commit", "-m", message], cwd=cwd) if result.returncode != 0: print(f"Error: git commit failed: {result.stderr}", file=sys.stderr) return False # Push to remote - result = git_run(['push', 'origin', 'HEAD'], cwd=cwd) + result = git_run(["push", "origin", "HEAD"], cwd=cwd) if result.returncode != 0: print(f"Error: git push failed: {result.stderr}", file=sys.stderr) return False @@ -2130,6 +2311,7 @@ def git_commit_and_push(local_path: Path, message: str) -> bool: # Commit Functions # ============================================================================= + def git_init_commit_repo() -> Path: """ Initialize the git repository for committing if it doesn't exist. @@ -2145,29 +2327,32 @@ def git_init_commit_repo() -> Path: git_dir.mkdir(parents=True, exist_ok=True) # Check if it's already a git repo - git_metadata = git_dir / '.git' + git_metadata = git_dir / ".git" if not git_metadata.exists(): - result = git_run(['init'], cwd=str(git_dir)) + result = git_run(["init"], cwd=str(git_dir)) if result.returncode != 0: - print(f"Error: Failed to initialize git repository: {result.stderr}", file=sys.stderr) + print( + f"Error: Failed to initialize git repository: {result.stderr}", + file=sys.stderr, + ) sys.exit(1) # Configure git user from bb config config = storage_read_config() - name = config['user'].get('name', '') or 'bb' - email = config['user'].get('email', '') or 'bb@localhost' + name = config["user"].get("name", "") or "bb" + email = config["user"].get("email", "") or "bb@localhost" - git_run(['config', 'user.name', name], cwd=str(git_dir)) - git_run(['config', 'user.email', email], cwd=str(git_dir)) + git_run(["config", "user.name", name], cwd=str(git_dir)) + git_run(["config", "user.email", email], cwd=str(git_dir)) # Disable commit signing for this repository - git_run(['config', 'commit.gpgsign', 'false'], cwd=str(git_dir)) + git_run(["config", "commit.gpgsign", "false"], cwd=str(git_dir)) print(f"Initialized git repository at {git_dir}") return git_dir -def helper_open_editor_for_message() -> str: +def command_open_editor_for_message() -> str: """ Open the user's editor to write a commit message. @@ -2176,13 +2361,13 @@ def helper_open_editor_for_message() -> str: """ import tempfile - editor = os.environ.get('EDITOR', os.environ.get('VISUAL', 'vi')) + editor = os.environ.get("EDITOR", os.environ.get("VISUAL", "vi")) # Create a temporary file for the commit message - with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as f: - f.write('\n') - f.write('# Enter commit message above.\n') - f.write('# Lines starting with # will be ignored.\n') + with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f: + f.write("\n") + f.write("# Enter commit message above.\n") + f.write("# Lines starting with # will be ignored.\n") temp_path = f.name try: @@ -2193,12 +2378,12 @@ def helper_open_editor_for_message() -> str: sys.exit(1) # Read the message - with open(temp_path, 'r', encoding='utf-8') as f: + with open(temp_path, "r", encoding="utf-8") as f: lines = f.readlines() # Filter out comment lines and strip - message_lines = [line.rstrip() for line in lines if not line.startswith('#')] - message = '\n'.join(message_lines).strip() + message_lines = [line.rstrip() for line in lines if not line.startswith("#")] + message = "\n".join(message_lines).strip() if not message: print("Error: Empty commit message, aborting", file=sys.stderr) @@ -2223,7 +2408,7 @@ def command_commit(hash_value: str, comment: str = None): import shutil # Validate hash format - if len(hash_value) != 64 or not all(c in '0123456789abcdef' for c in hash_value): + if len(hash_value) != 64 or not all(c in "0123456789abcdef" for c in hash_value): print(f"Error: Invalid hash format: {hash_value}", file=sys.stderr) sys.exit(1) @@ -2258,13 +2443,13 @@ def command_commit(hash_value: str, comment: str = None): print(f" Copied {func_hash[:12]}...") # Stage all changes - result = git_run(['add', '-A'], cwd=str(git_dir)) + result = git_run(["add", "-A"], cwd=str(git_dir)) if result.returncode != 0: print(f"Error: git add failed: {result.stderr}", file=sys.stderr) sys.exit(1) # Check if there are changes to commit - result = git_run(['diff', '--cached', '--quiet'], cwd=str(git_dir)) + result = git_run(["diff", "--cached", "--quiet"], cwd=str(git_dir)) if result.returncode == 0: print("No new changes to commit") return @@ -2273,10 +2458,10 @@ def command_commit(hash_value: str, comment: str = None): if comment: message = comment else: - message = helper_open_editor_for_message() + message = command_open_editor_for_message() # Commit - result = git_run(['commit', '-m', message], cwd=str(git_dir)) + result = git_run(["commit", "-m", message], cwd=str(git_dir)) if result.returncode != 0: print(f"Error: git commit failed: {result.stderr}", file=sys.stderr) sys.exit(1) @@ -2300,14 +2485,14 @@ def command_remote_add(name: str, url: str, read_only: bool = False): """ config = storage_read_config() - if name in config['remotes']: + if name in config["remotes"]: print(f"Error: Remote '{name}' already exists", file=sys.stderr) sys.exit(1) # Detect remote type remote_type = git_detect_remote_type(url) - if remote_type == 'unknown': + if remote_type == "unknown": print(f"Error: Invalid URL format: {url}", file=sys.stderr) print("Supported formats:", file=sys.stderr) print(" file:///path/to/pool - Direct file copy", file=sys.stderr) @@ -2316,14 +2501,11 @@ def command_remote_add(name: str, url: str, read_only: bool = False): print(" git+file:///path/to/repo - Local Git repository", file=sys.stderr) sys.exit(1) - remote_config = { - 'url': url, - 'type': remote_type - } + remote_config = {"url": url, "type": remote_type} if read_only: - remote_config['read_only'] = True + remote_config["read_only"] = True - config['remotes'][name] = remote_config + config["remotes"][name] = remote_config storage_write_config(config) ro_suffix = " (read-only)" if read_only else "" @@ -2339,11 +2521,11 @@ def command_remote_remove(name: str): """ config = storage_read_config() - if name not in config['remotes']: + if name not in config["remotes"]: print(f"Error: Remote '{name}' not found", file=sys.stderr) sys.exit(1) - del config['remotes'][name] + del config["remotes"][name] storage_write_config(config) print(f"Removed remote '{name}'") @@ -2354,12 +2536,12 @@ def command_remote_list(): """ config = storage_read_config() - if not config['remotes']: + if not config["remotes"]: print("No remotes configured") return print("Configured remotes:") - for name, remote in config['remotes'].items(): + for name, remote in config["remotes"].items(): print(f" {name}: {remote['url']}") @@ -2376,13 +2558,13 @@ def command_remote_pull(name: str): config = storage_read_config() - if name not in config['remotes']: + if name not in config["remotes"]: print(f"Error: Remote '{name}' not found", file=sys.stderr) sys.exit(1) - remote = config['remotes'][name] - url = remote['url'] - remote_type = remote.get('type', git_detect_remote_type(url)) + remote = config["remotes"][name] + url = remote["url"] + remote_type = remote.get("type", git_detect_remote_type(url)) # Get local directories git_dir = storage_get_git_directory() @@ -2391,7 +2573,7 @@ def command_remote_pull(name: str): print(f"Pulling from remote '{name}': {url}") print() - if remote_type == 'file': + if remote_type == "file": # Local file system remote (direct copy) remote_path = Path(url[7:]) # Remove file:// prefix @@ -2403,7 +2585,7 @@ def command_remote_pull(name: str): print("Validating remote pool structure...") is_valid, errors = storage_validate_pool(remote_path) if not is_valid: - print(f"Error: Remote is not a valid bb pool:", file=sys.stderr) + print("Error: Remote is not a valid bb pool:", file=sys.stderr) for err in errors[:5]: # Show first 5 errors print(f" - {err}", file=sys.stderr) if len(errors) > 5: @@ -2416,7 +2598,7 @@ def command_remote_pull(name: str): pulled_to_git = 0 pulled_to_pool = 0 - for item in remote_path.rglob('*.json'): + for item in remote_path.rglob("*.json"): rel_path = item.relative_to(remote_path) # Copy to git directory @@ -2435,7 +2617,7 @@ def command_remote_pull(name: str): print(f"Pulled {pulled_to_pool} new functions from '{name}'") - elif remote_type in ('git-ssh', 'git-https', 'git-file'): + elif remote_type in ("git-ssh", "git-https", "git-file"): # Git remote - fetch into local git dir then copy to pool parsed = git_url_parse(url) @@ -2443,55 +2625,68 @@ def command_remote_pull(name: str): git_dir = git_init_commit_repo() # Check if remote exists in git config, add if not - result = git_run(['remote', 'get-url', name], cwd=str(git_dir)) + result = git_run(["remote", "get-url", name], cwd=str(git_dir)) if result.returncode != 0: - result = git_run(['remote', 'add', name, parsed['git_url']], cwd=str(git_dir)) + result = git_run( + ["remote", "add", name, parsed["git_url"]], cwd=str(git_dir) + ) if result.returncode != 0: - print(f"Error: Failed to add git remote: {result.stderr}", file=sys.stderr) + print( + f"Error: Failed to add git remote: {result.stderr}", file=sys.stderr + ) sys.exit(1) print(f"Added git remote '{name}'") # Fetch from remote print(f"Fetching from {parsed['git_url']}...") - result = git_run(['fetch', name], cwd=str(git_dir)) + result = git_run(["fetch", name], cwd=str(git_dir)) if result.returncode != 0: print(f"Error: git fetch failed: {result.stderr}", file=sys.stderr) sys.exit(1) # Determine which branch exists (main or master) remote_branch = None - result = git_run(['rev-parse', '--verify', f'{name}/main'], cwd=str(git_dir)) + result = git_run(["rev-parse", "--verify", f"{name}/main"], cwd=str(git_dir)) if result.returncode == 0: - remote_branch = f'{name}/main' + remote_branch = f"{name}/main" else: - result = git_run(['rev-parse', '--verify', f'{name}/master'], cwd=str(git_dir)) + result = git_run( + ["rev-parse", "--verify", f"{name}/master"], cwd=str(git_dir) + ) if result.returncode == 0: - remote_branch = f'{name}/master' + remote_branch = f"{name}/master" if remote_branch: # Validate remote branch content before rebase using temp worktree import tempfile + print("Validating remote pool structure...") with tempfile.TemporaryDirectory() as temp_dir: - temp_path = Path(temp_dir) / 'validate' - result = git_run(['worktree', 'add', '--detach', str(temp_path), remote_branch], cwd=str(git_dir)) + temp_path = Path(temp_dir) / "validate" + result = git_run( + ["worktree", "add", "--detach", str(temp_path), remote_branch], + cwd=str(git_dir), + ) if result.returncode == 0: is_valid, errors = storage_validate_pool(temp_path) # Clean up worktree - git_run(['worktree', 'remove', str(temp_path)], cwd=str(git_dir)) + git_run(["worktree", "remove", str(temp_path)], cwd=str(git_dir)) if not is_valid: - print(f"Error: Remote is not a valid bb pool:", file=sys.stderr) + print("Error: Remote is not a valid bb pool:", file=sys.stderr) for err in errors[:5]: # Show first 5 errors print(f" - {err}", file=sys.stderr) if len(errors) > 5: - print(f" ... and {len(errors) - 5} more errors", file=sys.stderr) + print( + f" ... and {len(errors) - 5} more errors", + file=sys.stderr, + ) sys.exit(1) # Rebase onto remote changes (try main, then master) # Use rebase since content-addressed storage is append-only with zero conflicts - result = git_run(['rebase', f'{name}/main'], cwd=str(git_dir)) + result = git_run(["rebase", f"{name}/main"], cwd=str(git_dir)) if result.returncode != 0: - result = git_run(['rebase', f'{name}/master'], cwd=str(git_dir)) + result = git_run(["rebase", f"{name}/master"], cwd=str(git_dir)) if result.returncode != 0: # May fail if no common history, which is fine for initial pull pass @@ -2500,7 +2695,7 @@ def command_remote_pull(name: str): pool_dir.mkdir(parents=True, exist_ok=True) pulled_count = 0 - for item in git_dir.rglob('*.json'): + for item in git_dir.rglob("*.json"): rel_path = item.relative_to(git_dir) pool_item = pool_dir / rel_path @@ -2528,33 +2723,37 @@ def command_remote_push(name: str): """ config = storage_read_config() - if name not in config['remotes']: + if name not in config["remotes"]: print(f"Error: Remote '{name}' not found", file=sys.stderr) sys.exit(1) - remote = config['remotes'][name] + remote = config["remotes"][name] # Check if remote is read-only - if remote.get('read_only', False): + if remote.get("read_only", False): print(f"Error: Remote '{name}' is read-only", file=sys.stderr) sys.exit(1) - url = remote['url'] - remote_type = remote.get('type', git_detect_remote_type(url)) + url = remote["url"] + remote_type = remote.get("type", git_detect_remote_type(url)) # Get local git directory git_dir = storage_get_git_directory() - if not git_dir.exists() or not (git_dir / '.git').exists(): - print("Error: No committed functions. Use 'bb commit HASH' first.", file=sys.stderr) + if not git_dir.exists() or not (git_dir / ".git").exists(): + print( + "Error: No committed functions. Use 'bb commit HASH' first.", + file=sys.stderr, + ) sys.exit(1) print(f"Pushing to remote '{name}': {url}") print() - if remote_type == 'file': + if remote_type == "file": # Local file system remote (direct copy from git dir) import shutil + remote_path = Path(url[7:]) # Remove file:// prefix # Create remote directory if it doesn't exist @@ -2562,7 +2761,7 @@ def command_remote_push(name: str): # Copy functions from git directory to remote pushed_count = 0 - for item in git_dir.rglob('*.json'): + for item in git_dir.rglob("*.json"): rel_path = item.relative_to(git_dir) remote_item = remote_path / rel_path @@ -2573,26 +2772,30 @@ def command_remote_push(name: str): print(f"Pushed {pushed_count} new functions to '{name}'") - elif remote_type in ('git-ssh', 'git-https', 'git-file'): + elif remote_type in ("git-ssh", "git-https", "git-file"): # Git remote - add remote to local git dir and push parsed = git_url_parse(url) # Check if remote exists in git config, add if not - result = git_run(['remote', 'get-url', name], cwd=str(git_dir)) + result = git_run(["remote", "get-url", name], cwd=str(git_dir)) if result.returncode != 0: # Remote doesn't exist, add it - result = git_run(['remote', 'add', name, parsed['git_url']], cwd=str(git_dir)) + result = git_run( + ["remote", "add", name, parsed["git_url"]], cwd=str(git_dir) + ) if result.returncode != 0: - print(f"Error: Failed to add git remote: {result.stderr}", file=sys.stderr) + print( + f"Error: Failed to add git remote: {result.stderr}", file=sys.stderr + ) sys.exit(1) print(f"Added git remote '{name}'") # Push to remote print(f"Pushing to {parsed['git_url']}...") - result = git_run(['push', name, 'HEAD:main'], cwd=str(git_dir)) + result = git_run(["push", name, "HEAD:main"], cwd=str(git_dir)) if result.returncode != 0: # Try master if main fails - result = git_run(['push', name, 'HEAD:master'], cwd=str(git_dir)) + result = git_run(["push", name, "HEAD:master"], cwd=str(git_dir)) if result.returncode != 0: print(f"Error: git push failed: {result.stderr}", file=sys.stderr) sys.exit(1) @@ -2618,69 +2821,82 @@ def command_remote_sync(): config = storage_read_config() - if not config['remotes']: + if not config["remotes"]: print("No remotes configured. Use 'bb remote add' first.") return git_dir = storage_get_git_directory() pool_dir = storage_get_pool_directory() - if not git_dir.exists() or not (git_dir / '.git').exists(): - print("Error: No committed functions. Use 'bb commit HASH' first.", file=sys.stderr) + if not git_dir.exists() or not (git_dir / ".git").exists(): + print( + "Error: No committed functions. Use 'bb commit HASH' first.", + file=sys.stderr, + ) sys.exit(1) print("Syncing with all remotes...") print() # Phase 1: Pull rebase from all remotes - for name, remote in config['remotes'].items(): - url = remote['url'] - remote_type = remote.get('type', git_detect_remote_type(url)) + for name, remote in config["remotes"].items(): + url = remote["url"] + remote_type = remote.get("type", git_detect_remote_type(url)) - if remote_type not in ('git-ssh', 'git-https', 'git-file'): + if remote_type not in ("git-ssh", "git-https", "git-file"): print(f"Skipping '{name}': only git remotes supported for sync") continue parsed = git_url_parse(url) # Ensure remote is configured - result = git_run(['remote', 'get-url', name], cwd=str(git_dir)) + result = git_run(["remote", "get-url", name], cwd=str(git_dir)) if result.returncode != 0: - result = git_run(['remote', 'add', name, parsed['git_url']], cwd=str(git_dir)) + result = git_run( + ["remote", "add", name, parsed["git_url"]], cwd=str(git_dir) + ) if result.returncode != 0: print(f"Warning: Failed to add remote '{name}': {result.stderr}") continue # Fetch print(f"Fetching from '{name}'...") - result = git_run(['fetch', name], cwd=str(git_dir)) + result = git_run(["fetch", name], cwd=str(git_dir)) if result.returncode != 0: print(f"Warning: Failed to fetch from '{name}': {result.stderr}") continue # Determine which branch exists (main or master) remote_branch = None - result = git_run(['rev-parse', '--verify', f'{name}/main'], cwd=str(git_dir)) + result = git_run(["rev-parse", "--verify", f"{name}/main"], cwd=str(git_dir)) if result.returncode == 0: - remote_branch = f'{name}/main' + remote_branch = f"{name}/main" else: - result = git_run(['rev-parse', '--verify', f'{name}/master'], cwd=str(git_dir)) + result = git_run( + ["rev-parse", "--verify", f"{name}/master"], cwd=str(git_dir) + ) if result.returncode == 0: - remote_branch = f'{name}/master' + remote_branch = f"{name}/master" if remote_branch: # Validate remote branch content before rebase using temp worktree import tempfile + print(f" Validating '{name}' pool structure...") with tempfile.TemporaryDirectory() as temp_dir: - temp_path = Path(temp_dir) / 'validate' - result = git_run(['worktree', 'add', '--detach', str(temp_path), remote_branch], cwd=str(git_dir)) + temp_path = Path(temp_dir) / "validate" + result = git_run( + ["worktree", "add", "--detach", str(temp_path), remote_branch], + cwd=str(git_dir), + ) if result.returncode == 0: is_valid, errors = storage_validate_pool(temp_path) # Clean up worktree - git_run(['worktree', 'remove', str(temp_path)], cwd=str(git_dir)) + git_run(["worktree", "remove", str(temp_path)], cwd=str(git_dir)) if not is_valid: - print(f"Warning: Remote '{name}' is not a valid bb pool, skipping:") + print( + f"Warning: Remote '{name}' is not a valid bb pool, skipping:" + ) for err in errors[:3]: # Show first 3 errors print(f" - {err}") if len(errors) > 3: @@ -2688,12 +2904,12 @@ def command_remote_sync(): continue # Rebase on remote (try main, then master) - result = git_run(['rebase', f'{name}/main'], cwd=str(git_dir)) + result = git_run(["rebase", f"{name}/main"], cwd=str(git_dir)) if result.returncode != 0: - result = git_run(['rebase', f'{name}/master'], cwd=str(git_dir)) + result = git_run(["rebase", f"{name}/master"], cwd=str(git_dir)) if result.returncode != 0: # Abort rebase if it failed - git_run(['rebase', '--abort'], cwd=str(git_dir)) + git_run(["rebase", "--abort"], cwd=str(git_dir)) print(f"Warning: Rebase from '{name}' failed, skipping") continue @@ -2701,7 +2917,7 @@ def command_remote_sync(): # Copy any new functions from git dir to pool pulled_count = 0 - for item in git_dir.rglob('*.json'): + for item in git_dir.rglob("*.json"): rel_path = item.relative_to(git_dir) pool_item = pool_dir / rel_path @@ -2716,23 +2932,23 @@ def command_remote_sync(): print() # Phase 2: Push to all remotes - for name, remote in config['remotes'].items(): - url = remote['url'] - remote_type = remote.get('type', git_detect_remote_type(url)) + for name, remote in config["remotes"].items(): + url = remote["url"] + remote_type = remote.get("type", git_detect_remote_type(url)) - if remote_type not in ('git-ssh', 'git-https', 'git-file'): + if remote_type not in ("git-ssh", "git-https", "git-file"): continue # Skip read-only remotes - if remote.get('read_only', False): + if remote.get("read_only", False): continue parsed = git_url_parse(url) print(f"Pushing to '{name}'...") - result = git_run(['push', name, 'HEAD:main'], cwd=str(git_dir)) + result = git_run(["push", name, "HEAD:main"], cwd=str(git_dir)) if result.returncode != 0: - result = git_run(['push', name, 'HEAD:master'], cwd=str(git_dir)) + result = git_run(["push", name, "HEAD:master"], cwd=str(git_dir)) if result.returncode != 0: print(f"Warning: Failed to push to '{name}': {result.stderr}") continue @@ -2754,12 +2970,12 @@ def code_extract_dependencies(normalized_code: str) -> List[str]: tree = ast.parse(normalized_code) for node in ast.walk(tree): - if isinstance(node, ast.ImportFrom) and node.module == 'bb.pool': + if isinstance(node, ast.ImportFrom) and node.module == "bb.pool": for alias in node.names: import_name = alias.name # e.g., "object_c0ff33..." # Strip object_ prefix to get actual hash if import_name.startswith(BB_IMPORT_PREFIX): - actual_hash = import_name[len(BB_IMPORT_PREFIX):] + actual_hash = import_name[len(BB_IMPORT_PREFIX) :] else: actual_hash = import_name # Backward compatibility dependencies.append(actual_hash) @@ -2795,7 +3011,7 @@ def visit(hash_value: str): # Load function to get its code (v1 only) func_data = code_load_v1(hash_value) - normalized_code = func_data['normalized_code'] + normalized_code = func_data["normalized_code"] # Extract and visit dependencies first deps = code_extract_dependencies(normalized_code) @@ -2843,7 +3059,7 @@ def code_bundle_dependencies(hashes: List[str], output_dir: Path) -> Path: return output_dir -def review_load_state() -> set: +def command_review_load_state() -> set: """ Load the set of previously reviewed function hashes. @@ -2851,20 +3067,20 @@ def review_load_state() -> set: Set of reviewed function hashes """ bb_dir = storage_get_bb_directory() - state_file = bb_dir / 'review_state.json' + state_file = bb_dir / "review_state.json" if not state_file.exists(): return set() try: - with open(state_file, 'r', encoding='utf-8') as f: + with open(state_file, "r", encoding="utf-8") as f: data = json.load(f) - return set(data.get('reviewed', [])) + return set(data.get("reviewed", [])) except (json.JSONDecodeError, OSError): return set() -def review_save_state(reviewed: set): +def command_review_save_state(reviewed: set): """ Save the set of reviewed function hashes. @@ -2874,10 +3090,10 @@ def review_save_state(reviewed: set): bb_dir = storage_get_bb_directory() bb_dir.mkdir(parents=True, exist_ok=True) - state_file = bb_dir / 'review_state.json' - data = {'reviewed': list(reviewed)} + state_file = bb_dir / "review_state.json" + data = {"reviewed": list(reviewed)} - with open(state_file, 'w', encoding='utf-8') as f: + with open(state_file, "w", encoding="utf-8") as f: json.dump(data, f, indent=2) @@ -2893,19 +3109,24 @@ def command_review(hash_value: str): hash_value: Function hash to review """ # Validate hash format - if len(hash_value) != 64 or not all(c in '0123456789abcdef' for c in hash_value.lower()): - print(f"Error: Invalid hash format. Expected 64 hex characters. Got: {hash_value}", file=sys.stderr) + if len(hash_value) != 64 or not all( + c in "0123456789abcdef" for c in hash_value.lower() + ): + print( + f"Error: Invalid hash format. Expected 64 hex characters. Got: {hash_value}", + file=sys.stderr, + ) sys.exit(1) # Get user's preferred languages config = storage_read_config() - preferred_langs = config['user'].get('languages', ['eng']) + preferred_langs = config["user"].get("languages", ["eng"]) if not preferred_langs: - preferred_langs = ['eng'] + preferred_langs = ["eng"] # Load previously reviewed functions - reviewed = review_load_state() + reviewed = command_review_load_state() # Resolve all dependencies (returns list with dependencies first, main function last) try: @@ -2937,24 +3158,33 @@ def command_review(hash_value: str): # Detect schema version version = code_detect_schema(current_hash) if version is None: - print(f"Warning: Function {current_hash} not found in local pool", file=sys.stderr) + print( + f"Warning: Function {current_hash} not found in local pool", + file=sys.stderr, + ) continue # Try to load in user's preferred languages loaded = False for lang in preferred_langs: try: - normalized_code, name_mapping, alias_mapping, docstring = code_load(current_hash, lang) - func_name = name_mapping.get('_bb_v_0', 'unknown') + normalized_code, name_mapping, alias_mapping, docstring = code_load( + current_hash, lang + ) + func_name = name_mapping.get("_bb_v_0", "unknown") # Show function header - print(f"[{i+1}/{len(to_review)}] Function: {func_name} ({lang})") + print(f"[{i + 1}/{len(to_review)}] Function: {func_name} ({lang})") print(f"Hash: {current_hash}") print("-" * 80) # Denormalize and show code - normalized_code_with_doc = code_replace_docstring(normalized_code, docstring) - original_code = code_denormalize(normalized_code_with_doc, name_mapping, alias_mapping) + normalized_code_with_doc = code_replace_docstring( + normalized_code, docstring + ) + original_code = code_denormalize( + normalized_code_with_doc, name_mapping, alias_mapping + ) print(original_code) print("-" * 80) @@ -2976,7 +3206,10 @@ def command_review(hash_value: str): continue if not loaded: - print(f"Warning: Function {current_hash} not available in any preferred language", file=sys.stderr) + print( + f"Warning: Function {current_hash} not available in any preferred language", + file=sys.stderr, + ) print(f"Preferred languages: {', '.join(preferred_langs)}", file=sys.stderr) print() continue @@ -2987,19 +3220,21 @@ def command_review(hash_value: str): response = input("Approve this function? [y/n/q]: ").strip().lower() except EOFError: print("\nNon-interactive mode - skipping remaining reviews.") - review_save_state(reviewed) + command_review_save_state(reviewed) return - if response == 'y': + if response == "y": reviewed.add(current_hash) - review_save_state(reviewed) - print(f"✓ Function approved and saved to review state.\n") + command_review_save_state(reviewed) + print("✓ Function approved and saved to review state.\n") break - elif response == 'n': - print(f"✗ Function skipped.\n") + elif response == "n": + print("✗ Function skipped.\n") break - elif response == 'q': - print(f"\nReview paused. Progress saved ({len(reviewed)} functions reviewed).") + elif response == "q": + print( + f"\nReview paused. Progress saved ({len(reviewed)} functions reviewed)." + ) print("Run the command again to continue from where you left off.") return else: @@ -3036,19 +3271,19 @@ def command_log(): if not func_dir.is_dir(): continue - object_json = func_dir / 'object.json' + object_json = func_dir / "object.json" if not object_json.exists(): continue # Load function metadata try: - with open(object_json, 'r', encoding='utf-8') as f: + with open(object_json, "r", encoding="utf-8") as f: data = json.load(f) - func_hash = data['hash'] - metadata = data.get('metadata', {}) - created = metadata.get('created', 'unknown') - author = metadata.get('author', 'unknown') + func_hash = data["hash"] + metadata = data.get("metadata", {}) + created = metadata.get("created", "unknown") + author = metadata.get("author", "unknown") # Get available languages langs = [] @@ -3056,17 +3291,19 @@ def command_log(): if item.is_dir() and len(item.name) == 3: langs.append(item.name) - functions.append({ - 'hash': func_hash, - 'created': created, - 'author': author, - 'langs': sorted(langs) - }) + functions.append( + { + "hash": func_hash, + "created": created, + "author": author, + "langs": sorted(langs), + } + ) except (IOError, json.JSONDecodeError): continue # Sort by created timestamp (newest first) - functions.sort(key=lambda x: x['created'], reverse=True) + functions.sort(key=lambda x: x["created"], reverse=True) # Display log print(f"Function Pool Log ({len(functions)} functions)") @@ -3074,7 +3311,7 @@ def command_log(): print() for func in functions: - langs_str = ', '.join(func['langs']) if func['langs'] else 'none' + langs_str = ", ".join(func["langs"]) if func["langs"] else "none" print(f"Hash: {func['hash']}") print(f"Date: {func['created']}") print(f"Author: {func['author']}") @@ -3115,47 +3352,66 @@ def command_search(query: List[str]): if not func_dir.is_dir(): continue - object_json = func_dir / 'object.json' + object_json = func_dir / "object.json" if not object_json.exists(): continue # Load function try: - with open(object_json, 'r', encoding='utf-8') as f: + with open(object_json, "r", encoding="utf-8") as f: data = json.load(f) - func_hash = data['hash'] + func_hash = data["hash"] # Get available languages and search in mappings for lang_dir in func_dir.iterdir(): if lang_dir.is_dir() and len(lang_dir.name) == 3: lang = lang_dir.name try: - _, name_mapping, _, docstring = code_load(func_hash, lang) - func_name = name_mapping.get('_bb_v_0', 'unknown') + _, name_mapping, _, docstring = code_load( + func_hash, lang + ) + func_name = name_mapping.get("_bb_v_0", "unknown") # Search in function name, docstring, and original variable names - all_original_names = ' '.join(name_mapping.values()).lower() + all_original_names = " ".join( + name_mapping.values() + ).lower() searchable = f"{func_name} {docstring} {all_original_names}".lower() if any(term in searchable for term in search_terms): # Determine where match was found match_in = [] - if any(term in func_name.lower() for term in search_terms): - match_in.append('name') - if any(term in docstring.lower() for term in search_terms): - match_in.append('docstring') - if any(term in all_original_names for term in search_terms): - if 'name' not in match_in: # Don't duplicate if func name matched - match_in.append('variables') - - results.append({ - 'hash': func_hash, - 'name': func_name, - 'lang': lang, - 'docstring': docstring[:100], # First 100 chars - 'match_in': match_in - }) + if any( + term in func_name.lower() + for term in search_terms + ): + match_in.append("name") + if any( + term in docstring.lower() + for term in search_terms + ): + match_in.append("docstring") + if any( + term in all_original_names + for term in search_terms + ): + if ( + "name" not in match_in + ): # Don't duplicate if func name matched + match_in.append("variables") + + results.append( + { + "hash": func_hash, + "name": func_name, + "lang": lang, + "docstring": docstring[ + :100 + ], # First 100 chars + "match_in": match_in, + } + ) break except SystemExit: continue @@ -3172,11 +3428,11 @@ def command_search(query: List[str]): return for result in results: - match_str = ', '.join(result['match_in']) + match_str = ", ".join(result["match_in"]) print(f"Name: {result['name']} ({result['lang']})") print(f"Hash: {result['hash']}") print(f"Match: {match_str}") - if result['docstring']: + if result["docstring"]: print(f"Description: {result['docstring']}...") print(f"View: bb.py show {result['hash']}@{result['lang']}") print() @@ -3199,7 +3455,7 @@ def code_strip_bb_imports(code: str) -> str: # Filter out bb.pool imports new_body = [] for node in tree.body: - if isinstance(node, ast.ImportFrom) and node.module == 'bb.pool': + if isinstance(node, ast.ImportFrom) and node.module == "bb.pool": continue new_body.append(node) @@ -3207,7 +3463,9 @@ def code_strip_bb_imports(code: str) -> str: return ast.unparse(tree) -def code_load_dependencies_recursive(func_hash: str, lang: str, namespace: dict, loaded: set = None): +def code_load_dependencies_recursive( + func_hash: str, lang: str, namespace: dict, loaded: set = None +): """ Recursively load a function and all its dependencies into a namespace. @@ -3235,7 +3493,9 @@ def code_load_dependencies_recursive(func_hash: str, lang: str, namespace: dict, # Load the function try: - normalized_code, name_mapping, alias_mapping, docstring = code_load(func_hash, lang) + normalized_code, name_mapping, alias_mapping, docstring = code_load( + func_hash, lang + ) except SystemExit: print(f"Error: Could not load dependency {func_hash}@{lang}", file=sys.stderr) sys.exit(1) @@ -3247,7 +3507,9 @@ def code_load_dependencies_recursive(func_hash: str, lang: str, namespace: dict, # Denormalize the code normalized_code_with_doc = code_replace_docstring(normalized_code, docstring) - original_code = code_denormalize(normalized_code_with_doc, name_mapping, alias_mapping) + original_code = code_denormalize( + normalized_code_with_doc, name_mapping, alias_mapping + ) # Strip bb imports (dependencies are already in namespace) executable_code = code_strip_bb_imports(original_code) @@ -3259,7 +3521,7 @@ def code_load_dependencies_recursive(func_hash: str, lang: str, namespace: dict, if prefixed_dep_name in namespace: # The dependency's function is already loaded, make alias point to it dep_module = namespace[prefixed_dep_name] - if hasattr(dep_module, '_bb_v_0'): + if hasattr(dep_module, "_bb_v_0"): namespace[alias] = dep_module._bb_v_0 # Execute the code in the namespace (dependencies are already loaded) @@ -3268,11 +3530,12 @@ def code_load_dependencies_recursive(func_hash: str, lang: str, namespace: dict, except Exception as e: print(f"Error executing dependency {func_hash}: {e}", file=sys.stderr) import traceback + traceback.print_exc() sys.exit(1) # Get function name and create a module-like object for this hash - func_name = name_mapping.get('_bb_v_0', 'unknown') + func_name = name_mapping.get("_bb_v_0", "unknown") if func_name in namespace: # Create a simple namespace object that has _bb_v_0 attribute @@ -3326,42 +3589,58 @@ def command_run(hash_with_lang: str, debug: bool = False, func_args: list = None func_args = [] # Parse hash and optional language - if '@' in hash_with_lang: - hash_value, lang = hash_with_lang.rsplit('@', 1) + if "@" in hash_with_lang: + hash_value, lang = hash_with_lang.rsplit("@", 1) # Validate language code if len(lang) < 3 or len(lang) > 256: - print(f"Error: Language code must be 3-256 characters. Got: {lang}", file=sys.stderr) + print( + f"Error: Language code must be 3-256 characters. Got: {lang}", + file=sys.stderr, + ) sys.exit(1) else: hash_value = hash_with_lang lang = None # Validate hash format - if len(hash_value) != 64 or not all(c in '0123456789abcdef' for c in hash_value.lower()): - print(f"Error: Invalid hash format. Expected 64 hex characters. Got: {hash_value}", file=sys.stderr) + if len(hash_value) != 64 or not all( + c in "0123456789abcdef" for c in hash_value.lower() + ): + print( + f"Error: Invalid hash format. Expected 64 hex characters. Got: {hash_value}", + file=sys.stderr, + ) sys.exit(1) # If no language provided, find first available if lang is None: if debug: - print("Error: Language suffix required when using --debug. Use format: HASH@lang", file=sys.stderr) + print( + "Error: Language suffix required when using --debug. Use format: HASH@lang", + file=sys.stderr, + ) sys.exit(1) available_langs = storage_list_languages(hash_value) if not available_langs: - print(f"Error: No language mappings found for function {hash_value}", file=sys.stderr) + print( + f"Error: No language mappings found for function {hash_value}", + file=sys.stderr, + ) sys.exit(1) lang = available_langs[0] # Use first available language # Load function from pool try: - normalized_code, name_mapping, alias_mapping, docstring = code_load(hash_value, lang) + normalized_code, name_mapping, alias_mapping, docstring = code_load( + hash_value, lang + ) except SystemExit: print(f"Error: Could not load function {hash_value}@{lang}", file=sys.stderr) sys.exit(1) # Get function name from mapping - func_name = name_mapping.get('_bb_v_0', 'unknown_function') + func_name = name_mapping.get("_bb_v_0", "unknown_function") # Create execution namespace namespace = {} @@ -3376,7 +3655,9 @@ def command_run(hash_with_lang: str, debug: bool = False, func_args: list = None # Denormalize to original language normalized_code_with_doc = code_replace_docstring(normalized_code, docstring) - original_code = code_denormalize(normalized_code_with_doc, name_mapping, alias_mapping) + original_code = code_denormalize( + normalized_code_with_doc, name_mapping, alias_mapping + ) print(f"Running function: {func_name} ({lang})") print("=" * 60) @@ -3394,7 +3675,7 @@ def command_run(hash_with_lang: str, debug: bool = False, func_args: list = None if prefixed_dep_name in namespace: # The dependency's function is already loaded, make alias point to it dep_module = namespace[prefixed_dep_name] - if hasattr(dep_module, '_bb_v_0'): + if hasattr(dep_module, "_bb_v_0"): namespace[alias] = dep_module._bb_v_0 # Execute the code @@ -3403,6 +3684,7 @@ def command_run(hash_with_lang: str, debug: bool = False, func_args: list = None except Exception as e: print(f"Error executing function: {e}", file=sys.stderr) import traceback + traceback.print_exc() sys.exit(1) @@ -3441,11 +3723,13 @@ def command_run(hash_with_lang: str, debug: bool = False, func_args: list = None except Exception as e: print(f"Error: {e}", file=sys.stderr) import traceback + traceback.print_exc() sys.exit(1) elif debug: # Run with debugger import pdb + print("Starting debugger...") print(f"The function '{func_name}' is available in the namespace.") print(f"Call it with: {func_name}(...)") @@ -3459,6 +3743,7 @@ def command_run(hash_with_lang: str, debug: bool = False, func_args: list = None # Start interactive Python shell with the function available import code + code.interact(local=namespace, banner="") @@ -3474,38 +3759,59 @@ def command_translate(hash_with_lang: str, target_lang: str): target_lang: Target language code (e.g., "fra", "spa") """ # Parse hash and source language - if '@' not in hash_with_lang: - print("Error: Missing language suffix. Use format: HASH@source_lang", file=sys.stderr) + if "@" not in hash_with_lang: + print( + "Error: Missing language suffix. Use format: HASH@source_lang", + file=sys.stderr, + ) sys.exit(1) - hash_value, source_lang = hash_with_lang.rsplit('@', 1) + hash_value, source_lang = hash_with_lang.rsplit("@", 1) # Validate language codes if len(source_lang) < 3 or len(source_lang) > 256: - print(f"Error: Source language code must be 3-256 characters. Got: {source_lang}", file=sys.stderr) + print( + f"Error: Source language code must be 3-256 characters. Got: {source_lang}", + file=sys.stderr, + ) sys.exit(1) if len(target_lang) < 3 or len(target_lang) > 256: - print(f"Error: Target language code must be 3-256 characters. Got: {target_lang}", file=sys.stderr) + print( + f"Error: Target language code must be 3-256 characters. Got: {target_lang}", + file=sys.stderr, + ) sys.exit(1) # Validate hash format - if len(hash_value) != 64 or not all(c in '0123456789abcdef' for c in hash_value.lower()): - print(f"Error: Invalid hash format. Expected 64 hex characters. Got: {hash_value}", file=sys.stderr) + if len(hash_value) != 64 or not all( + c in "0123456789abcdef" for c in hash_value.lower() + ): + print( + f"Error: Invalid hash format. Expected 64 hex characters. Got: {hash_value}", + file=sys.stderr, + ) sys.exit(1) # Load source function try: - normalized_code, name_mapping_source, alias_mapping_source, docstring_source = code_load(hash_value, source_lang) + normalized_code, name_mapping_source, alias_mapping_source, docstring_source = ( + code_load(hash_value, source_lang) + ) except SystemExit: - print(f"Error: Could not load function {hash_value}@{source_lang}", file=sys.stderr) + print( + f"Error: Could not load function {hash_value}@{source_lang}", + file=sys.stderr, + ) sys.exit(1) # Show source code for reference print(f"Source function ({source_lang}):") print("=" * 60) normalized_code_with_doc = code_replace_docstring(normalized_code, docstring_source) - source_code = code_denormalize(normalized_code_with_doc, name_mapping_source, alias_mapping_source) + source_code = code_denormalize( + normalized_code_with_doc, name_mapping_source, alias_mapping_source + ) print(source_code) print("=" * 60) print() @@ -3536,14 +3842,23 @@ def command_translate(hash_with_lang: str, target_lang: str): alias_mapping_target = alias_mapping_source # Optionally add a comment - comment = input("Optional comment for this translation (press Enter to skip): ").strip() + comment = input( + "Optional comment for this translation (press Enter to skip): " + ).strip() # Save the translation - mapping_hash = mapping_save_v1(hash_value, target_lang, target_docstring, name_mapping_target, alias_mapping_target, comment) + mapping_hash = storage_mapping_save_v1( + hash_value, + target_lang, + target_docstring, + name_mapping_target, + alias_mapping_target, + comment, + ) print(f"Mapping hash: {mapping_hash}") print() - print(f"Translation saved successfully!") + print("Translation saved successfully!") print(f"View with: bb.py show {hash_value}@{target_lang}") @@ -3556,15 +3871,21 @@ def code_add(file_path_with_lang: str, comment: str = ""): comment: Optional comment explaining this mapping variant """ # Parse the path and language - if '@' not in file_path_with_lang: - print("Error: Missing language suffix. Use format: path/to/file.py@lang", file=sys.stderr) + if "@" not in file_path_with_lang: + print( + "Error: Missing language suffix. Use format: path/to/file.py@lang", + file=sys.stderr, + ) sys.exit(1) - file_path, lang = file_path_with_lang.rsplit('@', 1) + file_path, lang = file_path_with_lang.rsplit("@", 1) # Validate language code (should be 3 characters, ISO 639-3) if len(lang) < 3 or len(lang) > 256: - print(f"Error: Language code must be 3-256 characters. Got: {lang}", file=sys.stderr) + print( + f"Error: Language code must be 3-256 characters. Got: {lang}", + file=sys.stderr, + ) sys.exit(1) # Check if file exists @@ -3573,7 +3894,7 @@ def code_add(file_path_with_lang: str, comment: str = ""): sys.exit(1) # Read and parse the file - with open(file_path, 'r', encoding='utf-8') as f: + with open(file_path, "r", encoding="utf-8") as f: source_code = f.read() try: @@ -3592,7 +3913,13 @@ def code_add(file_path_with_lang: str, comment: str = ""): # Normalize the AST try: - normalized_code_with_docstring, normalized_code_without_docstring, docstring, name_mapping, alias_mapping = code_normalize(tree, lang) + ( + normalized_code_with_docstring, + normalized_code_without_docstring, + docstring, + name_mapping, + alias_mapping, + ) = code_normalize(tree, lang) except Exception as e: print(f"Error: Failed to normalize AST: {e}", file=sys.stderr) sys.exit(1) @@ -3604,14 +3931,20 @@ def code_add(file_path_with_lang: str, comment: str = ""): missing_deps = [] for dep_hash, alias in alias_mapping.items(): dep_dir = pool_dir / dep_hash[:2] / dep_hash[2:] - if not (dep_dir / 'object.json').exists(): + if not (dep_dir / "object.json").exists(): missing_deps.append((dep_hash, alias)) if missing_deps: - print("Error: The following bb imports do not exist in the local pool:", file=sys.stderr) + print( + "Error: The following bb imports do not exist in the local pool:", + file=sys.stderr, + ) for dep_hash, alias in missing_deps: print(f" - {alias} (hash: {dep_hash[:12]}...)", file=sys.stderr) - print("\nPlease add these functions to the pool first, or pull them from a remote.", file=sys.stderr) + print( + "\nPlease add these functions to the pool first, or pull them from a remote.", + file=sys.stderr, + ) sys.exit(1) # Verify all @check target hashes exist in the local pool @@ -3619,21 +3952,36 @@ def code_add(file_path_with_lang: str, comment: str = ""): missing_checks = [] for check_hash in checks: check_dir = pool_dir / check_hash[:2] / check_hash[2:] - if not (check_dir / 'object.json').exists(): + if not (check_dir / "object.json").exists(): missing_checks.append(check_hash) if missing_checks: - print("Error: The following @check target functions do not exist in the local pool:", file=sys.stderr) + print( + "Error: The following @check target functions do not exist in the local pool:", + file=sys.stderr, + ) for check_hash in missing_checks: print(f" - {check_hash[:12]}...", file=sys.stderr) - print("\nPlease add these functions to the pool first, or pull them from a remote.", file=sys.stderr) + print( + "\nPlease add these functions to the pool first, or pull them from a remote.", + file=sys.stderr, + ) sys.exit(1) # Compute hash on code WITHOUT docstring (so same logic = same hash regardless of language) - hash_value = hash_compute(normalized_code_without_docstring) + hash_value = code_hash_compute(normalized_code_without_docstring) # Save to v1 format (docstring stored separately in mapping.json) - code_save(hash_value, lang, normalized_code_without_docstring, docstring, name_mapping, alias_mapping, comment, checks=checks) + code_save( + hash_value, + lang, + normalized_code_without_docstring, + docstring, + name_mapping, + alias_mapping, + comment, + checks=checks, + ) def code_replace_docstring(code: str, new_docstring: str) -> str: @@ -3654,10 +4002,12 @@ def code_replace_docstring(code: str, new_docstring: str) -> str: return code # Check if there's an existing docstring - has_docstring = (function_def.body and - isinstance(function_def.body[0], ast.Expr) and - isinstance(function_def.body[0].value, ast.Constant) and - isinstance(function_def.body[0].value.value, str)) + has_docstring = ( + function_def.body + and isinstance(function_def.body[0], ast.Expr) + and isinstance(function_def.body[0].value, ast.Constant) + and isinstance(function_def.body[0].value.value, str) + ) if new_docstring: # Create new docstring node @@ -3693,7 +4043,7 @@ def code_load_v1(hash_value: str) -> Dict[str, any]: # Build path: pool/XX/YYYYYY.../object.json func_dir = pool_dir / hash_value[:2] / hash_value[2:] - object_json = func_dir / 'object.json' + object_json = func_dir / "object.json" # Check if file exists if not object_json.exists(): @@ -3702,7 +4052,7 @@ def code_load_v1(hash_value: str) -> Dict[str, any]: # Load the JSON data try: - with open(object_json, 'r', encoding='utf-8') as f: + with open(object_json, "r", encoding="utf-8") as f: data = json.load(f) except json.JSONDecodeError as e: print(f"Error: Failed to parse object.json: {e}", file=sys.stderr) @@ -3711,7 +4061,7 @@ def code_load_v1(hash_value: str) -> Dict[str, any]: return data -def mappings_list_v1(func_hash: str, lang: str) -> list: +def storage_mappings_list_v1(func_hash: str, lang: str) -> list: """ List all mapping variants for a given function and language. @@ -3748,7 +4098,7 @@ def mappings_list_v1(func_hash: str, lang: str) -> list: continue # Check if mapping.json exists - mapping_json = mapping_hash_dir / 'mapping.json' + mapping_json = mapping_hash_dir / "mapping.json" if not mapping_json.exists(): continue @@ -3757,9 +4107,9 @@ def mappings_list_v1(func_hash: str, lang: str) -> list: # Load mapping to get comment try: - with open(mapping_json, 'r', encoding='utf-8') as f: + with open(mapping_json, "r", encoding="utf-8") as f: mapping_data = json.load(f) - comment = mapping_data.get('comment', '') + comment = mapping_data.get("comment", "") mappings.append((mapping_hash, comment)) except (json.JSONDecodeError, IOError): # Skip invalid mapping files @@ -3768,7 +4118,9 @@ def mappings_list_v1(func_hash: str, lang: str) -> list: return mappings -def mapping_load_v1(func_hash: str, lang: str, mapping_hash: str) -> Tuple[str, Dict[str, str], Dict[str, str], str]: +def storage_mapping_load_v1( + func_hash: str, lang: str, mapping_hash: str +) -> Tuple[str, Dict[str, str], Dict[str, str], str]: """ Load a specific language mapping using schema v1. @@ -3785,30 +4137,35 @@ def mapping_load_v1(func_hash: str, lang: str, mapping_hash: str) -> Tuple[str, # Build path: pool/XX/Y.../lang/ZZ/W.../mapping.json func_dir = pool_dir / func_hash[:2] / func_hash[2:] mapping_dir = func_dir / lang / mapping_hash[:2] / mapping_hash[2:] - mapping_json = mapping_dir / 'mapping.json' + mapping_json = mapping_dir / "mapping.json" # Check if file exists if not mapping_json.exists(): - print(f"Error: Mapping not found: {func_hash}@{lang} (mapping hash: {mapping_hash})", file=sys.stderr) + print( + f"Error: Mapping not found: {func_hash}@{lang} (mapping hash: {mapping_hash})", + file=sys.stderr, + ) sys.exit(1) # Load the JSON data try: - with open(mapping_json, 'r', encoding='utf-8') as f: + with open(mapping_json, "r", encoding="utf-8") as f: data = json.load(f) except json.JSONDecodeError as e: print(f"Error: Failed to parse mapping.json: {e}", file=sys.stderr) sys.exit(1) - docstring = data.get('docstring', '') - name_mapping = data.get('name_mapping', {}) - alias_mapping = data.get('alias_mapping', {}) - comment = data.get('comment', '') + docstring = data.get("docstring", "") + name_mapping = data.get("name_mapping", {}) + alias_mapping = data.get("alias_mapping", {}) + comment = data.get("comment", "") return docstring, name_mapping, alias_mapping, comment -def code_load(hash_value: str, lang: str, mapping_hash: str = None) -> Tuple[str, Dict[str, str], Dict[str, str], str]: +def code_load( + hash_value: str, lang: str, mapping_hash: str = None +) -> Tuple[str, Dict[str, str], Dict[str, str], str]: """ Load a function from the bb pool (v1 format only). @@ -3830,10 +4187,10 @@ def code_load(hash_value: str, lang: str, mapping_hash: str = None) -> Tuple[str # Load v1 format # Load object.json func_data = code_load_v1(hash_value) - normalized_code = func_data['normalized_code'] + normalized_code = func_data["normalized_code"] # Get available mappings - mappings = mappings_list_v1(hash_value, lang) + mappings = storage_mappings_list_v1(hash_value, lang) if len(mappings) == 0: print(f"Error: No mappings found for language '{lang}'", file=sys.stderr) @@ -3853,7 +4210,9 @@ def code_load(hash_value: str, lang: str, mapping_hash: str = None) -> Tuple[str selected_hash, _ = mappings_sorted[0] # Load the mapping - docstring, name_mapping, alias_mapping, comment = mapping_load_v1(hash_value, lang, selected_hash) + docstring, name_mapping, alias_mapping, comment = storage_mapping_load_v1( + hash_value, lang, selected_hash + ) return normalized_code, name_mapping, alias_mapping, docstring @@ -3871,13 +4230,18 @@ def code_show(hash_with_lang_and_mapping: str): hash_with_lang_and_mapping: Function identifier in format HASH[@LANG[@MAPPING_HASH]] """ # Parse the format - if '@' not in hash_with_lang_and_mapping: + if "@" not in hash_with_lang_and_mapping: # Just hash provided - list available languages hash_value = hash_with_lang_and_mapping # Validate hash format - if len(hash_value) != 64 or not all(c in '0123456789abcdef' for c in hash_value.lower()): - print(f"Error: Invalid hash format. Expected 64 hex characters. Got: {hash_value}", file=sys.stderr) + if len(hash_value) != 64 or not all( + c in "0123456789abcdef" for c in hash_value.lower() + ): + print( + f"Error: Invalid hash format. Expected 64 hex characters. Got: {hash_value}", + file=sys.stderr, + ) sys.exit(1) # Check if function exists @@ -3894,13 +4258,16 @@ def code_show(hash_with_lang_and_mapping: str): print(f"Available languages for {hash_value}:") for lang in languages: - mappings = mappings_list_v1(hash_value, lang) + mappings = storage_mappings_list_v1(hash_value, lang) print(f" {lang} - {len(mappings)} mapping(s)") return - parts = hash_with_lang_and_mapping.split('@') + parts = hash_with_lang_and_mapping.split("@") if len(parts) < 2: - print("Error: Invalid format. Use format: HASH[@lang[@mapping_hash]]", file=sys.stderr) + print( + "Error: Invalid format. Use format: HASH[@lang[@mapping_hash]]", + file=sys.stderr, + ) sys.exit(1) hash_value = parts[0] @@ -3908,13 +4275,21 @@ def code_show(hash_with_lang_and_mapping: str): mapping_hash = parts[2] if len(parts) > 2 else None # Validate hash format (should be 64 hex characters for SHA256) - if len(hash_value) != 64 or not all(c in '0123456789abcdef' for c in hash_value.lower()): - print(f"Error: Invalid hash format. Expected 64 hex characters. Got: {hash_value}", file=sys.stderr) + if len(hash_value) != 64 or not all( + c in "0123456789abcdef" for c in hash_value.lower() + ): + print( + f"Error: Invalid hash format. Expected 64 hex characters. Got: {hash_value}", + file=sys.stderr, + ) sys.exit(1) # Validate language code (should be 3 characters, ISO 639-3) if len(lang) < 3 or len(lang) > 256: - print(f"Error: Language code must be 3-256 characters. Got: {lang}", file=sys.stderr) + print( + f"Error: Language code must be 3-256 characters. Got: {lang}", + file=sys.stderr, + ) sys.exit(1) # Detect schema version @@ -3924,7 +4299,7 @@ def code_show(hash_with_lang_and_mapping: str): sys.exit(1) # Get available mappings for the language - mappings = mappings_list_v1(hash_value, lang) + mappings = storage_mappings_list_v1(hash_value, lang) if len(mappings) == 0: print(f"Error: No mappings found for language '{lang}'", file=sys.stderr) @@ -3946,7 +4321,9 @@ def code_show(hash_with_lang_and_mapping: str): return # Load the selected mapping - normalized_code, name_mapping, alias_mapping, docstring = code_load(hash_value, lang, mapping_hash=selected_hash) + normalized_code, name_mapping, alias_mapping, docstring = code_load( + hash_value, lang, mapping_hash=selected_hash + ) # Replace docstring and denormalize try: @@ -3963,27 +4340,40 @@ def code_show(hash_with_lang_and_mapping: str): def code_get(hash_with_lang: str): """Get a function from the bb pool (backward compatible with show command)""" # Deprecation warning - print("Warning: 'get' is deprecated. Use 'show' instead for better mapping support.", file=sys.stderr) + print( + "Warning: 'get' is deprecated. Use 'show' instead for better mapping support.", + file=sys.stderr, + ) # Parse the hash and language - if '@' not in hash_with_lang: + if "@" not in hash_with_lang: print("Error: Missing language suffix. Use format: HASH@lang", file=sys.stderr) sys.exit(1) - hash_value, lang = hash_with_lang.rsplit('@', 1) + hash_value, lang = hash_with_lang.rsplit("@", 1) # Validate language code (should be 3 characters, ISO 639-3) if len(lang) < 3 or len(lang) > 256: - print(f"Error: Language code must be 3-256 characters. Got: {lang}", file=sys.stderr) + print( + f"Error: Language code must be 3-256 characters. Got: {lang}", + file=sys.stderr, + ) sys.exit(1) # Validate hash format (should be 64 hex characters for SHA256) - if len(hash_value) != 64 or not all(c in '0123456789abcdef' for c in hash_value.lower()): - print(f"Error: Invalid hash format. Expected 64 hex characters. Got: {hash_value}", file=sys.stderr) + if len(hash_value) != 64 or not all( + c in "0123456789abcdef" for c in hash_value.lower() + ): + print( + f"Error: Invalid hash format. Expected 64 hex characters. Got: {hash_value}", + file=sys.stderr, + ) sys.exit(1) # Load function data from pool - normalized_code, name_mapping, alias_mapping, docstring = code_load(hash_value, lang) + normalized_code, name_mapping, alias_mapping, docstring = code_load( + hash_value, lang + ) # Replace the docstring with the language-specific one try: @@ -4003,7 +4393,7 @@ def code_get(hash_with_lang: str): print(original_code) -def schema_validate_v1(func_hash: str) -> tuple: +def storage_schema_validate_v1(func_hash: str) -> tuple: """ Validate a v1 function. @@ -4023,7 +4413,7 @@ def schema_validate_v1(func_hash: str) -> tuple: # Check object.json exists func_dir = pool_dir / func_hash[:2] / func_hash[2:] - object_json = func_dir / 'object.json' + object_json = func_dir / "object.json" if not object_json.exists(): errors.append(f"object.json not found for function {func_hash}") @@ -4031,17 +4421,17 @@ def schema_validate_v1(func_hash: str) -> tuple: # Validate object.json structure try: - with open(object_json, 'r', encoding='utf-8') as f: + with open(object_json, "r", encoding="utf-8") as f: func_data = json.load(f) # Check required fields - required_fields = ['schema_version', 'hash', 'normalized_code', 'metadata'] + required_fields = ["schema_version", "hash", "normalized_code", "metadata"] for field in required_fields: if field not in func_data: errors.append(f"Missing required field in object.json: {field}") # Verify schema version - if func_data.get('schema_version') != 1: + if func_data.get("schema_version") != 1: errors.append(f"Invalid schema version: {func_data.get('schema_version')}") except (IOError, json.JSONDecodeError) as e: @@ -4056,7 +4446,7 @@ def schema_validate_v1(func_hash: str) -> tuple: # Count language directories lang_count = 0 for item in func_dir.iterdir(): - if item.is_dir() and not item.name.endswith('.json'): + if item.is_dir() and not item.name.endswith(".json"): lang_count += 1 if lang_count == 0: @@ -4069,7 +4459,7 @@ def schema_validate_v1(func_hash: str) -> tuple: return True, [] -def schema_validate_directory() -> tuple: +def storage_schema_validate_directory() -> tuple: """ Validate the entire bb directory structure. @@ -4087,11 +4477,11 @@ def schema_validate_directory() -> tuple: """ errors = [] stats = { - 'functions_total': 0, - 'functions_valid': 0, - 'functions_invalid': 0, - 'languages_total': set(), - 'dependencies_missing': [] + "functions_total": 0, + "functions_valid": 0, + "functions_invalid": 0, + "languages_total": set(), + "dependencies_missing": [], } bb_dir = storage_get_bb_directory() @@ -4102,10 +4492,10 @@ def schema_validate_directory() -> tuple: return False, errors, stats # Validate config file - config_path = bb_dir / 'config.json' + config_path = bb_dir / "config.json" if config_path.exists(): try: - with open(config_path, 'r', encoding='utf-8') as f: + with open(config_path, "r", encoding="utf-8") as f: config = json.load(f) if not isinstance(config, dict): errors.append("Config file is not a valid JSON object") @@ -4137,48 +4527,50 @@ def schema_validate_directory() -> tuple: # Skip if not a valid hash format if len(func_hash) != 64: continue - if not all(c in '0123456789abcdef' for c in func_hash.lower()): + if not all(c in "0123456789abcdef" for c in func_hash.lower()): continue all_hashes.add(func_hash) - stats['functions_total'] += 1 + stats["functions_total"] += 1 # Validate individual function - is_valid, func_errors = schema_validate_v1(func_hash) + is_valid, func_errors = storage_schema_validate_v1(func_hash) if is_valid: - stats['functions_valid'] += 1 + stats["functions_valid"] += 1 # Check for available languages for item in func_dir.iterdir(): - if item.is_dir() and not item.name.startswith('.'): - stats['languages_total'].add(item.name) + if item.is_dir() and not item.name.startswith("."): + stats["languages_total"].add(item.name) else: - stats['functions_invalid'] += 1 + stats["functions_invalid"] += 1 for err in func_errors: errors.append(f"[{func_hash[:12]}...] {err}") # Verify all dependencies are resolvable (only for valid functions) for func_hash in all_hashes: # Skip if this function had validation errors - is_valid, _ = schema_validate_v1(func_hash) + is_valid, _ = storage_schema_validate_v1(func_hash) if not is_valid: continue try: func_data = code_load_v1(func_hash) - normalized_code = func_data['normalized_code'] + normalized_code = func_data["normalized_code"] deps = code_extract_dependencies(normalized_code) for dep in deps: if dep not in all_hashes: - stats['dependencies_missing'].append((func_hash, dep)) - errors.append(f"[{func_hash[:12]}...] Missing dependency: {dep[:12]}...") + stats["dependencies_missing"].append((func_hash, dep)) + errors.append( + f"[{func_hash[:12]}...] Missing dependency: {dep[:12]}..." + ) except (Exception, SystemExit): # Already reported in individual validation pass # Convert set to count for stats - stats['languages_total'] = len(stats['languages_total']) + stats["languages_total"] = len(stats["languages_total"]) is_valid = len(errors) == 0 return is_valid, errors, stats @@ -4217,12 +4609,12 @@ def storage_validate_pool(pool_path: Path) -> tuple: if not prefix_dir.is_dir(): continue # Skip .git directory and other non-hex directories - if prefix_dir.name == '.git': + if prefix_dir.name == ".git": continue # Check if it's a 2-char hex prefix if len(prefix_dir.name) != 2: continue # Skip non-prefix directories silently - if not all(c in '0123456789abcdef' for c in prefix_dir.name.lower()): + if not all(c in "0123456789abcdef" for c in prefix_dir.name.lower()): continue # Skip non-hex directories silently for func_dir in prefix_dir.iterdir(): @@ -4232,29 +4624,36 @@ def storage_validate_pool(pool_path: Path) -> tuple: # Reconstruct and validate hash func_hash = prefix_dir.name + func_dir.name if len(func_hash) != 64: - errors.append(f"Invalid hash length in {prefix_dir.name}/{func_dir.name}") + errors.append( + f"Invalid hash length in {prefix_dir.name}/{func_dir.name}" + ) continue - if not all(c in '0123456789abcdef' for c in func_hash.lower()): + if not all(c in "0123456789abcdef" for c in func_hash.lower()): errors.append(f"Invalid hash format: {func_hash}") continue # Check object.json exists and is valid - object_json = func_dir / 'object.json' + object_json = func_dir / "object.json" if not object_json.exists(): errors.append(f"Missing object.json for {func_hash[:12]}...") continue try: - with open(object_json, 'r', encoding='utf-8') as f: + with open(object_json, "r", encoding="utf-8") as f: func_data = json.load(f) # Check required fields - required_fields = ['schema_version', 'hash', 'normalized_code', 'metadata'] + required_fields = [ + "schema_version", + "hash", + "normalized_code", + "metadata", + ] for field in required_fields: if field not in func_data: errors.append(f"Missing field '{field}' in {func_hash[:12]}...") - if func_data.get('schema_version') != 1: + if func_data.get("schema_version") != 1: errors.append(f"Invalid schema version in {func_hash[:12]}...") except (IOError, json.JSONDecodeError) as e: @@ -4280,8 +4679,13 @@ def command_caller(hash_value: str): hash_value: Function hash (64-character hex) to find callers of """ # Validate hash format - if len(hash_value) != 64 or not all(c in '0123456789abcdef' for c in hash_value.lower()): - print(f"Error: Invalid hash format. Expected 64 hex characters. Got: {hash_value}", file=sys.stderr) + if len(hash_value) != 64 or not all( + c in "0123456789abcdef" for c in hash_value.lower() + ): + print( + f"Error: Invalid hash format. Expected 64 hex characters. Got: {hash_value}", + file=sys.stderr, + ) sys.exit(1) # Check if function exists @@ -4307,16 +4711,16 @@ def command_caller(hash_value: str): if not func_dir.is_dir(): continue - object_json = func_dir / 'object.json' + object_json = func_dir / "object.json" if not object_json.exists(): continue try: - with open(object_json, 'r', encoding='utf-8') as f: + with open(object_json, "r", encoding="utf-8") as f: data = json.load(f) - func_hash = data['hash'] - normalized_code = data['normalized_code'] + func_hash = data["hash"] + normalized_code = data["normalized_code"] # Check if this function depends on the target hash deps = code_extract_dependencies(normalized_code) @@ -4330,7 +4734,7 @@ def command_caller(hash_value: str): print(f"bb.py show {caller_hash}") -def command_check(hash_value: str): +def command_check_run(hash_value: str): """ Find and run all tests for the given function. @@ -4342,8 +4746,13 @@ def command_check(hash_value: str): hash_value: Function hash (64-character hex) to find tests for """ # Validate hash format - if len(hash_value) != 64 or not all(c in '0123456789abcdef' for c in hash_value.lower()): - print(f"Error: Invalid hash format. Expected 64 hex characters. Got: {hash_value}", file=sys.stderr) + if len(hash_value) != 64 or not all( + c in "0123456789abcdef" for c in hash_value.lower() + ): + print( + f"Error: Invalid hash format. Expected 64 hex characters. Got: {hash_value}", + file=sys.stderr, + ) sys.exit(1) # Check if function exists @@ -4369,17 +4778,17 @@ def command_check(hash_value: str): if not func_dir.is_dir(): continue - object_json = func_dir / 'object.json' + object_json = func_dir / "object.json" if not object_json.exists(): continue try: - with open(object_json, 'r', encoding='utf-8') as f: + with open(object_json, "r", encoding="utf-8") as f: data = json.load(f) - func_hash = data['hash'] - metadata = data.get('metadata', {}) - checks = metadata.get('checks', []) + func_hash = data["hash"] + metadata = data.get("metadata", {}) + checks = metadata.get("checks", []) # Check if this function tests the target hash if hash_value in checks: @@ -4410,9 +4819,12 @@ def command_refactor(what_hash: str, from_hash: str, to_hash: str): to_hash: New dependency hash (64-character hex) """ # Validate hash formats - for name, h in [('what', what_hash), ('from', from_hash), ('to', to_hash)]: - if len(h) != 64 or not all(c in '0123456789abcdef' for c in h.lower()): - print(f"Error: Invalid {name} hash format. Expected 64 hex characters. Got: {h}", file=sys.stderr) + for name, h in [("what", what_hash), ("from", from_hash), ("to", to_hash)]: + if len(h) != 64 or not all(c in "0123456789abcdef" for c in h.lower()): + print( + f"Error: Invalid {name} hash format. Expected 64 hex characters. Got: {h}", + file=sys.stderr, + ) sys.exit(1) # Check if what function exists @@ -4429,10 +4841,10 @@ def command_refactor(what_hash: str, from_hash: str, to_hash: str): # Load the function's normalized code (v1 only) func_data = code_load_v1(what_hash) - normalized_code = func_data['normalized_code'] + normalized_code = func_data["normalized_code"] # Get all languages from v1 directory structure pool_dir = storage_get_pool_directory() - func_dir = pool_dir / 'sha256' / what_hash[:2] / what_hash[2:] + func_dir = pool_dir / "sha256" / what_hash[:2] / what_hash[2:] languages = [] for item in func_dir.iterdir(): if item.is_dir() and len(item.name) == 3: @@ -4441,7 +4853,10 @@ def command_refactor(what_hash: str, from_hash: str, to_hash: str): # Check that the function actually depends on from_hash deps = code_extract_dependencies(normalized_code) if from_hash not in deps: - print(f"Error: Function {what_hash} does not depend on {from_hash}", file=sys.stderr) + print( + f"Error: Function {what_hash} does not depend on {from_hash}", + file=sys.stderr, + ) sys.exit(1) # Replace the dependency in the code @@ -4449,13 +4864,13 @@ def command_refactor(what_hash: str, from_hash: str, to_hash: str): class DependencyReplacer(ast.NodeTransformer): def visit_ImportFrom(self, node): - if node.module == 'bb.pool': + if node.module == "bb.pool": new_names = [] for alias in node.names: import_name = alias.name # Check if this is the from_hash import if import_name.startswith(BB_IMPORT_PREFIX): - actual_hash = import_name[len(BB_IMPORT_PREFIX):] + actual_hash = import_name[len(BB_IMPORT_PREFIX) :] else: actual_hash = import_name @@ -4470,11 +4885,10 @@ def visit_ImportFrom(self, node): def visit_Attribute(self, node): # Transform object_from_hash._bb_v_0 -> object_to_hash._bb_v_0 - if (isinstance(node.value, ast.Name) and - node.attr == '_bb_v_0'): + if isinstance(node.value, ast.Name) and node.attr == "_bb_v_0": prefixed_name = node.value.id if prefixed_name.startswith(BB_IMPORT_PREFIX): - actual_hash = prefixed_name[len(BB_IMPORT_PREFIX):] + actual_hash = prefixed_name[len(BB_IMPORT_PREFIX) :] else: actual_hash = prefixed_name @@ -4496,13 +4910,17 @@ def visit_Attribute(self, node): if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): _, func_without_docstring = code_extract_docstring(node) # Rebuild module without docstring for hashing - imports = [n for n in new_tree.body if isinstance(n, (ast.Import, ast.ImportFrom))] - module_without_docstring = ast.Module(body=imports + [func_without_docstring], type_ignores=[]) + imports = [ + n for n in new_tree.body if isinstance(n, (ast.Import, ast.ImportFrom)) + ] + module_without_docstring = ast.Module( + body=imports + [func_without_docstring], type_ignores=[] + ) ast.fix_missing_locations(module_without_docstring) code_without_docstring = ast.unparse(module_without_docstring) break - new_hash = hash_compute(code_without_docstring) + new_hash = code_hash_compute(code_without_docstring) # Create metadata for the new function metadata = code_create_metadata() @@ -4512,9 +4930,11 @@ def visit_Attribute(self, node): # Copy all language mappings from what_hash to new_hash (v1 only) for lang in languages: - mappings = mappings_list_v1(what_hash, lang) + mappings = storage_mappings_list_v1(what_hash, lang) for mapping_hash, comment in mappings: - docstring, name_mapping, alias_mapping, comment = mapping_load_v1(what_hash, lang, mapping_hash) + docstring, name_mapping, alias_mapping, comment = storage_mapping_load_v1( + what_hash, lang, mapping_hash + ) # Update alias_mapping: replace from_hash key with to_hash new_alias_mapping = {} @@ -4524,7 +4944,9 @@ def visit_Attribute(self, node): else: new_alias_mapping[dep_hash] = alias - mapping_save_v1(new_hash, lang, docstring, name_mapping, new_alias_mapping, comment) + storage_mapping_save_v1( + new_hash, lang, docstring, name_mapping, new_alias_mapping, comment + ) # Print the result command print(f"bb.py show {new_hash}") @@ -4534,7 +4956,10 @@ def visit_Attribute(self, node): # Compilation Functions # ============================================================================= -def compile_get_nuitka_command(main_file: str, output_name: str, onefile: bool = True) -> list: + +def command_compile_get_nuitka_command( + main_file: str, output_name: str, onefile: bool = True +) -> list: """ Build Nuitka command line arguments. @@ -4547,22 +4972,26 @@ def compile_get_nuitka_command(main_file: str, output_name: str, onefile: bool = List of command arguments for subprocess """ cmd = [ - 'python3', '-m', 'nuitka', - '--standalone', - f'--output-filename={output_name}', + "python3", + "-m", + "nuitka", + "--standalone", + f"--output-filename={output_name}", ] if onefile: - cmd.append('--onefile') + cmd.append("--onefile") # Suppress Nuitka's info messages for cleaner output - cmd.append('--quiet') + cmd.append("--quiet") cmd.append(main_file) return cmd -def compile_generate_runtime(func_hash: str, lang: str, output_dir: Path) -> Path: +def command_compile_generate_runtime( + func_hash: str, lang: str, output_dir: Path +) -> Path: """ Generate the bb runtime module for the compiled executable. @@ -4574,7 +5003,7 @@ def compile_generate_runtime(func_hash: str, lang: str, output_dir: Path) -> Pat Returns: Path to the generated runtime module """ - runtime_dir = output_dir / 'bb_runtime' + runtime_dir = output_dir / "bb_runtime" runtime_dir.mkdir(parents=True, exist_ok=True) # Copy the necessary parts of bb for runtime @@ -4611,7 +5040,7 @@ def code_load_v1(hash_value: str): return json.load(f) -def mapping_load_v1(func_hash: str, lang: str, mapping_hash: str): +def storage_mapping_load_v1(func_hash: str, lang: str, mapping_hash: str): """Load mapping from v1 format.""" bundle_dir = storage_get_bundle_directory() mapping_path = (bundle_dir / 'sha256' / func_hash[:2] / func_hash[2:] / @@ -4628,7 +5057,7 @@ def mapping_load_v1(func_hash: str, lang: str, mapping_hash: str): ) -def mappings_list_v1(func_hash: str, lang: str): +def storage_mappings_list_v1(func_hash: str, lang: str): """List mappings for a function in a language.""" bundle_dir = storage_get_bundle_directory() lang_dir = bundle_dir / 'sha256' / func_hash[:2] / func_hash[2:] / lang / 'sha256' @@ -4656,7 +5085,7 @@ def code_load(hash_value: str, lang: str, mapping_hash: str = None): func_data = code_load_v1(hash_value) normalized_code = func_data['normalized_code'] - mappings = mappings_list_v1(hash_value, lang) + mappings = storage_mappings_list_v1(hash_value, lang) if not mappings: raise ValueError(f"No mapping found for language: {lang}") @@ -4665,7 +5094,7 @@ def code_load(hash_value: str, lang: str, mapping_hash: str = None): else: selected_hash = mappings[0][0] - docstring, name_mapping, alias_mapping, comment = mapping_load_v1(hash_value, lang, selected_hash) + docstring, name_mapping, alias_mapping, comment = storage_mapping_load_v1(hash_value, lang, selected_hash) return normalized_code, name_mapping, alias_mapping, docstring @@ -4742,14 +5171,16 @@ def code_execute(func_hash: str, lang: str, args: list): ''' # Write __init__.py - init_path = runtime_dir / '__init__.py' - with open(init_path, 'w', encoding='utf-8') as f: + init_path = runtime_dir / "__init__.py" + with open(init_path, "w", encoding="utf-8") as f: f.write(runtime_code) return runtime_dir -def compile_generate_python(func_hash: str, lang: str = None, debug_mode: bool = False) -> str: +def command_compile_generate_python( + func_hash: str, lang: str = None, debug_mode: bool = False +) -> str: """ Generate a single Python file that includes all dependencies. @@ -4773,7 +5204,7 @@ def compile_generate_python(func_hash: str, lang: str = None, debug_mode: bool = # Debug mode: check that all dependencies have the requested language available missing_lang = [] for dep_hash in deps: - mappings = mappings_list_v1(dep_hash, lang) + mappings = storage_mappings_list_v1(dep_hash, lang) if not mappings: available_langs = storage_list_languages(dep_hash) missing_lang.append((dep_hash, available_langs)) @@ -4782,14 +5213,20 @@ def compile_generate_python(func_hash: str, lang: str = None, debug_mode: bool = error_lines = [f"The following functions are not available in '{lang}':"] for dep_hash, available in missing_lang: if available: - error_lines.append(f" - {dep_hash[:16]}... (available: {', '.join(available)})") + error_lines.append( + f" - {dep_hash[:16]}... (available: {', '.join(available)})" + ) else: - error_lines.append(f" - {dep_hash[:16]}... (no languages available)") + error_lines.append( + f" - {dep_hash[:16]}... (no languages available)" + ) error_lines.append("") error_lines.append("Please add translations for these functions first:") for dep_hash, available in missing_lang: if available: - error_lines.append(f" python3 bb.py translate {dep_hash}@{available[0]} {lang}") + error_lines.append( + f" python3 bb.py translate {dep_hash}@{available[0]} {lang}" + ) raise ValueError("\n".join(error_lines)) # Load all functions @@ -4797,22 +5234,28 @@ def compile_generate_python(func_hash: str, lang: str = None, debug_mode: bool = # Create a mapping from hash to unique function name for normal mode hash_to_func_name = {} for dep_hash in deps: - hash_to_func_name[dep_hash] = f'_bb_{dep_hash[:8]}' + hash_to_func_name[dep_hash] = f"_bb_{dep_hash[:8]}" for dep_hash in deps: func_data = code_load_v1(dep_hash) - normalized_code = func_data['normalized_code'] + normalized_code = func_data["normalized_code"] if debug_mode: # Debug mode: denormalize to human-readable names - mappings = mappings_list_v1(dep_hash, lang) + mappings = storage_mappings_list_v1(dep_hash, lang) mapping_hash = mappings[0][0] - docstring, name_mapping, alias_mapping, _ = mapping_load_v1(dep_hash, lang, mapping_hash) + docstring, name_mapping, alias_mapping, _ = storage_mapping_load_v1( + dep_hash, lang, mapping_hash + ) # Denormalize the code - normalized_code_with_doc = code_replace_docstring(normalized_code, docstring) - code = code_denormalize(normalized_code_with_doc, name_mapping, alias_mapping) - func_name = name_mapping.get('_bb_v_0', '_bb_v_0') + normalized_code_with_doc = code_replace_docstring( + normalized_code, docstring + ) + code = code_denormalize( + normalized_code_with_doc, name_mapping, alias_mapping + ) + func_name = name_mapping.get("_bb_v_0", "_bb_v_0") else: # Normal mode: use normalized code with unique function names code = normalized_code @@ -4825,19 +5268,19 @@ def compile_generate_python(func_hash: str, lang: str = None, debug_mode: bool = class FunctionRenamer(ast.NodeTransformer): def visit_Name(self, node): # Replace recursive calls to _bb_v_0 - if node.id == '_bb_v_0': + if node.id == "_bb_v_0": node.id = func_name return node def visit_FunctionDef(self, node): # Replace function definition name - if node.name == '_bb_v_0': + if node.name == "_bb_v_0": node.name = func_name self.generic_visit(node) return node def visit_AsyncFunctionDef(self, node): - if node.name == '_bb_v_0': + if node.name == "_bb_v_0": node.name = func_name self.generic_visit(node) return node @@ -4848,24 +5291,32 @@ def visit_AsyncFunctionDef(self, node): # Replace calls to other bb functions with their unique names for other_hash, other_name in hash_to_func_name.items(): # Replace object_HASH._bb_v_0 with the unique function name - code = code.replace(f'object_{other_hash}._bb_v_0', other_name) + code = code.replace(f"object_{other_hash}._bb_v_0", other_name) # Strip bb imports - dependencies will be included inline code = code_strip_bb_imports(code) - functions.append({ - 'hash': dep_hash, - 'code': code, - 'func_name': func_name, - }) + functions.append( + { + "hash": dep_hash, + "code": code, + "func_name": func_name, + } + ) # Build the Python file - lines = ['#!/usr/bin/env python3', '"""', f'Compiled bb function: {func_hash}', '"""', ''] + lines = [ + "#!/usr/bin/env python3", + '"""', + f"Compiled bb function: {func_hash}", + '"""', + "", + ] # Collect all standard imports from all functions all_imports = set() for func in functions: - tree = ast.parse(func['code']) + tree = ast.parse(func["code"]) for node in tree.body: if isinstance(node, (ast.Import, ast.ImportFrom)): all_imports.add(ast.unparse(node)) @@ -4874,45 +5325,49 @@ def visit_AsyncFunctionDef(self, node): for imp in sorted(all_imports): lines.append(imp) if all_imports: - lines.append('') + lines.append("") # Add each function (without imports) for func in functions: # Parse and extract just the function definition - tree = ast.parse(func['code']) + tree = ast.parse(func["code"]) for node in tree.body: if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): - lines.append('') + lines.append("") lines.append(ast.unparse(node)) - lines.append('') + lines.append("") # Add main entry point - main_func = functions[-1] # The last one is the main function (root of dependency tree) - lines.append('') + main_func = functions[ + -1 + ] # The last one is the main function (root of dependency tree) + lines.append("") lines.append('if __name__ == "__main__":') - lines.append(' import sys') - lines.append(f' # Entry point: {main_func["func_name"]}') - lines.append(' if len(sys.argv) > 1:') - lines.append(' args = []') - lines.append(' for arg in sys.argv[1:]:') - lines.append(' try:') - lines.append(' args.append(int(arg))') - lines.append(' except ValueError:') - lines.append(' try:') - lines.append(' args.append(float(arg))') - lines.append(' except ValueError:') - lines.append(' args.append(arg)') - lines.append(f' result = {main_func["func_name"]}(*args)') - lines.append(' print(result)') - lines.append(' else:') - lines.append(f' print("Usage: python {{sys.argv[0]}} [args...]")') + lines.append(" import sys") + lines.append(f" # Entry point: {main_func['func_name']}") + lines.append(" if len(sys.argv) > 1:") + lines.append(" args = []") + lines.append(" for arg in sys.argv[1:]:") + lines.append(" try:") + lines.append(" args.append(int(arg))") + lines.append(" except ValueError:") + lines.append(" try:") + lines.append(" args.append(float(arg))") + lines.append(" except ValueError:") + lines.append(" args.append(arg)") + lines.append(f" result = {main_func['func_name']}(*args)") + lines.append(" print(result)") + lines.append(" else:") + lines.append(' print("Usage: python {sys.argv[0]} [args...]")') lines.append(f' print("Available function: {main_func["func_name"]}")') - lines.append('') + lines.append("") - return '\n'.join(lines) + return "\n".join(lines) -def command_compile(hash_with_lang: str, python_mode: bool = False, debug_mode: bool = False): +def command_compile( + hash_with_lang: str, python_mode: bool = False, debug_mode: bool = False +): """ Compile a function into a standalone executable or Python file. @@ -4925,11 +5380,14 @@ def command_compile(hash_with_lang: str, python_mode: bool = False, debug_mode: import platform # Parse the hash and optional language - if '@' in hash_with_lang: - func_hash, lang = hash_with_lang.rsplit('@', 1) + if "@" in hash_with_lang: + func_hash, lang = hash_with_lang.rsplit("@", 1) # Validate language code if len(lang) < 3 or len(lang) > 256: - print(f"Error: Language code must be 3-256 characters. Got: {lang}", file=sys.stderr) + print( + f"Error: Language code must be 3-256 characters. Got: {lang}", + file=sys.stderr, + ) sys.exit(1) else: func_hash = hash_with_lang @@ -4937,12 +5395,20 @@ def command_compile(hash_with_lang: str, python_mode: bool = False, debug_mode: # Debug mode requires language if debug_mode and lang is None: - print("Error: --debug requires language suffix. Use format: HASH@lang", file=sys.stderr) + print( + "Error: --debug requires language suffix. Use format: HASH@lang", + file=sys.stderr, + ) sys.exit(1) # Validate hash format - if len(func_hash) != 64 or not all(c in '0123456789abcdef' for c in func_hash.lower()): - print(f"Error: Invalid hash format. Expected 64 hex characters. Got: {func_hash}", file=sys.stderr) + if len(func_hash) != 64 or not all( + c in "0123456789abcdef" for c in func_hash.lower() + ): + print( + f"Error: Invalid hash format. Expected 64 hex characters. Got: {func_hash}", + file=sys.stderr, + ) sys.exit(1) # Check if function exists @@ -4969,11 +5435,13 @@ def command_compile(hash_with_lang: str, python_mode: bool = False, debug_mode: if python_mode: # Generate single Python file print("Generating Python file...") - output_path = Path('main.py') + output_path = Path("main.py") try: - python_code = compile_generate_python(func_hash, lang, debug_mode=debug_mode) - with open(output_path, 'w', encoding='utf-8') as f: + python_code = command_compile_generate_python( + func_hash, lang, debug_mode=debug_mode + ) + with open(output_path, "w", encoding="utf-8") as f: f.write(python_code) print(f"Python file created: {output_path}") print(f"Run with: python3 {output_path} [args...]") @@ -4983,47 +5451,45 @@ def command_compile(hash_with_lang: str, python_mode: bool = False, debug_mode: else: # Native executable mode - use Nuitka # Determine default output name - if platform.system() == 'Windows': - output_name = 'a.out.exe' + if platform.system() == "Windows": + output_name = "a.out.exe" else: - output_name = 'a.out' + output_name = "a.out" # Check if Nuitka is available result = subprocess.run( - ['python3', '-m', 'nuitka', '--version'], - capture_output=True, - text=True + ["python3", "-m", "nuitka", "--version"], capture_output=True, text=True ) if result.returncode != 0: print("Error: Nuitka not found. Please install it first:", file=sys.stderr) print(" pip install nuitka", file=sys.stderr) - print("\nAlternatively, use --python flag to generate a Python file.", file=sys.stderr) + print( + "\nAlternatively, use --python flag to generate a Python file.", + file=sys.stderr, + ) sys.exit(1) # Create build directory - build_dir = Path(f'.bb_build_{func_hash[:8]}') + build_dir = Path(f".bb_build_{func_hash[:8]}") build_dir.mkdir(parents=True, exist_ok=True) try: # Generate inline Python code print("Generating Python code...") - python_code = compile_generate_python(func_hash, lang, debug_mode=debug_mode) - main_path = build_dir / 'main.py' - with open(main_path, 'w', encoding='utf-8') as f: + python_code = command_compile_generate_python( + func_hash, lang, debug_mode=debug_mode + ) + main_path = build_dir / "main.py" + with open(main_path, "w", encoding="utf-8") as f: f.write(python_code) # Build with Nuitka print("Building executable with Nuitka...") - nuitka_cmd = compile_get_nuitka_command( - str(main_path), - output_name, - onefile=True + nuitka_cmd = command_compile_get_nuitka_command( + str(main_path), output_name, onefile=True ) result = subprocess.run( - nuitka_cmd, - cwd=str(build_dir), - capture_output=True, - text=True + nuitka_cmd, cwd=str(build_dir), capture_output=True, text=True ) if result.returncode != 0: @@ -5042,7 +5508,7 @@ def command_compile(hash_with_lang: str, python_mode: bool = False, debug_mode: final_path = Path(output_name) shutil.copy2(exe_path, final_path) # Make executable on Unix - if platform.system() != 'Windows': + if platform.system() != "Windows": final_path.chmod(final_path.stat().st_mode | 0o111) exe_found = True print(f"Executable created: {final_path}") @@ -5053,7 +5519,7 @@ def command_compile(hash_with_lang: str, python_mode: bool = False, debug_mode: if exe.is_file(): final_path = Path(output_name) shutil.copy2(exe, final_path) - if platform.system() != 'Windows': + if platform.system() != "Windows": final_path.chmod(final_path.stat().st_mode | 0o111) exe_found = True print(f"Executable created: {final_path}") @@ -5079,7 +5545,7 @@ def command_aston(filepath: str, test_mode: bool = False): test_mode: If True, run round-trip test instead of outputting tuples """ try: - with open(filepath, 'r', encoding='utf-8') as f: + with open(filepath, "r", encoding="utf-8") as f: source = f.read() except FileNotFoundError: print(f"Error: File not found: {filepath}", file=sys.stderr) @@ -5095,9 +5561,9 @@ def command_aston(filepath: str, test_mode: bool = False): sys.exit(1) if test_mode: - # Test round-trip: expected == aston_read(aston_write(expected)) - _, tuples = aston_write(tree) - reconstructed = aston_read(tuples) + # Test round-trip: expected == code_aston_read(code_aston_write(expected)) + _, tuples = code_aston_write(tree) + reconstructed = code_aston_read(tuples) # Compare using ast.dump original_dump = ast.dump(tree) @@ -5116,166 +5582,252 @@ def command_aston(filepath: str, test_mode: bool = False): sys.exit(1) else: # Normal mode - output tuples as JSON lines - _, tuples = aston_write(tree) + _, tuples = code_aston_write(tree) for tup in tuples: print(json.dumps(tup, ensure_ascii=False)) def main(): - parser = argparse.ArgumentParser(description='bb - Function pool manager') - subparsers = parser.add_subparsers(dest='command', help='Commands') + parser = argparse.ArgumentParser(description="bb - Function pool manager") + subparsers = parser.add_subparsers(dest="command", help="Commands") # Init command - init_parser = subparsers.add_parser('init', help='Initialize bb directory and config') + subparsers.add_parser("init", help="Initialize bb directory and config") # Whoami command - whoami_parser = subparsers.add_parser('whoami', help='Get or set user configuration') - whoami_parser.add_argument('subcommand', choices=['name', 'email', 'public-key', 'language'], - help='Configuration field to get/set') - whoami_parser.add_argument('value', nargs='*', help='New value(s) to set (omit to get current value)') + whoami_parser = subparsers.add_parser( + "whoami", help="Get or set user configuration" + ) + whoami_parser.add_argument( + "subcommand", + choices=["name", "email", "public-key", "language"], + help="Configuration field to get/set", + ) + whoami_parser.add_argument( + "value", nargs="*", help="New value(s) to set (omit to get current value)" + ) # Add command - add_parser = subparsers.add_parser('add', help='Add a function to the pool') - add_parser.add_argument('file', help='Path to Python file with @lang suffix (e.g., file.py@eng)') - add_parser.add_argument('--comment', default='', help='Optional comment explaining this mapping variant') + add_parser = subparsers.add_parser("add", help="Add a function to the pool") + add_parser.add_argument( + "file", help="Path to Python file with @lang suffix (e.g., file.py@eng)" + ) + add_parser.add_argument( + "--comment", default="", help="Optional comment explaining this mapping variant" + ) # Get command (backward compatibility) - get_parser = subparsers.add_parser('get', help='Get a function from the pool') - get_parser.add_argument('hash', help='Function hash with @lang suffix (e.g., abc123...@eng)') + get_parser = subparsers.add_parser("get", help="Get a function from the pool") + get_parser.add_argument( + "hash", help="Function hash with @lang suffix (e.g., abc123...@eng)" + ) # Show command (improved version of get with mapping selection) - show_parser = subparsers.add_parser('show', help='Show a function with mapping selection support') - show_parser.add_argument('hash', help='Function hash with @lang[@mapping_hash] (e.g., abc123...@eng or abc123...@eng@xyz789...)') + show_parser = subparsers.add_parser( + "show", help="Show a function with mapping selection support" + ) + show_parser.add_argument( + "hash", + help="Function hash with @lang[@mapping_hash] (e.g., abc123...@eng or abc123...@eng@xyz789...)", + ) # Translate command - translate_parser = subparsers.add_parser('translate', help='Add translation for existing function') - translate_parser.add_argument('hash', help='Function hash with source language (e.g., abc123...@eng)') - translate_parser.add_argument('target_lang', help='Target language code (e.g., fra, spa)') + translate_parser = subparsers.add_parser( + "translate", help="Add translation for existing function" + ) + translate_parser.add_argument( + "hash", help="Function hash with source language (e.g., abc123...@eng)" + ) + translate_parser.add_argument( + "target_lang", help="Target language code (e.g., fra, spa)" + ) # Run command - run_parser = subparsers.add_parser('run', help='Execute function interactively') - run_parser.add_argument('hash', help='Function hash with language (e.g., abc123...@eng)') - run_parser.add_argument('--debug', action='store_true', help='Run with debugger (pdb)') - run_parser.add_argument('func_args', nargs='*', help='Arguments to pass to function (after --)') + run_parser = subparsers.add_parser("run", help="Execute function interactively") + run_parser.add_argument( + "hash", help="Function hash with language (e.g., abc123...@eng)" + ) + run_parser.add_argument( + "--debug", action="store_true", help="Run with debugger (pdb)" + ) + run_parser.add_argument( + "func_args", nargs="*", help="Arguments to pass to function (after --)" + ) # Review command - review_parser = subparsers.add_parser('review', help='Recursively review function and dependencies') - review_parser.add_argument('hash', help='Function hash to review') + review_parser = subparsers.add_parser( + "review", help="Recursively review function and dependencies" + ) + review_parser.add_argument("hash", help="Function hash to review") # Log command - log_parser = subparsers.add_parser('log', help='Show git-like commit log of pool') + subparsers.add_parser("log", help="Show git-like commit log of pool") # Search command - search_parser = subparsers.add_parser('search', help='Search and list functions by query') - search_parser.add_argument('query', nargs='+', help='Search terms') + search_parser = subparsers.add_parser( + "search", help="Search and list functions by query" + ) + search_parser.add_argument("query", nargs="+", help="Search terms") # Remote command - remote_parser = subparsers.add_parser('remote', help='Manage remote repositories') - remote_subparsers = remote_parser.add_subparsers(dest='remote_command', help='Remote subcommands') + remote_parser = subparsers.add_parser("remote", help="Manage remote repositories") + remote_subparsers = remote_parser.add_subparsers( + dest="remote_command", help="Remote subcommands" + ) # Remote add - remote_add_parser = remote_subparsers.add_parser('add', help='Add remote repository') - remote_add_parser.add_argument('name', help='Remote name') - remote_add_parser.add_argument('url', help='Remote URL (http://, https://, or file://)') - remote_add_parser.add_argument('--read-only', action='store_true', help='Mark remote as read-only (push will be rejected)') + remote_add_parser = remote_subparsers.add_parser( + "add", help="Add remote repository" + ) + remote_add_parser.add_argument("name", help="Remote name") + remote_add_parser.add_argument( + "url", help="Remote URL (http://, https://, or file://)" + ) + remote_add_parser.add_argument( + "--read-only", + action="store_true", + help="Mark remote as read-only (push will be rejected)", + ) # Remote remove - remote_remove_parser = remote_subparsers.add_parser('remove', help='Remove remote repository') - remote_remove_parser.add_argument('name', help='Remote name to remove') + remote_remove_parser = remote_subparsers.add_parser( + "remove", help="Remove remote repository" + ) + remote_remove_parser.add_argument("name", help="Remote name to remove") # Remote list - remote_list_parser = remote_subparsers.add_parser('list', help='List configured remotes') + remote_subparsers.add_parser("list", help="List configured remotes") # Remote pull - remote_pull_parser = remote_subparsers.add_parser('pull', help='Fetch functions from remote') - remote_pull_parser.add_argument('name', help='Remote name to pull from') + remote_pull_parser = remote_subparsers.add_parser( + "pull", help="Fetch functions from remote" + ) + remote_pull_parser.add_argument("name", help="Remote name to pull from") # Remote push - remote_push_parser = remote_subparsers.add_parser('push', help='Publish functions to remote') - remote_push_parser.add_argument('name', help='Remote name to push to') + remote_push_parser = remote_subparsers.add_parser( + "push", help="Publish functions to remote" + ) + remote_push_parser.add_argument("name", help="Remote name to push to") # Remote sync - remote_sync_parser = remote_subparsers.add_parser('sync', help='Pull rebase then push to all remotes') + remote_subparsers.add_parser("sync", help="Pull rebase then push to all remotes") # Validate command - validate_parser = subparsers.add_parser('validate', help='Validate function or entire bb directory') - validate_parser.add_argument('hash', nargs='?', help='Function hash to validate (omit for whole directory)') - validate_parser.add_argument('--all', '-a', action='store_true', - help='Validate entire bb directory including pool and config') + validate_parser = subparsers.add_parser( + "validate", help="Validate function or entire bb directory" + ) + validate_parser.add_argument( + "hash", nargs="?", help="Function hash to validate (omit for whole directory)" + ) + validate_parser.add_argument( + "--all", + "-a", + action="store_true", + help="Validate entire bb directory including pool and config", + ) # Caller command - caller_parser = subparsers.add_parser('caller', help='Find functions that depend on a given function') - caller_parser.add_argument('hash', help='Function hash to find callers of') + caller_parser = subparsers.add_parser( + "caller", help="Find functions that depend on a given function" + ) + caller_parser.add_argument("hash", help="Function hash to find callers of") # Check command - check_parser = subparsers.add_parser('check', help='Find and run tests for a function') - check_parser.add_argument('hash', help='Function hash to find tests for') + check_parser = subparsers.add_parser( + "check", help="Find and run tests for a function" + ) + check_parser.add_argument("hash", help="Function hash to find tests for") # Refactor command - refactor_parser = subparsers.add_parser('refactor', help='Replace a dependency in a function') - refactor_parser.add_argument('what', help='Function hash to modify') - refactor_parser.add_argument('from_hash', metavar='from', help='Dependency hash to replace') - refactor_parser.add_argument('to_hash', metavar='to', help='New dependency hash') + refactor_parser = subparsers.add_parser( + "refactor", help="Replace a dependency in a function" + ) + refactor_parser.add_argument("what", help="Function hash to modify") + refactor_parser.add_argument( + "from_hash", metavar="from", help="Dependency hash to replace" + ) + refactor_parser.add_argument("to_hash", metavar="to", help="New dependency hash") # Compile command - compile_parser = subparsers.add_parser('compile', help='Compile function to standalone executable') - compile_parser.add_argument('hash', help='Function hash (HASH or HASH@lang). @lang required with --debug') - compile_parser.add_argument('--python', action='store_true', - help='Produce a single Python file instead of native executable (default output: main.py)') - compile_parser.add_argument('--debug', action='store_true', - help='Use human-readable names (requires HASH@lang and all translations)') + compile_parser = subparsers.add_parser( + "compile", help="Compile function to standalone executable" + ) + compile_parser.add_argument( + "hash", help="Function hash (HASH or HASH@lang). @lang required with --debug" + ) + compile_parser.add_argument( + "--python", + action="store_true", + help="Produce a single Python file instead of native executable (default output: main.py)", + ) + compile_parser.add_argument( + "--debug", + action="store_true", + help="Use human-readable names (requires HASH@lang and all translations)", + ) # Commit command - commit_parser = subparsers.add_parser('commit', help='Commit function and dependencies to git repository') - commit_parser.add_argument('hash', help='Function hash to commit') - commit_parser.add_argument('--comment', '-c', help='Commit message (opens editor if not provided)') + commit_parser = subparsers.add_parser( + "commit", help="Commit function and dependencies to git repository" + ) + commit_parser.add_argument("hash", help="Function hash to commit") + commit_parser.add_argument( + "--comment", "-c", help="Commit message (opens editor if not provided)" + ) # Aston command - aston_parser = subparsers.add_parser('aston', help='Convert Python file to ASTON representation') - aston_parser.add_argument('file', help='Path to Python source file') - aston_parser.add_argument('--test', action='store_true', help='Run round-trip test instead of outputting tuples') + aston_parser = subparsers.add_parser( + "aston", help="Convert Python file to ASTON representation" + ) + aston_parser.add_argument("file", help="Path to Python source file") + aston_parser.add_argument( + "--test", + action="store_true", + help="Run round-trip test instead of outputting tuples", + ) args = parser.parse_args() - if args.command == 'init': + if args.command == "init": command_init() - elif args.command == 'whoami': + elif args.command == "whoami": command_whoami(args.subcommand, args.value) - elif args.command == 'add': + elif args.command == "add": code_add(args.file, args.comment) - elif args.command == 'get': + elif args.command == "get": code_get(args.hash) - elif args.command == 'show': + elif args.command == "show": code_show(args.hash) - elif args.command == 'translate': + elif args.command == "translate": command_translate(args.hash, args.target_lang) - elif args.command == 'run': + elif args.command == "run": command_run(args.hash, debug=args.debug, func_args=args.func_args) - elif args.command == 'review': + elif args.command == "review": command_review(args.hash) - elif args.command == 'log': + elif args.command == "log": command_log() - elif args.command == 'search': + elif args.command == "search": command_search(args.query) - elif args.command == 'remote': - if args.remote_command == 'add': + elif args.command == "remote": + if args.remote_command == "add": command_remote_add(args.name, args.url, read_only=args.read_only) - elif args.remote_command == 'remove': + elif args.remote_command == "remove": command_remote_remove(args.name) - elif args.remote_command == 'list': + elif args.remote_command == "list": command_remote_list() - elif args.remote_command == 'pull': + elif args.remote_command == "pull": command_remote_pull(args.name) - elif args.remote_command == 'push': + elif args.remote_command == "push": command_remote_push(args.name) - elif args.remote_command == 'sync': + elif args.remote_command == "sync": command_remote_sync() else: remote_parser.print_help() - elif args.command == 'validate': + elif args.command == "validate": if args.all or not args.hash: # Validate entire directory - is_valid, errors, stats = schema_validate_directory() + is_valid, errors, stats = storage_schema_validate_directory() print("BB Directory Validation") print("=" * 60) print(f"Functions total: {stats['functions_total']}") @@ -5296,7 +5848,7 @@ def main(): sys.exit(1) else: # Validate single function - is_valid, errors = schema_validate_v1(args.hash) + is_valid, errors = storage_schema_validate_v1(args.hash) if is_valid: print(f"✓ Function {args.hash} is valid") else: @@ -5304,21 +5856,21 @@ def main(): for error in errors: print(f" - {error}", file=sys.stderr) sys.exit(1) - elif args.command == 'caller': + elif args.command == "caller": command_caller(args.hash) - elif args.command == 'check': - command_check(args.hash) - elif args.command == 'refactor': + elif args.command == "check": + command_check_run(args.hash) + elif args.command == "refactor": command_refactor(args.what, args.from_hash, args.to_hash) - elif args.command == 'compile': + elif args.command == "compile": command_compile(args.hash, python_mode=args.python, debug_mode=args.debug) - elif args.command == 'commit': + elif args.command == "commit": command_commit(args.hash, comment=args.comment) - elif args.command == 'aston': + elif args.command == "aston": command_aston(args.file, test_mode=args.test) else: parser.print_help() -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/bin/github-review-threads.py b/bin/github-review-threads.py new file mode 100755 index 0000000..4724e02 --- /dev/null +++ b/bin/github-review-threads.py @@ -0,0 +1,172 @@ +#!/usr/bin/env python3 +""" +GitHub Review Threads Helper (JSON output) + +Fetches unresolved review threads for the current branch's PR and displays them +in JSON format using GitHub's GraphQL API. + +This script checks for unresolved review threads (not individual comments) because +GitHub's review system works with threads that can contain multiple comments. +A thread is considered resolved when the code changes address the concerns. + +Usage: ./bin/github-review-threads.py +""" + +import json +import subprocess +import sys + + +def run_command(cmd, cwd=None): + """Run a shell command and return stdout, or exit on error.""" + try: + result = subprocess.run( + cmd, shell=True, cwd=cwd, capture_output=True, text=True, check=True + ) + return result.stdout.strip() + except subprocess.CalledProcessError as e: + print(f"Error running command: {cmd}", file=sys.stderr) + print(f"Error: {e.stderr.strip()}", file=sys.stderr) + sys.exit(1) + + +def get_current_branch(): + """Get the current git branch name.""" + return run_command("git branch --show-current") + + +def get_repo_info(): + """Get repository owner and name.""" + remote_url = run_command("git remote get-url origin") + # Extract owner/repo from git@github.com:owner/repo.git or https://github.com/owner/repo + if remote_url.startswith("git@github.com:"): + owner_repo = remote_url.replace("git@github.com:", "").replace(".git", "") + elif remote_url.startswith("https://github.com/"): + owner_repo = remote_url.replace("https://github.com/", "").replace(".git", "") + else: + print(f"Unsupported remote URL format: {remote_url}", file=sys.stderr) + sys.exit(1) + + owner, repo = owner_repo.split("/") + return owner, repo + + +def get_pr_number(branch): + """Get the PR number for the current branch.""" + try: + # Try to get PR info for current branch + pr_info = run_command("gh pr view --json number") + pr_data = json.loads(pr_info) + return pr_data.get("number") + except Exception: + # If no PR is found, try to find it by branch name + try: + pr_list = run_command("gh pr list --head {branch} --json number --limit 1") + pr_list_data = json.loads(pr_list) + if pr_list_data: + return pr_list_data[0].get("number") + except Exception: + pass + + print(f"No PR found for branch: {branch}", file=sys.stderr) + sys.exit(1) + + +def get_review_threads(owner, repo, pr_number): + """Get review threads for a PR using GitHub GraphQL API.""" + query = """ + query FetchReviewComments($owner: String!, $repo: String!, $prNumber: Int!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $prNumber) { + reviewThreads(first: 100) { + edges { + node { + isResolved + comments(first: 100) { + nodes { + id + body + diffHunk + path + position + originalPosition + url + } + } + } + } + } + } + } + } + """ + + # Use gh api graphql with variables + result = run_command( + f"gh api graphql --field owner={owner} --field repo={repo} --field prNumber={pr_number} -F query='{query}'" + ) + data = json.loads(result) + + # Extract review threads and convert to comment-like format + threads = data["data"]["repository"]["pullRequest"]["reviewThreads"]["edges"] + + comments = [] + for thread in threads: + thread_node = thread["node"] + for comment in thread_node["comments"]["nodes"]: + # Add resolution status to comment + comment["isResolved"] = thread_node["isResolved"] + comments.append(comment) + + return comments + + +def main(): + """Main function to fetch and display review comments.""" + # Get current branch + branch = get_current_branch() + print(f"Current branch: {branch}", file=sys.stderr) + + # Get repository info + owner, repo = get_repo_info() + print(f"Repository: {owner}/{repo}", file=sys.stderr) + + # Get PR number + pr_number = get_pr_number(branch) + print(f"PR number: {pr_number}", file=sys.stderr) + + # Get review threads + threads = get_review_threads(owner, repo, pr_number) + + # Filter unresolved threads and extract required fields + unresolved_threads = [] + for thread in threads: + # Use the isResolved field from GraphQL API + if not thread.get("isResolved", False): + unresolved_threads.append( + { + "id": thread.get("id"), + "url": thread.get("url"), + "diff_hunk": thread.get("diffHunk", ""), + "path": thread.get("path", ""), + "position": thread.get("position"), + "original_position": thread.get("originalPosition"), + "body": thread.get("body", ""), + } + ) + + # Output as JSON + print(json.dumps(unresolved_threads, indent=2)) + + print( + f"\nFound {len(unresolved_threads)} unresolved review threads.", + file=sys.stderr, + ) + + # Exit with code 2 if there are unresolved threads + if unresolved_threads: + sys.exit(2) + + +if __name__ == "__main__": + main() diff --git a/bonafide.py b/bonafide.py new file mode 100644 index 0000000..67cbb84 --- /dev/null +++ b/bonafide.py @@ -0,0 +1,1049 @@ +#!/usr/bin/env python3 +""" +Bonafide Storage Primitives + +Bonafide is a storage layer for bb.py that implements an ordered key-value store using SQLite. It provides thread-safe access to the database through a connection pool, ensuring efficient concurrency while respecting SQLite's single-writer constraint. +""" + +import sqlite3 +import threading +import os +import queue +import itertools +import struct +import uuid +from collections import namedtuple +from typing import Any, Callable, List, Optional, TypeVar, Dict, Tuple + +# Type variable for function return types +T = TypeVar("T") + +# Store builtin bytes to avoid naming conflicts +_builtin_bytes = bytes + +# Default constants +DEFAULT_DB_PATH = "bonafide.db" +POOL_SIZE_DEFAULT = 4 + + +def pool_size_default() -> int: + """Calculate the default pool size based on available CPU cores. + + Returns: + int: Default pool size (2 * CPU count, or 4 if CPU count is not available) + """ + return os.cpu_count() * 2 if os.cpu_count() else POOL_SIZE_DEFAULT + + +# Order-preserving encoding constants +_BONAFIDE_BYTE_NULL = 0x00 +_BONAFIDE_BYTE_BYTES = 0x01 +_BONAFIDE_BYTE_STRING = 0x02 +_BONAFIDE_BYTE_NESTED = 0x03 +_BONAFIDE_BYTE_INT_ZERO = 0x04 +_BONAFIDE_BYTE_INT_POS = 0x05 +_BONAFIDE_BYTE_INT_NEG = 0x06 +_BONAFIDE_BYTE_FLOAT = 0x07 +_BONAFIDE_BYTE_TRUE = 0x08 +_BONAFIDE_BYTE_FALSE = 0x09 +_BONAFIDE_BYTE_UUID = 0x0A +_BONAFIDE_BYTE_BBH = 0x0B + +# BBH (Beyond Babel Hash) type for content-addressed references +BBH = namedtuple("BBH", ["value"]) + +# Variable type for pattern matching +Variable = namedtuple("Variable", ["name"]) + +# NStore type +NStore = namedtuple("NStore", ["prefix", "n", "indices", "name"]) + + +def _bytes_write_one(value: Any, nested: bool = False) -> bytes: + """Encode a single value to bytes with order preservation.""" + if value is None: + return _builtin_bytes( + [_BONAFIDE_BYTE_NULL, 0xFF] if nested else [_BONAFIDE_BYTE_NULL] + ) + elif isinstance(value, bool): + return _builtin_bytes([_BONAFIDE_BYTE_TRUE if value else _BONAFIDE_BYTE_FALSE]) + elif isinstance(value, _builtin_bytes): + return ( + _builtin_bytes([_BONAFIDE_BYTE_BYTES]) + + value.replace(b"\x00", b"\x00\xff") + + b"\x00" + ) + elif isinstance(value, str): + return ( + _builtin_bytes([_BONAFIDE_BYTE_STRING]) + + value.encode("utf-8").replace(b"\x00", b"\x00\xff") + + b"\x00" + ) + elif value == 0: + return _builtin_bytes([_BONAFIDE_BYTE_INT_ZERO]) + elif isinstance(value, int): + if value > 0: + return _builtin_bytes([_BONAFIDE_BYTE_INT_POS]) + struct.pack(">Q", value) + else: + return _builtin_bytes([_BONAFIDE_BYTE_INT_NEG]) + struct.pack( + ">Q", (1 << 64) - 1 + value + ) + elif isinstance(value, float): + bits = struct.pack(">d", value) + # Flip sign bit, or flip all bits if negative + if bits[0] & 0x80: + bits = _builtin_bytes(b ^ 0xFF for b in bits) + else: + bits = _builtin_bytes([bits[0] ^ 0x80]) + bits[1:] + return _builtin_bytes([_BONAFIDE_BYTE_FLOAT]) + bits + elif isinstance(value, uuid.UUID): + # UUIDs are stored as 16 bytes (128 bits) + # UUID.bytes maintains lexicographic ordering for ULIDs + return _builtin_bytes([_BONAFIDE_BYTE_UUID]) + value.bytes + elif isinstance(value, BBH): + # BBH stores a SHA256 hash (32 bytes) + # value can be bytes or hex string + if isinstance(value.value, _builtin_bytes): + if len(value.value) != 32: + raise ValueError( + f"BBH bytes must be exactly 32 bytes, got {len(value.value)}" + ) + return _builtin_bytes([_BONAFIDE_BYTE_BBH]) + value.value + elif isinstance(value.value, str): + if len(value.value) != 64: + raise ValueError( + f"BBH hex string must be exactly 64 characters, got {len(value.value)}" + ) + return _builtin_bytes([_BONAFIDE_BYTE_BBH]) + _builtin_bytes.fromhex( + value.value + ) + else: + raise ValueError( + f"BBH value must be bytes or hex string, got {type(value.value)}" + ) + elif isinstance(value, (tuple, list)): + return ( + _builtin_bytes([_BONAFIDE_BYTE_NESTED]) + + b"".join(_bytes_write_one(v, True) for v in value) + + _builtin_bytes([0x00]) + ) + else: + raise ValueError(f"Unsupported type for encoding: {type(value)}") + + +def _bytes_read_one(data: bytes, pos: int = 0) -> Tuple[Any, int]: + """Decode a single value from bytes.""" + code = data[pos] + if code == _BONAFIDE_BYTE_NULL: + return (None, pos + 1) + elif code == _BONAFIDE_BYTE_BYTES: + end = pos + 1 + while end < len(data): + if data[end] == 0x00 and (end + 1 >= len(data) or data[end + 1] != 0xFF): + break + end += 1 if data[end] != 0x00 else 2 + return (data[pos + 1 : end].replace(b"\x00\xff", b"\x00"), end + 1) + elif code == _BONAFIDE_BYTE_STRING: + end = pos + 1 + while end < len(data): + if data[end] == 0x00 and (end + 1 >= len(data) or data[end + 1] != 0xFF): + break + end += 1 if data[end] != 0x00 else 2 + return ( + data[pos + 1 : end].replace(b"\x00\xff", b"\x00").decode("utf-8"), + end + 1, + ) + elif code == _BONAFIDE_BYTE_INT_ZERO: + return (0, pos + 1) + elif code == _BONAFIDE_BYTE_INT_POS: + return (struct.unpack(">Q", data[pos + 1 : pos + 9])[0], pos + 9) + elif code == _BONAFIDE_BYTE_INT_NEG: + val = struct.unpack(">Q", data[pos + 1 : pos + 9])[0] + return (val - ((1 << 64) - 1), pos + 9) + elif code == _BONAFIDE_BYTE_FLOAT: + bits = bytearray(data[pos + 1 : pos + 9]) + if bits[0] & 0x80: + bits[0] ^= 0x80 + else: + bits = _builtin_bytes(b ^ 0xFF for b in bits) + return (struct.unpack(">d", _builtin_bytes(bits))[0], pos + 9) + elif code == _BONAFIDE_BYTE_TRUE: + return (True, pos + 1) + elif code == _BONAFIDE_BYTE_FALSE: + return (False, pos + 1) + elif code == _BONAFIDE_BYTE_UUID: + # UUIDs are stored as 16 bytes (128 bits) + return (uuid.UUID(bytes=data[pos + 1 : pos + 17]), pos + 17) + elif code == _BONAFIDE_BYTE_BBH: + # BBH stores a SHA256 hash (32 bytes) + # Return as hex string for easier use + hash_bytes = data[pos + 1 : pos + 33] + return (BBH(hash_bytes.hex()), pos + 33) + elif code == _BONAFIDE_BYTE_NESTED: + result = [] + pos += 1 + while pos < len(data): + if data[pos] == 0x00: + if pos + 1 < len(data) and data[pos + 1] == 0xFF: + result.append(None) + pos += 2 + else: + break + else: + val, pos = _bytes_read_one(data, pos) + result.append(val) + return (tuple(result), pos + 1) + else: + raise ValueError(f"Unknown encode type code: {code}") + + +def bytes_write(items: Tuple) -> bytes: + """Encode a tuple to bytes with order preservation.""" + return b"".join(_bytes_write_one(item) for item in items) + + +def bytes_read(data: bytes) -> Tuple: + """Decode bytes back to tuple.""" + result = [] + pos = 0 + while pos < len(data): + val, pos = _bytes_read_one(data, pos) + result.append(val) + return tuple(result) + + +def bytes_next(data: _builtin_bytes) -> Optional[_builtin_bytes]: + """Compute next byte sequence for exclusive upper bound in range queries.""" + if not data: + return _builtin_bytes([0x00]) + + # Find rightmost byte that's not 0xFF + for i in range(len(data) - 1, -1, -1): + if data[i] != 0xFF: + # Increment this byte and truncate everything after + return data[:i] + _builtin_bytes([data[i] + 1]) + + # All bytes are 0xFF, no successor exists + return None + + +# Bonafide namedtuple to hold configuration and state +Bonafide = namedtuple( + "Bonafide", + [ + "db_path", + "pool_size", + "worker_queue", + "worker_threads", + "worker_lock", + "subspace", + ], +) + +# Connection and transaction types +BonafideCnx = namedtuple("BonafideCnx", ["bonafide", "sqlite"]) +BonafideTxn = namedtuple("BonafideTxn", ["cnx"]) + + +def transactional(func): + """Decorator that provides transactional behavior for database operations. + + This decorator wraps a function to provide automatic transaction management. + It handles commit/rollback behavior and connection cleanup based on whether + the function executes successfully or raises an exception. + + The decorator supports three modes of operation: + 1. When called with a BonafideCnx: Creates a transaction, executes the function, + commits on success, rolls back on failure, and closes the connection. + 2. When called with a BonafideTxn: Passes through to the function directly + (for nested transactional calls). + 3. When called with unsupported types: Raises NotImplementedError. + + Args: + func: The function to wrap with transactional behavior. + + Returns: + A wrapped function that provides transactional behavior. + + Behavior: + - On success: Commits the transaction and returns the function result + - On exception: Rolls back the transaction and re-raises the exception + - Always: Closes the database connection in a finally block + """ + + def wrapper(something, *args, **kwargs): + if isinstance(something, BonafideCnx): + cnx = something + txn = BonafideTxn(cnx) + try: + out = func(txn, *args, **kwargs) + # Commit if no exception occurred + cnx.sqlite.commit() + except Exception: + # Rollback if an exception occurred + cnx.sqlite.rollback() + raise + else: + return out + elif isinstance(something, BonafideTxn): + return func(something, *args, **kwargs) + else: + msg = "transactional does not support unexpected: {}".format( + type(something) + ) + raise NotImplementedError(msg) + + return wrapper + + +@transactional +def nstore_add(txn, name: str, items: Tuple) -> None: + """Add a tuple to the nstore.""" + nstore = nstore_get(txn.cnx.bonafide, name) + assert len(items) == nstore.n, f"Expected {nstore.n} items, got {len(items)}" + + # Add to all permuted indices + for subspace, index in enumerate(nstore.indices): + permuted = _nstore_permute(items, index) + key = bytes_write(nstore.prefix + (subspace,) + permuted) + set(txn, key, b"\x01") + + +def _nstore_indices_verify_coverage(indices: List[List[int]], n: int) -> bool: + """Verify that indices cover all possible query patterns.""" + tab = list(range(n)) + for r in range(1, n + 1): + for combination in itertools.combinations(tab, r): + covered = False + for index in indices: + for perm in itertools.permutations(combination): + if len(perm) <= len(index): + if all(a == b for a, b in zip(perm, index)): + covered = True + break + if covered: + break + if not covered: + return False + return True + + +def nstore_indices(n: int) -> List[List[int]]: + """Compute minimal set of permutation indices for n-tuple store. + + This function implements an algorithm based on Dilworth's theorem to determine + the minimal number of permutation indices needed to enable efficient single-hop + queries for any query pattern in an n-tuple store. + + Mathematical Foundation: + - Based on covering the boolean lattice by the minimal number of maximal chains + - By Dilworth's theorem, this minimal number equals the cardinality of the maximal + antichain in the boolean lattice, which is the central binomial coefficient C(n, n//2) + - For n=3: C(3,1) = 3 indices needed + - For n=4: C(4,2) = 6 indices needed + - For n=5: C(5,2) = 10 indices needed + + Args: + n: Number of elements in tuples + + Returns: + List of exactly C(n, n//2) index permutations in lexicographic order + + Examples: + >>> nstore_indices(3) # C(3, 1) = 3 indices + [[0, 1, 2], [1, 2, 0], [2, 0, 1]] + >>> nstore_indices(4) # C(4, 2) = 6 indices + [[0, 1, 2, 3], [1, 2, 3, 0], [2, 0, 3, 1], [3, 0, 1, 2], [3, 1, 2, 0], [3, 2, 0, 1]] + """ + tab = list(range(n)) + cx = list(itertools.combinations(tab, n // 2)) + out = [] + + for combo in cx: + L = [(i, i in combo) for i in tab] + a, b = [], [] + + while True: + # Find swap pair (inline findij logic) + found = False + for idx in range(len(L) - 1): + if L[idx][1] is False and L[idx + 1][1] is True: + remaining = L[:idx] + L[idx + 2 :] + i, j = L[idx][0], L[idx + 1][0] + L = remaining + a.append(j) + b.append(i) + found = True + break + + if not found: + out.append(list(reversed(a)) + [x[0] for x in L] + list(reversed(b))) + break + + out.sort() + + # Verify coverage + assert _nstore_indices_verify_coverage(out, n), ( + "Generated indices do not cover all combinations" + ) + + return out + + +# NStore functions + + +def _nstore_create(prefix: Tuple, n: int, name: str) -> NStore: + """Create an NStore instance. + + This function maintains backward compatibility with the old API while supporting + the new subspace-based approach. + + Args: + prefix: Tuple prefix for this nstore + n: Number of elements in tuples + name: Name for this nstore instance + + Returns: + NStore instance with the specified configuration + """ + indices = nstore_indices(n) + return NStore(prefix=prefix, n=n, indices=indices, name=name) + + +def nstore_new(name: str, prefix: Tuple, n: int) -> NStore: + """Create a new NStore instance with subspace-based configuration. + + This function creates an NStore that can be looked up by name in the Bonafide + configuration, allowing for more flexible nstore management. + + Args: + name: Unique name for this nstore instance + prefix: Tuple prefix for this nstore + n: Number of elements in tuples + + Returns: + NStore instance with the specified configuration + """ + indices = nstore_indices(n) + return NStore(prefix=prefix, n=n, indices=indices, name=name) + + +def nstore(bonafide: Bonafide, name: str, n: int) -> NStore: + """Create and register a new NStore with inferred prefix (name,). + + This is a convenience function that creates an NStore with a prefix of (name,) + and automatically registers it in the Bonafide subspace. + + Args: + bonafide: Bonafide instance + name: Unique name for this nstore instance (used as prefix) + n: Number of elements in tuples + + Returns: + NStore instance with the specified configuration + """ + prefix = (name,) + nstore_instance = nstore_new(name, prefix, n) + _nstore_register(bonafide, name, nstore_instance) + return nstore_instance + + +def _nstore_permute(items: Tuple, index: List[int]) -> Tuple: + """Permute tuple elements according to index.""" + return tuple(items[i] for i in index) + + +def _nstore_unpermute(items: Tuple, index: List[int]) -> Tuple: + """Reverse a permutation to get original tuple.""" + result = [None] * len(items) + for i, idx in enumerate(index): + result[idx] = items[i] + return tuple(result) + + +def _nstore_register(bonafide: Bonafide, name: str, nstore: NStore) -> None: + """Register an NStore instance in the Bonafide subspace. + + Args: + bonafide: Bonafide instance + name: Name to register the nstore under + nstore: NStore instance to register + """ + bonafide.subspace[name] = nstore + + +def nstore_get(bonafide: Bonafide, name: str) -> NStore: + """Get an NStore instance by name from the Bonafide subspace. + + Args: + bonafide: Bonafide instance + name: Name of the nstore to retrieve + + Returns: + NStore instance + + Raises: + KeyError: If no nstore with the given name exists + """ + return bonafide.subspace[name] + + +@transactional +def nstore_delete(txn, name: str, items: Tuple) -> None: + """Delete a tuple from the nstore.""" + nstore = nstore_get(txn.cnx.bonafide, name) + assert len(items) == nstore.n, f"Expected {nstore.n} items, got {len(items)}" + + # Delete from all permuted indices + for subspace, index in enumerate(nstore.indices): + permuted = _nstore_permute(items, index) + key = bytes_write(nstore.prefix + (subspace,) + permuted) + delete(txn, key) + + +@transactional +def nstore_ask(txn, name: str, items: Tuple) -> bool: + """Check if a tuple exists in the nstore.""" + nstore = nstore_get(txn.cnx.bonafide, name) + assert len(items) == nstore.n, f"Expected {nstore.n} items, got {len(items)}" + + # Check base index (subspace 0) with the original tuple + # The key format is: prefix + (subspace,) + permuted_tuple + # For subspace 0, the permutation is the identity permutation [0, 1, 2, ...] + permuted = _nstore_permute(items, nstore.indices[0]) + key = bytes_write(nstore.prefix + (0,) + permuted) + return query(txn, key) is not None + + +def _nstore_pattern_to_combination(pattern: Tuple) -> List[int]: + """Extract positions of non-variable elements from pattern.""" + return [i for i, item in enumerate(pattern) if not isinstance(item, Variable)] + + +def _nstore_pattern_to_index( + pattern: Tuple, indices: List[List[int]] +) -> Tuple[List[int], int]: + """Find the index and subspace that matches the pattern.""" + combination = _nstore_pattern_to_combination(pattern) + + for subspace, index in enumerate(indices): + # Check if any permutation of combination is a prefix of index + for perm in itertools.permutations(combination): + if len(perm) <= len(index) and all(a == b for a, b in zip(perm, index)): + return (index, subspace) + + raise ValueError(f"No matching index found for pattern {pattern}") + + +def _nstore_pattern_to_prefix(pattern: Tuple, index: List[int]) -> Tuple: + """Extract the concrete prefix from pattern for range query.""" + result = [] + for idx in index: + value = pattern[idx] + if isinstance(value, Variable): + break + result.append(value) + return tuple(result) + + +def _nstore_bind_pattern(pattern: Tuple, bindings: Dict[str, Any]) -> Tuple: + """Replace variables in pattern with their bound values.""" + return tuple( + bindings[item.name] + if isinstance(item, Variable) and item.name in bindings + else item + for item in pattern + ) + + +def _nstore_bind_tuple( + pattern: Tuple, tuple_items: Tuple, seed: Dict[str, Any] +) -> Dict[str, Any]: + """Bind variables in pattern to values from matching tuple.""" + result = dict(seed) + for pattern_item, tuple_item in zip(pattern, tuple_items): + if isinstance(pattern_item, Variable): + result[pattern_item.name] = tuple_item + return result + + +@transactional +def nstore_query( + txn, name: str, pattern: Tuple, *patterns: Tuple +) -> List[Dict[str, Any]]: + """Query tuples matching pattern and optional additional where patterns.""" + nstore = nstore_get(txn.cnx.bonafide, name) + patterns = [pattern] + list(patterns) + + # Start with initial empty binding + bindings = [{}] + + # Process each pattern + for pat in patterns: + assert len(pat) == nstore.n, ( + f"Pattern length {len(pat)} doesn't match nstore size {nstore.n}" + ) + + new_bindings = [] + + for binding in bindings: + # Bind variables in pattern with current bindings + bound_pattern = _nstore_bind_pattern(pat, binding) + + # Find matching index + index, subspace = _nstore_pattern_to_index(bound_pattern, nstore.indices) + + # Build prefix for range query + prefix_items = _nstore_pattern_to_prefix(bound_pattern, index) + key_start = bytes_write(nstore.prefix + (subspace,) + prefix_items) + key_end = bytes_next(key_start) + if key_end is None: + # All bytes are 0xFF, use next longer sequence + key_end = key_start + b"\x00" + + # Range scan + results = query(txn, key_start, key_end) + results = [(row[0], row[1]) for row in results] + + for key, _ in results: + # Decode key + unpacked = bytes_read(key) + + # Extract tuple (skip prefix + subspace) + permuted_tuple = unpacked[len(nstore.prefix) + 1 :] + + # Reverse permutation + original_tuple = _nstore_unpermute(permuted_tuple, index) + + # Bind variables from pattern + new_binding = _nstore_bind_tuple(pat, original_tuple, binding) + new_bindings.append(new_binding) + + bindings = new_bindings + + return bindings + + +@transactional +def nstore_count(txn, name: str, pattern: Tuple) -> int: + """Count tuples matching pattern.""" + nstore = nstore_get(txn.cnx.bonafide, name) + assert len(pattern) == nstore.n, ( + f"Pattern length {len(pattern)} doesn't match nstore size {nstore.n}" + ) + + # Find matching index + index, subspace = _nstore_pattern_to_index(pattern, nstore.indices) + + # Build prefix for range query + prefix_items = _nstore_pattern_to_prefix(pattern, index) + key_start = bytes_write(nstore.prefix + (subspace,) + prefix_items) + key_end = bytes_next(key_start) + if key_end is None: + # All bytes are 0xFF, use next longer sequence + key_end = key_start + b"\x00" + + return count(txn, key_start, key_end) + + +@transactional +def nstore_bytes(txn, name: str, pattern: Tuple) -> int: + """Sum the length of bytes in keys and values for tuples matching pattern.""" + nstore = nstore_get(txn.cnx.bonafide, name) + assert len(pattern) == nstore.n, ( + f"Pattern length {len(pattern)} doesn't match nstore size {nstore.n}" + ) + + # Find matching index + index, subspace = _nstore_pattern_to_index(pattern, nstore.indices) + + # Build prefix for range query + prefix_items = _nstore_pattern_to_prefix(pattern, index) + key_start = bytes_write(nstore.prefix + (subspace,) + prefix_items) + key_end = bytes_next(key_start) + if key_end is None: + # All bytes are 0xFF, use next longer sequence + key_end = key_start + b"\x00" + + return bytes(txn, key_start, key_end) + + +def _bonafide_worker(bonafide: Bonafide) -> None: + """ + Worker thread that processes database operations. + """ + # TODO: implement clean exit shutdown + + # Create a new connection for this operation + cnx = sqlite3.connect(bonafide.db_path, check_same_thread=False) + cnx.execute("PRAGMA journal_mode=WAL") + cnx = BonafideCnx(bonafide, cnx) + + while True: + try: + # Get task from queue + task_id, func, args, kwargs, result_queue = bonafide.worker_queue.get() + try: + result = func(cnx, *args, **kwargs) + result_queue.put((task_id, result)) + except Exception as e: + result_queue.put((task_id, e)) + finally: + bonafide.worker_queue.task_done() + except Exception: + # If any error occurs, continue processing + bonafide.worker_queue.task_done() + cnx.sqlite.close() + cnx = sqlite3.connect(bonafide.db_path, check_same_thread=False) + cnx.execute("PRAGMA journal_mode=WAL") + cnx = BonafideCnx(bonafide, cnx) + + +def _start_worker_threads(bonafide: Bonafide, num_threads: int = None) -> None: + """ + Start worker threads for processing database operations. + """ + num_threads = num_threads if num_threads is not None else bonafide.pool_size + + with bonafide.worker_lock: + # Don't start more threads than needed + if len(bonafide.worker_threads) >= num_threads: + return + + # Start new worker threads + for _ in range(num_threads - len(bonafide.worker_threads)): + thread = threading.Thread( + target=_bonafide_worker, args=(bonafide,), daemon=True + ) + thread.start() + bonafide.worker_threads.append(thread) + + +def apply( + bonafide: Bonafide, + func: Callable[..., T], + *args: Any, + readonly: bool = False, + **kwargs: Any, +) -> T: + """ + Execute a function within the bonafide thread. + + Args: + bonafide: The Bonafide configuration and state. + func: The function to execute. It should accept a connection as the first parameter. + *args: Arguments to pass to the function (after the connection). + readonly: If False, acquire the worker lock for write operations. + **kwargs: Keyword arguments to pass to the function. + + Returns: + The result of the function execution. + """ + # Start worker threads if not already running + _start_worker_threads(bonafide) + + # Acquire lock for write operations + if not readonly: + bonafide.worker_lock.acquire() + + try: + # Create a result queue for this specific call + result_queue = queue.Queue() + + # Generate a unique task ID + task_id = id(result_queue) + + # Put the task in the worker queue + bonafide.worker_queue.put((task_id, func, args, kwargs, result_queue)) + + # Wait for the result + task_id_result, result = result_queue.get() + + # Check if we got the right result + if task_id_result != task_id: + raise RuntimeError("Received result for wrong task") + + # Handle exceptions + if isinstance(result, Exception): + raise result + + return result + finally: + # Release lock for write operations + if not readonly: + bonafide.worker_lock.release() + + +@transactional +def query( + txn, + key: _builtin_bytes, + other: Optional[_builtin_bytes] = None, + offset: int = 0, + limit: Optional[int] = None, +) -> Optional[_builtin_bytes]: + """Query key-value pairs from the database. + + Args: + conn: SQLite connection + key: Key to query + other: Optional end key for range queries + offset: Number of results to skip + limit: Maximum results to return + + Returns: + If other is None: value associated with key, or None if not found + If other is not None: list of (key, value) tuples in range [key, other) + """ + if other is None: + # Single key lookup + cursor = txn.cnx.sqlite.execute( + "SELECT value FROM kv_store WHERE key = ?", (key,) + ) + result = cursor.fetchone() + return result[0] if result else None + else: + # Range query inspired by storage_db_query + if key <= other: + # Forward scan: key <= k < other + query = "SELECT key, value FROM kv_store WHERE key >= ? AND key < ? ORDER BY key ASC" + params = [key, other] + else: + # Reverse scan: other <= k < key, descending order + query = "SELECT key, value FROM kv_store WHERE key >= ? AND key < ? ORDER BY key DESC" + params = [other, key] + + if limit is not None: + query += " LIMIT ?" + params.append(limit) + if offset > 0: + query += " OFFSET ?" + params.append(offset) + elif offset > 0: + query += " LIMIT -1 OFFSET ?" + params.append(offset) + + cursor = txn.cnx.sqlite.execute(query, params) + return [(row[0], row[1]) for row in cursor] + + +@transactional +def set(txn, key: _builtin_bytes, value: _builtin_bytes) -> None: + """Set a key-value pair in the database. + + Args: + conn: SQLite connection + key: Key to set + value: Value to associate with the key + """ + cursor = txn.cnx.sqlite.cursor() + cursor.execute( + "INSERT OR REPLACE INTO kv_store (key, value) VALUES (?, ?)", (key, value) + ) + + +@transactional +def delete( + txn, + key: _builtin_bytes, + other: Optional[_builtin_bytes] = None, + offset: int = 0, + limit: Optional[int] = None, +) -> int: + """Delete key-value pairs from the database. + + Args: + conn: SQLite connection + key: Start key (inclusive if forward, exclusive if reverse) + other: Optional end key for range delete + offset: Number of results to skip + limit: Maximum results to delete + + Returns: + Number of rows deleted + + Behavior: + - If other is None: delete single key + - If other is not None: delete range [key, other) + - If key <= other: forward scan [key, other) in ascending order + - If key > other: reverse scan [other, key) in descending order + """ + if other is None: + # Single key delete + cursor = txn.cnx.sqlite.cursor() + cursor.execute("DELETE FROM kv_store WHERE key = ?", (key,)) + return cursor.rowcount + else: + # Range delete + if key <= other: + # Forward scan: key <= k < other + base_query = "DELETE FROM kv_store WHERE key >= ? AND key < ?" + params: List[Any] = [key, other] + else: + # Reverse scan: other <= k < key + base_query = "DELETE FROM kv_store WHERE key >= ? AND key < ?" + params = [other, key] + + if limit is not None: + base_query += " LIMIT ?" + params.append(limit) + if offset > 0: + base_query += " OFFSET ?" + params.append(offset) + elif offset > 0: + base_query += " LIMIT -1 OFFSET ?" + params.append(offset) + + cursor = txn.cnx.sqlite.cursor() + cursor.execute(base_query, params) + return cursor.rowcount + + +@transactional +def bytes( + txn, + key: _builtin_bytes, + other: _builtin_bytes, + offset: int = 0, + limit: Optional[int] = None, +) -> int: + """Calculate total bytes (key lengths + value lengths) in a key range. + + Args: + conn: SQLite connection + key: Start key (inclusive if forward, exclusive if reverse) + other: End key (exclusive if forward, inclusive if reverse) + offset: Number of results to skip + limit: Maximum results to consider + + Returns: + Total bytes (key lengths + value lengths) + + Behavior: + - If key <= other: forward scan [key, other) in ascending order + - If key > other: reverse scan [other, key) in descending order + """ + if key <= other: + # Forward scan: key <= k < other + base_query = "SELECT key, value FROM kv_store WHERE key >= ? AND key < ? ORDER BY key ASC" + params: List[Any] = [key, other] + else: + # Reverse scan: other <= k < key, descending order + base_query = "SELECT key, value FROM kv_store WHERE key >= ? AND key < ? ORDER BY key DESC" + params = [other, key] + + if limit is not None: + base_query += " LIMIT ?" + params.append(limit) + if offset > 0: + base_query += " OFFSET ?" + params.append(offset) + elif offset > 0: + base_query += " LIMIT -1 OFFSET ?" + params.append(offset) + + # Wrap in SUM query + query = f"SELECT COALESCE(SUM(LENGTH(key) + LENGTH(value)), 0) FROM ({base_query})" + cursor = txn.cnx.sqlite.execute(query, params) + return cursor.fetchone()[0] + + +@transactional +def count( + txn, + key: _builtin_bytes, + other: _builtin_bytes, + offset: int = 0, + limit: Optional[int] = None, +) -> int: + """Count the number of key-value pairs in a key range. + + Args: + conn: SQLite connection + key: Start key (inclusive if forward, exclusive if reverse) + other: End key (exclusive if forward, inclusive if reverse) + offset: Number of results to skip + limit: Maximum results to count + + Returns: + Number of key-value pairs in the range + + Behavior: + - If key <= other: forward scan [key, other) in ascending order + - If key > other: reverse scan [other, key) in descending order + """ + if key <= other: + # Forward scan: key <= k < other + base_query = ( + "SELECT key FROM kv_store WHERE key >= ? AND key < ? ORDER BY key ASC" + ) + params: List[Any] = [key, other] + else: + # Reverse scan: other <= k < key + base_query = ( + "SELECT key FROM kv_store WHERE key >= ? AND key < ? ORDER BY key DESC" + ) + params = [other, key] + + if limit is not None: + base_query += " LIMIT ?" + params.append(limit) + if offset > 0: + base_query += " OFFSET ?" + params.append(offset) + elif offset > 0: + base_query += " LIMIT -1 OFFSET ?" + params.append(offset) + + # Wrap in COUNT query + query = f"SELECT COUNT(*) FROM ({base_query})" + cursor = txn.cnx.sqlite.execute(query, params) + return cursor.fetchone()[0] + + +def new( + db_path: str = DEFAULT_DB_PATH, + pool_size: int = None, + name: str = "kv_store", +) -> Bonafide: + """ + Create a new Bonafide instance with an initialized table. + + Args: + db_path: Path to the SQLite database file. + pool_size: Maximum number of worker threads. + name: Name of the table to create with key and value BLOB fields. + + Returns: + A Bonafide namedtuple with configuration and state. + """ + # Calculate default pool size if not provided + if pool_size is None: + pool_size = pool_size_default() + + # Create the Bonafide instance + bonafide = Bonafide( + db_path=db_path, + pool_size=pool_size, + worker_queue=queue.Queue(), + worker_threads=[], + worker_lock=threading.Lock(), + subspace={}, + ) + + # Initialize the table with key and value BLOB fields + cnx = sqlite3.connect(bonafide.db_path, check_same_thread=False) + cnx.execute("PRAGMA journal_mode=WAL") + cnx.execute( + f""" + CREATE TABLE IF NOT EXISTS {name} ( + key BLOB PRIMARY KEY, + value BLOB NOT NULL + ) + """ + ) + cnx.commit() + cnx.close() + + return bonafide diff --git a/LIMITS.md b/doc/LIMITS.md similarity index 100% rename from LIMITS.md rename to doc/LIMITS.md diff --git a/ROADMAP.md b/doc/ROADMAP.md similarity index 100% rename from ROADMAP.md rename to doc/ROADMAP.md diff --git a/STORE.md b/doc/STORE.md similarity index 100% rename from STORE.md rename to doc/STORE.md diff --git a/TODO.md b/doc/TODO.md similarity index 95% rename from TODO.md rename to doc/TODO.md index 95e319e..d5aa9ad 100644 --- a/TODO.md +++ b/doc/TODO.md @@ -10,3 +10,4 @@ - `add`: support python namespaces - `storage`: export from SQLite to directory - `identity`: support cryptographic identity management and signing function hash with mapping upon review +- `bonafide`: clean pool exit shutdown diff --git a/USAGE.md b/doc/USAGE.md similarity index 100% rename from USAGE.md rename to doc/USAGE.md diff --git a/examples/README.md b/doc/examples/README.md similarity index 94% rename from examples/README.md rename to doc/examples/README.md index 51f4690..01c5ecb 100644 --- a/examples/README.md +++ b/doc/examples/README.md @@ -10,13 +10,13 @@ Learn by doing! Copy and paste these commands to see how bb works. ```bash # Add a simple function (English) -python3 bb.py add examples/example_simple.py@eng +python3 bb.py add doc/examples/example_simple.py@eng # Add the same function in French (same code, different docstring) -python3 bb.py add examples/example_simple_french.py@fra +python3 bb.py add doc/examples/example_simple_french.py@fra # Add the same function in Spanish (same code, different docstring) -python3 bb.py add examples/example_simple_spanish.py@spa +python3 bb.py add doc/examples/example_simple_spanish.py@spa ``` **Expected output:** @@ -98,7 +98,7 @@ find ~/.local/bb/objects -name "*.json" ```bash # Add a function that uses the standard library -python3 bb.py add examples/example_with_import.py@fra +python3 bb.py add doc/examples/example_with_import.py@fra ``` The `show` command will reveal that imported names (like `Counter`) are NOT renamed: @@ -111,7 +111,7 @@ python3 bb.py show @fra ```bash # Add a function that calls another function from the pool -python3 bb.py add examples/example_with_bb.py@spa +python3 bb.py add doc/examples/example_with_bb.py@spa ``` View it to see how bb handles function composition: diff --git a/examples/add.py b/doc/examples/add.py similarity index 100% rename from examples/add.py rename to doc/examples/add.py diff --git a/examples/example_simple.py b/doc/examples/example_simple.py similarity index 100% rename from examples/example_simple.py rename to doc/examples/example_simple.py diff --git a/examples/example_simple_french.py b/doc/examples/example_simple_french.py similarity index 100% rename from examples/example_simple_french.py rename to doc/examples/example_simple_french.py diff --git a/examples/example_simple_spanish.py b/doc/examples/example_simple_spanish.py similarity index 100% rename from examples/example_simple_spanish.py rename to doc/examples/example_simple_spanish.py diff --git a/examples/example_with_import.py b/doc/examples/example_with_import.py similarity index 99% rename from examples/example_with_import.py rename to doc/examples/example_with_import.py index 0e66fa8..c59f1e0 100644 --- a/examples/example_with_import.py +++ b/doc/examples/example_with_import.py @@ -1,6 +1,7 @@ import math from collections import Counter + def process_data(items, threshold): """Process a list of items with a threshold.""" count = Counter(items) diff --git a/doc/examples/triple.py b/doc/examples/triple.py new file mode 100644 index 0000000..82866be --- /dev/null +++ b/doc/examples/triple.py @@ -0,0 +1,11 @@ +from bb.pool import ( + object_28cdad4124395ef2b6ff6d41b605ee1c6d12fcc5cdb5a61407b1f17a9a8499d4 as twice, +) +from bb.pool import ( + object_d6ecfc908f64e3118d390d922f6dc2354d0a10a1bc2d823994afcccbe2280f02 as add, +) + + +def triple(number): + """Triple a number by adding its double to itself.""" + return add(twice(number), number) diff --git a/doc/examples/triple_spanish.py b/doc/examples/triple_spanish.py new file mode 100644 index 0000000..d40db8e --- /dev/null +++ b/doc/examples/triple_spanish.py @@ -0,0 +1,11 @@ +from bb.pool import ( + object_28cdad4124395ef2b6ff6d41b605ee1c6d12fcc5cdb5a61407b1f17a9a8499d4 as doble, +) +from bb.pool import ( + object_d6ecfc908f64e3118d390d922f6dc2354d0a10a1bc2d823994afcccbe2280f02 as sumar, +) + + +def triplicar(numero): + """Triplicar un número sumando su doble con él mismo.""" + return sumar(doble(numero), numero) diff --git a/doc/examples/twice.py b/doc/examples/twice.py new file mode 100644 index 0000000..4e4b746 --- /dev/null +++ b/doc/examples/twice.py @@ -0,0 +1,8 @@ +from bb.pool import ( + object_d6ecfc908f64e3118d390d922f6dc2354d0a10a1bc2d823994afcccbe2280f02 as add, +) + + +def twice(number): + """Double a number by adding it to itself.""" + return add(number, number) diff --git a/strategies/strategy-20251121-000-schema-v1.md b/doc/plans/20251121-000-schema-v1.md similarity index 100% rename from strategies/strategy-20251121-000-schema-v1.md rename to doc/plans/20251121-000-schema-v1.md diff --git a/strategies/strategy-20251121-001-priority-zero.md b/doc/plans/20251121-001-priority-zero.md similarity index 100% rename from strategies/strategy-20251121-001-priority-zero.md rename to doc/plans/20251121-001-priority-zero.md diff --git a/doc/plans/20251211-000-bonafide-storage.md b/doc/plans/20251211-000-bonafide-storage.md new file mode 100644 index 0000000..8093684 --- /dev/null +++ b/doc/plans/20251211-000-bonafide-storage.md @@ -0,0 +1,18 @@ +# Bonafide Storage Primitives + +Bonafide is a storage layer for bb.py that implements an ordered +key-value store using SQLite. It provides thread-safe access to the +database through a connection pool, ensuring efficient concurrency +while respecting SQLite's single-writer constraint. This document +tracks the plan and todos for building bonafide. + +## Plan + +- [x] **Thread Pool**: Create a thread pool to manage database connections. +- [x] **Thread-Specific Connections**: Ensure each thread has its own connection to the database. +- [x] **Locking Mechanism**: Implement a lock to manage write operations safely. +- [x] **Connection Pool Initialization**: Initialize the connection pool with a configurable size. +- [x] **Query Execution**: Implement functions to execute queries within transactions. +- [ ] **Error Handling**: Add robust error handling for database operations. +- [x] **Testing**: Write tests to verify thread safety and concurrency. +- [ ] **Documentation**: Document the API and usage examples. diff --git a/transcripts/_/00-langue-du-feu.md b/doc/transcripts/_/00-langue-du-feu.md similarity index 100% rename from transcripts/_/00-langue-du-feu.md rename to doc/transcripts/_/00-langue-du-feu.md diff --git a/transcripts/add/00-simple-function.md b/doc/transcripts/add/00-simple-function.md similarity index 100% rename from transcripts/add/00-simple-function.md rename to doc/transcripts/add/00-simple-function.md diff --git a/transcripts/add/01-creates-v1-structure.md b/doc/transcripts/add/01-creates-v1-structure.md similarity index 100% rename from transcripts/add/01-creates-v1-structure.md rename to doc/transcripts/add/01-creates-v1-structure.md diff --git a/transcripts/add/02-stores-normalized-code.md b/doc/transcripts/add/02-stores-normalized-code.md similarity index 100% rename from transcripts/add/02-stores-normalized-code.md rename to doc/transcripts/add/02-stores-normalized-code.md diff --git a/transcripts/add/03-same-logic-same-hash.md b/doc/transcripts/add/03-same-logic-same-hash.md similarity index 100% rename from transcripts/add/03-same-logic-same-hash.md rename to doc/transcripts/add/03-same-logic-same-hash.md diff --git a/transcripts/add/04-multilingual-mappings.md b/doc/transcripts/add/04-multilingual-mappings.md similarity index 100% rename from transcripts/add/04-multilingual-mappings.md rename to doc/transcripts/add/04-multilingual-mappings.md diff --git a/transcripts/add/05-async-function.md b/doc/transcripts/add/05-async-function.md similarity index 100% rename from transcripts/add/05-async-function.md rename to doc/transcripts/add/05-async-function.md diff --git a/transcripts/add/06-validation-missing-language.md b/doc/transcripts/add/06-validation-missing-language.md similarity index 100% rename from transcripts/add/06-validation-missing-language.md rename to doc/transcripts/add/06-validation-missing-language.md diff --git a/transcripts/add/07-validation-invalid-language-code.md b/doc/transcripts/add/07-validation-invalid-language-code.md similarity index 100% rename from transcripts/add/07-validation-invalid-language-code.md rename to doc/transcripts/add/07-validation-invalid-language-code.md diff --git a/transcripts/add/08-validation-nonexistent-file.md b/doc/transcripts/add/08-validation-nonexistent-file.md similarity index 100% rename from transcripts/add/08-validation-nonexistent-file.md rename to doc/transcripts/add/08-validation-nonexistent-file.md diff --git a/transcripts/caller/00-find-callers.md b/doc/transcripts/caller/00-find-callers.md similarity index 100% rename from transcripts/caller/00-find-callers.md rename to doc/transcripts/caller/00-find-callers.md diff --git a/transcripts/check/00-find-and-run-tests.md b/doc/transcripts/check/00-find-and-run-tests.md similarity index 100% rename from transcripts/check/00-find-and-run-tests.md rename to doc/transcripts/check/00-find-and-run-tests.md diff --git a/transcripts/commit/00-copies-function-to-git-directory.md b/doc/transcripts/commit/00-copies-function-to-git-directory.md similarity index 100% rename from transcripts/commit/00-copies-function-to-git-directory.md rename to doc/transcripts/commit/00-copies-function-to-git-directory.md diff --git a/transcripts/commit/01-copies-all-language-mappings.md b/doc/transcripts/commit/01-copies-all-language-mappings.md similarity index 100% rename from transcripts/commit/01-copies-all-language-mappings.md rename to doc/transcripts/commit/01-copies-all-language-mappings.md diff --git a/transcripts/commit/02-copies-dependencies-recursively.md b/doc/transcripts/commit/02-copies-dependencies-recursively.md similarity index 100% rename from transcripts/commit/02-copies-dependencies-recursively.md rename to doc/transcripts/commit/02-copies-dependencies-recursively.md diff --git a/transcripts/commit/03-creates-git-commit.md b/doc/transcripts/commit/03-creates-git-commit.md similarity index 100% rename from transcripts/commit/03-creates-git-commit.md rename to doc/transcripts/commit/03-creates-git-commit.md diff --git a/transcripts/commit/04-no-changes-when-already-committed.md b/doc/transcripts/commit/04-no-changes-when-already-committed.md similarity index 100% rename from transcripts/commit/04-no-changes-when-already-committed.md rename to doc/transcripts/commit/04-no-changes-when-already-committed.md diff --git a/transcripts/compile/00-create-executable.md b/doc/transcripts/compile/00-create-executable.md similarity index 100% rename from transcripts/compile/00-create-executable.md rename to doc/transcripts/compile/00-create-executable.md diff --git a/transcripts/get/00-returns-denormalized-code.md b/doc/transcripts/get/00-returns-denormalized-code.md similarity index 100% rename from transcripts/get/00-returns-denormalized-code.md rename to doc/transcripts/get/00-returns-denormalized-code.md diff --git a/transcripts/get/01-deprecation-warning.md b/doc/transcripts/get/01-deprecation-warning.md similarity index 100% rename from transcripts/get/01-deprecation-warning.md rename to doc/transcripts/get/01-deprecation-warning.md diff --git a/transcripts/init/00-creates-directory-structure.md b/doc/transcripts/init/00-creates-directory-structure.md similarity index 100% rename from transcripts/init/00-creates-directory-structure.md rename to doc/transcripts/init/00-creates-directory-structure.md diff --git a/transcripts/log/00-displays-pool-log.md b/doc/transcripts/log/00-displays-pool-log.md similarity index 100% rename from transcripts/log/00-displays-pool-log.md rename to doc/transcripts/log/00-displays-pool-log.md diff --git a/transcripts/refactor/00-replace-dependency.md b/doc/transcripts/refactor/00-replace-dependency.md similarity index 100% rename from transcripts/refactor/00-replace-dependency.md rename to doc/transcripts/refactor/00-replace-dependency.md diff --git a/transcripts/remote/00-manage-remotes.md b/doc/transcripts/remote/00-manage-remotes.md similarity index 100% rename from transcripts/remote/00-manage-remotes.md rename to doc/transcripts/remote/00-manage-remotes.md diff --git a/transcripts/review/00-interactive-review.md b/doc/transcripts/review/00-interactive-review.md similarity index 100% rename from transcripts/review/00-interactive-review.md rename to doc/transcripts/review/00-interactive-review.md diff --git a/transcripts/run/00-execute-function.md b/doc/transcripts/run/00-execute-function.md similarity index 100% rename from transcripts/run/00-execute-function.md rename to doc/transcripts/run/00-execute-function.md diff --git a/transcripts/search/00-search-by-docstring.md b/doc/transcripts/search/00-search-by-docstring.md similarity index 100% rename from transcripts/search/00-search-by-docstring.md rename to doc/transcripts/search/00-search-by-docstring.md diff --git a/transcripts/show/00-displays-denormalized-code.md b/doc/transcripts/show/00-displays-denormalized-code.md similarity index 100% rename from transcripts/show/00-displays-denormalized-code.md rename to doc/transcripts/show/00-displays-denormalized-code.md diff --git a/transcripts/show/01-displays-docstring.md b/doc/transcripts/show/01-displays-docstring.md similarity index 100% rename from transcripts/show/01-displays-docstring.md rename to doc/transcripts/show/01-displays-docstring.md diff --git a/transcripts/show/02-async-function.md b/doc/transcripts/show/02-async-function.md similarity index 100% rename from transcripts/show/02-async-function.md rename to doc/transcripts/show/02-async-function.md diff --git a/transcripts/show/03-function-with-imports.md b/doc/transcripts/show/03-function-with-imports.md similarity index 100% rename from transcripts/show/03-function-with-imports.md rename to doc/transcripts/show/03-function-with-imports.md diff --git a/transcripts/show/04-multilingual-english.md b/doc/transcripts/show/04-multilingual-english.md similarity index 100% rename from transcripts/show/04-multilingual-english.md rename to doc/transcripts/show/04-multilingual-english.md diff --git a/transcripts/show/05-multilingual-french.md b/doc/transcripts/show/05-multilingual-french.md similarity index 100% rename from transcripts/show/05-multilingual-french.md rename to doc/transcripts/show/05-multilingual-french.md diff --git a/transcripts/show/06-without-language-lists-languages.md b/doc/transcripts/show/06-without-language-lists-languages.md similarity index 100% rename from transcripts/show/06-without-language-lists-languages.md rename to doc/transcripts/show/06-without-language-lists-languages.md diff --git a/transcripts/show/07-multiple-mappings-shows-menu.md b/doc/transcripts/show/07-multiple-mappings-shows-menu.md similarity index 100% rename from transcripts/show/07-multiple-mappings-shows-menu.md rename to doc/transcripts/show/07-multiple-mappings-shows-menu.md diff --git a/transcripts/show/08-explicit-mapping-hash.md b/doc/transcripts/show/08-explicit-mapping-hash.md similarity index 100% rename from transcripts/show/08-explicit-mapping-hash.md rename to doc/transcripts/show/08-explicit-mapping-hash.md diff --git a/transcripts/show/09-validation-nonexistent-function.md b/doc/transcripts/show/09-validation-nonexistent-function.md similarity index 100% rename from transcripts/show/09-validation-nonexistent-function.md rename to doc/transcripts/show/09-validation-nonexistent-function.md diff --git a/transcripts/show/10-validation-nonexistent-language.md b/doc/transcripts/show/10-validation-nonexistent-language.md similarity index 100% rename from transcripts/show/10-validation-nonexistent-language.md rename to doc/transcripts/show/10-validation-nonexistent-language.md diff --git a/transcripts/show/11-validation-invalid-hash-format.md b/doc/transcripts/show/11-validation-invalid-hash-format.md similarity index 100% rename from transcripts/show/11-validation-invalid-hash-format.md rename to doc/transcripts/show/11-validation-invalid-hash-format.md diff --git a/transcripts/show/12-validation-invalid-language-code.md b/doc/transcripts/show/12-validation-invalid-language-code.md similarity index 100% rename from transcripts/show/12-validation-invalid-language-code.md rename to doc/transcripts/show/12-validation-invalid-language-code.md diff --git a/transcripts/translate/00-interactive-translation.md b/doc/transcripts/translate/00-interactive-translation.md similarity index 100% rename from transcripts/translate/00-interactive-translation.md rename to doc/transcripts/translate/00-interactive-translation.md diff --git a/transcripts/translate/01-validation-failures.md b/doc/transcripts/translate/01-validation-failures.md similarity index 100% rename from transcripts/translate/01-validation-failures.md rename to doc/transcripts/translate/01-validation-failures.md diff --git a/transcripts/validate/00-validate-function.md b/doc/transcripts/validate/00-validate-function.md similarity index 100% rename from transcripts/validate/00-validate-function.md rename to doc/transcripts/validate/00-validate-function.md diff --git a/transcripts/whoami/00-get-and-set-user-config.md b/doc/transcripts/whoami/00-get-and-set-user-config.md similarity index 100% rename from transcripts/whoami/00-get-and-set-user-config.md rename to doc/transcripts/whoami/00-get-and-set-user-config.md diff --git a/examples/triple.py b/examples/triple.py deleted file mode 100644 index 3d8ea6b..0000000 --- a/examples/triple.py +++ /dev/null @@ -1,6 +0,0 @@ -from bb.pool import object_28cdad4124395ef2b6ff6d41b605ee1c6d12fcc5cdb5a61407b1f17a9a8499d4 as twice -from bb.pool import object_d6ecfc908f64e3118d390d922f6dc2354d0a10a1bc2d823994afcccbe2280f02 as add - -def triple(number): - """Triple a number by adding its double to itself.""" - return add(twice(number), number) diff --git a/examples/triple_spanish.py b/examples/triple_spanish.py deleted file mode 100644 index fab33ed..0000000 --- a/examples/triple_spanish.py +++ /dev/null @@ -1,6 +0,0 @@ -from bb.pool import object_28cdad4124395ef2b6ff6d41b605ee1c6d12fcc5cdb5a61407b1f17a9a8499d4 as doble -from bb.pool import object_d6ecfc908f64e3118d390d922f6dc2354d0a10a1bc2d823994afcccbe2280f02 as sumar - -def triplicar(numero): - """Triplicar un número sumando su doble con él mismo.""" - return sumar(doble(numero), numero) diff --git a/examples/twice.py b/examples/twice.py deleted file mode 100644 index 85a4e01..0000000 --- a/examples/twice.py +++ /dev/null @@ -1,5 +0,0 @@ -from bb.pool import object_d6ecfc908f64e3118d390d922f6dc2354d0a10a1bc2d823994afcccbe2280f02 as add - -def twice(number): - """Double a number by adding it to itself.""" - return add(number, number) diff --git a/pyproject.toml b/pyproject.toml index 50ab3b4..3c94036 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,3 +42,8 @@ dev = [ [tool.setuptools] py-modules = ["bb"] + +[dependency-groups] +dev = [ + "pytest-cov>=7.0.0", +] diff --git a/requirements-dev.txt b/requirements-dev.txt deleted file mode 100644 index 0ca45cd..0000000 --- a/requirements-dev.txt +++ /dev/null @@ -1,2 +0,0 @@ -pytest>=7.0.0 -pytest-cov>=4.0.0 diff --git a/tests/add/test_add.py b/tests/add/test_add.py index 47146f6..e079a7e 100644 --- a/tests/add/test_add.py +++ b/tests/add/test_add.py @@ -6,6 +6,7 @@ - Test: Call CLI command - Assert: Check output and files """ + import json from pathlib import Path @@ -20,14 +21,14 @@ def test_add_simple_function(cli_runner, tmp_path): ''') # Test: Run add command - result = cli_runner.run(['add', f'{test_file}@eng']) + result = cli_runner.run(["add", f"{test_file}@eng"]) # Assert: Check success and output assert result.returncode == 0 - assert 'Hash:' in result.stdout + assert "Hash:" in result.stdout # Extract hash and verify file was created - func_hash = result.stdout.split('Hash:')[1].strip().split()[0] + func_hash = result.stdout.split("Hash:")[1].strip().split()[0] assert len(func_hash) == 64 # Verify object was stored @@ -45,18 +46,18 @@ def test_add_function_creates_v1_structure(cli_runner, tmp_path): ''') # Test - func_hash = cli_runner.add(str(test_file), 'eng') + func_hash = cli_runner.add(str(test_file), "eng") # Assert: Check v1 directory structure func_dir = cli_runner.pool_dir / func_hash[:2] / func_hash[2:] assert func_dir.exists() # Check object.json exists - object_json = func_dir / 'object.json' + object_json = func_dir / "object.json" assert object_json.exists() # Check language mapping exists - eng_dir = func_dir / 'eng' + eng_dir = func_dir / "eng" assert eng_dir.exists() @@ -71,19 +72,19 @@ def test_add_function_stores_normalized_code(cli_runner, tmp_path): ''') # Test - func_hash = cli_runner.add(str(test_file), 'eng') + func_hash = cli_runner.add(str(test_file), "eng") # Assert: Check normalized code in object.json func_dir = cli_runner.pool_dir / func_hash[:2] / func_hash[2:] - object_json = func_dir / 'object.json' + object_json = func_dir / "object.json" - with open(object_json, 'r') as f: + with open(object_json, "r") as f: data = json.load(f) # Function should be renamed to _bb_v_0 - assert '_bb_v_0' in data['normalized_code'] + assert "_bb_v_0" in data["normalized_code"] # Original function name should NOT appear - assert 'my_function' not in data['normalized_code'] + assert "my_function" not in data["normalized_code"] def test_add_same_logic_same_hash(cli_runner, tmp_path): @@ -108,8 +109,8 @@ def test_add_same_logic_same_hash(cli_runner, tmp_path): ''') # Test: Add both - eng_hash = cli_runner.add(str(eng_file), 'eng') - fra_hash = cli_runner.add(str(fra_file), 'fra') + eng_hash = cli_runner.add(str(eng_file), "eng") + fra_hash = cli_runner.add(str(fra_file), "fra") # Assert: Same hash (logic is identical, only docstring differs) assert eng_hash == fra_hash @@ -131,15 +132,15 @@ def test_add_multilingual_creates_mappings(cli_runner, tmp_path): ''') # Test - eng_hash = cli_runner.add(str(eng_file), 'eng') - fra_hash = cli_runner.add(str(fra_file), 'fra') + eng_hash = cli_runner.add(str(eng_file), "eng") + fra_hash = cli_runner.add(str(fra_file), "fra") # Assert: Same hash, both language directories exist assert eng_hash == fra_hash func_dir = cli_runner.pool_dir / eng_hash[:2] / eng_hash[2:] - assert (func_dir / 'eng').exists() - assert (func_dir / 'fra').exists() + assert (func_dir / "eng").exists() + assert (func_dir / "fra").exists() def test_add_async_function(cli_runner, tmp_path): @@ -153,45 +154,45 @@ def test_add_async_function(cli_runner, tmp_path): ''') # Test - result = cli_runner.run(['add', f'{test_file}@eng']) + result = cli_runner.run(["add", f"{test_file}@eng"]) # Assert assert result.returncode == 0 - assert 'Hash:' in result.stdout + assert "Hash:" in result.stdout def test_add_missing_language_suffix_fails(cli_runner, tmp_path): """Test that add fails without language suffix""" # Setup test_file = tmp_path / "test.py" - test_file.write_text('def foo(): pass') + test_file.write_text("def foo(): pass") # Test: Run without @lang - result = cli_runner.run(['add', str(test_file)]) + result = cli_runner.run(["add", str(test_file)]) # Assert: Should fail assert result.returncode != 0 - assert 'Missing language suffix' in result.stderr + assert "Missing language suffix" in result.stderr def test_add_invalid_language_code_fails(cli_runner, tmp_path): """Test that add fails with too short language code""" # Setup test_file = tmp_path / "test.py" - test_file.write_text('def foo(): pass') + test_file.write_text("def foo(): pass") # Test: Run with too short lang (must be 3-256 chars) - result = cli_runner.run(['add', f'{test_file}@ab']) + result = cli_runner.run(["add", f"{test_file}@ab"]) # Assert: Should fail assert result.returncode != 0 - assert 'Language code must be 3-256 characters' in result.stderr + assert "Language code must be 3-256 characters" in result.stderr def test_add_nonexistent_file_fails(cli_runner): """Test that add fails for nonexistent file""" # Test - result = cli_runner.run(['add', '/nonexistent/file.py@eng']) + result = cli_runner.run(["add", "/nonexistent/file.py@eng"]) # Assert assert result.returncode != 0 @@ -211,21 +212,21 @@ def analyze(data): ''') # Test - result = cli_runner.run(['add', f'{test_file}@eng']) + result = cli_runner.run(["add", f"{test_file}@eng"]) # Assert assert result.returncode == 0 # Verify imports are preserved in object.json - func_hash = result.stdout.split('Hash:')[1].strip().split()[0] + func_hash = result.stdout.split("Hash:")[1].strip().split()[0] func_dir = cli_runner.pool_dir / func_hash[:2] / func_hash[2:] - object_json = func_dir / 'object.json' + object_json = func_dir / "object.json" - with open(object_json, 'r') as f: + with open(object_json, "r") as f: data = json.load(f) - assert 'import math' in data['normalized_code'] - assert 'from collections import Counter' in data['normalized_code'] + assert "import math" in data["normalized_code"] + assert "from collections import Counter" in data["normalized_code"] def test_add_syntax_error_fails(cli_runner, tmp_path): @@ -235,7 +236,7 @@ def test_add_syntax_error_fails(cli_runner, tmp_path): test_file.write_text("def foo( invalid syntax") # Test - result = cli_runner.run(['add', f'{test_file}@eng']) + result = cli_runner.run(["add", f"{test_file}@eng"]) # Assert assert result.returncode != 0 @@ -248,11 +249,11 @@ def test_add_empty_file_fails(cli_runner, tmp_path): test_file.write_text("") # Test - result = cli_runner.run(['add', f'{test_file}@eng']) + result = cli_runner.run(["add", f"{test_file}@eng"]) # Assert assert result.returncode != 0 - assert 'No function definition' in result.stderr + assert "No function definition" in result.stderr def test_add_class_only_fails(cli_runner, tmp_path): @@ -262,11 +263,11 @@ def test_add_class_only_fails(cli_runner, tmp_path): test_file.write_text("class Foo:\n pass\n") # Test - result = cli_runner.run(['add', f'{test_file}@eng']) + result = cli_runner.run(["add", f"{test_file}@eng"]) # Assert assert result.returncode != 0 - assert 'No function definition' in result.stderr + assert "No function definition" in result.stderr def test_add_function_without_docstring(cli_runner, tmp_path): @@ -276,11 +277,11 @@ def test_add_function_without_docstring(cli_runner, tmp_path): test_file.write_text("def double(x):\n return x * 2\n") # Test - result = cli_runner.run(['add', f'{test_file}@eng']) + result = cli_runner.run(["add", f"{test_file}@eng"]) # Assert assert result.returncode == 0 - assert 'Hash:' in result.stdout + assert "Hash:" in result.stdout def test_add_hash_stability(cli_runner, tmp_path): @@ -293,8 +294,8 @@ def test_add_hash_stability(cli_runner, tmp_path): ''') # Test: Add twice - hash1 = cli_runner.add(str(test_file), 'eng') - hash2 = cli_runner.add(str(test_file), 'eng') + hash1 = cli_runner.add(str(test_file), "eng") + hash2 = cli_runner.add(str(test_file), "eng") # Assert: Identical hashes assert hash1 == hash2 @@ -304,7 +305,7 @@ def test_add_hash_stability(cli_runner, tmp_path): def test_add_missing_bb_import_fails(cli_runner, tmp_path): """Test that add fails when bb imports don't exist in pool""" # Setup: Create function that imports a non-existent bb function - fake_hash = 'a' * 64 + fake_hash = "a" * 64 test_file = tmp_path / "with_missing_dep.py" test_file.write_text(f'''from bb.pool import object_{fake_hash} as helper @@ -314,12 +315,12 @@ def use_helper(x): ''') # Test - result = cli_runner.run(['add', f'{test_file}@eng']) + result = cli_runner.run(["add", f"{test_file}@eng"]) # Assert: Should fail with informative error assert result.returncode != 0 - assert 'do not exist in the local pool' in result.stderr - assert 'helper' in result.stderr + assert "do not exist in the local pool" in result.stderr + assert "helper" in result.stderr def test_add_with_existing_bb_import_succeeds(cli_runner, tmp_path): @@ -330,7 +331,7 @@ def test_add_with_existing_bb_import_succeeds(cli_runner, tmp_path): """Helper function""" return x * 2 ''') - helper_hash = cli_runner.add(str(helper_file), 'eng') + helper_hash = cli_runner.add(str(helper_file), "eng") # Create function that imports the helper test_file = tmp_path / "use_helper.py" @@ -342,11 +343,11 @@ def use_helper(x): ''') # Test - result = cli_runner.run(['add', f'{test_file}@eng']) + result = cli_runner.run(["add", f"{test_file}@eng"]) # Assert: Should succeed assert result.returncode == 0 - assert 'Hash:' in result.stdout + assert "Hash:" in result.stdout def test_add_hash_determinism_with_example_files(cli_runner): @@ -354,24 +355,24 @@ def test_add_hash_determinism_with_example_files(cli_runner): This verifies the core BB principle via CLI: same logic = same hash, regardless of variable names or human language. Uses the example files: - - examples/example_simple.py (English) - - examples/example_simple_french.py (French) + - doc/examples/example_simple.py (English) + - doc/examples/example_simple_french.py (French) Grey-box: We use CLI to add files, then verify both the output AND the internal storage structure. """ # Setup: Locate the example files - examples_dir = Path(__file__).parent.parent.parent / 'examples' - english_file = examples_dir / 'example_simple.py' - french_file = examples_dir / 'example_simple_french.py' + examples_dir = Path(__file__).parent.parent.parent / "doc" / "examples" + english_file = examples_dir / "example_simple.py" + french_file = examples_dir / "example_simple_french.py" # Verify example files exist assert english_file.exists(), f"Example file not found: {english_file}" assert french_file.exists(), f"Example file not found: {french_file}" # Test: Add both files via CLI - eng_hash = cli_runner.add(str(english_file), 'eng') - fra_hash = cli_runner.add(str(french_file), 'fra') + eng_hash = cli_runner.add(str(english_file), "eng") + fra_hash = cli_runner.add(str(french_file), "fra") # Assert 1: Same hash (core principle) assert eng_hash == fra_hash, ( @@ -390,17 +391,17 @@ def test_add_hash_determinism_with_example_files(cli_runner): assert func_dir.exists(), "Function directory should exist" # Assert 4: Both language mappings exist under same function - assert (func_dir / 'eng').exists(), "English mapping directory should exist" - assert (func_dir / 'fra').exists(), "French mapping directory should exist" + assert (func_dir / "eng").exists(), "English mapping directory should exist" + assert (func_dir / "fra").exists(), "French mapping directory should exist" # Assert 5: object.json exists with normalized code - object_json = func_dir / 'object.json' + object_json = func_dir / "object.json" assert object_json.exists(), "object.json should exist" - with open(object_json, 'r') as f: + with open(object_json, "r") as f: data = json.load(f) # Assert 6: Normalized code uses _bb_v_0 (not original function names) - assert '_bb_v_0' in data['normalized_code'] - assert 'calculate_sum' not in data['normalized_code'] - assert 'calculer_somme' not in data['normalized_code'] + assert "_bb_v_0" in data["normalized_code"] + assert "calculate_sum" not in data["normalized_code"] + assert "calculer_somme" not in data["normalized_code"] diff --git a/tests/aston/fuzz.py b/tests/aston/fuzz.py index 9383657..1e84f91 100755 --- a/tests/aston/fuzz.py +++ b/tests/aston/fuzz.py @@ -7,7 +7,7 @@ 2. Mutation-based fuzzing (imports, type hints, docstrings) 3. Generative fuzzing (random valid Python AST using tests/code/code.py) -All tests verify the round-trip invariant: ast == aston_read(aston_write(ast)) +All tests verify the round-trip invariant: ast == code_aston_read(code_aston_write(ast)) """ import ast @@ -15,11 +15,11 @@ import sys import traceback from pathlib import Path -from typing import List, Tuple, Optional + # Import ASTON from bb.py sys.path.insert(0, str(Path(__file__).parent.parent.parent)) -from bb import aston_write, aston_read +from bb import code_aston_write, code_aston_read # Import AST code generator from tests.code.code import generate as generate_ast_code @@ -27,9 +27,21 @@ # Mutation fuzzing constants and utilities IMPORT_MODULES = [ - "os", "sys", "re", "json", "math", "random", "pathlib", - "collections", "itertools", "functools", "typing", "datetime", - "hashlib", "urllib", "abc", + "os", + "sys", + "re", + "json", + "math", + "random", + "pathlib", + "collections", + "itertools", + "functools", + "typing", + "datetime", + "hashlib", + "urllib", + "abc", ] IMPORT_ITEMS = { @@ -86,7 +98,9 @@ def mutate_code(code: str, seed: int) -> str: class FuzzResult: """Result of a single fuzz test.""" - def __init__(self, success: bool, error: str = "", code: str = "", test_id: str = ""): + def __init__( + self, success: bool, error: str = "", code: str = "", test_id: str = "" + ): self.success = success self.error = error self.code = code @@ -104,15 +118,15 @@ def test_round_trip(code: str, test_id: str) -> FuzzResult: tree = ast.parse(code) # Convert to ASTON and back - _, tuples = aston_write(tree) - reconstructed = aston_read(tuples) + _, tuples = code_aston_write(tree) + reconstructed = code_aston_read(tuples) # Compare using ast.dump (structural equivalence) original_dump = ast.dump(tree) reconstructed_dump = ast.dump(reconstructed) if original_dump != reconstructed_dump: - error = f"AST structural mismatch" + error = "AST structural mismatch" return FuzzResult(False, error, code, test_id) # Verify code equivalence @@ -138,7 +152,7 @@ def save_failure(code: str, test_id: str, error: str) -> str: """Save failing code to /tmp and return filepath.""" filename = f"/tmp/aston_fuzz_fail_{test_id}.py" - with open(filename, 'w', encoding='utf-8') as f: + with open(filename, "w", encoding="utf-8") as f: f.write(f"# Failure: {error}\n") f.write(f"# Test ID: {test_id}\n\n") f.write(code) @@ -167,13 +181,15 @@ def report(self): print(f"{self.name} - Summary") print(f"{'=' * 70}") print(f"Total: {total}") - print(f"Passed: {self.passed} ({100 * self.passed // total if total > 0 else 0}%)") + print( + f"Passed: {self.passed} ({100 * self.passed // total if total > 0 else 0}%)" + ) if self.skipped > 0: print(f"Skipped: {self.skipped}") print(f"Failed: {self.failed}") if self.failures: - print(f"\nFailures:") + print("\nFailures:") for failure in self.failures: print(f" {failure['test_id']}: {failure['error'][:100]}") print(f" File: {failure['filepath']}") @@ -185,7 +201,7 @@ class CorpusFuzzStrategy(FuzzStrategy): def __init__(self): super().__init__("Corpus Fuzzing") - self.examples_dir = Path(__file__).parent.parent.parent / 'examples' + self.examples_dir = Path(__file__).parent.parent.parent / "doc" / "examples" def run(self): """Test all example files.""" @@ -193,12 +209,12 @@ def run(self): print(f"⚠ Examples directory not found: {self.examples_dir}") return - example_files = sorted(self.examples_dir.glob('*.py')) + example_files = sorted(self.examples_dir.glob("*.py")) print(f"\n[1/3] Testing corpus: {len(example_files)} example files") for filepath in example_files: try: - with open(filepath, 'r', encoding='utf-8') as f: + with open(filepath, "r", encoding="utf-8") as f: code = f.read() test_id = f"corpus_{filepath.stem}" @@ -213,12 +229,14 @@ def run(self): print(f" ✗ {filepath.name}: FAILED") print(f" Error: {result.error[:100]}") - self.failures.append({ - 'test_id': test_id, - 'error': result.error, - 'filepath': saved_path, - 'reproduce': f"python3 bb.py aston --test {saved_path}", - }) + self.failures.append( + { + "test_id": test_id, + "error": result.error, + "filepath": saved_path, + "reproduce": f"python3 bb.py aston --test {saved_path}", + } + ) except Exception as e: self.failed += 1 @@ -244,7 +262,9 @@ def __init__(self, num_mutations: int = 50): def run(self): """Generate and test mutations.""" total_tests = len(self.base_corpus) * self.num_mutations - print(f"\n[2/3] Testing mutations: {len(self.base_corpus)} base × {self.num_mutations} mutations = {total_tests} tests") + print( + f"\n[2/3] Testing mutations: {len(self.base_corpus)} base × {self.num_mutations} mutations = {total_tests} tests" + ) for base_idx, base_code in enumerate(self.base_corpus): base_passed = 0 @@ -264,17 +284,23 @@ def run(self): base_failed += 1 saved_path = save_failure(mutated_code, test_id, result.error) - self.failures.append({ - 'test_id': test_id, - 'error': result.error, - 'filepath': saved_path, - 'reproduce': f"python3 tests/aston/fuzz.py --mutation --seed {seed}", - }) + self.failures.append( + { + "test_id": test_id, + "error": result.error, + "filepath": saved_path, + "reproduce": f"python3 tests/aston/fuzz.py --mutation --seed {seed}", + } + ) if base_failed == 0: - print(f" ✓ Base {base_idx + 1}/{len(self.base_corpus)}: {base_passed} mutations passed") + print( + f" ✓ Base {base_idx + 1}/{len(self.base_corpus)}: {base_passed} mutations passed" + ) else: - print(f" ⚠ Base {base_idx + 1}/{len(self.base_corpus)}: {base_passed} passed, {base_failed} failed") + print( + f" ⚠ Base {base_idx + 1}/{len(self.base_corpus)}: {base_passed} passed, {base_failed} failed" + ) class GenerativeFuzzStrategy(FuzzStrategy): @@ -287,7 +313,9 @@ def __init__(self, num_tests: int = 100, start_seed: int = 0): def run(self): """Generate and test random AST code.""" - print(f"\n[3/3] Testing generated code: {self.num_tests} tests (seeds {self.start_seed}-{self.start_seed + self.num_tests - 1})") + print( + f"\n[3/3] Testing generated code: {self.num_tests} tests (seeds {self.start_seed}-{self.start_seed + self.num_tests - 1})" + ) for i in range(self.num_tests): seed = self.start_seed + i @@ -313,12 +341,14 @@ def run(self): print(f" ✗ Seed {seed}: FAILED") print(f" Error: {result.error[:100]}") - self.failures.append({ - 'test_id': test_id, - 'error': result.error, - 'filepath': saved_path, - 'reproduce': f"python3 tests/aston/fuzz.py --generative --seed {seed}", - }) + self.failures.append( + { + "test_id": test_id, + "error": result.error, + "filepath": saved_path, + "reproduce": f"python3 tests/aston/fuzz.py --generative --seed {seed}", + } + ) if self.passed > 0: print(f" ✓ Total: {self.passed}/{self.num_tests} passed") @@ -329,13 +359,28 @@ def main(): # Parse command line arguments import argparse - parser = argparse.ArgumentParser(description='Comprehensive ASTON round-trip fuzzer') - parser.add_argument('--corpus', action='store_true', help='Run corpus fuzzing only') - parser.add_argument('--mutation', action='store_true', help='Run mutation fuzzing only') - parser.add_argument('--generative', action='store_true', help='Run generative fuzzing only') - parser.add_argument('--seed', type=int, default=0, help='Starting seed for generative fuzzing') - parser.add_argument('--mutations', type=int, default=50, help='Mutations per base (default: 50)') - parser.add_argument('--tests', type=int, default=100, help='Number of generative tests (default: 100)') + parser = argparse.ArgumentParser( + description="Comprehensive ASTON round-trip fuzzer" + ) + parser.add_argument("--corpus", action="store_true", help="Run corpus fuzzing only") + parser.add_argument( + "--mutation", action="store_true", help="Run mutation fuzzing only" + ) + parser.add_argument( + "--generative", action="store_true", help="Run generative fuzzing only" + ) + parser.add_argument( + "--seed", type=int, default=0, help="Starting seed for generative fuzzing" + ) + parser.add_argument( + "--mutations", type=int, default=50, help="Mutations per base (default: 50)" + ) + parser.add_argument( + "--tests", + type=int, + default=100, + help="Number of generative tests (default: 100)", + ) args = parser.parse_args() @@ -353,7 +398,9 @@ def main(): strategies.append(MutationFuzzStrategy(num_mutations=args.mutations)) if args.generative or not (args.corpus or args.mutation): - strategies.append(GenerativeFuzzStrategy(num_tests=args.tests, start_seed=args.seed)) + strategies.append( + GenerativeFuzzStrategy(num_tests=args.tests, start_seed=args.seed) + ) # Run all strategies for strategy in strategies: @@ -370,7 +417,9 @@ def main(): print("Overall Summary") print("=" * 70) print(f"Total tests: {total}") - print(f"Passed: {total_passed} ({100 * total_passed // total if total > 0 else 0}%)") + print( + f"Passed: {total_passed} ({100 * total_passed // total if total > 0 else 0}%)" + ) if total_skipped > 0: print(f"Skipped: {total_skipped}") print(f"Failed: {total_failed}") @@ -399,5 +448,5 @@ def main(): sys.exit(0) -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/tests/async/test_async.py b/tests/async/test_async.py index abe8f0f..cadda34 100644 --- a/tests/async/test_async.py +++ b/tests/async/test_async.py @@ -4,18 +4,18 @@ Unit tests for low-level async function normalization (AST aspects). Integration tests for async function CLI commands. """ + import ast -import pytest import bb -from tests.conftest import normalize_code_for_test # ============================================================================= # Integration tests for async function CLI commands # ============================================================================= + def test_async_add_and_show(cli_runner, tmp_path): """Integration test: Add async function and show it""" test_file = tmp_path / "async.py" @@ -25,12 +25,12 @@ def test_async_add_and_show(cli_runner, tmp_path): return response ''') - func_hash = cli_runner.add(str(test_file), 'eng') - result = cli_runner.run(['show', f'{func_hash}@eng']) + func_hash = cli_runner.add(str(test_file), "eng") + result = cli_runner.run(["show", f"{func_hash}@eng"]) assert result.returncode == 0 - assert 'async def fetch_data' in result.stdout - assert 'url' in result.stdout + assert "async def fetch_data" in result.stdout + assert "url" in result.stdout def test_async_add_and_get(cli_runner, tmp_path): @@ -42,12 +42,12 @@ def test_async_add_and_get(cli_runner, tmp_path): return result ''') - func_hash = cli_runner.add(str(test_file), 'eng') - result = cli_runner.run(['get', f'{func_hash}@eng']) + func_hash = cli_runner.add(str(test_file), "eng") + result = cli_runner.run(["get", f"{func_hash}@eng"]) assert result.returncode == 0 - assert 'async def process_item' in result.stdout - assert 'item' in result.stdout + assert "async def process_item" in result.stdout + assert "item" in result.stdout def test_async_multilingual_same_hash(cli_runner, tmp_path): @@ -66,8 +66,8 @@ def test_async_multilingual_same_hash(cli_runner, tmp_path): return data ''') - eng_hash = cli_runner.add(str(eng_file), 'eng') - fra_hash = cli_runner.add(str(fra_file), 'fra') + eng_hash = cli_runner.add(str(eng_file), "eng") + fra_hash = cli_runner.add(str(fra_file), "fra") assert eng_hash == fra_hash @@ -76,6 +76,7 @@ def test_async_multilingual_same_hash(cli_runner, tmp_path): # Unit tests for async function normalization (low-level AST) # ============================================================================= + def test_normalize_simple_async_function(): """Test normalizing a simple async function""" code = '''async def fetch_data(): @@ -149,8 +150,8 @@ def test_async_function_hash_determinism(): _, normalized_eng_no_doc, _, _, _ = bb.code_normalize(tree_eng, "eng") _, normalized_fra_no_doc, _, _, _ = bb.code_normalize(tree_fra, "fra") - hash_eng = bb.hash_compute(normalized_eng_no_doc) - hash_fra = bb.hash_compute(normalized_fra_no_doc) + hash_eng = bb.code_hash_compute(normalized_eng_no_doc) + hash_fra = bb.code_hash_compute(normalized_fra_no_doc) assert hash_eng == hash_fra @@ -163,7 +164,9 @@ def test_async_function_preserves_async_keyword(): ''' tree = ast.parse(code) - normalized_with_doc, normalized_without_doc, _, _, _ = bb.code_normalize(tree, "eng") + normalized_with_doc, normalized_without_doc, _, _, _ = bb.code_normalize( + tree, "eng" + ) assert "async def _bb_v_0" in normalized_with_doc assert "async def _bb_v_0" in normalized_without_doc @@ -171,9 +174,9 @@ def test_async_function_preserves_async_keyword(): def test_ast_normalizer_visit_async_function_def(): """Test ASTNormalizer handles AsyncFunctionDef""" - code = '''async def original_name(): + code = """async def original_name(): pass -''' +""" tree = ast.parse(code) name_mapping = {"original_name": "_bb_v_0"} @@ -187,10 +190,10 @@ def test_ast_normalizer_visit_async_function_def(): def test_names_collect_includes_async_function(): """Test names_collect handles async functions""" - code = '''async def async_func(param): + code = """async def async_func(param): local_var = 42 return local_var -''' +""" tree = ast.parse(code) names = bb.code_collect_names(tree) diff --git a/tests/caller/test_caller.py b/tests/caller/test_caller.py index d3bf032..e7c369c 100644 --- a/tests/caller/test_caller.py +++ b/tests/caller/test_caller.py @@ -3,67 +3,61 @@ Grey-box integration tests for reverse dependency discovery. """ + import os import subprocess import sys from pathlib import Path -import pytest - def cli_run(args: list, env: dict = None) -> subprocess.CompletedProcess: """Run bb.py CLI command.""" - cmd = [sys.executable, str(Path(__file__).parent.parent.parent / 'bb.py')] + args + cmd = [sys.executable, str(Path(__file__).parent.parent.parent / "bb.py")] + args run_env = os.environ.copy() if env: run_env.update(env) - return subprocess.run( - cmd, - capture_output=True, - text=True, - env=run_env - ) + return subprocess.run(cmd, capture_output=True, text=True, env=run_env) def test_caller_invalid_hash_fails(tmp_path): """Test that caller fails with invalid hash format""" - bb_dir = tmp_path / '.bb' - env = {'BB_DIRECTORY': str(bb_dir)} + bb_dir = tmp_path / ".bb" + env = {"BB_DIRECTORY": str(bb_dir)} - result = cli_run(['caller', 'invalid-hash'], env=env) + result = cli_run(["caller", "invalid-hash"], env=env) assert result.returncode != 0 - assert 'Invalid hash format' in result.stderr + assert "Invalid hash format" in result.stderr def test_caller_nonexistent_function_fails(tmp_path): """Test that caller fails for nonexistent function""" - bb_dir = tmp_path / '.bb' - (bb_dir / 'pool').mkdir(parents=True) - env = {'BB_DIRECTORY': str(bb_dir)} + bb_dir = tmp_path / ".bb" + (bb_dir / "pool").mkdir(parents=True) + env = {"BB_DIRECTORY": str(bb_dir)} - fake_hash = 'f' * 64 - result = cli_run(['caller', fake_hash], env=env) + fake_hash = "f" * 64 + result = cli_run(["caller", fake_hash], env=env) assert result.returncode != 0 - assert 'not found' in result.stderr.lower() + assert "not found" in result.stderr.lower() def test_caller_no_callers_succeeds(tmp_path): """Test that caller succeeds with no callers found""" - bb_dir = tmp_path / '.bb' - env = {'BB_DIRECTORY': str(bb_dir)} + bb_dir = tmp_path / ".bb" + env = {"BB_DIRECTORY": str(bb_dir)} # Setup: Add a function test_file = tmp_path / "func.py" - test_file.write_text('def foo(): return 42') - add_result = cli_run(['add', f'{test_file}@eng'], env=env) - func_hash = add_result.stdout.split('Hash:')[1].strip().split()[0] + test_file.write_text("def foo(): return 42") + add_result = cli_run(["add", f"{test_file}@eng"], env=env) + func_hash = add_result.stdout.split("Hash:")[1].strip().split()[0] # Test - result = cli_run(['caller', func_hash], env=env) + result = cli_run(["caller", func_hash], env=env) # Assert: Should succeed even with no callers assert result.returncode == 0 @@ -71,11 +65,11 @@ def test_caller_no_callers_succeeds(tmp_path): def test_caller_empty_pool_fails(tmp_path): """Test that caller handles empty pool""" - bb_dir = tmp_path / '.bb' - env = {'BB_DIRECTORY': str(bb_dir)} + bb_dir = tmp_path / ".bb" + env = {"BB_DIRECTORY": str(bb_dir)} - fake_hash = 'a' * 64 - result = cli_run(['caller', fake_hash], env=env) + fake_hash = "a" * 64 + result = cli_run(["caller", fake_hash], env=env) # Should fail because function doesn't exist assert result.returncode != 0 diff --git a/tests/check/test_check.py b/tests/check/test_check.py index ce6e559..2602baf 100644 --- a/tests/check/test_check.py +++ b/tests/check/test_check.py @@ -6,8 +6,8 @@ - Test: Call CLI commands - Assert: Check output and files """ + import json -from pathlib import Path def test_add_function_with_check_decorator(cli_runner, tmp_path): @@ -19,7 +19,7 @@ def test_add_function_with_check_decorator(cli_runner, tmp_path): return a + b ''') - target_hash = cli_runner.add(str(target_file), 'eng') + target_hash = cli_runner.add(str(target_file), "eng") # Setup: Create a test file with @check decorator test_file = tmp_path / "test_add.py" @@ -34,25 +34,25 @@ def test_add(): ''') # Test: Run add command for the test function - result = cli_runner.run(['add', f'{test_file}@eng']) + result = cli_runner.run(["add", f"{test_file}@eng"]) # Assert: Check success assert result.returncode == 0 - assert 'Hash:' in result.stdout + assert "Hash:" in result.stdout # Extract hash - test_hash = result.stdout.split('Hash:')[1].strip().split()[0] + test_hash = result.stdout.split("Hash:")[1].strip().split()[0] # Verify metadata contains checks func_dir = cli_runner.pool_dir / test_hash[:2] / test_hash[2:] - object_json = func_dir / 'object.json' + object_json = func_dir / "object.json" - with open(object_json, 'r') as f: + with open(object_json, "r") as f: data = json.load(f) - assert 'metadata' in data - assert 'checks' in data['metadata'] - assert target_hash in data['metadata']['checks'] + assert "metadata" in data + assert "checks" in data["metadata"] + assert target_hash in data["metadata"]["checks"] def test_add_function_with_multiple_check_decorators(cli_runner, tmp_path): @@ -70,8 +70,8 @@ def test_add_function_with_multiple_check_decorators(cli_runner, tmp_path): return a - b ''') - target1_hash = cli_runner.add(str(target1_file), 'eng') - target2_hash = cli_runner.add(str(target2_file), 'eng') + target1_hash = cli_runner.add(str(target1_file), "eng") + target2_hash = cli_runner.add(str(target2_file), "eng") # Setup: Create a test file with multiple @check decorators test_file = tmp_path / "test_math.py" @@ -88,25 +88,25 @@ def test_math(): ''') # Test: Run add command - result = cli_runner.run(['add', f'{test_file}@eng']) + result = cli_runner.run(["add", f"{test_file}@eng"]) # Assert: Check success assert result.returncode == 0 # Extract hash - test_hash = result.stdout.split('Hash:')[1].strip().split()[0] + test_hash = result.stdout.split("Hash:")[1].strip().split()[0] # Verify metadata contains both checks func_dir = cli_runner.pool_dir / test_hash[:2] / test_hash[2:] - object_json = func_dir / 'object.json' + object_json = func_dir / "object.json" - with open(object_json, 'r') as f: + with open(object_json, "r") as f: data = json.load(f) - assert 'metadata' in data - assert 'checks' in data['metadata'] - assert target1_hash in data['metadata']['checks'] - assert target2_hash in data['metadata']['checks'] + assert "metadata" in data + assert "checks" in data["metadata"] + assert target1_hash in data["metadata"]["checks"] + assert target2_hash in data["metadata"]["checks"] def test_check_command_finds_tests(cli_runner, tmp_path): @@ -118,7 +118,7 @@ def test_check_command_finds_tests(cli_runner, tmp_path): return a * b ''') - target_hash = cli_runner.add(str(target_file), 'eng') + target_hash = cli_runner.add(str(target_file), "eng") # Setup: Create a test file with @check decorator test_file = tmp_path / "test_multiply.py" @@ -131,16 +131,16 @@ def test_multiply(): assert multiply(3, 4) == 12 ''') - result = cli_runner.run(['add', f'{test_file}@eng']) - test_hash = result.stdout.split('Hash:')[1].strip().split()[0] + result = cli_runner.run(["add", f"{test_file}@eng"]) + test_hash = result.stdout.split("Hash:")[1].strip().split()[0] # Test: Run check command - check_result = cli_runner.run(['check', target_hash]) + check_result = cli_runner.run(["check", target_hash]) # Assert: Check that test function is found assert check_result.returncode == 0 assert test_hash in check_result.stdout - assert 'bb.py run' in check_result.stdout + assert "bb.py run" in check_result.stdout def test_check_command_no_tests_found(cli_runner, tmp_path): @@ -152,20 +152,20 @@ def test_check_command_no_tests_found(cli_runner, tmp_path): return 42 ''') - target_hash = cli_runner.add(str(target_file), 'eng') + target_hash = cli_runner.add(str(target_file), "eng") # Test: Run check command - result = cli_runner.run(['check', target_hash]) + result = cli_runner.run(["check", target_hash]) # Assert: Check no tests found assert result.returncode == 0 - assert 'No tests found' in result.stdout + assert "No tests found" in result.stdout def test_add_with_check_missing_target_fails(cli_runner, tmp_path): """Test that adding a function with @check fails if target doesn't exist""" # Setup: Create a test file with @check pointing to non-existent function - fake_hash = 'a' * 64 # Non-existent hash + fake_hash = "a" * 64 # Non-existent hash test_file = tmp_path / "test_bad.py" test_file.write_text(f'''from bb import check from bb.pool import object_{fake_hash} as fake_func @@ -177,32 +177,32 @@ def test_fake(): ''') # Test: Run add command - result = cli_runner.run(['add', f'{test_file}@eng']) + result = cli_runner.run(["add", f"{test_file}@eng"]) # Assert: Should fail because target doesn't exist assert result.returncode != 0 - assert 'do not exist' in result.stderr.lower() or 'error' in result.stderr.lower() + assert "do not exist" in result.stderr.lower() or "error" in result.stderr.lower() def test_check_command_invalid_hash(cli_runner, tmp_path): """Test that 'check' command rejects invalid hash format""" # Test: Run check with invalid hash - result = cli_runner.run(['check', 'invalid-hash']) + result = cli_runner.run(["check", "invalid-hash"]) # Assert: Should fail assert result.returncode != 0 - assert 'invalid' in result.stderr.lower() + assert "invalid" in result.stderr.lower() def test_check_command_nonexistent_hash(cli_runner, tmp_path): """Test that 'check' command fails for non-existent function""" # Test: Run check with valid format but non-existent hash - fake_hash = 'b' * 64 - result = cli_runner.run(['check', fake_hash]) + fake_hash = "b" * 64 + result = cli_runner.run(["check", fake_hash]) # Assert: Should fail assert result.returncode != 0 - assert 'not found' in result.stderr.lower() + assert "not found" in result.stderr.lower() def test_add_function_without_check_decorator(cli_runner, tmp_path): @@ -215,19 +215,19 @@ def test_add_function_without_check_decorator(cli_runner, tmp_path): ''') # Test: Run add command - result = cli_runner.run(['add', f'{test_file}@eng']) + result = cli_runner.run(["add", f"{test_file}@eng"]) assert result.returncode == 0 # Extract hash - func_hash = result.stdout.split('Hash:')[1].strip().split()[0] + func_hash = result.stdout.split("Hash:")[1].strip().split()[0] # Verify metadata does NOT contain checks func_dir = cli_runner.pool_dir / func_hash[:2] / func_hash[2:] - object_json = func_dir / 'object.json' + object_json = func_dir / "object.json" - with open(object_json, 'r') as f: + with open(object_json, "r") as f: data = json.load(f) - assert 'metadata' in data + assert "metadata" in data # checks should not be present (or should be empty/None) - assert 'checks' not in data['metadata'] or not data['metadata'].get('checks') + assert "checks" not in data["metadata"] or not data["metadata"].get("checks") diff --git a/tests/code/code.py b/tests/code/code.py index 3efc2bd..b7d957e 100644 --- a/tests/code/code.py +++ b/tests/code/code.py @@ -11,7 +11,7 @@ import ast import random import sys -from typing import Dict, List, Optional, Any +from typing import Dict, Optional, Any def introspect_ast_nodes() -> Dict[str, Dict[str, str]]: @@ -42,7 +42,7 @@ def introspect_ast_nodes() -> Dict[str, Dict[str, str]]: continue # Get fields and their types - if hasattr(obj, '_fields'): + if hasattr(obj, "_fields"): field_types = {} for field_name in obj._fields: field_type = infer_field_type(obj, field_name) @@ -74,124 +74,144 @@ def infer_field_type(node_class: type, field_name: str) -> str: # In practice, we need to inspect actual instances or refer to docs # Common patterns - if field_name in ['lineno', 'col_offset', 'end_lineno', 'end_col_offset']: - return 'int' - if field_name in ['name', 'id', 'attr', 'arg', 'module']: - return 'identifier' - if field_name in ['asname']: - return 'identifier?' - if field_name == 'value': + if field_name in ["lineno", "col_offset", "end_lineno", "end_col_offset"]: + return "int" + if field_name in ["name", "id", "attr", "arg", "module"]: + return "identifier" + if field_name in ["asname"]: + return "identifier?" + if field_name == "value": # Special handling for value field - if node_class.__name__ in ['Constant', 'Num', 'Str', 'Bytes', 'NameConstant', 'Ellipsis']: - return 'constant' - elif node_class.__name__ in ['Assign', 'AugAssign', 'AnnAssign', 'Return']: - return 'expr' - elif node_class.__name__ in ['Attribute', 'Subscript']: - return 'expr' + if node_class.__name__ in [ + "Constant", + "Num", + "Str", + "Bytes", + "NameConstant", + "Ellipsis", + ]: + return "constant" + elif node_class.__name__ in ["Assign", "AugAssign", "AnnAssign", "Return"]: + return "expr" + elif node_class.__name__ in ["Attribute", "Subscript"]: + return "expr" else: - return 'unknown' + return "unknown" # Lists - if field_name in ['body', 'orelse', 'finalbody']: + if field_name in ["body", "orelse", "finalbody"]: # Lambda.body is expr, not stmt* - if node_class.__name__ == 'Lambda' and field_name == 'body': - return 'expr' + if node_class.__name__ == "Lambda" and field_name == "body": + return "expr" # IfExp.body and IfExp.orelse are expr, not stmt* - if node_class.__name__ == 'IfExp' and field_name in ['body', 'orelse']: - return 'expr' - return 'stmt*' - if field_name in ['elts', 'keys', 'values', 'comparators']: - return 'expr*' - if field_name in ['args', 'posonlyargs', 'kwonlyargs']: + if node_class.__name__ == "IfExp" and field_name in ["body", "orelse"]: + return "expr" + return "stmt*" + if field_name in ["elts", "keys", "values", "comparators"]: + return "expr*" + if field_name in ["args", "posonlyargs", "kwonlyargs"]: # FunctionDef/AsyncFunctionDef/Lambda.args is 'arguments', not 'arg*' - if node_class.__name__ in ['FunctionDef', 'AsyncFunctionDef', 'Lambda'] and field_name == 'args': - return 'arguments' - return 'arg*' - if field_name in ['names']: + if ( + node_class.__name__ in ["FunctionDef", "AsyncFunctionDef", "Lambda"] + and field_name == "args" + ): + return "arguments" + return "arg*" + if field_name in ["names"]: # Import/ImportFrom use alias*, Global/Nonlocal use identifier* - if node_class.__name__ in ['Import', 'ImportFrom']: - return 'alias*' - elif node_class.__name__ in ['Global', 'Nonlocal']: - return 'identifier*' + if node_class.__name__ in ["Import", "ImportFrom"]: + return "alias*" + elif node_class.__name__ in ["Global", "Nonlocal"]: + return "identifier*" else: - return 'alias*' # default - if field_name in ['bases', 'keywords', 'decorator_list']: - return 'expr*' - if field_name in ['targets']: - return 'expr*' - if field_name in ['handlers']: - return 'excepthandler*' - if field_name in ['items']: - return 'withitem*' - if field_name in ['ifs']: - return 'expr*' - if field_name in ['generators']: - return 'comprehension*' + return "alias*" # default + if field_name in ["bases", "keywords", "decorator_list"]: + return "expr*" + if field_name in ["targets"]: + return "expr*" + if field_name in ["handlers"]: + return "excepthandler*" + if field_name in ["items"]: + return "withitem*" + if field_name in ["ifs"]: + return "expr*" + if field_name in ["generators"]: + return "comprehension*" # Optional fields - if field_name == 'returns': - return 'expr?' - if field_name == 'type_comment': - return 'string?' - if field_name in ['defaults', 'kw_defaults']: - return 'expr*' + if field_name == "returns": + return "expr?" + if field_name == "type_comment": + return "string?" + if field_name in ["defaults", "kw_defaults"]: + return "expr*" # Single nodes - if field_name in ['test', 'iter', 'target', 'left', 'right', 'func', 'lower', 'upper', 'step']: - return 'expr' - if field_name in ['cause']: - return 'expr?' - if field_name in ['exc']: - return 'expr?' - if field_name in ['type']: - return 'expr?' - if field_name in ['slice']: - return 'expr' - if field_name == 'annotation': + if field_name in [ + "test", + "iter", + "target", + "left", + "right", + "func", + "lower", + "upper", + "step", + ]: + return "expr" + if field_name in ["cause"]: + return "expr?" + if field_name in ["exc"]: + return "expr?" + if field_name in ["type"]: + return "expr?" + if field_name in ["slice"]: + return "expr" + if field_name == "annotation": # AnnAssign.annotation is required, others might be optional - if node_class.__name__ == 'AnnAssign': - return 'expr' + if node_class.__name__ == "AnnAssign": + return "expr" else: - return 'expr?' - if field_name in ['simple']: - return 'int' + return "expr?" + if field_name in ["simple"]: + return "int" # Operators and context - if field_name == 'op': + if field_name == "op": # Check node class to determine operator type - if node_class.__name__ == 'BoolOp': - return 'boolop' - elif node_class.__name__ == 'UnaryOp': - return 'unaryop' + if node_class.__name__ == "BoolOp": + return "boolop" + elif node_class.__name__ == "UnaryOp": + return "unaryop" else: - return 'operator' - if field_name == 'ops': + return "operator" + if field_name == "ops": # Compare uses cmpop*, others use operator* - if node_class.__name__ == 'Compare': - return 'cmpop*' + if node_class.__name__ == "Compare": + return "cmpop*" else: - return 'operator*' - if field_name in ['ctx']: - return 'expr_context' - if field_name in ['boolop']: - return 'boolop' - if field_name in ['unaryop']: - return 'unaryop' - if field_name in ['cmpop']: - return 'cmpop*' + return "operator*" + if field_name in ["ctx"]: + return "expr_context" + if field_name in ["boolop"]: + return "boolop" + if field_name in ["unaryop"]: + return "unaryop" + if field_name in ["cmpop"]: + return "cmpop*" # Special cases - if field_name == 'kind': - return 'string?' - if field_name == 'n': - return 'constant' - if field_name == 's': - return 'constant' - if field_name in ['vararg', 'kwarg']: - return 'arg?' + if field_name == "kind": + return "string?" + if field_name == "n": + return "constant" + if field_name == "s": + return "constant" + if field_name in ["vararg", "kwarg"]: + return "arg?" # Default: try to infer from context - return 'unknown' + return "unknown" def get_usable_nodes() -> Dict[str, Dict[str, str]]: @@ -206,10 +226,10 @@ def get_usable_nodes() -> Dict[str, Dict[str, str]]: # Nodes with complex inter-field dependencies or that cause frequent syntax errors excluded_nodes = { - 'Raise', # cause requires exc to be set - 'Delete', # requires valid delete targets (often generates invalid literals) - 'Global', # requires identifiers, not expressions - 'Nonlocal', # requires identifiers, not expressions + "Raise", # cause requires exc to be set + "Delete", # requires valid delete targets (often generates invalid literals) + "Global", # requires identifiers, not expressions + "Nonlocal", # requires identifiers, not expressions } for node_name, fields in all_nodes.items(): @@ -217,7 +237,7 @@ def get_usable_nodes() -> Dict[str, Dict[str, str]]: if node_name in excluded_nodes: continue # Skip nodes with unknown field types - if any(field_type == 'unknown' for field_type in fields.values()): + if any(field_type == "unknown" for field_type in fields.values()): continue usable_nodes[node_name] = fields @@ -252,21 +272,21 @@ def __init__(self, seed: int, max_depth: int = 3, energy: Optional[int] = None): continue # Categorize by base class - if hasattr(obj, '__bases__'): + if hasattr(obj, "__bases__"): bases = [b.__name__ for b in obj.__bases__] - if 'expr' in bases: + if "expr" in bases: self.expr_nodes.append(name) - elif 'stmt' in bases: + elif "stmt" in bases: self.stmt_nodes.append(name) - elif 'operator' in bases: + elif "operator" in bases: self.operator_nodes.append(name) - elif 'boolop' in bases: + elif "boolop" in bases: self.boolop_nodes.append(name) - elif 'unaryop' in bases: + elif "unaryop" in bases: self.unaryop_nodes.append(name) - elif 'cmpop' in bases: + elif "cmpop" in bases: self.cmpop_nodes.append(name) - elif 'expr_context' in bases: + elif "expr_context" in bases: self.expr_context_nodes.append(name) def consume_energy(self, amount: int = 1) -> bool: @@ -282,9 +302,9 @@ def generate_identifier(self) -> Optional[str]: """Generate a random valid Python identifier.""" if not self.consume_energy(): return None - letters = 'abcdefghijklmnopqrstuvwxyz' + letters = "abcdefghijklmnopqrstuvwxyz" length = self.rng.randint(1, 8) - return ''.join(self.rng.choice(letters) for _ in range(length)) + return "".join(self.rng.choice(letters) for _ in range(length)) def generate_constant(self) -> Any: """Generate a random constant value (for use in non-assignment contexts).""" @@ -312,39 +332,45 @@ def generate_valid_target(self, depth: int) -> Optional[ast.expr]: return None return ast.Name(id=identifier, ctx=ast.Store()) - def generate_field_value(self, field_type: str, depth: int, field_name: str = '') -> Any: + def generate_field_value( + self, field_type: str, depth: int, field_name: str = "" + ) -> Any: """Generate a value for a specific field type.""" if not self.consume_energy(): return None if depth >= self.max_depth: # At max depth, generate simple values only - if field_type == 'identifier': + if field_type == "identifier": return self.generate_identifier() - elif field_type == 'identifier?': + elif field_type == "identifier?": return None if self.rng.random() < 0.5 else self.generate_identifier() - elif field_type == 'int': + elif field_type == "int": return self.rng.randint(0, 10) - elif field_type == 'constant': + elif field_type == "constant": return self.generate_constant() - elif field_type in ['string', 'string?']: - return None if field_type == 'string?' and self.rng.random() < 0.5 else self.generate_identifier() - elif field_type in ['expr', 'stmt']: + elif field_type in ["string", "string?"]: + return ( + None + if field_type == "string?" and self.rng.random() < 0.5 + else self.generate_identifier() + ) + elif field_type in ["expr", "stmt"]: # Return simplest possible node return self.generate_simple_node(field_type) - elif field_type.endswith('*'): + elif field_type.endswith("*"): return [] - elif field_type.endswith('?'): + elif field_type.endswith("?"): return None else: return None # Regular generation - if field_type == 'identifier': + if field_type == "identifier": return self.generate_identifier() - elif field_type == 'identifier?': + elif field_type == "identifier?": return None if self.rng.random() < 0.5 else self.generate_identifier() - elif field_type == 'identifier*': + elif field_type == "identifier*": count = self.rng.randint(1, 2) ids = [] for _ in range(count): @@ -352,26 +378,30 @@ def generate_field_value(self, field_type: str, depth: int, field_name: str = '' if id_val: ids.append(id_val) return ids - elif field_type == 'int': + elif field_type == "int": return self.rng.randint(0, 10) - elif field_type == 'constant': + elif field_type == "constant": return self.generate_constant() - elif field_type == 'string': + elif field_type == "string": return self.generate_identifier() - elif field_type == 'string?': + elif field_type == "string?": return None if self.rng.random() < 0.5 else self.generate_identifier() - elif field_type == 'expr': + elif field_type == "expr": # Special case for assignment/loop targets - if field_name in ['target', 'targets']: + if field_name in ["target", "targets"]: return self.generate_valid_target(depth) return self.generate_expr(depth + 1) - elif field_type == 'expr?': + elif field_type == "expr?": return None if self.rng.random() < 0.5 else self.generate_expr(depth + 1) - elif field_type == 'expr*': + elif field_type == "expr*": # Special case for assignment/delete targets - if field_name in ['targets']: + if field_name in ["targets"]: count = self.rng.randint(1, 2) - return [self.generate_valid_target(depth + 1) for _ in range(count) if self.consume_energy()] + return [ + self.generate_valid_target(depth + 1) + for _ in range(count) + if self.consume_energy() + ] count = self.rng.randint(0, 2) exprs = [] for _ in range(count): @@ -379,9 +409,9 @@ def generate_field_value(self, field_type: str, depth: int, field_name: str = '' if expr is not None: exprs.append(expr) return exprs - elif field_type == 'stmt': + elif field_type == "stmt": return self.generate_stmt(depth + 1) - elif field_type == 'stmt*': + elif field_type == "stmt*": count = self.rng.randint(1, 3) stmts = [] for _ in range(count): @@ -389,21 +419,25 @@ def generate_field_value(self, field_type: str, depth: int, field_name: str = '' if stmt is not None: stmts.append(stmt) return stmts if stmts else [ast.Pass()] - elif field_type == 'operator': + elif field_type == "operator": return self.generate_operator() - elif field_type == 'operator*': + elif field_type == "operator*": count = self.rng.randint(1, 2) - return [self.generate_operator() for _ in range(count) if self.consume_energy()] - elif field_type == 'boolop': + return [ + self.generate_operator() for _ in range(count) if self.consume_energy() + ] + elif field_type == "boolop": return self.generate_boolop() - elif field_type == 'unaryop': + elif field_type == "unaryop": return self.generate_unaryop() - elif field_type == 'cmpop*': + elif field_type == "cmpop*": count = self.rng.randint(1, 2) - return [self.generate_cmpop() for _ in range(count) if self.consume_energy()] - elif field_type == 'expr_context': + return [ + self.generate_cmpop() for _ in range(count) if self.consume_energy() + ] + elif field_type == "expr_context": return self.generate_expr_context() - elif field_type == 'arg*': + elif field_type == "arg*": count = self.rng.randint(0, 2) args = [] for _ in range(count): @@ -411,11 +445,11 @@ def generate_field_value(self, field_type: str, depth: int, field_name: str = '' if arg: args.append(arg) return args - elif field_type == 'arg?': + elif field_type == "arg?": return None if self.rng.random() < 0.7 else self.generate_arg(depth + 1) - elif field_type == 'arguments': + elif field_type == "arguments": return self.generate_arguments(depth + 1) - elif field_type == 'alias*': + elif field_type == "alias*": count = self.rng.randint(1, 2) aliases = [] for _ in range(count): @@ -423,13 +457,21 @@ def generate_field_value(self, field_type: str, depth: int, field_name: str = '' if alias: aliases.append(alias) return aliases - elif field_type == 'excepthandler*': + elif field_type == "excepthandler*": count = self.rng.randint(1, 2) - return [self.generate_excepthandler(depth + 1) for _ in range(count) if self.consume_energy()] - elif field_type == 'withitem*': + return [ + self.generate_excepthandler(depth + 1) + for _ in range(count) + if self.consume_energy() + ] + elif field_type == "withitem*": count = self.rng.randint(1, 2) - return [self.generate_withitem(depth + 1) for _ in range(count) if self.consume_energy()] - elif field_type == 'comprehension*': + return [ + self.generate_withitem(depth + 1) + for _ in range(count) + if self.consume_energy() + ] + elif field_type == "comprehension*": comp = self.generate_comprehension(depth + 1) return [comp] if comp else [] else: @@ -437,9 +479,9 @@ def generate_field_value(self, field_type: str, depth: int, field_name: str = '' def generate_simple_node(self, node_type: str) -> ast.AST: """Generate simplest possible node of given type.""" - if node_type == 'expr': + if node_type == "expr": return ast.Constant(value=42) - elif node_type == 'stmt': + elif node_type == "stmt": return ast.Pass() return ast.Constant(value=None) @@ -449,7 +491,11 @@ def generate_expr(self, depth: int = 0) -> Optional[ast.expr]: return None if depth >= self.max_depth or not self.expr_nodes: const_val = self.generate_constant() - return ast.Constant(value=const_val) if const_val is not None else ast.Constant(value=42) + return ( + ast.Constant(value=const_val) + if const_val is not None + else ast.Constant(value=42) + ) # Small chance to generate function composition or method chaining (only at low depth) if depth < 2 and self.rng.random() < 0.15: # 15% chance @@ -466,7 +512,7 @@ def generate_expr(self, depth: int = 0) -> Optional[ast.expr]: # Prefer simpler expressions at higher depths if depth > 1: - simple_exprs = ['Constant', 'Name'] + simple_exprs = ["Constant", "Name"] available = [n for n in simple_exprs if n in self.expr_nodes] if available: node_name = self.rng.choice(available) @@ -500,7 +546,7 @@ def generate_function_composition(self, depth: int) -> Optional[ast.Call]: outer_func = ast.Attribute( value=ast.Name(id=obj_name, ctx=ast.Load()), attr=attr_name, - ctx=ast.Load() + ctx=ast.Load(), ) # Generate inner call @@ -514,15 +560,11 @@ def generate_function_composition(self, depth: int) -> Optional[ast.Call]: inner_call = ast.Call( func=ast.Name(id=inner_func_name, ctx=ast.Load()), args=[inner_arg], - keywords=[] + keywords=[], ) # Generate outer call with inner call as argument - return ast.Call( - func=outer_func, - args=[inner_call], - keywords=[] - ) + return ast.Call(func=outer_func, args=[inner_call], keywords=[]) def generate_method_chain(self, depth: int) -> Optional[ast.Call]: """ @@ -543,13 +585,9 @@ def generate_method_chain(self, depth: int) -> Optional[ast.Call]: return None first_call = ast.Call( - func=ast.Attribute( - value=base, - attr=method1_name, - ctx=ast.Load() - ), + func=ast.Attribute(value=base, attr=method1_name, ctx=ast.Load()), args=[], - keywords=[] + keywords=[], ) # Possibly add a second method in the chain @@ -559,13 +597,9 @@ def generate_method_chain(self, depth: int) -> Optional[ast.Call]: return first_call second_call = ast.Call( - func=ast.Attribute( - value=first_call, - attr=method2_name, - ctx=ast.Load() - ), + func=ast.Attribute(value=first_call, attr=method2_name, ctx=ast.Load()), args=[], - keywords=[] + keywords=[], ) return second_call @@ -581,7 +615,7 @@ def generate_stmt(self, depth: int = 0) -> Optional[ast.stmt]: # Prefer simpler statements at higher depths if depth > 1: # At higher depths, strongly prefer simple statements - simple_stmts = ['Pass', 'Expr'] + simple_stmts = ["Pass", "Expr"] available = [n for n in simple_stmts if n in self.stmt_nodes] if available and self.rng.random() < 0.7: # 70% chance to use simple stmt node_name = self.rng.choice(available) @@ -643,7 +677,7 @@ def generate_arguments(self, depth: int) -> ast.arguments: kwonlyargs=[], kw_defaults=[], kwarg=None, - defaults=[] + defaults=[], ) def generate_alias(self, depth: int) -> ast.alias: @@ -652,17 +686,12 @@ def generate_alias(self, depth: int) -> ast.alias: def generate_excepthandler(self, depth: int) -> ast.ExceptHandler: """Generate an exception handler.""" - return ast.ExceptHandler( - type=None, - name=None, - body=[ast.Pass()] - ) + return ast.ExceptHandler(type=None, name=None, body=[ast.Pass()]) def generate_withitem(self, depth: int) -> ast.withitem: """Generate a with item.""" return ast.withitem( - context_expr=self.generate_expr(depth + 1), - optional_vars=None + context_expr=self.generate_expr(depth + 1), optional_vars=None ) def generate_comprehension(self, depth: int) -> Optional[ast.comprehension]: @@ -673,12 +702,7 @@ def generate_comprehension(self, depth: int) -> Optional[ast.comprehension]: iter_expr = self.generate_expr(depth + 1) if target is None or iter_expr is None: return None - return ast.comprehension( - target=target, - iter=iter_expr, - ifs=[], - is_async=0 - ) + return ast.comprehension(target=target, iter=iter_expr, ifs=[], is_async=0) def generate_node(self, node_name: str, depth: int) -> Optional[ast.AST]: """Generate a specific AST node by name.""" @@ -686,9 +710,9 @@ def generate_node(self, node_name: str, depth: int) -> Optional[ast.AST]: return None # Special handling for nodes with ordering constraints - if node_name == 'Try': + if node_name == "Try": return self.generate_try(depth) - elif node_name == 'TryStar': + elif node_name == "TryStar": return self.generate_try_star(depth) if node_name not in self.usable_nodes: @@ -704,7 +728,9 @@ def generate_node(self, node_name: str, depth: int) -> Optional[ast.AST]: # Generate values for all fields kwargs = {} for field_name, field_type in field_types.items(): - kwargs[field_name] = self.generate_field_value(field_type, depth, field_name) + kwargs[field_name] = self.generate_field_value( + field_type, depth, field_name + ) try: return node_class(**kwargs) @@ -738,23 +764,19 @@ def generate_try(self, depth: int) -> Optional[ast.Try]: # First generate handlers with types for i in range(num_handlers - 1): if self.consume_energy(): - exc_type = self.generate_expr(depth + 1) if self.rng.random() < 0.7 else None - handler = ast.ExceptHandler( - type=exc_type, - name=None, - body=[ast.Pass()] + exc_type = ( + self.generate_expr(depth + 1) if self.rng.random() < 0.7 else None ) + handler = ast.ExceptHandler(type=exc_type, name=None, body=[ast.Pass()]) handlers.append(handler) # Last handler - might be bare if self.consume_energy(): # 30% chance for bare except as last handler - exc_type = None if self.rng.random() < 0.3 else self.generate_expr(depth + 1) - handler = ast.ExceptHandler( - type=exc_type, - name=None, - body=[ast.Pass()] + exc_type = ( + None if self.rng.random() < 0.3 else self.generate_expr(depth + 1) ) + handler = ast.ExceptHandler(type=exc_type, name=None, body=[ast.Pass()]) handlers.append(handler) if not handlers: @@ -773,12 +795,7 @@ def generate_try(self, depth: int) -> Optional[ast.Try]: if final_stmt: finalbody.append(final_stmt) - return ast.Try( - body=body, - handlers=handlers, - orelse=orelse, - finalbody=finalbody - ) + return ast.Try(body=body, handlers=handlers, orelse=orelse, finalbody=finalbody) def generate_try_star(self, depth: int) -> Optional[ast.TryStar]: """Generate a TryStar node with properly ordered except handlers.""" @@ -803,23 +820,19 @@ def generate_try_star(self, depth: int) -> Optional[ast.TryStar]: # First generate handlers with types for i in range(num_handlers - 1): if self.consume_energy(): - exc_type = self.generate_expr(depth + 1) if self.rng.random() < 0.7 else None - handler = ast.ExceptHandler( - type=exc_type, - name=None, - body=[ast.Pass()] + exc_type = ( + self.generate_expr(depth + 1) if self.rng.random() < 0.7 else None ) + handler = ast.ExceptHandler(type=exc_type, name=None, body=[ast.Pass()]) handlers.append(handler) # Last handler - might be bare if self.consume_energy(): # 30% chance for bare except as last handler - exc_type = None if self.rng.random() < 0.3 else self.generate_expr(depth + 1) - handler = ast.ExceptHandler( - type=exc_type, - name=None, - body=[ast.Pass()] + exc_type = ( + None if self.rng.random() < 0.3 else self.generate_expr(depth + 1) ) + handler = ast.ExceptHandler(type=exc_type, name=None, body=[ast.Pass()]) handlers.append(handler) if not handlers: @@ -839,10 +852,7 @@ def generate_try_star(self, depth: int) -> Optional[ast.TryStar]: finalbody.append(final_stmt) return ast.TryStar( - body=body, - handlers=handlers, - orelse=orelse, - finalbody=finalbody + body=body, handlers=handlers, orelse=orelse, finalbody=finalbody ) def generate_module(self) -> Optional[ast.Module]: @@ -864,30 +874,30 @@ def generate_module(self) -> Optional[ast.Module]: if not self.consume_energy(): break # Choose between Import and ImportFrom - if self.rng.random() < 0.5 and 'Import' in self.usable_nodes: - import_stmt = self.generate_node('Import', 0) + if self.rng.random() < 0.5 and "Import" in self.usable_nodes: + import_stmt = self.generate_node("Import", 0) if import_stmt: body.append(import_stmt) - elif 'ImportFrom' in self.usable_nodes: - import_stmt = self.generate_node('ImportFrom', 0) + elif "ImportFrom" in self.usable_nodes: + import_stmt = self.generate_node("ImportFrom", 0) if import_stmt: body.append(import_stmt) # Optionally generate one FunctionDef or AsyncFunctionDef if self.rng.random() < 0.8: # 80% chance to include a function - if self.rng.random() < 0.5 and 'FunctionDef' in self.usable_nodes: + if self.rng.random() < 0.5 and "FunctionDef" in self.usable_nodes: func = self.generate_function_def(0) if func: body.append(func) - elif 'AsyncFunctionDef' in self.usable_nodes: + elif "AsyncFunctionDef" in self.usable_nodes: func = self.generate_async_function_def(0) if func: body.append(func) # Ensure we have at least one import if not body: - if 'Import' in self.usable_nodes: - import_stmt = self.generate_node('Import', 0) + if "Import" in self.usable_nodes: + import_stmt = self.generate_node("Import", 0) if import_stmt: body.append(import_stmt) else: @@ -933,7 +943,7 @@ def generate_function_def(self, depth: int) -> Optional[ast.FunctionDef]: body=body_stmts, decorator_list=decorator_list, returns=None, - type_comment=None + type_comment=None, ) def generate_async_function_def(self, depth: int) -> Optional[ast.AsyncFunctionDef]: @@ -974,7 +984,7 @@ def generate_async_function_def(self, depth: int) -> Optional[ast.AsyncFunctionD body=body_stmts, decorator_list=decorator_list, returns=None, - type_comment=None + type_comment=None, ) @@ -1001,7 +1011,7 @@ def generate(seed: int, energy: Optional[int] = None) -> Optional[str]: code = ast.unparse(module) # Validate the generated code by trying to parse it try: - compile(code, '', 'exec') + compile(code, "", "exec") return code except SyntaxError: # Generated code has syntax errors, return None @@ -1029,16 +1039,24 @@ def generate_field_type_mapping() -> str: for field_name, field_type in sorted(fields.items()): output.append(f" {field_name}: {field_type}") - return '\n'.join(output) + return "\n".join(output) -if __name__ == '__main__': +if __name__ == "__main__": import argparse - parser = argparse.ArgumentParser(description='Generate random Python AST code') - parser.add_argument('--seed', type=int, help='Random seed for deterministic generation') - parser.add_argument('--energy', type=int, help='Energy budget for generation (default: 1000)') - parser.add_argument('--mapping', action='store_true', help='Print field type mapping instead of generating code') + parser = argparse.ArgumentParser(description="Generate random Python AST code") + parser.add_argument( + "--seed", type=int, help="Random seed for deterministic generation" + ) + parser.add_argument( + "--energy", type=int, help="Energy budget for generation (default: 1000)" + ) + parser.add_argument( + "--mapping", + action="store_true", + help="Print field type mapping instead of generating code", + ) args = parser.parse_args() if args.mapping: diff --git a/tests/code/example_seed_1337.py b/tests/code/example_seed_1337.py deleted file mode 100644 index 7dc14aa..0000000 --- a/tests/code/example_seed_1337.py +++ /dev/null @@ -1 +0,0 @@ -# Failed to generate diff --git a/tests/code/example_seed_2001.py b/tests/code/example_seed_2001.py deleted file mode 100644 index ef6df84..0000000 --- a/tests/code/example_seed_2001.py +++ /dev/null @@ -1,4 +0,0 @@ -import ut - -async def tbi(cun): - return True diff --git a/tests/code/example_seed_2600.py b/tests/code/example_seed_2600.py deleted file mode 100644 index b397005..0000000 --- a/tests/code/example_seed_2600.py +++ /dev/null @@ -1,33 +0,0 @@ -import bvv, fv - -async def itais(o): - - @uFalse - def uu(bxwofy) -> z: - for qt in -61: # type: r - pass - else: - pass - pass - pass - for xbtrktn in ved: # type: qunrvvb - async for rapnibos in -22: # type: smkiic - pass - pass - else: - pass - - @42 - async def xrjrr(hqgocdm, lugpixj) -> True: - pass - pass - filj = 19.61228714796065 - else: - ptfk += False - - @False - @lfeyjbbm - async def wcjqtv(mba, zzcfodhm): - with 42, 78.93854634000706: - pass - pass diff --git a/tests/code/example_seed_3000.py b/tests/code/example_seed_3000.py deleted file mode 100644 index 860cc6c..0000000 --- a/tests/code/example_seed_3000.py +++ /dev/null @@ -1,46 +0,0 @@ -import wujet -import rvg - -async def ttunnj(mhyvmm, wykw): - async with 42: - while -80: - pass - pass - pass - else: - pass - pass - pass - async for fozpvugu in 'cg': # type: srwhek - pass - else: - pass - pass - for s in vg: # type: ks - - @-12 - class jau(-67, 'xxleabq'): - pass - import rr - while True: - pass - pass - pass - else: - pass - pass - pass - else: - while 42: - pass - pass - pass - else: - pass - pass - - @8.479746889556717 - async def vd(f): # type: sa - pass - pass - pass diff --git a/tests/code/example_seed_42.py b/tests/code/example_seed_42.py deleted file mode 100644 index 7a69b21..0000000 --- a/tests/code/example_seed_42.py +++ /dev/null @@ -1,30 +0,0 @@ -import hexd, sn -import hqta - -async def hosizay(): - try: - - @-9 - def gyk() -> False: - pass - mc = utlsg = 42 - except* 86.64836667552697: - pass - lgviwv += False - for yybhbz in icgswkg: - with 42, True: - pass - for xsnsm in 'eqpc': - pass - else: - pass - tcmmtoq += 'v' - else: - - @False - async def yukdj(oax) -> False: # type: uqtge - pass - pass - pass - return 53.937903011962575 - padlzj: 5.792516649418755 = False diff --git a/tests/code/example_seed_666.py b/tests/code/example_seed_666.py deleted file mode 100644 index 5008c9e..0000000 --- a/tests/code/example_seed_666.py +++ /dev/null @@ -1 +0,0 @@ -import z, dbemuo diff --git a/tests/commit/test_commit.py b/tests/commit/test_commit.py index df799ba..a478f58 100644 --- a/tests/commit/test_commit.py +++ b/tests/commit/test_commit.py @@ -3,21 +3,21 @@ Integration tests for CLI validation and functionality. """ -import json + import subprocess import sys from pathlib import Path -import pytest import bb -from tests.conftest import normalize_code_for_test # Helper to run CLI commands -def cli_run(args: list, env: dict = None, cwd: str = None) -> subprocess.CompletedProcess: +def cli_run( + args: list, env: dict = None, cwd: str = None +) -> subprocess.CompletedProcess: """Run bb.py CLI command.""" - cmd = [sys.executable, str(Path(__file__).parent.parent.parent / 'bb.py')] + args + cmd = [sys.executable, str(Path(__file__).parent.parent.parent / "bb.py")] + args return subprocess.run(cmd, capture_output=True, text=True, env=env, cwd=cwd) @@ -25,319 +25,350 @@ def cli_run(args: list, env: dict = None, cwd: str = None) -> subprocess.Complet # Integration tests for commit CLI validation # ============================================================================= + def test_commit_invalid_hash_format_fails(tmp_path): """Test that commit fails with invalid hash format""" - bb_dir = tmp_path / '.bb' - env = {'BB_DIRECTORY': str(bb_dir)} + bb_dir = tmp_path / ".bb" + env = {"BB_DIRECTORY": str(bb_dir)} - result = cli_run(['commit', 'not-a-valid-hash', '--comment', 'test'], env=env) + result = cli_run(["commit", "not-a-valid-hash", "--comment", "test"], env=env) assert result.returncode != 0 - assert 'Invalid hash format' in result.stderr + assert "Invalid hash format" in result.stderr def test_commit_nonexistent_function_fails(tmp_path): """Test that commit fails for nonexistent function""" - bb_dir = tmp_path / '.bb' - env = {'BB_DIRECTORY': str(bb_dir)} - fake_hash = 'f' * 64 + bb_dir = tmp_path / ".bb" + env = {"BB_DIRECTORY": str(bb_dir)} + fake_hash = "f" * 64 - result = cli_run(['commit', fake_hash, '--comment', 'test'], env=env) + result = cli_run(["commit", fake_hash, "--comment", "test"], env=env) assert result.returncode != 0 - assert 'not found' in result.stderr.lower() + assert "not found" in result.stderr.lower() def test_commit_help_shows_usage(): """Test that commit --help shows usage information""" - result = cli_run(['commit', '--help']) + result = cli_run(["commit", "--help"]) assert result.returncode == 0 - assert 'hash' in result.stdout.lower() - assert '--comment' in result.stdout + assert "hash" in result.stdout.lower() + assert "--comment" in result.stdout # ============================================================================= # Integration tests for commit functionality # ============================================================================= + def test_commit_copies_function_to_git_directory(tmp_path): """Test that commit copies function to git directory""" - bb_dir = tmp_path / '.bb' - pool_dir = bb_dir / 'pool' + bb_dir = tmp_path / ".bb" + pool_dir = bb_dir / "pool" pool_dir.mkdir(parents=True, exist_ok=True) - env = {'BB_DIRECTORY': str(bb_dir)} + env = {"BB_DIRECTORY": str(bb_dir)} # Create a test file and add it - test_file = tmp_path / 'test_func.py' - test_file.write_text('''def hello(): + test_file = tmp_path / "test_func.py" + test_file.write_text( + '''def hello(): """Say hello""" return "hello" -''', encoding='utf-8') +''', + encoding="utf-8", + ) # Add function to pool - add_result = cli_run(['add', f'{test_file}@eng'], env=env) + add_result = cli_run(["add", f"{test_file}@eng"], env=env) assert add_result.returncode == 0 # Extract hash from output func_hash = None - for line in add_result.stdout.split('\n'): - if 'Hash:' in line: - func_hash = line.split('Hash:')[1].strip() + for line in add_result.stdout.split("\n"): + if "Hash:" in line: + func_hash = line.split("Hash:")[1].strip() break assert func_hash is not None # Commit the function - result = cli_run(['commit', func_hash, '--comment', 'Add hello function'], env=env) + result = cli_run(["commit", func_hash, "--comment", "Add hello function"], env=env) assert result.returncode == 0 - assert 'Committed' in result.stdout + assert "Committed" in result.stdout # Verify function was copied to git directory - git_dir = bb_dir / 'git' + git_dir = bb_dir / "git" assert git_dir.exists() - func_in_git = git_dir / func_hash[:2] / func_hash[2:] / 'object.json' + func_in_git = git_dir / func_hash[:2] / func_hash[2:] / "object.json" assert func_in_git.exists() def test_commit_copies_all_language_mappings(tmp_path): """Test that commit copies all language mappings""" - bb_dir = tmp_path / '.bb' - pool_dir = bb_dir / 'pool' + bb_dir = tmp_path / ".bb" + pool_dir = bb_dir / "pool" pool_dir.mkdir(parents=True, exist_ok=True) - env = {'BB_DIRECTORY': str(bb_dir)} + env = {"BB_DIRECTORY": str(bb_dir)} # Create English version - test_file_eng = tmp_path / 'hello_eng.py' - test_file_eng.write_text('''def hello(): + test_file_eng = tmp_path / "hello_eng.py" + test_file_eng.write_text( + '''def hello(): """Say hello""" return "hello" -''', encoding='utf-8') +''', + encoding="utf-8", + ) # Create French version (same logic, different names) - test_file_fra = tmp_path / 'bonjour_fra.py' - test_file_fra.write_text('''def bonjour(): + test_file_fra = tmp_path / "bonjour_fra.py" + test_file_fra.write_text( + '''def bonjour(): """Dire bonjour""" return "hello" -''', encoding='utf-8') +''', + encoding="utf-8", + ) # Add both versions - add_eng = cli_run(['add', f'{test_file_eng}@eng'], env=env) + add_eng = cli_run(["add", f"{test_file_eng}@eng"], env=env) assert add_eng.returncode == 0 func_hash = None - for line in add_eng.stdout.split('\n'): - if 'Hash:' in line: - func_hash = line.split('Hash:')[1].strip() + for line in add_eng.stdout.split("\n"): + if "Hash:" in line: + func_hash = line.split("Hash:")[1].strip() break - add_fra = cli_run(['add', f'{test_file_fra}@fra'], env=env) + add_fra = cli_run(["add", f"{test_file_fra}@fra"], env=env) assert add_fra.returncode == 0 # Commit the function - result = cli_run(['commit', func_hash, '--comment', 'Add multilingual hello'], env=env) + result = cli_run( + ["commit", func_hash, "--comment", "Add multilingual hello"], env=env + ) assert result.returncode == 0 # Verify both language mappings were copied - git_dir = bb_dir / 'git' + git_dir = bb_dir / "git" func_git_dir = git_dir / func_hash[:2] / func_hash[2:] - assert (func_git_dir / 'eng').exists() - assert (func_git_dir / 'fra').exists() + assert (func_git_dir / "eng").exists() + assert (func_git_dir / "fra").exists() def test_commit_copies_dependencies_recursively(tmp_path): """Test that commit copies all dependencies recursively""" - bb_dir = tmp_path / '.bb' - pool_dir = bb_dir / 'pool' + bb_dir = tmp_path / ".bb" + pool_dir = bb_dir / "pool" pool_dir.mkdir(parents=True, exist_ok=True) - env = {'BB_DIRECTORY': str(bb_dir)} + env = {"BB_DIRECTORY": str(bb_dir)} # Create helper function - helper_file = tmp_path / 'helper.py' - helper_file.write_text('''def helper(): + helper_file = tmp_path / "helper.py" + helper_file.write_text( + '''def helper(): """Helper function""" return 42 -''', encoding='utf-8') +''', + encoding="utf-8", + ) # Add helper - add_helper = cli_run(['add', f'{helper_file}@eng'], env=env) + add_helper = cli_run(["add", f"{helper_file}@eng"], env=env) assert add_helper.returncode == 0 helper_hash = None - for line in add_helper.stdout.split('\n'): - if 'Hash:' in line: - helper_hash = line.split('Hash:')[1].strip() + for line in add_helper.stdout.split("\n"): + if "Hash:" in line: + helper_hash = line.split("Hash:")[1].strip() break # Create main function that depends on helper - main_file = tmp_path / 'main.py' - main_file.write_text(f'''from bb.pool import object_{helper_hash} as helper + main_file = tmp_path / "main.py" + main_file.write_text( + f'''from bb.pool import object_{helper_hash} as helper def main(): """Main function""" return helper() -''', encoding='utf-8') +''', + encoding="utf-8", + ) # Add main - add_main = cli_run(['add', f'{main_file}@eng'], env=env) + add_main = cli_run(["add", f"{main_file}@eng"], env=env) assert add_main.returncode == 0 main_hash = None - for line in add_main.stdout.split('\n'): - if 'Hash:' in line: - main_hash = line.split('Hash:')[1].strip() + for line in add_main.stdout.split("\n"): + if "Hash:" in line: + main_hash = line.split("Hash:")[1].strip() break # Commit the main function - result = cli_run(['commit', main_hash, '--comment', 'Add main with dependency'], env=env) + result = cli_run( + ["commit", main_hash, "--comment", "Add main with dependency"], env=env + ) assert result.returncode == 0 - assert '2 function(s)' in result.stdout # main + helper + assert "2 function(s)" in result.stdout # main + helper # Verify both functions were copied - git_dir = bb_dir / 'git' - main_in_git = git_dir / main_hash[:2] / main_hash[2:] / 'object.json' - helper_in_git = git_dir / helper_hash[:2] / helper_hash[2:] / 'object.json' + git_dir = bb_dir / "git" + main_in_git = git_dir / main_hash[:2] / main_hash[2:] / "object.json" + helper_in_git = git_dir / helper_hash[:2] / helper_hash[2:] / "object.json" assert main_in_git.exists() assert helper_in_git.exists() def test_commit_initializes_git_repo(tmp_path): """Test that commit initializes git repository if not present""" - bb_dir = tmp_path / '.bb' - pool_dir = bb_dir / 'pool' + bb_dir = tmp_path / ".bb" + pool_dir = bb_dir / "pool" pool_dir.mkdir(parents=True, exist_ok=True) - env = {'BB_DIRECTORY': str(bb_dir)} + env = {"BB_DIRECTORY": str(bb_dir)} # Create and add a function - test_file = tmp_path / 'test.py' - test_file.write_text('''def test(): + test_file = tmp_path / "test.py" + test_file.write_text( + '''def test(): """Test function""" return 1 -''', encoding='utf-8') +''', + encoding="utf-8", + ) - add_result = cli_run(['add', f'{test_file}@eng'], env=env) + add_result = cli_run(["add", f"{test_file}@eng"], env=env) func_hash = None - for line in add_result.stdout.split('\n'): - if 'Hash:' in line: - func_hash = line.split('Hash:')[1].strip() + for line in add_result.stdout.split("\n"): + if "Hash:" in line: + func_hash = line.split("Hash:")[1].strip() break # Commit - result = cli_run(['commit', func_hash, '--comment', 'Initial commit'], env=env) + result = cli_run(["commit", func_hash, "--comment", "Initial commit"], env=env) assert result.returncode == 0 # Verify git was initialized - git_dir = bb_dir / 'git' - assert (git_dir / '.git').exists() + git_dir = bb_dir / "git" + assert (git_dir / ".git").exists() def test_commit_creates_git_commit(tmp_path): """Test that commit creates an actual git commit""" - bb_dir = tmp_path / '.bb' - pool_dir = bb_dir / 'pool' + bb_dir = tmp_path / ".bb" + pool_dir = bb_dir / "pool" pool_dir.mkdir(parents=True, exist_ok=True) - env = {'BB_DIRECTORY': str(bb_dir)} + env = {"BB_DIRECTORY": str(bb_dir)} # Create and add a function - test_file = tmp_path / 'test.py' - test_file.write_text('''def test(): + test_file = tmp_path / "test.py" + test_file.write_text( + '''def test(): """Test function""" return 1 -''', encoding='utf-8') +''', + encoding="utf-8", + ) - add_result = cli_run(['add', f'{test_file}@eng'], env=env) + add_result = cli_run(["add", f"{test_file}@eng"], env=env) func_hash = None - for line in add_result.stdout.split('\n'): - if 'Hash:' in line: - func_hash = line.split('Hash:')[1].strip() + for line in add_result.stdout.split("\n"): + if "Hash:" in line: + func_hash = line.split("Hash:")[1].strip() break # Commit with specific message - commit_msg = 'Test commit message' - result = cli_run(['commit', func_hash, '--comment', commit_msg], env=env) + commit_msg = "Test commit message" + result = cli_run(["commit", func_hash, "--comment", commit_msg], env=env) assert result.returncode == 0 # Verify git log shows the commit - git_dir = bb_dir / 'git' + git_dir = bb_dir / "git" git_log = subprocess.run( - ['git', 'log', '--oneline', '-1'], + ["git", "log", "--oneline", "-1"], cwd=str(git_dir), capture_output=True, - text=True + text=True, ) assert commit_msg in git_log.stdout def test_commit_no_changes_when_already_committed(tmp_path): """Test that commit reports no changes when function already committed""" - bb_dir = tmp_path / '.bb' - pool_dir = bb_dir / 'pool' + bb_dir = tmp_path / ".bb" + pool_dir = bb_dir / "pool" pool_dir.mkdir(parents=True, exist_ok=True) - env = {'BB_DIRECTORY': str(bb_dir)} + env = {"BB_DIRECTORY": str(bb_dir)} # Create and add a function - test_file = tmp_path / 'test.py' - test_file.write_text('''def test(): + test_file = tmp_path / "test.py" + test_file.write_text( + '''def test(): """Test function""" return 1 -''', encoding='utf-8') +''', + encoding="utf-8", + ) - add_result = cli_run(['add', f'{test_file}@eng'], env=env) + add_result = cli_run(["add", f"{test_file}@eng"], env=env) func_hash = None - for line in add_result.stdout.split('\n'): - if 'Hash:' in line: - func_hash = line.split('Hash:')[1].strip() + for line in add_result.stdout.split("\n"): + if "Hash:" in line: + func_hash = line.split("Hash:")[1].strip() break # First commit - cli_run(['commit', func_hash, '--comment', 'First commit'], env=env) + cli_run(["commit", func_hash, "--comment", "First commit"], env=env) # Second commit of same function - result = cli_run(['commit', func_hash, '--comment', 'Second commit'], env=env) + result = cli_run(["commit", func_hash, "--comment", "Second commit"], env=env) assert result.returncode == 0 - assert 'No new changes to commit' in result.stdout + assert "No new changes to commit" in result.stdout # ============================================================================= # Unit tests for commit helper functions # ============================================================================= + def test_storage_get_git_directory(): """Test that storage_get_git_directory returns correct path""" import os # Test with BB_DIRECTORY set - original = os.environ.get('BB_DIRECTORY') + original = os.environ.get("BB_DIRECTORY") try: - os.environ['BB_DIRECTORY'] = '/test/bb' + os.environ["BB_DIRECTORY"] = "/test/bb" result = bb.storage_get_git_directory() - assert result == Path('/test/bb/git') + assert result == Path("/test/bb/git") finally: if original: - os.environ['BB_DIRECTORY'] = original - elif 'BB_DIRECTORY' in os.environ: - del os.environ['BB_DIRECTORY'] + os.environ["BB_DIRECTORY"] = original + elif "BB_DIRECTORY" in os.environ: + del os.environ["BB_DIRECTORY"] def test_git_init_commit_repo_creates_directory(tmp_path, monkeypatch): """Test that git_init_commit_repo creates git directory""" - git_dir = tmp_path / 'git' + git_dir = tmp_path / "git" - monkeypatch.setattr(bb, 'storage_get_git_directory', lambda: git_dir) + monkeypatch.setattr(bb, "storage_get_git_directory", lambda: git_dir) result = bb.git_init_commit_repo() assert result == git_dir assert git_dir.exists() - assert (git_dir / '.git').exists() + assert (git_dir / ".git").exists() def test_git_init_commit_repo_idempotent(tmp_path, monkeypatch): """Test that git_init_commit_repo is idempotent""" - git_dir = tmp_path / 'git' + git_dir = tmp_path / "git" - monkeypatch.setattr(bb, 'storage_get_git_directory', lambda: git_dir) + monkeypatch.setattr(bb, "storage_get_git_directory", lambda: git_dir) # Call twice bb.git_init_commit_repo() result = bb.git_init_commit_repo() assert result == git_dir - assert (git_dir / '.git').exists() + assert (git_dir / ".git").exists() diff --git a/tests/compile/test_compile.py b/tests/compile/test_compile.py index 4cd9849..3ecf2c0 100644 --- a/tests/compile/test_compile.py +++ b/tests/compile/test_compile.py @@ -4,6 +4,7 @@ Unit tests for dependency resolution and bundling (complex low-level aspects). Integration tests for CLI compile command error handling. """ + import pytest import bb @@ -14,27 +15,28 @@ # Integration tests for compile CLI command # ============================================================================= + def test_compile_debug_without_language_fails(cli_runner): """Test that compile --debug fails without language suffix""" - result = cli_runner.run(['compile', '--debug', 'a' * 64]) + result = cli_runner.run(["compile", "--debug", "a" * 64]) assert result.returncode != 0 - assert '--debug requires language suffix' in result.stderr + assert "--debug requires language suffix" in result.stderr def test_compile_invalid_hash_format_fails(cli_runner): """Test that compile fails with invalid hash format""" - result = cli_runner.run(['compile', 'not-a-valid-hash@eng']) + result = cli_runner.run(["compile", "not-a-valid-hash@eng"]) assert result.returncode != 0 - assert 'Invalid hash format' in result.stderr + assert "Invalid hash format" in result.stderr def test_compile_nonexistent_function_fails(cli_runner): """Test that compile fails for nonexistent function""" fake_hash = "f" * 64 - result = cli_runner.run(['compile', f'{fake_hash}@eng']) + result = cli_runner.run(["compile", f"{fake_hash}@eng"]) assert result.returncode != 0 @@ -43,11 +45,11 @@ def test_compile_prepares_bundle(cli_runner, tmp_path): """Test that compile prepares the bundle directory before failing on Nuitka""" # Setup: Add a simple function test_file = tmp_path / "simple.py" - test_file.write_text('def answer(): return 42') - func_hash = cli_runner.add(str(test_file), 'eng') + test_file.write_text("def answer(): return 42") + func_hash = cli_runner.add(str(test_file), "eng") # Test: Run compile (will fail because Nuitka not installed) - result = cli_runner.run(['compile', f'{func_hash}@eng']) + result = cli_runner.run(["compile", f"{func_hash}@eng"]) # Assert: Should fail on Nuitka, not on setup # If it gets to "Nuitka not found" or similar, the bundle prep succeeded @@ -57,10 +59,10 @@ def test_compile_prepares_bundle(cli_runner, tmp_path): def test_compile_too_short_language_code_fails(cli_runner): """Test that compile fails with too short language code""" - result = cli_runner.run(['compile', 'a' * 64 + '@ab']) + result = cli_runner.run(["compile", "a" * 64 + "@ab"]) assert result.returncode != 0 - assert 'Language code must be 3-256 characters' in result.stderr + assert "Language code must be 3-256 characters" in result.stderr # ============================================================================= @@ -110,12 +112,15 @@ def _bb_v_0(): # Unit tests for dependency resolution (complex low-level aspect) # ============================================================================= + def test_dependencies_resolve_no_deps(mock_bb_dir): """Test resolving dependencies for function with no deps""" func_hash = "nodeps01" + "0" * 56 normalized_code = normalize_code_for_test("def _bb_v_0(): return 42") - bb.code_save(func_hash, "eng", normalized_code, "No deps", {"_bb_v_0": "answer"}, {}) + bb.code_save( + func_hash, "eng", normalized_code, "No deps", {"_bb_v_0": "answer"}, {} + ) deps = bb.code_resolve_dependencies(func_hash) @@ -137,7 +142,14 @@ def test_dependencies_resolve_single_dep(mock_bb_dir): def _bb_v_0(): return object_{dep_hash}._bb_v_0() * 2 """) - bb.code_save(main_hash, "eng", main_code, "Main", {"_bb_v_0": "double_helper"}, {dep_hash: "helper"}) + bb.code_save( + main_hash, + "eng", + main_code, + "Main", + {"_bb_v_0": "double_helper"}, + {dep_hash: "helper"}, + ) deps = bb.code_resolve_dependencies(main_hash) @@ -184,7 +196,9 @@ def _bb_v_0(): def _bb_v_0(): return object_{b_hash}._bb_v_0() + object_{c_hash}._bb_v_0() """) - bb.code_save(a_hash, "eng", a_code, "A", {"_bb_v_0": "a"}, {b_hash: "b", c_hash: "c"}) + bb.code_save( + a_hash, "eng", a_code, "A", {"_bb_v_0": "a"}, {b_hash: "b", c_hash: "c"} + ) deps = bb.code_resolve_dependencies(a_hash) @@ -208,7 +222,14 @@ def test_dependencies_resolve_missing_dependency_fails(mock_bb_dir): def _bb_v_0(): return object_{missing_hash}._bb_v_0() """) - bb.code_save(main_hash, "eng", main_code, "Main with missing dep", {"_bb_v_0": "test"}, {missing_hash: "missing"}) + bb.code_save( + main_hash, + "eng", + main_code, + "Main with missing dep", + {"_bb_v_0": "test"}, + {missing_hash: "missing"}, + ) with pytest.raises(ValueError) as exc_info: bb.code_resolve_dependencies(main_hash) @@ -254,11 +275,14 @@ def _bb_v_0(): # Unit tests for bundling (complex low-level aspect) # ============================================================================= + def test_dependencies_bundle(mock_bb_dir, tmp_path): """Test bundling functions to output directory""" func_hash = "bundle01" + "0" * 56 normalized_code = normalize_code_for_test("def _bb_v_0(): return 99") - bb.code_save(func_hash, "eng", normalized_code, "Bundle test", {"_bb_v_0": "test"}, {}) + bb.code_save( + func_hash, "eng", normalized_code, "Bundle test", {"_bb_v_0": "test"}, {} + ) output_dir = tmp_path / "bundle_output" result = bb.code_bundle_dependencies([func_hash], output_dir) @@ -272,9 +296,10 @@ def test_dependencies_bundle(mock_bb_dir, tmp_path): # Unit tests for Nuitka command generation (complex low-level aspect) # ============================================================================= + def test_compile_get_nuitka_command_basic(): """Test generating basic Nuitka command""" - cmd = bb.compile_get_nuitka_command("main.py", "myapp") + cmd = bb.command_compile_get_nuitka_command("main.py", "myapp") assert cmd[0] == "python3" assert cmd[1] == "-m" @@ -288,7 +313,7 @@ def test_compile_get_nuitka_command_basic(): def test_compile_get_nuitka_command_no_onefile(): """Test generating Nuitka command without onefile""" - cmd = bb.compile_get_nuitka_command("main.py", "myapp", onefile=False) + cmd = bb.command_compile_get_nuitka_command("main.py", "myapp", onefile=False) assert "--standalone" in cmd assert "--onefile" not in cmd @@ -298,7 +323,7 @@ def test_compile_get_nuitka_command_no_onefile(): def test_compile_generate_runtime(tmp_path): """Test generating runtime module""" func_hash = "runtime1" + "0" * 56 - runtime_dir = bb.compile_generate_runtime(func_hash, "eng", tmp_path) + runtime_dir = bb.command_compile_generate_runtime(func_hash, "eng", tmp_path) assert runtime_dir.exists() assert (runtime_dir / "__init__.py").exists() @@ -314,6 +339,7 @@ def test_compile_generate_runtime(tmp_path): # Tests for --python mode # ============================================================================= + def test_compile_python_mode_creates_file(cli_runner, tmp_path): """Test that compile --python creates a main.py file""" import os @@ -324,28 +350,28 @@ def test_compile_python_mode_creates_file(cli_runner, tmp_path): """Say hello""" return f"Hello, {name}!" ''') - func_hash = cli_runner.add(str(test_file), 'eng') + func_hash = cli_runner.add(str(test_file), "eng") # Test: Run compile with --python # Change to tmp_path so main.py is created there original_cwd = os.getcwd() os.chdir(tmp_path) try: - result = cli_runner.run(['compile', '--python', f'{func_hash}@eng']) + result = cli_runner.run(["compile", "--python", f"{func_hash}@eng"]) finally: os.chdir(original_cwd) # Assert: Should succeed and create main.py assert result.returncode == 0 - assert 'Python file created: main.py' in result.stdout + assert "Python file created: main.py" in result.stdout - main_py = tmp_path / 'main.py' + main_py = tmp_path / "main.py" assert main_py.exists() # Verify the content (without --debug, uses normalized names) content = main_py.read_text() - assert '#!/usr/bin/env python3' in content - assert 'def _bb_' in content # Normalized function name + assert "#!/usr/bin/env python3" in content + assert "def _bb_" in content # Normalized function name assert 'if __name__ == "__main__":' in content @@ -361,28 +387,26 @@ def test_compile_python_mode_executable(cli_runner, tmp_path): """Double a number""" return x * 2 ''') - func_hash = cli_runner.add(str(test_file), 'eng') + func_hash = cli_runner.add(str(test_file), "eng") # Compile with --python original_cwd = os.getcwd() os.chdir(tmp_path) try: - result = cli_runner.run(['compile', '--python', f'{func_hash}@eng']) + result = cli_runner.run(["compile", "--python", f"{func_hash}@eng"]) finally: os.chdir(original_cwd) assert result.returncode == 0 # Run the compiled file - main_py = tmp_path / 'main.py' + main_py = tmp_path / "main.py" run_result = subprocess.run( - [sys.executable, str(main_py), '21'], - capture_output=True, - text=True + [sys.executable, str(main_py), "21"], capture_output=True, text=True ) assert run_result.returncode == 0 - assert '42' in run_result.stdout + assert "42" in run_result.stdout def test_compile_generate_python(mock_bb_dir): @@ -392,17 +416,21 @@ def test_compile_generate_python(mock_bb_dir): """Test function""" return 123 ''') - bb.code_save(func_hash, "eng", normalized_code, "Test function", {"_bb_v_0": "test_func"}, {}) + bb.code_save( + func_hash, "eng", normalized_code, "Test function", {"_bb_v_0": "test_func"}, {} + ) # Without debug_mode, uses normalized names - python_code = bb.compile_generate_python(func_hash, "eng") - assert '#!/usr/bin/env python3' in python_code - assert 'def _bb_pytest01():' in python_code # Normalized name + python_code = bb.command_compile_generate_python(func_hash, "eng") + assert "#!/usr/bin/env python3" in python_code + assert "def _bb_pytest01():" in python_code # Normalized name assert 'if __name__ == "__main__":' in python_code # With debug_mode=True, uses human-readable names - python_code_debug = bb.compile_generate_python(func_hash, "eng", debug_mode=True) - assert 'def test_func():' in python_code_debug + python_code_debug = bb.command_compile_generate_python( + func_hash, "eng", debug_mode=True + ) + assert "def test_func():" in python_code_debug def test_compile_recursive_function_no_debug(mock_bb_dir): @@ -415,16 +443,22 @@ def test_compile_recursive_function_no_debug(mock_bb_dir): return 1 return _bb_v_1 * _bb_v_0(_bb_v_1 - 1) ''') - bb.code_save(func_hash, "eng", normalized_code, "Calculate factorial", - {"_bb_v_0": "factorial", "_bb_v_1": "n"}, {}) + bb.code_save( + func_hash, + "eng", + normalized_code, + "Calculate factorial", + {"_bb_v_0": "factorial", "_bb_v_1": "n"}, + {}, + ) # Without debug_mode, uses hash-based names - python_code = bb.compile_generate_python(func_hash, "eng") + python_code = bb.command_compile_generate_python(func_hash, "eng") # Both function definition AND recursive call should use the same name - assert 'def _bb_recursiv(' in python_code # Function definition - assert '_bb_recursiv(_bb_v_1 - 1)' in python_code # Recursive call renamed - assert '_bb_v_0' not in python_code # No leftover _bb_v_0 references + assert "def _bb_recursiv(" in python_code # Function definition + assert "_bb_recursiv(_bb_v_1 - 1)" in python_code # Recursive call renamed + assert "_bb_v_0" not in python_code # No leftover _bb_v_0 references def test_compile_recursive_function_debug_mode(mock_bb_dir): @@ -437,13 +471,19 @@ def test_compile_recursive_function_debug_mode(mock_bb_dir): return 1 return _bb_v_1 * _bb_v_0(_bb_v_1 - 1) ''') - bb.code_save(func_hash, "eng", normalized_code, "Calculate factorial", - {"_bb_v_0": "factorial", "_bb_v_1": "n"}, {}) + bb.code_save( + func_hash, + "eng", + normalized_code, + "Calculate factorial", + {"_bb_v_0": "factorial", "_bb_v_1": "n"}, + {}, + ) # With debug_mode=True, uses human-readable names - python_code = bb.compile_generate_python(func_hash, "eng", debug_mode=True) + python_code = bb.command_compile_generate_python(func_hash, "eng", debug_mode=True) # Both function definition AND recursive call should use human-readable name - assert 'def factorial(' in python_code # Function definition - assert 'factorial(n - 1)' in python_code # Recursive call renamed - assert '_bb_v_0' not in python_code # No leftover _bb_v_0 references + assert "def factorial(" in python_code # Function definition + assert "factorial(n - 1)" in python_code # Recursive call renamed + assert "_bb_v_0" not in python_code # No leftover _bb_v_0 references diff --git a/tests/conftest.py b/tests/conftest.py index 91e1eb2..db56be0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,6 +10,7 @@ - Assert: Check CLI output and/or files directly - Unit tests only for complex low-level aspects (AST, hashing, schema, migration) """ + import ast import subprocess import sys @@ -23,9 +24,16 @@ import bb # Export fixtures and helpers -__all__ = ['normalize_code_for_test', 'mock_bb_dir', 'sample_function_code', - 'sample_function_file', 'sample_async_function_code', 'sample_async_function_file', - 'cli_run', 'cli_runner'] +__all__ = [ + "normalize_code_for_test", + "mock_bb_dir", + "sample_function_code", + "sample_function_file", + "sample_async_function_code", + "sample_async_function_file", + "cli_run", + "cli_runner", +] def normalize_code_for_test(code: str) -> str: @@ -57,7 +65,9 @@ def normalize_code_for_test(code: str) -> str: return ast.unparse(tree) -def cli_run(args: list, env: dict = None, cwd: str = None) -> subprocess.CompletedProcess: +def cli_run( + args: list, env: dict = None, cwd: str = None +) -> subprocess.CompletedProcess: """ Run bb.py CLI command. @@ -76,19 +86,13 @@ def cli_run(args: list, env: dict = None, cwd: str = None) -> subprocess.Complet """ import os - cmd = [sys.executable, str(Path(__file__).parent.parent / 'bb.py')] + args + cmd = [sys.executable, str(Path(__file__).parent.parent / "bb.py")] + args run_env = os.environ.copy() if env: run_env.update(env) - return subprocess.run( - cmd, - capture_output=True, - text=True, - env=run_env, - cwd=cwd - ) + return subprocess.run(cmd, capture_output=True, text=True, env=run_env, cwd=cwd) class CLIRunner: @@ -102,10 +106,8 @@ class CLIRunner: def __init__(self, bb_dir: Path): self.bb_dir = bb_dir - self.pool_dir = bb_dir / 'pool' - self.env = { - 'BB_DIRECTORY': str(bb_dir) - } + self.pool_dir = bb_dir / "pool" + self.env = {"BB_DIRECTORY": str(bb_dir)} def run(self, args: list, cwd: str = None) -> subprocess.CompletedProcess: """Run CLI command with this runner's bb directory.""" @@ -113,25 +115,25 @@ def run(self, args: list, cwd: str = None) -> subprocess.CompletedProcess: def add(self, file_path: str, lang: str) -> str: """Add a function and return its hash.""" - result = self.run(['add', f'{file_path}@{lang}']) + result = self.run(["add", f"{file_path}@{lang}"]) if result.returncode != 0: raise RuntimeError(f"add failed: {result.stderr}") # Extract hash from output - for line in result.stdout.split('\n'): - if 'Hash:' in line: - return line.split('Hash:')[1].strip() + for line in result.stdout.split("\n"): + if "Hash:" in line: + return line.split("Hash:")[1].strip() raise RuntimeError(f"Could not find hash in output: {result.stdout}") def show(self, hash_lang: str) -> str: """Show a function and return its code.""" - result = self.run(['show', hash_lang]) + result = self.run(["show", hash_lang]) if result.returncode != 0: raise RuntimeError(f"show failed: {result.stderr}") return result.stdout def get(self, hash_lang: str) -> str: """Get a function and return its code.""" - result = self.run(['get', hash_lang]) + result = self.run(["get", hash_lang]) if result.returncode != 0: raise RuntimeError(f"get failed: {result.stderr}") return result.stdout @@ -148,8 +150,8 @@ def mock_bb_dir(tmp_path, monkeypatch): ├── pool/ # Pool directory └── config.json # Configuration file """ - base_dir = tmp_path / '.bb' - pool_dir = base_dir / 'pool' + base_dir = tmp_path / ".bb" + pool_dir = base_dir / "pool" def _get_temp_bb_dir(): return base_dir @@ -157,8 +159,8 @@ def _get_temp_bb_dir(): def _get_temp_pool_dir(): return pool_dir - monkeypatch.setattr(bb, 'storage_get_bb_directory', _get_temp_bb_dir) - monkeypatch.setattr(bb, 'storage_get_pool_directory', _get_temp_pool_dir) + monkeypatch.setattr(bb, "storage_get_bb_directory", _get_temp_bb_dir) + monkeypatch.setattr(bb, "storage_get_pool_directory", _get_temp_pool_dir) return tmp_path @@ -173,8 +175,8 @@ def cli_runner(tmp_path): ├── pool/ # Pool directory └── config.json # Configuration file """ - bb_dir = tmp_path / '.bb' - pool_dir = bb_dir / 'pool' + bb_dir = tmp_path / ".bb" + pool_dir = bb_dir / "pool" pool_dir.mkdir(parents=True, exist_ok=True) @@ -194,7 +196,7 @@ def sample_function_code(): def sample_function_file(tmp_path, sample_function_code): """Create a temporary file with sample function code.""" test_file = tmp_path / "sample.py" - test_file.write_text(sample_function_code, encoding='utf-8') + test_file.write_text(sample_function_code, encoding="utf-8") return test_file @@ -211,5 +213,5 @@ def sample_async_function_code(): def sample_async_function_file(tmp_path, sample_async_function_code): """Create a temporary file with sample async function code.""" test_file = tmp_path / "async_sample.py" - test_file.write_text(sample_async_function_code, encoding='utf-8') + test_file.write_text(sample_async_function_code, encoding="utf-8") return test_file diff --git a/tests/get/test_get.py b/tests/get/test_get.py index 7131f50..5d33adf 100644 --- a/tests/get/test_get.py +++ b/tests/get/test_get.py @@ -19,15 +19,15 @@ def test_get_returns_denormalized_code(cli_runner, tmp_path): result = data * 2 return result ''') - func_hash = cli_runner.add(str(test_file), 'eng') + func_hash = cli_runner.add(str(test_file), "eng") # Test: Get the function - result = cli_runner.run(['get', f'{func_hash}@eng']) + result = cli_runner.run(["get", f"{func_hash}@eng"]) # Assert: Should show original function name assert result.returncode == 0 - assert 'def process(data):' in result.stdout - assert 'result' in result.stdout + assert "def process(data):" in result.stdout + assert "result" in result.stdout def test_get_preserves_imports(cli_runner, tmp_path): @@ -43,15 +43,15 @@ def load_config(filepath): with path.open() as f: return json.load(f) ''') - func_hash = cli_runner.add(str(test_file), 'eng') + func_hash = cli_runner.add(str(test_file), "eng") # Test - result = cli_runner.run(['get', f'{func_hash}@eng']) + result = cli_runner.run(["get", f"{func_hash}@eng"]) # Assert assert result.returncode == 0 - assert 'import json' in result.stdout - assert 'from pathlib import Path' in result.stdout + assert "import json" in result.stdout + assert "from pathlib import Path" in result.stdout def test_get_multilingual_english(cli_runner, tmp_path): @@ -68,15 +68,15 @@ def test_get_multilingual_english(cli_runner, tmp_path): return f"Hello, {name}!" ''') - func_hash = cli_runner.add(str(eng_file), 'eng') - cli_runner.add(str(fra_file), 'fra') + func_hash = cli_runner.add(str(eng_file), "eng") + cli_runner.add(str(fra_file), "fra") # Test - result = cli_runner.run(['get', f'{func_hash}@eng']) + result = cli_runner.run(["get", f"{func_hash}@eng"]) # Assert assert result.returncode == 0 - assert 'Greet someone in English' in result.stdout + assert "Greet someone in English" in result.stdout def test_get_multilingual_french(cli_runner, tmp_path): @@ -93,26 +93,26 @@ def test_get_multilingual_french(cli_runner, tmp_path): return f"Hello, {name}!" ''') - func_hash = cli_runner.add(str(eng_file), 'eng') - cli_runner.add(str(fra_file), 'fra') + func_hash = cli_runner.add(str(eng_file), "eng") + cli_runner.add(str(fra_file), "fra") # Test - result = cli_runner.run(['get', f'{func_hash}@fra']) + result = cli_runner.run(["get", f"{func_hash}@fra"]) # Assert assert result.returncode == 0 - assert 'Saluer' in result.stdout + assert "Saluer" in result.stdout def test_get_missing_language_suffix_fails(cli_runner, tmp_path): """Test that get fails without language suffix""" # Setup test_file = tmp_path / "test.py" - test_file.write_text('def foo(): pass') - func_hash = cli_runner.add(str(test_file), 'eng') + test_file.write_text("def foo(): pass") + func_hash = cli_runner.add(str(test_file), "eng") # Test - result = cli_runner.run(['get', func_hash]) + result = cli_runner.run(["get", func_hash]) # Assert assert result.returncode != 0 @@ -120,7 +120,7 @@ def test_get_missing_language_suffix_fails(cli_runner, tmp_path): def test_get_invalid_hash_fails(cli_runner): """Test that get fails with invalid hash format""" - result = cli_runner.run(['get', 'not-a-valid-hash@eng']) + result = cli_runner.run(["get", "not-a-valid-hash@eng"]) assert result.returncode != 0 @@ -129,7 +129,7 @@ def test_get_nonexistent_function_fails(cli_runner): """Test that get fails for nonexistent function""" fake_hash = "f" * 64 - result = cli_runner.run(['get', f'{fake_hash}@eng']) + result = cli_runner.run(["get", f"{fake_hash}@eng"]) assert result.returncode != 0 @@ -138,11 +138,11 @@ def test_get_nonexistent_language_fails(cli_runner, tmp_path): """Test that get fails when language doesn't exist""" # Setup: Add function in English only test_file = tmp_path / "eng_only.py" - test_file.write_text('def foo(): pass') - func_hash = cli_runner.add(str(test_file), 'eng') + test_file.write_text("def foo(): pass") + func_hash = cli_runner.add(str(test_file), "eng") # Test: Try to get in Spanish (doesn't exist) - result = cli_runner.run(['get', f'{func_hash}@spa']) + result = cli_runner.run(["get", f"{func_hash}@spa"]) # Assert assert result.returncode != 0 @@ -152,13 +152,13 @@ def test_get_shows_deprecation_warning(cli_runner, tmp_path): """Test that get shows deprecation warning""" # Setup test_file = tmp_path / "func.py" - test_file.write_text('def foo(): pass') - func_hash = cli_runner.add(str(test_file), 'eng') + test_file.write_text("def foo(): pass") + func_hash = cli_runner.add(str(test_file), "eng") # Test - result = cli_runner.run(['get', f'{func_hash}@eng']) + result = cli_runner.run(["get", f"{func_hash}@eng"]) # Assert: Deprecation warning in stderr assert result.returncode == 0 - assert 'deprecated' in result.stderr.lower() - assert 'show' in result.stderr + assert "deprecated" in result.stderr.lower() + assert "show" in result.stderr diff --git a/tests/init/test_init.py b/tests/init/test_init.py index 33565e9..3073ad9 100644 --- a/tests/init/test_init.py +++ b/tests/init/test_init.py @@ -3,165 +3,160 @@ Grey-box integration tests that verify CLI behavior and internal storage state. """ + import json import os import subprocess import sys from pathlib import Path -import pytest - -def cli_run(args: list, env: dict = None, cwd: str = None) -> subprocess.CompletedProcess: +def cli_run( + args: list, env: dict = None, cwd: str = None +) -> subprocess.CompletedProcess: """Run bb.py CLI command.""" - cmd = [sys.executable, str(Path(__file__).parent.parent.parent / 'bb.py')] + args + cmd = [sys.executable, str(Path(__file__).parent.parent.parent / "bb.py")] + args run_env = os.environ.copy() if env: run_env.update(env) - return subprocess.run( - cmd, - capture_output=True, - text=True, - env=run_env, - cwd=cwd - ) + return subprocess.run(cmd, capture_output=True, text=True, env=run_env, cwd=cwd) def test_init_creates_pool_directory(tmp_path): """Test that init creates the pool directory structure.""" - bb_dir = tmp_path / '.bb' - env = {'BB_DIRECTORY': str(bb_dir)} + bb_dir = tmp_path / ".bb" + env = {"BB_DIRECTORY": str(bb_dir)} - result = cli_run(['init'], env=env) + result = cli_run(["init"], env=env) assert result.returncode == 0 - assert (bb_dir / 'pool').exists() - assert (bb_dir / 'pool').is_dir() + assert (bb_dir / "pool").exists() + assert (bb_dir / "pool").is_dir() def test_init_creates_config_file(tmp_path): """Test that init creates config.json with correct structure.""" - bb_dir = tmp_path / '.bb' - env = {'BB_DIRECTORY': str(bb_dir)} + bb_dir = tmp_path / ".bb" + env = {"BB_DIRECTORY": str(bb_dir)} - result = cli_run(['init'], env=env) + result = cli_run(["init"], env=env) assert result.returncode == 0 - config_path = bb_dir / 'config.json' + config_path = bb_dir / "config.json" assert config_path.exists() config = json.loads(config_path.read_text()) - assert 'user' in config - assert 'remotes' in config - assert 'username' in config['user'] - assert 'email' in config['user'] - assert 'public_key' in config['user'] - assert 'languages' in config['user'] - assert config['user']['languages'] == ['eng'] + assert "user" in config + assert "remotes" in config + assert "username" in config["user"] + assert "email" in config["user"] + assert "public_key" in config["user"] + assert "languages" in config["user"] + assert config["user"]["languages"] == ["eng"] def test_init_uses_username_from_environment(tmp_path, monkeypatch): """Test that init uses USER environment variable for username.""" - bb_dir = tmp_path / '.bb' - monkeypatch.setenv('USER', 'testuser123') - env = {'BB_DIRECTORY': str(bb_dir), 'USER': 'testuser123'} + bb_dir = tmp_path / ".bb" + monkeypatch.setenv("USER", "testuser123") + env = {"BB_DIRECTORY": str(bb_dir), "USER": "testuser123"} - result = cli_run(['init'], env=env) + result = cli_run(["init"], env=env) assert result.returncode == 0 - config = json.loads((bb_dir / 'config.json').read_text()) - assert config['user']['username'] == 'testuser123' + config = json.loads((bb_dir / "config.json").read_text()) + assert config["user"]["username"] == "testuser123" def test_init_output_messages(tmp_path): """Test that init outputs correct messages.""" - bb_dir = tmp_path / '.bb' - env = {'BB_DIRECTORY': str(bb_dir)} + bb_dir = tmp_path / ".bb" + env = {"BB_DIRECTORY": str(bb_dir)} - result = cli_run(['init'], env=env) + result = cli_run(["init"], env=env) assert result.returncode == 0 - assert 'Created config file' in result.stdout - assert 'Initialized bb directory' in result.stdout + assert "Created config file" in result.stdout + assert "Initialized bb directory" in result.stdout def test_init_existing_config_not_overwritten(tmp_path): """Test that init does not overwrite existing config.""" - bb_dir = tmp_path / '.bb' + bb_dir = tmp_path / ".bb" bb_dir.mkdir(parents=True) - config_path = bb_dir / 'config.json' + config_path = bb_dir / "config.json" # Create existing config with custom content - existing_config = {'user': {'username': 'existing_user', 'custom': 'value'}} + existing_config = {"user": {"username": "existing_user", "custom": "value"}} config_path.write_text(json.dumps(existing_config)) - env = {'BB_DIRECTORY': str(bb_dir)} - result = cli_run(['init'], env=env) + env = {"BB_DIRECTORY": str(bb_dir)} + result = cli_run(["init"], env=env) assert result.returncode == 0 - assert 'already exists' in result.stdout + assert "already exists" in result.stdout # Verify original config is preserved preserved_config = json.loads(config_path.read_text()) - assert preserved_config['user']['username'] == 'existing_user' - assert preserved_config['user']['custom'] == 'value' + assert preserved_config["user"]["username"] == "existing_user" + assert preserved_config["user"]["custom"] == "value" def test_init_idempotent(tmp_path): """Test that running init twice is safe.""" - bb_dir = tmp_path / '.bb' - env = {'BB_DIRECTORY': str(bb_dir)} + bb_dir = tmp_path / ".bb" + env = {"BB_DIRECTORY": str(bb_dir)} # First init - result1 = cli_run(['init'], env=env) + result1 = cli_run(["init"], env=env) assert result1.returncode == 0 # Get config after first init - config1 = json.loads((bb_dir / 'config.json').read_text()) + config1 = json.loads((bb_dir / "config.json").read_text()) # Second init - result2 = cli_run(['init'], env=env) + result2 = cli_run(["init"], env=env) assert result2.returncode == 0 # Config should be unchanged - config2 = json.loads((bb_dir / 'config.json').read_text()) + config2 = json.loads((bb_dir / "config.json").read_text()) assert config1 == config2 def test_init_creates_empty_remotes(tmp_path): """Test that init creates empty remotes dict.""" - bb_dir = tmp_path / '.bb' - env = {'BB_DIRECTORY': str(bb_dir)} + bb_dir = tmp_path / ".bb" + env = {"BB_DIRECTORY": str(bb_dir)} - result = cli_run(['init'], env=env) + result = cli_run(["init"], env=env) assert result.returncode == 0 - config = json.loads((bb_dir / 'config.json').read_text()) - assert config['remotes'] == {} + config = json.loads((bb_dir / "config.json").read_text()) + assert config["remotes"] == {} def test_init_respects_bb_directory_env(tmp_path): """Test that BB_DIRECTORY env var is respected.""" - custom_dir = tmp_path / 'custom_bb_location' - env = {'BB_DIRECTORY': str(custom_dir)} + custom_dir = tmp_path / "custom_bb_location" + env = {"BB_DIRECTORY": str(custom_dir)} - result = cli_run(['init'], env=env) + result = cli_run(["init"], env=env) assert result.returncode == 0 assert custom_dir.exists() - assert (custom_dir / 'pool').exists() - assert (custom_dir / 'config.json').exists() + assert (custom_dir / "pool").exists() + assert (custom_dir / "config.json").exists() def test_init_creates_parent_directories(tmp_path): """Test that init creates parent directories if they don't exist.""" - nested_dir = tmp_path / 'deeply' / 'nested' / 'path' / '.bb' - env = {'BB_DIRECTORY': str(nested_dir)} + nested_dir = tmp_path / "deeply" / "nested" / "path" / ".bb" + env = {"BB_DIRECTORY": str(nested_dir)} - result = cli_run(['init'], env=env) + result = cli_run(["init"], env=env) assert result.returncode == 0 assert nested_dir.exists() - assert (nested_dir / 'pool').exists() + assert (nested_dir / "pool").exists() diff --git a/tests/integration/test_workflows.py b/tests/integration/test_workflows.py index cee97da..3d6c5ea 100644 --- a/tests/integration/test_workflows.py +++ b/tests/integration/test_workflows.py @@ -3,15 +3,15 @@ Grey-box style tests that exercise complete CLI workflows combining multiple commands. """ -import ast -import pytest +import ast # ============================================================================= # Integration tests for complete CLI workflows # ============================================================================= + def test_workflow_add_show_roundtrip(cli_runner, tmp_path): """Test add then show produces correct output""" test_file = tmp_path / "greet.py" @@ -21,15 +21,15 @@ def test_workflow_add_show_roundtrip(cli_runner, tmp_path): ''') # Add function - func_hash = cli_runner.add(str(test_file), 'eng') + func_hash = cli_runner.add(str(test_file), "eng") # Show function - result = cli_runner.run(['show', f'{func_hash}@eng']) + result = cli_runner.run(["show", f"{func_hash}@eng"]) assert result.returncode == 0 - assert 'def greet' in result.stdout - assert 'name' in result.stdout - assert 'Hello' in result.stdout + assert "def greet" in result.stdout + assert "name" in result.stdout + assert "Hello" in result.stdout def test_workflow_add_get_roundtrip(cli_runner, tmp_path): @@ -41,8 +41,8 @@ def test_workflow_add_get_roundtrip(cli_runner, tmp_path): return result''' test_file.write_text(original_code) - func_hash = cli_runner.add(str(test_file), 'eng') - result = cli_runner.run(['get', f'{func_hash}@eng']) + func_hash = cli_runner.add(str(test_file), "eng") + result = cli_runner.run(["get", f"{func_hash}@eng"]) assert result.returncode == 0 @@ -71,15 +71,15 @@ def test_workflow_multilingual_same_hash(cli_runner, tmp_path): result = first + second return result''') - eng_hash = cli_runner.add(str(eng_file), 'eng') - fra_hash = cli_runner.add(str(fra_file), 'fra') + eng_hash = cli_runner.add(str(eng_file), "eng") + fra_hash = cli_runner.add(str(fra_file), "fra") # Should have the same hash assert eng_hash == fra_hash # Should be able to retrieve in both languages - eng_result = cli_runner.run(['get', f'{eng_hash}@eng']) - fra_result = cli_runner.run(['get', f'{fra_hash}@fra']) + eng_result = cli_runner.run(["get", f"{eng_hash}@eng"]) + fra_result = cli_runner.run(["get", f"{fra_hash}@fra"]) assert eng_result.returncode == 0 assert fra_result.returncode == 0 @@ -99,16 +99,16 @@ def test_workflow_multilingual_get_different_languages(cli_runner, tmp_path): return f"Hello, {name}!" ''') - eng_hash = cli_runner.add(str(eng_file), 'eng') - cli_runner.add(str(fra_file), 'fra') + eng_hash = cli_runner.add(str(eng_file), "eng") + cli_runner.add(str(fra_file), "fra") # Get English version - eng_result = cli_runner.run(['get', f'{eng_hash}@eng']) - assert 'Greet someone in English' in eng_result.stdout + eng_result = cli_runner.run(["get", f"{eng_hash}@eng"]) + assert "Greet someone in English" in eng_result.stdout # Get French version - fra_result = cli_runner.run(['get', f'{eng_hash}@fra']) - assert 'Saluer' in fra_result.stdout + fra_result = cli_runner.run(["get", f"{eng_hash}@fra"]) + assert "Saluer" in fra_result.stdout def test_workflow_function_with_imports(cli_runner, tmp_path): @@ -123,13 +123,13 @@ def analyze(data): return math.sqrt(len(count)) ''') - func_hash = cli_runner.add(str(test_file), 'eng') - result = cli_runner.run(['show', f'{func_hash}@eng']) + func_hash = cli_runner.add(str(test_file), "eng") + result = cli_runner.run(["show", f"{func_hash}@eng"]) assert result.returncode == 0 - assert 'import math' in result.stdout - assert 'from collections import Counter' in result.stdout - assert 'def analyze' in result.stdout + assert "import math" in result.stdout + assert "from collections import Counter" in result.stdout + assert "def analyze" in result.stdout def test_workflow_function_with_bb_import(cli_runner, tmp_path): @@ -141,7 +141,7 @@ def test_workflow_function_with_bb_import(cli_runner, tmp_path): return x * 2 ''') - helper_hash = cli_runner.add(str(helper_file), 'eng') + helper_hash = cli_runner.add(str(helper_file), "eng") # Now add a function that uses the helper main_file = tmp_path / "main.py" @@ -152,54 +152,54 @@ def process(value): return helper(value) + 1 ''') - main_hash = cli_runner.add(str(main_file), 'eng') + main_hash = cli_runner.add(str(main_file), "eng") # Show should restore the import with alias - result = cli_runner.run(['show', f'{main_hash}@eng']) + result = cli_runner.run(["show", f"{main_hash}@eng"]) assert result.returncode == 0 - assert 'from bb.pool import' in result.stdout - assert 'as helper' in result.stdout - assert 'def process' in result.stdout + assert "from bb.pool import" in result.stdout + assert "as helper" in result.stdout + assert "def process" in result.stdout def test_workflow_add_multiple_then_list(cli_runner, tmp_path): """Test adding multiple functions and listing them via log""" # Add three different functions - for i, name in enumerate(['alpha', 'beta', 'gamma']): + for i, name in enumerate(["alpha", "beta", "gamma"]): test_file = tmp_path / f"{name}.py" test_file.write_text(f'''def {name}(): """Function {name}""" return {i} ''') - cli_runner.add(str(test_file), 'eng') + cli_runner.add(str(test_file), "eng") # Log should work (even if empty, shouldn't error) - result = cli_runner.run(['log']) + result = cli_runner.run(["log"]) assert result.returncode == 0 def test_workflow_error_handling_invalid_file(cli_runner): """Test error handling for nonexistent file""" - result = cli_runner.run(['add', '/nonexistent/file.py@eng']) + result = cli_runner.run(["add", "/nonexistent/file.py@eng"]) assert result.returncode != 0 def test_workflow_error_handling_missing_language(cli_runner, tmp_path): """Test error handling for missing language suffix""" test_file = tmp_path / "test.py" - test_file.write_text('def foo(): pass') + test_file.write_text("def foo(): pass") - result = cli_runner.run(['add', str(test_file)]) + result = cli_runner.run(["add", str(test_file)]) assert result.returncode != 0 - assert 'Missing language suffix' in result.stderr + assert "Missing language suffix" in result.stderr def test_workflow_error_handling_invalid_language(cli_runner, tmp_path): """Test error handling for too short language code""" test_file = tmp_path / "test.py" - test_file.write_text('def foo(): pass') + test_file.write_text("def foo(): pass") - result = cli_runner.run(['add', f'{test_file}@ab']) + result = cli_runner.run(["add", f"{test_file}@ab"]) assert result.returncode != 0 - assert 'Language code must be 3-256 characters' in result.stderr + assert "Language code must be 3-256 characters" in result.stderr diff --git a/tests/log/test_log.py b/tests/log/test_log.py index 5fe1f30..ff0d2c0 100644 --- a/tests/log/test_log.py +++ b/tests/log/test_log.py @@ -3,117 +3,110 @@ Grey-box integration tests for pool log display. """ -import json + import os import subprocess import sys from pathlib import Path -import pytest - def cli_run(args: list, env: dict = None) -> subprocess.CompletedProcess: """Run bb.py CLI command.""" - cmd = [sys.executable, str(Path(__file__).parent.parent.parent / 'bb.py')] + args + cmd = [sys.executable, str(Path(__file__).parent.parent.parent / "bb.py")] + args run_env = os.environ.copy() if env: run_env.update(env) - return subprocess.run( - cmd, - capture_output=True, - text=True, - env=run_env - ) + return subprocess.run(cmd, capture_output=True, text=True, env=run_env) def test_log_empty_pool(tmp_path): """Test that log handles empty pool gracefully""" - bb_dir = tmp_path / '.bb' - env = {'BB_DIRECTORY': str(bb_dir)} + bb_dir = tmp_path / ".bb" + env = {"BB_DIRECTORY": str(bb_dir)} - result = cli_run(['log'], env=env) + result = cli_run(["log"], env=env) assert result.returncode == 0 - assert 'No functions in pool' in result.stdout + assert "No functions in pool" in result.stdout def test_log_empty_pool_with_pool_dir(tmp_path): """Test that log handles empty pool directory""" - bb_dir = tmp_path / '.bb' - (bb_dir / 'pool').mkdir(parents=True) - env = {'BB_DIRECTORY': str(bb_dir)} + bb_dir = tmp_path / ".bb" + (bb_dir / "pool").mkdir(parents=True) + env = {"BB_DIRECTORY": str(bb_dir)} - result = cli_run(['log'], env=env) + result = cli_run(["log"], env=env) assert result.returncode == 0 - assert '0 functions' in result.stdout or 'No functions' in result.stdout + assert "0 functions" in result.stdout or "No functions" in result.stdout def test_log_displays_function_info(tmp_path): """Test that log displays function hash, date, and author""" - bb_dir = tmp_path / '.bb' - env = {'BB_DIRECTORY': str(bb_dir)} + bb_dir = tmp_path / ".bb" + env = {"BB_DIRECTORY": str(bb_dir)} # Setup test_file = tmp_path / "func.py" - test_file.write_text('def foo(): pass') - add_result = cli_run(['add', f'{test_file}@eng'], env=env) - func_hash = add_result.stdout.split('Hash:')[1].strip().split()[0] + test_file.write_text("def foo(): pass") + add_result = cli_run(["add", f"{test_file}@eng"], env=env) + func_hash = add_result.stdout.split("Hash:")[1].strip().split()[0] # Test - result = cli_run(['log'], env=env) + result = cli_run(["log"], env=env) # Assert assert result.returncode == 0 assert func_hash in result.stdout - assert 'Date:' in result.stdout - assert 'Author:' in result.stdout + assert "Date:" in result.stdout + assert "Author:" in result.stdout def test_log_shows_header_with_count(tmp_path): """Test that log shows header with function count""" - bb_dir = tmp_path / '.bb' - env = {'BB_DIRECTORY': str(bb_dir)} + bb_dir = tmp_path / ".bb" + env = {"BB_DIRECTORY": str(bb_dir)} # Setup: Add a function test_file = tmp_path / "func.py" - test_file.write_text('def bar(): pass') - cli_run(['add', f'{test_file}@eng'], env=env) + test_file.write_text("def bar(): pass") + cli_run(["add", f"{test_file}@eng"], env=env) # Test - result = cli_run(['log'], env=env) + result = cli_run(["log"], env=env) # Assert assert result.returncode == 0 - assert 'Function Pool Log' in result.stdout - assert '1 functions' in result.stdout + assert "Function Pool Log" in result.stdout + assert "1 functions" in result.stdout def test_log_shows_languages(tmp_path): """Test that log displays available languages""" - bb_dir = tmp_path / '.bb' - env = {'BB_DIRECTORY': str(bb_dir)} + bb_dir = tmp_path / ".bb" + env = {"BB_DIRECTORY": str(bb_dir)} # Setup test_file = tmp_path / "func.py" - test_file.write_text('def foo(): pass') - cli_run(['add', f'{test_file}@eng'], env=env) + test_file.write_text("def foo(): pass") + cli_run(["add", f"{test_file}@eng"], env=env) # Test - result = cli_run(['log'], env=env) + result = cli_run(["log"], env=env) # Assert assert result.returncode == 0 - assert 'Languages:' in result.stdout - assert 'eng' in result.stdout + assert "Languages:" in result.stdout + assert "eng" in result.stdout def test_log_multiple_languages(tmp_path): """Test that log shows multiple languages for same function""" - bb_dir = tmp_path / '.bb' - env = {'BB_DIRECTORY': str(bb_dir)} + bb_dir = tmp_path / ".bb" + env = {"BB_DIRECTORY": str(bb_dir)} # Setup: Add same function in multiple languages test_file = tmp_path / "func.py" @@ -121,41 +114,41 @@ def test_log_multiple_languages(tmp_path): """Hello""" pass ''') - cli_run(['add', f'{test_file}@eng'], env=env) + cli_run(["add", f"{test_file}@eng"], env=env) fra_file = tmp_path / "func_fra.py" fra_file.write_text('''def greet(): """Bonjour""" pass ''') - cli_run(['add', f'{fra_file}@fra'], env=env) + cli_run(["add", f"{fra_file}@fra"], env=env) # Test - result = cli_run(['log'], env=env) + result = cli_run(["log"], env=env) # Assert: Should show both languages assert result.returncode == 0 - assert 'eng' in result.stdout - assert 'fra' in result.stdout + assert "eng" in result.stdout + assert "fra" in result.stdout def test_log_multiple_functions(tmp_path): """Test that log shows multiple functions""" - bb_dir = tmp_path / '.bb' - env = {'BB_DIRECTORY': str(bb_dir)} + bb_dir = tmp_path / ".bb" + env = {"BB_DIRECTORY": str(bb_dir)} # Setup: Add multiple different functions test_file1 = tmp_path / "func1.py" - test_file1.write_text('def one(): return 1') - cli_run(['add', f'{test_file1}@eng'], env=env) + test_file1.write_text("def one(): return 1") + cli_run(["add", f"{test_file1}@eng"], env=env) test_file2 = tmp_path / "func2.py" - test_file2.write_text('def two(): return 2') - cli_run(['add', f'{test_file2}@eng'], env=env) + test_file2.write_text("def two(): return 2") + cli_run(["add", f"{test_file2}@eng"], env=env) # Test - result = cli_run(['log'], env=env) + result = cli_run(["log"], env=env) # Assert assert result.returncode == 0 - assert '2 functions' in result.stdout + assert "2 functions" in result.stdout diff --git a/tests/refactor/test_refactor.py b/tests/refactor/test_refactor.py index 73dd970..4d5ea52 100644 --- a/tests/refactor/test_refactor.py +++ b/tests/refactor/test_refactor.py @@ -3,99 +3,93 @@ Grey-box integration tests for hash replacement in functions. """ + import os import subprocess import sys from pathlib import Path -import pytest - def cli_run(args: list, env: dict = None) -> subprocess.CompletedProcess: """Run bb.py CLI command.""" - cmd = [sys.executable, str(Path(__file__).parent.parent.parent / 'bb.py')] + args + cmd = [sys.executable, str(Path(__file__).parent.parent.parent / "bb.py")] + args run_env = os.environ.copy() if env: run_env.update(env) - return subprocess.run( - cmd, - capture_output=True, - text=True, - env=run_env - ) + return subprocess.run(cmd, capture_output=True, text=True, env=run_env) def test_refactor_invalid_what_hash_fails(tmp_path): """Test that refactor fails with invalid what hash""" - bb_dir = tmp_path / '.bb' - env = {'BB_DIRECTORY': str(bb_dir)} + bb_dir = tmp_path / ".bb" + env = {"BB_DIRECTORY": str(bb_dir)} - fake_from = 'a' * 64 - fake_to = 'b' * 64 - result = cli_run(['refactor', 'invalid-hash', fake_from, fake_to], env=env) + fake_from = "a" * 64 + fake_to = "b" * 64 + result = cli_run(["refactor", "invalid-hash", fake_from, fake_to], env=env) assert result.returncode != 0 - assert 'Invalid' in result.stderr - assert 'what' in result.stderr.lower() + assert "Invalid" in result.stderr + assert "what" in result.stderr.lower() def test_refactor_invalid_from_hash_fails(tmp_path): """Test that refactor fails with invalid from hash""" - bb_dir = tmp_path / '.bb' - env = {'BB_DIRECTORY': str(bb_dir)} + bb_dir = tmp_path / ".bb" + env = {"BB_DIRECTORY": str(bb_dir)} - fake_what = 'a' * 64 - fake_to = 'b' * 64 - result = cli_run(['refactor', fake_what, 'invalid', fake_to], env=env) + fake_what = "a" * 64 + fake_to = "b" * 64 + result = cli_run(["refactor", fake_what, "invalid", fake_to], env=env) assert result.returncode != 0 - assert 'Invalid' in result.stderr + assert "Invalid" in result.stderr def test_refactor_invalid_to_hash_fails(tmp_path): """Test that refactor fails with invalid to hash""" - bb_dir = tmp_path / '.bb' - env = {'BB_DIRECTORY': str(bb_dir)} + bb_dir = tmp_path / ".bb" + env = {"BB_DIRECTORY": str(bb_dir)} - fake_what = 'a' * 64 - fake_from = 'b' * 64 - result = cli_run(['refactor', fake_what, fake_from, 'invalid'], env=env) + fake_what = "a" * 64 + fake_from = "b" * 64 + result = cli_run(["refactor", fake_what, fake_from, "invalid"], env=env) assert result.returncode != 0 - assert 'Invalid' in result.stderr + assert "Invalid" in result.stderr def test_refactor_nonexistent_what_function_fails(tmp_path): """Test that refactor fails when what function doesn't exist""" - bb_dir = tmp_path / '.bb' - (bb_dir / 'pool').mkdir(parents=True) - env = {'BB_DIRECTORY': str(bb_dir)} + bb_dir = tmp_path / ".bb" + (bb_dir / "pool").mkdir(parents=True) + env = {"BB_DIRECTORY": str(bb_dir)} - fake_what = 'a' * 64 - fake_from = 'b' * 64 - fake_to = 'c' * 64 - result = cli_run(['refactor', fake_what, fake_from, fake_to], env=env) + fake_what = "a" * 64 + fake_from = "b" * 64 + fake_to = "c" * 64 + result = cli_run(["refactor", fake_what, fake_from, fake_to], env=env) assert result.returncode != 0 - assert 'not found' in result.stderr.lower() + assert "not found" in result.stderr.lower() def test_refactor_nonexistent_to_function_fails(tmp_path): """Test that refactor fails when to function doesn't exist""" - bb_dir = tmp_path / '.bb' - env = {'BB_DIRECTORY': str(bb_dir)} + bb_dir = tmp_path / ".bb" + env = {"BB_DIRECTORY": str(bb_dir)} # Setup: Add a function (what) test_file = tmp_path / "func.py" - test_file.write_text('def foo(): return 42') - add_result = cli_run(['add', f'{test_file}@eng'], env=env) - what_hash = add_result.stdout.split('Hash:')[1].strip().split()[0] + test_file.write_text("def foo(): return 42") + add_result = cli_run(["add", f"{test_file}@eng"], env=env) + what_hash = add_result.stdout.split("Hash:")[1].strip().split()[0] - fake_from = 'b' * 64 - fake_to = 'c' * 64 - result = cli_run(['refactor', what_hash, fake_from, fake_to], env=env) + fake_from = "b" * 64 + fake_to = "c" * 64 + result = cli_run(["refactor", what_hash, fake_from, fake_to], env=env) assert result.returncode != 0 - assert 'not found' in result.stderr.lower() + assert "not found" in result.stderr.lower() diff --git a/tests/remote/test_git_remote.py b/tests/remote/test_git_remote.py index a17163f..40c77d5 100644 --- a/tests/remote/test_git_remote.py +++ b/tests/remote/test_git_remote.py @@ -4,6 +4,7 @@ Unit tests for low-level git operations (URL parsing, type detection). Integration tests for CLI commands (remote add/list/remove/pull/push). """ + import json import pytest @@ -16,6 +17,7 @@ # Unit tests for low-level git operations # ============================================================================= + def test_remote_type_detect_file(): """Test detecting file:// remote type""" assert bb.git_detect_remote_type("file:///path/to/pool") == "file" @@ -28,7 +30,9 @@ def test_remote_type_detect_git_ssh(): def test_remote_type_detect_git_https(): """Test detecting git HTTPS remote type""" - assert bb.git_detect_remote_type("git+https://github.com/user/repo.git") == "git-https" + assert ( + bb.git_detect_remote_type("git+https://github.com/user/repo.git") == "git-https" + ) def test_remote_type_detect_git_file(): @@ -44,23 +48,23 @@ def test_remote_type_detect_unknown(): def test_git_url_parse_ssh(): """Test parsing SSH Git URL""" result = bb.git_url_parse("git@github.com:user/repo.git") - assert result['protocol'] == 'ssh' - assert result['host'] == 'github.com' - assert result['git_url'] == 'git@github.com:user/repo.git' + assert result["protocol"] == "ssh" + assert result["host"] == "github.com" + assert result["git_url"] == "git@github.com:user/repo.git" def test_git_url_parse_https(): """Test parsing HTTPS Git URL""" result = bb.git_url_parse("git+https://github.com/user/repo.git") - assert result['protocol'] == 'https' - assert result['git_url'] == 'https://github.com/user/repo.git' + assert result["protocol"] == "https" + assert result["git_url"] == "https://github.com/user/repo.git" def test_git_url_parse_file(): """Test parsing file Git URL""" result = bb.git_url_parse("git+file:///home/user/repo") - assert result['protocol'] == 'file' - assert result['git_url'] == 'file:///home/user/repo' + assert result["protocol"] == "file" + assert result["git_url"] == "file:///home/user/repo" def test_git_url_parse_invalid(): @@ -73,42 +77,45 @@ def test_git_url_parse_invalid(): # Integration tests for remote CLI commands # ============================================================================= + def test_remote_add_file(cli_runner, tmp_path): """Test adding a file:// remote via CLI""" remote_path = tmp_path / "remote_pool" remote_path.mkdir() - result = cli_runner.run(['remote', 'add', 'local', f'file://{remote_path}']) + result = cli_runner.run(["remote", "add", "local", f"file://{remote_path}"]) assert result.returncode == 0 - assert 'Added remote' in result.stdout - assert 'local' in result.stdout + assert "Added remote" in result.stdout + assert "local" in result.stdout def test_remote_add_git_ssh(cli_runner): """Test adding a git SSH remote via CLI""" - result = cli_runner.run(['remote', 'add', 'origin', 'git@github.com:user/pool.git']) + result = cli_runner.run(["remote", "add", "origin", "git@github.com:user/pool.git"]) assert result.returncode == 0 - assert 'Added remote' in result.stdout - assert 'git-ssh' in result.stdout + assert "Added remote" in result.stdout + assert "git-ssh" in result.stdout def test_remote_add_git_https(cli_runner): """Test adding a git HTTPS remote via CLI""" - result = cli_runner.run(['remote', 'add', 'upstream', 'git+https://github.com/org/pool.git']) + result = cli_runner.run( + ["remote", "add", "upstream", "git+https://github.com/org/pool.git"] + ) assert result.returncode == 0 - assert 'Added remote' in result.stdout - assert 'git-https' in result.stdout + assert "Added remote" in result.stdout + assert "git-https" in result.stdout def test_remote_add_invalid_url_fails(cli_runner): """Test adding invalid URL format fails""" - result = cli_runner.run(['remote', 'add', 'bad', 'ftp://invalid']) + result = cli_runner.run(["remote", "add", "bad", "ftp://invalid"]) assert result.returncode != 0 - assert 'Invalid URL format' in result.stderr + assert "Invalid URL format" in result.stderr def test_remote_add_duplicate_fails(cli_runner, tmp_path): @@ -116,19 +123,19 @@ def test_remote_add_duplicate_fails(cli_runner, tmp_path): remote_path = tmp_path / "remote" remote_path.mkdir() - cli_runner.run(['remote', 'add', 'dup', f'file://{remote_path}']) - result = cli_runner.run(['remote', 'add', 'dup', f'file://{remote_path}']) + cli_runner.run(["remote", "add", "dup", f"file://{remote_path}"]) + result = cli_runner.run(["remote", "add", "dup", f"file://{remote_path}"]) assert result.returncode != 0 - assert 'already exists' in result.stderr + assert "already exists" in result.stderr def test_remote_list_empty(cli_runner): """Test listing remotes when none configured""" - result = cli_runner.run(['remote', 'list']) + result = cli_runner.run(["remote", "list"]) assert result.returncode == 0 - assert 'No remotes configured' in result.stdout + assert "No remotes configured" in result.stdout def test_remote_list_shows_remotes(cli_runner, tmp_path): @@ -136,11 +143,11 @@ def test_remote_list_shows_remotes(cli_runner, tmp_path): remote_path = tmp_path / "remote" remote_path.mkdir() - cli_runner.run(['remote', 'add', 'myremote', f'file://{remote_path}']) - result = cli_runner.run(['remote', 'list']) + cli_runner.run(["remote", "add", "myremote", f"file://{remote_path}"]) + result = cli_runner.run(["remote", "list"]) assert result.returncode == 0 - assert 'myremote' in result.stdout + assert "myremote" in result.stdout def test_remote_remove(cli_runner, tmp_path): @@ -148,19 +155,19 @@ def test_remote_remove(cli_runner, tmp_path): remote_path = tmp_path / "remote" remote_path.mkdir() - cli_runner.run(['remote', 'add', 'toremove', f'file://{remote_path}']) - result = cli_runner.run(['remote', 'remove', 'toremove']) + cli_runner.run(["remote", "add", "toremove", f"file://{remote_path}"]) + result = cli_runner.run(["remote", "remove", "toremove"]) assert result.returncode == 0 - assert 'Removed remote' in result.stdout + assert "Removed remote" in result.stdout def test_remote_remove_nonexistent_fails(cli_runner): """Test removing nonexistent remote fails""" - result = cli_runner.run(['remote', 'remove', 'doesnotexist']) + result = cli_runner.run(["remote", "remove", "doesnotexist"]) assert result.returncode != 0 - assert 'not found' in result.stderr + assert "not found" in result.stderr def test_remote_pull_file(cli_runner, tmp_path): @@ -168,60 +175,68 @@ def test_remote_pull_file(cli_runner, tmp_path): # Setup: Create remote pool with a function (structure: remote_pool/XX/YYYY.../object.json) remote_pool = tmp_path / "remote_pool" remote_pool.mkdir() - remote_objects = remote_pool / "ab" # No 'pool' subdirectory - git dir has XX/YYYY structure + remote_objects = ( + remote_pool / "ab" + ) # No 'pool' subdirectory - git dir has XX/YYYY structure remote_objects.mkdir(parents=True) # Create a minimal v1 function with all required fields # Hash is 64 hex chars: prefix (2) + rest (62) func_hash = "ab" + "0" * 62 # 64 chars total - func_dir = remote_objects / ("0" * 62) # Directory is remaining 62 chars after prefix + func_dir = remote_objects / ( + "0" * 62 + ) # Directory is remaining 62 chars after prefix func_dir.mkdir() - (func_dir / "object.json").write_text(json.dumps({ - "schema_version": 1, - "hash": func_hash, - "normalized_code": normalize_code_for_test("def _bb_v_0(): pass"), - "metadata": {"created": "2025-01-01T00:00:00Z", "author": "test"} - })) + (func_dir / "object.json").write_text( + json.dumps( + { + "schema_version": 1, + "hash": func_hash, + "normalized_code": normalize_code_for_test("def _bb_v_0(): pass"), + "metadata": {"created": "2025-01-01T00:00:00Z", "author": "test"}, + } + ) + ) # Add remote and pull - cli_runner.run(['remote', 'add', 'source', f'file://{remote_pool}']) - result = cli_runner.run(['remote', 'pull', 'source']) + cli_runner.run(["remote", "add", "source", f"file://{remote_pool}"]) + result = cli_runner.run(["remote", "pull", "source"]) assert result.returncode == 0 - assert 'Pulling from remote' in result.stdout + assert "Pulling from remote" in result.stdout def test_remote_push_file(cli_runner, tmp_path): """Test pushing to file:// remote""" # Setup: Add a function to local pool test_file = tmp_path / "func.py" - test_file.write_text('def foo(): pass') - func_hash = cli_runner.add(str(test_file), 'eng') + test_file.write_text("def foo(): pass") + func_hash = cli_runner.add(str(test_file), "eng") # Commit the function to git directory - result = cli_runner.run(['commit', func_hash, '--comment', 'test']) + result = cli_runner.run(["commit", func_hash, "--comment", "test"]) assert result.returncode == 0 # Create remote and push remote_pool = tmp_path / "remote_pool" - cli_runner.run(['remote', 'add', 'dest', f'file://{remote_pool}']) - result = cli_runner.run(['remote', 'push', 'dest']) + cli_runner.run(["remote", "add", "dest", f"file://{remote_pool}"]) + result = cli_runner.run(["remote", "push", "dest"]) assert result.returncode == 0 - assert 'Pushing to remote' in result.stdout + assert "Pushing to remote" in result.stdout def test_remote_pull_nonexistent_fails(cli_runner): """Test pulling from nonexistent remote fails""" - result = cli_runner.run(['remote', 'pull', 'noremote']) + result = cli_runner.run(["remote", "pull", "noremote"]) assert result.returncode != 0 - assert 'not found' in result.stderr + assert "not found" in result.stderr def test_remote_push_nonexistent_fails(cli_runner): """Test pushing to nonexistent remote fails""" - result = cli_runner.run(['remote', 'push', 'noremote']) + result = cli_runner.run(["remote", "push", "noremote"]) assert result.returncode != 0 - assert 'not found' in result.stderr + assert "not found" in result.stderr diff --git a/tests/review/test_review.py b/tests/review/test_review.py index 6500d13..b8fd4ad 100644 --- a/tests/review/test_review.py +++ b/tests/review/test_review.py @@ -4,110 +4,109 @@ Grey-box integration tests for function review with dependency resolution. Note: review is now interactive, so some tests use stdin injection. """ + import json import os import subprocess import sys from pathlib import Path -import pytest - -def cli_run(args: list, env: dict = None, input_text: str = None) -> subprocess.CompletedProcess: +def cli_run( + args: list, env: dict = None, input_text: str = None +) -> subprocess.CompletedProcess: """Run bb.py CLI command with optional stdin input.""" - cmd = [sys.executable, str(Path(__file__).parent.parent.parent / 'bb.py')] + args + cmd = [sys.executable, str(Path(__file__).parent.parent.parent / "bb.py")] + args run_env = os.environ.copy() if env: run_env.update(env) return subprocess.run( - cmd, - capture_output=True, - text=True, - env=run_env, - input=input_text + cmd, capture_output=True, text=True, env=run_env, input=input_text ) def test_review_invalid_hash_fails(tmp_path): """Test that review fails with invalid hash format""" - bb_dir = tmp_path / '.bb' - env = {'BB_DIRECTORY': str(bb_dir)} + bb_dir = tmp_path / ".bb" + env = {"BB_DIRECTORY": str(bb_dir)} - result = cli_run(['review', 'not-a-valid-hash'], env=env) + result = cli_run(["review", "not-a-valid-hash"], env=env) assert result.returncode != 0 - assert 'Invalid hash format' in result.stderr + assert "Invalid hash format" in result.stderr def test_review_nonexistent_function_warns(tmp_path): """Test that review warns for nonexistent function""" - bb_dir = tmp_path / '.bb' - (bb_dir / 'pool').mkdir(parents=True) - env = {'BB_DIRECTORY': str(bb_dir)} + bb_dir = tmp_path / ".bb" + (bb_dir / "pool").mkdir(parents=True) + env = {"BB_DIRECTORY": str(bb_dir)} - fake_hash = 'f' * 64 - result = cli_run(['review', fake_hash], env=env) + fake_hash = "f" * 64 + result = cli_run(["review", fake_hash], env=env) # Review continues but warns about missing function - assert 'not found' in result.stderr.lower() or 'not available' in result.stderr.lower() + assert ( + "not found" in result.stderr.lower() or "not available" in result.stderr.lower() + ) def test_review_displays_function_code(tmp_path): """Test that review displays function code""" - bb_dir = tmp_path / '.bb' - env = {'BB_DIRECTORY': str(bb_dir)} + bb_dir = tmp_path / ".bb" + env = {"BB_DIRECTORY": str(bb_dir)} # Setup - cli_run(['init'], env=env) + cli_run(["init"], env=env) test_file = tmp_path / "func.py" test_file.write_text('''def process(data): """Process some data""" return data * 2 ''') - add_result = cli_run(['add', f'{test_file}@eng'], env=env) - func_hash = add_result.stdout.split('Hash:')[1].strip().split()[0] + add_result = cli_run(["add", f"{test_file}@eng"], env=env) + func_hash = add_result.stdout.split("Hash:")[1].strip().split()[0] # Test - provide 'y' to approve the function - result = cli_run(['review', func_hash], env=env, input_text='y\n') + result = cli_run(["review", func_hash], env=env, input_text="y\n") # Assert assert result.returncode == 0 - assert 'Function: process (eng)' in result.stdout - assert f'Hash: {func_hash}' in result.stdout - assert 'def process(data):' in result.stdout - assert 'Dependencies: None' in result.stdout + assert "Function: process (eng)" in result.stdout + assert f"Hash: {func_hash}" in result.stdout + assert "def process(data):" in result.stdout + assert "Dependencies: None" in result.stdout def test_review_shows_function_review_header(tmp_path): """Test that review shows proper header""" - bb_dir = tmp_path / '.bb' - env = {'BB_DIRECTORY': str(bb_dir)} + bb_dir = tmp_path / ".bb" + env = {"BB_DIRECTORY": str(bb_dir)} # Setup - cli_run(['init'], env=env) + cli_run(["init"], env=env) test_file = tmp_path / "func.py" - test_file.write_text('def foo(): pass') - add_result = cli_run(['add', f'{test_file}@eng'], env=env) - func_hash = add_result.stdout.split('Hash:')[1].strip().split()[0] + test_file.write_text("def foo(): pass") + add_result = cli_run(["add", f"{test_file}@eng"], env=env) + func_hash = add_result.stdout.split("Hash:")[1].strip().split()[0] # Test - provide 'y' to approve - result = cli_run(['review', func_hash], env=env, input_text='y\n') + result = cli_run(["review", func_hash], env=env, input_text="y\n") # Assert assert result.returncode == 0 - assert 'Interactive Function Review' in result.stdout + assert "Interactive Function Review" in result.stdout def test_review_uses_preferred_language(tmp_path): """Test that review uses user's preferred languages""" - bb_dir = tmp_path / '.bb' - env = {'BB_DIRECTORY': str(bb_dir)} + bb_dir = tmp_path / ".bb" + env = {"BB_DIRECTORY": str(bb_dir)} # Setup: Initialize and set French as preferred language - cli_run(['init'], env=env) - cli_run(['whoami', 'language', 'fra'], env=env) + cli_run(["init"], env=env) + cli_run(["whoami", "language", "fra"], env=env) # Add function in French test_file = tmp_path / "func.py" @@ -115,124 +114,124 @@ def test_review_uses_preferred_language(tmp_path): """Calculer le resultat""" return valeur * 2 ''') - add_result = cli_run(['add', f'{test_file}@fra'], env=env) - func_hash = add_result.stdout.split('Hash:')[1].strip().split()[0] + add_result = cli_run(["add", f"{test_file}@fra"], env=env) + func_hash = add_result.stdout.split("Hash:")[1].strip().split()[0] # Test - provide 'y' to approve - result = cli_run(['review', func_hash], env=env, input_text='y\n') + result = cli_run(["review", func_hash], env=env, input_text="y\n") # Assert: Should show French version assert result.returncode == 0 - assert 'calculer (fra)' in result.stdout - assert 'Calculer le resultat' in result.stdout + assert "calculer (fra)" in result.stdout + assert "Calculer le resultat" in result.stdout def test_review_fallback_when_language_unavailable(tmp_path): """Test that review warns when function not in preferred language""" - bb_dir = tmp_path / '.bb' - env = {'BB_DIRECTORY': str(bb_dir)} + bb_dir = tmp_path / ".bb" + env = {"BB_DIRECTORY": str(bb_dir)} # Setup: Initialize with Spanish as preferred language - cli_run(['init'], env=env) - cli_run(['whoami', 'language', 'spa'], env=env) + cli_run(["init"], env=env) + cli_run(["whoami", "language", "spa"], env=env) # Add function in English only test_file = tmp_path / "func.py" - test_file.write_text('def foo(): pass') - add_result = cli_run(['add', f'{test_file}@eng'], env=env) - func_hash = add_result.stdout.split('Hash:')[1].strip().split()[0] + test_file.write_text("def foo(): pass") + add_result = cli_run(["add", f"{test_file}@eng"], env=env) + func_hash = add_result.stdout.split("Hash:")[1].strip().split()[0] # Test - run will exit early due to no matching language - result = cli_run(['review', func_hash], env=env, input_text='y\n') + result = cli_run(["review", func_hash], env=env, input_text="y\n") # Assert: Should warn about unavailable language - assert 'not available in any preferred language' in result.stderr + assert "not available in any preferred language" in result.stderr def test_review_default_language_fallback(tmp_path): """Test that review falls back to 'eng' when no preferred languages set""" - bb_dir = tmp_path / '.bb' - env = {'BB_DIRECTORY': str(bb_dir)} + bb_dir = tmp_path / ".bb" + env = {"BB_DIRECTORY": str(bb_dir)} # Setup: Add function without init (no preferred languages) test_file = tmp_path / "func.py" - test_file.write_text('def foo(): pass') - add_result = cli_run(['add', f'{test_file}@eng'], env=env) - func_hash = add_result.stdout.split('Hash:')[1].strip().split()[0] + test_file.write_text("def foo(): pass") + add_result = cli_run(["add", f"{test_file}@eng"], env=env) + func_hash = add_result.stdout.split("Hash:")[1].strip().split()[0] # Test: Review without init (config doesn't exist) - provide 'y' - result = cli_run(['review', func_hash], env=env, input_text='y\n') + result = cli_run(["review", func_hash], env=env, input_text="y\n") # Assert: Should still work using 'eng' as default assert result.returncode == 0 - assert 'foo (eng)' in result.stdout + assert "foo (eng)" in result.stdout def test_review_saves_state(tmp_path): """Test that review saves reviewed functions to state file""" - bb_dir = tmp_path / '.bb' - env = {'BB_DIRECTORY': str(bb_dir)} + bb_dir = tmp_path / ".bb" + env = {"BB_DIRECTORY": str(bb_dir)} # Setup - cli_run(['init'], env=env) + cli_run(["init"], env=env) test_file = tmp_path / "func.py" - test_file.write_text('def bar(): pass') - add_result = cli_run(['add', f'{test_file}@eng'], env=env) - func_hash = add_result.stdout.split('Hash:')[1].strip().split()[0] + test_file.write_text("def bar(): pass") + add_result = cli_run(["add", f"{test_file}@eng"], env=env) + func_hash = add_result.stdout.split("Hash:")[1].strip().split()[0] # Test - approve the function - result = cli_run(['review', func_hash], env=env, input_text='y\n') + result = cli_run(["review", func_hash], env=env, input_text="y\n") # Assert: State file should contain the approved hash assert result.returncode == 0 - assert 'approved' in result.stdout.lower() + assert "approved" in result.stdout.lower() - state_file = bb_dir / 'review_state.json' + state_file = bb_dir / "review_state.json" assert state_file.exists() - with open(state_file, 'r') as f: + with open(state_file, "r") as f: state = json.load(f) - assert func_hash in state['reviewed'] + assert func_hash in state["reviewed"] def test_review_skips_already_reviewed(tmp_path): """Test that review skips already reviewed functions""" - bb_dir = tmp_path / '.bb' - env = {'BB_DIRECTORY': str(bb_dir)} + bb_dir = tmp_path / ".bb" + env = {"BB_DIRECTORY": str(bb_dir)} # Setup - cli_run(['init'], env=env) + cli_run(["init"], env=env) test_file = tmp_path / "func.py" - test_file.write_text('def baz(): pass') - add_result = cli_run(['add', f'{test_file}@eng'], env=env) - func_hash = add_result.stdout.split('Hash:')[1].strip().split()[0] + test_file.write_text("def baz(): pass") + add_result = cli_run(["add", f"{test_file}@eng"], env=env) + func_hash = add_result.stdout.split("Hash:")[1].strip().split()[0] # First review - approve - cli_run(['review', func_hash], env=env, input_text='y\n') + cli_run(["review", func_hash], env=env, input_text="y\n") # Second review - should skip - result = cli_run(['review', func_hash], env=env) + result = cli_run(["review", func_hash], env=env) # Assert: Should say all already reviewed assert result.returncode == 0 - assert 'already been reviewed' in result.stdout + assert "already been reviewed" in result.stdout def test_review_quit_saves_progress(tmp_path): """Test that 'q' quits review and saves progress""" - bb_dir = tmp_path / '.bb' - env = {'BB_DIRECTORY': str(bb_dir)} + bb_dir = tmp_path / ".bb" + env = {"BB_DIRECTORY": str(bb_dir)} # Setup - cli_run(['init'], env=env) + cli_run(["init"], env=env) test_file = tmp_path / "func.py" - test_file.write_text('def qux(): pass') - add_result = cli_run(['add', f'{test_file}@eng'], env=env) - func_hash = add_result.stdout.split('Hash:')[1].strip().split()[0] + test_file.write_text("def qux(): pass") + add_result = cli_run(["add", f"{test_file}@eng"], env=env) + func_hash = add_result.stdout.split("Hash:")[1].strip().split()[0] # Test - quit without approving - result = cli_run(['review', func_hash], env=env, input_text='q\n') + result = cli_run(["review", func_hash], env=env, input_text="q\n") # Assert: Should indicate paused assert result.returncode == 0 - assert 'paused' in result.stdout.lower() or 'Review paused' in result.stdout + assert "paused" in result.stdout.lower() or "Review paused" in result.stdout diff --git a/tests/run/test_run.py b/tests/run/test_run.py index 2012f6d..fe2a827 100644 --- a/tests/run/test_run.py +++ b/tests/run/test_run.py @@ -3,35 +3,28 @@ Grey-box integration tests for function execution. """ -import json + import os import subprocess import sys from pathlib import Path -import pytest - def cli_run(args: list, env: dict = None) -> subprocess.CompletedProcess: """Run bb.py CLI command.""" - cmd = [sys.executable, str(Path(__file__).parent.parent.parent / 'bb.py')] + args + cmd = [sys.executable, str(Path(__file__).parent.parent.parent / "bb.py")] + args run_env = os.environ.copy() if env: run_env.update(env) - return subprocess.run( - cmd, - capture_output=True, - text=True, - env=run_env - ) + return subprocess.run(cmd, capture_output=True, text=True, env=run_env) def test_run_without_language_works(tmp_path): """Test that run works without language suffix when function exists""" - bb_dir = tmp_path / '.bb' - env = {'BB_DIRECTORY': str(bb_dir)} + bb_dir = tmp_path / ".bb" + env = {"BB_DIRECTORY": str(bb_dir)} # Setup: Add a function first test_file = tmp_path / "func.py" @@ -39,34 +32,34 @@ def test_run_without_language_works(tmp_path): """Greet someone""" return f"Hello, {name}!" ''') - add_result = cli_run(['add', f'{test_file}@eng'], env=env) - func_hash = add_result.stdout.split('Hash:')[1].strip().split()[0] + add_result = cli_run(["add", f"{test_file}@eng"], env=env) + func_hash = add_result.stdout.split("Hash:")[1].strip().split()[0] # Test: Run without @lang - result = cli_run(['run', func_hash, '--', 'World'], env=env) + result = cli_run(["run", func_hash, "--", "World"], env=env) # Assert: Should succeed assert result.returncode == 0 - assert 'Hello, World!' in result.stdout + assert "Hello, World!" in result.stdout def test_run_without_language_nonexistent_fails(tmp_path): """Test that run fails without language suffix when function doesn't exist""" - bb_dir = tmp_path / '.bb' - (bb_dir / 'pool').mkdir(parents=True) - env = {'BB_DIRECTORY': str(bb_dir)} + bb_dir = tmp_path / ".bb" + (bb_dir / "pool").mkdir(parents=True) + env = {"BB_DIRECTORY": str(bb_dir)} - fake_hash = '0' * 64 - result = cli_run(['run', fake_hash], env=env) + fake_hash = "0" * 64 + result = cli_run(["run", fake_hash], env=env) assert result.returncode != 0 - assert 'No language mappings found' in result.stderr + assert "No language mappings found" in result.stderr def test_run_debug_requires_language(tmp_path): """Test that run --debug requires language suffix""" - bb_dir = tmp_path / '.bb' - env = {'BB_DIRECTORY': str(bb_dir)} + bb_dir = tmp_path / ".bb" + env = {"BB_DIRECTORY": str(bb_dir)} # Setup: Add a function first test_file = tmp_path / "func.py" @@ -74,57 +67,60 @@ def test_run_debug_requires_language(tmp_path): """Greet someone""" return f"Hello, {name}!" ''') - add_result = cli_run(['add', f'{test_file}@eng'], env=env) - func_hash = add_result.stdout.split('Hash:')[1].strip().split()[0] + add_result = cli_run(["add", f"{test_file}@eng"], env=env) + func_hash = add_result.stdout.split("Hash:")[1].strip().split()[0] # Test: Run --debug without @lang - result = cli_run(['run', '--debug', func_hash], env=env) + result = cli_run(["run", "--debug", func_hash], env=env) # Assert: Should fail requiring language assert result.returncode != 0 - assert 'Language suffix required when using --debug' in result.stderr + assert "Language suffix required when using --debug" in result.stderr def test_run_invalid_language_fails(tmp_path): """Test that run fails with too short language code""" - bb_dir = tmp_path / '.bb' - env = {'BB_DIRECTORY': str(bb_dir)} + bb_dir = tmp_path / ".bb" + env = {"BB_DIRECTORY": str(bb_dir)} - fake_hash = '0' * 64 - result = cli_run(['run', f'{fake_hash}@ab'], env=env) + fake_hash = "0" * 64 + result = cli_run(["run", f"{fake_hash}@ab"], env=env) assert result.returncode != 0 - assert 'Language code must be 3-256 characters' in result.stderr + assert "Language code must be 3-256 characters" in result.stderr def test_run_invalid_hash_fails(tmp_path): """Test that run fails with invalid hash format""" - bb_dir = tmp_path / '.bb' - env = {'BB_DIRECTORY': str(bb_dir)} + bb_dir = tmp_path / ".bb" + env = {"BB_DIRECTORY": str(bb_dir)} - result = cli_run(['run', 'not-valid-hash@eng'], env=env) + result = cli_run(["run", "not-valid-hash@eng"], env=env) assert result.returncode != 0 - assert 'Invalid hash format' in result.stderr + assert "Invalid hash format" in result.stderr def test_run_nonexistent_function_fails(tmp_path): """Test that run fails for nonexistent function""" - bb_dir = tmp_path / '.bb' - (bb_dir / 'pool').mkdir(parents=True) - env = {'BB_DIRECTORY': str(bb_dir)} + bb_dir = tmp_path / ".bb" + (bb_dir / "pool").mkdir(parents=True) + env = {"BB_DIRECTORY": str(bb_dir)} - fake_hash = 'f' * 64 - result = cli_run(['run', f'{fake_hash}@eng'], env=env) + fake_hash = "f" * 64 + result = cli_run(["run", f"{fake_hash}@eng"], env=env) assert result.returncode != 0 - assert 'Could not load function' in result.stderr or 'not found' in result.stderr.lower() + assert ( + "Could not load function" in result.stderr + or "not found" in result.stderr.lower() + ) def test_run_with_string_argument(tmp_path): """Test running function with string argument""" - bb_dir = tmp_path / '.bb' - env = {'BB_DIRECTORY': str(bb_dir)} + bb_dir = tmp_path / ".bb" + env = {"BB_DIRECTORY": str(bb_dir)} # Setup test_file = tmp_path / "func.py" @@ -132,21 +128,21 @@ def test_run_with_string_argument(tmp_path): """Greet someone""" return f"Hello, {name}!" ''') - add_result = cli_run(['add', f'{test_file}@eng'], env=env) - func_hash = add_result.stdout.split('Hash:')[1].strip().split()[0] + add_result = cli_run(["add", f"{test_file}@eng"], env=env) + func_hash = add_result.stdout.split("Hash:")[1].strip().split()[0] # Test - arguments are passed as strings, no implicit coercion - result = cli_run(['run', f'{func_hash}@eng', '--', 'World'], env=env) + result = cli_run(["run", f"{func_hash}@eng", "--", "World"], env=env) # Assert assert result.returncode == 0 - assert 'Hello, World!' in result.stdout + assert "Hello, World!" in result.stdout def test_run_with_multiple_string_arguments(tmp_path): """Test running function with multiple string arguments (no implicit coercion)""" - bb_dir = tmp_path / '.bb' - env = {'BB_DIRECTORY': str(bb_dir)} + bb_dir = tmp_path / ".bb" + env = {"BB_DIRECTORY": str(bb_dir)} # Setup - function concatenates strings test_file = tmp_path / "func.py" @@ -154,21 +150,21 @@ def test_run_with_multiple_string_arguments(tmp_path): """Concatenate two strings""" return a + b ''') - add_result = cli_run(['add', f'{test_file}@eng'], env=env) - func_hash = add_result.stdout.split('Hash:')[1].strip().split()[0] + add_result = cli_run(["add", f"{test_file}@eng"], env=env) + func_hash = add_result.stdout.split("Hash:")[1].strip().split()[0] # Test - arguments passed as strings - result = cli_run(['run', f'{func_hash}@eng', '--', 'Hello', 'World'], env=env) + result = cli_run(["run", f"{func_hash}@eng", "--", "Hello", "World"], env=env) # Assert assert result.returncode == 0 - assert 'HelloWorld' in result.stdout + assert "HelloWorld" in result.stdout def test_run_displays_function_code(tmp_path): """Test that run displays the function code""" - bb_dir = tmp_path / '.bb' - env = {'BB_DIRECTORY': str(bb_dir)} + bb_dir = tmp_path / ".bb" + env = {"BB_DIRECTORY": str(bb_dir)} # Setup test_file = tmp_path / "func.py" @@ -176,22 +172,22 @@ def test_run_displays_function_code(tmp_path): """Process value""" return value + 1 ''') - add_result = cli_run(['add', f'{test_file}@eng'], env=env) - func_hash = add_result.stdout.split('Hash:')[1].strip().split()[0] + add_result = cli_run(["add", f"{test_file}@eng"], env=env) + func_hash = add_result.stdout.split("Hash:")[1].strip().split()[0] # Test - result = cli_run(['run', f'{func_hash}@eng', '--', '10'], env=env) + result = cli_run(["run", f"{func_hash}@eng", "--", "10"], env=env) # Assert assert result.returncode == 0 - assert 'def my_func(value):' in result.stdout - assert 'Running function: my_func' in result.stdout + assert "def my_func(value):" in result.stdout + assert "Running function: my_func" in result.stdout def test_run_function_with_exception(tmp_path): """Test that run handles function exceptions gracefully""" - bb_dir = tmp_path / '.bb' - env = {'BB_DIRECTORY': str(bb_dir)} + bb_dir = tmp_path / ".bb" + env = {"BB_DIRECTORY": str(bb_dir)} # Setup: Function that raises exception test_file = tmp_path / "func.py" @@ -199,12 +195,12 @@ def test_run_function_with_exception(tmp_path): """Divide a by b""" return a / b ''') - add_result = cli_run(['add', f'{test_file}@eng'], env=env) - func_hash = add_result.stdout.split('Hash:')[1].strip().split()[0] + add_result = cli_run(["add", f"{test_file}@eng"], env=env) + func_hash = add_result.stdout.split("Hash:")[1].strip().split()[0] # Test: Division by zero - result = cli_run(['run', f'{func_hash}@eng', '--', '10', '0'], env=env) + result = cli_run(["run", f"{func_hash}@eng", "--", "10", "0"], env=env) # Assert: Should fail with error message assert result.returncode != 0 - assert 'Error' in result.stderr or 'ZeroDivisionError' in result.stderr + assert "Error" in result.stderr or "ZeroDivisionError" in result.stderr diff --git a/tests/search/test_search.py b/tests/search/test_search.py index 9380305..787208b 100644 --- a/tests/search/test_search.py +++ b/tests/search/test_search.py @@ -3,55 +3,49 @@ Grey-box integration tests for function search. """ + import os import subprocess import sys from pathlib import Path -import pytest - def cli_run(args: list, env: dict = None) -> subprocess.CompletedProcess: """Run bb.py CLI command.""" - cmd = [sys.executable, str(Path(__file__).parent.parent.parent / 'bb.py')] + args + cmd = [sys.executable, str(Path(__file__).parent.parent.parent / "bb.py")] + args run_env = os.environ.copy() if env: run_env.update(env) - return subprocess.run( - cmd, - capture_output=True, - text=True, - env=run_env - ) + return subprocess.run(cmd, capture_output=True, text=True, env=run_env) def test_search_no_query_fails(tmp_path): """Test that search fails without query""" - bb_dir = tmp_path / '.bb' - env = {'BB_DIRECTORY': str(bb_dir)} + bb_dir = tmp_path / ".bb" + env = {"BB_DIRECTORY": str(bb_dir)} - result = cli_run(['search'], env=env) + result = cli_run(["search"], env=env) assert result.returncode != 0 def test_search_empty_pool(tmp_path): """Test that search handles empty pool gracefully""" - bb_dir = tmp_path / '.bb' - env = {'BB_DIRECTORY': str(bb_dir)} + bb_dir = tmp_path / ".bb" + env = {"BB_DIRECTORY": str(bb_dir)} - result = cli_run(['search', 'foo'], env=env) + result = cli_run(["search", "foo"], env=env) assert result.returncode == 0 - assert 'No functions in pool' in result.stdout + assert "No functions in pool" in result.stdout def test_search_finds_by_function_name(tmp_path): """Test that search finds function by docstring content (name in docstring)""" - bb_dir = tmp_path / '.bb' - env = {'BB_DIRECTORY': str(bb_dir)} + bb_dir = tmp_path / ".bb" + env = {"BB_DIRECTORY": str(bb_dir)} # Setup: Include search term in docstring since names are normalized test_file = tmp_path / "func.py" @@ -59,21 +53,21 @@ def test_search_finds_by_function_name(tmp_path): """Calculate the average of numbers""" return sum(numbers) / len(numbers) ''') - cli_run(['add', f'{test_file}@eng'], env=env) + cli_run(["add", f"{test_file}@eng"], env=env) # Test: Search for term in docstring - result = cli_run(['search', 'calculate'], env=env) + result = cli_run(["search", "calculate"], env=env) # Assert assert result.returncode == 0 - assert 'calculate_average' in result.stdout - assert 'docstring' in result.stdout.lower() + assert "calculate_average" in result.stdout + assert "docstring" in result.stdout.lower() def test_search_finds_by_docstring(tmp_path): """Test that search finds function by docstring content""" - bb_dir = tmp_path / '.bb' - env = {'BB_DIRECTORY': str(bb_dir)} + bb_dir = tmp_path / ".bb" + env = {"BB_DIRECTORY": str(bb_dir)} # Setup test_file = tmp_path / "func.py" @@ -81,21 +75,21 @@ def test_search_finds_by_docstring(tmp_path): """Transform the input data using special algorithm""" return data * 2 ''') - cli_run(['add', f'{test_file}@eng'], env=env) + cli_run(["add", f"{test_file}@eng"], env=env) # Test - result = cli_run(['search', 'algorithm'], env=env) + result = cli_run(["search", "algorithm"], env=env) # Assert assert result.returncode == 0 - assert 'process' in result.stdout - assert 'docstring' in result.stdout.lower() + assert "process" in result.stdout + assert "docstring" in result.stdout.lower() def test_search_case_insensitive(tmp_path): """Test that search is case insensitive""" - bb_dir = tmp_path / '.bb' - env = {'BB_DIRECTORY': str(bb_dir)} + bb_dir = tmp_path / ".bb" + env = {"BB_DIRECTORY": str(bb_dir)} # Setup: Put searchable term in docstring test_file = tmp_path / "func.py" @@ -103,38 +97,38 @@ def test_search_case_insensitive(tmp_path): """MySpecialFunction docstring""" pass ''') - cli_run(['add', f'{test_file}@eng'], env=env) + cli_run(["add", f"{test_file}@eng"], env=env) # Test: Search with different case - result = cli_run(['search', 'MYSPECIALFUNCTION'], env=env) + result = cli_run(["search", "MYSPECIALFUNCTION"], env=env) # Assert assert result.returncode == 0 - assert '1 matches' in result.stdout + assert "1 matches" in result.stdout def test_search_no_matches(tmp_path): """Test that search handles no matches gracefully""" - bb_dir = tmp_path / '.bb' - env = {'BB_DIRECTORY': str(bb_dir)} + bb_dir = tmp_path / ".bb" + env = {"BB_DIRECTORY": str(bb_dir)} # Setup: Add a function test_file = tmp_path / "func.py" - test_file.write_text('def foo(): pass') - cli_run(['add', f'{test_file}@eng'], env=env) + test_file.write_text("def foo(): pass") + cli_run(["add", f"{test_file}@eng"], env=env) # Test: Search for non-existent term - result = cli_run(['search', 'nonexistent'], env=env) + result = cli_run(["search", "nonexistent"], env=env) # Assert assert result.returncode == 0 - assert '0 matches' in result.stdout + assert "0 matches" in result.stdout def test_search_shows_view_command(tmp_path): """Test that search results include view command""" - bb_dir = tmp_path / '.bb' - env = {'BB_DIRECTORY': str(bb_dir)} + bb_dir = tmp_path / ".bb" + env = {"BB_DIRECTORY": str(bb_dir)} # Setup: Put searchable term in docstring test_file = tmp_path / "func.py" @@ -142,21 +136,21 @@ def test_search_shows_view_command(tmp_path): """A searchable docstring""" pass ''') - add_result = cli_run(['add', f'{test_file}@eng'], env=env) - func_hash = add_result.stdout.split('Hash:')[1].strip().split()[0] + add_result = cli_run(["add", f"{test_file}@eng"], env=env) + func_hash = add_result.stdout.split("Hash:")[1].strip().split()[0] # Test - result = cli_run(['search', 'searchable'], env=env) + result = cli_run(["search", "searchable"], env=env) # Assert assert result.returncode == 0 - assert f'bb.py show {func_hash}@eng' in result.stdout + assert f"bb.py show {func_hash}@eng" in result.stdout def test_search_multiple_terms(tmp_path): """Test that search works with multiple terms""" - bb_dir = tmp_path / '.bb' - env = {'BB_DIRECTORY': str(bb_dir)} + bb_dir = tmp_path / ".bb" + env = {"BB_DIRECTORY": str(bb_dir)} # Setup test_file = tmp_path / "func.py" @@ -164,11 +158,11 @@ def test_search_multiple_terms(tmp_path): """Calculate total value""" return sum(items) ''') - cli_run(['add', f'{test_file}@eng'], env=env) + cli_run(["add", f"{test_file}@eng"], env=env) # Test: Multiple search terms - result = cli_run(['search', 'calculate', 'total'], env=env) + result = cli_run(["search", "calculate", "total"], env=env) # Assert assert result.returncode == 0 - assert 'calculate_total' in result.stdout + assert "calculate_total" in result.stdout diff --git a/tests/show/test_show.py b/tests/show/test_show.py index ca9d1d4..b7016bf 100644 --- a/tests/show/test_show.py +++ b/tests/show/test_show.py @@ -16,17 +16,17 @@ def test_show_displays_denormalized_code(cli_runner, tmp_path): """Say hello""" return f"Hello, {name}!" ''') - func_hash = cli_runner.add(str(test_file), 'eng') + func_hash = cli_runner.add(str(test_file), "eng") # Test: Show the function - result = cli_runner.run(['show', f'{func_hash}@eng']) + result = cli_runner.run(["show", f"{func_hash}@eng"]) # Assert: Should show original function name assert result.returncode == 0 - assert 'def greet(name):' in result.stdout - assert 'Hello' in result.stdout + assert "def greet(name):" in result.stdout + assert "Hello" in result.stdout # Should NOT show normalized names - assert '_bb_v_0' not in result.stdout + assert "_bb_v_0" not in result.stdout def test_show_displays_docstring(cli_runner, tmp_path): @@ -45,15 +45,15 @@ def test_show_displays_docstring(cli_runner, tmp_path): total = sum(numbers) return total / len(numbers) ''') - func_hash = cli_runner.add(str(test_file), 'eng') + func_hash = cli_runner.add(str(test_file), "eng") # Test - result = cli_runner.run(['show', f'{func_hash}@eng']) + result = cli_runner.run(["show", f"{func_hash}@eng"]) # Assert assert result.returncode == 0 - assert 'Calculate the average' in result.stdout - assert 'arithmetic mean' in result.stdout + assert "Calculate the average" in result.stdout + assert "arithmetic mean" in result.stdout def test_show_async_function(cli_runner, tmp_path): @@ -65,14 +65,14 @@ def test_show_async_function(cli_runner, tmp_path): response = await http_get(url) return response ''') - func_hash = cli_runner.add(str(test_file), 'eng') + func_hash = cli_runner.add(str(test_file), "eng") # Test - result = cli_runner.run(['show', f'{func_hash}@eng']) + result = cli_runner.run(["show", f"{func_hash}@eng"]) # Assert assert result.returncode == 0 - assert 'async def fetch_data' in result.stdout + assert "async def fetch_data" in result.stdout def test_show_function_with_imports(cli_runner, tmp_path): @@ -85,15 +85,15 @@ def circle_area(radius): """Calculate area of a circle""" return math.pi * radius ** 2 ''') - func_hash = cli_runner.add(str(test_file), 'eng') + func_hash = cli_runner.add(str(test_file), "eng") # Test - result = cli_runner.run(['show', f'{func_hash}@eng']) + result = cli_runner.run(["show", f"{func_hash}@eng"]) # Assert assert result.returncode == 0 - assert 'import math' in result.stdout - assert 'def circle_area(radius):' in result.stdout + assert "import math" in result.stdout + assert "def circle_area(radius):" in result.stdout def test_show_multilang_english(cli_runner, tmp_path): @@ -112,15 +112,15 @@ def test_show_multilang_english(cli_runner, tmp_path): return result ''') - eng_hash = cli_runner.add(str(eng_file), 'eng') - cli_runner.add(str(fra_file), 'fra') + eng_hash = cli_runner.add(str(eng_file), "eng") + cli_runner.add(str(fra_file), "fra") # Test: Show English version - result = cli_runner.run(['show', f'{eng_hash}@eng']) + result = cli_runner.run(["show", f"{eng_hash}@eng"]) # Assert: Should show English docstring assert result.returncode == 0 - assert 'Multiply value by factor' in result.stdout + assert "Multiply value by factor" in result.stdout def test_show_multilang_french(cli_runner, tmp_path): @@ -139,39 +139,39 @@ def test_show_multilang_french(cli_runner, tmp_path): return result ''') - eng_hash = cli_runner.add(str(eng_file), 'eng') - cli_runner.add(str(fra_file), 'fra') + eng_hash = cli_runner.add(str(eng_file), "eng") + cli_runner.add(str(fra_file), "fra") # Test: Show French version - result = cli_runner.run(['show', f'{eng_hash}@fra']) + result = cli_runner.run(["show", f"{eng_hash}@fra"]) # Assert: Should show French docstring assert result.returncode == 0 - assert 'Multiplier valeur par facteur' in result.stdout + assert "Multiplier valeur par facteur" in result.stdout def test_show_without_language_lists_languages(cli_runner, tmp_path): """Test that show without @lang lists available languages""" # Setup test_file = tmp_path / "test.py" - test_file.write_text('def foo(): pass') - func_hash = cli_runner.add(str(test_file), 'eng') + test_file.write_text("def foo(): pass") + func_hash = cli_runner.add(str(test_file), "eng") # Test: Run without @lang - result = cli_runner.run(['show', func_hash]) + result = cli_runner.run(["show", func_hash]) # Assert: Should list available languages assert result.returncode == 0 - assert 'Available languages' in result.stdout - assert 'eng' in result.stdout - assert '1 mapping(s)' in result.stdout + assert "Available languages" in result.stdout + assert "eng" in result.stdout + assert "1 mapping(s)" in result.stdout def test_show_nonexistent_function_fails(cli_runner): """Test that show fails for nonexistent function""" fake_hash = "0" * 64 - result = cli_runner.run(['show', f'{fake_hash}@eng']) + result = cli_runner.run(["show", f"{fake_hash}@eng"]) assert result.returncode != 0 @@ -180,11 +180,11 @@ def test_show_nonexistent_language_fails(cli_runner, tmp_path): """Test that show fails when language doesn't exist for function""" # Setup: Add function in English only test_file = tmp_path / "eng_only.py" - test_file.write_text('def foo(): pass') - func_hash = cli_runner.add(str(test_file), 'eng') + test_file.write_text("def foo(): pass") + func_hash = cli_runner.add(str(test_file), "eng") # Test: Try to show in French (doesn't exist) - result = cli_runner.run(['show', f'{func_hash}@fra']) + result = cli_runner.run(["show", f"{func_hash}@fra"]) # Assert: Should fail assert result.returncode != 0 @@ -200,22 +200,22 @@ def test_show_multiple_mappings_shows_menu(cli_runner, tmp_path): ''') # Add with two different comments (creates two mappings) - result1 = cli_runner.run(['add', f'{test_file}@eng', '--comment', 'first version']) + result1 = cli_runner.run(["add", f"{test_file}@eng", "--comment", "first version"]) assert result1.returncode == 0 - result2 = cli_runner.run(['add', f'{test_file}@eng', '--comment', 'second version']) + result2 = cli_runner.run(["add", f"{test_file}@eng", "--comment", "second version"]) assert result2.returncode == 0 # Extract hash - func_hash = result1.stdout.split('Hash:')[1].strip().split()[0] + func_hash = result1.stdout.split("Hash:")[1].strip().split()[0] # Test: Show function with multiple mappings - result = cli_runner.run(['show', f'{func_hash}@eng']) + result = cli_runner.run(["show", f"{func_hash}@eng"]) # Assert: Should show menu with options assert result.returncode == 0 - assert 'Multiple mappings found' in result.stdout - assert 'first version' in result.stdout - assert 'second version' in result.stdout + assert "Multiple mappings found" in result.stdout + assert "first version" in result.stdout + assert "second version" in result.stdout def test_show_explicit_mapping_hash(cli_runner, tmp_path): @@ -228,34 +228,34 @@ def test_show_explicit_mapping_hash(cli_runner, tmp_path): ''') # Add with comment - result1 = cli_runner.run(['add', f'{test_file}@eng', '--comment', 'target version']) - func_hash = result1.stdout.split('Hash:')[1].strip().split()[0] - mapping_hash = result1.stdout.split('Mapping hash:')[1].strip().split()[0] + result1 = cli_runner.run(["add", f"{test_file}@eng", "--comment", "target version"]) + func_hash = result1.stdout.split("Hash:")[1].strip().split()[0] + mapping_hash = result1.stdout.split("Mapping hash:")[1].strip().split()[0] # Test: Show with explicit mapping hash - result = cli_runner.run(['show', f'{func_hash}@eng@{mapping_hash}']) + result = cli_runner.run(["show", f"{func_hash}@eng@{mapping_hash}"]) # Assert: Should show the code directly assert result.returncode == 0 - assert 'def foo():' in result.stdout - assert 'Test function' in result.stdout + assert "def foo():" in result.stdout + assert "Test function" in result.stdout def test_show_invalid_hash_format_fails(cli_runner): """Test that show fails with invalid hash format""" - result = cli_runner.run(['show', 'not-valid-hash@eng']) + result = cli_runner.run(["show", "not-valid-hash@eng"]) assert result.returncode != 0 - assert 'Invalid hash format' in result.stderr + assert "Invalid hash format" in result.stderr def test_show_invalid_language_code_fails(cli_runner, tmp_path): """Test that show fails with too short language code""" test_file = tmp_path / "func.py" - test_file.write_text('def foo(): pass') - func_hash = cli_runner.add(str(test_file), 'eng') + test_file.write_text("def foo(): pass") + func_hash = cli_runner.add(str(test_file), "eng") - result = cli_runner.run(['show', f'{func_hash}@ab']) + result = cli_runner.run(["show", f"{func_hash}@ab"]) assert result.returncode != 0 - assert 'Language code must be 3-256 characters' in result.stderr + assert "Language code must be 3-256 characters" in result.stderr diff --git a/tests/storage/test_bytes.py b/tests/storage/test_bytes.py index 86ea92d..6f036ba 100644 --- a/tests/storage/test_bytes.py +++ b/tests/storage/test_bytes.py @@ -3,65 +3,65 @@ Tests order-preserving encoding of Python values to bytes and back. """ -import pytest -from bb import bytes_write, bytes_read, bytes_next +from bb import storage_bytes_write, storage_bytes_read, storage_bytes_next # ============================================================================ -# Tests for bytes_write and bytes_read +# Tests for storage_bytes_write and storage_bytes_read # ============================================================================ -def test_bytes_write_read_empty_tuple(): + +def test_storage_bytes_write_read_empty_tuple(): """Test encoding/decoding empty tuple""" original = () - encoded = bytes_write(original) - decoded = bytes_read(encoded) + encoded = storage_bytes_write(original) + decoded = storage_bytes_read(encoded) assert decoded == original -def test_bytes_write_read_single_element(): +def test_storage_bytes_write_read_single_element(): """Test encoding/decoding single element tuple""" - original = ('hello',) - encoded = bytes_write(original) - decoded = bytes_read(encoded) + original = ("hello",) + encoded = storage_bytes_write(original) + decoded = storage_bytes_read(encoded) assert decoded == original -def test_bytes_write_read_mixed_types(): +def test_storage_bytes_write_read_mixed_types(): """Test encoding/decoding tuple with mixed types""" - original = ('hello', 42, 3.14, True, None) - encoded = bytes_write(original) - decoded = bytes_read(encoded) + original = ("hello", 42, 3.14, True, None) + encoded = storage_bytes_write(original) + decoded = storage_bytes_read(encoded) assert decoded == original -def test_bytes_write_read_nested_tuple(): +def test_storage_bytes_write_read_nested_tuple(): """Test encoding/decoding tuple with nested tuple""" - original = ('user123', 'metadata', ('tag1', 'tag2', 'tag3')) - encoded = bytes_write(original) - decoded = bytes_read(encoded) + original = ("user123", "metadata", ("tag1", "tag2", "tag3")) + encoded = storage_bytes_write(original) + decoded = storage_bytes_read(encoded) assert decoded == original -def test_bytes_write_read_triple(): +def test_storage_bytes_write_read_triple(): """Test encoding/decoding 3-tuple (common nstore case)""" - original = ('P4X432', 'blog/title', 'hyper.dev') - encoded = bytes_write(original) - decoded = bytes_read(encoded) + original = ("P4X432", "blog/title", "hyper.dev") + encoded = storage_bytes_write(original) + decoded = storage_bytes_read(encoded) assert decoded == original -def test_bytes_write_read_unicode(): +def test_storage_bytes_write_read_unicode(): """Test encoding/decoding tuple with unicode""" - original = ('user', 'name', '你好世界') - encoded = bytes_write(original) - decoded = bytes_read(encoded) + original = ("user", "name", "你好世界") + encoded = storage_bytes_write(original) + decoded = storage_bytes_read(encoded) assert decoded == original @@ -70,30 +70,31 @@ def test_bytes_write_read_unicode(): # Tests for order preservation # ============================================================================ -def test_bytes_write_order_strings(): + +def test_storage_bytes_write_order_strings(): """Test that encoded strings preserve lexicographic order""" - values = [('apple',), ('banana',), ('cherry',)] - encoded = [bytes_write(v) for v in values] + values = [("apple",), ("banana",), ("cherry",)] + encoded = [storage_bytes_write(v) for v in values] # Encoded values should maintain order assert encoded[0] < encoded[1] < encoded[2] -def test_bytes_write_order_integers(): +def test_storage_bytes_write_order_integers(): """Test that encoded integers preserve numeric order""" values = [(1,), (42,), (100,), (1000,)] - encoded = [bytes_write(v) for v in values] + encoded = [storage_bytes_write(v) for v in values] # Encoded values should maintain order assert encoded[0] < encoded[1] < encoded[2] < encoded[3] -def test_bytes_write_order_negative_integers(): +def test_storage_bytes_write_order_negative_integers(): """Test that encoded negative integers preserve order among themselves""" # Note: Current encoding has negative ints type code (0x06) > zero type code (0x04), # so negative integers sort after zero. Test only negative number ordering. values = [(-100,), (-42,), (-1,)] - encoded = [bytes_write(v) for v in values] + encoded = [storage_bytes_write(v) for v in values] # Encoded negative values should maintain order among themselves for i in range(len(encoded) - 1): @@ -101,43 +102,43 @@ def test_bytes_write_order_negative_integers(): # Test positive integers separately pos_values = [(0,), (1,), (42,), (100,)] - pos_encoded = [bytes_write(v) for v in pos_values] + pos_encoded = [storage_bytes_write(v) for v in pos_values] for i in range(len(pos_encoded) - 1): assert pos_encoded[i] < pos_encoded[i + 1] -def test_bytes_write_order_floats(): +def test_storage_bytes_write_order_floats(): """Test that encoded floats preserve numeric order""" values = [(0.1,), (1.5,), (3.14,), (10.0,)] - encoded = [bytes_write(v) for v in values] + encoded = [storage_bytes_write(v) for v in values] # Encoded values should maintain order assert encoded[0] < encoded[1] < encoded[2] < encoded[3] -def test_bytes_write_order_mixed_tuples(): +def test_storage_bytes_write_order_mixed_tuples(): """Test order preservation with mixed-type tuples""" values = [ - ('user', 'age', 20), - ('user', 'age', 30), - ('user', 'age', 40), + ("user", "age", 20), + ("user", "age", 30), + ("user", "age", 40), ] - encoded = [bytes_write(v) for v in values] + encoded = [storage_bytes_write(v) for v in values] # Encoded values should maintain order assert encoded[0] < encoded[1] < encoded[2] -def test_bytes_write_order_prefix_matching(): +def test_storage_bytes_write_order_prefix_matching(): """Test order preservation with common prefixes""" values = [ - ('blog', 'post', 'a'), - ('blog', 'post', 'b'), - ('blog', 'post', 'c'), - ('blog', 'title', 'x'), + ("blog", "post", "a"), + ("blog", "post", "b"), + ("blog", "post", "c"), + ("blog", "title", "x"), ] - encoded = [bytes_write(v) for v in values] + encoded = [storage_bytes_write(v) for v in values] # Encoded values should maintain order assert encoded[0] < encoded[1] < encoded[2] < encoded[3] @@ -147,107 +148,109 @@ def test_bytes_write_order_prefix_matching(): # Tests for special cases # ============================================================================ -def test_bytes_write_null_byte_in_string(): + +def test_storage_bytes_write_null_byte_in_string(): """Test encoding string with null byte escape""" - original = ('test\x00data',) - encoded = bytes_write(original) - decoded = bytes_read(encoded) + original = ("test\x00data",) + encoded = storage_bytes_write(original) + decoded = storage_bytes_read(encoded) assert decoded == original -def test_bytes_write_null_byte_in_bytes(): +def test_storage_bytes_write_null_byte_in_bytes(): """Test encoding bytes with null byte escape""" - original = (b'test\x00data',) - encoded = bytes_write(original) - decoded = bytes_read(encoded) + original = (b"test\x00data",) + encoded = storage_bytes_write(original) + decoded = storage_bytes_read(encoded) assert decoded == original -def test_bytes_write_empty_string(): +def test_storage_bytes_write_empty_string(): """Test encoding empty string""" - original = ('',) - encoded = bytes_write(original) - decoded = bytes_read(encoded) + original = ("",) + encoded = storage_bytes_write(original) + decoded = storage_bytes_read(encoded) assert decoded == original -def test_bytes_write_empty_bytes(): +def test_storage_bytes_write_empty_bytes(): """Test encoding empty bytes""" - original = (b'',) - encoded = bytes_write(original) - decoded = bytes_read(encoded) + original = (b"",) + encoded = storage_bytes_write(original) + decoded = storage_bytes_read(encoded) assert decoded == original # ============================================================================ -# Tests for bytes_next +# Tests for storage_bytes_next # ============================================================================ -def test_bytes_next_simple(): - """Test bytes_next with simple increment""" - assert bytes_next(b'abc') == b'abd' - assert bytes_next(b'hello') == b'hellp' + +def test_storage_bytes_next_simple(): + """Test storage_bytes_next with simple increment""" + assert storage_bytes_next(b"abc") == b"abd" + assert storage_bytes_next(b"hello") == b"hellp" -def test_bytes_next_empty(): - """Test bytes_next with empty bytes""" - assert bytes_next(b'') == b'\x00' +def test_storage_bytes_next_empty(): + """Test storage_bytes_next with empty bytes""" + assert storage_bytes_next(b"") == b"\x00" -def test_bytes_next_with_0xff(): - """Test bytes_next when last byte is 0xFF""" +def test_storage_bytes_next_with_0xff(): + """Test storage_bytes_next when last byte is 0xFF""" # Should skip 0xFF and increment previous byte - assert bytes_next(b'ab\xff') == b'ac' - assert bytes_next(b'test\xff') == b'tesu' + assert storage_bytes_next(b"ab\xff") == b"ac" + assert storage_bytes_next(b"test\xff") == b"tesu" -def test_bytes_next_multiple_0xff(): - """Test bytes_next with multiple trailing 0xFF bytes""" - assert bytes_next(b'a\xff\xff') == b'b' - assert bytes_next(b'hello\xff\xff\xff') == b'hellp' +def test_storage_bytes_next_multiple_0xff(): + """Test storage_bytes_next with multiple trailing 0xFF bytes""" + assert storage_bytes_next(b"a\xff\xff") == b"b" + assert storage_bytes_next(b"hello\xff\xff\xff") == b"hellp" -def test_bytes_next_all_0xff(): - """Test bytes_next when all bytes are 0xFF""" - assert bytes_next(b'\xff') is None - assert bytes_next(b'\xff\xff') is None - assert bytes_next(b'\xff\xff\xff\xff') is None +def test_storage_bytes_next_all_0xff(): + """Test storage_bytes_next when all bytes are 0xFF""" + assert storage_bytes_next(b"\xff") is None + assert storage_bytes_next(b"\xff\xff") is None + assert storage_bytes_next(b"\xff\xff\xff\xff") is None -def test_bytes_next_prefix_scan(): - """Test bytes_next for prefix scans""" - # All keys starting with b'user:' would be in range [b'user:', bytes_next(b'user:')) - prefix = b'user:' - next_key = bytes_next(prefix) +def test_storage_bytes_next_prefix_scan(): + """Test storage_bytes_next for prefix scans""" + # All keys starting with b'user:' would be in range [b'user:', storage_bytes_next(b'user:')) + prefix = b"user:" + next_key = storage_bytes_next(prefix) - assert next_key == b'user;' + assert next_key == b"user;" assert prefix < next_key -def test_bytes_next_ordering(): - """Test that bytes_next maintains ordering property""" - test_cases = [b'a', b'abc', b'test', b'hello'] +def test_storage_bytes_next_ordering(): + """Test that storage_bytes_next maintains ordering property""" + test_cases = [b"a", b"abc", b"test", b"hello"] for data in test_cases: - next_data = bytes_next(data) + next_data = storage_bytes_next(data) if next_data is not None: # next_data should be greater than data assert next_data > data # Everything starting with data should be less than next_data - assert data + b'\x00' < next_data + assert data + b"\x00" < next_data -def test_bytes_next_boundary_cases(): - """Test bytes_next with boundary values""" +def test_storage_bytes_next_boundary_cases(): + """Test storage_bytes_next with boundary values""" # Single byte increment - assert bytes_next(b'\x00') == b'\x01' - assert bytes_next(b'\x01') == b'\x02' - assert bytes_next(b'\xfe') == b'\xff' + assert storage_bytes_next(b"\x00") == b"\x01" + assert storage_bytes_next(b"\x01") == b"\x02" + assert storage_bytes_next(b"\xfe") == b"\xff" # Mixed cases - assert bytes_next(b'\x00\xff') == b'\x01' - assert bytes_next(b'\xfe\xff') == b'\xff' + assert storage_bytes_next(b"\x00\xff") == b"\x01" + assert storage_bytes_next(b"\xfe\xff") == b"\xff" diff --git a/tests/storage/test_db.py b/tests/storage/test_db.py index ef95b29..f099f5a 100644 --- a/tests/storage/test_db.py +++ b/tests/storage/test_db.py @@ -3,625 +3,647 @@ Tests SQLite3-based ordered key-value store operations. """ + import pytest -from bb import db_open, db_get, db_set, db_delete, db_query, db_transaction, db_bytes, db_count +from bb import ( + storage_db_open, + storage_db_get, + storage_db_set, + storage_db_delete, + storage_db_query, + storage_db_transaction, + storage_db_bytes, + storage_db_count, +) # ============================================================================ -# Tests for db_open +# Tests for storage_db_open # ============================================================================ -def test_db_open_memory(): + +def test_storage_db_open_memory(): """Test opening in-memory database""" - db = db_open(':memory:') + db = storage_db_open(":memory:") assert db is not None # Verify table exists - cursor = db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='kv'") + cursor = db.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='kv'" + ) assert cursor.fetchone() is not None -def test_db_open_file(tmp_path): +def test_storage_db_open_file(tmp_path): """Test opening file-based database""" - db_path = tmp_path / 'test.db' - db = db_open(str(db_path)) + db_path = tmp_path / "test.db" + db = storage_db_open(str(db_path)) assert db is not None assert db_path.exists() -def test_db_open_creates_index(): - """Test that db_open creates index on key column""" - db = db_open(':memory:') +def test_storage_db_open_creates_index(): + """Test that storage_db_open creates index on key column""" + db = storage_db_open(":memory:") # Verify index exists - cursor = db.execute("SELECT name FROM sqlite_master WHERE type='index' AND name='idx_key'") + cursor = db.execute( + "SELECT name FROM sqlite_master WHERE type='index' AND name='idx_key'" + ) assert cursor.fetchone() is not None # ============================================================================ -# Tests for db_set +# Tests for storage_db_set # ============================================================================ -def test_db_set_basic(): + +def test_storage_db_set_basic(): """Test basic set operation""" - db = db_open(':memory:') - key = b'test_key' - value = b'test_value' + db = storage_db_open(":memory:") + key = b"test_key" + value = b"test_value" - db_set(db, key, value) + storage_db_set(db, key, value) # Verify stored - cursor = db.execute('SELECT value FROM kv WHERE key = ?', (key,)) + cursor = db.execute("SELECT value FROM kv WHERE key = ?", (key,)) row = cursor.fetchone() assert row is not None assert row[0] == value -def test_db_set_replace(): +def test_storage_db_set_replace(): """Test that set replaces existing value""" - db = db_open(':memory:') - key = b'test_key' - value1 = b'value1' - value2 = b'value2' + db = storage_db_open(":memory:") + key = b"test_key" + value1 = b"value1" + value2 = b"value2" - db_set(db, key, value1) - db_set(db, key, value2) + storage_db_set(db, key, value1) + storage_db_set(db, key, value2) # Should have only the new value - cursor = db.execute('SELECT value FROM kv WHERE key = ?', (key,)) + cursor = db.execute("SELECT value FROM kv WHERE key = ?", (key,)) row = cursor.fetchone() assert row[0] == value2 -def test_db_set_multiple_keys(): +def test_storage_db_set_multiple_keys(): """Test setting multiple different keys""" - db = db_open(':memory:') + db = storage_db_open(":memory:") - db_set(db, b'key1', b'value1') - db_set(db, b'key2', b'value2') - db_set(db, b'key3', b'value3') + storage_db_set(db, b"key1", b"value1") + storage_db_set(db, b"key2", b"value2") + storage_db_set(db, b"key3", b"value3") # All three should exist - cursor = db.execute('SELECT COUNT(*) FROM kv') + cursor = db.execute("SELECT COUNT(*) FROM kv") assert cursor.fetchone()[0] == 3 -def test_db_set_key_size_limit(): +def test_storage_db_set_key_size_limit(): """Test that keys exceeding 1KB are rejected""" - db = db_open(':memory:') - oversized_key = b'x' * 1025 + db = storage_db_open(":memory:") + oversized_key = b"x" * 1025 with pytest.raises(AssertionError, match="Key size .* exceeds maximum"): - db_set(db, oversized_key, b'value') + storage_db_set(db, oversized_key, b"value") -def test_db_set_value_size_limit(): +def test_storage_db_set_value_size_limit(): """Test that values exceeding 1MB are rejected""" - db = db_open(':memory:') - oversized_value = b'x' * (1048576 + 1) + db = storage_db_open(":memory:") + oversized_value = b"x" * (1048576 + 1) with pytest.raises(AssertionError, match="Value size .* exceeds maximum"): - db_set(db, b'key', oversized_value) + storage_db_set(db, b"key", oversized_value) -def test_db_set_max_key_size(): +def test_storage_db_set_max_key_size(): """Test that 1KB key is accepted""" - db = db_open(':memory:') - max_key = b'x' * 1024 + db = storage_db_open(":memory:") + max_key = b"x" * 1024 - db_set(db, max_key, b'value') + storage_db_set(db, max_key, b"value") # Should succeed - assert db_get(db, max_key) == b'value' + assert storage_db_get(db, max_key) == b"value" -def test_db_set_max_value_size(): +def test_storage_db_set_max_value_size(): """Test that 1MB value is accepted""" - db = db_open(':memory:') - max_value = b'x' * 1048576 + db = storage_db_open(":memory:") + max_value = b"x" * 1048576 - db_set(db, b'key', max_value) + storage_db_set(db, b"key", max_value) # Should succeed - assert db_get(db, b'key') == max_value + assert storage_db_get(db, b"key") == max_value # ============================================================================ -# Tests for db_get +# Tests for storage_db_get # ============================================================================ -def test_db_get_existing_key(): + +def test_storage_db_get_existing_key(): """Test getting existing key""" - db = db_open(':memory:') - key = b'test_key' - value = b'test_value' + db = storage_db_open(":memory:") + key = b"test_key" + value = b"test_value" - db_set(db, key, value) - result = db_get(db, key) + storage_db_set(db, key, value) + result = storage_db_get(db, key) assert result == value -def test_db_get_nonexistent_key(): +def test_storage_db_get_nonexistent_key(): """Test getting nonexistent key returns None""" - db = db_open(':memory:') + db = storage_db_open(":memory:") - result = db_get(db, b'nonexistent') + result = storage_db_get(db, b"nonexistent") assert result is None -def test_db_get_after_delete(): +def test_storage_db_get_after_delete(): """Test getting key after deletion returns None""" - db = db_open(':memory:') - key = b'test_key' + db = storage_db_open(":memory:") + key = b"test_key" - db_set(db, key, b'value') - db_delete(db, key) - result = db_get(db, key) + storage_db_set(db, key, b"value") + storage_db_delete(db, key) + result = storage_db_get(db, key) assert result is None # ============================================================================ -# Tests for db_delete +# Tests for storage_db_delete # ============================================================================ -def test_db_delete_existing_key(): + +def test_storage_db_delete_existing_key(): """Test deleting existing key""" - db = db_open(':memory:') - key = b'test_key' + db = storage_db_open(":memory:") + key = b"test_key" - db_set(db, key, b'value') - db_delete(db, key) + storage_db_set(db, key, b"value") + storage_db_delete(db, key) # Key should no longer exist - assert db_get(db, key) is None + assert storage_db_get(db, key) is None -def test_db_delete_nonexistent_key(): +def test_storage_db_delete_nonexistent_key(): """Test deleting nonexistent key does not error""" - db = db_open(':memory:') + db = storage_db_open(":memory:") # Should not raise - db_delete(db, b'nonexistent') + storage_db_delete(db, b"nonexistent") -def test_db_delete_multiple_keys(): +def test_storage_db_delete_multiple_keys(): """Test deleting one key doesn't affect others""" - db = db_open(':memory:') + db = storage_db_open(":memory:") - db_set(db, b'key1', b'value1') - db_set(db, b'key2', b'value2') - db_set(db, b'key3', b'value3') + storage_db_set(db, b"key1", b"value1") + storage_db_set(db, b"key2", b"value2") + storage_db_set(db, b"key3", b"value3") - db_delete(db, b'key2') + storage_db_delete(db, b"key2") # key1 and key3 should still exist - assert db_get(db, b'key1') == b'value1' - assert db_get(db, b'key2') is None - assert db_get(db, b'key3') == b'value3' + assert storage_db_get(db, b"key1") == b"value1" + assert storage_db_get(db, b"key2") is None + assert storage_db_get(db, b"key3") == b"value3" # ============================================================================ -# Tests for db_query +# Tests for storage_db_query # ============================================================================ -def test_db_query_forward_scan(): + +def test_storage_db_query_forward_scan(): """Test forward range scan (key <= other)""" - db = db_open(':memory:') + db = storage_db_open(":memory:") # Insert ordered keys - db_set(db, b'a', b'value_a') - db_set(db, b'b', b'value_b') - db_set(db, b'c', b'value_c') - db_set(db, b'd', b'value_d') + storage_db_set(db, b"a", b"value_a") + storage_db_set(db, b"b", b"value_b") + storage_db_set(db, b"c", b"value_c") + storage_db_set(db, b"d", b"value_d") # Query [b, d) - should get b and c - results = db_query(db, b'b', b'd') + results = storage_db_query(db, b"b", b"d") assert len(results) == 2 - assert results[0] == (b'b', b'value_b') - assert results[1] == (b'c', b'value_c') + assert results[0] == (b"b", b"value_b") + assert results[1] == (b"c", b"value_c") -def test_db_query_reverse_scan(): +def test_storage_db_query_reverse_scan(): """Test reverse range scan (key > other)""" - db = db_open(':memory:') + db = storage_db_open(":memory:") # Insert ordered keys - db_set(db, b'a', b'value_a') - db_set(db, b'b', b'value_b') - db_set(db, b'c', b'value_c') - db_set(db, b'd', b'value_d') + storage_db_set(db, b"a", b"value_a") + storage_db_set(db, b"b", b"value_b") + storage_db_set(db, b"c", b"value_c") + storage_db_set(db, b"d", b"value_d") # Query reverse [d, b) - should get c and b in descending order # Range is [b, d) = {b, c}, returned in descending order - results = db_query(db, b'd', b'b') + results = storage_db_query(db, b"d", b"b") assert len(results) == 2 - assert results[0] == (b'c', b'value_c') - assert results[1] == (b'b', b'value_b') + assert results[0] == (b"c", b"value_c") + assert results[1] == (b"b", b"value_b") -def test_db_query_empty_result(): +def test_storage_db_query_empty_result(): """Test query with no matching keys""" - db = db_open(':memory:') + db = storage_db_open(":memory:") - db_set(db, b'a', b'value_a') - db_set(db, b'z', b'value_z') + storage_db_set(db, b"a", b"value_a") + storage_db_set(db, b"z", b"value_z") # Query [m, n) - no keys in this range - results = db_query(db, b'm', b'n') + results = storage_db_query(db, b"m", b"n") assert len(results) == 0 -def test_db_query_offset(): +def test_storage_db_query_offset(): """Test query with offset parameter""" - db = db_open(':memory:') + db = storage_db_open(":memory:") - db_set(db, b'a', b'value_a') - db_set(db, b'b', b'value_b') - db_set(db, b'c', b'value_c') - db_set(db, b'd', b'value_d') + storage_db_set(db, b"a", b"value_a") + storage_db_set(db, b"b", b"value_b") + storage_db_set(db, b"c", b"value_c") + storage_db_set(db, b"d", b"value_d") # Query with offset=2 and limit (SQLite requires LIMIT with OFFSET) - results = db_query(db, b'a', b'e', offset=2, limit=10) + results = storage_db_query(db, b"a", b"e", offset=2, limit=10) assert len(results) == 2 - assert results[0] == (b'c', b'value_c') - assert results[1] == (b'd', b'value_d') + assert results[0] == (b"c", b"value_c") + assert results[1] == (b"d", b"value_d") -def test_db_query_limit(): +def test_storage_db_query_limit(): """Test query with limit parameter""" - db = db_open(':memory:') + db = storage_db_open(":memory:") - db_set(db, b'a', b'value_a') - db_set(db, b'b', b'value_b') - db_set(db, b'c', b'value_c') - db_set(db, b'd', b'value_d') + storage_db_set(db, b"a", b"value_a") + storage_db_set(db, b"b", b"value_b") + storage_db_set(db, b"c", b"value_c") + storage_db_set(db, b"d", b"value_d") # Query with limit=2 - results = db_query(db, b'a', b'e', limit=2) + results = storage_db_query(db, b"a", b"e", limit=2) assert len(results) == 2 - assert results[0] == (b'a', b'value_a') - assert results[1] == (b'b', b'value_b') + assert results[0] == (b"a", b"value_a") + assert results[1] == (b"b", b"value_b") -def test_db_query_offset_and_limit(): +def test_storage_db_query_offset_and_limit(): """Test query with both offset and limit""" - db = db_open(':memory:') + db = storage_db_open(":memory:") - db_set(db, b'a', b'value_a') - db_set(db, b'b', b'value_b') - db_set(db, b'c', b'value_c') - db_set(db, b'd', b'value_d') + storage_db_set(db, b"a", b"value_a") + storage_db_set(db, b"b", b"value_b") + storage_db_set(db, b"c", b"value_c") + storage_db_set(db, b"d", b"value_d") # Query with offset=1, limit=2 - results = db_query(db, b'a', b'e', offset=1, limit=2) + results = storage_db_query(db, b"a", b"e", offset=1, limit=2) assert len(results) == 2 - assert results[0] == (b'b', b'value_b') - assert results[1] == (b'c', b'value_c') + assert results[0] == (b"b", b"value_b") + assert results[1] == (b"c", b"value_c") -def test_db_query_prefix_scan(): +def test_storage_db_query_prefix_scan(): """Test prefix scan using range query""" - db = db_open(':memory:') + db = storage_db_open(":memory:") - db_set(db, b'user:1:name', b'alice') - db_set(db, b'user:1:email', b'alice@example.com') - db_set(db, b'user:2:name', b'bob') - db_set(db, b'post:1:title', b'hello') + storage_db_set(db, b"user:1:name", b"alice") + storage_db_set(db, b"user:1:email", b"alice@example.com") + storage_db_set(db, b"user:2:name", b"bob") + storage_db_set(db, b"post:1:title", b"hello") # Query all user:1: keys - key_start = b'user:1:' - key_end = b'user:1;' # Next character after ':' is ';' + key_start = b"user:1:" + key_end = b"user:1;" # Next character after ':' is ';' - results = db_query(db, key_start, key_end) + results = storage_db_query(db, key_start, key_end) assert len(results) == 2 - assert results[0][0] == b'user:1:email' - assert results[1][0] == b'user:1:name' + assert results[0][0] == b"user:1:email" + assert results[1][0] == b"user:1:name" # ============================================================================ -# Tests for db_transaction +# Tests for storage_db_transaction # ============================================================================ -def test_db_transaction_commit(): + +def test_storage_db_transaction_commit(): """Test that transaction commits on success""" - db = db_open(':memory:') + db = storage_db_open(":memory:") - with db_transaction(db): - db_set(db, b'key1', b'value1') - db_set(db, b'key2', b'value2') + with storage_db_transaction(db): + storage_db_set(db, b"key1", b"value1") + storage_db_set(db, b"key2", b"value2") # Both should be committed - assert db_get(db, b'key1') == b'value1' - assert db_get(db, b'key2') == b'value2' + assert storage_db_get(db, b"key1") == b"value1" + assert storage_db_get(db, b"key2") == b"value2" -def test_db_transaction_rollback(): +def test_storage_db_transaction_rollback(): """Test that transaction rolls back on exception""" - db = db_open(':memory:') + db = storage_db_open(":memory:") # Set initial value - db_set(db, b'key1', b'initial') + storage_db_set(db, b"key1", b"initial") db.commit() try: - with db_transaction(db): - db_set(db, b'key1', b'modified') - db_set(db, b'key2', b'new_value') + with storage_db_transaction(db): + storage_db_set(db, b"key1", b"modified") + storage_db_set(db, b"key2", b"new_value") raise ValueError("Test error") except ValueError: pass # Should have rolled back - assert db_get(db, b'key1') == b'initial' - assert db_get(db, b'key2') is None + assert storage_db_get(db, b"key1") == b"initial" + assert storage_db_get(db, b"key2") is None -def test_db_transaction_nested_operations(): +def test_storage_db_transaction_nested_operations(): """Test multiple operations within transaction""" - db = db_open(':memory:') + db = storage_db_open(":memory:") - with db_transaction(db): - db_set(db, b'key1', b'value1') - assert db_get(db, b'key1') == b'value1' + with storage_db_transaction(db): + storage_db_set(db, b"key1", b"value1") + assert storage_db_get(db, b"key1") == b"value1" - db_set(db, b'key2', b'value2') - db_delete(db, b'key1') + storage_db_set(db, b"key2", b"value2") + storage_db_delete(db, b"key1") - assert db_get(db, b'key1') is None - assert db_get(db, b'key2') == b'value2' + assert storage_db_get(db, b"key1") is None + assert storage_db_get(db, b"key2") == b"value2" # Final state should be committed - assert db_get(db, b'key1') is None - assert db_get(db, b'key2') == b'value2' + assert storage_db_get(db, b"key1") is None + assert storage_db_get(db, b"key2") == b"value2" -def test_db_transaction_returns_db(): +def test_storage_db_transaction_returns_db(): """Test that transaction yields database connection""" - db = db_open(':memory:') + db = storage_db_open(":memory:") - with db_transaction(db) as conn: + with storage_db_transaction(db) as conn: assert conn is db # ============================================================================ -# Tests for db_bytes +# Tests for storage_db_bytes # ============================================================================ -def test_db_bytes_basic(): + +def test_storage_db_bytes_basic(): """Test basic bytes calculation""" - db = db_open(':memory:') + db = storage_db_open(":memory:") # Insert keys and values with known sizes - db_set(db, b'aa', b'value1') # key: 2, value: 6 = 8 - db_set(db, b'ab', b'value2') # key: 2, value: 6 = 8 - db_set(db, b'ac', b'val') # key: 2, value: 3 = 5 + storage_db_set(db, b"aa", b"value1") # key: 2, value: 6 = 8 + storage_db_set(db, b"ab", b"value2") # key: 2, value: 6 = 8 + storage_db_set(db, b"ac", b"val") # key: 2, value: 3 = 5 # Query all keys [aa, ad) - total = db_bytes(db, b'aa', b'ad') + total = storage_db_bytes(db, b"aa", b"ad") assert total == 21 # 8 + 8 + 5 -def test_db_bytes_forward_scan(): +def test_storage_db_bytes_forward_scan(): """Test bytes calculation with forward range scan""" - db = db_open(':memory:') + db = storage_db_open(":memory:") - db_set(db, b'a', b'1') - db_set(db, b'b', b'22') - db_set(db, b'c', b'333') - db_set(db, b'd', b'4444') + storage_db_set(db, b"a", b"1") + storage_db_set(db, b"b", b"22") + storage_db_set(db, b"c", b"333") + storage_db_set(db, b"d", b"4444") # Query [b, d) - should get b and c - total = db_bytes(db, b'b', b'd') + total = storage_db_bytes(db, b"b", b"d") # b: 1 + 2 = 3, c: 1 + 3 = 4, total = 7 assert total == 7 -def test_db_bytes_reverse_scan(): +def test_storage_db_bytes_reverse_scan(): """Test bytes calculation with reverse range scan""" - db = db_open(':memory:') + db = storage_db_open(":memory:") - db_set(db, b'a', b'1') - db_set(db, b'b', b'22') - db_set(db, b'c', b'333') - db_set(db, b'd', b'4444') + storage_db_set(db, b"a", b"1") + storage_db_set(db, b"b", b"22") + storage_db_set(db, b"c", b"333") + storage_db_set(db, b"d", b"4444") # Query reverse [d, b) - should get c and b in descending order - total = db_bytes(db, b'd', b'b') + total = storage_db_bytes(db, b"d", b"b") # Same range [b, d) = b + c = 7 assert total == 7 -def test_db_bytes_empty_result(): +def test_storage_db_bytes_empty_result(): """Test bytes calculation with no matching keys""" - db = db_open(':memory:') + db = storage_db_open(":memory:") - db_set(db, b'a', b'value_a') - db_set(db, b'z', b'value_z') + storage_db_set(db, b"a", b"value_a") + storage_db_set(db, b"z", b"value_z") # Query [m, n) - no keys in this range - total = db_bytes(db, b'm', b'n') + total = storage_db_bytes(db, b"m", b"n") assert total == 0 -def test_db_bytes_with_offset(): +def test_storage_db_bytes_with_offset(): """Test bytes calculation with offset""" - db = db_open(':memory:') + db = storage_db_open(":memory:") - db_set(db, b'a', b'11') # key: 1, value: 2 = 3 - db_set(db, b'b', b'222') # key: 1, value: 3 = 4 - db_set(db, b'c', b'3333') # key: 1, value: 4 = 5 - db_set(db, b'd', b'44444')# key: 1, value: 5 = 6 + storage_db_set(db, b"a", b"11") # key: 1, value: 2 = 3 + storage_db_set(db, b"b", b"222") # key: 1, value: 3 = 4 + storage_db_set(db, b"c", b"3333") # key: 1, value: 4 = 5 + storage_db_set(db, b"d", b"44444") # key: 1, value: 5 = 6 # Query with offset=2, limit required for offset - total = db_bytes(db, b'a', b'e', offset=2, limit=10) + total = storage_db_bytes(db, b"a", b"e", offset=2, limit=10) # Skip a and b, get c and d: 5 + 6 = 11 assert total == 11 -def test_db_bytes_with_limit(): +def test_storage_db_bytes_with_limit(): """Test bytes calculation with limit""" - db = db_open(':memory:') + db = storage_db_open(":memory:") - db_set(db, b'a', b'11') # key: 1, value: 2 = 3 - db_set(db, b'b', b'222') # key: 1, value: 3 = 4 - db_set(db, b'c', b'3333') # key: 1, value: 4 = 5 - db_set(db, b'd', b'44444')# key: 1, value: 5 = 6 + storage_db_set(db, b"a", b"11") # key: 1, value: 2 = 3 + storage_db_set(db, b"b", b"222") # key: 1, value: 3 = 4 + storage_db_set(db, b"c", b"3333") # key: 1, value: 4 = 5 + storage_db_set(db, b"d", b"44444") # key: 1, value: 5 = 6 # Query with limit=2 - total = db_bytes(db, b'a', b'e', limit=2) + total = storage_db_bytes(db, b"a", b"e", limit=2) # Get only a and b: 3 + 4 = 7 assert total == 7 -def test_db_bytes_with_offset_and_limit(): +def test_storage_db_bytes_with_offset_and_limit(): """Test bytes calculation with offset and limit""" - db = db_open(':memory:') + db = storage_db_open(":memory:") - db_set(db, b'a', b'11') # key: 1, value: 2 = 3 - db_set(db, b'b', b'222') # key: 1, value: 3 = 4 - db_set(db, b'c', b'3333') # key: 1, value: 4 = 5 - db_set(db, b'd', b'44444')# key: 1, value: 5 = 6 + storage_db_set(db, b"a", b"11") # key: 1, value: 2 = 3 + storage_db_set(db, b"b", b"222") # key: 1, value: 3 = 4 + storage_db_set(db, b"c", b"3333") # key: 1, value: 4 = 5 + storage_db_set(db, b"d", b"44444") # key: 1, value: 5 = 6 # Query with offset=1, limit=2 - total = db_bytes(db, b'a', b'e', offset=1, limit=2) + total = storage_db_bytes(db, b"a", b"e", offset=1, limit=2) # Skip a, get b and c: 4 + 5 = 9 assert total == 9 # ============================================================================ -# Tests for db_count +# Tests for storage_db_count # ============================================================================ -def test_db_count_basic(): + +def test_storage_db_count_basic(): """Test basic count""" - db = db_open(':memory:') + db = storage_db_open(":memory:") - db_set(db, b'aa', b'value1') - db_set(db, b'ab', b'value2') - db_set(db, b'ac', b'value3') + storage_db_set(db, b"aa", b"value1") + storage_db_set(db, b"ab", b"value2") + storage_db_set(db, b"ac", b"value3") # Count all keys [aa, ad) - count = db_count(db, b'aa', b'ad') + count = storage_db_count(db, b"aa", b"ad") assert count == 3 -def test_db_count_forward_scan(): +def test_storage_db_count_forward_scan(): """Test count with forward range scan""" - db = db_open(':memory:') + db = storage_db_open(":memory:") - db_set(db, b'a', b'value_a') - db_set(db, b'b', b'value_b') - db_set(db, b'c', b'value_c') - db_set(db, b'd', b'value_d') + storage_db_set(db, b"a", b"value_a") + storage_db_set(db, b"b", b"value_b") + storage_db_set(db, b"c", b"value_c") + storage_db_set(db, b"d", b"value_d") # Count [b, d) - should get b and c - count = db_count(db, b'b', b'd') + count = storage_db_count(db, b"b", b"d") assert count == 2 -def test_db_count_reverse_scan(): +def test_storage_db_count_reverse_scan(): """Test count with reverse range scan""" - db = db_open(':memory:') + db = storage_db_open(":memory:") - db_set(db, b'a', b'value_a') - db_set(db, b'b', b'value_b') - db_set(db, b'c', b'value_c') - db_set(db, b'd', b'value_d') + storage_db_set(db, b"a", b"value_a") + storage_db_set(db, b"b", b"value_b") + storage_db_set(db, b"c", b"value_c") + storage_db_set(db, b"d", b"value_d") # Count reverse [d, b) - should get b and c - count = db_count(db, b'd', b'b') + count = storage_db_count(db, b"d", b"b") assert count == 2 -def test_db_count_empty_result(): +def test_storage_db_count_empty_result(): """Test count with no matching keys""" - db = db_open(':memory:') + db = storage_db_open(":memory:") - db_set(db, b'a', b'value_a') - db_set(db, b'z', b'value_z') + storage_db_set(db, b"a", b"value_a") + storage_db_set(db, b"z", b"value_z") # Count [m, n) - no keys in this range - count = db_count(db, b'm', b'n') + count = storage_db_count(db, b"m", b"n") assert count == 0 -def test_db_count_with_offset(): +def test_storage_db_count_with_offset(): """Test count with offset""" - db = db_open(':memory:') + db = storage_db_open(":memory:") - db_set(db, b'a', b'value_a') - db_set(db, b'b', b'value_b') - db_set(db, b'c', b'value_c') - db_set(db, b'd', b'value_d') + storage_db_set(db, b"a", b"value_a") + storage_db_set(db, b"b", b"value_b") + storage_db_set(db, b"c", b"value_c") + storage_db_set(db, b"d", b"value_d") # Count with offset=2, limit required - count = db_count(db, b'a', b'e', offset=2, limit=10) + count = storage_db_count(db, b"a", b"e", offset=2, limit=10) # Skip a and b, count c and d assert count == 2 -def test_db_count_with_limit(): +def test_storage_db_count_with_limit(): """Test count with limit""" - db = db_open(':memory:') + db = storage_db_open(":memory:") - db_set(db, b'a', b'value_a') - db_set(db, b'b', b'value_b') - db_set(db, b'c', b'value_c') - db_set(db, b'd', b'value_d') + storage_db_set(db, b"a", b"value_a") + storage_db_set(db, b"b", b"value_b") + storage_db_set(db, b"c", b"value_c") + storage_db_set(db, b"d", b"value_d") # Count with limit=2 - count = db_count(db, b'a', b'e', limit=2) + count = storage_db_count(db, b"a", b"e", limit=2) assert count == 2 -def test_db_count_with_offset_and_limit(): +def test_storage_db_count_with_offset_and_limit(): """Test count with offset and limit""" - db = db_open(':memory:') + db = storage_db_open(":memory:") - db_set(db, b'a', b'value_a') - db_set(db, b'b', b'value_b') - db_set(db, b'c', b'value_c') - db_set(db, b'd', b'value_d') + storage_db_set(db, b"a", b"value_a") + storage_db_set(db, b"b", b"value_b") + storage_db_set(db, b"c", b"value_c") + storage_db_set(db, b"d", b"value_d") # Count with offset=1, limit=2 - count = db_count(db, b'a', b'e', offset=1, limit=2) + count = storage_db_count(db, b"a", b"e", offset=1, limit=2) # Skip a, count b and c assert count == 2 -def test_db_count_single_key(): +def test_storage_db_count_single_key(): """Test count with single matching key""" - db = db_open(':memory:') + db = storage_db_open(":memory:") - db_set(db, b'key', b'value') + storage_db_set(db, b"key", b"value") - count = db_count(db, b'key', b'key\x00') + count = storage_db_count(db, b"key", b"key\x00") assert count == 1 diff --git a/tests/storage/test_erdos.py b/tests/storage/test_erdos.py index 5ed263b..952ad3e 100644 --- a/tests/storage/test_erdos.py +++ b/tests/storage/test_erdos.py @@ -1,5 +1,5 @@ """ -Tests for nstore_indices computation. +Tests for storage_nstore_indices computation. Tests the nstore indices algorithm for generating minimal permutation sets that cover all possible query patterns. @@ -9,17 +9,17 @@ of maximal chains. The minimal number equals the cardinality of the maximal antichain in the boolean lattice, which is the central binomial coefficient C(n, n//2). -IMPORTANT: The length of nstore_indices(n) always equals the central binomial +IMPORTANT: The length of storage_nstore_indices(n) always equals the central binomial coefficient C(n, n//2), which is the number of ways to choose n//2 items from n. """ -import pytest + import math -from bb import nstore_indices +from bb import storage_nstore_indices -def test_nstore_indices_central_binomial_coefficient(): - """Test that nstore_indices length equals central binomial coefficient C(n, n//2)""" +def test_storage_nstore_indices_central_binomial_coefficient(): + """Test that storage_nstore_indices length equals central binomial coefficient C(n, n//2)""" # Test for multiple values of n test_cases = [ (3, math.comb(3, 1)), # C(3, 1) = 3 @@ -29,48 +29,49 @@ def test_nstore_indices_central_binomial_coefficient(): ] for n, expected_count in test_cases: - indices = nstore_indices(n) - assert len(indices) == expected_count, \ - f"For n={n}, expected {expected_count} indices (C({n}, {n//2})), got {len(indices)}" + indices = storage_nstore_indices(n) + assert len(indices) == expected_count, ( + f"For n={n}, expected {expected_count} indices (C({n}, {n // 2})), got {len(indices)}" + ) -def test_nstore_indices_n4_count(): - """Test that nstore_indices for n=4 generates correct number of indices""" - indices = nstore_indices(4) +def test_storage_nstore_indices_n4_count(): + """Test that storage_nstore_indices for n=4 generates correct number of indices""" + indices = storage_nstore_indices(4) # For n=4, we expect C(4, 2) = 6 indices (central binomial coefficient) assert len(indices) == 6 assert len(indices) == math.comb(4, 2) -def test_nstore_indices_n4_length(): - """Test that nstore_indices for n=4 generates indices of correct length""" - indices = nstore_indices(4) +def test_storage_nstore_indices_n4_length(): + """Test that storage_nstore_indices for n=4 generates indices of correct length""" + indices = storage_nstore_indices(4) # Each index should have length 4 for index in indices: assert len(index) == 4 -def test_nstore_indices_n4_contains_all_positions(): +def test_storage_nstore_indices_n4_contains_all_positions(): """Test that each index contains all positions 0-3""" - indices = nstore_indices(4) + indices = storage_nstore_indices(4) for index in indices: assert set(index) == {0, 1, 2, 3} -def test_nstore_indices_n4_sorted(): +def test_storage_nstore_indices_n4_sorted(): """Test that indices are returned in sorted order""" - indices = nstore_indices(4) + indices = storage_nstore_indices(4) # Indices should be sorted lexicographically assert indices == sorted(indices) -def test_nstore_indices_n4_specific_indices(): - """Test that nstore_indices for n=4 generates expected indices""" - indices = nstore_indices(4) +def test_storage_nstore_indices_n4_specific_indices(): + """Test that storage_nstore_indices for n=4 generates expected indices""" + indices = storage_nstore_indices(4) # Based on the algorithm, these are the expected indices for n=4 expected = [ @@ -79,51 +80,51 @@ def test_nstore_indices_n4_specific_indices(): [2, 0, 3, 1], [3, 0, 1, 2], [3, 1, 2, 0], - [3, 2, 0, 1] + [3, 2, 0, 1], ] assert indices == expected -def test_nstore_indices_n5_count(): - """Test that nstore_indices for n=5 generates correct number of indices""" - indices = nstore_indices(5) +def test_storage_nstore_indices_n5_count(): + """Test that storage_nstore_indices for n=5 generates correct number of indices""" + indices = storage_nstore_indices(5) # For n=5, we expect C(5, 2) = 10 indices (central binomial coefficient) assert len(indices) == 10 assert len(indices) == math.comb(5, 2) -def test_nstore_indices_n5_length(): - """Test that nstore_indices for n=5 generates indices of correct length""" - indices = nstore_indices(5) +def test_storage_nstore_indices_n5_length(): + """Test that storage_nstore_indices for n=5 generates indices of correct length""" + indices = storage_nstore_indices(5) # Each index should have length 5 for index in indices: assert len(index) == 5 -def test_nstore_indices_n5_contains_all_positions(): +def test_storage_nstore_indices_n5_contains_all_positions(): """Test that each index contains all positions 0-4""" - indices = nstore_indices(5) + indices = storage_nstore_indices(5) for index in indices: assert set(index) == {0, 1, 2, 3, 4} -def test_nstore_indices_n5_sorted(): +def test_storage_nstore_indices_n5_sorted(): """Test that indices are returned in sorted order""" - indices = nstore_indices(5) + indices = storage_nstore_indices(5) # Indices should be sorted lexicographically assert indices == sorted(indices) -def test_nstore_indices_n5_coverage(): - """Test that nstore_indices for n=5 covers all query patterns""" +def test_storage_nstore_indices_n5_coverage(): + """Test that storage_nstore_indices for n=5 covers all query patterns""" import itertools - indices = nstore_indices(5) + indices = storage_nstore_indices(5) tab = list(range(5)) # Check all possible combinations @@ -142,9 +143,9 @@ def test_nstore_indices_n5_coverage(): assert covered, f"Combination {combination} not covered by any index" -def test_nstore_indices_n5_specific_first_index(): +def test_storage_nstore_indices_n5_specific_first_index(): """Test that first index for n=5 is identity permutation""" - indices = nstore_indices(5) + indices = storage_nstore_indices(5) # First index should be [0, 1, 2, 3, 4] assert indices[0] == [0, 1, 2, 3, 4] diff --git a/tests/storage/test_nstore.py b/tests/storage/test_nstore.py index 3f1355b..a6fea43 100644 --- a/tests/storage/test_nstore.py +++ b/tests/storage/test_nstore.py @@ -3,43 +3,47 @@ Tests NStore operations: add, ask, delete, and query with pattern matching. """ + import pytest from bb import ( - db_open, - nstore_create, - nstore_add, - nstore_ask, - nstore_delete, - nstore_query, - Variable + storage_db_open, + storage_nstore_create, + storage_nstore_add, + storage_nstore_ask, + storage_nstore_delete, + storage_nstore_query, + storage_nstore_bytes, + storage_nstore_count, + Variable, ) # ============================================================================ -# Tests for nstore_create +# Tests for storage_nstore_create # ============================================================================ -def test_nstore_create_basic(): + +def test_storage_nstore_create_basic(): """Test creating basic nstore""" - store = nstore_create((0,), 3) + store = storage_nstore_create((0,), 3) assert store.prefix == (0,) assert store.n == 3 assert len(store.indices) > 0 -def test_nstore_create_custom_prefix(): +def test_storage_nstore_create_custom_prefix(): """Test creating nstore with custom prefix""" - store = nstore_create(('blog',), 3) + store = storage_nstore_create(("blog",), 3) - assert store.prefix == ('blog',) + assert store.prefix == ("blog",) assert store.n == 3 -def test_nstore_create_generates_indices(): - """Test that nstore_create generates correct indices""" - store = nstore_create((0,), 4) +def test_storage_nstore_create_generates_indices(): + """Test that storage_nstore_create generates correct indices""" + store = storage_nstore_create((0,), 4) # For n=4, should have 6 indices assert len(store.indices) == 6 @@ -50,400 +54,416 @@ def test_nstore_create_generates_indices(): # ============================================================================ -# Tests for nstore_add +# Tests for storage_nstore_add # ============================================================================ -def test_nstore_add_basic(): + +def test_storage_nstore_add_basic(): """Test adding tuple to nstore""" - db = db_open(':memory:') - store = nstore_create((0,), 3) + db = storage_db_open(":memory:") + store = storage_nstore_create((0,), 3) - nstore_add(db, store, ('user123', 'name', 'Alice')) + storage_nstore_add(db, store, ("user123", "name", "Alice")) # Should be able to find it - assert nstore_ask(db, store, ('user123', 'name', 'Alice')) + assert storage_nstore_ask(db, store, ("user123", "name", "Alice")) -def test_nstore_add_multiple(): +def test_storage_nstore_add_multiple(): """Test adding multiple tuples""" - db = db_open(':memory:') - store = nstore_create((0,), 3) + db = storage_db_open(":memory:") + store = storage_nstore_create((0,), 3) - nstore_add(db, store, ('user123', 'name', 'Alice')) - nstore_add(db, store, ('user123', 'email', 'alice@example.com')) - nstore_add(db, store, ('user456', 'name', 'Bob')) + storage_nstore_add(db, store, ("user123", "name", "Alice")) + storage_nstore_add(db, store, ("user123", "email", "alice@example.com")) + storage_nstore_add(db, store, ("user456", "name", "Bob")) # All should exist - assert nstore_ask(db, store, ('user123', 'name', 'Alice')) - assert nstore_ask(db, store, ('user123', 'email', 'alice@example.com')) - assert nstore_ask(db, store, ('user456', 'name', 'Bob')) + assert storage_nstore_ask(db, store, ("user123", "name", "Alice")) + assert storage_nstore_ask(db, store, ("user123", "email", "alice@example.com")) + assert storage_nstore_ask(db, store, ("user456", "name", "Bob")) -def test_nstore_add_different_types(): +def test_storage_nstore_add_different_types(): """Test adding tuples with different value types""" - db = db_open(':memory:') - store = nstore_create((0,), 3) + db = storage_db_open(":memory:") + store = storage_nstore_create((0,), 3) - nstore_add(db, store, ('user123', 'age', 42)) - nstore_add(db, store, ('user123', 'score', 3.14)) - nstore_add(db, store, ('user123', 'active', True)) + storage_nstore_add(db, store, ("user123", "age", 42)) + storage_nstore_add(db, store, ("user123", "score", 3.14)) + storage_nstore_add(db, store, ("user123", "active", True)) - assert nstore_ask(db, store, ('user123', 'age', 42)) - assert nstore_ask(db, store, ('user123', 'score', 3.14)) - assert nstore_ask(db, store, ('user123', 'active', True)) + assert storage_nstore_ask(db, store, ("user123", "age", 42)) + assert storage_nstore_ask(db, store, ("user123", "score", 3.14)) + assert storage_nstore_ask(db, store, ("user123", "active", True)) -def test_nstore_add_wrong_size(): +def test_storage_nstore_add_wrong_size(): """Test that adding tuple with wrong size raises error""" - db = db_open(':memory:') - store = nstore_create((0,), 3) + db = storage_db_open(":memory:") + store = storage_nstore_create((0,), 3) with pytest.raises(AssertionError, match="Expected 3 items"): - nstore_add(db, store, ('too', 'few')) + storage_nstore_add(db, store, ("too", "few")) with pytest.raises(AssertionError, match="Expected 3 items"): - nstore_add(db, store, ('too', 'many', 'items', 'here')) + storage_nstore_add(db, store, ("too", "many", "items", "here")) # ============================================================================ -# Tests for nstore_ask +# Tests for storage_nstore_ask # ============================================================================ -def test_nstore_ask_existing(): + +def test_storage_nstore_ask_existing(): """Test asking for existing tuple""" - db = db_open(':memory:') - store = nstore_create((0,), 3) + db = storage_db_open(":memory:") + store = storage_nstore_create((0,), 3) - nstore_add(db, store, ('user123', 'name', 'Alice')) + storage_nstore_add(db, store, ("user123", "name", "Alice")) - assert nstore_ask(db, store, ('user123', 'name', 'Alice')) is True + assert storage_nstore_ask(db, store, ("user123", "name", "Alice")) is True -def test_nstore_ask_nonexistent(): +def test_storage_nstore_ask_nonexistent(): """Test asking for nonexistent tuple""" - db = db_open(':memory:') - store = nstore_create((0,), 3) + db = storage_db_open(":memory:") + store = storage_nstore_create((0,), 3) - assert nstore_ask(db, store, ('user123', 'name', 'Alice')) is False + assert storage_nstore_ask(db, store, ("user123", "name", "Alice")) is False -def test_nstore_ask_after_add(): +def test_storage_nstore_ask_after_add(): """Test ask immediately after add""" - db = db_open(':memory:') - store = nstore_create((0,), 3) + db = storage_db_open(":memory:") + store = storage_nstore_create((0,), 3) - nstore_add(db, store, ('blog', 'title', 'hyper.dev')) + storage_nstore_add(db, store, ("blog", "title", "hyper.dev")) - assert nstore_ask(db, store, ('blog', 'title', 'hyper.dev')) + assert storage_nstore_ask(db, store, ("blog", "title", "hyper.dev")) -def test_nstore_ask_partial_match(): +def test_storage_nstore_ask_partial_match(): """Test that ask requires exact match""" - db = db_open(':memory:') - store = nstore_create((0,), 3) + db = storage_db_open(":memory:") + store = storage_nstore_create((0,), 3) - nstore_add(db, store, ('user123', 'name', 'Alice')) + storage_nstore_add(db, store, ("user123", "name", "Alice")) # Different values should not match - assert nstore_ask(db, store, ('user123', 'name', 'Bob')) is False - assert nstore_ask(db, store, ('user456', 'name', 'Alice')) is False - assert nstore_ask(db, store, ('user123', 'email', 'Alice')) is False + assert storage_nstore_ask(db, store, ("user123", "name", "Bob")) is False + assert storage_nstore_ask(db, store, ("user456", "name", "Alice")) is False + assert storage_nstore_ask(db, store, ("user123", "email", "Alice")) is False # ============================================================================ -# Tests for nstore_delete +# Tests for storage_nstore_delete # ============================================================================ -def test_nstore_delete_existing(): + +def test_storage_nstore_delete_existing(): """Test deleting existing tuple""" - db = db_open(':memory:') - store = nstore_create((0,), 3) + db = storage_db_open(":memory:") + store = storage_nstore_create((0,), 3) - nstore_add(db, store, ('user123', 'name', 'Alice')) - assert nstore_ask(db, store, ('user123', 'name', 'Alice')) + storage_nstore_add(db, store, ("user123", "name", "Alice")) + assert storage_nstore_ask(db, store, ("user123", "name", "Alice")) - nstore_delete(db, store, ('user123', 'name', 'Alice')) + storage_nstore_delete(db, store, ("user123", "name", "Alice")) - assert not nstore_ask(db, store, ('user123', 'name', 'Alice')) + assert not storage_nstore_ask(db, store, ("user123", "name", "Alice")) -def test_nstore_delete_nonexistent(): +def test_storage_nstore_delete_nonexistent(): """Test deleting nonexistent tuple does not error""" - db = db_open(':memory:') - store = nstore_create((0,), 3) + db = storage_db_open(":memory:") + store = storage_nstore_create((0,), 3) # Should not raise - nstore_delete(db, store, ('user123', 'name', 'Alice')) + storage_nstore_delete(db, store, ("user123", "name", "Alice")) -def test_nstore_delete_one_of_many(): +def test_storage_nstore_delete_one_of_many(): """Test deleting one tuple doesn't affect others""" - db = db_open(':memory:') - store = nstore_create((0,), 3) + db = storage_db_open(":memory:") + store = storage_nstore_create((0,), 3) - nstore_add(db, store, ('user123', 'name', 'Alice')) - nstore_add(db, store, ('user123', 'email', 'alice@example.com')) - nstore_add(db, store, ('user456', 'name', 'Bob')) + storage_nstore_add(db, store, ("user123", "name", "Alice")) + storage_nstore_add(db, store, ("user123", "email", "alice@example.com")) + storage_nstore_add(db, store, ("user456", "name", "Bob")) - nstore_delete(db, store, ('user123', 'email', 'alice@example.com')) + storage_nstore_delete(db, store, ("user123", "email", "alice@example.com")) # Others should still exist - assert nstore_ask(db, store, ('user123', 'name', 'Alice')) - assert not nstore_ask(db, store, ('user123', 'email', 'alice@example.com')) - assert nstore_ask(db, store, ('user456', 'name', 'Bob')) + assert storage_nstore_ask(db, store, ("user123", "name", "Alice")) + assert not storage_nstore_ask(db, store, ("user123", "email", "alice@example.com")) + assert storage_nstore_ask(db, store, ("user456", "name", "Bob")) # ============================================================================ -# Tests for nstore_query - Simple queries +# Tests for storage_nstore_query - Simple queries # ============================================================================ -def test_nstore_query_single_variable(): + +def test_storage_nstore_query_single_variable(): """Test query with single variable""" - db = db_open(':memory:') - store = nstore_create((0,), 3) + db = storage_db_open(":memory:") + store = storage_nstore_create((0,), 3) - nstore_add(db, store, ('P4X432', 'blog/title', 'hyper.dev')) + storage_nstore_add(db, store, ("P4X432", "blog/title", "hyper.dev")) - results = nstore_query(db, store, ('P4X432', 'blog/title', Variable('title'))) + results = storage_nstore_query( + db, store, ("P4X432", "blog/title", Variable("title")) + ) assert len(results) == 1 - assert results[0] == {'title': 'hyper.dev'} + assert results[0] == {"title": "hyper.dev"} -def test_nstore_query_multiple_results(): +def test_storage_nstore_query_multiple_results(): """Test query returning multiple results""" - db = db_open(':memory:') - store = nstore_create((0,), 3) + db = storage_db_open(":memory:") + store = storage_nstore_create((0,), 3) - nstore_add(db, store, ('user123', 'tag', 'python')) - nstore_add(db, store, ('user123', 'tag', 'rust')) - nstore_add(db, store, ('user123', 'tag', 'go')) + storage_nstore_add(db, store, ("user123", "tag", "python")) + storage_nstore_add(db, store, ("user123", "tag", "rust")) + storage_nstore_add(db, store, ("user123", "tag", "go")) - results = nstore_query(db, store, ('user123', 'tag', Variable('tag'))) + results = storage_nstore_query(db, store, ("user123", "tag", Variable("tag"))) assert len(results) == 3 - tags = {r['tag'] for r in results} - assert tags == {'python', 'rust', 'go'} + tags = {r["tag"] for r in results} + assert tags == {"python", "rust", "go"} -def test_nstore_query_no_results(): +def test_storage_nstore_query_no_results(): """Test query with no matching tuples""" - db = db_open(':memory:') - store = nstore_create((0,), 3) + db = storage_db_open(":memory:") + store = storage_nstore_create((0,), 3) - nstore_add(db, store, ('user123', 'name', 'Alice')) + storage_nstore_add(db, store, ("user123", "name", "Alice")) - results = nstore_query(db, store, ('user456', 'name', Variable('name'))) + results = storage_nstore_query(db, store, ("user456", "name", Variable("name"))) assert len(results) == 0 -def test_nstore_query_multiple_variables(): +def test_storage_nstore_query_multiple_variables(): """Test query with multiple variables""" - db = db_open(':memory:') - store = nstore_create((0,), 3) + db = storage_db_open(":memory:") + store = storage_nstore_create((0,), 3) - nstore_add(db, store, ('user123', 'name', 'Alice')) - nstore_add(db, store, ('user456', 'name', 'Bob')) + storage_nstore_add(db, store, ("user123", "name", "Alice")) + storage_nstore_add(db, store, ("user456", "name", "Bob")) - results = nstore_query(db, store, (Variable('uid'), 'name', Variable('name'))) + results = storage_nstore_query( + db, store, (Variable("uid"), "name", Variable("name")) + ) assert len(results) == 2 # Check both users are in results - uids = {r['uid'] for r in results} - names = {r['name'] for r in results} - assert uids == {'user123', 'user456'} - assert names == {'Alice', 'Bob'} + uids = {r["uid"] for r in results} + names = {r["name"] for r in results} + assert uids == {"user123", "user456"} + assert names == {"Alice", "Bob"} -def test_nstore_query_no_variables(): +def test_storage_nstore_query_no_variables(): """Test query with no variables (exact match)""" - db = db_open(':memory:') - store = nstore_create((0,), 3) + db = storage_db_open(":memory:") + store = storage_nstore_create((0,), 3) - nstore_add(db, store, ('user123', 'name', 'Alice')) - nstore_add(db, store, ('user456', 'name', 'Bob')) + storage_nstore_add(db, store, ("user123", "name", "Alice")) + storage_nstore_add(db, store, ("user456", "name", "Bob")) - results = nstore_query(db, store, ('user123', 'name', 'Alice')) + results = storage_nstore_query(db, store, ("user123", "name", "Alice")) assert len(results) == 1 assert results[0] == {} # No variables, empty binding # ============================================================================ -# Tests for nstore_query - Multi-pattern joins +# Tests for storage_nstore_query - Multi-pattern joins # ============================================================================ -def test_nstore_query_two_pattern_join(): + +def test_storage_nstore_query_two_pattern_join(): """Test query with two patterns (simple join)""" - db = db_open(':memory:') - store = nstore_create((0,), 3) + db = storage_db_open(":memory:") + store = storage_nstore_create((0,), 3) # Blog data - nstore_add(db, store, ('P4X432', 'blog/title', 'hyper.dev')) + storage_nstore_add(db, store, ("P4X432", "blog/title", "hyper.dev")) # Post data - nstore_add(db, store, ('123456', 'post/blog', 'P4X432')) - nstore_add(db, store, ('123456', 'post/title', 'Hello World')) - - results = nstore_query( - db, store, - (Variable('blog_uid'), 'blog/title', 'hyper.dev'), - (Variable('post_uid'), 'post/blog', Variable('blog_uid')) + storage_nstore_add(db, store, ("123456", "post/blog", "P4X432")) + storage_nstore_add(db, store, ("123456", "post/title", "Hello World")) + + results = storage_nstore_query( + db, + store, + (Variable("blog_uid"), "blog/title", "hyper.dev"), + (Variable("post_uid"), "post/blog", Variable("blog_uid")), ) assert len(results) == 1 - assert results[0]['blog_uid'] == 'P4X432' - assert results[0]['post_uid'] == '123456' + assert results[0]["blog_uid"] == "P4X432" + assert results[0]["post_uid"] == "123456" -def test_nstore_query_three_pattern_join(): +def test_storage_nstore_query_three_pattern_join(): """Test query with three patterns (multi-hop join)""" - db = db_open(':memory:') - store = nstore_create((0,), 3) + db = storage_db_open(":memory:") + store = storage_nstore_create((0,), 3) # Blog - nstore_add(db, store, ('P4X432', 'blog/title', 'hyper.dev')) + storage_nstore_add(db, store, ("P4X432", "blog/title", "hyper.dev")) # Posts - nstore_add(db, store, ('123456', 'post/blog', 'P4X432')) - nstore_add(db, store, ('123456', 'post/title', 'Hello World')) - nstore_add(db, store, ('654321', 'post/blog', 'P4X432')) - nstore_add(db, store, ('654321', 'post/title', 'Goodbye World')) - - results = nstore_query( - db, store, - (Variable('blog_uid'), 'blog/title', 'hyper.dev'), - (Variable('post_uid'), 'post/blog', Variable('blog_uid')), - (Variable('post_uid'), 'post/title', Variable('post_title')) + storage_nstore_add(db, store, ("123456", "post/blog", "P4X432")) + storage_nstore_add(db, store, ("123456", "post/title", "Hello World")) + storage_nstore_add(db, store, ("654321", "post/blog", "P4X432")) + storage_nstore_add(db, store, ("654321", "post/title", "Goodbye World")) + + results = storage_nstore_query( + db, + store, + (Variable("blog_uid"), "blog/title", "hyper.dev"), + (Variable("post_uid"), "post/blog", Variable("blog_uid")), + (Variable("post_uid"), "post/title", Variable("post_title")), ) assert len(results) == 2 - titles = {r['post_title'] for r in results} - assert titles == {'Hello World', 'Goodbye World'} + titles = {r["post_title"] for r in results} + assert titles == {"Hello World", "Goodbye World"} -def test_nstore_query_join_filters(): +def test_storage_nstore_query_join_filters(): """Test that join properly filters results""" - db = db_open(':memory:') - store = nstore_create((0,), 3) + db = storage_db_open(":memory:") + store = storage_nstore_create((0,), 3) # Two blogs - nstore_add(db, store, ('blog1', 'blog/title', 'Blog One')) - nstore_add(db, store, ('blog2', 'blog/title', 'Blog Two')) + storage_nstore_add(db, store, ("blog1", "blog/title", "Blog One")) + storage_nstore_add(db, store, ("blog2", "blog/title", "Blog Two")) # Posts for blog1 - nstore_add(db, store, ('post1', 'post/blog', 'blog1')) - nstore_add(db, store, ('post1', 'post/title', 'Post 1')) + storage_nstore_add(db, store, ("post1", "post/blog", "blog1")) + storage_nstore_add(db, store, ("post1", "post/title", "Post 1")) # Posts for blog2 - nstore_add(db, store, ('post2', 'post/blog', 'blog2')) - nstore_add(db, store, ('post2', 'post/title', 'Post 2')) + storage_nstore_add(db, store, ("post2", "post/blog", "blog2")) + storage_nstore_add(db, store, ("post2", "post/title", "Post 2")) # Query only blog1 posts - results = nstore_query( - db, store, - (Variable('blog_uid'), 'blog/title', 'Blog One'), - (Variable('post_uid'), 'post/blog', Variable('blog_uid')), - (Variable('post_uid'), 'post/title', Variable('post_title')) + results = storage_nstore_query( + db, + store, + (Variable("blog_uid"), "blog/title", "Blog One"), + (Variable("post_uid"), "post/blog", Variable("blog_uid")), + (Variable("post_uid"), "post/title", Variable("post_title")), ) assert len(results) == 1 - assert results[0]['post_title'] == 'Post 1' + assert results[0]["post_title"] == "Post 1" -def test_nstore_query_multiple_join_results(): +def test_storage_nstore_query_multiple_join_results(): """Test join that produces multiple results""" - db = db_open(':memory:') - store = nstore_create((0,), 3) + db = storage_db_open(":memory:") + store = storage_nstore_create((0,), 3) # One author, multiple posts - nstore_add(db, store, ('alice', 'author/name', 'Alice')) - - nstore_add(db, store, ('post1', 'post/author', 'alice')) - nstore_add(db, store, ('post1', 'post/title', 'First Post')) - nstore_add(db, store, ('post2', 'post/author', 'alice')) - nstore_add(db, store, ('post2', 'post/title', 'Second Post')) - nstore_add(db, store, ('post3', 'post/author', 'alice')) - nstore_add(db, store, ('post3', 'post/title', 'Third Post')) - - results = nstore_query( - db, store, - (Variable('author_uid'), 'author/name', 'Alice'), - (Variable('post_uid'), 'post/author', Variable('author_uid')), - (Variable('post_uid'), 'post/title', Variable('title')) + storage_nstore_add(db, store, ("alice", "author/name", "Alice")) + + storage_nstore_add(db, store, ("post1", "post/author", "alice")) + storage_nstore_add(db, store, ("post1", "post/title", "First Post")) + storage_nstore_add(db, store, ("post2", "post/author", "alice")) + storage_nstore_add(db, store, ("post2", "post/title", "Second Post")) + storage_nstore_add(db, store, ("post3", "post/author", "alice")) + storage_nstore_add(db, store, ("post3", "post/title", "Third Post")) + + results = storage_nstore_query( + db, + store, + (Variable("author_uid"), "author/name", "Alice"), + (Variable("post_uid"), "post/author", Variable("author_uid")), + (Variable("post_uid"), "post/title", Variable("title")), ) assert len(results) == 3 - titles = {r['title'] for r in results} - assert titles == {'First Post', 'Second Post', 'Third Post'} + titles = {r["title"] for r in results} + assert titles == {"First Post", "Second Post", "Third Post"} # ============================================================================ -# Tests for nstore_query - Edge cases +# Tests for storage_nstore_query - Edge cases # ============================================================================ -def test_nstore_query_empty_store(): + +def test_storage_nstore_query_empty_store(): """Test query on empty store""" - db = db_open(':memory:') - store = nstore_create((0,), 3) + db = storage_db_open(":memory:") + store = storage_nstore_create((0,), 3) - results = nstore_query(db, store, (Variable('a'), Variable('b'), Variable('c'))) + results = storage_nstore_query( + db, store, (Variable("a"), Variable("b"), Variable("c")) + ) assert len(results) == 0 -def test_nstore_query_with_integers(): +def test_storage_nstore_query_with_integers(): """Test query with integer values""" - db = db_open(':memory:') - store = nstore_create((0,), 3) + db = storage_db_open(":memory:") + store = storage_nstore_create((0,), 3) - nstore_add(db, store, ('user123', 'age', 25)) - nstore_add(db, store, ('user456', 'age', 30)) + storage_nstore_add(db, store, ("user123", "age", 25)) + storage_nstore_add(db, store, ("user456", "age", 30)) - results = nstore_query(db, store, (Variable('uid'), 'age', Variable('age'))) + results = storage_nstore_query(db, store, (Variable("uid"), "age", Variable("age"))) assert len(results) == 2 - ages = {r['age'] for r in results} + ages = {r["age"] for r in results} assert ages == {25, 30} -def test_nstore_query_with_nested_tuple(): +def test_storage_nstore_query_with_nested_tuple(): """Test query with nested tuple values""" - db = db_open(':memory:') - store = nstore_create((0,), 3) + db = storage_db_open(":memory:") + store = storage_nstore_create((0,), 3) - nstore_add(db, store, ('item123', 'tags', ('python', 'code', 'tutorial'))) + storage_nstore_add(db, store, ("item123", "tags", ("python", "code", "tutorial"))) - results = nstore_query(db, store, ('item123', 'tags', Variable('tags'))) + results = storage_nstore_query(db, store, ("item123", "tags", Variable("tags"))) assert len(results) == 1 - assert results[0]['tags'] == ('python', 'code', 'tutorial') + assert results[0]["tags"] == ("python", "code", "tutorial") -def test_nstore_query_pattern_wrong_size(): +def test_storage_nstore_query_pattern_wrong_size(): """Test that pattern with wrong size raises error""" - db = db_open(':memory:') - store = nstore_create((0,), 3) + db = storage_db_open(":memory:") + store = storage_nstore_create((0,), 3) with pytest.raises(AssertionError, match="Pattern length .* doesn't match"): - nstore_query(db, store, (Variable('a'), Variable('b'))) + storage_nstore_query(db, store, (Variable("a"), Variable("b"))) -def test_nstore_query_result_list_slicing(): +def test_storage_nstore_query_result_list_slicing(): """Test that query results can be sliced for pagination""" - db = db_open(':memory:') - store = nstore_create((0,), 3) + db = storage_db_open(":memory:") + store = storage_nstore_create((0,), 3) # Add many tuples for i in range(10): - nstore_add(db, store, (f'user{i}', 'type', 'user')) + storage_nstore_add(db, store, (f"user{i}", "type", "user")) - results = nstore_query(db, store, (Variable('uid'), 'type', 'user')) + results = storage_nstore_query(db, store, (Variable("uid"), "type", "user")) # Should get all 10 assert len(results) == 10 @@ -454,3 +474,166 @@ def test_nstore_query_result_list_slicing(): assert len(page1) == 5 assert len(page2) == 5 + + +# ============================================================================ +# Tests for storage_nstore_count +# ============================================================================ + + +def test_storage_nstore_count_single_pattern(): + """Test count with single pattern""" + db = storage_db_open(":memory:") + store = storage_nstore_create((0,), 3) + + storage_nstore_add(db, store, ("user123", "tag", "python")) + storage_nstore_add(db, store, ("user123", "tag", "rust")) + storage_nstore_add(db, store, ("user123", "tag", "go")) + + count = storage_nstore_count(db, store, ("user123", "tag", Variable("tag"))) + + assert count == 3 + + +def test_storage_nstore_count_no_matches(): + """Test count with no matching tuples""" + db = storage_db_open(":memory:") + store = storage_nstore_create((0,), 3) + + storage_nstore_add(db, store, ("user123", "name", "Alice")) + + count = storage_nstore_count(db, store, ("user456", "name", Variable("name"))) + + assert count == 0 + + +def test_storage_nstore_count_empty_store(): + """Test count on empty store""" + db = storage_db_open(":memory:") + store = storage_nstore_create((0,), 3) + + count = storage_nstore_count( + db, store, (Variable("a"), Variable("b"), Variable("c")) + ) + + assert count == 0 + + +def test_storage_nstore_count_exact_match(): + """Test count with exact match (no variables)""" + db = storage_db_open(":memory:") + store = storage_nstore_create((0,), 3) + + storage_nstore_add(db, store, ("user123", "name", "Alice")) + storage_nstore_add(db, store, ("user456", "name", "Bob")) + + count = storage_nstore_count(db, store, ("user123", "name", "Alice")) + + assert count == 1 + + +def test_storage_nstore_count_multiple_variables(): + """Test count with multiple variables""" + db = storage_db_open(":memory:") + store = storage_nstore_create((0,), 3) + + storage_nstore_add(db, store, ("user123", "name", "Alice")) + storage_nstore_add(db, store, ("user456", "name", "Bob")) + storage_nstore_add(db, store, ("user789", "email", "carol@example.com")) + + count = storage_nstore_count(db, store, (Variable("uid"), "name", Variable("name"))) + + assert count == 2 + + +def test_storage_nstore_count_with_integers(): + """Test count with integer values""" + db = storage_db_open(":memory:") + store = storage_nstore_create((0,), 3) + + storage_nstore_add(db, store, ("user123", "age", 25)) + storage_nstore_add(db, store, ("user456", "age", 30)) + storage_nstore_add(db, store, ("user789", "age", 35)) + + count = storage_nstore_count(db, store, (Variable("uid"), "age", Variable("age"))) + + assert count == 3 + + +def test_storage_nstore_count_pattern_wrong_size(): + """Test that pattern with wrong size raises error""" + db = storage_db_open(":memory:") + store = storage_nstore_create((0,), 3) + + with pytest.raises(AssertionError, match="Pattern length .* doesn't match"): + storage_nstore_count(db, store, (Variable("a"), Variable("b"))) + + +# ============================================================================ +# Tests for storage_nstore_bytes +# ============================================================================ + + +def test_storage_nstore_bytes_single_pattern(): + """Test bytes with single pattern""" + db = storage_db_open(":memory:") + store = storage_nstore_create((0,), 3) + + storage_nstore_add(db, store, ("user123", "tag", "python")) + storage_nstore_add(db, store, ("user123", "tag", "rust")) + storage_nstore_add(db, store, ("user123", "tag", "go")) + + total = storage_nstore_bytes(db, store, ("user123", "tag", Variable("tag"))) + + # Should be non-zero + assert total > 0 + + +def test_storage_nstore_bytes_no_matches(): + """Test bytes with no matching tuples""" + db = storage_db_open(":memory:") + store = storage_nstore_create((0,), 3) + + storage_nstore_add(db, store, ("user123", "name", "Alice")) + + total = storage_nstore_bytes(db, store, ("user456", "name", Variable("name"))) + + assert total == 0 + + +def test_storage_nstore_bytes_empty_store(): + """Test bytes on empty store""" + db = storage_db_open(":memory:") + store = storage_nstore_create((0,), 3) + + total = storage_nstore_bytes( + db, store, (Variable("a"), Variable("b"), Variable("c")) + ) + + assert total == 0 + + +def test_storage_nstore_bytes_increases_with_more_data(): + """Test that bytes increases with more data""" + db = storage_db_open(":memory:") + store = storage_nstore_create((0,), 3) + + storage_nstore_add(db, store, ("user123", "tag", "python")) + bytes1 = storage_nstore_bytes(db, store, ("user123", "tag", Variable("tag"))) + + storage_nstore_add(db, store, ("user123", "tag", "rust")) + bytes2 = storage_nstore_bytes(db, store, ("user123", "tag", Variable("tag"))) + + storage_nstore_add(db, store, ("user123", "tag", "go")) + bytes3 = storage_nstore_bytes(db, store, ("user123", "tag", Variable("tag"))) + + assert bytes1 < bytes2 < bytes3 + + +def test_storage_nstore_bytes_pattern_wrong_size(): + """Test that pattern with wrong size raises error""" + db = storage_db_open(":memory:") + store = storage_nstore_create((0,), 3) + + with pytest.raises(AssertionError, match="Pattern length .* doesn't match"): + storage_nstore_bytes(db, store, (Variable("a"), Variable("b"))) diff --git a/tests/test_bonafide.py b/tests/test_bonafide.py new file mode 100644 index 0000000..83e0777 --- /dev/null +++ b/tests/test_bonafide.py @@ -0,0 +1,595 @@ +#!/usr/bin/env python3 +""" +Simple tests for core bonafide.py functions +""" + +import sys +import os +import uuid +import contextlib +import tempfile + +# Add the project root to Python path +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from bonafide import ( + pool_size_default, + bytes_write, + bytes_read, + bytes_next, + nstore_indices, + nstore_new, + BBH, + Variable, + NStore, + Bonafide, + BonafideCnx, + BonafideTxn, + # Integration test functions + new, + apply, + set, + query, + delete, + count, + bytes, + nstore, + nstore_add, + nstore_ask, + nstore_delete, + nstore_query, + nstore_count, + nstore_bytes, + transactional, +) + + +def test_pool_size_default_uses_cpu_count(monkeypatch): + """Test that pool_size_default uses CPU count""" + monkeypatch.setattr(os, "cpu_count", lambda: 4) + result = pool_size_default() + assert result == 8 # 2 * 4 + + +def test_pool_size_default_fallback(monkeypatch): + """Test fallback when CPU count is not available""" + monkeypatch.setattr(os, "cpu_count", lambda: None) + result = pool_size_default() + assert result == 4 # POOL_SIZE_DEFAULT + + +def test_bytes_write_read_roundtrip(): + """Test that bytes_write and bytes_read work together""" + original = ( + None, + True, + False, + 0, + 1, + -1, + 3.14, + b"hello", + "world", + (1, 2, 3), + (4, 5, 6), + ) + encoded = bytes_write(original) + decoded = bytes_read(encoded) + assert decoded == original + + +def test_bytes_write_none(): + """Test encoding None""" + assert bytes_write((None,)) == b"\x00" + + +def test_bytes_write_boolean(): + """Test encoding booleans""" + assert bytes_write((True,)) == b"\x08" + assert bytes_write((False,)) == b"\x09" + + +def test_bytes_write_integers(): + """Test encoding integers""" + assert bytes_write((0,)) == b"\x04" + assert bytes_write((42,)) == b"\x05\x00\x00\x00\x00\x00\x00\x00*" + assert bytes_write((-42,)) == b"\x06\xff\xff\xff\xff\xff\xff\xff\xd5" + + +def test_bytes_write_float(): + """Test encoding floats""" + encoded = bytes_write((3.14,)) + assert len(encoded) == 9 # 1 byte type + 8 bytes float + + +def test_bytes_write_string(): + """Test encoding strings""" + encoded = bytes_write(("hello",)) + assert encoded.startswith(b"\x02hello\x00") + + +def test_bytes_write_bytes(): + """Test encoding bytes""" + encoded = bytes_write((b"hello",)) + assert encoded.startswith(b"\x01hello\x00") + + +def test_bytes_write_uuid(): + """Test encoding UUID""" + test_uuid = uuid.UUID("12345678-1234-5678-1234-567812345678") + encoded = bytes_write((test_uuid,)) + assert len(encoded) == 17 # 1 byte type + 16 bytes UUID + + +def test_bytes_write_bbh(): + """Test encoding BBH""" + test_bbh = BBH("a" * 64) # 64 char hex string + encoded = bytes_write((test_bbh,)) + assert len(encoded) == 33 # 1 byte type + 32 bytes hash + + +def test_bytes_write_nested(): + """Test encoding nested tuples""" + original = ((1, 2), (3, 4)) + encoded = bytes_write(original) + decoded = bytes_read(encoded) + assert decoded == original + + +def test_bytes_next_simple(): + """Test bytes_next with simple cases""" + assert bytes_next(b"\x00") == b"\x01" + assert bytes_next(b"\x01") == b"\x02" + assert bytes_next(b"\xff") is None + + +def test_bytes_next_multi_byte(): + """Test bytes_next with multi-byte sequences""" + assert bytes_next(b"\x00\x00") == b"\x00\x01" + # Note: bytes_next returns the next byte sequence, which may be shorter + # when there's trailing 0xFF bytes + result = bytes_next(b"\x00\xff") + assert result == b"\x01" # This is correct behavior + assert bytes_next(b"\xff\xff") is None + + +def test_nstore_indices_n_1(): + """Test nstore_indices for n=1""" + indices = nstore_indices(1) + assert len(indices) == 1 + assert indices[0] == [0] + + +def test_nstore_indices_n_2(): + """Test nstore_indices for n=2""" + indices = nstore_indices(2) + assert len(indices) == 2 + assert [0, 1] in indices + assert [1, 0] in indices + + +def test_nstore_indices_n_3(): + """Test nstore_indices for n=3""" + indices = nstore_indices(3) + assert len(indices) == 3 # C(3,1) = 3 + expected = [[0, 1, 2], [1, 2, 0], [2, 0, 1]] + for idx in indices: + assert idx in expected + + +def test_nstore_indices_n_4(): + """Test nstore_indices for n=4""" + indices = nstore_indices(4) + assert len(indices) == 6 # C(4,2) = 6 + + +def test_nstore_new(): + """Test nstore_new function""" + prefix = ("test",) + n = 2 + name = "test_store" + nstore = nstore_new(name, prefix, n) + + assert nstore.prefix == prefix + assert nstore.n == n + assert nstore.name == name + assert len(nstore.indices) == 2 # C(2,1) = 2 + + +def test_bbh_creation_with_bytes(): + """Test BBH creation with bytes""" + hash_bytes = b"\x00" * 32 # 32 bytes + bbh = BBH(hash_bytes) + assert bbh.value == hash_bytes + + +def test_bbh_creation_with_hex_string(): + """Test BBH creation with hex string""" + hash_hex = "0" * 64 # 64 hex characters + bbh = BBH(hash_hex) + assert bbh.value == hash_hex + + +def test_variable_creation(): + """Test Variable creation""" + var = Variable("test_var") + assert var.name == "test_var" + + +def test_variable_equality(): + """Test Variable equality""" + var1 = Variable("test") + var2 = Variable("test") + var3 = Variable("other") + + assert var1 == var2 + assert var1 != var3 + + +def test_nstore_creation(): + """Test NStore creation""" + prefix = ("test",) + n = 3 + indices = [[0, 1, 2], [1, 2, 0], [2, 0, 1]] + name = "test_store" + + nstore = NStore(prefix=prefix, n=n, indices=indices, name=name) + + assert nstore.prefix == prefix + assert nstore.n == n + assert nstore.indices == indices + assert nstore.name == name + + +def test_bonafide_creation(): + """Test Bonafide namedtuple creation""" + bonafide = Bonafide( + db_path="test.db", + pool_size=4, + worker_queue=None, + worker_threads=[], + worker_lock=None, + subspace={}, + ) + + assert bonafide.db_path == "test.db" + assert bonafide.pool_size == 4 + + +def test_bonafide_cnx_creation(): + """Test BonafideCnx creation""" + bonafide = Bonafide( + db_path="test.db", + pool_size=4, + worker_queue=None, + worker_threads=[], + worker_lock=None, + subspace={}, + ) + + # Create a mock connection + mock_conn = object() + cnx = BonafideCnx(bonafide, mock_conn) + + assert cnx.bonafide == bonafide + assert cnx.sqlite == mock_conn + + +def test_bonafide_txn_creation(): + """Test BonafideTxn creation""" + # Create a mock connection + mock_cnx = object() + txn = BonafideTxn(mock_cnx) + + assert txn.cnx == mock_cnx + + +@contextlib.contextmanager +def temp_bonafide_db(pool_size=2): + """Context manager for creating a temporary Bonafide database. + + Yields: + bonafide: A Bonafide instance connected to a temporary database + """ + with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as tmp_file: + db_path = tmp_file.name + + try: + # Create and yield the Bonafide instance + bonafide_instance = new(db_path=db_path, pool_size=pool_size) + yield bonafide_instance + finally: + # Clean up the temporary database file + if os.path.exists(db_path): + os.unlink(db_path) + + +def test_bonafide_basic_workflow(): + """Test the basic bonafide workflow: create, set, get, delete""" + + with temp_bonafide_db(pool_size=2) as bonafide_instance: + # 1. Test basic key-value operations using apply + # Set a key-value pair + def set_test_data(cnx): + set(cnx, b"test_key", b"test_value") + + apply(bonafide_instance, set_test_data) + + # Get the value back + def get_test_data(cnx): + return query(cnx, b"test_key") + + result = apply(bonafide_instance, get_test_data) + assert result == b"test_value", f"Expected b'test_value', got {result}" + + # Test count + def count_all(cnx): + return count(cnx, b"", b"\xff") + + count_result = apply(bonafide_instance, count_all) + assert count_result == 1, f"Expected 1 item, got {count_result}" + + # Test bytes calculation + def calc_bytes(cnx): + return bytes(cnx, b"", b"\xff") + + bytes_result = apply(bonafide_instance, calc_bytes) + expected_bytes = len(b"test_key") + len(b"test_value") + assert bytes_result == expected_bytes, ( + f"Expected {expected_bytes}, got {bytes_result}" + ) + + # 2. Test deletion + def delete_test_data(cnx): + return delete(cnx, b"test_key") + + delete_count = apply(bonafide_instance, delete_test_data) + assert delete_count == 1, f"Expected 1 deletion, got {delete_count}" + + # Verify deletion + result_after_delete = apply(bonafide_instance, get_test_data) + assert result_after_delete is None, ( + f"Expected None after deletion, got {result_after_delete}" + ) + + +def test_nstore_workflow(): + """Test nstore functionality: create store, add tuples, query, delete""" + + with temp_bonafide_db(pool_size=2) as bonafide_instance: + # 1. Create an nstore for 3-tuples (automatically registered) + nstore(bonafide_instance, "test_relations", 3) + + # 2. Add some test data + def add_test_tuples(cnx): + # Add some test tuples: (subject, predicate, object) + tuples_to_add = [ + ("alice", "knows", "bob"), + ("bob", "knows", "charlie"), + ("alice", "likes", "python"), + ("bob", "likes", "rust"), + ] + + for tuple_data in tuples_to_add: + nstore_add(cnx, "test_relations", tuple_data) + + apply(bonafide_instance, add_test_tuples) + + # 3. Test nstore_ask (existence check) + def check_tuple_exists(cnx): + return nstore_ask(cnx, "test_relations", ("alice", "knows", "bob")) + + exists = apply(bonafide_instance, check_tuple_exists) + assert exists is True, "Expected tuple to exist" + + # 4. Test nstore_count + def count_relations(cnx): + return nstore_count( + cnx, "test_relations", (Variable("s"), Variable("p"), Variable("o")) + ) + + total_count = apply(bonafide_instance, count_relations) + assert total_count == 4, f"Expected 4 tuples, got {total_count}" + + # 5. Test nstore_query with patterns + def query_alice_relations(cnx): + # Query all relations where subject is "alice" + return nstore_query( + cnx, "test_relations", ("alice", Variable("p"), Variable("o")) + ) + + alice_results = apply(bonafide_instance, query_alice_relations) + assert len(alice_results) == 2, ( + f"Expected 2 results for alice, got {len(alice_results)}" + ) + + # Verify the results contain the expected bindings + found_knows = False + found_likes = False + for result in alice_results: + if result.get("p") == "knows" and result.get("o") == "bob": + found_knows = True + elif result.get("p") == "likes" and result.get("o") == "python": + found_likes = True + + assert found_knows and found_likes, ( + "Expected to find both 'knows' and 'likes' relations for alice" + ) + + # 6. Test nstore_delete + def delete_bob_relations(cnx): + # Delete all relations where subject is "bob" + bob_relations = nstore_query( + cnx, "test_relations", ("bob", Variable("p"), Variable("o")) + ) + for relation in bob_relations: + nstore_delete( + cnx, "test_relations", ("bob", relation["p"], relation["o"]) + ) + + apply(bonafide_instance, delete_bob_relations) + + # Verify deletion + remaining_count = apply(bonafide_instance, count_relations) + assert remaining_count == 2, ( + f"Expected 2 tuples after deletion, got {remaining_count}" + ) + + +def test_bbh_functionality(): + """Test BBH (Beyond Babel Hash) functionality""" + + with temp_bonafide_db(pool_size=2) as bonafide_instance: + # 1. Test BBH creation and storage + test_hash_hex = "a" * 64 # 64 char hex string + test_bbh = BBH(test_hash_hex) + + def store_bbh(cnx): + # Store BBH as key and value + set(cnx, bytes_write((test_bbh,)), bytes_write(("metadata",))) + + apply(bonafide_instance, store_bbh) + + # 2. Test retrieval and verification + def retrieve_bbh(cnx): + encoded_key = bytes_write((test_bbh,)) + return query(cnx, encoded_key) + + result = apply(bonafide_instance, retrieve_bbh) + assert result is not None, "Expected to find BBH in storage" + + # Decode the result to verify it's our metadata + decoded = bytes_read(result) + assert decoded == ("metadata",), f"Expected ('metadata',), got {decoded}" + + +def test_transaction_isolation(): + """Test that transactions are properly isolated""" + + with temp_bonafide_db(pool_size=2) as bonafide_instance: + # 1. Set initial data + def set_initial_data(cnx): + set(cnx, b"counter", b"0") + + apply(bonafide_instance, set_initial_data) + + # 2. Test multiple concurrent operations + def increment_counter(cnx): + current = query(cnx, b"counter") + if current is None: + current_value = 0 + else: + current_value = int(current.decode()) + + new_value = current_value + 1 + set(cnx, b"counter", str(new_value).encode()) + return new_value + + # Apply multiple increments + results = [] + for i in range(5): + result = apply(bonafide_instance, increment_counter) + results.append(result) + + # Verify final counter value + def get_final_counter(cnx): + final = query(cnx, b"counter") + return int(final.decode()) if final else 0 + + final_counter = apply(bonafide_instance, get_final_counter) + assert final_counter == 5, ( + f"Expected final counter to be 5, got {final_counter}" + ) + + +def test_nstore_bytes(): + """Test nstore_bytes functionality for calculating storage usage""" + + with temp_bonafide_db(pool_size=2) as bonafide_instance: + # 1. Create an nstore and add some data + nstore(bonafide_instance, "test_bytes", 2) + + def add_test_data(cnx): + # Add some test tuples + tuples_to_add = [ + ("user1", "data1"), + ("user2", "data2"), + ("user1", "data3"), + ] + + for tuple_data in tuples_to_add: + nstore_add(cnx, "test_bytes", tuple_data) + + apply(bonafide_instance, add_test_data) + + # 2. Test nstore_bytes with different patterns + def calc_bytes_for_user(cnx, user): + return nstore_bytes(cnx, "test_bytes", (user, Variable("data"))) + + # Calculate bytes for user1 + user1_bytes = apply(bonafide_instance, calc_bytes_for_user, "user1") + assert user1_bytes > 0, "Expected positive byte count for user1" + + # Calculate bytes for user2 + user2_bytes = apply(bonafide_instance, calc_bytes_for_user, "user2") + assert user2_bytes > 0, "Expected positive byte count for user2" + + # Calculate bytes for all users + def calc_all_bytes(cnx): + return nstore_bytes(cnx, "test_bytes", (Variable("user"), Variable("data"))) + + total_bytes = apply(bonafide_instance, calc_all_bytes) + assert total_bytes > 0, "Expected positive total byte count" + + # Verify that total bytes is at least as much as individual user bytes + assert total_bytes >= user1_bytes + user2_bytes, ( + "Total bytes should be at least sum of individual user bytes" + ) + + +def test_transactional_decorator(): + """Test the transactional decorator behavior""" + + with temp_bonafide_db(pool_size=2) as bonafide_instance: + # 1. Test that transactional decorator works with BonafideCnx + @transactional + def test_transactional_function(txn): + # This function should work when called with a BonafideCnx + set(txn, b"test_key", b"test_value") + result = query(txn, b"test_key") + return result + + def run_transactional_test(cnx): + # Test the transactional function + result = test_transactional_function(cnx) + assert result == b"test_value", f"Expected b'test_value', got {result}" + + # Verify the data was actually stored + stored_result = query(cnx, b"test_key") + assert stored_result == b"test_value", ( + "Data should be stored after transactional function" + ) + + apply(bonafide_instance, run_transactional_test) + + # 2. Test that transactional decorator handles exceptions properly + @transactional + def failing_transactional_function(txn): + set(txn, b"temp_key", b"temp_value") + raise ValueError("Test exception") + + def test_exception_handling(cnx): + # This should raise an exception and rollback + try: + failing_transactional_function(cnx) + assert False, "Expected exception to be raised" + except ValueError: + pass # Expected + + # Verify that the temp data was not stored (rolled back) + temp_result = query(cnx, b"temp_key") + assert temp_result is None, ( + "Temp data should not exist after failed transaction" + ) + + apply(bonafide_instance, test_exception_handling) diff --git a/tests/test_internals.py b/tests/test_internals.py index 47a8f23..161f16d 100644 --- a/tests/test_internals.py +++ b/tests/test_internals.py @@ -8,12 +8,13 @@ - Hash computation - Schema detection and validation """ + import ast import json -import os + import sys from pathlib import Path -from unittest.mock import patch + import pytest @@ -28,6 +29,7 @@ # Tests for ASTNormalizer class # ============================================================================ + def test_ast_normalizer_visit_name_with_mapping(): """Test that Name nodes are renamed according to mapping""" mapping = {"x": "_bb_v_1", "y": "_bb_v_2"} @@ -88,6 +90,7 @@ def test_ast_normalizer_visit_functiondef_with_mapping(): # Tests for names_collect function # ============================================================================ + def test_collect_names_simple_names(): """Test collecting variable names""" code = "x = 1\ny = 2\nz = x + y" @@ -122,6 +125,7 @@ def test_collect_names_empty_tree(): # Tests for imports_get_names function # ============================================================================ + def test_get_imported_names_import_statement(): """Test extracting names from import statement""" code = "import math" @@ -179,6 +183,7 @@ def test_get_imported_names_multiple_imports(): # Tests for imports_check_unused function # ============================================================================ + def test_check_unused_imports_all_imports_used(): """Test when all imports are used""" code = """ @@ -213,20 +218,20 @@ def foo(): # Tests for imports_sort function # ============================================================================ + def test_sort_imports_simple_imports(): """Test sorting import statements""" code = """ import sys import ast -import os + """ tree = ast.parse(code) sorted_tree = bb.code_sort_imports(tree) result = ast.unparse(sorted_tree) - # ast should come before os, os before sys - assert result.index("ast") < result.index("os") - assert result.index("os") < result.index("sys") + # ast should come before sys + assert result.index("ast") < result.index("sys") def test_sort_imports_from_imports(): @@ -251,7 +256,7 @@ def test_sort_imports_imports_before_code(): import sys def foo(): pass -import os + """ tree = ast.parse(code) sorted_tree = bb.code_sort_imports(tree) @@ -266,6 +271,7 @@ def foo(): # Tests for function_extract_definition function # ============================================================================ + def test_extract_function_def_simple_function(): """Test extracting a simple function""" code = """ @@ -325,6 +331,7 @@ def bar(): # Tests for mapping_create_name function # ============================================================================ + def test_create_name_mapping_function_name_always_v0(): """Test that function name always maps to _bb_v_0""" code = "def my_function(x): return x" @@ -411,6 +418,7 @@ def foo(x): # Tests for imports_rewrite_bb function # ============================================================================ + def test_rewrite_bb_imports_with_alias(): """Test rewriting bb import with alias""" code = "from bb.pool import abc123 as helper" @@ -459,6 +467,7 @@ def test_rewrite_bb_imports_non_bb_imports_unchanged(): # Tests for calls_replace_bb function # ============================================================================ + def test_replace_bb_calls_aliased_call(): """Test replacing aliased bb function calls""" code = """ @@ -498,6 +507,7 @@ def foo(x): # Tests for ast_clear_locations function # ============================================================================ + def test_clear_locations_all_location_info(): """Test that all location info is cleared""" code = "def foo(x): return x + 1" @@ -505,7 +515,7 @@ def test_clear_locations_all_location_info(): # Verify locations exist initially for node in ast.walk(tree): - if hasattr(node, 'lineno'): + if hasattr(node, "lineno"): assert node.lineno is not None break @@ -513,9 +523,9 @@ def test_clear_locations_all_location_info(): # Verify all locations are None for node in ast.walk(tree): - if hasattr(node, 'lineno'): + if hasattr(node, "lineno"): assert node.lineno is None - if hasattr(node, 'col_offset'): + if hasattr(node, "col_offset"): assert node.col_offset is None @@ -523,6 +533,7 @@ def test_clear_locations_all_location_info(): # Tests for docstring_extract function # ============================================================================ + def test_extract_docstring_existing_docstring(): """Test extracting an existing docstring""" code = ''' @@ -578,6 +589,7 @@ def foo(): # Tests for ast_normalize function # ============================================================================ + def test_normalize_ast_simple_function(): """Test normalizing a simple function""" code = """ @@ -588,8 +600,9 @@ def calculate_sum(first, second): """ tree = ast.parse(code) - code_with_doc, code_without_doc, docstring, name_mapping, alias_mapping = \ + code_with_doc, code_without_doc, docstring, name_mapping, alias_mapping = ( bb.code_normalize(tree, "eng") + ) assert "_bb_v_0" in code_with_doc # Function name normalized assert docstring == "Add two numbers" @@ -602,7 +615,7 @@ def test_normalize_ast_imports_sorted(): code = """ import sys import ast -import os + def foo(): return 42 @@ -612,8 +625,7 @@ def foo(): code_with_doc, _, _, _, _ = bb.code_normalize(tree, "eng") # Verify imports are sorted - assert code_with_doc.index("import ast") < code_with_doc.index("import os") - assert code_with_doc.index("import os") < code_with_doc.index("import sys") + assert code_with_doc.index("import ast") < code_with_doc.index("import sys") def test_normalize_ast_with_bb_import(): @@ -627,8 +639,9 @@ def foo(x): """ tree = ast.parse(code) - code_with_doc, code_without_doc, docstring, name_mapping, alias_mapping = \ + code_with_doc, code_without_doc, docstring, name_mapping, alias_mapping = ( bb.code_normalize(tree, "eng") + ) # Should remove alias but keep bb.pool module name assert "from bb.pool import abc123" in code_with_doc @@ -645,12 +658,13 @@ def foo(x): # Tests for hash_compute function # ============================================================================ + def test_compute_hash_deterministic(): """Test that same input produces same hash""" code = "def foo(): return 42" - hash1 = bb.hash_compute(code) - hash2 = bb.hash_compute(code) + hash1 = bb.code_hash_compute(code) + hash2 = bb.code_hash_compute(code) assert hash1 == hash2 @@ -659,10 +673,10 @@ def test_compute_hash_format(): """Test that hash is 64 hex characters""" code = "def foo(): return 42" - hash_value = bb.hash_compute(code) + hash_value = bb.code_hash_compute(code) assert len(hash_value) == 64 - assert all(c in '0123456789abcdef' for c in hash_value) + assert all(c in "0123456789abcdef" for c in hash_value) def test_compute_hash_different_code_different_hash(): @@ -670,8 +684,8 @@ def test_compute_hash_different_code_different_hash(): code1 = "def foo(): return 42" code2 = "def bar(): return 43" - hash1 = bb.hash_compute(code1) - hash2 = bb.hash_compute(code2) + hash1 = bb.code_hash_compute(code1) + hash2 = bb.code_hash_compute(code2) assert hash1 != hash2 @@ -681,8 +695,8 @@ def test_hash_compute_with_algorithm_parameter(): code = "def foo(): pass" # Default should be sha256 - hash_default = bb.hash_compute(code) - hash_sha256 = bb.hash_compute(code, algorithm='sha256') + hash_default = bb.code_hash_compute(code) + hash_sha256 = bb.code_hash_compute(code, algorithm="sha256") assert hash_default == hash_sha256 assert len(hash_default) == 64 @@ -692,8 +706,8 @@ def test_hash_compute_algorithm_deterministic(): """Test that hash_compute with algorithm produces deterministic results""" code = "def foo(): pass" - hash1 = bb.hash_compute(code, algorithm='sha256') - hash2 = bb.hash_compute(code, algorithm='sha256') + hash1 = bb.code_hash_compute(code, algorithm="sha256") + hash2 = bb.code_hash_compute(code, algorithm="sha256") assert hash1 == hash2 @@ -702,6 +716,7 @@ def test_hash_compute_algorithm_deterministic(): # Tests for docstring_replace function # ============================================================================ + def test_replace_docstring_existing_docstring(): """Test replacing an existing docstring""" code = ''' @@ -752,6 +767,7 @@ def foo(): # Tests for code_denormalize function # ============================================================================ + def test_denormalize_code_variable_names(): """Test denormalizing variable names""" normalized = """ @@ -763,7 +779,7 @@ def _bb_v_0(_bb_v_1, _bb_v_2): "_bb_v_0": "calculate", "_bb_v_1": "first", "_bb_v_2": "second", - "_bb_v_3": "result" + "_bb_v_3": "result", } alias_mapping = {} @@ -784,13 +800,8 @@ def test_denormalize_code_bb_imports(): def _bb_v_0(_bb_v_1): return abc123._bb_v_0(_bb_v_1) """ - name_mapping = { - "_bb_v_0": "process", - "_bb_v_1": "data" - } - alias_mapping = { - "abc123": "helper" - } + name_mapping = {"_bb_v_0": "process", "_bb_v_1": "data"} + alias_mapping = {"abc123": "helper"} result = bb.code_denormalize(normalized, name_mapping, alias_mapping) @@ -806,6 +817,7 @@ def _bb_v_0(_bb_v_1): # Tests for Schema v1 - Foundation # ============================================================================ + def test_mapping_compute_hash_deterministic(): """Test that mapping_compute_hash produces deterministic hashes""" docstring = "Calculate the average" @@ -814,12 +826,16 @@ def test_mapping_compute_hash_deterministic(): comment = "Formal terminology" # Compute hash twice - should be identical - hash1 = bb.code_compute_mapping_hash(docstring, name_mapping, alias_mapping, comment) - hash2 = bb.code_compute_mapping_hash(docstring, name_mapping, alias_mapping, comment) + hash1 = bb.code_compute_mapping_hash( + docstring, name_mapping, alias_mapping, comment + ) + hash2 = bb.code_compute_mapping_hash( + docstring, name_mapping, alias_mapping, comment + ) assert hash1 == hash2 assert len(hash1) == 64 # SHA256 produces 64 hex characters - assert all(c in '0123456789abcdef' for c in hash1) + assert all(c in "0123456789abcdef" for c in hash1) def test_mapping_compute_hash_different_comments(): @@ -828,8 +844,12 @@ def test_mapping_compute_hash_different_comments(): name_mapping = {"_bb_v_0": "calculate_average", "_bb_v_1": "numbers"} alias_mapping = {"abc123": "helper"} - hash1 = bb.code_compute_mapping_hash(docstring, name_mapping, alias_mapping, "Formal") - hash2 = bb.code_compute_mapping_hash(docstring, name_mapping, alias_mapping, "Informal") + hash1 = bb.code_compute_mapping_hash( + docstring, name_mapping, alias_mapping, "Formal" + ) + hash2 = bb.code_compute_mapping_hash( + docstring, name_mapping, alias_mapping, "Informal" + ) assert hash1 != hash2 @@ -843,7 +863,7 @@ def test_mapping_compute_hash_empty_comment(): hash_val = bb.code_compute_mapping_hash(docstring, name_mapping, alias_mapping, "") assert len(hash_val) == 64 - assert all(c in '0123456789abcdef' for c in hash_val) + assert all(c in "0123456789abcdef" for c in hash_val) def test_mapping_compute_hash_canonical_json(): @@ -862,22 +882,22 @@ def test_mapping_compute_hash_canonical_json(): def test_schema_detect_version_v1(mock_bb_dir): """Test that schema_detect_version correctly identifies v1 format""" - pool_dir = mock_bb_dir / '.bb' / 'pool' + pool_dir = mock_bb_dir / ".bb" / "pool" test_hash = "abcd1234" + "0" * 56 # Create v1 format: pool/XX/YYYYYY.../object.json func_dir = pool_dir / test_hash[:2] / test_hash[2:] func_dir.mkdir(parents=True, exist_ok=True) - object_json = func_dir / 'object.json' + object_json = func_dir / "object.json" v1_data = { - 'schema_version': 1, - 'hash': test_hash, - 'normalized_code': normalize_code_for_test('def _bb_v_0(): pass'), - 'metadata': {} + "schema_version": 1, + "hash": test_hash, + "normalized_code": normalize_code_for_test("def _bb_v_0(): pass"), + "metadata": {}, } - with open(object_json, 'w', encoding='utf-8') as f: + with open(object_json, "w", encoding="utf-8") as f: json.dump(v1_data, f) version = bb.code_detect_schema(test_hash) @@ -896,40 +916,40 @@ def test_metadata_create_basic(): """Test that metadata_create generates proper metadata structure""" metadata = bb.code_create_metadata() - assert 'created' in metadata - assert 'name' in metadata - assert 'email' in metadata + assert "created" in metadata + assert "name" in metadata + assert "email" in metadata def test_metadata_create_reads_from_config(mock_bb_dir): """Test that metadata_create reads name and email from config""" # Write config with name and email config = { - 'user': { - 'name': 'testuser', - 'email': 'test@example.com', - 'public_key': '', - 'languages': [] + "user": { + "name": "testuser", + "email": "test@example.com", + "public_key": "", + "languages": [], }, - 'remotes': {} + "remotes": {}, } - config_path = bb.storage_get_bb_directory() / 'config.json' + config_path = bb.storage_get_bb_directory() / "config.json" config_path.parent.mkdir(parents=True, exist_ok=True) - with open(config_path, 'w') as f: + with open(config_path, "w") as f: json.dump(config, f) metadata = bb.code_create_metadata() - assert metadata['name'] == 'testuser' - assert metadata['email'] == 'test@example.com' + assert metadata["name"] == "testuser" + assert metadata["email"] == "test@example.com" def test_metadata_create_timestamp_format(): """Test that metadata_create uses ISO 8601 timestamp format""" metadata = bb.code_create_metadata() - created = metadata['created'] + created = metadata["created"] # Should be ISO 8601 format: YYYY-MM-DDTHH:MM:SSZ - assert 'T' in created + assert "T" in created assert len(created) >= 19 # At minimum: 2025-01-01T00:00:00 @@ -937,35 +957,46 @@ def test_metadata_create_timestamp_format(): # Tests for hash determinism across languages # ============================================================================ + def test_hash_determinism_multilingual_same_logic(): """Test that functions with identical logic but different names produce the same hash. This verifies the core BB principle: same logic = same hash, regardless of variable names or human language used. Uses the example files: - - examples/example_simple.py (English) - - examples/example_simple_french.py (French) + - doc/examples/example_simple.py (English) + - doc/examples/example_simple_french.py (French) """ - examples_dir = Path(__file__).parent.parent / 'examples' - english_file = examples_dir / 'example_simple.py' - french_file = examples_dir / 'example_simple_french.py' + examples_dir = Path(__file__).parent.parent / "doc" / "examples" + english_file = examples_dir / "example_simple.py" + french_file = examples_dir / "example_simple_french.py" # Read both files - english_code = english_file.read_text(encoding='utf-8') - french_code = french_file.read_text(encoding='utf-8') + english_code = english_file.read_text(encoding="utf-8") + french_code = french_file.read_text(encoding="utf-8") # Parse to AST english_tree = ast.parse(english_code) french_tree = ast.parse(french_code) # Normalize both - eng_with_doc, eng_without_doc, eng_docstring, eng_name_mapping, eng_alias_mapping = \ - bb.code_normalize(english_tree, "eng") - fra_with_doc, fra_without_doc, fra_docstring, fra_name_mapping, fra_alias_mapping = \ - bb.code_normalize(french_tree, "fra") + ( + eng_with_doc, + eng_without_doc, + eng_docstring, + eng_name_mapping, + eng_alias_mapping, + ) = bb.code_normalize(english_tree, "eng") + ( + fra_with_doc, + fra_without_doc, + fra_docstring, + fra_name_mapping, + fra_alias_mapping, + ) = bb.code_normalize(french_tree, "fra") # Compute hashes on code WITHOUT docstring (this is critical for multilingual support) - english_hash = bb.hash_compute(eng_without_doc) - french_hash = bb.hash_compute(fra_without_doc) + english_hash = bb.code_hash_compute(eng_without_doc) + french_hash = bb.code_hash_compute(fra_without_doc) # Core assertion: same logic = same hash assert english_hash == french_hash, ( @@ -978,5 +1009,9 @@ def test_hash_determinism_multilingual_same_logic(): # Additional sanity checks assert len(english_hash) == 64, "Hash should be 64 hex characters (SHA256)" - assert eng_docstring != fra_docstring, "Docstrings should differ (different languages)" - assert eng_name_mapping != fra_name_mapping, "Name mappings should differ (different variable names)" + assert eng_docstring != fra_docstring, ( + "Docstrings should differ (different languages)" + ) + assert eng_name_mapping != fra_name_mapping, ( + "Name mappings should differ (different variable names)" + ) diff --git a/tests/test_storage.py b/tests/test_storage.py index 9246abd..89d6680 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -3,9 +3,9 @@ Tests for saving and loading functions in v1 format. """ + import json -import pytest import bb from tests.conftest import normalize_code_for_test @@ -15,33 +15,34 @@ # Tests for V1 Write Path # ============================================================================ + def test_function_save_v1_creates_object_json(mock_bb_dir): """Test that function_save_v1 creates proper object.json""" test_hash = "abcd1234" + "0" * 56 normalized_code = normalize_code_for_test("def _bb_v_0(): pass") metadata = { - 'created': '2025-01-01T00:00:00Z', - 'name': 'testuser', - 'email': 'test@example.com' + "created": "2025-01-01T00:00:00Z", + "name": "testuser", + "email": "test@example.com", } bb.code_save_v1(test_hash, normalized_code, metadata) # Check that object.json was created - pool_dir = mock_bb_dir / '.bb' / 'pool' + pool_dir = mock_bb_dir / ".bb" / "pool" func_dir = pool_dir / test_hash[:2] / test_hash[2:] - object_json = func_dir / 'object.json' + object_json = func_dir / "object.json" assert object_json.exists() # Load and verify structure - with open(object_json, 'r', encoding='utf-8') as f: + with open(object_json, "r", encoding="utf-8") as f: data = json.load(f) - assert data['schema_version'] == 1 - assert data['hash'] == test_hash - assert data['normalized_code'] == normalized_code - assert data['metadata'] == metadata + assert data["schema_version"] == 1 + assert data["hash"] == test_hash + assert data["normalized_code"] == normalized_code + assert data["metadata"] == metadata def test_function_save_v1_no_language_data(mock_bb_dir): @@ -52,21 +53,21 @@ def test_function_save_v1_no_language_data(mock_bb_dir): bb.code_save_v1(test_hash, normalized_code, metadata) - pool_dir = mock_bb_dir / '.bb' / 'pool' + pool_dir = mock_bb_dir / ".bb" / "pool" func_dir = pool_dir / test_hash[:2] / test_hash[2:] - object_json = func_dir / 'object.json' + object_json = func_dir / "object.json" - with open(object_json, 'r', encoding='utf-8') as f: + with open(object_json, "r", encoding="utf-8") as f: data = json.load(f) # Should NOT have docstrings, name_mappings, alias_mappings - assert 'docstrings' not in data - assert 'name_mappings' not in data - assert 'alias_mappings' not in data + assert "docstrings" not in data + assert "name_mappings" not in data + assert "alias_mappings" not in data -def test_mapping_save_v1_creates_mapping_json(mock_bb_dir): - """Test that mapping_save_v1 creates proper mapping.json""" +def test_storage_mapping_save_v1_creates_mapping_json(mock_bb_dir): + """Test that storage_mapping_save_v1 creates proper mapping.json""" func_hash = "abcd1234" + "0" * 56 lang = "eng" docstring = "Test function" @@ -80,28 +81,30 @@ def test_mapping_save_v1_creates_mapping_json(mock_bb_dir): bb.code_save_v1(func_hash, normalized_code, metadata) # Now save the mapping - mapping_hash = bb.mapping_save_v1(func_hash, lang, docstring, name_mapping, alias_mapping, comment) + mapping_hash = bb.storage_mapping_save_v1( + func_hash, lang, docstring, name_mapping, alias_mapping, comment + ) # Check that mapping.json was created - pool_dir = mock_bb_dir / '.bb' / 'pool' + pool_dir = mock_bb_dir / ".bb" / "pool" func_dir = pool_dir / func_hash[:2] / func_hash[2:] mapping_dir = func_dir / lang / mapping_hash[:2] / mapping_hash[2:] - mapping_json = mapping_dir / 'mapping.json' + mapping_json = mapping_dir / "mapping.json" assert mapping_json.exists() # Load and verify structure - with open(mapping_json, 'r', encoding='utf-8') as f: + with open(mapping_json, "r", encoding="utf-8") as f: data = json.load(f) - assert data['docstring'] == docstring - assert data['name_mapping'] == name_mapping - assert data['alias_mapping'] == alias_mapping - assert data['comment'] == comment + assert data["docstring"] == docstring + assert data["name_mapping"] == name_mapping + assert data["alias_mapping"] == alias_mapping + assert data["comment"] == comment -def test_mapping_save_v1_returns_hash(mock_bb_dir): - """Test that mapping_save_v1 returns the mapping hash""" +def test_storage_mapping_save_v1_returns_hash(mock_bb_dir): + """Test that storage_mapping_save_v1 returns the mapping hash""" func_hash = "abcd1234" + "0" * 56 lang = "eng" docstring = "Test" @@ -110,21 +113,29 @@ def test_mapping_save_v1_returns_hash(mock_bb_dir): comment = "" # Create function first - bb.code_save_v1(func_hash, normalize_code_for_test("def _bb_v_0(): pass"), bb.code_create_metadata()) + bb.code_save_v1( + func_hash, + normalize_code_for_test("def _bb_v_0(): pass"), + bb.code_create_metadata(), + ) # Save mapping - mapping_hash = bb.mapping_save_v1(func_hash, lang, docstring, name_mapping, alias_mapping, comment) + mapping_hash = bb.storage_mapping_save_v1( + func_hash, lang, docstring, name_mapping, alias_mapping, comment + ) # Verify it's a valid hash assert len(mapping_hash) == 64 - assert all(c in '0123456789abcdef' for c in mapping_hash) + assert all(c in "0123456789abcdef" for c in mapping_hash) # Verify it matches computed hash - expected_hash = bb.code_compute_mapping_hash(docstring, name_mapping, alias_mapping, comment) + expected_hash = bb.code_compute_mapping_hash( + docstring, name_mapping, alias_mapping, comment + ) assert mapping_hash == expected_hash -def test_mapping_save_v1_deduplication(mock_bb_dir): +def test_storage_mapping_save_v1_deduplication(mock_bb_dir): """Test that identical mappings share the same file (deduplication)""" func_hash1 = "aaaa" + "0" * 60 func_hash2 = "bbbb" + "0" * 60 @@ -135,18 +146,30 @@ def test_mapping_save_v1_deduplication(mock_bb_dir): comment = "Same comment" # Create two different functions - bb.code_save_v1(func_hash1, normalize_code_for_test("def _bb_v_0(): pass"), bb.code_create_metadata()) - bb.code_save_v1(func_hash2, normalize_code_for_test("def _bb_v_0(): return 42"), bb.code_create_metadata()) + bb.code_save_v1( + func_hash1, + normalize_code_for_test("def _bb_v_0(): pass"), + bb.code_create_metadata(), + ) + bb.code_save_v1( + func_hash2, + normalize_code_for_test("def _bb_v_0(): return 42"), + bb.code_create_metadata(), + ) # Save identical mappings for both - mapping_hash1 = bb.mapping_save_v1(func_hash1, lang, docstring, name_mapping, alias_mapping, comment) - mapping_hash2 = bb.mapping_save_v1(func_hash2, lang, docstring, name_mapping, alias_mapping, comment) + mapping_hash1 = bb.storage_mapping_save_v1( + func_hash1, lang, docstring, name_mapping, alias_mapping, comment + ) + mapping_hash2 = bb.storage_mapping_save_v1( + func_hash2, lang, docstring, name_mapping, alias_mapping, comment + ) # Hashes should be identical assert mapping_hash1 == mapping_hash2 -def test_mapping_save_v1_different_comments_different_hashes(mock_bb_dir): +def test_storage_mapping_save_v1_different_comments_different_hashes(mock_bb_dir): """Test that different comments produce different mapping hashes""" func_hash = "abcd1234" + "0" * 56 lang = "eng" @@ -155,11 +178,19 @@ def test_mapping_save_v1_different_comments_different_hashes(mock_bb_dir): alias_mapping = {} # Create function - bb.code_save_v1(func_hash, normalize_code_for_test("def _bb_v_0(): pass"), bb.code_create_metadata()) + bb.code_save_v1( + func_hash, + normalize_code_for_test("def _bb_v_0(): pass"), + bb.code_create_metadata(), + ) # Save two mappings with different comments - hash1 = bb.mapping_save_v1(func_hash, lang, docstring, name_mapping, alias_mapping, "Formal") - hash2 = bb.mapping_save_v1(func_hash, lang, docstring, name_mapping, alias_mapping, "Informal") + hash1 = bb.storage_mapping_save_v1( + func_hash, lang, docstring, name_mapping, alias_mapping, "Formal" + ) + hash2 = bb.storage_mapping_save_v1( + func_hash, lang, docstring, name_mapping, alias_mapping, "Informal" + ) # Hashes should be different assert hash1 != hash2 @@ -168,65 +199,72 @@ def test_mapping_save_v1_different_comments_different_hashes(mock_bb_dir): def test_v1_write_integration_full_structure(mock_bb_dir): """Integration test: verify complete v1 directory structure""" func_hash = "test1234" + "0" * 56 - normalized_code = normalize_code_for_test("def _bb_v_0(_bb_v_1): return _bb_v_1 * 2") + normalized_code = normalize_code_for_test( + "def _bb_v_0(_bb_v_1): return _bb_v_1 * 2" + ) metadata = { - 'created': '2025-01-01T00:00:00Z', - 'name': 'testuser', - 'email': 'test@example.com', - 'tags': ['math'], - 'dependencies': [] + "created": "2025-01-01T00:00:00Z", + "name": "testuser", + "email": "test@example.com", + "tags": ["math"], + "dependencies": [], } # Save function bb.code_save_v1(func_hash, normalized_code, metadata) # Save mappings in two languages - eng_hash = bb.mapping_save_v1( - func_hash, "eng", + eng_hash = bb.storage_mapping_save_v1( + func_hash, + "eng", "Double the input", {"_bb_v_0": "double", "_bb_v_1": "value"}, {}, - "Simple English" + "Simple English", ) - fra_hash = bb.mapping_save_v1( - func_hash, "fra", + fra_hash = bb.storage_mapping_save_v1( + func_hash, + "fra", "Doubler l'entrée", {"_bb_v_0": "doubler", "_bb_v_1": "valeur"}, {}, - "Français simple" + "Français simple", ) # Verify directory structure - pool_dir = mock_bb_dir / '.bb' / 'pool' + pool_dir = mock_bb_dir / ".bb" / "pool" func_dir = pool_dir / func_hash[:2] / func_hash[2:] # Check object.json exists - assert (func_dir / 'object.json').exists() + assert (func_dir / "object.json").exists() # Check language directories exist - assert (func_dir / 'eng').exists() - assert (func_dir / 'fra').exists() + assert (func_dir / "eng").exists() + assert (func_dir / "fra").exists() # Check mapping files exist - assert (func_dir / 'eng' / eng_hash[:2] / eng_hash[2:] / 'mapping.json').exists() - assert (func_dir / 'fra' / fra_hash[:2] / fra_hash[2:] / 'mapping.json').exists() + assert (func_dir / "eng" / eng_hash[:2] / eng_hash[2:] / "mapping.json").exists() + assert (func_dir / "fra" / fra_hash[:2] / fra_hash[2:] / "mapping.json").exists() # ============================================================================ # Tests for V1 Read Path # ============================================================================ + def test_function_load_v1_loads_object_json(mock_bb_dir): """Test that function_load_v1 loads object.json correctly""" func_hash = "test5678" + "0" * 56 - normalized_code = normalize_code_for_test("def _bb_v_0(_bb_v_1): return _bb_v_1 * 2") + normalized_code = normalize_code_for_test( + "def _bb_v_0(_bb_v_1): return _bb_v_1 * 2" + ) metadata = { - 'created': '2025-01-01T00:00:00Z', - 'name': 'testuser', - 'email': 'test@example.com', - 'tags': ['test'], - 'dependencies': [] + "created": "2025-01-01T00:00:00Z", + "name": "testuser", + "email": "test@example.com", + "tags": ["test"], + "dependencies": [], } # Save function first @@ -236,14 +274,14 @@ def test_function_load_v1_loads_object_json(mock_bb_dir): loaded_data = bb.code_load_v1(func_hash) # Verify data - assert loaded_data['schema_version'] == 1 - assert loaded_data['hash'] == func_hash - assert loaded_data['normalized_code'] == normalized_code - assert loaded_data['metadata'] == metadata + assert loaded_data["schema_version"] == 1 + assert loaded_data["hash"] == func_hash + assert loaded_data["normalized_code"] == normalized_code + assert loaded_data["metadata"] == metadata -def test_mappings_list_v1_single_mapping(mock_bb_dir): - """Test that mappings_list_v1 returns single mapping correctly""" +def test_storage_mappings_list_v1_single_mapping(mock_bb_dir): + """Test that storage_mappings_list_v1 returns single mapping correctly""" func_hash = "list1234" + "0" * 56 lang = "eng" docstring = "Test function" @@ -252,11 +290,17 @@ def test_mappings_list_v1_single_mapping(mock_bb_dir): comment = "Test variant" # Create function and mapping - bb.code_save_v1(func_hash, normalize_code_for_test("def _bb_v_0(): pass"), bb.code_create_metadata()) - bb.mapping_save_v1(func_hash, lang, docstring, name_mapping, alias_mapping, comment) + bb.code_save_v1( + func_hash, + normalize_code_for_test("def _bb_v_0(): pass"), + bb.code_create_metadata(), + ) + bb.storage_mapping_save_v1( + func_hash, lang, docstring, name_mapping, alias_mapping, comment + ) # List mappings - mappings = bb.mappings_list_v1(func_hash, lang) + mappings = bb.storage_mappings_list_v1(func_hash, lang) # Should have exactly one mapping assert len(mappings) == 1 @@ -265,20 +309,28 @@ def test_mappings_list_v1_single_mapping(mock_bb_dir): assert mapping_comment == comment -def test_mappings_list_v1_multiple_mappings(mock_bb_dir): - """Test that mappings_list_v1 returns multiple mappings""" +def test_storage_mappings_list_v1_multiple_mappings(mock_bb_dir): + """Test that storage_mappings_list_v1 returns multiple mappings""" func_hash = "list5678" + "0" * 56 lang = "eng" # Create function - bb.code_save_v1(func_hash, normalize_code_for_test("def _bb_v_0(): pass"), bb.code_create_metadata()) + bb.code_save_v1( + func_hash, + normalize_code_for_test("def _bb_v_0(): pass"), + bb.code_create_metadata(), + ) # Add two mappings with different comments - bb.mapping_save_v1(func_hash, lang, "Doc 1", {"_bb_v_0": "func1"}, {}, "Formal") - bb.mapping_save_v1(func_hash, lang, "Doc 2", {"_bb_v_0": "func2"}, {}, "Casual") + bb.storage_mapping_save_v1( + func_hash, lang, "Doc 1", {"_bb_v_0": "func1"}, {}, "Formal" + ) + bb.storage_mapping_save_v1( + func_hash, lang, "Doc 2", {"_bb_v_0": "func2"}, {}, "Casual" + ) # List mappings - mappings = bb.mappings_list_v1(func_hash, lang) + mappings = bb.storage_mappings_list_v1(func_hash, lang) # Should have two mappings assert len(mappings) == 2 @@ -289,22 +341,26 @@ def test_mappings_list_v1_multiple_mappings(mock_bb_dir): assert "Casual" in comments -def test_mappings_list_v1_no_mappings(mock_bb_dir): - """Test that mappings_list_v1 returns empty list when no mappings exist""" +def test_storage_mappings_list_v1_no_mappings(mock_bb_dir): + """Test that storage_mappings_list_v1 returns empty list when no mappings exist""" func_hash = "nomaps12" + "0" * 56 # Create function without any mappings - bb.code_save_v1(func_hash, normalize_code_for_test("def _bb_v_0(): pass"), bb.code_create_metadata()) + bb.code_save_v1( + func_hash, + normalize_code_for_test("def _bb_v_0(): pass"), + bb.code_create_metadata(), + ) # List mappings for a language that doesn't exist - mappings = bb.mappings_list_v1(func_hash, "fra") + mappings = bb.storage_mappings_list_v1(func_hash, "fra") # Should be empty assert len(mappings) == 0 -def test_mapping_load_v1_loads_correctly(mock_bb_dir): - """Test that mapping_load_v1 loads a specific mapping""" +def test_storage_mapping_load_v1_loads_correctly(mock_bb_dir): + """Test that storage_mapping_load_v1 loads a specific mapping""" func_hash = "load1234" + "0" * 56 lang = "eng" docstring = "Test docstring" @@ -313,11 +369,19 @@ def test_mapping_load_v1_loads_correctly(mock_bb_dir): comment = "Test variant" # Create function and mapping - bb.code_save_v1(func_hash, normalize_code_for_test("def _bb_v_0(): pass"), bb.code_create_metadata()) - mapping_hash = bb.mapping_save_v1(func_hash, lang, docstring, name_mapping, alias_mapping, comment) + bb.code_save_v1( + func_hash, + normalize_code_for_test("def _bb_v_0(): pass"), + bb.code_create_metadata(), + ) + mapping_hash = bb.storage_mapping_save_v1( + func_hash, lang, docstring, name_mapping, alias_mapping, comment + ) # Load the mapping - loaded_doc, loaded_name, loaded_alias, loaded_comment = bb.mapping_load_v1(func_hash, lang, mapping_hash) + loaded_doc, loaded_name, loaded_alias, loaded_comment = bb.storage_mapping_load_v1( + func_hash, lang, mapping_hash + ) # Verify data assert loaded_doc == docstring @@ -330,14 +394,24 @@ def test_function_load_v1_integration(mock_bb_dir): """Integration test: write v1, read v1, verify correctness""" func_hash = "integ123" + "0" * 56 lang = "eng" - normalized_code = normalize_code_for_test("def _bb_v_0(_bb_v_1): return _bb_v_1 + 1") + normalized_code = normalize_code_for_test( + "def _bb_v_0(_bb_v_1): return _bb_v_1 + 1" + ) docstring = "Increment by one" name_mapping = {"_bb_v_0": "increment", "_bb_v_1": "value"} alias_mapping = {} comment = "Simple increment" # Write v1 format - bb.code_save(func_hash, lang, normalized_code, docstring, name_mapping, alias_mapping, comment) + bb.code_save( + func_hash, + lang, + normalized_code, + docstring, + name_mapping, + alias_mapping, + comment, + ) # Read back using dispatch (should detect v1) loaded_code, loaded_name, loaded_alias, loaded_doc = bb.code_load(func_hash, lang) @@ -357,8 +431,12 @@ def test_function_load_dispatch_multiple_mappings(mock_bb_dir): # Create function with two mappings bb.code_save_v1(func_hash, normalized_code, bb.code_create_metadata()) - hash1 = bb.mapping_save_v1(func_hash, lang, "Doc 1", {"_bb_v_0": "func1"}, {}, "First") - hash2 = bb.mapping_save_v1(func_hash, lang, "Doc 2", {"_bb_v_0": "func2"}, {}, "Second") + bb.storage_mapping_save_v1( + func_hash, lang, "Doc 1", {"_bb_v_0": "func1"}, {}, "First" + ) + bb.storage_mapping_save_v1( + func_hash, lang, "Doc 2", {"_bb_v_0": "func2"}, {}, "Second" + ) # Load without specifying mapping_hash (should return first alphabetically) loaded_code, loaded_name, loaded_alias, loaded_doc = bb.code_load(func_hash, lang) @@ -377,11 +455,17 @@ def test_function_load_dispatch_explicit_mapping(mock_bb_dir): # Create function with two mappings bb.code_save_v1(func_hash, normalized_code, bb.code_create_metadata()) - hash1 = bb.mapping_save_v1(func_hash, lang, "Doc 1", {"_bb_v_0": "func1"}, {}, "First") - hash2 = bb.mapping_save_v1(func_hash, lang, "Doc 2", {"_bb_v_0": "func2"}, {}, "Second") + bb.storage_mapping_save_v1( + func_hash, lang, "Doc 1", {"_bb_v_0": "func1"}, {}, "First" + ) + hash2 = bb.storage_mapping_save_v1( + func_hash, lang, "Doc 2", {"_bb_v_0": "func2"}, {}, "Second" + ) # Load with specific mapping_hash - loaded_code, loaded_name, loaded_alias, loaded_doc = bb.code_load(func_hash, lang, mapping_hash=hash2) + loaded_code, loaded_name, loaded_alias, loaded_doc = bb.code_load( + func_hash, lang, mapping_hash=hash2 + ) # Should load the second mapping assert loaded_code == normalized_code diff --git a/tests/translate/test_translate.py b/tests/translate/test_translate.py index 7125a9b..d1f5c86 100644 --- a/tests/translate/test_translate.py +++ b/tests/translate/test_translate.py @@ -4,112 +4,111 @@ Grey-box integration tests for adding translations to functions. Note: translate is interactive, so some tests use stdin injection. """ -import json + import os import subprocess import sys from pathlib import Path -import pytest - -def cli_run(args: list, env: dict = None, input_text: str = None) -> subprocess.CompletedProcess: +def cli_run( + args: list, env: dict = None, input_text: str = None +) -> subprocess.CompletedProcess: """Run bb.py CLI command with optional stdin input.""" - cmd = [sys.executable, str(Path(__file__).parent.parent.parent / 'bb.py')] + args + cmd = [sys.executable, str(Path(__file__).parent.parent.parent / "bb.py")] + args run_env = os.environ.copy() if env: run_env.update(env) return subprocess.run( - cmd, - capture_output=True, - text=True, - env=run_env, - input=input_text + cmd, capture_output=True, text=True, env=run_env, input=input_text ) def test_translate_missing_source_language_fails(tmp_path): """Test that translate fails without source language suffix""" - bb_dir = tmp_path / '.bb' - env = {'BB_DIRECTORY': str(bb_dir)} + bb_dir = tmp_path / ".bb" + env = {"BB_DIRECTORY": str(bb_dir)} # Setup: Add a function first test_file = tmp_path / "func.py" - test_file.write_text('def foo(): pass') - add_result = cli_run(['add', f'{test_file}@eng'], env=env) - func_hash = add_result.stdout.split('Hash:')[1].strip().split()[0] + test_file.write_text("def foo(): pass") + add_result = cli_run(["add", f"{test_file}@eng"], env=env) + func_hash = add_result.stdout.split("Hash:")[1].strip().split()[0] # Test: translate without @lang - result = cli_run(['translate', func_hash, 'fra'], env=env) + result = cli_run(["translate", func_hash, "fra"], env=env) # Assert assert result.returncode != 0 - assert 'Missing language suffix' in result.stderr + assert "Missing language suffix" in result.stderr def test_translate_invalid_source_language_fails(tmp_path): """Test that translate fails with too short source language code""" - bb_dir = tmp_path / '.bb' - env = {'BB_DIRECTORY': str(bb_dir)} + bb_dir = tmp_path / ".bb" + env = {"BB_DIRECTORY": str(bb_dir)} - fake_hash = '0' * 64 + fake_hash = "0" * 64 # Test with too short language code (must be 3-256 chars) - result = cli_run(['translate', f'{fake_hash}@ab', 'fra'], env=env) + result = cli_run(["translate", f"{fake_hash}@ab", "fra"], env=env) assert result.returncode != 0 - assert 'Source language code must be 3-256 characters' in result.stderr + assert "Source language code must be 3-256 characters" in result.stderr def test_translate_invalid_target_language_fails(tmp_path): """Test that translate fails with too short target language code""" - bb_dir = tmp_path / '.bb' - env = {'BB_DIRECTORY': str(bb_dir)} + bb_dir = tmp_path / ".bb" + env = {"BB_DIRECTORY": str(bb_dir)} # Setup test_file = tmp_path / "func.py" - test_file.write_text('def foo(): pass') - add_result = cli_run(['add', f'{test_file}@eng'], env=env) - func_hash = add_result.stdout.split('Hash:')[1].strip().split()[0] + test_file.write_text("def foo(): pass") + add_result = cli_run(["add", f"{test_file}@eng"], env=env) + func_hash = add_result.stdout.split("Hash:")[1].strip().split()[0] # Test with too short language code (must be 3-256 chars) - result = cli_run(['translate', f'{func_hash}@eng', 'ab'], env=env) + result = cli_run(["translate", f"{func_hash}@eng", "ab"], env=env) assert result.returncode != 0 - assert 'Target language code must be 3-256 characters' in result.stderr + assert "Target language code must be 3-256 characters" in result.stderr def test_translate_invalid_hash_fails(tmp_path): """Test that translate fails with invalid hash format""" - bb_dir = tmp_path / '.bb' - env = {'BB_DIRECTORY': str(bb_dir)} + bb_dir = tmp_path / ".bb" + env = {"BB_DIRECTORY": str(bb_dir)} - result = cli_run(['translate', 'not-valid@eng', 'fra'], env=env) + result = cli_run(["translate", "not-valid@eng", "fra"], env=env) assert result.returncode != 0 - assert 'Invalid hash format' in result.stderr + assert "Invalid hash format" in result.stderr def test_translate_nonexistent_function_fails(tmp_path): """Test that translate fails for nonexistent function""" - bb_dir = tmp_path / '.bb' - (bb_dir / 'pool').mkdir(parents=True) - env = {'BB_DIRECTORY': str(bb_dir)} + bb_dir = tmp_path / ".bb" + (bb_dir / "pool").mkdir(parents=True) + env = {"BB_DIRECTORY": str(bb_dir)} - fake_hash = 'f' * 64 + fake_hash = "f" * 64 - result = cli_run(['translate', f'{fake_hash}@eng', 'fra'], env=env) + result = cli_run(["translate", f"{fake_hash}@eng", "fra"], env=env) assert result.returncode != 0 - assert 'Could not load function' in result.stderr or 'not found' in result.stderr.lower() + assert ( + "Could not load function" in result.stderr + or "not found" in result.stderr.lower() + ) def test_translate_shows_source_function(tmp_path): """Test that translate displays the source function""" - bb_dir = tmp_path / '.bb' - env = {'BB_DIRECTORY': str(bb_dir)} + bb_dir = tmp_path / ".bb" + env = {"BB_DIRECTORY": str(bb_dir)} # Setup test_file = tmp_path / "func.py" @@ -117,23 +116,25 @@ def test_translate_shows_source_function(tmp_path): """Say hello""" return f"Hello, {name}!" ''') - add_result = cli_run(['add', f'{test_file}@eng'], env=env) - func_hash = add_result.stdout.split('Hash:')[1].strip().split()[0] + add_result = cli_run(["add", f"{test_file}@eng"], env=env) + func_hash = add_result.stdout.split("Hash:")[1].strip().split()[0] # Test: Provide interactive input (will fail on empty input but we can check output) # Provide translations: function name, variable name, docstring, comment input_text = "saluer\nnom\nDire bonjour\nFrench translation\n" - result = cli_run(['translate', f'{func_hash}@eng', 'fra'], env=env, input_text=input_text) + result = cli_run( + ["translate", f"{func_hash}@eng", "fra"], env=env, input_text=input_text + ) # Assert: Should show source function - assert 'Source function (eng):' in result.stdout - assert 'def greet' in result.stdout + assert "Source function (eng):" in result.stdout + assert "def greet" in result.stdout def test_translate_creates_mapping(tmp_path): """Test that translate creates a new language mapping""" - bb_dir = tmp_path / '.bb' - env = {'BB_DIRECTORY': str(bb_dir)} + bb_dir = tmp_path / ".bb" + env = {"BB_DIRECTORY": str(bb_dir)} # Setup test_file = tmp_path / "func.py" @@ -141,27 +142,29 @@ def test_translate_creates_mapping(tmp_path): """Say hello""" return f"Hello, {name}!" ''') - add_result = cli_run(['add', f'{test_file}@eng'], env=env) - func_hash = add_result.stdout.split('Hash:')[1].strip().split()[0] + add_result = cli_run(["add", f"{test_file}@eng"], env=env) + func_hash = add_result.stdout.split("Hash:")[1].strip().split()[0] # Test: Provide translations for both names # _bb_v_0 = greet, _bb_v_1 = name input_text = "saluer\nnom\nDire bonjour\n\n" # empty comment - result = cli_run(['translate', f'{func_hash}@eng', 'fra'], env=env, input_text=input_text) + result = cli_run( + ["translate", f"{func_hash}@eng", "fra"], env=env, input_text=input_text + ) # Assert assert result.returncode == 0 - assert 'Translation saved' in result.stdout + assert "Translation saved" in result.stdout # Verify mapping was created - func_dir = bb_dir / 'pool' / func_hash[:2] / func_hash[2:] - assert (func_dir / 'fra').exists() + func_dir = bb_dir / "pool" / func_hash[:2] / func_hash[2:] + assert (func_dir / "fra").exists() def test_translate_prompts_for_all_names(tmp_path): """Test that translate prompts for all variable names""" - bb_dir = tmp_path / '.bb' - env = {'BB_DIRECTORY': str(bb_dir)} + bb_dir = tmp_path / ".bb" + env = {"BB_DIRECTORY": str(bb_dir)} # Setup: Function with multiple variables test_file = tmp_path / "func.py" @@ -170,14 +173,16 @@ def test_translate_prompts_for_all_names(tmp_path): result = value * multiplier return result ''') - add_result = cli_run(['add', f'{test_file}@eng'], env=env) - func_hash = add_result.stdout.split('Hash:')[1].strip().split()[0] + add_result = cli_run(["add", f"{test_file}@eng"], env=env) + func_hash = add_result.stdout.split("Hash:")[1].strip().split()[0] # Test: Provide translations for all names # 4 names: function + 3 variables input_text = "calculer\nresultat\nvaleur\nmultiplicateur\nCalculer le resultat\nTest comment\n" - result = cli_run(['translate', f'{func_hash}@eng', 'fra'], env=env, input_text=input_text) + result = cli_run( + ["translate", f"{func_hash}@eng", "fra"], env=env, input_text=input_text + ) # Assert assert result.returncode == 0 - assert 'Translation saved' in result.stdout + assert "Translation saved" in result.stdout diff --git a/tests/validate/test_validate.py b/tests/validate/test_validate.py index c1fed53..53d2bb7 100644 --- a/tests/validate/test_validate.py +++ b/tests/validate/test_validate.py @@ -3,253 +3,265 @@ Grey-box integration tests for function validation. """ + import json import os import subprocess import sys from pathlib import Path -import pytest from tests.conftest import normalize_code_for_test def cli_run(args: list, env: dict = None) -> subprocess.CompletedProcess: """Run bb.py CLI command.""" - cmd = [sys.executable, str(Path(__file__).parent.parent.parent / 'bb.py')] + args + cmd = [sys.executable, str(Path(__file__).parent.parent.parent / "bb.py")] + args run_env = os.environ.copy() if env: run_env.update(env) - return subprocess.run( - cmd, - capture_output=True, - text=True, - env=run_env - ) + return subprocess.run(cmd, capture_output=True, text=True, env=run_env) def test_validate_valid_function(tmp_path): """Test that validate succeeds for valid function""" - bb_dir = tmp_path / '.bb' - env = {'BB_DIRECTORY': str(bb_dir)} + bb_dir = tmp_path / ".bb" + env = {"BB_DIRECTORY": str(bb_dir)} # Setup: Add a function test_file = tmp_path / "func.py" - test_file.write_text('def foo(): pass') - add_result = cli_run(['add', f'{test_file}@eng'], env=env) - func_hash = add_result.stdout.split('Hash:')[1].strip().split()[0] + test_file.write_text("def foo(): pass") + add_result = cli_run(["add", f"{test_file}@eng"], env=env) + func_hash = add_result.stdout.split("Hash:")[1].strip().split()[0] # Test - result = cli_run(['validate', func_hash], env=env) + result = cli_run(["validate", func_hash], env=env) # Assert assert result.returncode == 0 - assert 'valid' in result.stdout.lower() + assert "valid" in result.stdout.lower() def test_validate_nonexistent_function_fails(tmp_path): """Test that validate fails for nonexistent function""" - bb_dir = tmp_path / '.bb' - (bb_dir / 'pool').mkdir(parents=True) - env = {'BB_DIRECTORY': str(bb_dir)} + bb_dir = tmp_path / ".bb" + (bb_dir / "pool").mkdir(parents=True) + env = {"BB_DIRECTORY": str(bb_dir)} - fake_hash = 'f' * 64 - result = cli_run(['validate', fake_hash], env=env) + fake_hash = "f" * 64 + result = cli_run(["validate", fake_hash], env=env) assert result.returncode != 0 - assert 'invalid' in result.stderr.lower() - assert 'object.json not found' in result.stderr + assert "invalid" in result.stderr.lower() + assert "object.json not found" in result.stderr def test_validate_corrupted_object_json_fails(tmp_path): """Test that validate fails for corrupted object.json""" - bb_dir = tmp_path / '.bb' - env = {'BB_DIRECTORY': str(bb_dir)} + bb_dir = tmp_path / ".bb" + env = {"BB_DIRECTORY": str(bb_dir)} # Setup: Create corrupted function - fake_hash = 'a' * 64 - func_dir = bb_dir / 'pool' / fake_hash[:2] / fake_hash[2:] + fake_hash = "a" * 64 + func_dir = bb_dir / "pool" / fake_hash[:2] / fake_hash[2:] func_dir.mkdir(parents=True) - (func_dir / 'object.json').write_text('not valid json') + (func_dir / "object.json").write_text("not valid json") # Test - result = cli_run(['validate', fake_hash], env=env) + result = cli_run(["validate", fake_hash], env=env) # Assert assert result.returncode != 0 - assert 'invalid' in result.stderr.lower() + assert "invalid" in result.stderr.lower() def test_validate_missing_fields_fails(tmp_path): """Test that validate fails when required fields are missing""" - bb_dir = tmp_path / '.bb' - env = {'BB_DIRECTORY': str(bb_dir)} + bb_dir = tmp_path / ".bb" + env = {"BB_DIRECTORY": str(bb_dir)} # Setup: Create function with incomplete object.json - fake_hash = 'b' * 64 - func_dir = bb_dir / 'pool' / fake_hash[:2] / fake_hash[2:] + fake_hash = "b" * 64 + func_dir = bb_dir / "pool" / fake_hash[:2] / fake_hash[2:] func_dir.mkdir(parents=True) - (func_dir / 'object.json').write_text(json.dumps({ - 'schema_version': 1, - 'hash': fake_hash - # Missing: normalized_code, metadata - })) + (func_dir / "object.json").write_text( + json.dumps( + { + "schema_version": 1, + "hash": fake_hash, + # Missing: normalized_code, metadata + } + ) + ) # Test - result = cli_run(['validate', fake_hash], env=env) + result = cli_run(["validate", fake_hash], env=env) # Assert assert result.returncode != 0 - assert 'invalid' in result.stderr.lower() - assert 'Missing required field' in result.stderr + assert "invalid" in result.stderr.lower() + assert "Missing required field" in result.stderr def test_validate_wrong_schema_version_fails(tmp_path): """Test that validate fails for wrong schema version""" - bb_dir = tmp_path / '.bb' - env = {'BB_DIRECTORY': str(bb_dir)} + bb_dir = tmp_path / ".bb" + env = {"BB_DIRECTORY": str(bb_dir)} # Setup: Create function with wrong schema version - fake_hash = 'c' * 64 - func_dir = bb_dir / 'pool' / fake_hash[:2] / fake_hash[2:] + fake_hash = "c" * 64 + func_dir = bb_dir / "pool" / fake_hash[:2] / fake_hash[2:] func_dir.mkdir(parents=True) - (func_dir / 'object.json').write_text(json.dumps({ - 'schema_version': 99, - 'hash': fake_hash, - 'normalized_code': normalize_code_for_test('def _bb_v_0(): pass'), - 'metadata': {} - })) + (func_dir / "object.json").write_text( + json.dumps( + { + "schema_version": 99, + "hash": fake_hash, + "normalized_code": normalize_code_for_test("def _bb_v_0(): pass"), + "metadata": {}, + } + ) + ) # Test - result = cli_run(['validate', fake_hash], env=env) + result = cli_run(["validate", fake_hash], env=env) # Assert assert result.returncode != 0 - assert 'Invalid schema version' in result.stderr + assert "Invalid schema version" in result.stderr def test_validate_no_language_mapping_fails(tmp_path): """Test that validate fails when no language mapping exists""" - bb_dir = tmp_path / '.bb' - env = {'BB_DIRECTORY': str(bb_dir)} + bb_dir = tmp_path / ".bb" + env = {"BB_DIRECTORY": str(bb_dir)} # Setup: Create function without language mapping - fake_hash = 'd' * 64 - func_dir = bb_dir / 'pool' / fake_hash[:2] / fake_hash[2:] + fake_hash = "d" * 64 + func_dir = bb_dir / "pool" / fake_hash[:2] / fake_hash[2:] func_dir.mkdir(parents=True) - (func_dir / 'object.json').write_text(json.dumps({ - 'schema_version': 1, - 'hash': fake_hash, - 'normalized_code': normalize_code_for_test('def _bb_v_0(): pass'), - 'metadata': {'created': '2025-01-01', 'name': 'test', 'email': 'test@example.com'} - })) + (func_dir / "object.json").write_text( + json.dumps( + { + "schema_version": 1, + "hash": fake_hash, + "normalized_code": normalize_code_for_test("def _bb_v_0(): pass"), + "metadata": { + "created": "2025-01-01", + "name": "test", + "email": "test@example.com", + }, + } + ) + ) # Test - result = cli_run(['validate', fake_hash], env=env) + result = cli_run(["validate", fake_hash], env=env) # Assert assert result.returncode != 0 - assert 'No language mappings found' in result.stderr + assert "No language mappings found" in result.stderr # ============================================================================= # Tests for validate --all (entire directory) # ============================================================================= + def test_validate_all_empty_pool(tmp_path): """Test that validate --all works with empty pool""" - bb_dir = tmp_path / '.bb' - (bb_dir / 'pool').mkdir(parents=True) - env = {'BB_DIRECTORY': str(bb_dir)} + bb_dir = tmp_path / ".bb" + (bb_dir / "pool").mkdir(parents=True) + env = {"BB_DIRECTORY": str(bb_dir)} # Test - result = cli_run(['validate', '--all'], env=env) + result = cli_run(["validate", "--all"], env=env) # Assert: Should succeed with 0 functions assert result.returncode == 0 - assert 'Functions total: 0' in result.stdout - assert 'valid' in result.stdout.lower() + assert "Functions total: 0" in result.stdout + assert "valid" in result.stdout.lower() def test_validate_all_valid_pool(tmp_path): """Test that validate --all succeeds for valid pool""" - bb_dir = tmp_path / '.bb' - env = {'BB_DIRECTORY': str(bb_dir)} + bb_dir = tmp_path / ".bb" + env = {"BB_DIRECTORY": str(bb_dir)} # Setup: Add multiple functions for i in range(3): test_file = tmp_path / f"func{i}.py" - test_file.write_text(f'def func{i}(): return {i}') - cli_run(['add', f'{test_file}@eng'], env=env) + test_file.write_text(f"def func{i}(): return {i}") + cli_run(["add", f"{test_file}@eng"], env=env) # Test - result = cli_run(['validate', '--all'], env=env) + result = cli_run(["validate", "--all"], env=env) # Assert assert result.returncode == 0 - assert 'Functions total: 3' in result.stdout - assert 'Functions valid: 3' in result.stdout - assert 'Functions invalid: 0' in result.stdout - assert 'valid' in result.stdout.lower() + assert "Functions total: 3" in result.stdout + assert "Functions valid: 3" in result.stdout + assert "Functions invalid: 0" in result.stdout + assert "valid" in result.stdout.lower() def test_validate_all_shows_statistics(tmp_path): """Test that validate --all shows statistics""" - bb_dir = tmp_path / '.bb' - env = {'BB_DIRECTORY': str(bb_dir)} + bb_dir = tmp_path / ".bb" + env = {"BB_DIRECTORY": str(bb_dir)} # Setup: Add a function test_file = tmp_path / "func.py" - test_file.write_text('def stats_test(): pass') - cli_run(['add', f'{test_file}@eng'], env=env) + test_file.write_text("def stats_test(): pass") + cli_run(["add", f"{test_file}@eng"], env=env) # Test - result = cli_run(['validate', '--all'], env=env) + result = cli_run(["validate", "--all"], env=env) # Assert: Should show statistics assert result.returncode == 0 - assert 'BB Directory Validation' in result.stdout - assert 'Functions total' in result.stdout - assert 'Functions valid' in result.stdout - assert 'Languages found' in result.stdout - assert 'Missing deps' in result.stdout + assert "BB Directory Validation" in result.stdout + assert "Functions total" in result.stdout + assert "Functions valid" in result.stdout + assert "Languages found" in result.stdout + assert "Missing deps" in result.stdout def test_validate_all_detects_invalid_function(tmp_path): """Test that validate --all detects invalid functions""" - bb_dir = tmp_path / '.bb' - env = {'BB_DIRECTORY': str(bb_dir)} + bb_dir = tmp_path / ".bb" + env = {"BB_DIRECTORY": str(bb_dir)} # Setup: Add a valid function first test_file = tmp_path / "valid.py" - test_file.write_text('def valid(): pass') - cli_run(['add', f'{test_file}@eng'], env=env) + test_file.write_text("def valid(): pass") + cli_run(["add", f"{test_file}@eng"], env=env) # Create an invalid function manually - fake_hash = 'e' * 64 - func_dir = bb_dir / 'pool' / fake_hash[:2] / fake_hash[2:] + fake_hash = "e" * 64 + func_dir = bb_dir / "pool" / fake_hash[:2] / fake_hash[2:] func_dir.mkdir(parents=True) - (func_dir / 'object.json').write_text('invalid json') + (func_dir / "object.json").write_text("invalid json") # Test - result = cli_run(['validate', '--all'], env=env) + result = cli_run(["validate", "--all"], env=env) # Assert: Should fail and report error assert result.returncode != 0 - assert 'Functions invalid: 1' in result.stdout + assert "Functions invalid: 1" in result.stdout def test_validate_no_args_validates_directory(tmp_path): """Test that validate without hash validates entire directory""" - bb_dir = tmp_path / '.bb' - (bb_dir / 'pool').mkdir(parents=True) - env = {'BB_DIRECTORY': str(bb_dir)} + bb_dir = tmp_path / ".bb" + (bb_dir / "pool").mkdir(parents=True) + env = {"BB_DIRECTORY": str(bb_dir)} # Test: No hash provided, should validate directory - result = cli_run(['validate'], env=env) + result = cli_run(["validate"], env=env) # Assert: Should run directory validation assert result.returncode == 0 - assert 'BB Directory Validation' in result.stdout + assert "BB Directory Validation" in result.stdout diff --git a/tests/whoami/test_whoami.py b/tests/whoami/test_whoami.py index 043c662..54e8ab5 100644 --- a/tests/whoami/test_whoami.py +++ b/tests/whoami/test_whoami.py @@ -3,198 +3,195 @@ Grey-box integration tests for user configuration management. """ + import json import os import subprocess import sys from pathlib import Path -import pytest - -def cli_run(args: list, env: dict = None, cwd: str = None) -> subprocess.CompletedProcess: +def cli_run( + args: list, env: dict = None, cwd: str = None +) -> subprocess.CompletedProcess: """Run bb.py CLI command.""" - cmd = [sys.executable, str(Path(__file__).parent.parent.parent / 'bb.py')] + args + cmd = [sys.executable, str(Path(__file__).parent.parent.parent / "bb.py")] + args run_env = os.environ.copy() if env: run_env.update(env) - return subprocess.run( - cmd, - capture_output=True, - text=True, - env=run_env, - cwd=cwd - ) + return subprocess.run(cmd, capture_output=True, text=True, env=run_env, cwd=cwd) def test_whoami_get_name_empty_without_init(tmp_path): """Test getting name returns empty when config doesn't exist.""" - bb_dir = tmp_path / '.bb' - env = {'BB_DIRECTORY': str(bb_dir)} + bb_dir = tmp_path / ".bb" + env = {"BB_DIRECTORY": str(bb_dir)} - result = cli_run(['whoami', 'name'], env=env) + result = cli_run(["whoami", "name"], env=env) assert result.returncode == 0 - assert result.stdout.strip() == '' + assert result.stdout.strip() == "" def test_whoami_set_and_get_name(tmp_path): """Test setting and getting name.""" - bb_dir = tmp_path / '.bb' - env = {'BB_DIRECTORY': str(bb_dir)} + bb_dir = tmp_path / ".bb" + env = {"BB_DIRECTORY": str(bb_dir)} # Initialize first - cli_run(['init'], env=env) + cli_run(["init"], env=env) # Set name - result = cli_run(['whoami', 'name', 'testuser'], env=env) + result = cli_run(["whoami", "name", "testuser"], env=env) assert result.returncode == 0 - assert 'Set name: testuser' in result.stdout + assert "Set name: testuser" in result.stdout # Get name - result = cli_run(['whoami', 'name'], env=env) + result = cli_run(["whoami", "name"], env=env) assert result.returncode == 0 - assert result.stdout.strip() == 'testuser' + assert result.stdout.strip() == "testuser" def test_whoami_set_and_get_email(tmp_path): """Test setting and getting email.""" - bb_dir = tmp_path / '.bb' - env = {'BB_DIRECTORY': str(bb_dir)} + bb_dir = tmp_path / ".bb" + env = {"BB_DIRECTORY": str(bb_dir)} - cli_run(['init'], env=env) + cli_run(["init"], env=env) # Set email - result = cli_run(['whoami', 'email', 'test@example.com'], env=env) + result = cli_run(["whoami", "email", "test@example.com"], env=env) assert result.returncode == 0 - assert 'Set email: test@example.com' in result.stdout + assert "Set email: test@example.com" in result.stdout # Get email - result = cli_run(['whoami', 'email'], env=env) + result = cli_run(["whoami", "email"], env=env) assert result.returncode == 0 - assert result.stdout.strip() == 'test@example.com' + assert result.stdout.strip() == "test@example.com" def test_whoami_set_and_get_public_key(tmp_path): """Test setting and getting public key.""" - bb_dir = tmp_path / '.bb' - env = {'BB_DIRECTORY': str(bb_dir)} + bb_dir = tmp_path / ".bb" + env = {"BB_DIRECTORY": str(bb_dir)} - cli_run(['init'], env=env) + cli_run(["init"], env=env) # Set public key - result = cli_run(['whoami', 'public-key', 'https://keys.example.com/key.pub'], env=env) + result = cli_run( + ["whoami", "public-key", "https://keys.example.com/key.pub"], env=env + ) assert result.returncode == 0 - assert 'Set public-key: https://keys.example.com/key.pub' in result.stdout + assert "Set public-key: https://keys.example.com/key.pub" in result.stdout # Get public key - result = cli_run(['whoami', 'public-key'], env=env) + result = cli_run(["whoami", "public-key"], env=env) assert result.returncode == 0 - assert result.stdout.strip() == 'https://keys.example.com/key.pub' + assert result.stdout.strip() == "https://keys.example.com/key.pub" def test_whoami_set_and_get_languages(tmp_path): """Test setting and getting languages (multiple values).""" - bb_dir = tmp_path / '.bb' - env = {'BB_DIRECTORY': str(bb_dir)} + bb_dir = tmp_path / ".bb" + env = {"BB_DIRECTORY": str(bb_dir)} - cli_run(['init'], env=env) + cli_run(["init"], env=env) # Set multiple languages - result = cli_run(['whoami', 'language', 'eng', 'fra', 'spa'], env=env) + result = cli_run(["whoami", "language", "eng", "fra", "spa"], env=env) assert result.returncode == 0 - assert 'Set language: eng fra spa' in result.stdout + assert "Set language: eng fra spa" in result.stdout # Get languages - result = cli_run(['whoami', 'language'], env=env) + result = cli_run(["whoami", "language"], env=env) assert result.returncode == 0 - assert result.stdout.strip() == 'eng fra spa' + assert result.stdout.strip() == "eng fra spa" def test_whoami_languages_replace_not_append(tmp_path): """Test that setting languages replaces previous values.""" - bb_dir = tmp_path / '.bb' - env = {'BB_DIRECTORY': str(bb_dir)} + bb_dir = tmp_path / ".bb" + env = {"BB_DIRECTORY": str(bb_dir)} - cli_run(['init'], env=env) + cli_run(["init"], env=env) # Set initial languages - cli_run(['whoami', 'language', 'eng', 'fra'], env=env) + cli_run(["whoami", "language", "eng", "fra"], env=env) # Set new languages (should replace) - cli_run(['whoami', 'language', 'deu', 'ita'], env=env) + cli_run(["whoami", "language", "deu", "ita"], env=env) # Get languages - result = cli_run(['whoami', 'language'], env=env) + result = cli_run(["whoami", "language"], env=env) assert result.returncode == 0 - assert result.stdout.strip() == 'deu ita' + assert result.stdout.strip() == "deu ita" def test_whoami_invalid_subcommand(tmp_path): """Test that invalid subcommand fails with argparse error.""" - bb_dir = tmp_path / '.bb' - env = {'BB_DIRECTORY': str(bb_dir)} + bb_dir = tmp_path / ".bb" + env = {"BB_DIRECTORY": str(bb_dir)} - result = cli_run(['whoami', 'invalid'], env=env) + result = cli_run(["whoami", "invalid"], env=env) assert result.returncode == 2 - assert 'invalid choice' in result.stderr + assert "invalid choice" in result.stderr def test_whoami_persists_to_config_file(tmp_path): """Test that whoami changes are persisted to config.json.""" - bb_dir = tmp_path / '.bb' - env = {'BB_DIRECTORY': str(bb_dir)} + bb_dir = tmp_path / ".bb" + env = {"BB_DIRECTORY": str(bb_dir)} - cli_run(['init'], env=env) - cli_run(['whoami', 'name', 'persisteduser'], env=env) - cli_run(['whoami', 'email', 'persisted@example.com'], env=env) + cli_run(["init"], env=env) + cli_run(["whoami", "name", "persisteduser"], env=env) + cli_run(["whoami", "email", "persisted@example.com"], env=env) # Read config directly - config = json.loads((bb_dir / 'config.json').read_text()) + config = json.loads((bb_dir / "config.json").read_text()) - assert config['user']['name'] == 'persisteduser' - assert config['user']['email'] == 'persisted@example.com' + assert config["user"]["name"] == "persisteduser" + assert config["user"]["email"] == "persisted@example.com" def test_whoami_corrupted_config_fails_gracefully(tmp_path): """Test that corrupted config file shows helpful error.""" - bb_dir = tmp_path / '.bb' + bb_dir = tmp_path / ".bb" bb_dir.mkdir(parents=True) - (bb_dir / 'config.json').write_text('corrupted json') - env = {'BB_DIRECTORY': str(bb_dir)} + (bb_dir / "config.json").write_text("corrupted json") + env = {"BB_DIRECTORY": str(bb_dir)} - result = cli_run(['whoami', 'name'], env=env) + result = cli_run(["whoami", "name"], env=env) assert result.returncode == 1 - assert 'Error' in result.stderr - assert 'config' in result.stderr.lower() + assert "Error" in result.stderr + assert "config" in result.stderr.lower() def test_whoami_get_empty_languages(tmp_path): """Test getting languages when none are set.""" - bb_dir = tmp_path / '.bb' - env = {'BB_DIRECTORY': str(bb_dir)} + bb_dir = tmp_path / ".bb" + env = {"BB_DIRECTORY": str(bb_dir)} # Don't init - default config has empty languages list - result = cli_run(['whoami', 'language'], env=env) + result = cli_run(["whoami", "language"], env=env) assert result.returncode == 0 - assert result.stdout.strip() == '' + assert result.stdout.strip() == "" def test_whoami_single_language(tmp_path): """Test setting a single language.""" - bb_dir = tmp_path / '.bb' - env = {'BB_DIRECTORY': str(bb_dir)} + bb_dir = tmp_path / ".bb" + env = {"BB_DIRECTORY": str(bb_dir)} - cli_run(['init'], env=env) - result = cli_run(['whoami', 'language', 'fra'], env=env) + cli_run(["init"], env=env) + result = cli_run(["whoami", "language", "fra"], env=env) assert result.returncode == 0 - assert 'Set language: fra' in result.stdout + assert "Set language: fra" in result.stdout - result = cli_run(['whoami', 'language'], env=env) - assert result.stdout.strip() == 'fra' + result = cli_run(["whoami", "language"], env=env) + assert result.stdout.strip() == "fra" diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..467c571 --- /dev/null +++ b/uv.lock @@ -0,0 +1,448 @@ +version = 1 +revision = 3 +requires-python = ">=3.9" +resolution-markers = [ + "python_full_version >= '3.10'", + "python_full_version < '3.10'", +] + +[[package]] +name = "bb-py" +version = "0.1.0" +source = { editable = "." } + +[package.optional-dependencies] +dev = [ + { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "pytest", version = "9.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pytest-cov" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest-cov" }, +] + +[package.metadata] +requires-dist = [ + { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0.0" }, + { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.0.0" }, +] +provides-extras = ["dev"] + +[package.metadata.requires-dev] +dev = [{ name = "pytest-cov", specifier = ">=7.0.0" }] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.10.7" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/51/26/d22c300112504f5f9a9fd2297ce33c35f3d353e4aeb987c8419453b2a7c2/coverage-7.10.7.tar.gz", hash = "sha256:f4ab143ab113be368a3e9b795f9cd7906c5ef407d6173fe9675a902e1fffc239", size = 827704, upload-time = "2025-09-21T20:03:56.815Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/6c/3a3f7a46888e69d18abe3ccc6fe4cb16cccb1e6a2f99698931dafca489e6/coverage-7.10.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:fc04cc7a3db33664e0c2d10eb8990ff6b3536f6842c9590ae8da4c614b9ed05a", size = 217987, upload-time = "2025-09-21T20:00:57.218Z" }, + { url = "https://files.pythonhosted.org/packages/03/94/952d30f180b1a916c11a56f5c22d3535e943aa22430e9e3322447e520e1c/coverage-7.10.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e201e015644e207139f7e2351980feb7040e6f4b2c2978892f3e3789d1c125e5", size = 218388, upload-time = "2025-09-21T20:01:00.081Z" }, + { url = "https://files.pythonhosted.org/packages/50/2b/9e0cf8ded1e114bcd8b2fd42792b57f1c4e9e4ea1824cde2af93a67305be/coverage-7.10.7-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:240af60539987ced2c399809bd34f7c78e8abe0736af91c3d7d0e795df633d17", size = 245148, upload-time = "2025-09-21T20:01:01.768Z" }, + { url = "https://files.pythonhosted.org/packages/19/20/d0384ac06a6f908783d9b6aa6135e41b093971499ec488e47279f5b846e6/coverage-7.10.7-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8421e088bc051361b01c4b3a50fd39a4b9133079a2229978d9d30511fd05231b", size = 246958, upload-time = "2025-09-21T20:01:03.355Z" }, + { url = "https://files.pythonhosted.org/packages/60/83/5c283cff3d41285f8eab897651585db908a909c572bdc014bcfaf8a8b6ae/coverage-7.10.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6be8ed3039ae7f7ac5ce058c308484787c86e8437e72b30bf5e88b8ea10f3c87", size = 248819, upload-time = "2025-09-21T20:01:04.968Z" }, + { url = "https://files.pythonhosted.org/packages/60/22/02eb98fdc5ff79f423e990d877693e5310ae1eab6cb20ae0b0b9ac45b23b/coverage-7.10.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e28299d9f2e889e6d51b1f043f58d5f997c373cc12e6403b90df95b8b047c13e", size = 245754, upload-time = "2025-09-21T20:01:06.321Z" }, + { url = "https://files.pythonhosted.org/packages/b4/bc/25c83bcf3ad141b32cd7dc45485ef3c01a776ca3aa8ef0a93e77e8b5bc43/coverage-7.10.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c4e16bd7761c5e454f4efd36f345286d6f7c5fa111623c355691e2755cae3b9e", size = 246860, upload-time = "2025-09-21T20:01:07.605Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b7/95574702888b58c0928a6e982038c596f9c34d52c5e5107f1eef729399b5/coverage-7.10.7-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b1c81d0e5e160651879755c9c675b974276f135558cf4ba79fee7b8413a515df", size = 244877, upload-time = "2025-09-21T20:01:08.829Z" }, + { url = "https://files.pythonhosted.org/packages/47/b6/40095c185f235e085df0e0b158f6bd68cc6e1d80ba6c7721dc81d97ec318/coverage-7.10.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:606cc265adc9aaedcc84f1f064f0e8736bc45814f15a357e30fca7ecc01504e0", size = 245108, upload-time = "2025-09-21T20:01:10.527Z" }, + { url = "https://files.pythonhosted.org/packages/c8/50/4aea0556da7a4b93ec9168420d170b55e2eb50ae21b25062513d020c6861/coverage-7.10.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:10b24412692df990dbc34f8fb1b6b13d236ace9dfdd68df5b28c2e39cafbba13", size = 245752, upload-time = "2025-09-21T20:01:11.857Z" }, + { url = "https://files.pythonhosted.org/packages/6a/28/ea1a84a60828177ae3b100cb6723838523369a44ec5742313ed7db3da160/coverage-7.10.7-cp310-cp310-win32.whl", hash = "sha256:b51dcd060f18c19290d9b8a9dd1e0181538df2ce0717f562fff6cf74d9fc0b5b", size = 220497, upload-time = "2025-09-21T20:01:13.459Z" }, + { url = "https://files.pythonhosted.org/packages/fc/1a/a81d46bbeb3c3fd97b9602ebaa411e076219a150489bcc2c025f151bd52d/coverage-7.10.7-cp310-cp310-win_amd64.whl", hash = "sha256:3a622ac801b17198020f09af3eaf45666b344a0d69fc2a6ffe2ea83aeef1d807", size = 221392, upload-time = "2025-09-21T20:01:14.722Z" }, + { url = "https://files.pythonhosted.org/packages/d2/5d/c1a17867b0456f2e9ce2d8d4708a4c3a089947d0bec9c66cdf60c9e7739f/coverage-7.10.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a609f9c93113be646f44c2a0256d6ea375ad047005d7f57a5c15f614dc1b2f59", size = 218102, upload-time = "2025-09-21T20:01:16.089Z" }, + { url = "https://files.pythonhosted.org/packages/54/f0/514dcf4b4e3698b9a9077f084429681bf3aad2b4a72578f89d7f643eb506/coverage-7.10.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:65646bb0359386e07639c367a22cf9b5bf6304e8630b565d0626e2bdf329227a", size = 218505, upload-time = "2025-09-21T20:01:17.788Z" }, + { url = "https://files.pythonhosted.org/packages/20/f6/9626b81d17e2a4b25c63ac1b425ff307ecdeef03d67c9a147673ae40dc36/coverage-7.10.7-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5f33166f0dfcce728191f520bd2692914ec70fac2713f6bf3ce59c3deacb4699", size = 248898, upload-time = "2025-09-21T20:01:19.488Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ef/bd8e719c2f7417ba03239052e099b76ea1130ac0cbb183ee1fcaa58aaff3/coverage-7.10.7-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:35f5e3f9e455bb17831876048355dca0f758b6df22f49258cb5a91da23ef437d", size = 250831, upload-time = "2025-09-21T20:01:20.817Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b6/bf054de41ec948b151ae2b79a55c107f5760979538f5fb80c195f2517718/coverage-7.10.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4da86b6d62a496e908ac2898243920c7992499c1712ff7c2b6d837cc69d9467e", size = 252937, upload-time = "2025-09-21T20:01:22.171Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e5/3860756aa6f9318227443c6ce4ed7bf9e70bb7f1447a0353f45ac5c7974b/coverage-7.10.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6b8b09c1fad947c84bbbc95eca841350fad9cbfa5a2d7ca88ac9f8d836c92e23", size = 249021, upload-time = "2025-09-21T20:01:23.907Z" }, + { url = "https://files.pythonhosted.org/packages/26/0f/bd08bd042854f7fd07b45808927ebcce99a7ed0f2f412d11629883517ac2/coverage-7.10.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4376538f36b533b46f8971d3a3e63464f2c7905c9800db97361c43a2b14792ab", size = 250626, upload-time = "2025-09-21T20:01:25.721Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a7/4777b14de4abcc2e80c6b1d430f5d51eb18ed1d75fca56cbce5f2db9b36e/coverage-7.10.7-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:121da30abb574f6ce6ae09840dae322bef734480ceafe410117627aa54f76d82", size = 248682, upload-time = "2025-09-21T20:01:27.105Z" }, + { url = "https://files.pythonhosted.org/packages/34/72/17d082b00b53cd45679bad682fac058b87f011fd8b9fe31d77f5f8d3a4e4/coverage-7.10.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:88127d40df529336a9836870436fc2751c339fbaed3a836d42c93f3e4bd1d0a2", size = 248402, upload-time = "2025-09-21T20:01:28.629Z" }, + { url = "https://files.pythonhosted.org/packages/81/7a/92367572eb5bdd6a84bfa278cc7e97db192f9f45b28c94a9ca1a921c3577/coverage-7.10.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ba58bbcd1b72f136080c0bccc2400d66cc6115f3f906c499013d065ac33a4b61", size = 249320, upload-time = "2025-09-21T20:01:30.004Z" }, + { url = "https://files.pythonhosted.org/packages/2f/88/a23cc185f6a805dfc4fdf14a94016835eeb85e22ac3a0e66d5e89acd6462/coverage-7.10.7-cp311-cp311-win32.whl", hash = "sha256:972b9e3a4094b053a4e46832b4bc829fc8a8d347160eb39d03f1690316a99c14", size = 220536, upload-time = "2025-09-21T20:01:32.184Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ef/0b510a399dfca17cec7bc2f05ad8bd78cf55f15c8bc9a73ab20c5c913c2e/coverage-7.10.7-cp311-cp311-win_amd64.whl", hash = "sha256:a7b55a944a7f43892e28ad4bc0561dfd5f0d73e605d1aa5c3c976b52aea121d2", size = 221425, upload-time = "2025-09-21T20:01:33.557Z" }, + { url = "https://files.pythonhosted.org/packages/51/7f/023657f301a276e4ba1850f82749bc136f5a7e8768060c2e5d9744a22951/coverage-7.10.7-cp311-cp311-win_arm64.whl", hash = "sha256:736f227fb490f03c6488f9b6d45855f8e0fd749c007f9303ad30efab0e73c05a", size = 220103, upload-time = "2025-09-21T20:01:34.929Z" }, + { url = "https://files.pythonhosted.org/packages/13/e4/eb12450f71b542a53972d19117ea5a5cea1cab3ac9e31b0b5d498df1bd5a/coverage-7.10.7-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7bb3b9ddb87ef7725056572368040c32775036472d5a033679d1fa6c8dc08417", size = 218290, upload-time = "2025-09-21T20:01:36.455Z" }, + { url = "https://files.pythonhosted.org/packages/37/66/593f9be12fc19fb36711f19a5371af79a718537204d16ea1d36f16bd78d2/coverage-7.10.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:18afb24843cbc175687225cab1138c95d262337f5473512010e46831aa0c2973", size = 218515, upload-time = "2025-09-21T20:01:37.982Z" }, + { url = "https://files.pythonhosted.org/packages/66/80/4c49f7ae09cafdacc73fbc30949ffe77359635c168f4e9ff33c9ebb07838/coverage-7.10.7-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:399a0b6347bcd3822be369392932884b8216d0944049ae22925631a9b3d4ba4c", size = 250020, upload-time = "2025-09-21T20:01:39.617Z" }, + { url = "https://files.pythonhosted.org/packages/a6/90/a64aaacab3b37a17aaedd83e8000142561a29eb262cede42d94a67f7556b/coverage-7.10.7-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:314f2c326ded3f4b09be11bc282eb2fc861184bc95748ae67b360ac962770be7", size = 252769, upload-time = "2025-09-21T20:01:41.341Z" }, + { url = "https://files.pythonhosted.org/packages/98/2e/2dda59afd6103b342e096f246ebc5f87a3363b5412609946c120f4e7750d/coverage-7.10.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c41e71c9cfb854789dee6fc51e46743a6d138b1803fab6cb860af43265b42ea6", size = 253901, upload-time = "2025-09-21T20:01:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/53/dc/8d8119c9051d50f3119bb4a75f29f1e4a6ab9415cd1fa8bf22fcc3fb3b5f/coverage-7.10.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc01f57ca26269c2c706e838f6422e2a8788e41b3e3c65e2f41148212e57cd59", size = 250413, upload-time = "2025-09-21T20:01:44.469Z" }, + { url = "https://files.pythonhosted.org/packages/98/b3/edaff9c5d79ee4d4b6d3fe046f2b1d799850425695b789d491a64225d493/coverage-7.10.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a6442c59a8ac8b85812ce33bc4d05bde3fb22321fa8294e2a5b487c3505f611b", size = 251820, upload-time = "2025-09-21T20:01:45.915Z" }, + { url = "https://files.pythonhosted.org/packages/11/25/9a0728564bb05863f7e513e5a594fe5ffef091b325437f5430e8cfb0d530/coverage-7.10.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:78a384e49f46b80fb4c901d52d92abe098e78768ed829c673fbb53c498bef73a", size = 249941, upload-time = "2025-09-21T20:01:47.296Z" }, + { url = "https://files.pythonhosted.org/packages/e0/fd/ca2650443bfbef5b0e74373aac4df67b08180d2f184b482c41499668e258/coverage-7.10.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5e1e9802121405ede4b0133aa4340ad8186a1d2526de5b7c3eca519db7bb89fb", size = 249519, upload-time = "2025-09-21T20:01:48.73Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/f692f125fb4299b6f963b0745124998ebb8e73ecdfce4ceceb06a8c6bec5/coverage-7.10.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d41213ea25a86f69efd1575073d34ea11aabe075604ddf3d148ecfec9e1e96a1", size = 251375, upload-time = "2025-09-21T20:01:50.529Z" }, + { url = "https://files.pythonhosted.org/packages/5e/75/61b9bbd6c7d24d896bfeec57acba78e0f8deac68e6baf2d4804f7aae1f88/coverage-7.10.7-cp312-cp312-win32.whl", hash = "sha256:77eb4c747061a6af8d0f7bdb31f1e108d172762ef579166ec84542f711d90256", size = 220699, upload-time = "2025-09-21T20:01:51.941Z" }, + { url = "https://files.pythonhosted.org/packages/ca/f3/3bf7905288b45b075918d372498f1cf845b5b579b723c8fd17168018d5f5/coverage-7.10.7-cp312-cp312-win_amd64.whl", hash = "sha256:f51328ffe987aecf6d09f3cd9d979face89a617eacdaea43e7b3080777f647ba", size = 221512, upload-time = "2025-09-21T20:01:53.481Z" }, + { url = "https://files.pythonhosted.org/packages/5c/44/3e32dbe933979d05cf2dac5e697c8599cfe038aaf51223ab901e208d5a62/coverage-7.10.7-cp312-cp312-win_arm64.whl", hash = "sha256:bda5e34f8a75721c96085903c6f2197dc398c20ffd98df33f866a9c8fd95f4bf", size = 220147, upload-time = "2025-09-21T20:01:55.2Z" }, + { url = "https://files.pythonhosted.org/packages/9a/94/b765c1abcb613d103b64fcf10395f54d69b0ef8be6a0dd9c524384892cc7/coverage-7.10.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:981a651f543f2854abd3b5fcb3263aac581b18209be49863ba575de6edf4c14d", size = 218320, upload-time = "2025-09-21T20:01:56.629Z" }, + { url = "https://files.pythonhosted.org/packages/72/4f/732fff31c119bb73b35236dd333030f32c4bfe909f445b423e6c7594f9a2/coverage-7.10.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:73ab1601f84dc804f7812dc297e93cd99381162da39c47040a827d4e8dafe63b", size = 218575, upload-time = "2025-09-21T20:01:58.203Z" }, + { url = "https://files.pythonhosted.org/packages/87/02/ae7e0af4b674be47566707777db1aa375474f02a1d64b9323e5813a6cdd5/coverage-7.10.7-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a8b6f03672aa6734e700bbcd65ff050fd19cddfec4b031cc8cf1c6967de5a68e", size = 249568, upload-time = "2025-09-21T20:01:59.748Z" }, + { url = "https://files.pythonhosted.org/packages/a2/77/8c6d22bf61921a59bce5471c2f1f7ac30cd4ac50aadde72b8c48d5727902/coverage-7.10.7-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10b6ba00ab1132a0ce4428ff68cf50a25efd6840a42cdf4239c9b99aad83be8b", size = 252174, upload-time = "2025-09-21T20:02:01.192Z" }, + { url = "https://files.pythonhosted.org/packages/b1/20/b6ea4f69bbb52dac0aebd62157ba6a9dddbfe664f5af8122dac296c3ee15/coverage-7.10.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c79124f70465a150e89340de5963f936ee97097d2ef76c869708c4248c63ca49", size = 253447, upload-time = "2025-09-21T20:02:02.701Z" }, + { url = "https://files.pythonhosted.org/packages/f9/28/4831523ba483a7f90f7b259d2018fef02cb4d5b90bc7c1505d6e5a84883c/coverage-7.10.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:69212fbccdbd5b0e39eac4067e20a4a5256609e209547d86f740d68ad4f04911", size = 249779, upload-time = "2025-09-21T20:02:04.185Z" }, + { url = "https://files.pythonhosted.org/packages/a7/9f/4331142bc98c10ca6436d2d620c3e165f31e6c58d43479985afce6f3191c/coverage-7.10.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7ea7c6c9d0d286d04ed3541747e6597cbe4971f22648b68248f7ddcd329207f0", size = 251604, upload-time = "2025-09-21T20:02:06.034Z" }, + { url = "https://files.pythonhosted.org/packages/ce/60/bda83b96602036b77ecf34e6393a3836365481b69f7ed7079ab85048202b/coverage-7.10.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b9be91986841a75042b3e3243d0b3cb0b2434252b977baaf0cd56e960fe1e46f", size = 249497, upload-time = "2025-09-21T20:02:07.619Z" }, + { url = "https://files.pythonhosted.org/packages/5f/af/152633ff35b2af63977edd835d8e6430f0caef27d171edf2fc76c270ef31/coverage-7.10.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b281d5eca50189325cfe1f365fafade89b14b4a78d9b40b05ddd1fc7d2a10a9c", size = 249350, upload-time = "2025-09-21T20:02:10.34Z" }, + { url = "https://files.pythonhosted.org/packages/9d/71/d92105d122bd21cebba877228990e1646d862e34a98bb3374d3fece5a794/coverage-7.10.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:99e4aa63097ab1118e75a848a28e40d68b08a5e19ce587891ab7fd04475e780f", size = 251111, upload-time = "2025-09-21T20:02:12.122Z" }, + { url = "https://files.pythonhosted.org/packages/a2/9e/9fdb08f4bf476c912f0c3ca292e019aab6712c93c9344a1653986c3fd305/coverage-7.10.7-cp313-cp313-win32.whl", hash = "sha256:dc7c389dce432500273eaf48f410b37886be9208b2dd5710aaf7c57fd442c698", size = 220746, upload-time = "2025-09-21T20:02:13.919Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b1/a75fd25df44eab52d1931e89980d1ada46824c7a3210be0d3c88a44aaa99/coverage-7.10.7-cp313-cp313-win_amd64.whl", hash = "sha256:cac0fdca17b036af3881a9d2729a850b76553f3f716ccb0360ad4dbc06b3b843", size = 221541, upload-time = "2025-09-21T20:02:15.57Z" }, + { url = "https://files.pythonhosted.org/packages/14/3a/d720d7c989562a6e9a14b2c9f5f2876bdb38e9367126d118495b89c99c37/coverage-7.10.7-cp313-cp313-win_arm64.whl", hash = "sha256:4b6f236edf6e2f9ae8fcd1332da4e791c1b6ba0dc16a2dc94590ceccb482e546", size = 220170, upload-time = "2025-09-21T20:02:17.395Z" }, + { url = "https://files.pythonhosted.org/packages/bb/22/e04514bf2a735d8b0add31d2b4ab636fc02370730787c576bb995390d2d5/coverage-7.10.7-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a0ec07fd264d0745ee396b666d47cef20875f4ff2375d7c4f58235886cc1ef0c", size = 219029, upload-time = "2025-09-21T20:02:18.936Z" }, + { url = "https://files.pythonhosted.org/packages/11/0b/91128e099035ece15da3445d9015e4b4153a6059403452d324cbb0a575fa/coverage-7.10.7-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd5e856ebb7bfb7672b0086846db5afb4567a7b9714b8a0ebafd211ec7ce6a15", size = 219259, upload-time = "2025-09-21T20:02:20.44Z" }, + { url = "https://files.pythonhosted.org/packages/8b/51/66420081e72801536a091a0c8f8c1f88a5c4bf7b9b1bdc6222c7afe6dc9b/coverage-7.10.7-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f57b2a3c8353d3e04acf75b3fed57ba41f5c0646bbf1d10c7c282291c97936b4", size = 260592, upload-time = "2025-09-21T20:02:22.313Z" }, + { url = "https://files.pythonhosted.org/packages/5d/22/9b8d458c2881b22df3db5bb3e7369e63d527d986decb6c11a591ba2364f7/coverage-7.10.7-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1ef2319dd15a0b009667301a3f84452a4dc6fddfd06b0c5c53ea472d3989fbf0", size = 262768, upload-time = "2025-09-21T20:02:24.287Z" }, + { url = "https://files.pythonhosted.org/packages/f7/08/16bee2c433e60913c610ea200b276e8eeef084b0d200bdcff69920bd5828/coverage-7.10.7-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83082a57783239717ceb0ad584de3c69cf581b2a95ed6bf81ea66034f00401c0", size = 264995, upload-time = "2025-09-21T20:02:26.133Z" }, + { url = "https://files.pythonhosted.org/packages/20/9d/e53eb9771d154859b084b90201e5221bca7674ba449a17c101a5031d4054/coverage-7.10.7-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:50aa94fb1fb9a397eaa19c0d5ec15a5edd03a47bf1a3a6111a16b36e190cff65", size = 259546, upload-time = "2025-09-21T20:02:27.716Z" }, + { url = "https://files.pythonhosted.org/packages/ad/b0/69bc7050f8d4e56a89fb550a1577d5d0d1db2278106f6f626464067b3817/coverage-7.10.7-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2120043f147bebb41c85b97ac45dd173595ff14f2a584f2963891cbcc3091541", size = 262544, upload-time = "2025-09-21T20:02:29.216Z" }, + { url = "https://files.pythonhosted.org/packages/ef/4b/2514b060dbd1bc0aaf23b852c14bb5818f244c664cb16517feff6bb3a5ab/coverage-7.10.7-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2fafd773231dd0378fdba66d339f84904a8e57a262f583530f4f156ab83863e6", size = 260308, upload-time = "2025-09-21T20:02:31.226Z" }, + { url = "https://files.pythonhosted.org/packages/54/78/7ba2175007c246d75e496f64c06e94122bdb914790a1285d627a918bd271/coverage-7.10.7-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:0b944ee8459f515f28b851728ad224fa2d068f1513ef6b7ff1efafeb2185f999", size = 258920, upload-time = "2025-09-21T20:02:32.823Z" }, + { url = "https://files.pythonhosted.org/packages/c0/b3/fac9f7abbc841409b9a410309d73bfa6cfb2e51c3fada738cb607ce174f8/coverage-7.10.7-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4b583b97ab2e3efe1b3e75248a9b333bd3f8b0b1b8e5b45578e05e5850dfb2c2", size = 261434, upload-time = "2025-09-21T20:02:34.86Z" }, + { url = "https://files.pythonhosted.org/packages/ee/51/a03bec00d37faaa891b3ff7387192cef20f01604e5283a5fabc95346befa/coverage-7.10.7-cp313-cp313t-win32.whl", hash = "sha256:2a78cd46550081a7909b3329e2266204d584866e8d97b898cd7fb5ac8d888b1a", size = 221403, upload-time = "2025-09-21T20:02:37.034Z" }, + { url = "https://files.pythonhosted.org/packages/53/22/3cf25d614e64bf6d8e59c7c669b20d6d940bb337bdee5900b9ca41c820bb/coverage-7.10.7-cp313-cp313t-win_amd64.whl", hash = "sha256:33a5e6396ab684cb43dc7befa386258acb2d7fae7f67330ebb85ba4ea27938eb", size = 222469, upload-time = "2025-09-21T20:02:39.011Z" }, + { url = "https://files.pythonhosted.org/packages/49/a1/00164f6d30d8a01c3c9c48418a7a5be394de5349b421b9ee019f380df2a0/coverage-7.10.7-cp313-cp313t-win_arm64.whl", hash = "sha256:86b0e7308289ddde73d863b7683f596d8d21c7d8664ce1dee061d0bcf3fbb4bb", size = 220731, upload-time = "2025-09-21T20:02:40.939Z" }, + { url = "https://files.pythonhosted.org/packages/23/9c/5844ab4ca6a4dd97a1850e030a15ec7d292b5c5cb93082979225126e35dd/coverage-7.10.7-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b06f260b16ead11643a5a9f955bd4b5fd76c1a4c6796aeade8520095b75de520", size = 218302, upload-time = "2025-09-21T20:02:42.527Z" }, + { url = "https://files.pythonhosted.org/packages/f0/89/673f6514b0961d1f0e20ddc242e9342f6da21eaba3489901b565c0689f34/coverage-7.10.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:212f8f2e0612778f09c55dd4872cb1f64a1f2b074393d139278ce902064d5b32", size = 218578, upload-time = "2025-09-21T20:02:44.468Z" }, + { url = "https://files.pythonhosted.org/packages/05/e8/261cae479e85232828fb17ad536765c88dd818c8470aca690b0ac6feeaa3/coverage-7.10.7-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3445258bcded7d4aa630ab8296dea4d3f15a255588dd535f980c193ab6b95f3f", size = 249629, upload-time = "2025-09-21T20:02:46.503Z" }, + { url = "https://files.pythonhosted.org/packages/82/62/14ed6546d0207e6eda876434e3e8475a3e9adbe32110ce896c9e0c06bb9a/coverage-7.10.7-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb45474711ba385c46a0bfe696c695a929ae69ac636cda8f532be9e8c93d720a", size = 252162, upload-time = "2025-09-21T20:02:48.689Z" }, + { url = "https://files.pythonhosted.org/packages/ff/49/07f00db9ac6478e4358165a08fb41b469a1b053212e8a00cb02f0d27a05f/coverage-7.10.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:813922f35bd800dca9994c5971883cbc0d291128a5de6b167c7aa697fcf59360", size = 253517, upload-time = "2025-09-21T20:02:50.31Z" }, + { url = "https://files.pythonhosted.org/packages/a2/59/c5201c62dbf165dfbc91460f6dbbaa85a8b82cfa6131ac45d6c1bfb52deb/coverage-7.10.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:93c1b03552081b2a4423091d6fb3787265b8f86af404cff98d1b5342713bdd69", size = 249632, upload-time = "2025-09-21T20:02:51.971Z" }, + { url = "https://files.pythonhosted.org/packages/07/ae/5920097195291a51fb00b3a70b9bbd2edbfe3c84876a1762bd1ef1565ebc/coverage-7.10.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:cc87dd1b6eaf0b848eebb1c86469b9f72a1891cb42ac7adcfbce75eadb13dd14", size = 251520, upload-time = "2025-09-21T20:02:53.858Z" }, + { url = "https://files.pythonhosted.org/packages/b9/3c/a815dde77a2981f5743a60b63df31cb322c944843e57dbd579326625a413/coverage-7.10.7-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:39508ffda4f343c35f3236fe8d1a6634a51f4581226a1262769d7f970e73bffe", size = 249455, upload-time = "2025-09-21T20:02:55.807Z" }, + { url = "https://files.pythonhosted.org/packages/aa/99/f5cdd8421ea656abefb6c0ce92556709db2265c41e8f9fc6c8ae0f7824c9/coverage-7.10.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:925a1edf3d810537c5a3abe78ec5530160c5f9a26b1f4270b40e62cc79304a1e", size = 249287, upload-time = "2025-09-21T20:02:57.784Z" }, + { url = "https://files.pythonhosted.org/packages/c3/7a/e9a2da6a1fc5d007dd51fca083a663ab930a8c4d149c087732a5dbaa0029/coverage-7.10.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2c8b9a0636f94c43cd3576811e05b89aa9bc2d0a85137affc544ae5cb0e4bfbd", size = 250946, upload-time = "2025-09-21T20:02:59.431Z" }, + { url = "https://files.pythonhosted.org/packages/ef/5b/0b5799aa30380a949005a353715095d6d1da81927d6dbed5def2200a4e25/coverage-7.10.7-cp314-cp314-win32.whl", hash = "sha256:b7b8288eb7cdd268b0304632da8cb0bb93fadcfec2fe5712f7b9cc8f4d487be2", size = 221009, upload-time = "2025-09-21T20:03:01.324Z" }, + { url = "https://files.pythonhosted.org/packages/da/b0/e802fbb6eb746de006490abc9bb554b708918b6774b722bb3a0e6aa1b7de/coverage-7.10.7-cp314-cp314-win_amd64.whl", hash = "sha256:1ca6db7c8807fb9e755d0379ccc39017ce0a84dcd26d14b5a03b78563776f681", size = 221804, upload-time = "2025-09-21T20:03:03.4Z" }, + { url = "https://files.pythonhosted.org/packages/9e/e8/71d0c8e374e31f39e3389bb0bd19e527d46f00ea8571ec7ec8fd261d8b44/coverage-7.10.7-cp314-cp314-win_arm64.whl", hash = "sha256:097c1591f5af4496226d5783d036bf6fd6cd0cbc132e071b33861de756efb880", size = 220384, upload-time = "2025-09-21T20:03:05.111Z" }, + { url = "https://files.pythonhosted.org/packages/62/09/9a5608d319fa3eba7a2019addeacb8c746fb50872b57a724c9f79f146969/coverage-7.10.7-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:a62c6ef0d50e6de320c270ff91d9dd0a05e7250cac2a800b7784bae474506e63", size = 219047, upload-time = "2025-09-21T20:03:06.795Z" }, + { url = "https://files.pythonhosted.org/packages/f5/6f/f58d46f33db9f2e3647b2d0764704548c184e6f5e014bef528b7f979ef84/coverage-7.10.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9fa6e4dd51fe15d8738708a973470f67a855ca50002294852e9571cdbd9433f2", size = 219266, upload-time = "2025-09-21T20:03:08.495Z" }, + { url = "https://files.pythonhosted.org/packages/74/5c/183ffc817ba68e0b443b8c934c8795553eb0c14573813415bd59941ee165/coverage-7.10.7-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8fb190658865565c549b6b4706856d6a7b09302c797eb2cf8e7fe9dabb043f0d", size = 260767, upload-time = "2025-09-21T20:03:10.172Z" }, + { url = "https://files.pythonhosted.org/packages/0f/48/71a8abe9c1ad7e97548835e3cc1adbf361e743e9d60310c5f75c9e7bf847/coverage-7.10.7-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:affef7c76a9ef259187ef31599a9260330e0335a3011732c4b9effa01e1cd6e0", size = 262931, upload-time = "2025-09-21T20:03:11.861Z" }, + { url = "https://files.pythonhosted.org/packages/84/fd/193a8fb132acfc0a901f72020e54be5e48021e1575bb327d8ee1097a28fd/coverage-7.10.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e16e07d85ca0cf8bafe5f5d23a0b850064e8e945d5677492b06bbe6f09cc699", size = 265186, upload-time = "2025-09-21T20:03:13.539Z" }, + { url = "https://files.pythonhosted.org/packages/b1/8f/74ecc30607dd95ad50e3034221113ccb1c6d4e8085cc761134782995daae/coverage-7.10.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:03ffc58aacdf65d2a82bbeb1ffe4d01ead4017a21bfd0454983b88ca73af94b9", size = 259470, upload-time = "2025-09-21T20:03:15.584Z" }, + { url = "https://files.pythonhosted.org/packages/0f/55/79ff53a769f20d71b07023ea115c9167c0bb56f281320520cf64c5298a96/coverage-7.10.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1b4fd784344d4e52647fd7857b2af5b3fbe6c239b0b5fa63e94eb67320770e0f", size = 262626, upload-time = "2025-09-21T20:03:17.673Z" }, + { url = "https://files.pythonhosted.org/packages/88/e2/dac66c140009b61ac3fc13af673a574b00c16efdf04f9b5c740703e953c0/coverage-7.10.7-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0ebbaddb2c19b71912c6f2518e791aa8b9f054985a0769bdb3a53ebbc765c6a1", size = 260386, upload-time = "2025-09-21T20:03:19.36Z" }, + { url = "https://files.pythonhosted.org/packages/a2/f1/f48f645e3f33bb9ca8a496bc4a9671b52f2f353146233ebd7c1df6160440/coverage-7.10.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a2d9a3b260cc1d1dbdb1c582e63ddcf5363426a1a68faa0f5da28d8ee3c722a0", size = 258852, upload-time = "2025-09-21T20:03:21.007Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3b/8442618972c51a7affeead957995cfa8323c0c9bcf8fa5a027421f720ff4/coverage-7.10.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a3cc8638b2480865eaa3926d192e64ce6c51e3d29c849e09d5b4ad95efae5399", size = 261534, upload-time = "2025-09-21T20:03:23.12Z" }, + { url = "https://files.pythonhosted.org/packages/b2/dc/101f3fa3a45146db0cb03f5b4376e24c0aac818309da23e2de0c75295a91/coverage-7.10.7-cp314-cp314t-win32.whl", hash = "sha256:67f8c5cbcd3deb7a60b3345dffc89a961a484ed0af1f6f73de91705cc6e31235", size = 221784, upload-time = "2025-09-21T20:03:24.769Z" }, + { url = "https://files.pythonhosted.org/packages/4c/a1/74c51803fc70a8a40d7346660379e144be772bab4ac7bb6e6b905152345c/coverage-7.10.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e1ed71194ef6dea7ed2d5cb5f7243d4bcd334bfb63e59878519be558078f848d", size = 222905, upload-time = "2025-09-21T20:03:26.93Z" }, + { url = "https://files.pythonhosted.org/packages/12/65/f116a6d2127df30bcafbceef0302d8a64ba87488bf6f73a6d8eebf060873/coverage-7.10.7-cp314-cp314t-win_arm64.whl", hash = "sha256:7fe650342addd8524ca63d77b2362b02345e5f1a093266787d210c70a50b471a", size = 220922, upload-time = "2025-09-21T20:03:28.672Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ad/d1c25053764b4c42eb294aae92ab617d2e4f803397f9c7c8295caa77a260/coverage-7.10.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fff7b9c3f19957020cac546c70025331113d2e61537f6e2441bc7657913de7d3", size = 217978, upload-time = "2025-09-21T20:03:30.362Z" }, + { url = "https://files.pythonhosted.org/packages/52/2f/b9f9daa39b80ece0b9548bbb723381e29bc664822d9a12c2135f8922c22b/coverage-7.10.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:bc91b314cef27742da486d6839b677b3f2793dfe52b51bbbb7cf736d5c29281c", size = 218370, upload-time = "2025-09-21T20:03:32.147Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6e/30d006c3b469e58449650642383dddf1c8fb63d44fdf92994bfd46570695/coverage-7.10.7-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:567f5c155eda8df1d3d439d40a45a6a5f029b429b06648235f1e7e51b522b396", size = 244802, upload-time = "2025-09-21T20:03:33.919Z" }, + { url = "https://files.pythonhosted.org/packages/b0/49/8a070782ce7e6b94ff6a0b6d7c65ba6bc3091d92a92cef4cd4eb0767965c/coverage-7.10.7-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2af88deffcc8a4d5974cf2d502251bc3b2db8461f0b66d80a449c33757aa9f40", size = 246625, upload-time = "2025-09-21T20:03:36.09Z" }, + { url = "https://files.pythonhosted.org/packages/6a/92/1c1c5a9e8677ce56d42b97bdaca337b2d4d9ebe703d8c174ede52dbabd5f/coverage-7.10.7-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7315339eae3b24c2d2fa1ed7d7a38654cba34a13ef19fbcb9425da46d3dc594", size = 248399, upload-time = "2025-09-21T20:03:38.342Z" }, + { url = "https://files.pythonhosted.org/packages/c0/54/b140edee7257e815de7426d5d9846b58505dffc29795fff2dfb7f8a1c5a0/coverage-7.10.7-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:912e6ebc7a6e4adfdbb1aec371ad04c68854cd3bf3608b3514e7ff9062931d8a", size = 245142, upload-time = "2025-09-21T20:03:40.591Z" }, + { url = "https://files.pythonhosted.org/packages/e4/9e/6d6b8295940b118e8b7083b29226c71f6154f7ff41e9ca431f03de2eac0d/coverage-7.10.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:f49a05acd3dfe1ce9715b657e28d138578bc40126760efb962322c56e9ca344b", size = 246284, upload-time = "2025-09-21T20:03:42.355Z" }, + { url = "https://files.pythonhosted.org/packages/db/e5/5e957ca747d43dbe4d9714358375c7546cb3cb533007b6813fc20fce37ad/coverage-7.10.7-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:cce2109b6219f22ece99db7644b9622f54a4e915dad65660ec435e89a3ea7cc3", size = 244353, upload-time = "2025-09-21T20:03:44.218Z" }, + { url = "https://files.pythonhosted.org/packages/9a/45/540fc5cc92536a1b783b7ef99450bd55a4b3af234aae35a18a339973ce30/coverage-7.10.7-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:f3c887f96407cea3916294046fc7dab611c2552beadbed4ea901cbc6a40cc7a0", size = 244430, upload-time = "2025-09-21T20:03:46.065Z" }, + { url = "https://files.pythonhosted.org/packages/75/0b/8287b2e5b38c8fe15d7e3398849bb58d382aedc0864ea0fa1820e8630491/coverage-7.10.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:635adb9a4507c9fd2ed65f39693fa31c9a3ee3a8e6dc64df033e8fdf52a7003f", size = 245311, upload-time = "2025-09-21T20:03:48.19Z" }, + { url = "https://files.pythonhosted.org/packages/0c/1d/29724999984740f0c86d03e6420b942439bf5bd7f54d4382cae386a9d1e9/coverage-7.10.7-cp39-cp39-win32.whl", hash = "sha256:5a02d5a850e2979b0a014c412573953995174743a3f7fa4ea5a6e9a3c5617431", size = 220500, upload-time = "2025-09-21T20:03:50.024Z" }, + { url = "https://files.pythonhosted.org/packages/43/11/4b1e6b129943f905ca54c339f343877b55b365ae2558806c1be4f7476ed5/coverage-7.10.7-cp39-cp39-win_amd64.whl", hash = "sha256:c134869d5ffe34547d14e174c866fd8fe2254918cc0a95e99052903bc1543e07", size = 221408, upload-time = "2025-09-21T20:03:51.803Z" }, + { url = "https://files.pythonhosted.org/packages/ec/16/114df1c291c22cac3b0c127a73e0af5c12ed7bbb6558d310429a0ae24023/coverage-7.10.7-py3-none-any.whl", hash = "sha256:f7941f6f2fe6dd6807a1208737b8a0cbcf1cc6d7b07d24998ad2d63590868260", size = 209952, upload-time = "2025-09-21T20:03:53.918Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version < '3.10'" }, +] + +[[package]] +name = "coverage" +version = "7.13.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/b6/45/2c665ca77ec32ad67e25c77daf1cee28ee4558f3bc571cdbaf88a00b9f23/coverage-7.13.0.tar.gz", hash = "sha256:a394aa27f2d7ff9bc04cf703817773a59ad6dfbd577032e690f961d2460ee936", size = 820905, upload-time = "2025-12-08T13:14:38.055Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/08/bdd7ccca14096f7eb01412b87ac11e5d16e4cb54b6e328afc9dee8bdaec1/coverage-7.13.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:02d9fb9eccd48f6843c98a37bd6817462f130b86da8660461e8f5e54d4c06070", size = 217979, upload-time = "2025-12-08T13:12:14.505Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f0/d1302e3416298a28b5663ae1117546a745d9d19fde7e28402b2c5c3e2109/coverage-7.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:367449cf07d33dc216c083f2036bb7d976c6e4903ab31be400ad74ad9f85ce98", size = 218496, upload-time = "2025-12-08T13:12:16.237Z" }, + { url = "https://files.pythonhosted.org/packages/07/26/d36c354c8b2a320819afcea6bffe72839efd004b98d1d166b90801d49d57/coverage-7.13.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cdb3c9f8fef0a954c632f64328a3935988d33a6604ce4bf67ec3e39670f12ae5", size = 245237, upload-time = "2025-12-08T13:12:17.858Z" }, + { url = "https://files.pythonhosted.org/packages/91/52/be5e85631e0eec547873d8b08dd67a5f6b111ecfe89a86e40b89b0c1c61c/coverage-7.13.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d10fd186aac2316f9bbb46ef91977f9d394ded67050ad6d84d94ed6ea2e8e54e", size = 247061, upload-time = "2025-12-08T13:12:19.132Z" }, + { url = "https://files.pythonhosted.org/packages/0f/45/a5e8fa0caf05fbd8fa0402470377bff09cc1f026d21c05c71e01295e55ab/coverage-7.13.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f88ae3e69df2ab62fb0bc5219a597cb890ba5c438190ffa87490b315190bb33", size = 248928, upload-time = "2025-12-08T13:12:20.702Z" }, + { url = "https://files.pythonhosted.org/packages/f5/42/ffb5069b6fd1b95fae482e02f3fecf380d437dd5a39bae09f16d2e2e7e01/coverage-7.13.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c4be718e51e86f553bcf515305a158a1cd180d23b72f07ae76d6017c3cc5d791", size = 245931, upload-time = "2025-12-08T13:12:22.243Z" }, + { url = "https://files.pythonhosted.org/packages/95/6e/73e809b882c2858f13e55c0c36e94e09ce07e6165d5644588f9517efe333/coverage-7.13.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a00d3a393207ae12f7c49bb1c113190883b500f48979abb118d8b72b8c95c032", size = 246968, upload-time = "2025-12-08T13:12:23.52Z" }, + { url = "https://files.pythonhosted.org/packages/87/08/64ebd9e64b6adb8b4a4662133d706fbaccecab972e0b3ccc23f64e2678ad/coverage-7.13.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3a7b1cd820e1b6116f92c6128f1188e7afe421c7e1b35fa9836b11444e53ebd9", size = 244972, upload-time = "2025-12-08T13:12:24.781Z" }, + { url = "https://files.pythonhosted.org/packages/12/97/f4d27c6fe0cb375a5eced4aabcaef22de74766fb80a3d5d2015139e54b22/coverage-7.13.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:37eee4e552a65866f15dedd917d5e5f3d59805994260720821e2c1b51ac3248f", size = 245241, upload-time = "2025-12-08T13:12:28.041Z" }, + { url = "https://files.pythonhosted.org/packages/0c/94/42f8ae7f633bf4c118bf1038d80472f9dade88961a466f290b81250f7ab7/coverage-7.13.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:62d7c4f13102148c78d7353c6052af6d899a7f6df66a32bddcc0c0eb7c5326f8", size = 245847, upload-time = "2025-12-08T13:12:29.337Z" }, + { url = "https://files.pythonhosted.org/packages/a8/2f/6369ca22b6b6d933f4f4d27765d313d8914cc4cce84f82a16436b1a233db/coverage-7.13.0-cp310-cp310-win32.whl", hash = "sha256:24e4e56304fdb56f96f80eabf840eab043b3afea9348b88be680ec5986780a0f", size = 220573, upload-time = "2025-12-08T13:12:30.905Z" }, + { url = "https://files.pythonhosted.org/packages/f1/dc/a6a741e519acceaeccc70a7f4cfe5d030efc4b222595f0677e101af6f1f3/coverage-7.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:74c136e4093627cf04b26a35dab8cbfc9b37c647f0502fc313376e11726ba303", size = 221509, upload-time = "2025-12-08T13:12:32.09Z" }, + { url = "https://files.pythonhosted.org/packages/f1/dc/888bf90d8b1c3d0b4020a40e52b9f80957d75785931ec66c7dfaccc11c7d/coverage-7.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0dfa3855031070058add1a59fdfda0192fd3e8f97e7c81de0596c145dea51820", size = 218104, upload-time = "2025-12-08T13:12:33.333Z" }, + { url = "https://files.pythonhosted.org/packages/8d/ea/069d51372ad9c380214e86717e40d1a743713a2af191cfba30a0911b0a4a/coverage-7.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4fdb6f54f38e334db97f72fa0c701e66d8479af0bc3f9bfb5b90f1c30f54500f", size = 218606, upload-time = "2025-12-08T13:12:34.498Z" }, + { url = "https://files.pythonhosted.org/packages/68/09/77b1c3a66c2aa91141b6c4471af98e5b1ed9b9e6d17255da5eb7992299e3/coverage-7.13.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7e442c013447d1d8d195be62852270b78b6e255b79b8675bad8479641e21fd96", size = 248999, upload-time = "2025-12-08T13:12:36.02Z" }, + { url = "https://files.pythonhosted.org/packages/0a/32/2e2f96e9d5691eaf1181d9040f850b8b7ce165ea10810fd8e2afa534cef7/coverage-7.13.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1ed5630d946859de835a85e9a43b721123a8a44ec26e2830b296d478c7fd4259", size = 250925, upload-time = "2025-12-08T13:12:37.221Z" }, + { url = "https://files.pythonhosted.org/packages/7b/45/b88ddac1d7978859b9a39a8a50ab323186148f1d64bc068f86fc77706321/coverage-7.13.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f15a931a668e58087bc39d05d2b4bf4b14ff2875b49c994bbdb1c2217a8daeb", size = 253032, upload-time = "2025-12-08T13:12:38.763Z" }, + { url = "https://files.pythonhosted.org/packages/71/cb/e15513f94c69d4820a34b6bf3d2b1f9f8755fa6021be97c7065442d7d653/coverage-7.13.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:30a3a201a127ea57f7e14ba43c93c9c4be8b7d17a26e03bb49e6966d019eede9", size = 249134, upload-time = "2025-12-08T13:12:40.382Z" }, + { url = "https://files.pythonhosted.org/packages/09/61/d960ff7dc9e902af3310ce632a875aaa7860f36d2bc8fc8b37ee7c1b82a5/coverage-7.13.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7a485ff48fbd231efa32d58f479befce52dcb6bfb2a88bb7bf9a0b89b1bc8030", size = 250731, upload-time = "2025-12-08T13:12:41.992Z" }, + { url = "https://files.pythonhosted.org/packages/98/34/c7c72821794afc7c7c2da1db8f00c2c98353078aa7fb6b5ff36aac834b52/coverage-7.13.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:22486cdafba4f9e471c816a2a5745337742a617fef68e890d8baf9f3036d7833", size = 248795, upload-time = "2025-12-08T13:12:43.331Z" }, + { url = "https://files.pythonhosted.org/packages/0a/5b/e0f07107987a43b2def9aa041c614ddb38064cbf294a71ef8c67d43a0cdd/coverage-7.13.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:263c3dbccc78e2e331e59e90115941b5f53e85cfcc6b3b2fbff1fd4e3d2c6ea8", size = 248514, upload-time = "2025-12-08T13:12:44.546Z" }, + { url = "https://files.pythonhosted.org/packages/71/c2/c949c5d3b5e9fc6dd79e1b73cdb86a59ef14f3709b1d72bf7668ae12e000/coverage-7.13.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e5330fa0cc1f5c3c4c3bb8e101b742025933e7848989370a1d4c8c5e401ea753", size = 249424, upload-time = "2025-12-08T13:12:45.759Z" }, + { url = "https://files.pythonhosted.org/packages/11/f1/bbc009abd6537cec0dffb2cc08c17a7f03de74c970e6302db4342a6e05af/coverage-7.13.0-cp311-cp311-win32.whl", hash = "sha256:0f4872f5d6c54419c94c25dd6ae1d015deeb337d06e448cd890a1e89a8ee7f3b", size = 220597, upload-time = "2025-12-08T13:12:47.378Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f6/d9977f2fb51c10fbaed0718ce3d0a8541185290b981f73b1d27276c12d91/coverage-7.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:51a202e0f80f241ccb68e3e26e19ab5b3bf0f813314f2c967642f13ebcf1ddfe", size = 221536, upload-time = "2025-12-08T13:12:48.7Z" }, + { url = "https://files.pythonhosted.org/packages/be/ad/3fcf43fd96fb43e337a3073dea63ff148dcc5c41ba7a14d4c7d34efb2216/coverage-7.13.0-cp311-cp311-win_arm64.whl", hash = "sha256:d2a9d7f1c11487b1c69367ab3ac2d81b9b3721f097aa409a3191c3e90f8f3dd7", size = 220206, upload-time = "2025-12-08T13:12:50.365Z" }, + { url = "https://files.pythonhosted.org/packages/9b/f1/2619559f17f31ba00fc40908efd1fbf1d0a5536eb75dc8341e7d660a08de/coverage-7.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0b3d67d31383c4c68e19a88e28fc4c2e29517580f1b0ebec4a069d502ce1e0bf", size = 218274, upload-time = "2025-12-08T13:12:52.095Z" }, + { url = "https://files.pythonhosted.org/packages/2b/11/30d71ae5d6e949ff93b2a79a2c1b4822e00423116c5c6edfaeef37301396/coverage-7.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:581f086833d24a22c89ae0fe2142cfaa1c92c930adf637ddf122d55083fb5a0f", size = 218638, upload-time = "2025-12-08T13:12:53.418Z" }, + { url = "https://files.pythonhosted.org/packages/79/c2/fce80fc6ded8d77e53207489d6065d0fed75db8951457f9213776615e0f5/coverage-7.13.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0a3a30f0e257df382f5f9534d4ce3d4cf06eafaf5192beb1a7bd066cb10e78fb", size = 250129, upload-time = "2025-12-08T13:12:54.744Z" }, + { url = "https://files.pythonhosted.org/packages/5b/b6/51b5d1eb6fcbb9a1d5d6984e26cbe09018475c2922d554fd724dd0f056ee/coverage-7.13.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:583221913fbc8f53b88c42e8dbb8fca1d0f2e597cb190ce45916662b8b9d9621", size = 252885, upload-time = "2025-12-08T13:12:56.401Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f8/972a5affea41de798691ab15d023d3530f9f56a72e12e243f35031846ff7/coverage-7.13.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f5d9bd30756fff3e7216491a0d6d520c448d5124d3d8e8f56446d6412499e74", size = 253974, upload-time = "2025-12-08T13:12:57.718Z" }, + { url = "https://files.pythonhosted.org/packages/8a/56/116513aee860b2c7968aa3506b0f59b22a959261d1dbf3aea7b4450a7520/coverage-7.13.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a23e5a1f8b982d56fa64f8e442e037f6ce29322f1f9e6c2344cd9e9f4407ee57", size = 250538, upload-time = "2025-12-08T13:12:59.254Z" }, + { url = "https://files.pythonhosted.org/packages/d6/75/074476d64248fbadf16dfafbf93fdcede389ec821f74ca858d7c87d2a98c/coverage-7.13.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9b01c22bc74a7fb44066aaf765224c0d933ddf1f5047d6cdfe4795504a4493f8", size = 251912, upload-time = "2025-12-08T13:13:00.604Z" }, + { url = "https://files.pythonhosted.org/packages/f2/d2/aa4f8acd1f7c06024705c12609d8698c51b27e4d635d717cd1934c9668e2/coverage-7.13.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:898cce66d0836973f48dda4e3514d863d70142bdf6dfab932b9b6a90ea5b222d", size = 250054, upload-time = "2025-12-08T13:13:01.892Z" }, + { url = "https://files.pythonhosted.org/packages/19/98/8df9e1af6a493b03694a1e8070e024e7d2cdc77adedc225a35e616d505de/coverage-7.13.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:3ab483ea0e251b5790c2aac03acde31bff0c736bf8a86829b89382b407cd1c3b", size = 249619, upload-time = "2025-12-08T13:13:03.236Z" }, + { url = "https://files.pythonhosted.org/packages/d8/71/f8679231f3353018ca66ef647fa6fe7b77e6bff7845be54ab84f86233363/coverage-7.13.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1d84e91521c5e4cb6602fe11ece3e1de03b2760e14ae4fcf1a4b56fa3c801fcd", size = 251496, upload-time = "2025-12-08T13:13:04.511Z" }, + { url = "https://files.pythonhosted.org/packages/04/86/9cb406388034eaf3c606c22094edbbb82eea1fa9d20c0e9efadff20d0733/coverage-7.13.0-cp312-cp312-win32.whl", hash = "sha256:193c3887285eec1dbdb3f2bd7fbc351d570ca9c02ca756c3afbc71b3c98af6ef", size = 220808, upload-time = "2025-12-08T13:13:06.422Z" }, + { url = "https://files.pythonhosted.org/packages/1c/59/af483673df6455795daf5f447c2f81a3d2fcfc893a22b8ace983791f6f34/coverage-7.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:4f3e223b2b2db5e0db0c2b97286aba0036ca000f06aca9b12112eaa9af3d92ae", size = 221616, upload-time = "2025-12-08T13:13:07.95Z" }, + { url = "https://files.pythonhosted.org/packages/64/b0/959d582572b30a6830398c60dd419c1965ca4b5fb38ac6b7093a0d50ca8d/coverage-7.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:086cede306d96202e15a4b77ace8472e39d9f4e5f9fd92dd4fecdfb2313b2080", size = 220261, upload-time = "2025-12-08T13:13:09.581Z" }, + { url = "https://files.pythonhosted.org/packages/7c/cc/bce226595eb3bf7d13ccffe154c3c487a22222d87ff018525ab4dd2e9542/coverage-7.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:28ee1c96109974af104028a8ef57cec21447d42d0e937c0275329272e370ebcf", size = 218297, upload-time = "2025-12-08T13:13:10.977Z" }, + { url = "https://files.pythonhosted.org/packages/3b/9f/73c4d34600aae03447dff3d7ad1d0ac649856bfb87d1ca7d681cfc913f9e/coverage-7.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d1e97353dcc5587b85986cda4ff3ec98081d7e84dd95e8b2a6d59820f0545f8a", size = 218673, upload-time = "2025-12-08T13:13:12.562Z" }, + { url = "https://files.pythonhosted.org/packages/63/ab/8fa097db361a1e8586535ae5073559e6229596b3489ec3ef2f5b38df8cb2/coverage-7.13.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:99acd4dfdfeb58e1937629eb1ab6ab0899b131f183ee5f23e0b5da5cba2fec74", size = 249652, upload-time = "2025-12-08T13:13:13.909Z" }, + { url = "https://files.pythonhosted.org/packages/90/3a/9bfd4de2ff191feb37ef9465855ca56a6f2f30a3bca172e474130731ac3d/coverage-7.13.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ff45e0cd8451e293b63ced93161e189780baf444119391b3e7d25315060368a6", size = 252251, upload-time = "2025-12-08T13:13:15.553Z" }, + { url = "https://files.pythonhosted.org/packages/df/61/b5d8105f016e1b5874af0d7c67542da780ccd4a5f2244a433d3e20ceb1ad/coverage-7.13.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f4f72a85316d8e13234cafe0a9f81b40418ad7a082792fa4165bd7d45d96066b", size = 253492, upload-time = "2025-12-08T13:13:16.849Z" }, + { url = "https://files.pythonhosted.org/packages/f3/b8/0fad449981803cc47a4694768b99823fb23632150743f9c83af329bb6090/coverage-7.13.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:11c21557d0e0a5a38632cbbaca5f008723b26a89d70db6315523df6df77d6232", size = 249850, upload-time = "2025-12-08T13:13:18.142Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e9/8d68337c3125014d918cf4327d5257553a710a2995a6a6de2ac77e5aa429/coverage-7.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76541dc8d53715fb4f7a3a06b34b0dc6846e3c69bc6204c55653a85dd6220971", size = 251633, upload-time = "2025-12-08T13:13:19.56Z" }, + { url = "https://files.pythonhosted.org/packages/55/14/d4112ab26b3a1bc4b3c1295d8452dcf399ed25be4cf649002fb3e64b2d93/coverage-7.13.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6e9e451dee940a86789134b6b0ffbe31c454ade3b849bb8a9d2cca2541a8e91d", size = 249586, upload-time = "2025-12-08T13:13:20.883Z" }, + { url = "https://files.pythonhosted.org/packages/2c/a9/22b0000186db663b0d82f86c2f1028099ae9ac202491685051e2a11a5218/coverage-7.13.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5c67dace46f361125e6b9cace8fe0b729ed8479f47e70c89b838d319375c8137", size = 249412, upload-time = "2025-12-08T13:13:22.22Z" }, + { url = "https://files.pythonhosted.org/packages/a1/2e/42d8e0d9e7527fba439acdc6ed24a2b97613b1dc85849b1dd935c2cffef0/coverage-7.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f59883c643cb19630500f57016f76cfdcd6845ca8c5b5ea1f6e17f74c8e5f511", size = 251191, upload-time = "2025-12-08T13:13:23.899Z" }, + { url = "https://files.pythonhosted.org/packages/a4/af/8c7af92b1377fd8860536aadd58745119252aaaa71a5213e5a8e8007a9f5/coverage-7.13.0-cp313-cp313-win32.whl", hash = "sha256:58632b187be6f0be500f553be41e277712baa278147ecb7559983c6d9faf7ae1", size = 220829, upload-time = "2025-12-08T13:13:25.182Z" }, + { url = "https://files.pythonhosted.org/packages/58/f9/725e8bf16f343d33cbe076c75dc8370262e194ff10072c0608b8e5cf33a3/coverage-7.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:73419b89f812f498aca53f757dd834919b48ce4799f9d5cad33ca0ae442bdb1a", size = 221640, upload-time = "2025-12-08T13:13:26.836Z" }, + { url = "https://files.pythonhosted.org/packages/8a/ff/e98311000aa6933cc79274e2b6b94a2fe0fe3434fca778eba82003675496/coverage-7.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:eb76670874fdd6091eedcc856128ee48c41a9bbbb9c3f1c7c3cf169290e3ffd6", size = 220269, upload-time = "2025-12-08T13:13:28.116Z" }, + { url = "https://files.pythonhosted.org/packages/cf/cf/bbaa2e1275b300343ea865f7d424cc0a2e2a1df6925a070b2b2d5d765330/coverage-7.13.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6e63ccc6e0ad8986386461c3c4b737540f20426e7ec932f42e030320896c311a", size = 218990, upload-time = "2025-12-08T13:13:29.463Z" }, + { url = "https://files.pythonhosted.org/packages/21/1d/82f0b3323b3d149d7672e7744c116e9c170f4957e0c42572f0366dbb4477/coverage-7.13.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:494f5459ffa1bd45e18558cd98710c36c0b8fbfa82a5eabcbe671d80ecffbfe8", size = 219340, upload-time = "2025-12-08T13:13:31.524Z" }, + { url = "https://files.pythonhosted.org/packages/fb/e3/fe3fd4702a3832a255f4d43013eacb0ef5fc155a5960ea9269d8696db28b/coverage-7.13.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:06cac81bf10f74034e055e903f5f946e3e26fc51c09fc9f584e4a1605d977053", size = 260638, upload-time = "2025-12-08T13:13:32.965Z" }, + { url = "https://files.pythonhosted.org/packages/ad/01/63186cb000307f2b4da463f72af9b85d380236965574c78e7e27680a2593/coverage-7.13.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f2ffc92b46ed6e6760f1d47a71e56b5664781bc68986dbd1836b2b70c0ce2071", size = 262705, upload-time = "2025-12-08T13:13:34.378Z" }, + { url = "https://files.pythonhosted.org/packages/7c/a1/c0dacef0cc865f2455d59eed3548573ce47ed603205ffd0735d1d78b5906/coverage-7.13.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0602f701057c6823e5db1b74530ce85f17c3c5be5c85fc042ac939cbd909426e", size = 265125, upload-time = "2025-12-08T13:13:35.73Z" }, + { url = "https://files.pythonhosted.org/packages/ef/92/82b99223628b61300bd382c205795533bed021505eab6dd86e11fb5d7925/coverage-7.13.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:25dc33618d45456ccb1d37bce44bc78cf269909aa14c4db2e03d63146a8a1493", size = 259844, upload-time = "2025-12-08T13:13:37.69Z" }, + { url = "https://files.pythonhosted.org/packages/cf/2c/89b0291ae4e6cd59ef042708e1c438e2290f8c31959a20055d8768349ee2/coverage-7.13.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:71936a8b3b977ddd0b694c28c6a34f4fff2e9dd201969a4ff5d5fc7742d614b0", size = 262700, upload-time = "2025-12-08T13:13:39.525Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f9/a5f992efae1996245e796bae34ceb942b05db275e4b34222a9a40b9fbd3b/coverage-7.13.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:936bc20503ce24770c71938d1369461f0c5320830800933bc3956e2a4ded930e", size = 260321, upload-time = "2025-12-08T13:13:41.172Z" }, + { url = "https://files.pythonhosted.org/packages/4c/89/a29f5d98c64fedbe32e2ac3c227fbf78edc01cc7572eee17d61024d89889/coverage-7.13.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:af0a583efaacc52ae2521f8d7910aff65cdb093091d76291ac5820d5e947fc1c", size = 259222, upload-time = "2025-12-08T13:13:43.282Z" }, + { url = "https://files.pythonhosted.org/packages/b3/c3/940fe447aae302a6701ee51e53af7e08b86ff6eed7631e5740c157ee22b9/coverage-7.13.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f1c23e24a7000da892a312fb17e33c5f94f8b001de44b7cf8ba2e36fbd15859e", size = 261411, upload-time = "2025-12-08T13:13:44.72Z" }, + { url = "https://files.pythonhosted.org/packages/eb/31/12a4aec689cb942a89129587860ed4d0fd522d5fda81237147fde554b8ae/coverage-7.13.0-cp313-cp313t-win32.whl", hash = "sha256:5f8a0297355e652001015e93be345ee54393e45dc3050af4a0475c5a2b767d46", size = 221505, upload-time = "2025-12-08T13:13:46.332Z" }, + { url = "https://files.pythonhosted.org/packages/65/8c/3b5fe3259d863572d2b0827642c50c3855d26b3aefe80bdc9eba1f0af3b0/coverage-7.13.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6abb3a4c52f05e08460bd9acf04fec027f8718ecaa0d09c40ffbc3fbd70ecc39", size = 222569, upload-time = "2025-12-08T13:13:47.79Z" }, + { url = "https://files.pythonhosted.org/packages/b0/39/f71fa8316a96ac72fc3908839df651e8eccee650001a17f2c78cdb355624/coverage-7.13.0-cp313-cp313t-win_arm64.whl", hash = "sha256:3ad968d1e3aa6ce5be295ab5fe3ae1bf5bb4769d0f98a80a0252d543a2ef2e9e", size = 220841, upload-time = "2025-12-08T13:13:49.243Z" }, + { url = "https://files.pythonhosted.org/packages/f8/4b/9b54bedda55421449811dcd5263a2798a63f48896c24dfb92b0f1b0845bd/coverage-7.13.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:453b7ec753cf5e4356e14fe858064e5520c460d3bbbcb9c35e55c0d21155c256", size = 218343, upload-time = "2025-12-08T13:13:50.811Z" }, + { url = "https://files.pythonhosted.org/packages/59/df/c3a1f34d4bba2e592c8979f924da4d3d4598b0df2392fbddb7761258e3dc/coverage-7.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:af827b7cbb303e1befa6c4f94fd2bf72f108089cfa0f8abab8f4ca553cf5ca5a", size = 218672, upload-time = "2025-12-08T13:13:52.284Z" }, + { url = "https://files.pythonhosted.org/packages/07/62/eec0659e47857698645ff4e6ad02e30186eb8afd65214fd43f02a76537cb/coverage-7.13.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9987a9e4f8197a1000280f7cc089e3ea2c8b3c0a64d750537809879a7b4ceaf9", size = 249715, upload-time = "2025-12-08T13:13:53.791Z" }, + { url = "https://files.pythonhosted.org/packages/23/2d/3c7ff8b2e0e634c1f58d095f071f52ed3c23ff25be524b0ccae8b71f99f8/coverage-7.13.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3188936845cd0cb114fa6a51842a304cdbac2958145d03be2377ec41eb285d19", size = 252225, upload-time = "2025-12-08T13:13:55.274Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ac/fb03b469d20e9c9a81093575003f959cf91a4a517b783aab090e4538764b/coverage-7.13.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2bdb3babb74079f021696cb46b8bb5f5661165c385d3a238712b031a12355be", size = 253559, upload-time = "2025-12-08T13:13:57.161Z" }, + { url = "https://files.pythonhosted.org/packages/29/62/14afa9e792383c66cc0a3b872a06ded6e4ed1079c7d35de274f11d27064e/coverage-7.13.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7464663eaca6adba4175f6c19354feea61ebbdd735563a03d1e472c7072d27bb", size = 249724, upload-time = "2025-12-08T13:13:58.692Z" }, + { url = "https://files.pythonhosted.org/packages/31/b7/333f3dab2939070613696ab3ee91738950f0467778c6e5a5052e840646b7/coverage-7.13.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8069e831f205d2ff1f3d355e82f511eb7c5522d7d413f5db5756b772ec8697f8", size = 251582, upload-time = "2025-12-08T13:14:00.642Z" }, + { url = "https://files.pythonhosted.org/packages/81/cb/69162bda9381f39b2287265d7e29ee770f7c27c19f470164350a38318764/coverage-7.13.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:6fb2d5d272341565f08e962cce14cdf843a08ac43bd621783527adb06b089c4b", size = 249538, upload-time = "2025-12-08T13:14:02.556Z" }, + { url = "https://files.pythonhosted.org/packages/e0/76/350387b56a30f4970abe32b90b2a434f87d29f8b7d4ae40d2e8a85aacfb3/coverage-7.13.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5e70f92ef89bac1ac8a99b3324923b4749f008fdbd7aa9cb35e01d7a284a04f9", size = 249349, upload-time = "2025-12-08T13:14:04.015Z" }, + { url = "https://files.pythonhosted.org/packages/86/0d/7f6c42b8d59f4c7e43ea3059f573c0dcfed98ba46eb43c68c69e52ae095c/coverage-7.13.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4b5de7d4583e60d5fd246dd57fcd3a8aa23c6e118a8c72b38adf666ba8e7e927", size = 251011, upload-time = "2025-12-08T13:14:05.505Z" }, + { url = "https://files.pythonhosted.org/packages/d7/f1/4bb2dff379721bb0b5c649d5c5eaf438462cad824acf32eb1b7ca0c7078e/coverage-7.13.0-cp314-cp314-win32.whl", hash = "sha256:a6c6e16b663be828a8f0b6c5027d36471d4a9f90d28444aa4ced4d48d7d6ae8f", size = 221091, upload-time = "2025-12-08T13:14:07.127Z" }, + { url = "https://files.pythonhosted.org/packages/ba/44/c239da52f373ce379c194b0ee3bcc121020e397242b85f99e0afc8615066/coverage-7.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:0900872f2fdb3ee5646b557918d02279dc3af3dfb39029ac4e945458b13f73bc", size = 221904, upload-time = "2025-12-08T13:14:08.542Z" }, + { url = "https://files.pythonhosted.org/packages/89/1f/b9f04016d2a29c2e4a0307baefefad1a4ec5724946a2b3e482690486cade/coverage-7.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:3a10260e6a152e5f03f26db4a407c4c62d3830b9af9b7c0450b183615f05d43b", size = 220480, upload-time = "2025-12-08T13:14:10.958Z" }, + { url = "https://files.pythonhosted.org/packages/16/d4/364a1439766c8e8647860584171c36010ca3226e6e45b1753b1b249c5161/coverage-7.13.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9097818b6cc1cfb5f174e3263eba4a62a17683bcfe5c4b5d07f4c97fa51fbf28", size = 219074, upload-time = "2025-12-08T13:14:13.345Z" }, + { url = "https://files.pythonhosted.org/packages/ce/f4/71ba8be63351e099911051b2089662c03d5671437a0ec2171823c8e03bec/coverage-7.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0018f73dfb4301a89292c73be6ba5f58722ff79f51593352759c1790ded1cabe", size = 219342, upload-time = "2025-12-08T13:14:15.02Z" }, + { url = "https://files.pythonhosted.org/packages/5e/25/127d8ed03d7711a387d96f132589057213e3aef7475afdaa303412463f22/coverage-7.13.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:166ad2a22ee770f5656e1257703139d3533b4a0b6909af67c6b4a3adc1c98657", size = 260713, upload-time = "2025-12-08T13:14:16.907Z" }, + { url = "https://files.pythonhosted.org/packages/fd/db/559fbb6def07d25b2243663b46ba9eb5a3c6586c0c6f4e62980a68f0ee1c/coverage-7.13.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f6aaef16d65d1787280943f1c8718dc32e9cf141014e4634d64446702d26e0ff", size = 262825, upload-time = "2025-12-08T13:14:18.68Z" }, + { url = "https://files.pythonhosted.org/packages/37/99/6ee5bf7eff884766edb43bd8736b5e1c5144d0fe47498c3779326fe75a35/coverage-7.13.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e999e2dcc094002d6e2c7bbc1fb85b58ba4f465a760a8014d97619330cdbbbf3", size = 265233, upload-time = "2025-12-08T13:14:20.55Z" }, + { url = "https://files.pythonhosted.org/packages/d8/90/92f18fe0356ea69e1f98f688ed80cec39f44e9f09a1f26a1bbf017cc67f2/coverage-7.13.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:00c3d22cf6fb1cf3bf662aaaa4e563be8243a5ed2630339069799835a9cc7f9b", size = 259779, upload-time = "2025-12-08T13:14:22.367Z" }, + { url = "https://files.pythonhosted.org/packages/90/5d/b312a8b45b37a42ea7d27d7d3ff98ade3a6c892dd48d1d503e773503373f/coverage-7.13.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22ccfe8d9bb0d6134892cbe1262493a8c70d736b9df930f3f3afae0fe3ac924d", size = 262700, upload-time = "2025-12-08T13:14:24.309Z" }, + { url = "https://files.pythonhosted.org/packages/63/f8/b1d0de5c39351eb71c366f872376d09386640840a2e09b0d03973d791e20/coverage-7.13.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:9372dff5ea15930fea0445eaf37bbbafbc771a49e70c0aeed8b4e2c2614cc00e", size = 260302, upload-time = "2025-12-08T13:14:26.068Z" }, + { url = "https://files.pythonhosted.org/packages/aa/7c/d42f4435bc40c55558b3109a39e2d456cddcec37434f62a1f1230991667a/coverage-7.13.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:69ac2c492918c2461bc6ace42d0479638e60719f2a4ef3f0815fa2df88e9f940", size = 259136, upload-time = "2025-12-08T13:14:27.604Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d3/23413241dc04d47cfe19b9a65b32a2edd67ecd0b817400c2843ebc58c847/coverage-7.13.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:739c6c051a7540608d097b8e13c76cfa85263ced467168dc6b477bae3df7d0e2", size = 261467, upload-time = "2025-12-08T13:14:29.09Z" }, + { url = "https://files.pythonhosted.org/packages/13/e6/6e063174500eee216b96272c0d1847bf215926786f85c2bd024cf4d02d2f/coverage-7.13.0-cp314-cp314t-win32.whl", hash = "sha256:fe81055d8c6c9de76d60c94ddea73c290b416e061d40d542b24a5871bad498b7", size = 221875, upload-time = "2025-12-08T13:14:31.106Z" }, + { url = "https://files.pythonhosted.org/packages/3b/46/f4fb293e4cbe3620e3ac2a3e8fd566ed33affb5861a9b20e3dd6c1896cbc/coverage-7.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:445badb539005283825959ac9fa4a28f712c214b65af3a2c464f1adc90f5fcbc", size = 222982, upload-time = "2025-12-08T13:14:33.1Z" }, + { url = "https://files.pythonhosted.org/packages/68/62/5b3b9018215ed9733fbd1ae3b2ed75c5de62c3b55377a52cae732e1b7805/coverage-7.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:de7f6748b890708578fc4b7bb967d810aeb6fcc9bff4bb77dbca77dab2f9df6a", size = 221016, upload-time = "2025-12-08T13:14:34.601Z" }, + { url = "https://files.pythonhosted.org/packages/8d/4c/1968f32fb9a2604645827e11ff84a31e59d532e01995f904723b4f5328b3/coverage-7.13.0-py3-none-any.whl", hash = "sha256:850d2998f380b1e266459ca5b47bc9e7daf9af1d070f66317972f382d46f1904", size = 210068, upload-time = "2025-12-08T13:14:36.236Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version >= '3.10' and python_full_version <= '3.11'" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", +] +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 = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pytest" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.10'" }, + { name = "iniconfig", version = "2.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "packaging", marker = "python_full_version < '3.10'" }, + { name = "pluggy", marker = "python_full_version < '3.10'" }, + { name = "pygments", marker = "python_full_version < '3.10'" }, + { name = "tomli", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", +] +dependencies = [ + { name = "colorama", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version == '3.10.*'" }, + { name = "iniconfig", version = "2.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "packaging", marker = "python_full_version >= '3.10'" }, + { name = "pluggy", marker = "python_full_version >= '3.10'" }, + { name = "pygments", marker = "python_full_version >= '3.10'" }, + { name = "tomli", marker = "python_full_version == '3.10.*'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", version = "7.10.7", source = { registry = "https://pypi.org/simple" }, extra = ["toml"], marker = "python_full_version < '3.10'" }, + { name = "coverage", version = "7.13.0", source = { registry = "https://pypi.org/simple" }, extra = ["toml"], marker = "python_full_version >= '3.10'" }, + { name = "pluggy" }, + { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "pytest", version = "9.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, +] + +[[package]] +name = "tomli" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/ed/3f73f72945444548f33eba9a87fc7a6e969915e7b1acc8260b30e1f76a2f/tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549", size = 17392, upload-time = "2025-10-08T22:01:47.119Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/2e/299f62b401438d5fe1624119c723f5d877acc86a4c2492da405626665f12/tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45", size = 153236, upload-time = "2025-10-08T22:01:00.137Z" }, + { url = "https://files.pythonhosted.org/packages/86/7f/d8fffe6a7aefdb61bced88fcb5e280cfd71e08939da5894161bd71bea022/tomli-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba", size = 148084, upload-time = "2025-10-08T22:01:01.63Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/24935fb6a2ee63e86d80e4d3b58b222dafaf438c416752c8b58537c8b89a/tomli-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf", size = 234832, upload-time = "2025-10-08T22:01:02.543Z" }, + { url = "https://files.pythonhosted.org/packages/89/da/75dfd804fc11e6612846758a23f13271b76d577e299592b4371a4ca4cd09/tomli-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441", size = 242052, upload-time = "2025-10-08T22:01:03.836Z" }, + { url = "https://files.pythonhosted.org/packages/70/8c/f48ac899f7b3ca7eb13af73bacbc93aec37f9c954df3c08ad96991c8c373/tomli-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845", size = 239555, upload-time = "2025-10-08T22:01:04.834Z" }, + { url = "https://files.pythonhosted.org/packages/ba/28/72f8afd73f1d0e7829bfc093f4cb98ce0a40ffc0cc997009ee1ed94ba705/tomli-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c", size = 245128, upload-time = "2025-10-08T22:01:05.84Z" }, + { url = "https://files.pythonhosted.org/packages/b6/eb/a7679c8ac85208706d27436e8d421dfa39d4c914dcf5fa8083a9305f58d9/tomli-2.3.0-cp311-cp311-win32.whl", hash = "sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456", size = 96445, upload-time = "2025-10-08T22:01:06.896Z" }, + { url = "https://files.pythonhosted.org/packages/0a/fe/3d3420c4cb1ad9cb462fb52967080575f15898da97e21cb6f1361d505383/tomli-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be", size = 107165, upload-time = "2025-10-08T22:01:08.107Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b7/40f36368fcabc518bb11c8f06379a0fd631985046c038aca08c6d6a43c6e/tomli-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac", size = 154891, upload-time = "2025-10-08T22:01:09.082Z" }, + { url = "https://files.pythonhosted.org/packages/f9/3f/d9dd692199e3b3aab2e4e4dd948abd0f790d9ded8cd10cbaae276a898434/tomli-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22", size = 148796, upload-time = "2025-10-08T22:01:10.266Z" }, + { url = "https://files.pythonhosted.org/packages/60/83/59bff4996c2cf9f9387a0f5a3394629c7efa5ef16142076a23a90f1955fa/tomli-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f", size = 242121, upload-time = "2025-10-08T22:01:11.332Z" }, + { url = "https://files.pythonhosted.org/packages/45/e5/7c5119ff39de8693d6baab6c0b6dcb556d192c165596e9fc231ea1052041/tomli-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52", size = 250070, upload-time = "2025-10-08T22:01:12.498Z" }, + { url = "https://files.pythonhosted.org/packages/45/12/ad5126d3a278f27e6701abde51d342aa78d06e27ce2bb596a01f7709a5a2/tomli-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8", size = 245859, upload-time = "2025-10-08T22:01:13.551Z" }, + { url = "https://files.pythonhosted.org/packages/fb/a1/4d6865da6a71c603cfe6ad0e6556c73c76548557a8d658f9e3b142df245f/tomli-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6", size = 250296, upload-time = "2025-10-08T22:01:14.614Z" }, + { url = "https://files.pythonhosted.org/packages/a0/b7/a7a7042715d55c9ba6e8b196d65d2cb662578b4d8cd17d882d45322b0d78/tomli-2.3.0-cp312-cp312-win32.whl", hash = "sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876", size = 97124, upload-time = "2025-10-08T22:01:15.629Z" }, + { url = "https://files.pythonhosted.org/packages/06/1e/f22f100db15a68b520664eb3328fb0ae4e90530887928558112c8d1f4515/tomli-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878", size = 107698, upload-time = "2025-10-08T22:01:16.51Z" }, + { url = "https://files.pythonhosted.org/packages/89/48/06ee6eabe4fdd9ecd48bf488f4ac783844fd777f547b8d1b61c11939974e/tomli-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b", size = 154819, upload-time = "2025-10-08T22:01:17.964Z" }, + { url = "https://files.pythonhosted.org/packages/f1/01/88793757d54d8937015c75dcdfb673c65471945f6be98e6a0410fba167ed/tomli-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae", size = 148766, upload-time = "2025-10-08T22:01:18.959Z" }, + { url = "https://files.pythonhosted.org/packages/42/17/5e2c956f0144b812e7e107f94f1cc54af734eb17b5191c0bbfb72de5e93e/tomli-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b", size = 240771, upload-time = "2025-10-08T22:01:20.106Z" }, + { url = "https://files.pythonhosted.org/packages/d5/f4/0fbd014909748706c01d16824eadb0307115f9562a15cbb012cd9b3512c5/tomli-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf", size = 248586, upload-time = "2025-10-08T22:01:21.164Z" }, + { url = "https://files.pythonhosted.org/packages/30/77/fed85e114bde5e81ecf9bc5da0cc69f2914b38f4708c80ae67d0c10180c5/tomli-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f", size = 244792, upload-time = "2025-10-08T22:01:22.417Z" }, + { url = "https://files.pythonhosted.org/packages/55/92/afed3d497f7c186dc71e6ee6d4fcb0acfa5f7d0a1a2878f8beae379ae0cc/tomli-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05", size = 248909, upload-time = "2025-10-08T22:01:23.859Z" }, + { url = "https://files.pythonhosted.org/packages/f8/84/ef50c51b5a9472e7265ce1ffc7f24cd4023d289e109f669bdb1553f6a7c2/tomli-2.3.0-cp313-cp313-win32.whl", hash = "sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606", size = 96946, upload-time = "2025-10-08T22:01:24.893Z" }, + { url = "https://files.pythonhosted.org/packages/b2/b7/718cd1da0884f281f95ccfa3a6cc572d30053cba64603f79d431d3c9b61b/tomli-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999", size = 107705, upload-time = "2025-10-08T22:01:26.153Z" }, + { url = "https://files.pythonhosted.org/packages/19/94/aeafa14a52e16163008060506fcb6aa1949d13548d13752171a755c65611/tomli-2.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e", size = 154244, upload-time = "2025-10-08T22:01:27.06Z" }, + { url = "https://files.pythonhosted.org/packages/db/e4/1e58409aa78eefa47ccd19779fc6f36787edbe7d4cd330eeeedb33a4515b/tomli-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3", size = 148637, upload-time = "2025-10-08T22:01:28.059Z" }, + { url = "https://files.pythonhosted.org/packages/26/b6/d1eccb62f665e44359226811064596dd6a366ea1f985839c566cd61525ae/tomli-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc", size = 241925, upload-time = "2025-10-08T22:01:29.066Z" }, + { url = "https://files.pythonhosted.org/packages/70/91/7cdab9a03e6d3d2bb11beae108da5bdc1c34bdeb06e21163482544ddcc90/tomli-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0", size = 249045, upload-time = "2025-10-08T22:01:31.98Z" }, + { url = "https://files.pythonhosted.org/packages/15/1b/8c26874ed1f6e4f1fcfeb868db8a794cbe9f227299402db58cfcc858766c/tomli-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879", size = 245835, upload-time = "2025-10-08T22:01:32.989Z" }, + { url = "https://files.pythonhosted.org/packages/fd/42/8e3c6a9a4b1a1360c1a2a39f0b972cef2cc9ebd56025168c4137192a9321/tomli-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005", size = 253109, upload-time = "2025-10-08T22:01:34.052Z" }, + { url = "https://files.pythonhosted.org/packages/22/0c/b4da635000a71b5f80130937eeac12e686eefb376b8dee113b4a582bba42/tomli-2.3.0-cp314-cp314-win32.whl", hash = "sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463", size = 97930, upload-time = "2025-10-08T22:01:35.082Z" }, + { url = "https://files.pythonhosted.org/packages/b9/74/cb1abc870a418ae99cd5c9547d6bce30701a954e0e721821df483ef7223c/tomli-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8", size = 107964, upload-time = "2025-10-08T22:01:36.057Z" }, + { url = "https://files.pythonhosted.org/packages/54/78/5c46fff6432a712af9f792944f4fcd7067d8823157949f4e40c56b8b3c83/tomli-2.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77", size = 163065, upload-time = "2025-10-08T22:01:37.27Z" }, + { url = "https://files.pythonhosted.org/packages/39/67/f85d9bd23182f45eca8939cd2bc7050e1f90c41f4a2ecbbd5963a1d1c486/tomli-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf", size = 159088, upload-time = "2025-10-08T22:01:38.235Z" }, + { url = "https://files.pythonhosted.org/packages/26/5a/4b546a0405b9cc0659b399f12b6adb750757baf04250b148d3c5059fc4eb/tomli-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530", size = 268193, upload-time = "2025-10-08T22:01:39.712Z" }, + { url = "https://files.pythonhosted.org/packages/42/4f/2c12a72ae22cf7b59a7fe75b3465b7aba40ea9145d026ba41cb382075b0e/tomli-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b", size = 275488, upload-time = "2025-10-08T22:01:40.773Z" }, + { url = "https://files.pythonhosted.org/packages/92/04/a038d65dbe160c3aa5a624e93ad98111090f6804027d474ba9c37c8ae186/tomli-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67", size = 272669, upload-time = "2025-10-08T22:01:41.824Z" }, + { url = "https://files.pythonhosted.org/packages/be/2f/8b7c60a9d1612a7cbc39ffcca4f21a73bf368a80fc25bccf8253e2563267/tomli-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f", size = 279709, upload-time = "2025-10-08T22:01:43.177Z" }, + { url = "https://files.pythonhosted.org/packages/7e/46/cc36c679f09f27ded940281c38607716c86cf8ba4a518d524e349c8b4874/tomli-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0", size = 107563, upload-time = "2025-10-08T22:01:44.233Z" }, + { url = "https://files.pythonhosted.org/packages/84/ff/426ca8683cf7b753614480484f6437f568fd2fda2edbdf57a2d3d8b27a0b/tomli-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba", size = 119756, upload-time = "2025-10-08T22:01:45.234Z" }, + { url = "https://files.pythonhosted.org/packages/77/b8/0135fadc89e73be292b473cb820b4f5a08197779206b33191e801feeae40/tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b", size = 14408, upload-time = "2025-10-08T22:01:46.04Z" }, +] + +[[package]] +name = "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" }, +]