diff --git a/conductor/tracks/rule_engine_management_20260302/index.md b/conductor/archive/rule_engine_management_20260302/index.md similarity index 100% rename from conductor/tracks/rule_engine_management_20260302/index.md rename to conductor/archive/rule_engine_management_20260302/index.md diff --git a/conductor/tracks/rule_engine_management_20260302/metadata.json b/conductor/archive/rule_engine_management_20260302/metadata.json similarity index 100% rename from conductor/tracks/rule_engine_management_20260302/metadata.json rename to conductor/archive/rule_engine_management_20260302/metadata.json diff --git a/conductor/tracks/rule_engine_management_20260302/plan.md b/conductor/archive/rule_engine_management_20260302/plan.md similarity index 100% rename from conductor/tracks/rule_engine_management_20260302/plan.md rename to conductor/archive/rule_engine_management_20260302/plan.md diff --git a/conductor/tracks/rule_engine_management_20260302/spec.md b/conductor/archive/rule_engine_management_20260302/spec.md similarity index 100% rename from conductor/tracks/rule_engine_management_20260302/spec.md rename to conductor/archive/rule_engine_management_20260302/spec.md diff --git a/conductor/archive/rule_management_20260311/index.md b/conductor/archive/rule_management_20260311/index.md new file mode 100644 index 0000000..f8671c3 --- /dev/null +++ b/conductor/archive/rule_management_20260311/index.md @@ -0,0 +1,5 @@ +# Track rule_management_20260311 Context + +- [Specification](./spec.md) +- [Implementation Plan](./plan.md) +- [Metadata](./metadata.json) diff --git a/conductor/archive/rule_management_20260311/metadata.json b/conductor/archive/rule_management_20260311/metadata.json new file mode 100644 index 0000000..1c03753 --- /dev/null +++ b/conductor/archive/rule_management_20260311/metadata.json @@ -0,0 +1,8 @@ +{ + "track_id": "rule_management_20260311", + "type": "feature", + "status": "new", + "created_at": "2026-03-11T12:00:00Z", + "updated_at": "2026-03-11T12:00:00Z", + "description": "Implement automation rule management using Enapter HTTP API for Rule Engine (Create, List, Get, Update, Enable, Disable, Delete)." +} diff --git a/conductor/archive/rule_management_20260311/plan.md b/conductor/archive/rule_management_20260311/plan.md new file mode 100644 index 0000000..46adead --- /dev/null +++ b/conductor/archive/rule_management_20260311/plan.md @@ -0,0 +1,61 @@ +# Implementation Plan: Rule Management + +This plan outlines the implementation of Rule management (create, list, get, update, enable/disable, delete) in the Enapter HTTP API Rule Engine client. + +## Phase 1: Models and Foundation [checkpoint: c888a5d] + +- [x] Task: Define `Rule`, `RuleScript`, and `RuntimeVersion`, and `RuleState` data models + - [x] Create `src/enapter/http/api/rule_engine/rule.py`, `src/enapter/http/api/rule_engine/rule_script.py`, `src/enapter/http/api/rule_engine/runtime_version.py`, and `src/enapter/http/api/rule_engine/rule_state.py` + - [x] Implement `Rule` and `RuleScript` dataclasses and `RuntimeVersion` and `RuleState` enums + - [x] Implement `from_dto` and `to_dto` methods for all models + - [x] Add unit tests for models in `tests/unit/test_http/test_api/test_rule_engine/test_rule.py` +- [x] Task: Export models in `src/enapter/http/api/rule_engine/__init__.py` +- [x] Task: Conductor - User Manual Verification 'Phase 1: Models and Foundation' (Protocol in workflow.md) + +## Phase 2: Rule Management Implementation (Read Operations) [checkpoint: 6745120] + +- [x] Task: Implement `List Rules` method + - [x] Add `list_rules` method to `src/enapter/http/api/rule_engine/client.py` + - [x] Write failing tests in `tests/unit/test_http/test_api/test_rule_engine/test_client.py` + - [x] Implement method to pass tests +- [x] Task: Implement `Get Rule` method + - [x] Add `get_rule` method to `src/enapter/http/api/rule_engine/client.py` + - [x] Write failing tests + - [x] Implement method to pass tests +- [x] Task: Conductor - User Manual Verification 'Phase 2: Rule Management Implementation (Read Operations)' (Protocol in workflow.md) + +## Phase 3: Rule Management Implementation (Write Operations) [checkpoint: 15e657c] + +- [x] Task: Implement `Create Rule` method + - [x] Add `create_rule` method to `src/enapter/http/api/rule_engine/client.py` + - [x] Write failing tests (including base64 encoding check) + - [x] Implement method to pass tests +- [x] Task: Implement `Update Rule` (slug) method + - [x] Add `update_rule` method to `src/enapter/http/api/rule_engine/client.py` + - [x] Write failing tests + - [x] Implement method to pass tests +- [x] Task: Implement `Update Rule Script` method + - [x] Add `update_rule_script` method to `src/enapter/http/api/rule_engine/client.py` + - [x] Write failing tests + - [x] Implement method to pass tests +- [x] Task: Implement `Delete Rule` method + - [x] Add `delete_rule` method to `src/enapter/http/api/rule_engine/client.py` + - [x] Write failing tests + - [x] Implement method to pass tests +- [x] Task: Conductor - User Manual Verification 'Phase 3: Rule Management Implementation (Write Operations)' (Protocol in workflow.md) + +## Phase 4: Rule State Management [checkpoint: 43df5d0] + +- [x] Task: Implement `Enable Rule` and `Disable Rule` methods + - [x] Add `enable_rule` and `disable_rule` methods to `src/enapter/http/api/rule_engine/client.py` + - [x] Write failing tests + - [x] Implement methods to pass tests +- [x] Task: Conductor - User Manual Verification 'Phase 4: Rule State Management' (Protocol in workflow.md) + +## Phase 5: Integration and Finalization [checkpoint: 90987b4] + +- [x] Task: Add integration tests for all new Rule management methods + - [x] Create `tests/integration/test_rule_engine_management.py` (or similar) + - [x] Verify full flows against a mock or real environment if possible +- [x] Task: Final code quality check (linting, coverage) +- [x] Task: Conductor - User Manual Verification 'Phase 5: Integration and Finalization' (Protocol in workflow.md) diff --git a/conductor/archive/rule_management_20260311/spec.md b/conductor/archive/rule_management_20260311/spec.md new file mode 100644 index 0000000..3633f83 --- /dev/null +++ b/conductor/archive/rule_management_20260311/spec.md @@ -0,0 +1,46 @@ +# Specification: Rule Management + +## Overview +This track implements comprehensive rule management within the Enapter HTTP API Rule Engine client. This includes creating, listing, retrieving, updating, enabling/disabling, and deleting rules via the Enapter Cloud HTTP API. + +## Functional Requirements +The `enapter.http.api.rule_engine.Client` will be extended with methods to: +1. **Create Rule**: `POST` a new rule with a `slug`, `script` (code and runtime version), and optional `disabled` flag. +2. **List Rules**: `GET` all rules for a specific site or the default site. +3. **Get Rule**: `GET` a specific rule by its ID. +4. **Update Rule**: `PATCH` an existing rule's `slug`. +5. **Update Rule Script**: `POST` a new `script` (code and runtime version) for an existing rule. +6. **Enable Rule**: `POST` to enable a rule. +7. **Disable Rule**: `POST` to disable a rule. +8. **Delete Rule**: `DELETE` a rule by its ID. + +## Data Model: Rule +A `Rule` model will be introduced in `src/enapter/http/api/rule_engine/rule.py`, including: +- `id`: Unique identifier for the rule. +- `slug`: Human-readable identifier. +- `disabled`: Boolean indicating if the rule is disabled. +- `state`: Execution state (e.g., `STARTED`, `STOPPED`). +- `script`: An object containing: + - `code`: The rule's script code (base64 encoded string). + - `runtime_version`: The runtime environment version (`V1` or `V3`). + +## Technical Requirements +- **Integration**: Methods will be added to `enapter.http.api.rule_engine.Client`. +- **Async Strategy**: All API calls will be asynchronous using `httpx.AsyncClient`. +- **Error Handling**: Use the existing `enapter.http.api.check_error` mechanism which raises generic HTTP client exceptions for failed requests. +- **Base64 Encoding**: The client should handle base64 encoding/decoding of the rule script code for ease of use. +- **Modern Python**: Adhere to Python 3.11+ patterns and typing. + +## Acceptance Criteria +- [ ] Users can create a rule by providing a slug and script code. +- [ ] Users can list all rules for a site. +- [ ] Users can retrieve a specific rule by ID. +- [ ] Users can update a rule's slug. +- [ ] Users can update a rule's script code and runtime version. +- [ ] Users can enable and disable a rule. +- [ ] Users can delete a rule. +- [ ] All new methods are fully tested with unit and integration tests. + +## Out of Scope +- Management of Rule Engine itself (suspend/resume/get state) is already implemented and remains unchanged. +- Frontend/CLI integration for these new methods. diff --git a/conductor/archive/rule_management_cli_20260317/index.md b/conductor/archive/rule_management_cli_20260317/index.md new file mode 100644 index 0000000..9218f90 --- /dev/null +++ b/conductor/archive/rule_management_cli_20260317/index.md @@ -0,0 +1,5 @@ +# Track rule_management_cli_20260317 Context + +- [Specification](./spec.md) +- [Implementation Plan](./plan.md) +- [Metadata](./metadata.json) diff --git a/conductor/archive/rule_management_cli_20260317/metadata.json b/conductor/archive/rule_management_cli_20260317/metadata.json new file mode 100644 index 0000000..89776f1 --- /dev/null +++ b/conductor/archive/rule_management_cli_20260317/metadata.json @@ -0,0 +1,8 @@ +{ + "track_id": "rule_management_cli_20260317", + "type": "feature", + "status": "new", + "created_at": "2026-03-17T12:00:00Z", + "updated_at": "2026-03-17T12:00:00Z", + "description": "rule management cli" +} diff --git a/conductor/archive/rule_management_cli_20260317/plan.md b/conductor/archive/rule_management_cli_20260317/plan.md new file mode 100644 index 0000000..138b9ec --- /dev/null +++ b/conductor/archive/rule_management_cli_20260317/plan.md @@ -0,0 +1,48 @@ +# Implementation Plan: Rule Management CLI + +## Phase 1: CLI Infrastructure and Registration [checkpoint: 5de1d89] + +- [x] Task: Create `RuleCommand` group and register it + - [x] Create `src/enapter/cli/http/api/rule_command.py` + - [x] Register `RuleCommand` in `src/enapter/cli/http/api/rule_engine_command.py` + - [x] Implement registration of sub-commands (placeholders for now) +- [x] Task: Conductor - User Manual Verification 'Phase 1: CLI Infrastructure and Registration' (Protocol in workflow.md) + +## Phase 2: Read Operations (List and Get) + +- [x] Task: Implement `rule list` command + - [x] Create `src/enapter/cli/http/api/rule_list_command.py` + - [x] Implement command logic and JSON output +- [x] Task: Implement `rule get` command + - [x] Create `src/enapter/cli/http/api/rule_get_command.py` + - [x] Implement command logic and JSON output +- [x] Task: Conductor - User Manual Verification 'Phase 2: Read Operations (List and Get)' (Protocol in workflow.md) + +## Phase 3: Create and Delete Operations + +- [x] Task: Implement `rule create` command + - [x] Create `src/enapter/cli/http/api/rule_create_command.py` + - [x] Implement command logic (handle `--script-file`, `--runtime-version`, `--exec-interval`, `--disable`) +- [x] Task: Implement `rule delete` command + - [x] Create `src/enapter/cli/http/api/rule_delete_command.py` + - [x] Implement command logic +- [x] Task: Conductor - User Manual Verification 'Phase 3: Create and Delete Operations' (Protocol in workflow.md) + +## Phase 4: Update and State Management Operations + +- [x] Task: Implement `rule update` (slug) command + - [x] Create `src/enapter/cli/http/api/rule_update_command.py` + - [x] Implement command logic +- [x] Task: Implement `rule update-script` command + - [x] Create `src/enapter/cli/http/api/rule_update_script_command.py` + - [x] Implement command logic (handle `--script-file`, `--runtime-version`, `--exec-interval`) +- [x] Task: Implement `rule enable` and `rule disable` commands + - [x] Create `src/enapter/cli/http/api/rule_enable_command.py` and `src/enapter/cli/http/api/rule_disable_command.py` + - [x] Implement command logic +- [x] Task: Conductor - User Manual Verification 'Phase 4: Update and State Management Operations' (Protocol in workflow.md) + +## Phase 5: Finalization and Quality Check [checkpoint: 182fade] + +- [x] Task: Final code quality check (linting) +- [x] Task: Verify overall CLI consistency and help messages +- [x] Task: Conductor - User Manual Verification 'Phase 5: Finalization and Quality Check' (Protocol in workflow.md) diff --git a/conductor/archive/rule_management_cli_20260317/spec.md b/conductor/archive/rule_management_cli_20260317/spec.md new file mode 100644 index 0000000..102130d --- /dev/null +++ b/conductor/archive/rule_management_cli_20260317/spec.md @@ -0,0 +1,37 @@ +# Specification: Rule Management CLI + +## Overview +This track implements a command-line interface for managing rules within the Enapter HTTP API Rule Engine. These commands will be integrated into the existing `enapter` CLI under the `api rule-engine rule` group. + +## Functional Requirements +The CLI will be extended with the following commands under `api rule-engine rule`: + +1. **List Rules**: `api rule-engine rule list [--site-id SITE_ID]` +2. **Get Rule**: `api rule-engine rule get RULE_ID [--site-id SITE_ID]` +3. **Create Rule**: `api rule-engine rule create --script-file PATH [--slug SLUG] [--site-id SITE_ID] [--disable] [--runtime-version {V1,V3}] [--exec-interval INTERVAL]` + - Creates a new rule. `slug` is optional (autogenerated if missing). `runtime-version` defaults to V3. `exec-interval` required if V1. +4. **Update Rule**: `api rule-engine rule update RULE_ID --slug NEW_SLUG [--site-id SITE_ID]` + - Updates a rule's slug. +5. **Update Rule Script**: `api rule-engine rule update-script RULE_ID --script-file PATH [--site-id SITE_ID] [--runtime-version {V1,V3}] [--exec-interval INTERVAL]` + - Updates a rule's script code and optionally runtime version/interval. +6. **Enable Rule**: `api rule-engine rule enable RULE_ID [--site-id SITE_ID]` +7. **Disable Rule**: `api rule-engine rule disable RULE_ID [--site-id SITE_ID]` +8. **Delete Rule**: `api rule-engine rule delete RULE_ID [--site-id SITE_ID]` + +## Technical Requirements +- **Integration**: Commands will be added to `src/enapter/cli/http/api/`. +- **Command Group**: A new `RuleCommand` class will be created and registered within `RuleEngineCommand`. +- **Output**: All commands should output the resulting Rule object in JSON format. +- **Dependency**: Uses the `enapter.http.api.rule_engine.Client`. +- **Script Handling**: Read script file content from `PATH`. + +## Acceptance Criteria +- [ ] Users can perform all CRUD operations on rules via the CLI. +- [ ] Users can upload rule scripts from files. +- [ ] Commands support `--site-id` for multi-site management. +- [ ] Output is provided in JSON format. +- [ ] All new CLI commands are covered by manual verification (automated tests skipped per user request). + +## Out of Scope +- Downloading rule scripts to files. +- Management of Rule Engine itself (already exists). diff --git a/conductor/tracks.md b/conductor/tracks.md index 07dfeb1..22d3d64 100644 --- a/conductor/tracks.md +++ b/conductor/tracks.md @@ -1,13 +1,3 @@ # Project Tracks This file tracks all major tracks for the project. Each track has its own detailed plan in its respective folder. - ---- - -- [x] **Track: Add basic unit-tests for the HTTP client.** - *Link: [./tracks/http_client_tests_20260302/](./tracks/http_client_tests_20260302/)* - ---- - -- [x] **Track: Implement Enapter rule-engine rule management in HTTP API client** -*Link: [./tracks/rule_engine_management_20260302/](./tracks/rule_engine_management_20260302/)* \ No newline at end of file diff --git a/conductor/tracks/http_client_tests_20260302/index.md b/conductor/tracks/http_client_tests_20260302/index.md deleted file mode 100644 index 795c52a..0000000 --- a/conductor/tracks/http_client_tests_20260302/index.md +++ /dev/null @@ -1,5 +0,0 @@ -# Track http_client_tests_20260302 Context - -- [Specification](./spec.md) -- [Implementation Plan](./plan.md) -- [Metadata](./metadata.json) \ No newline at end of file diff --git a/conductor/tracks/http_client_tests_20260302/metadata.json b/conductor/tracks/http_client_tests_20260302/metadata.json deleted file mode 100644 index 12a0309..0000000 --- a/conductor/tracks/http_client_tests_20260302/metadata.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "track_id": "http_client_tests_20260302", - "type": "feature", - "status": "new", - "created_at": "2026-03-02T12:00:00Z", - "updated_at": "2026-03-02T12:00:00Z", - "description": "Add basic unit-tests for the HTTP client." -} \ No newline at end of file diff --git a/conductor/tracks/http_client_tests_20260302/plan.md b/conductor/tracks/http_client_tests_20260302/plan.md deleted file mode 100644 index 90390bf..0000000 --- a/conductor/tracks/http_client_tests_20260302/plan.md +++ /dev/null @@ -1,17 +0,0 @@ -# Implementation Plan: Add basic unit-tests for the HTTP client. - -## Phase 1: Setup and Basic Client Initialization Tests [checkpoint: e1a76d5] -- [x] Task: Create test file `tests/unit/test_http/test_api/test_client.py` fcd7dee - - [ ] Write tests for HTTP client initialization with valid and invalid tokens. - - [ ] Write tests for client default configurations and headers. -- [ ] Task: Conductor - User Manual Verification 'Phase 1: Setup and Basic Client Initialization Tests' (Protocol in workflow.md) - -## Phase 2: Sites API Tests [checkpoint: bf93d5a] -- [x] Task: Create test file `tests/unit/test_http/test_api/test_sites/test_client.py` 14ff304 - - [ ] Write tests for fetching site details and listing sites. - - [ ] Mock the HTTP responses for site endpoints. - - [ ] Verify that the returned site objects (`Site`, `SiteLocation`) are correctly parsed. -- [ ] Task: Conductor - User Manual Verification 'Phase 2: Sites API Tests' (Protocol in workflow.md) - -## Phase: Review Fixes -- [x] Task: Apply review suggestions 9470c8b \ No newline at end of file diff --git a/conductor/tracks/http_client_tests_20260302/spec.md b/conductor/tracks/http_client_tests_20260302/spec.md deleted file mode 100644 index c4b7dbd..0000000 --- a/conductor/tracks/http_client_tests_20260302/spec.md +++ /dev/null @@ -1,13 +0,0 @@ -# Track Specification: Add basic unit-tests for the HTTP client. - -## Objective -Implement basic unit testing coverage for the HTTP client module within the Enapter Python SDK. This ensures reliability and stability of the client's core operations like connecting to the Enapter API, managing sites, and handling telemetry. - -## Scope -- Focus on `src/enapter/http/api/client.py`, `src/enapter/http/api/sites/` and their related dependencies. -- Use `pytest` for running and structuring the tests. -- Use `httpx` mocking features (like `pytest-httpx` or similar) if needed, or stick to basic unit-tests on the existing public methods. - -## Exclusions -- Integration tests that require live Enapter Cloud credentials. -- Tests for MQTT client or Standalone devices. \ No newline at end of file diff --git a/conductor/tracks/rule_management_cli_20260317/index.md b/conductor/tracks/rule_management_cli_20260317/index.md new file mode 100644 index 0000000..9218f90 --- /dev/null +++ b/conductor/tracks/rule_management_cli_20260317/index.md @@ -0,0 +1,5 @@ +# Track rule_management_cli_20260317 Context + +- [Specification](./spec.md) +- [Implementation Plan](./plan.md) +- [Metadata](./metadata.json) diff --git a/conductor/tracks/rule_management_cli_20260317/metadata.json b/conductor/tracks/rule_management_cli_20260317/metadata.json new file mode 100644 index 0000000..89776f1 --- /dev/null +++ b/conductor/tracks/rule_management_cli_20260317/metadata.json @@ -0,0 +1,8 @@ +{ + "track_id": "rule_management_cli_20260317", + "type": "feature", + "status": "new", + "created_at": "2026-03-17T12:00:00Z", + "updated_at": "2026-03-17T12:00:00Z", + "description": "rule management cli" +} diff --git a/conductor/tracks/rule_management_cli_20260317/plan.md b/conductor/tracks/rule_management_cli_20260317/plan.md new file mode 100644 index 0000000..138b9ec --- /dev/null +++ b/conductor/tracks/rule_management_cli_20260317/plan.md @@ -0,0 +1,48 @@ +# Implementation Plan: Rule Management CLI + +## Phase 1: CLI Infrastructure and Registration [checkpoint: 5de1d89] + +- [x] Task: Create `RuleCommand` group and register it + - [x] Create `src/enapter/cli/http/api/rule_command.py` + - [x] Register `RuleCommand` in `src/enapter/cli/http/api/rule_engine_command.py` + - [x] Implement registration of sub-commands (placeholders for now) +- [x] Task: Conductor - User Manual Verification 'Phase 1: CLI Infrastructure and Registration' (Protocol in workflow.md) + +## Phase 2: Read Operations (List and Get) + +- [x] Task: Implement `rule list` command + - [x] Create `src/enapter/cli/http/api/rule_list_command.py` + - [x] Implement command logic and JSON output +- [x] Task: Implement `rule get` command + - [x] Create `src/enapter/cli/http/api/rule_get_command.py` + - [x] Implement command logic and JSON output +- [x] Task: Conductor - User Manual Verification 'Phase 2: Read Operations (List and Get)' (Protocol in workflow.md) + +## Phase 3: Create and Delete Operations + +- [x] Task: Implement `rule create` command + - [x] Create `src/enapter/cli/http/api/rule_create_command.py` + - [x] Implement command logic (handle `--script-file`, `--runtime-version`, `--exec-interval`, `--disable`) +- [x] Task: Implement `rule delete` command + - [x] Create `src/enapter/cli/http/api/rule_delete_command.py` + - [x] Implement command logic +- [x] Task: Conductor - User Manual Verification 'Phase 3: Create and Delete Operations' (Protocol in workflow.md) + +## Phase 4: Update and State Management Operations + +- [x] Task: Implement `rule update` (slug) command + - [x] Create `src/enapter/cli/http/api/rule_update_command.py` + - [x] Implement command logic +- [x] Task: Implement `rule update-script` command + - [x] Create `src/enapter/cli/http/api/rule_update_script_command.py` + - [x] Implement command logic (handle `--script-file`, `--runtime-version`, `--exec-interval`) +- [x] Task: Implement `rule enable` and `rule disable` commands + - [x] Create `src/enapter/cli/http/api/rule_enable_command.py` and `src/enapter/cli/http/api/rule_disable_command.py` + - [x] Implement command logic +- [x] Task: Conductor - User Manual Verification 'Phase 4: Update and State Management Operations' (Protocol in workflow.md) + +## Phase 5: Finalization and Quality Check [checkpoint: 182fade] + +- [x] Task: Final code quality check (linting) +- [x] Task: Verify overall CLI consistency and help messages +- [x] Task: Conductor - User Manual Verification 'Phase 5: Finalization and Quality Check' (Protocol in workflow.md) diff --git a/conductor/tracks/rule_management_cli_20260317/spec.md b/conductor/tracks/rule_management_cli_20260317/spec.md new file mode 100644 index 0000000..102130d --- /dev/null +++ b/conductor/tracks/rule_management_cli_20260317/spec.md @@ -0,0 +1,37 @@ +# Specification: Rule Management CLI + +## Overview +This track implements a command-line interface for managing rules within the Enapter HTTP API Rule Engine. These commands will be integrated into the existing `enapter` CLI under the `api rule-engine rule` group. + +## Functional Requirements +The CLI will be extended with the following commands under `api rule-engine rule`: + +1. **List Rules**: `api rule-engine rule list [--site-id SITE_ID]` +2. **Get Rule**: `api rule-engine rule get RULE_ID [--site-id SITE_ID]` +3. **Create Rule**: `api rule-engine rule create --script-file PATH [--slug SLUG] [--site-id SITE_ID] [--disable] [--runtime-version {V1,V3}] [--exec-interval INTERVAL]` + - Creates a new rule. `slug` is optional (autogenerated if missing). `runtime-version` defaults to V3. `exec-interval` required if V1. +4. **Update Rule**: `api rule-engine rule update RULE_ID --slug NEW_SLUG [--site-id SITE_ID]` + - Updates a rule's slug. +5. **Update Rule Script**: `api rule-engine rule update-script RULE_ID --script-file PATH [--site-id SITE_ID] [--runtime-version {V1,V3}] [--exec-interval INTERVAL]` + - Updates a rule's script code and optionally runtime version/interval. +6. **Enable Rule**: `api rule-engine rule enable RULE_ID [--site-id SITE_ID]` +7. **Disable Rule**: `api rule-engine rule disable RULE_ID [--site-id SITE_ID]` +8. **Delete Rule**: `api rule-engine rule delete RULE_ID [--site-id SITE_ID]` + +## Technical Requirements +- **Integration**: Commands will be added to `src/enapter/cli/http/api/`. +- **Command Group**: A new `RuleCommand` class will be created and registered within `RuleEngineCommand`. +- **Output**: All commands should output the resulting Rule object in JSON format. +- **Dependency**: Uses the `enapter.http.api.rule_engine.Client`. +- **Script Handling**: Read script file content from `PATH`. + +## Acceptance Criteria +- [ ] Users can perform all CRUD operations on rules via the CLI. +- [ ] Users can upload rule scripts from files. +- [ ] Commands support `--site-id` for multi-site management. +- [ ] Output is provided in JSON format. +- [ ] All new CLI commands are covered by manual verification (automated tests skipped per user request). + +## Out of Scope +- Downloading rule scripts to files. +- Management of Rule Engine itself (already exists). diff --git a/src/enapter/cli/http/api/blueprint_download_command.py b/src/enapter/cli/http/api/blueprint_download_command.py index 7856cf6..560d8bd 100644 --- a/src/enapter/cli/http/api/blueprint_download_command.py +++ b/src/enapter/cli/http/api/blueprint_download_command.py @@ -16,7 +16,11 @@ def register(parent: cli.Subparsers) -> None: ) parser.add_argument("id", help="ID of the blueprint to download") parser.add_argument( - "-o", "--output", type=pathlib.Path, help="Output file path", required=True + "-o", + "--output", + type=pathlib.Path, + help="Output directory path", + default=pathlib.Path.cwd(), ) parser.add_argument( "-v", @@ -32,5 +36,6 @@ async def run(args: argparse.Namespace) -> None: content = await client.blueprints.download( args.id, view=http.api.blueprints.BlueprintView(args.view.upper()) ) - with open(args.output, "wb") as f: + output_path = args.output / f"{args.id}.zip" + with output_path.open("wb") as f: f.write(content) diff --git a/src/enapter/cli/http/api/rule_command.py b/src/enapter/cli/http/api/rule_command.py new file mode 100644 index 0000000..78d3032 --- /dev/null +++ b/src/enapter/cli/http/api/rule_command.py @@ -0,0 +1,60 @@ +"""Rule command group.""" + +import argparse + +from enapter import cli + +from .rule_create_command import RuleCreateCommand +from .rule_delete_command import RuleDeleteCommand +from .rule_disable_command import RuleDisableCommand +from .rule_enable_command import RuleEnableCommand +from .rule_get_command import RuleGetCommand +from .rule_list_command import RuleListCommand +from .rule_update_command import RuleUpdateCommand +from .rule_update_script_command import RuleUpdateScriptCommand + + +class RuleCommand(cli.Command): + """Command group for Rule management.""" + + @staticmethod + def register(parent: cli.Subparsers) -> None: + """Register the command group in the subparsers.""" + parser = parent.add_parser( + "rule", formatter_class=argparse.ArgumentDefaultsHelpFormatter + ) + subparsers = parser.add_subparsers(dest="rule_command", required=True) + for command in [ + RuleListCommand, + RuleGetCommand, + RuleCreateCommand, + RuleUpdateCommand, + RuleUpdateScriptCommand, + RuleEnableCommand, + RuleDisableCommand, + RuleDeleteCommand, + ]: + command.register(subparsers) + + @staticmethod + async def run(args: argparse.Namespace) -> None: + """Run the sub-command.""" + match args.rule_command: + case "list": + await RuleListCommand.run(args) + case "get": + await RuleGetCommand.run(args) + case "create": + await RuleCreateCommand.run(args) + case "update": + await RuleUpdateCommand.run(args) + case "update-script": + await RuleUpdateScriptCommand.run(args) + case "enable": + await RuleEnableCommand.run(args) + case "disable": + await RuleDisableCommand.run(args) + case "delete": + await RuleDeleteCommand.run(args) + case _: + raise NotImplementedError(args.rule_command) diff --git a/src/enapter/cli/http/api/rule_create_command.py b/src/enapter/cli/http/api/rule_create_command.py new file mode 100644 index 0000000..67521c4 --- /dev/null +++ b/src/enapter/cli/http/api/rule_create_command.py @@ -0,0 +1,61 @@ +"""Rule create command.""" + +import argparse +import json +import sys + +from enapter import cli, http + + +class RuleCreateCommand(cli.Command): + """Command to create a new rule.""" + + @staticmethod + def register(parent: cli.Subparsers) -> None: + """Register the command in the subparsers.""" + parser = parent.add_parser( + "create", formatter_class=argparse.ArgumentDefaultsHelpFormatter + ) + parser.add_argument( + "script", + help="Path to the rule script file, or '-' to read from stdin", + ) + parser.add_argument("--slug", help="Rule slug (optional)") + parser.add_argument("--site-id", help="Site ID") + parser.add_argument( + "--disable", action="store_true", help="Create the rule in disabled state" + ) + parser.add_argument( + "--runtime-version", + choices=["V1", "V3"], + default="V3", + help="Rule runtime version", + ) + parser.add_argument( + "--exec-interval", help="Execution interval (required for V1)" + ) + + @staticmethod + async def run(args: argparse.Namespace) -> None: + """Run the command.""" + if args.script == "-": + code = sys.stdin.read() + else: + with open(args.script, "r", encoding="utf-8") as f: + code = f.read() + + runtime_version = http.api.rule_engine.RuntimeVersion(args.runtime_version) + script = http.api.rule_engine.RuleScript( + code=code, + runtime_version=runtime_version, + exec_interval=args.exec_interval, + ) + + async with http.api.Client(http.api.Config.from_env()) as client: + rule = await client.rule_engine.create_rule( + script=script, + slug=args.slug, + site_id=args.site_id, + disable=args.disable if args.disable else None, + ) + print(json.dumps(rule.to_dto())) diff --git a/src/enapter/cli/http/api/rule_delete_command.py b/src/enapter/cli/http/api/rule_delete_command.py new file mode 100644 index 0000000..c3400fe --- /dev/null +++ b/src/enapter/cli/http/api/rule_delete_command.py @@ -0,0 +1,24 @@ +"""Rule delete command.""" + +import argparse + +from enapter import cli, http + + +class RuleDeleteCommand(cli.Command): + """Command to delete a rule.""" + + @staticmethod + def register(parent: cli.Subparsers) -> None: + """Register the command in the subparsers.""" + parser = parent.add_parser( + "delete", formatter_class=argparse.ArgumentDefaultsHelpFormatter + ) + parser.add_argument("id", help="Rule ID") + parser.add_argument("--site-id", help="Site ID") + + @staticmethod + async def run(args: argparse.Namespace) -> None: + """Run the command.""" + async with http.api.Client(http.api.Config.from_env()) as client: + await client.rule_engine.delete_rule(rule_id=args.id, site_id=args.site_id) diff --git a/src/enapter/cli/http/api/rule_disable_command.py b/src/enapter/cli/http/api/rule_disable_command.py new file mode 100644 index 0000000..2c90c50 --- /dev/null +++ b/src/enapter/cli/http/api/rule_disable_command.py @@ -0,0 +1,28 @@ +"""Rule disable command.""" + +import argparse +import json + +from enapter import cli, http + + +class RuleDisableCommand(cli.Command): + """Command to disable a rule.""" + + @staticmethod + def register(parent: cli.Subparsers) -> None: + """Register the command in the subparsers.""" + parser = parent.add_parser( + "disable", formatter_class=argparse.ArgumentDefaultsHelpFormatter + ) + parser.add_argument("id", help="Rule ID") + parser.add_argument("--site-id", help="Site ID") + + @staticmethod + async def run(args: argparse.Namespace) -> None: + """Run the command.""" + async with http.api.Client(http.api.Config.from_env()) as client: + rule = await client.rule_engine.disable_rule( + rule_id=args.id, site_id=args.site_id + ) + print(json.dumps(rule.to_dto())) diff --git a/src/enapter/cli/http/api/rule_enable_command.py b/src/enapter/cli/http/api/rule_enable_command.py new file mode 100644 index 0000000..195883f --- /dev/null +++ b/src/enapter/cli/http/api/rule_enable_command.py @@ -0,0 +1,28 @@ +"""Rule enable command.""" + +import argparse +import json + +from enapter import cli, http + + +class RuleEnableCommand(cli.Command): + """Command to enable a rule.""" + + @staticmethod + def register(parent: cli.Subparsers) -> None: + """Register the command in the subparsers.""" + parser = parent.add_parser( + "enable", formatter_class=argparse.ArgumentDefaultsHelpFormatter + ) + parser.add_argument("id", help="Rule ID") + parser.add_argument("--site-id", help="Site ID") + + @staticmethod + async def run(args: argparse.Namespace) -> None: + """Run the command.""" + async with http.api.Client(http.api.Config.from_env()) as client: + rule = await client.rule_engine.enable_rule( + rule_id=args.id, site_id=args.site_id + ) + print(json.dumps(rule.to_dto())) diff --git a/src/enapter/cli/http/api/rule_engine_command.py b/src/enapter/cli/http/api/rule_engine_command.py index e58ce20..aeb8992 100644 --- a/src/enapter/cli/http/api/rule_engine_command.py +++ b/src/enapter/cli/http/api/rule_engine_command.py @@ -4,6 +4,7 @@ from enapter import cli +from .rule_command import RuleCommand from .rule_engine_get_command import RuleEngineGetCommand from .rule_engine_resume_command import RuleEngineResumeCommand from .rule_engine_suspend_command import RuleEngineSuspendCommand @@ -23,6 +24,7 @@ def register(parent: cli.Subparsers) -> None: RuleEngineGetCommand, RuleEngineSuspendCommand, RuleEngineResumeCommand, + RuleCommand, ]: command.register(subparsers) @@ -36,5 +38,7 @@ async def run(args: argparse.Namespace) -> None: await RuleEngineSuspendCommand.run(args) case "resume": await RuleEngineResumeCommand.run(args) + case "rule": + await RuleCommand.run(args) case _: raise NotImplementedError(args.rule_engine_command) diff --git a/src/enapter/cli/http/api/rule_get_command.py b/src/enapter/cli/http/api/rule_get_command.py new file mode 100644 index 0000000..e47db6a --- /dev/null +++ b/src/enapter/cli/http/api/rule_get_command.py @@ -0,0 +1,28 @@ +"""Rule get command.""" + +import argparse +import json + +from enapter import cli, http + + +class RuleGetCommand(cli.Command): + """Command to get a single rule.""" + + @staticmethod + def register(parent: cli.Subparsers) -> None: + """Register the command in the subparsers.""" + parser = parent.add_parser( + "get", formatter_class=argparse.ArgumentDefaultsHelpFormatter + ) + parser.add_argument("id", help="Rule ID") + parser.add_argument("--site-id", help="Site ID") + + @staticmethod + async def run(args: argparse.Namespace) -> None: + """Run the command.""" + async with http.api.Client(http.api.Config.from_env()) as client: + rule = await client.rule_engine.get_rule( + rule_id=args.id, site_id=args.site_id + ) + print(json.dumps(rule.to_dto())) diff --git a/src/enapter/cli/http/api/rule_list_command.py b/src/enapter/cli/http/api/rule_list_command.py new file mode 100644 index 0000000..2419ec7 --- /dev/null +++ b/src/enapter/cli/http/api/rule_list_command.py @@ -0,0 +1,26 @@ +"""Rule list command.""" + +import argparse +import json + +from enapter import cli, http + + +class RuleListCommand(cli.Command): + """Command to list rules.""" + + @staticmethod + def register(parent: cli.Subparsers) -> None: + """Register the command in the subparsers.""" + parser = parent.add_parser( + "list", formatter_class=argparse.ArgumentDefaultsHelpFormatter + ) + parser.add_argument("--site-id", help="Site ID") + + @staticmethod + async def run(args: argparse.Namespace) -> None: + """Run the command.""" + async with http.api.Client(http.api.Config.from_env()) as client: + async with client.rule_engine.list_rules(site_id=args.site_id) as stream: + async for rule in stream: + print(json.dumps(rule.to_dto())) diff --git a/src/enapter/cli/http/api/rule_update_command.py b/src/enapter/cli/http/api/rule_update_command.py new file mode 100644 index 0000000..9948941 --- /dev/null +++ b/src/enapter/cli/http/api/rule_update_command.py @@ -0,0 +1,29 @@ +"""Rule update command.""" + +import argparse +import json + +from enapter import cli, http + + +class RuleUpdateCommand(cli.Command): + """Command to update a rule's slug.""" + + @staticmethod + def register(parent: cli.Subparsers) -> None: + """Register the command in the subparsers.""" + parser = parent.add_parser( + "update", formatter_class=argparse.ArgumentDefaultsHelpFormatter + ) + parser.add_argument("id", help="Rule ID") + parser.add_argument("--slug", required=True, help="New rule slug") + parser.add_argument("--site-id", help="Site ID") + + @staticmethod + async def run(args: argparse.Namespace) -> None: + """Run the command.""" + async with http.api.Client(http.api.Config.from_env()) as client: + rule = await client.rule_engine.update_rule( + rule_id=args.id, slug=args.slug, site_id=args.site_id + ) + print(json.dumps(rule.to_dto())) diff --git a/src/enapter/cli/http/api/rule_update_script_command.py b/src/enapter/cli/http/api/rule_update_script_command.py new file mode 100644 index 0000000..b204a3e --- /dev/null +++ b/src/enapter/cli/http/api/rule_update_script_command.py @@ -0,0 +1,55 @@ +"""Rule update script command.""" + +import argparse +import json +import sys + +from enapter import cli, http + + +class RuleUpdateScriptCommand(cli.Command): + """Command to update a rule's script.""" + + @staticmethod + def register(parent: cli.Subparsers) -> None: + """Register the command in the subparsers.""" + parser = parent.add_parser( + "update-script", formatter_class=argparse.ArgumentDefaultsHelpFormatter + ) + parser.add_argument("id", help="Rule ID") + parser.add_argument( + "script", + help="Path to the rule script file, or '-' to read from stdin", + ) + parser.add_argument("--site-id", help="Site ID") + parser.add_argument( + "--runtime-version", + choices=["V1", "V3"], + default="V3", + help="Rule runtime version", + ) + parser.add_argument( + "--exec-interval", help="Execution interval (required for V1)" + ) + + @staticmethod + async def run(args: argparse.Namespace) -> None: + """Run the command.""" + if args.script == "-": + code = sys.stdin.read() + else: + with open(args.script, "r", encoding="utf-8") as f: + code = f.read() + + runtime_version = http.api.rule_engine.RuntimeVersion(args.runtime_version) + script = http.api.rule_engine.RuleScript( + code=code, + runtime_version=runtime_version, + exec_interval=args.exec_interval, + ) + + async with http.api.Client(http.api.Config.from_env()) as client: + rule = await client.rule_engine.update_rule_script( + rule_id=args.id, script=script, site_id=args.site_id + ) + print(json.dumps(rule.to_dto())) diff --git a/src/enapter/http/api/rule_engine/__init__.py b/src/enapter/http/api/rule_engine/__init__.py index 400f433..a403b1b 100644 --- a/src/enapter/http/api/rule_engine/__init__.py +++ b/src/enapter/http/api/rule_engine/__init__.py @@ -1,5 +1,19 @@ +"""Rule Engine HTTP API client.""" + from .client import Client from .engine import Engine from .engine_state import EngineState +from .rule import Rule +from .rule_script import RuleScript +from .rule_state import RuleState +from .runtime_version import RuntimeVersion -__all__ = ["Client", "Engine", "EngineState"] +__all__ = [ + "Client", + "Engine", + "EngineState", + "Rule", + "RuleScript", + "RuleState", + "RuntimeVersion", +] diff --git a/src/enapter/http/api/rule_engine/client.py b/src/enapter/http/api/rule_engine/client.py index a528946..00677c5 100644 --- a/src/enapter/http/api/rule_engine/client.py +++ b/src/enapter/http/api/rule_engine/client.py @@ -1,18 +1,29 @@ """Rule Engine HTTP API client.""" +import time +from typing import Any, AsyncGenerator, Callable + import httpx +from enapter import async_ from enapter.http import api from .engine import Engine +from .rule import Rule +from .rule_script import RuleScript class Client: """Client for Rule Engine management.""" - def __init__(self, client: httpx.AsyncClient) -> None: + def __init__( + self, + client: httpx.AsyncClient, + rule_slug_generator: Callable[[], str] | None = None, + ) -> None: """Initialize the client.""" self._client = client + self._rule_slug_generator = rule_slug_generator or random_rule_slug async def get(self, site_id: str | None = None) -> Engine: """Get the rule engine state.""" @@ -35,8 +46,100 @@ async def resume(self, site_id: str | None = None) -> Engine: await api.check_error(response) return Engine.from_dto(response.json()["engine"]) + @async_.generator + async def list_rules( + self, site_id: str | None = None + ) -> AsyncGenerator[Rule, None]: + """List all rules.""" + url = f"{self._url(site_id)}/rules" + response = await self._client.get(url) + await api.check_error(response) + for dto in response.json()["rules"]: + yield Rule.from_dto(dto) + + async def get_rule(self, rule_id: str, site_id: str | None = None) -> Rule: + """Get a single rule.""" + url = f"{self._url(site_id)}/rules/{rule_id}" + response = await self._client.get(url) + await api.check_error(response) + return Rule.from_dto(response.json()["rule"]) + + async def create_rule( + self, + script: RuleScript, + slug: str | None = None, + site_id: str | None = None, + disable: bool | None = None, + ) -> Rule: + """Create a new rule.""" + if slug is None: + slug = self._rule_slug_generator() + + url = f"{self._url(site_id)}/rules" + payload: dict[str, Any] = { + "slug": slug, + "script": script.to_dto(), + } + if disable is not None: + payload["disable"] = disable + + response = await self._client.post(url, json=payload) + await api.check_error(response) + return Rule.from_dto(response.json()["rule"]) + + async def update_rule( + self, + rule_id: str, + slug: str, + site_id: str | None = None, + ) -> Rule: + """Update a rule's slug.""" + url = f"{self._url(site_id)}/rules/{rule_id}" + payload = {"slug": slug} + response = await self._client.patch(url, json=payload) + await api.check_error(response) + return Rule.from_dto(response.json()["rule"]) + + async def update_rule_script( + self, + rule_id: str, + script: RuleScript, + site_id: str | None = None, + ) -> Rule: + """Update a rule's script.""" + url = f"{self._url(site_id)}/rules/{rule_id}/update_script" + payload = {"script": script.to_dto()} + response = await self._client.post(url, json=payload) + await api.check_error(response) + return Rule.from_dto(response.json()["rule"]) + + async def enable_rule(self, rule_id: str, site_id: str | None = None) -> Rule: + """Enable a rule.""" + url = f"{self._url(site_id)}/rules/{rule_id}/enable" + response = await self._client.post(url) + await api.check_error(response) + return Rule.from_dto(response.json()["rule"]) + + async def disable_rule(self, rule_id: str, site_id: str | None = None) -> Rule: + """Disable a rule.""" + url = f"{self._url(site_id)}/rules/{rule_id}/disable" + response = await self._client.post(url) + await api.check_error(response) + return Rule.from_dto(response.json()["rule"]) + + async def delete_rule(self, rule_id: str, site_id: str | None = None) -> None: + """Delete a rule.""" + url = f"{self._url(site_id)}/rules/{rule_id}" + response = await self._client.delete(url) + await api.check_error(response) + def _url(self, site_id: str | None) -> str: """Construct the URL for the rule engine endpoint.""" if site_id is not None: return f"v3/sites/{site_id}/rule_engine" return "v3/site/rule_engine" + + +def random_rule_slug() -> str: + timestamp = int(time.time()) + return f"rule-{timestamp}" diff --git a/src/enapter/http/api/rule_engine/rule.py b/src/enapter/http/api/rule_engine/rule.py new file mode 100644 index 0000000..64c5735 --- /dev/null +++ b/src/enapter/http/api/rule_engine/rule.py @@ -0,0 +1,39 @@ +"""Rule data model.""" + +import dataclasses +from typing import Any, Self + +from .rule_script import RuleScript +from .rule_state import RuleState + + +@dataclasses.dataclass +class Rule: + """Rule information.""" + + id: str + slug: str + disabled: bool + state: RuleState + script: RuleScript + + @classmethod + def from_dto(cls, dto: dict[str, Any]) -> Self: + """Create a Rule from a Data Transfer Object.""" + return cls( + id=dto["id"], + slug=dto["slug"], + disabled=dto["disabled"], + state=RuleState(dto["state"]), + script=RuleScript.from_dto(dto["script"]), + ) + + def to_dto(self) -> dict[str, Any]: + """Convert the Rule to a Data Transfer Object.""" + return { + "id": self.id, + "slug": self.slug, + "disabled": self.disabled, + "state": self.state.value, + "script": self.script.to_dto(), + } diff --git a/src/enapter/http/api/rule_engine/rule_script.py b/src/enapter/http/api/rule_engine/rule_script.py new file mode 100644 index 0000000..6eef146 --- /dev/null +++ b/src/enapter/http/api/rule_engine/rule_script.py @@ -0,0 +1,37 @@ +"""Rule script data model.""" + +import base64 +import dataclasses +from typing import Any, Self + +from .runtime_version import RuntimeVersion + + +@dataclasses.dataclass +class RuleScript: + """Rule script information.""" + + code: str + runtime_version: RuntimeVersion + exec_interval: str | None = None + + @classmethod + def from_dto(cls, dto: dict[str, Any]) -> Self: + """Create a RuleScript from a Data Transfer Object.""" + code = base64.b64decode(dto["code"]).decode("utf-8") + return cls( + code=code, + runtime_version=RuntimeVersion(dto["runtime_version"]), + exec_interval=dto.get("exec_interval"), + ) + + def to_dto(self) -> dict[str, Any]: + """Convert the RuleScript to a Data Transfer Object.""" + code = base64.b64encode(self.code.encode("utf-8")).decode("utf-8") + dto = { + "code": code, + "runtime_version": self.runtime_version.value, + } + if self.exec_interval is not None: + dto["exec_interval"] = self.exec_interval + return dto diff --git a/src/enapter/http/api/rule_engine/rule_state.py b/src/enapter/http/api/rule_engine/rule_state.py new file mode 100644 index 0000000..aae9962 --- /dev/null +++ b/src/enapter/http/api/rule_engine/rule_state.py @@ -0,0 +1,10 @@ +"""Rule states.""" + +import enum + + +class RuleState(enum.Enum): + """Enumeration of rule states.""" + + STARTED = "STARTED" + STOPPED = "STOPPED" diff --git a/src/enapter/http/api/rule_engine/runtime_version.py b/src/enapter/http/api/rule_engine/runtime_version.py new file mode 100644 index 0000000..d21f100 --- /dev/null +++ b/src/enapter/http/api/rule_engine/runtime_version.py @@ -0,0 +1,10 @@ +"""Rule runtime versions.""" + +import enum + + +class RuntimeVersion(enum.Enum): + """Enumeration of rule runtime versions.""" + + V1 = "V1" + V3 = "V3" diff --git a/tests/unit/test_http/test_api/test_rule_engine/test_client.py b/tests/unit/test_http/test_api/test_rule_engine/test_client.py index 272a08b..9eedb5a 100644 --- a/tests/unit/test_http/test_api/test_rule_engine/test_client.py +++ b/tests/unit/test_http/test_api/test_rule_engine/test_client.py @@ -86,3 +86,272 @@ async def test_resume_engine(client, mock_httpx_client): assert engine.state == enapter.http.api.rule_engine.EngineState.ACTIVE mock_httpx_client.post.assert_called_once_with("v3/site/rule_engine/resume") + + +@pytest.mark.asyncio +async def test_list_rules(client, mock_httpx_client): + """Test listing rules.""" + mock_response = MagicMock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.json.return_value = { + "rules": [ + { + "id": "rule_1", + "slug": "rule-1", + "disabled": False, + "state": "STARTED", + "script": {"code": "cHJpbnQoJzEnKQ==", "runtime_version": "V3"}, + }, + { + "id": "rule_2", + "slug": "rule-2", + "disabled": True, + "state": "STOPPED", + "script": {"code": "cHJpbnQoJzInKQ==", "runtime_version": "V3"}, + }, + ] + } + mock_httpx_client.get = AsyncMock(return_value=mock_response) + + rules = [] + async with client.list_rules(site_id="site_123") as stream: + async for rule in stream: + rules.append(rule) + + assert len(rules) == 2 + assert rules[0].id == "rule_1" + assert rules[0].slug == "rule-1" + assert rules[0].state == enapter.http.api.rule_engine.RuleState.STARTED + assert rules[1].id == "rule_2" + assert rules[1].disabled is True + assert rules[1].state == enapter.http.api.rule_engine.RuleState.STOPPED + mock_httpx_client.get.assert_called_once_with("v3/sites/site_123/rule_engine/rules") + + +@pytest.mark.asyncio +async def test_get_rule(client, mock_httpx_client): + """Test getting a single rule.""" + mock_response = MagicMock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.json.return_value = { + "rule": { + "id": "rule_123", + "slug": "test-rule", + "disabled": False, + "state": "STARTED", + "script": {"code": "cHJpbnQoJ2hlbGxvJyk=", "runtime_version": "V3"}, + } + } + mock_httpx_client.get = AsyncMock(return_value=mock_response) + + rule = await client.get_rule(rule_id="rule_123", site_id="site_123") + + assert rule.id == "rule_123" + assert rule.slug == "test-rule" + assert rule.state == enapter.http.api.rule_engine.RuleState.STARTED + mock_httpx_client.get.assert_called_once_with( + "v3/sites/site_123/rule_engine/rules/rule_123" + ) + + +@pytest.mark.asyncio +async def test_create_rule(client, mock_httpx_client): + """Test creating a new rule.""" + mock_response = MagicMock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.json.return_value = { + "rule": { + "id": "rule_123", + "slug": "test-rule", + "disabled": False, + "state": "STARTED", + "script": {"code": "cHJpbnQoJ2hlbGxvJyk=", "runtime_version": "V3"}, + } + } + mock_httpx_client.post = AsyncMock(return_value=mock_response) + + script = enapter.http.api.rule_engine.RuleScript( + code="print('hello')", + runtime_version=enapter.http.api.rule_engine.RuntimeVersion.V3, + ) + + rule = await client.create_rule( + script=script, + slug="test-rule", + site_id="site_123", + ) + + assert rule.id == "rule_123" + assert rule.slug == "test-rule" + mock_httpx_client.post.assert_called_once_with( + "v3/sites/site_123/rule_engine/rules", + json={ + "slug": "test-rule", + "script": {"code": "cHJpbnQoJ2hlbGxvJyk=", "runtime_version": "V3"}, + }, + ) + + +@pytest.mark.asyncio +async def test_create_rule_automatic_slug(mock_httpx_client): + """Test creating a new rule with automatic slug generation.""" + mock_response = MagicMock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.json.return_value = { + "rule": { + "id": "rule_123", + "slug": "rule_1234567890", + "disabled": False, + "state": "STARTED", + "script": {"code": "cHJpbnQoJ2hlbGxvJyk=", "runtime_version": "V3"}, + } + } + mock_httpx_client.post = AsyncMock(return_value=mock_response) + + def rule_slug_generator(): + return "rule_1234567890" + + client = enapter.http.api.rule_engine.Client( + client=mock_httpx_client, rule_slug_generator=rule_slug_generator + ) + + script = enapter.http.api.rule_engine.RuleScript( + code="print('hello')", + runtime_version=enapter.http.api.rule_engine.RuntimeVersion.V3, + ) + + rule = await client.create_rule(script=script, site_id="site_123") + + assert rule.slug == "rule_1234567890" + mock_httpx_client.post.assert_called_once_with( + "v3/sites/site_123/rule_engine/rules", + json={ + "slug": "rule_1234567890", + "script": {"code": "cHJpbnQoJ2hlbGxvJyk=", "runtime_version": "V3"}, + }, + ) + + +@pytest.mark.asyncio +async def test_update_rule(client, mock_httpx_client): + """Test updating a rule's slug.""" + mock_response = MagicMock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.json.return_value = { + "rule": { + "id": "rule_123", + "slug": "new-slug", + "disabled": False, + "state": "STARTED", + "script": {"code": "cHJpbnQoJ2hlbGxvJyk=", "runtime_version": "V3"}, + } + } + mock_httpx_client.patch = AsyncMock(return_value=mock_response) + + rule = await client.update_rule( + rule_id="rule_123", + slug="new-slug", + site_id="site_123", + ) + + assert rule.slug == "new-slug" + mock_httpx_client.patch.assert_called_once_with( + "v3/sites/site_123/rule_engine/rules/rule_123", + json={"slug": "new-slug"}, + ) + + +@pytest.mark.asyncio +async def test_update_rule_script(client, mock_httpx_client): + """Test updating a rule's script.""" + mock_response = MagicMock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.json.return_value = { + "rule": { + "id": "rule_123", + "slug": "test-rule", + "disabled": False, + "state": "STARTED", + "script": {"code": "cHJpbnQoJ25ldycp", "runtime_version": "V3"}, + } + } + mock_httpx_client.post = AsyncMock(return_value=mock_response) + + script = enapter.http.api.rule_engine.RuleScript( + code="print('new')", + runtime_version=enapter.http.api.rule_engine.RuntimeVersion.V3, + ) + + rule = await client.update_rule_script( + rule_id="rule_123", + script=script, + site_id="site_123", + ) + + assert rule.script.code == "print('new')" + mock_httpx_client.post.assert_called_once_with( + "v3/sites/site_123/rule_engine/rules/rule_123/update_script", + json={"script": {"code": "cHJpbnQoJ25ldycp", "runtime_version": "V3"}}, + ) + + +@pytest.mark.asyncio +async def test_delete_rule(client, mock_httpx_client): + """Test deleting a rule.""" + mock_response = MagicMock(spec=httpx.Response) + mock_response.status_code = 204 + mock_httpx_client.delete = AsyncMock(return_value=mock_response) + + await client.delete_rule(rule_id="rule_123", site_id="site_123") + + mock_httpx_client.delete.assert_called_once_with( + "v3/sites/site_123/rule_engine/rules/rule_123" + ) + + +@pytest.mark.asyncio +async def test_enable_rule(client, mock_httpx_client): + """Test enabling a rule.""" + mock_response = MagicMock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.json.return_value = { + "rule": { + "id": "rule_123", + "slug": "test-rule", + "disabled": False, + "state": "STARTED", + "script": {"code": "cHJpbnQoJ2hlbGxvJyk=", "runtime_version": "V3"}, + } + } + mock_httpx_client.post = AsyncMock(return_value=mock_response) + + rule = await client.enable_rule(rule_id="rule_123", site_id="site_123") + + assert rule.disabled is False + mock_httpx_client.post.assert_called_once_with( + "v3/sites/site_123/rule_engine/rules/rule_123/enable" + ) + + +@pytest.mark.asyncio +async def test_disable_rule(client, mock_httpx_client): + """Test disabling a rule.""" + mock_response = MagicMock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.json.return_value = { + "rule": { + "id": "rule_123", + "slug": "test-rule", + "disabled": True, + "state": "STOPPED", + "script": {"code": "cHJpbnQoJ2hlbGxvJyk=", "runtime_version": "V3"}, + } + } + mock_httpx_client.post = AsyncMock(return_value=mock_response) + + rule = await client.disable_rule(rule_id="rule_123", site_id="site_123") + + assert rule.disabled is True + mock_httpx_client.post.assert_called_once_with( + "v3/sites/site_123/rule_engine/rules/rule_123/disable" + ) diff --git a/tests/unit/test_http/test_api/test_rule_engine/test_rule.py b/tests/unit/test_http/test_api/test_rule_engine/test_rule.py new file mode 100644 index 0000000..837e830 --- /dev/null +++ b/tests/unit/test_http/test_api/test_rule_engine/test_rule.py @@ -0,0 +1,78 @@ +"""Unit tests for the Rule and RuleScript data models.""" + +import enapter + + +def test_rule_script_from_dto(): + """Test creating a RuleScript from a DTO.""" + dto = {"code": "cHJpbnQoJ2hlbGxvJyk=", "runtime_version": "V3"} + script = enapter.http.api.rule_engine.RuleScript.from_dto(dto) + assert script.code == "print('hello')" + assert script.runtime_version == enapter.http.api.rule_engine.RuntimeVersion.V3 + + +def test_rule_script_to_dto(): + """Test converting a RuleScript to a DTO.""" + script = enapter.http.api.rule_engine.RuleScript( + code="print('hello')", + runtime_version=enapter.http.api.rule_engine.RuntimeVersion.V3, + ) + dto = script.to_dto() + assert dto == {"code": "cHJpbnQoJ2hlbGxvJyk=", "runtime_version": "V3"} + + +def test_rule_script_with_exec_interval(): + """Test RuleScript with exec_interval.""" + dto = { + "code": "cHJpbnQoJ3YxJyk=", + "runtime_version": "V1", + "exec_interval": "1m", + } + script = enapter.http.api.rule_engine.RuleScript.from_dto(dto) + assert script.code == "print('v1')" + assert script.runtime_version == enapter.http.api.rule_engine.RuntimeVersion.V1 + assert script.exec_interval == "1m" + + dto_back = script.to_dto() + assert dto_back == dto + + +def test_rule_from_dto(): + """Test creating a Rule from a DTO.""" + dto = { + "id": "rule_123", + "slug": "test-rule", + "disabled": False, + "state": "STARTED", + "script": {"code": "cHJpbnQoJ2hlbGxvJyk=", "runtime_version": "V3"}, + } + rule = enapter.http.api.rule_engine.Rule.from_dto(dto) + assert rule.id == "rule_123" + assert rule.slug == "test-rule" + assert rule.disabled is False + assert rule.state == enapter.http.api.rule_engine.RuleState.STARTED + assert rule.script.code == "print('hello')" + assert rule.script.runtime_version == enapter.http.api.rule_engine.RuntimeVersion.V3 + + +def test_rule_to_dto(): + """Test converting a Rule to a DTO.""" + script = enapter.http.api.rule_engine.RuleScript( + code="print('hello')", + runtime_version=enapter.http.api.rule_engine.RuntimeVersion.V3, + ) + rule = enapter.http.api.rule_engine.Rule( + id="rule_123", + slug="test-rule", + disabled=True, + state=enapter.http.api.rule_engine.RuleState.STOPPED, + script=script, + ) + dto = rule.to_dto() + assert dto == { + "id": "rule_123", + "slug": "test-rule", + "disabled": True, + "state": "STOPPED", + "script": {"code": "cHJpbnQoJ2hlbGxvJyk=", "runtime_version": "V3"}, + }