Compare commits
6 Commits
8acf4ba5c0
...
673ad95bcb
| Author | SHA1 | Date | |
|---|---|---|---|
| 673ad95bcb | |||
| 892d0b40e1 | |||
| 8c70e8681b | |||
| c9542d36a9 | |||
| 38a47ab793 | |||
| 9624c65440 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -8,3 +8,4 @@ build/
|
|||||||
backups/
|
backups/
|
||||||
scan/
|
scan/
|
||||||
dist-source/
|
dist-source/
|
||||||
|
.worktrees/
|
||||||
|
|||||||
62
CLAUDE.md
62
CLAUDE.md
@@ -9,9 +9,67 @@ Order backup CLI tool — backs up order data by time range to CSV, then deletes
|
|||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
- **Language**: Python
|
- **Language**: Python
|
||||||
- **Package management**: `uv` (install via `uv tool`)
|
- **Package management**: `uv`
|
||||||
- **Storage**: Azure Table (Python SDK)
|
- **Storage**: Azure Table (`azure-data-tables` SDK)
|
||||||
|
- **CLI framework**: Click
|
||||||
|
|
||||||
## Reference Implementation
|
## Reference Implementation
|
||||||
|
|
||||||
Order data model and format: `/Users/tech/workspace/n3-world/N3-Server/n3backend/n3order`
|
Order data model and format: `/Users/tech/workspace/n3-world/N3-Server/n3backend/n3order`
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install dependencies for development
|
||||||
|
uv sync --group dev
|
||||||
|
|
||||||
|
# Run unit tests only (no Azure emulator required)
|
||||||
|
uv run pytest tests/ -v -m "not integration"
|
||||||
|
|
||||||
|
# Run a single test file
|
||||||
|
uv run pytest tests/test_backup.py -v
|
||||||
|
|
||||||
|
# Run integration tests (requires Azure Storage emulator at 192.168.9.101)
|
||||||
|
uv run pytest tests/test_integration.py -v
|
||||||
|
|
||||||
|
# Seed test data into the emulator
|
||||||
|
uv run python scripts/seed_test_data.py
|
||||||
|
|
||||||
|
# Install as a CLI tool
|
||||||
|
uv tool install .
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
The tool implements a three-phase pipeline: **scan → backup → delete**.
|
||||||
|
|
||||||
|
### Three-Phase Pipeline
|
||||||
|
|
||||||
|
1. **`scan`** (`scan.py`): Full table scan of the `order` Azure Table, writing all entities to `scan/scan-<date>.csv`. Uses pagination with configurable page size and inter-page delay to limit production load.
|
||||||
|
|
||||||
|
2. **`backup`** (`backup.py`): Reads the scan CSV offline (no Azure calls), filters to `state == 1` (`Recived`) orders with `OrderTime < cutoff`, and writes per-day CSVs to `backups/<YYYY-MM-DD>.csv`. The `--days` flag sets cutoff as `today_utc - N days`.
|
||||||
|
|
||||||
|
3. **`delete`** (`delete.py`): Reads a backup CSV and deletes those records from Azure Table using batched transactions grouped by `PartitionKey`. Requires interactive confirmation before executing.
|
||||||
|
|
||||||
|
### Data Flow
|
||||||
|
|
||||||
|
- Azure Table `order` → scan CSV → backup CSVs → Azure Table deletion
|
||||||
|
- Scan and backup are read-only with respect to Azure; only `delete` writes to production.
|
||||||
|
- The `state` field is stored as an integer in Azure but legacy scan files may contain string values (`"Payed"` → 0, `"Recived"` → 1). `csv_utils.parse_row` handles both forms.
|
||||||
|
|
||||||
|
### Key Modules
|
||||||
|
|
||||||
|
- `cli.py`: Click command definitions; loads config and delegates to module functions
|
||||||
|
- `config.py`: Reads `~/.config/order-bak/config.toml` (TOML via `tomllib`); only `[azure].connection_string` is required
|
||||||
|
- `csv_utils.py`: Canonical column list (`CSV_COLUMNS`), `entity_to_row` / `parse_row` for serialization with type coercion
|
||||||
|
- `scan.py`, `backup.py`, `delete.py`: One function each, thin and focused
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
Config file: `~/.config/order-bak/config.toml`. Template in `assets/config.toml`. For local development against the emulator, copy `assets/local.toml`.
|
||||||
|
|
||||||
|
Override config path with `order-bak --config /path/to/config.toml <command>`.
|
||||||
|
|
||||||
|
### Integration Tests
|
||||||
|
|
||||||
|
The integration test (`tests/test_integration.py`) hits a real Azure Storage emulator at `192.168.9.101:10002`. Override the endpoint via `ORDER_BAK_TEST_CONNECTION_STRING` env var. Tests are gated by the `integration` pytest marker.
|
||||||
|
|||||||
@@ -63,31 +63,38 @@ def backup(ctx, days, scan_path):
|
|||||||
|
|
||||||
|
|
||||||
@main.command()
|
@main.command()
|
||||||
@click.option("--csv", "csv_path", required=True, type=click.Path(exists=True), help="Path to backup CSV to delete")
|
|
||||||
@click.pass_context
|
@click.pass_context
|
||||||
def delete(ctx, csv_path):
|
def delete(ctx):
|
||||||
cfg = load_config(ctx.obj["config_path"])
|
cfg = load_config(ctx.obj["config_path"])
|
||||||
from azure.data.tables import TableClient
|
from azure.data.tables import TableClient
|
||||||
from order_bak.delete import delete_orders
|
from order_bak.delete import delete_orders
|
||||||
|
|
||||||
csv_file = Path(csv_path)
|
backup_dir = Path("./backups")
|
||||||
|
backup_files = sorted(backup_dir.glob("*.csv"))
|
||||||
|
if not backup_files:
|
||||||
|
click.echo("No backup files found in ./backups")
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
import csv as csv_mod
|
import csv as csv_mod
|
||||||
with open(csv_file, newline="") as f:
|
total = 0
|
||||||
count = sum(1 for _ in csv_mod.DictReader(f))
|
for f in backup_files:
|
||||||
|
with open(f, newline="") as fh:
|
||||||
|
total += sum(1 for _ in csv_mod.DictReader(fh))
|
||||||
|
click.echo(f" {f}")
|
||||||
|
|
||||||
click.echo(f"Will delete {count} orders from {csv_file}")
|
click.echo(f"Will delete {total} orders from {len(backup_files)} file(s)")
|
||||||
if not click.confirm("Continue?"):
|
if not click.confirm("Continue?"):
|
||||||
click.echo("Aborted")
|
click.echo("Aborted")
|
||||||
return
|
return
|
||||||
|
|
||||||
table = TableClient.from_connection_string(cfg.azure_connection_string, table_name="order")
|
table = TableClient.from_connection_string(cfg.azure_connection_string, table_name="order")
|
||||||
delete_orders(
|
for csv_file in backup_files:
|
||||||
table_client=table,
|
delete_orders(
|
||||||
csv_path=csv_file,
|
table_client=table,
|
||||||
batch_size=cfg.delete_batch_size,
|
csv_path=csv_file,
|
||||||
delay_ms=cfg.delete_delay_ms,
|
batch_size=cfg.delete_batch_size,
|
||||||
)
|
delay_ms=cfg.delete_delay_ms,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from click.testing import CliRunner
|
||||||
|
|
||||||
|
from order_bak.cli import main
|
||||||
from order_bak.csv_utils import write_csv
|
from order_bak.csv_utils import write_csv
|
||||||
from order_bak.delete import delete_orders
|
from order_bak.delete import delete_orders
|
||||||
|
|
||||||
@@ -23,6 +27,8 @@ def _make_entity(pk, rk):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# --- delete_orders unit tests ---
|
||||||
|
|
||||||
def test_delete_groups_by_partition_and_batches(tmp_path):
|
def test_delete_groups_by_partition_and_batches(tmp_path):
|
||||||
entities = []
|
entities = []
|
||||||
for i in range(3):
|
for i in range(3):
|
||||||
@@ -65,3 +71,75 @@ def test_delete_respects_batch_size(tmp_path):
|
|||||||
|
|
||||||
assert deleted_count == 5
|
assert deleted_count == 5
|
||||||
assert mock_table.submit_transaction.call_count == 3 # 2+2+1
|
assert mock_table.submit_transaction.call_count == 3 # 2+2+1
|
||||||
|
|
||||||
|
|
||||||
|
# --- delete CLI command tests ---
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def runner():
|
||||||
|
return CliRunner()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def config_file(tmp_path):
|
||||||
|
cfg = tmp_path / "config.toml"
|
||||||
|
cfg.write_text('[azure]\nconnection_string = "UseDevelopmentStorage=true"\n')
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_command_no_backups(runner, config_file, tmp_path, monkeypatch):
|
||||||
|
monkeypatch.chdir(tmp_path)
|
||||||
|
(tmp_path / "backups").mkdir()
|
||||||
|
|
||||||
|
result = runner.invoke(main, ["--config", str(config_file), "delete"])
|
||||||
|
|
||||||
|
assert result.exit_code == 1
|
||||||
|
assert "No backup files found" in result.output
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_command_aborts(runner, config_file, tmp_path, monkeypatch):
|
||||||
|
monkeypatch.chdir(tmp_path)
|
||||||
|
backup_dir = tmp_path / "backups"
|
||||||
|
backup_dir.mkdir()
|
||||||
|
write_csv(backup_dir / "2025-01-01.csv", [_make_entity("u1", "o1")])
|
||||||
|
|
||||||
|
mock_table = MagicMock()
|
||||||
|
with patch("azure.data.tables.TableClient.from_connection_string", return_value=mock_table):
|
||||||
|
result = runner.invoke(main, ["--config", str(config_file), "delete"], input="n\n")
|
||||||
|
|
||||||
|
assert "Aborted" in result.output
|
||||||
|
mock_table.submit_transaction.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_command_single_file(runner, config_file, tmp_path, monkeypatch):
|
||||||
|
monkeypatch.chdir(tmp_path)
|
||||||
|
backup_dir = tmp_path / "backups"
|
||||||
|
backup_dir.mkdir()
|
||||||
|
write_csv(backup_dir / "2025-01-01.csv", [_make_entity("u1", f"o{i}") for i in range(3)])
|
||||||
|
|
||||||
|
mock_table = MagicMock()
|
||||||
|
with patch("azure.data.tables.TableClient.from_connection_string", return_value=mock_table):
|
||||||
|
result = runner.invoke(main, ["--config", str(config_file), "delete"], input="y\n")
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "3 orders" in result.output
|
||||||
|
assert "1 file" in result.output
|
||||||
|
assert mock_table.submit_transaction.called
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_command_multiple_files(runner, config_file, tmp_path, monkeypatch):
|
||||||
|
monkeypatch.chdir(tmp_path)
|
||||||
|
backup_dir = tmp_path / "backups"
|
||||||
|
backup_dir.mkdir()
|
||||||
|
write_csv(backup_dir / "2025-01-01.csv", [_make_entity("u1", f"o{i}") for i in range(2)])
|
||||||
|
write_csv(backup_dir / "2025-01-02.csv", [_make_entity("u2", f"o{i}") for i in range(3)])
|
||||||
|
|
||||||
|
mock_table = MagicMock()
|
||||||
|
with patch("azure.data.tables.TableClient.from_connection_string", return_value=mock_table):
|
||||||
|
result = runner.invoke(main, ["--config", str(config_file), "delete"], input="y\n")
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "5 orders" in result.output
|
||||||
|
assert "2 file" in result.output
|
||||||
|
# u1 (2 entities, batch 100) → 1 call; u2 (3 entities, batch 100) → 1 call
|
||||||
|
assert mock_table.submit_transaction.call_count == 2
|
||||||
|
|||||||
@@ -6,12 +6,16 @@ from pathlib import Path
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from azure.data.tables import TableServiceClient
|
from azure.data.tables import TableServiceClient
|
||||||
|
from click.testing import CliRunner
|
||||||
|
|
||||||
from order_bak.backup import backup_orders
|
from order_bak.backup import backup_orders
|
||||||
|
from order_bak.cli import main
|
||||||
from order_bak.csv_utils import read_csv
|
from order_bak.csv_utils import read_csv
|
||||||
from order_bak.delete import delete_orders
|
from order_bak.delete import delete_orders
|
||||||
from order_bak.scan import scan_orders
|
from order_bak.scan import scan_orders
|
||||||
|
|
||||||
|
CONFIG_PATH = Path(__file__).parent.parent / "assets" / "s101.toml"
|
||||||
|
|
||||||
CONNECTION_STRING = os.environ.get(
|
CONNECTION_STRING = os.environ.get(
|
||||||
"ORDER_BAK_TEST_CONNECTION_STRING",
|
"ORDER_BAK_TEST_CONNECTION_STRING",
|
||||||
(
|
(
|
||||||
@@ -167,3 +171,62 @@ def test_full_pipeline(table_client, tmp_path):
|
|||||||
if e.get("OrderTime") and e["OrderTime"].day == 2 and e["state"] == 1
|
if e.get("OrderTime") and e["OrderTime"].day == 2 and e["state"] == 1
|
||||||
]
|
]
|
||||||
assert len(remaining_jan2_recived) == 4
|
assert len(remaining_jan2_recived) == 4
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
def test_delete_command_all_backups(table_client, tmp_path, monkeypatch):
|
||||||
|
"""CLI delete command reads all backup CSVs and deletes them from Azure."""
|
||||||
|
# Seed: 3 days × 2 users × 1 Recived order = 6 orders to back up
|
||||||
|
orders = []
|
||||||
|
for day in [1, 2, 3]:
|
||||||
|
for user in ["u1", "u2"]:
|
||||||
|
orders.append({
|
||||||
|
"PartitionKey": user,
|
||||||
|
"RowKey": f"o-d{day}-{user}",
|
||||||
|
"OrderTime": datetime(2025, 1, day, 10, 0, tzinfo=timezone.utc),
|
||||||
|
"ReceiveTime": datetime(2025, 1, day, 11, 0, tzinfo=timezone.utc),
|
||||||
|
"revenue": 10.0,
|
||||||
|
"platformOrder": "plat",
|
||||||
|
"prodCount": 1,
|
||||||
|
"pfId": user,
|
||||||
|
"prodId": "prod1",
|
||||||
|
"prodPrice": 10.0,
|
||||||
|
"state": 1,
|
||||||
|
"ver": 3,
|
||||||
|
"customData": None,
|
||||||
|
})
|
||||||
|
_seed_orders(table_client, orders)
|
||||||
|
|
||||||
|
# Scan then backup (cutoff excludes nothing — all 6 are Recived before Jan 4)
|
||||||
|
scan_dir = tmp_path / "scan"
|
||||||
|
scan_path = scan_orders(
|
||||||
|
connection_string=CONNECTION_STRING,
|
||||||
|
page_size=10,
|
||||||
|
delay_ms=0,
|
||||||
|
scan_dir=scan_dir,
|
||||||
|
)
|
||||||
|
backup_dir = tmp_path / "backups"
|
||||||
|
backed_up = backup_orders(
|
||||||
|
scan_path=scan_path,
|
||||||
|
backup_dir=backup_dir,
|
||||||
|
cutoff=datetime(2025, 1, 4, tzinfo=timezone.utc),
|
||||||
|
)
|
||||||
|
assert backed_up == 6
|
||||||
|
assert len(list(backup_dir.glob("*.csv"))) == 3 # one file per day
|
||||||
|
|
||||||
|
# chdir so Path("./backups") in the CLI resolves to tmp_path/backups
|
||||||
|
monkeypatch.chdir(tmp_path)
|
||||||
|
|
||||||
|
result = CliRunner().invoke(
|
||||||
|
main,
|
||||||
|
["--config", str(CONFIG_PATH), "delete"],
|
||||||
|
input="y\n",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
assert "6 orders" in result.output
|
||||||
|
assert "3 file" in result.output
|
||||||
|
|
||||||
|
# All 6 Recived orders must be gone from the table
|
||||||
|
remaining = list(table_client.list_entities())
|
||||||
|
assert len(remaining) == 0
|
||||||
|
|||||||
Reference in New Issue
Block a user