diff --git a/README.md b/README.md index 77fb9ee..1132335 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ with Enapter using Python. Install from PyPI: ```bash -pip install enapter==0.17.0 +pip install enapter==0.17.2 ``` ## Usage diff --git a/conductor/archive/rule_engine_management_20260302/index.md b/conductor/archive/rule_engine_management_20260302/index.md deleted file mode 100644 index 8c07f8a..0000000 --- a/conductor/archive/rule_engine_management_20260302/index.md +++ /dev/null @@ -1,5 +0,0 @@ -# Track rule_engine_management_20260302 Context - -- [Specification](./spec.md) -- [Implementation Plan](./plan.md) -- [Metadata](./metadata.json) diff --git a/conductor/archive/rule_engine_management_20260302/metadata.json b/conductor/archive/rule_engine_management_20260302/metadata.json deleted file mode 100644 index f54799e..0000000 --- a/conductor/archive/rule_engine_management_20260302/metadata.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "track_id": "rule_engine_management_20260302", - "type": "feature", - "status": "new", - "created_at": "2026-03-02T12:00:00Z", - "updated_at": "2026-03-02T12:00:00Z", - "description": "Implement Enapter rule-engine rule management in HTTP API client" -} diff --git a/conductor/archive/rule_engine_management_20260302/plan.md b/conductor/archive/rule_engine_management_20260302/plan.md deleted file mode 100644 index 1122d2c..0000000 --- a/conductor/archive/rule_engine_management_20260302/plan.md +++ /dev/null @@ -1,15 +0,0 @@ -# Implementation Plan: Rule Engine Management - -## Phase 1: HTTP API Client Implementation [checkpoint: 2d7be60] -- [x] Task: Create `Engine` data model in `src/enapter/http/api/rule_engine/engine.py`. fc7bab4 -- [x] Task: Implement `rule_engine.Client` with `get`, `suspend`, and `resume` methods in `src/enapter/http/api/rule_engine/client.py`. cee06dc -- [x] Task: Integrate `rule_engine` module into the main `api.Client` in `src/enapter/http/api/client.py`. 4eda240 -- [x] Task: Write unit tests for `rule_engine.Client` methods in `tests/unit/test_http/test_api/test_rule_engine/test_client.py`. cee06dc -- [x] Task: Conductor - User Manual Verification 'Phase 1: HTTP API Client' (Protocol in workflow.md) 8130b82 - -## Phase 2: CLI Implementation [checkpoint: 1f3ac00] -- [x] Task: Implement `RuleEngineGetCommand` in `src/enapter/cli/http/api/rule_engine_get_command.py`. 41551ab -- [x] Task: Implement `RuleEngineSuspendCommand` in `src/enapter/cli/http/api/rule_engine_suspend_command.py`. 41551ab -- [x] Task: Implement `RuleEngineResumeCommand` in `src/enapter/cli/http/api/rule_engine_resume_command.py`. 41551ab -- [x] Task: Create and register `RuleEngineCommand` group in `src/enapter/cli/http/api/rule_engine_command.py` and `src/enapter/cli/http/api/command.py`. 41551ab -- [x] Task: Conductor - User Manual Verification 'Phase 2: CLI Implementation' (Protocol in workflow.md) 1f3ac00 diff --git a/conductor/archive/rule_engine_management_20260302/spec.md b/conductor/archive/rule_engine_management_20260302/spec.md deleted file mode 100644 index d5c124e..0000000 --- a/conductor/archive/rule_engine_management_20260302/spec.md +++ /dev/null @@ -1,33 +0,0 @@ -# Specification: Rule Engine Management - -## Overview -Implement support for managing the Enapter Rule Engine through the HTTP API client and CLI. This includes retrieving the current state, suspending, and resuming the rule engine for a specific site. - -## Functional Requirements - -### HTTP API Client -- Add a new `rule_engine` property to the main `Client` in `src/enapter/http/api/client.py`. -- Implement a new module `src/enapter/http/api/rule_engine/` containing: - - `Engine` dataclass for representing the engine state. - - `Client` class with the following asynchronous methods: - - `get(site_id: str | None) -> Engine`: Retrieves the rule engine status. - - `suspend(site_id: str | None) -> Engine`: Suspends the rule engine. - - `resume(site_id: str | None) -> Engine`: Resumes the rule engine. -- If `site_id` is not provided, the client should use the default site endpoint (e.g., `v3/site/rule_engine`). - -### CLI -- Add a new command group `enapter api rule-engine` with the following subcommands: - - `get [--site-id SITE_ID]`: Display current rule engine status. - - `suspend [--site-id SITE_ID]`: Suspend the rule engine. - - `resume [--site-id SITE_ID]`: Resume the rule engine. - -## Non-Functional Requirements -- **Consistency:** Follow existing patterns for HTTP API modules (dataclasses with `from_dto`, async `httpx` calls). -- **Error Handling:** Use `api.check_error` for all HTTP responses. -- **Testing:** Comprehensive unit tests for the **API client** methods. **CLI command unit tests are excluded from this track.** -- **Documentation:** Proper type hints and docstrings. - -## Out of Scope -- Management of individual rules (Create, List, Update, Delete, Batch operations). -- Management of rule engine scripts. -- Telemetry related to rule engine execution. diff --git a/conductor/archive/rule_management_20260311/index.md b/conductor/archive/rule_management_20260311/index.md deleted file mode 100644 index f8671c3..0000000 --- a/conductor/archive/rule_management_20260311/index.md +++ /dev/null @@ -1,5 +0,0 @@ -# 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 deleted file mode 100644 index 1c03753..0000000 --- a/conductor/archive/rule_management_20260311/metadata.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "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 deleted file mode 100644 index 46adead..0000000 --- a/conductor/archive/rule_management_20260311/plan.md +++ /dev/null @@ -1,61 +0,0 @@ -# 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 deleted file mode 100644 index 3633f83..0000000 --- a/conductor/archive/rule_management_20260311/spec.md +++ /dev/null @@ -1,46 +0,0 @@ -# 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 deleted file mode 100644 index 9218f90..0000000 --- a/conductor/archive/rule_management_cli_20260317/index.md +++ /dev/null @@ -1,5 +0,0 @@ -# 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 deleted file mode 100644 index 89776f1..0000000 --- a/conductor/archive/rule_management_cli_20260317/metadata.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "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 deleted file mode 100644 index 138b9ec..0000000 --- a/conductor/archive/rule_management_cli_20260317/plan.md +++ /dev/null @@ -1,48 +0,0 @@ -# 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 deleted file mode 100644 index 102130d..0000000 --- a/conductor/archive/rule_management_cli_20260317/spec.md +++ /dev/null @@ -1,37 +0,0 @@ -# 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/code_styleguides/python.md b/conductor/code_styleguides/python.md deleted file mode 100644 index f141239..0000000 --- a/conductor/code_styleguides/python.md +++ /dev/null @@ -1,39 +0,0 @@ -# Python Style Guide Summary - -This document summarizes key rules and best practices used in this project. - -## 1. Python Language Rules - -- **Linting:** Run `pyflakes` and `mypy` on your code to catch bugs and style issues. -- **Imports:** Use `import x` for packages/modules. Use `from x import y` only when `y` is a subpackage. -- **Exceptions:** Use built-in exception classes. Do not use bare `except:` clauses. -- **Global State:** Avoid mutable global state. Module-level constants are okay and should be `ALL_CAPS_WITH_UNDERSCORES`. -- **Comprehensions:** Use for simple cases. Avoid for complex logic where a full loop is more readable. -- **Default Argument Values:** Do not use mutable objects (like `[]` or `{}`) as default values. -- **True/False Evaluations:** Use implicit false (e.g., `if not my_list:`). Use `if foo is None:` to check for `None`. -- **Type Annotations:** Obligatory for all code. - -## 2. Python Style Rules - -- **Line Length:** Maximum 88 characters. -- **Indentation:** 4 spaces per indentation level. Never use tabs. -- **Blank Lines:** Two blank lines between top-level definitions (classes, functions). One blank line between method definitions. Try to avoid blank lintes within functions and methods. -- **Whitespace:** Avoid extraneous whitespace. Surround binary operators with single spaces. -- **Docstrings:** Use `"""triple double quotes"""`. Every public module, function, class, and method must have a docstring. - - **Format:** Start with a one-line summary. Include `Args:`, `Returns:`, and `Raises:` sections. -- **Strings:** Use f-strings for formatting. Be consistent with single (`'`) or double (`"`) quotes. -- **`TODO` Comments:** Use `TODO(username): Fix this.` format. -- **Imports Formatting:** Imports should be on separate lines and grouped: standard library, third-party, and your own application's imports. - -## 3. Naming - -- **General:** `snake_case` for modules, functions, methods, and variables. -- **Classes:** `PascalCase`. -- **Constants:** `ALL_CAPS_WITH_UNDERSCORES`. -- **Internal Use:** Use a single leading underscore (`_internal_variable`) for internal module/class members. - -## 4. Main - -- All executable files should have a `main()` function that contains the main logic, called from a `if __name__ == '__main__':` block. - -**BE CONSISTENT.** When editing code, match the existing style. diff --git a/conductor/index.md b/conductor/index.md deleted file mode 100644 index ce6eea1..0000000 --- a/conductor/index.md +++ /dev/null @@ -1,14 +0,0 @@ -# Project Context - -## Definition -- [Product Definition](./product.md) -- [Product Guidelines](./product-guidelines.md) -- [Tech Stack](./tech-stack.md) - -## Workflow -- [Workflow](./workflow.md) -- [Code Style Guides](./code_styleguides/) - -## Management -- [Tracks Registry](./tracks.md) -- [Tracks Directory](./tracks/) diff --git a/conductor/product-guidelines.md b/conductor/product-guidelines.md deleted file mode 100644 index 02e6846..0000000 --- a/conductor/product-guidelines.md +++ /dev/null @@ -1,15 +0,0 @@ -# Product Guidelines - -## Developer Experience (DX) First -- **Intuitive & Discoverable:** The API surface should be logical and self-documenting. Use clear naming conventions for methods and classes. -- **Helpful Errors:** Raise specific, descriptive exceptions that guide developers toward a solution rather than just stating a failure. -- **Easy Onboarding:** Examples must be kept up-to-date and should provide working code snippets that developers can copy and paste. - -## Design Philosophy -- **Pythonic Style:** Strictly adhere to PEP 8 standards. The SDK should feel natural to Python developers, utilizing modern features like type hints and async/await where appropriate. -- **Sensible Defaults:** Provide default configurations that work out-of-the-box for the majority of use cases, while allowing deep customization when needed. -- **Minimal Dependencies:** Keep the dependency tree small to reduce the risk of conflicts in the consumer's environment. - -## Reliability and Stability -- **Robust Networking:** Gracefully handle transient network errors, timeouts, and API rate limits. -- **Semantic Versioning:** Clearly communicate breaking changes, new features, and bug fixes following SemVer principles to ensure predictable upgrades. diff --git a/conductor/product.md b/conductor/product.md deleted file mode 100644 index 507d83f..0000000 --- a/conductor/product.md +++ /dev/null @@ -1,17 +0,0 @@ -# Initial Concept -A software development kit (SDK) for building applications and integrations with Enapter using Python. - -## Target Audience -- Python Developers building custom applications for energy management. -- System Integrators connecting third-party systems to the Enapter platform. -- Hardware manufacturers integrating their devices into the Enapter ecosystem. - -## Core Features -- **Standalone Devices Framework:** Tools and utilities for developing standalone device integrations. -- **MQTT API Client:** Seamless communication with the Enapter MQTT broker for real-time device control and telemetry data. -- **HTTP API Client:** Easy-to-use client for interacting with the Enapter Cloud HTTP API to manage sites, devices, historical data, and the Rule Engine. - -## Key Goals -- Provide an idiomatic, modern Python 3.11+ interface for the Enapter ecosystem. -- Simplify authentication, networking, and error handling so developers can focus on business logic. -- Offer comprehensive examples and reliable asynchronous operations via tools like `aiomqtt` and `httpx`. diff --git a/conductor/setup_state.json b/conductor/setup_state.json deleted file mode 100644 index e23b6a6..0000000 --- a/conductor/setup_state.json +++ /dev/null @@ -1 +0,0 @@ -{"last_successful_step": "3.3_initial_track_generated"} \ No newline at end of file diff --git a/conductor/tech-stack.md b/conductor/tech-stack.md deleted file mode 100644 index 0f9a639..0000000 --- a/conductor/tech-stack.md +++ /dev/null @@ -1,16 +0,0 @@ -# Technology Stack - -## Core -- **Language:** Python (3.11+) - -## Networking & API Clients -- **HTTP Client:** `httpx` - Used for interactions with the Enapter Cloud HTTP API. -- **MQTT Client:** `aiomqtt` - Used for real-time device communication via the Enapter MQTT broker. -- **DNS/mDNS:** `dnspython` - Provides domain name resolution and mDNS capabilities. - -## Logging -- **Formatter:** `json-log-formatter` - Provides structured JSON logging capabilities. - -## Testing & QA -- **Testing Framework:** `pytest` - For running unit and integration tests. -- **Code Formatting:** `black` - Assumed standard for Python code formatting. diff --git a/conductor/tracks.md b/conductor/tracks.md deleted file mode 100644 index 22d3d64..0000000 --- a/conductor/tracks.md +++ /dev/null @@ -1,3 +0,0 @@ -# Project Tracks - -This file tracks all major tracks for the project. Each track has its own detailed plan in its respective folder. diff --git a/conductor/tracks/rule_management_cli_20260317/index.md b/conductor/tracks/rule_management_cli_20260317/index.md deleted file mode 100644 index 9218f90..0000000 --- a/conductor/tracks/rule_management_cli_20260317/index.md +++ /dev/null @@ -1,5 +0,0 @@ -# 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 deleted file mode 100644 index 89776f1..0000000 --- a/conductor/tracks/rule_management_cli_20260317/metadata.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "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 deleted file mode 100644 index 138b9ec..0000000 --- a/conductor/tracks/rule_management_cli_20260317/plan.md +++ /dev/null @@ -1,48 +0,0 @@ -# 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 deleted file mode 100644 index 102130d..0000000 --- a/conductor/tracks/rule_management_cli_20260317/spec.md +++ /dev/null @@ -1,37 +0,0 @@ -# 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/workflow.md b/conductor/workflow.md deleted file mode 100644 index b4a1313..0000000 --- a/conductor/workflow.md +++ /dev/null @@ -1,301 +0,0 @@ -# Project Workflow - -## Guiding Principles - -1. **The Plan is the Source of Truth:** All work must be tracked in `plan.md` -2. **The Tech Stack is Deliberate:** Changes to the tech stack must be documented in `tech-stack.md` *before* implementation -3. **Test-Driven Development:** Write unit tests before implementing functionality -4. **High Code Coverage:** Aim for >80% code coverage for all modules -5. **User Experience First:** Every decision should prioritize user experience -6. **Non-Interactive & CI-Aware:** Prefer non-interactive commands. Use `CI=true` for watch-mode tools (tests, linters) to ensure single execution. - -## Task Workflow - -All tasks follow a strict lifecycle: - -### Standard Task Workflow - -1. **Select Task:** Choose the next available task from `plan.md` in sequential order - -2. **Mark In Progress:** Before beginning work, edit `plan.md` and change the task from `[ ]` to `[~]` - -3. **Write Failing Tests (Red Phase):** - - Create a new test file for the feature or bug fix. - - Write one or more unit tests that clearly define the expected behavior and acceptance criteria for the task. - - **CRITICAL:** Run the tests and confirm that they fail as expected. This is the "Red" phase of TDD. Do not proceed until you have failing tests. - -4. **Implement to Pass Tests (Green Phase):** - - Write the minimum amount of application code necessary to make the failing tests pass. - - Run the test suite again and confirm that all tests now pass. This is the "Green" phase. - -5. **Refactor (Optional but Recommended):** - - With the safety of passing tests, refactor the implementation code and the test code to improve clarity, remove duplication, and enhance performance without changing the external behavior. - - Rerun tests to ensure they still pass after refactoring. - -6. **Verify Coverage:** Run coverage reports using the project's chosen tools. For example, in a Python project, this might look like: - ```bash - pytest --cov=app --cov-report=html - ``` - Target: >80% coverage for new code. The specific tools and commands will vary by language and framework. - -7. **Document Deviations:** If implementation differs from tech stack: - - **STOP** implementation - - Update `tech-stack.md` with new design - - Add dated note explaining the change - - Resume implementation - -8. **Stage Code Changes:** - - Stage all code changes related to the task. - - Do NOT commit yet; changes will be committed at the end of the phase. - -9. **Record Task Progress:** - - Read `plan.md`, find the line for the completed task, and update its status from `[~]` to `[x]`. - - Write the updated content back to `plan.md`. - - Stage the modified `plan.md` file. - -### Phase Completion Verification and Checkpointing Protocol - -**Trigger:** This protocol is executed immediately after a task is completed that also concludes a phase in `plan.md`. - -1. **Announce Protocol Start:** Inform the user that the phase is complete and the verification and checkpointing protocol has begun. - -2. **Ensure Test Coverage for Phase Changes:** - - **Step 2.1: Determine Phase Scope:** To identify the files changed in this phase, you must first find the starting point. Read `plan.md` to find the Git commit SHA of the *previous* phase's checkpoint. If no previous checkpoint exists, the scope is all changes since the first commit. - - **Step 2.2: List Changed Files:** Execute `git diff --name-only HEAD` to get a precise list of all files modified during this phase. - - **Step 2.3: Verify and Create Tests:** For each file in the list: - - **CRITICAL:** First, check its extension. Exclude non-code files (e.g., `.json`, `.md`, `.yaml`). - - For each remaining code file, verify a corresponding test file exists. - - If a test file is missing, you **must** create one. Before writing the test, **first, analyze other test files in the repository to determine the correct naming convention and testing style.** The new tests **must** validate the functionality described in this phase's tasks (`plan.md`). - -3. **Execute Automated Tests with Proactive Debugging:** - - Before execution, you **must** announce the exact shell command you will use to run the tests. - - **Example Announcement:** "I will now run the automated test suite to verify the phase. **Command:** `CI=true npm test`" - - Execute the announced command. - - If tests fail, you **must** inform the user and begin debugging. You may attempt to propose a fix a **maximum of two times**. If the tests still fail after your second proposed fix, you **must stop**, report the persistent failure, and ask the user for guidance. - -4. **Propose a Detailed, Actionable Manual Verification Plan:** - - **CRITICAL:** To generate the plan, first analyze `product.md`, `product-guidelines.md`, and `plan.md` to determine the user-facing goals of the completed phase. - - You **must** generate a step-by-step plan that walks the user through the verification process, including any necessary commands and specific, expected outcomes. - - The plan you present to the user **must** follow this format: - - ``` - The automated tests have passed. For manual verification, please follow these steps: - - **Manual Verification Steps:** - 1. **Ensure the server is running.** - 2. **Execute the following command in your terminal:** `curl -X POST http://localhost:8080/api/v1/users -d '{"name": "test"}'` - 3. **Confirm that you receive:** A JSON response with a status of `201 Created`. - ``` - -5. **Await Explicit User Feedback:** - - After presenting the detailed plan, ask the user for confirmation: "**Does this meet your expectations? Please confirm with yes or provide feedback on what needs to be changed.**" - - **PAUSE** and await the user's response. Do not proceed without an explicit yes or confirmation. - -6. **Create Phase Commit:** - - Stage all remaining changes. - - Perform a single commit for the entire phase. - - The commit message MUST include a detailed summary of all tasks completed in this phase, following the format: - ``` - feat/fix(): Checkpoint end of Phase - - Tasks completed: - - Task 1: Summary of changes... - - Task 2: Summary of changes... - ``` - -7. **Record Phase Checkpoint SHA:** - - **Step 7.1: Get Commit Hash:** Obtain the hash of the *just-created phase commit* (`git log -1 --format="%H"`). - - **Step 7.2: Update Plan:** Read `plan.md`, find the heading for the completed phase, and append the first 7 characters of the commit hash in the format `[checkpoint: ]`. - - **Step 7.3: Write Plan:** Write the updated content back to `plan.md`. - - **Step 7.4: Commit Plan Update:** Stage and commit the updated `plan.md` with the message `conductor(plan): Mark phase '' as complete`. - -10. **Announce Completion:** Inform the user that the phase is complete and the checkpoint has been created, with the detailed verification report attached as a git note. - -### Quality Gates - -Before marking any task complete, verify: - -- [ ] All tests pass -- [ ] Code coverage meets requirements (>80%) -- [ ] Code follows project's code style guidelines (as defined in `code_styleguides/`) -- [ ] All public functions/methods are documented (e.g., docstrings, JSDoc, GoDoc) -- [ ] Type safety is enforced (e.g., type hints, TypeScript types, Go types) -- [ ] No linting or static analysis errors (using the project's configured tools) -- [ ] Documentation updated if needed -- [ ] No security vulnerabilities introduced - -## Development Commands - -### Setup -```bash -make install-deps -``` - -### Daily Development -```bash -# Run unit tests -make test-unit - -# Run linters (black, isort, pyflakes, mypy) -make lint - -# Run integration tests -make test-integration -``` - -### Before Committing -```bash -# Run all checks (lint + all tests) -make check -``` - -## Testing Requirements - -### Unit Testing -- Every module must have corresponding tests. -- Use appropriate test setup/teardown mechanisms (e.g., fixtures, beforeEach/afterEach). -- Mock external dependencies. -- Test both success and failure cases. - -### Integration Testing -- Test complete user flows -- Verify database transactions -- Test authentication and authorization -- Check form submissions - -## Code Review Process - -### Self-Review Checklist -Before requesting review: - -1. **Functionality** - - Feature works as specified - - Edge cases handled - - Error messages are user-friendly - -2. **Code Quality** - - Follows style guide - - DRY principle applied - - Clear variable/function names - - Appropriate comments - -3. **Testing** - - Unit tests comprehensive - - Integration tests pass - - Coverage adequate (>80%) - -4. **Security** - - No hardcoded secrets - - Input validation present - - SQL injection prevented - - XSS protection in place - -5. **Performance** - - Database queries optimized - - Images optimized - - Caching implemented where needed - -6. **Mobile Experience** - - Touch targets adequate (44x44px) - - Text readable without zooming - - Performance acceptable on mobile - - Interactions feel native - -## Commit Guidelines - -### Message Format -``` -(): - -[optional body] - -[optional footer] -``` - -### Types -- `feat`: New feature -- `fix`: Bug fix -- `docs`: Documentation only -- `style`: Formatting, missing semicolons, etc. -- `refactor`: Code change that neither fixes a bug nor adds a feature -- `test`: Adding missing tests -- `chore`: Maintenance tasks - -### Examples -```bash -git commit -m "feat(auth): Add remember me functionality" -git commit -m "fix(posts): Correct excerpt generation for short posts" -git commit -m "test(comments): Add tests for emoji reaction limits" -git commit -m "style(mobile): Improve button touch targets" -``` - -## Definition of Done - -A task is complete when: - -1. All code implemented to specification -2. Unit tests written and passing -3. Code coverage meets project requirements -4. Documentation complete (if applicable) -5. Code passes all configured linting and static analysis checks -6. Implementation notes added to `plan.md` -7. Changes committed with proper message -8. Task summary included in the commit message - -## Emergency Procedures - -### Critical Bug in Production -1. Create hotfix branch from main -2. Write failing test for bug -3. Implement minimal fix -4. Test thoroughly including mobile -5. Deploy immediately -6. Document in plan.md - -### Data Loss -1. Stop all write operations -2. Restore from latest backup -3. Verify data integrity -4. Document incident -5. Update backup procedures - -### Security Breach -1. Rotate all secrets immediately -2. Review access logs -3. Patch vulnerability -4. Notify affected users (if any) -5. Document and update security procedures - -## Deployment Workflow - -### Pre-Deployment Checklist -- [ ] All tests passing -- [ ] Coverage >80% -- [ ] No linting errors -- [ ] Mobile testing complete -- [ ] Environment variables configured -- [ ] Database migrations ready -- [ ] Backup created - -### Deployment Steps -1. Merge feature branch to main -2. Tag release with version -3. Push to deployment service -4. Run database migrations -5. Verify deployment -6. Test critical paths -7. Monitor for errors - -### Post-Deployment -1. Monitor analytics -2. Check error logs -3. Gather user feedback -4. Plan next iteration - -## Continuous Improvement - -- Review workflow weekly -- Update based on pain points -- Document lessons learned -- Optimize for user happiness -- Keep things simple and maintainable diff --git a/examples/standalone/mi-fan-1c/requirements.txt b/examples/standalone/mi-fan-1c/requirements.txt index dd935da..3db59fb 100644 --- a/examples/standalone/mi-fan-1c/requirements.txt +++ b/examples/standalone/mi-fan-1c/requirements.txt @@ -1,2 +1,2 @@ -enapter==0.17.0 +enapter==0.17.2 python-miio==0.5.12 diff --git a/examples/standalone/psutil-battery/requirements.txt b/examples/standalone/psutil-battery/requirements.txt index 04e4cdb..bec99c7 100644 --- a/examples/standalone/psutil-battery/requirements.txt +++ b/examples/standalone/psutil-battery/requirements.txt @@ -1,2 +1,2 @@ -enapter==0.17.0 +enapter==0.17.2 psutil==7.1.2 diff --git a/examples/standalone/rl6-simulator/requirements.txt b/examples/standalone/rl6-simulator/requirements.txt index a50961e..709d57d 100644 --- a/examples/standalone/rl6-simulator/requirements.txt +++ b/examples/standalone/rl6-simulator/requirements.txt @@ -1 +1 @@ -enapter==0.17.0 +enapter==0.17.2 diff --git a/examples/standalone/snmp-eaton-ups/requirements.txt b/examples/standalone/snmp-eaton-ups/requirements.txt index 7199212..88c2da1 100644 --- a/examples/standalone/snmp-eaton-ups/requirements.txt +++ b/examples/standalone/snmp-eaton-ups/requirements.txt @@ -1,3 +1,3 @@ -enapter==0.17.0 +enapter==0.17.2 pysnmp==4.4.12 pyasn1<=0.4.8 diff --git a/examples/standalone/wttr-in/requirements.txt b/examples/standalone/wttr-in/requirements.txt index ad0615c..92826fd 100644 --- a/examples/standalone/wttr-in/requirements.txt +++ b/examples/standalone/wttr-in/requirements.txt @@ -1,2 +1,2 @@ -enapter==0.17.0 +enapter==0.17.2 python-weather==2.1.0 diff --git a/examples/standalone/zigbee2mqtt/requirements.txt b/examples/standalone/zigbee2mqtt/requirements.txt index a50961e..709d57d 100644 --- a/examples/standalone/zigbee2mqtt/requirements.txt +++ b/examples/standalone/zigbee2mqtt/requirements.txt @@ -1 +1 @@ -enapter==0.17.0 +enapter==0.17.2 diff --git a/src/enapter/__init__.py b/src/enapter/__init__.py index 4870ce0..fd06765 100644 --- a/src/enapter/__init__.py +++ b/src/enapter/__init__.py @@ -1,4 +1,4 @@ -__version__ = "0.17.0" +__version__ = "0.17.2" from . import async_, log, mdns, mqtt, http, standalone # isort: skip diff --git a/src/enapter/cli/http/api/device_get_command.py b/src/enapter/cli/http/api/device_get_command.py index d993f22..5d45b9f 100644 --- a/src/enapter/cli/http/api/device_get_command.py +++ b/src/enapter/cli/http/api/device_get_command.py @@ -38,6 +38,12 @@ def register(parent: cli.Subparsers) -> None: action="store_true", help="Expand device communication information", ) + parser.add_argument( + "-a", + "--raised-alert-names", + action="store_true", + help="Expand device raised alert names information", + ) @staticmethod async def run(args: argparse.Namespace) -> None: @@ -48,5 +54,6 @@ async def run(args: argparse.Namespace) -> None: expand_properties=args.properties, expand_connectivity=args.connectivity, expand_communication=args.communication, + expand_raised_alert_names=args.raised_alert_names, ) print(json.dumps(device.to_dto())) diff --git a/src/enapter/cli/http/api/device_list_command.py b/src/enapter/cli/http/api/device_list_command.py index 46e2071..68d2a24 100644 --- a/src/enapter/cli/http/api/device_list_command.py +++ b/src/enapter/cli/http/api/device_list_command.py @@ -37,10 +37,17 @@ def register(parent: cli.Subparsers) -> None: help="Expand device connectivity information", ) parser.add_argument( + "-u", "--communication", action="store_true", help="Expand device communication information", ) + parser.add_argument( + "-a", + "--raised-alert-names", + action="store_true", + help="Expand device raised alert names information", + ) parser.add_argument("-s", "--site-id", help="Filter devices by site ID") @staticmethod @@ -53,6 +60,7 @@ async def run(args: argparse.Namespace) -> None: expand_properties=args.properties, expand_connectivity=args.connectivity, expand_communication=args.communication, + expand_raised_alert_names=args.raised_alert_names, site_id=args.site_id, ) as stream: count = 0 diff --git a/src/enapter/cli/http/api/site_location.py b/src/enapter/cli/http/api/site_location.py index 9504cbf..54a6304 100644 --- a/src/enapter/cli/http/api/site_location.py +++ b/src/enapter/cli/http/api/site_location.py @@ -4,7 +4,7 @@ def parse_site_location(location_str: str) -> tuple[str, float, float]: try: name, lat_str, lon_str = location_str.split(",") - return name, float(lat_str), float(lon_str) + return name.strip(), float(lat_str), float(lon_str) except ValueError: raise argparse.ArgumentTypeError( "Location must be in the format NAME,LATITUDE,LONGITUDE" diff --git a/src/enapter/http/api/devices/client.py b/src/enapter/http/api/devices/client.py index d097e03..16cecbd 100644 --- a/src/enapter/http/api/devices/client.py +++ b/src/enapter/http/api/devices/client.py @@ -1,4 +1,4 @@ -import random +import secrets import time from typing import AsyncGenerator @@ -81,6 +81,7 @@ async def get( expand_properties: bool = False, expand_connectivity: bool = False, expand_communication: bool = False, + expand_raised_alert_names: bool = False, ) -> Device: url = f"v3/devices/{device_id}" expand = { @@ -88,6 +89,7 @@ async def get( "properties": expand_properties, "connectivity": expand_connectivity, "communication": expand_communication, + "raised_alert_names": expand_raised_alert_names, } expand_string = ",".join(k for k, v in expand.items() if v) response = await self._client.get(url, params={"expand": expand_string}) @@ -101,6 +103,7 @@ async def list( expand_properties: bool = False, expand_connectivity: bool = False, expand_communication: bool = False, + expand_raised_alert_names: bool = False, site_id: str | None = None, ) -> AsyncGenerator[Device, None]: url = "v3/devices" if site_id is None else f"v3/sites/{site_id}/devices" @@ -109,6 +112,7 @@ async def list( "properties": expand_properties, "connectivity": expand_connectivity, "communication": expand_communication, + "raised_alert_names": expand_raised_alert_names, } expand_string = ",".join(k for k, v in expand.items() if v) limit = 50 @@ -161,4 +165,4 @@ def random_device_name(device_type: DeviceType) -> str: def random_hardware_id() -> str: - return "V" + "".join(f"{b:02X}" for b in random.randbytes(16)) + return "V" + secrets.token_hex(16).upper() diff --git a/src/enapter/http/api/devices/device.py b/src/enapter/http/api/devices/device.py index 801c3c1..fcca049 100644 --- a/src/enapter/http/api/devices/device.py +++ b/src/enapter/http/api/devices/device.py @@ -23,6 +23,7 @@ class Device: properties: dict[str, Any] | None = None connectivity: DeviceConnectivity | None = None communication: DeviceCommunication | None = None + raised_alert_names: list[str] | None = None @classmethod def from_dto(cls, dto: dict[str, Any]) -> Self: @@ -47,6 +48,7 @@ def from_dto(cls, dto: dict[str, Any]) -> Self: if dto.get("communication") is not None else None ), + raised_alert_names=dto.get("raised_alert_names"), ) def to_dto(self) -> dict[str, Any]: @@ -67,4 +69,5 @@ def to_dto(self) -> dict[str, Any]: "communication": ( self.communication.to_dto() if self.communication is not None else None ), + "raised_alert_names": self.raised_alert_names, } diff --git a/src/enapter/http/api/devices/device_type.py b/src/enapter/http/api/devices/device_type.py index 0cd17f3..a30220d 100644 --- a/src/enapter/http/api/devices/device_type.py +++ b/src/enapter/http/api/devices/device_type.py @@ -12,3 +12,4 @@ class DeviceType(enum.Enum): LINK_SLAVE_UCM = "LINK_SLAVE_UCM" EMBEDDED_UCM = "EMBEDDED_UCM" NATIVE = "NATIVE" + CHILD = "CHILD" diff --git a/tests/unit/test_async/test_generator.py b/tests/unit/test_async/test_generator.py index 29fe070..ed4e3ba 100644 --- a/tests/unit/test_async/test_generator.py +++ b/tests/unit/test_async/test_generator.py @@ -16,3 +16,46 @@ async def agen(): with pytest.raises(StopAsyncIteration): await g.__anext__() + + +async def test_generator_closes_on_exception_in_context() -> None: + closed = False + + @enapter.async_.generator + async def agen(): + nonlocal closed + try: + yield 1 + yield 2 # pragma: no cover + finally: + closed = True + + with pytest.raises(ValueError, match="Test error"): + async with agen() as g: + assert await g.__anext__() == 1 + raise ValueError("Test error") + + assert closed + + +async def test_generator_raises_exception() -> None: + @enapter.async_.generator + async def agen(): + yield 1 + raise ValueError("Generator error") + + async with agen() as g: + assert await g.__anext__() == 1 + with pytest.raises(ValueError, match="Generator error"): + await g.__anext__() + + +async def test_generator_with_args() -> None: + @enapter.async_.generator + async def agen(a: int, b: int = 0): + yield a + yield b + + async with agen(10, b=20) as g: + assert await g.__anext__() == 10 + assert await g.__anext__() == 20 diff --git a/tests/unit/test_cli/test_http/test_api/test_site_location.py b/tests/unit/test_cli/test_http/test_api/test_site_location.py new file mode 100644 index 0000000..b737e0c --- /dev/null +++ b/tests/unit/test_cli/test_http/test_api/test_site_location.py @@ -0,0 +1,58 @@ +import argparse + +import pytest + +from enapter.cli.http.api.site_location import parse_site_location + + +def test_parse_site_location_valid(): + assert parse_site_location("Berlin,52.52,13.405") == ("Berlin", 52.52, 13.405) + + +def test_parse_site_location_with_spaces(): + # Note: name strips whitespace, float() handles surrounding whitespace + assert parse_site_location(" Berlin , 52.52 , 13.405 ") == ( + "Berlin", + 52.52, + 13.405, + ) + + +def test_parse_site_location_too_few_parts(): + with pytest.raises(argparse.ArgumentTypeError) as exc_info: + parse_site_location("Berlin,52.52") + assert "Location must be in the format NAME,LATITUDE,LONGITUDE" in str( + exc_info.value + ) + + +def test_parse_site_location_too_many_parts(): + with pytest.raises(argparse.ArgumentTypeError) as exc_info: + parse_site_location("Berlin,52.52,13.405,extra") + assert "Location must be in the format NAME,LATITUDE,LONGITUDE" in str( + exc_info.value + ) + + +def test_parse_site_location_invalid_latitude(): + with pytest.raises(argparse.ArgumentTypeError) as exc_info: + parse_site_location("Berlin,invalid,13.405") + assert "Location must be in the format NAME,LATITUDE,LONGITUDE" in str( + exc_info.value + ) + + +def test_parse_site_location_invalid_longitude(): + with pytest.raises(argparse.ArgumentTypeError) as exc_info: + parse_site_location("Berlin,52.52,invalid") + assert "Location must be in the format NAME,LATITUDE,LONGITUDE" in str( + exc_info.value + ) + + +def test_parse_site_location_empty(): + with pytest.raises(argparse.ArgumentTypeError) as exc_info: + parse_site_location("") + assert "Location must be in the format NAME,LATITUDE,LONGITUDE" in str( + exc_info.value + ) diff --git a/tests/unit/test_http/test_api/test_blueprints/__init__.py b/tests/unit/test_http/test_api/test_blueprints/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/test_http/test_api/test_blueprints/test_blueprint.py b/tests/unit/test_http/test_api/test_blueprints/test_blueprint.py new file mode 100644 index 0000000..46196c2 --- /dev/null +++ b/tests/unit/test_http/test_api/test_blueprints/test_blueprint.py @@ -0,0 +1,34 @@ +import datetime + +from enapter.http.api.blueprints.blueprint import Blueprint + + +def test_blueprint_from_dto(): + dto = { + "id": "bp_123", + "created_at": "2023-01-01T12:00:00+00:00", + } + blueprint = Blueprint.from_dto(dto) + assert blueprint.id == "bp_123" + assert blueprint.created_at == datetime.datetime( + 2023, 1, 1, 12, 0, 0, tzinfo=datetime.timezone.utc + ) + + +def test_blueprint_to_dto(): + created_at = datetime.datetime(2023, 1, 1, 12, 0, 0, tzinfo=datetime.timezone.utc) + blueprint = Blueprint(id="bp_123", created_at=created_at) + dto = blueprint.to_dto() + assert dto == { + "id": "bp_123", + "created_at": "2023-01-01T12:00:00+00:00", + } + + +def test_blueprint_roundtrip(): + dto = { + "id": "bp_123", + "created_at": "2023-01-01T12:00:00+00:00", + } + blueprint = Blueprint.from_dto(dto) + assert blueprint.to_dto() == dto diff --git a/tests/unit/test_http/test_api/test_blueprints/test_client.py b/tests/unit/test_http/test_api/test_blueprints/test_client.py new file mode 100644 index 0000000..2fe7968 --- /dev/null +++ b/tests/unit/test_http/test_api/test_blueprints/test_client.py @@ -0,0 +1,221 @@ +"""Unit tests for the Blueprints HTTP API client.""" + +import io +import zipfile +from unittest.mock import AsyncMock, MagicMock + +import httpx +import pytest + +import enapter +from enapter.http.api.blueprints.blueprint import BlueprintView + + +@pytest.fixture +def mock_client(): + """Fixture to provide a mocked httpx.AsyncClient.""" + return MagicMock(spec=httpx.AsyncClient) + + +@pytest.fixture +def blueprints_client(mock_client): + """Fixture to provide a Blueprints API client with a mocked internal client.""" + return enapter.http.api.blueprints.Client(client=mock_client) + + +@pytest.mark.asyncio +async def test_get_blueprint(blueprints_client, mock_client): + """Test getting a specific blueprint by ID.""" + mock_response = MagicMock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.json.return_value = { + "blueprint": { + "id": "bp_123", + "created_at": "2023-01-01T12:00:00+00:00", + } + } + mock_client.get = AsyncMock(return_value=mock_response) + + blueprint = await blueprints_client.get(blueprint_id="bp_123") + + assert blueprint.id == "bp_123" + mock_client.get.assert_called_once_with("v3/blueprints/bp_123") + + +@pytest.mark.asyncio +async def test_upload_blueprint(blueprints_client, mock_client): + """Test uploading a blueprint from bytes.""" + mock_response = MagicMock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.json.return_value = { + "blueprint": { + "id": "bp_123", + "created_at": "2023-01-01T12:00:00+00:00", + } + } + mock_client.post = AsyncMock(return_value=mock_response) + + data = b"test blueprint data" + blueprint = await blueprints_client.upload(data=data) + + assert blueprint.id == "bp_123" + mock_client.post.assert_called_once_with("v3/blueprints/upload", content=data) + + +@pytest.mark.asyncio +async def test_upload_file(blueprints_client, mock_client, tmp_path): + """Test uploading a blueprint from a file.""" + mock_response = MagicMock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.json.return_value = { + "blueprint": { + "id": "bp_123", + "created_at": "2023-01-01T12:00:00+00:00", + } + } + mock_client.post = AsyncMock(return_value=mock_response) + + bp_file = tmp_path / "blueprint.zip" + data = b"test blueprint file data" + bp_file.write_bytes(data) + + blueprint = await blueprints_client.upload_file(path=bp_file) + + assert blueprint.id == "bp_123" + mock_client.post.assert_called_once_with("v3/blueprints/upload", content=data) + + +@pytest.mark.asyncio +async def test_upload_directory(blueprints_client, mock_client, tmp_path): + """Test uploading a blueprint from a directory.""" + mock_response = MagicMock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.json.return_value = { + "blueprint": { + "id": "bp_123", + "created_at": "2023-01-01T12:00:00+00:00", + } + } + mock_client.post = AsyncMock(return_value=mock_response) + + bp_dir = tmp_path / "blueprint_dir" + bp_dir.mkdir() + (bp_dir / "main.lua").write_text("print('hello')") + + blueprint = await blueprints_client.upload_directory(path=bp_dir) + + assert blueprint.id == "bp_123" + mock_client.post.assert_called_once() + args, kwargs = mock_client.post.call_args + assert args[0] == "v3/blueprints/upload" + + zipped_content = kwargs["content"] + with zipfile.ZipFile(io.BytesIO(zipped_content)) as zf: + assert "main.lua" in zf.namelist() + assert zf.read("main.lua") == b"print('hello')" + + +@pytest.mark.asyncio +async def test_download_blueprint(blueprints_client, mock_client): + """Test downloading a blueprint.""" + mock_response = MagicMock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.content = b"blueprint zip content" + mock_client.get = AsyncMock(return_value=mock_response) + + content = await blueprints_client.download(blueprint_id="bp_123") + + assert content == b"blueprint zip content" + mock_client.get.assert_called_once_with( + "v3/blueprints/bp_123/zip", params={"view": "ORIGINAL"} + ) + + +@pytest.mark.asyncio +async def test_download_blueprint_compiled(blueprints_client, mock_client): + """Test downloading a compiled blueprint.""" + mock_response = MagicMock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.content = b"compiled blueprint zip content" + mock_client.get = AsyncMock(return_value=mock_response) + + content = await blueprints_client.download( + blueprint_id="bp_123", view=BlueprintView.COMPILED + ) + + assert content == b"compiled blueprint zip content" + mock_client.get.assert_called_once_with( + "v3/blueprints/bp_123/zip", params={"view": "COMPILED"} + ) + + +@pytest.mark.asyncio +async def test_validate_blueprint(blueprints_client, mock_client): + """Test validating a blueprint from bytes.""" + mock_response = MagicMock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.json.return_value = {} + mock_client.post = AsyncMock(return_value=mock_response) + + data = b"test blueprint data" + await blueprints_client.validate(data=data) + + mock_client.post.assert_called_once_with("v3/blueprints/validate", content=data) + + +@pytest.mark.asyncio +async def test_validate_blueprint_with_errors(blueprints_client, mock_client): + """Test validating a blueprint with errors.""" + mock_response = MagicMock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.json.return_value = {"validation_errors": ["Error 1", "Error 2"]} + mock_client.post = AsyncMock(return_value=mock_response) + + data = b"invalid blueprint data" + with pytest.raises(enapter.http.api.MultiError) as excinfo: + await blueprints_client.validate(data=data) + + assert len(excinfo.value.errors) == 2 + assert excinfo.value.errors[0].message == "Error 1" + assert excinfo.value.errors[1].message == "Error 2" + + +@pytest.mark.asyncio +async def test_validate_file(blueprints_client, mock_client, tmp_path): + """Test validating a blueprint from a file.""" + mock_response = MagicMock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.json.return_value = {} + mock_client.post = AsyncMock(return_value=mock_response) + + bp_file = tmp_path / "blueprint.zip" + data = b"test blueprint file data" + bp_file.write_bytes(data) + + await blueprints_client.validate_file(path=bp_file) + + mock_client.post.assert_called_once_with("v3/blueprints/validate", content=data) + + +@pytest.mark.asyncio +async def test_validate_directory(blueprints_client, mock_client, tmp_path): + """Test validating a blueprint from a directory.""" + mock_response = MagicMock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.json.return_value = {} + mock_client.post = AsyncMock(return_value=mock_response) + + bp_dir = tmp_path / "blueprint_dir" + bp_dir.mkdir() + (bp_dir / "main.lua").write_text("print('hello')") + + await blueprints_client.validate_directory(path=bp_dir) + + mock_client.post.assert_called_once() + args, kwargs = mock_client.post.call_args + assert args[0] == "v3/blueprints/validate" + + zipped_content = kwargs["content"] + with zipfile.ZipFile(io.BytesIO(zipped_content)) as zf: + assert "main.lua" in zf.namelist() + assert zf.read("main.lua") == b"print('hello')" diff --git a/tests/unit/test_http/test_api/test_devices/__init__.py b/tests/unit/test_http/test_api/test_devices/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/test_http/test_api/test_devices/test_client.py b/tests/unit/test_http/test_api/test_devices/test_client.py new file mode 100644 index 0000000..bd6bc9a --- /dev/null +++ b/tests/unit/test_http/test_api/test_devices/test_client.py @@ -0,0 +1,155 @@ +"""Unit tests for the Devices HTTP API client.""" + +import datetime +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +import httpx +import pytest + +import enapter + + +@pytest.fixture +def mock_client(): + """Fixture to provide a mocked httpx.AsyncClient.""" + return MagicMock(spec=httpx.AsyncClient) + + +@pytest.fixture +def devices_client(mock_client): + """Fixture to provide a Devices API client with a mocked internal client.""" + return enapter.http.api.devices.Client(client=mock_client) + + +def create_mock_device_dto( + device_id: str, raised_alert_names: list[str] | None = None +) -> dict[str, Any]: + dto: dict[str, Any] = { + "id": device_id, + "blueprint_id": "bp_123", + "name": f"Device {device_id}", + "site_id": "site_123", + "updated_at": "2023-10-10T10:00:00Z", + "slug": f"slug-{device_id}", + "type": "STANDALONE", + "authorized_role": "OWNER", + } + if raised_alert_names is not None: + dto["raised_alert_names"] = raised_alert_names + return dto + + +@pytest.mark.asyncio +async def test_get_device(devices_client, mock_client): + """Test getting a specific device without expand.""" + mock_response = MagicMock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.json.return_value = {"device": create_mock_device_dto("dev_1")} + mock_client.get = AsyncMock(return_value=mock_response) + + device = await devices_client.get(device_id="dev_1") + + assert device.id == "dev_1" + assert device.raised_alert_names is None + mock_client.get.assert_called_once_with("v3/devices/dev_1", params={"expand": ""}) + + +@pytest.mark.asyncio +async def test_get_device_expand_raised_alert_names(devices_client, mock_client): + """Test getting a specific device with expand_raised_alert_names.""" + mock_response = MagicMock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.json.return_value = { + "device": create_mock_device_dto( + "dev_2", raised_alert_names=["alert_1", "alert_2"] + ) + } + mock_client.get = AsyncMock(return_value=mock_response) + + device = await devices_client.get(device_id="dev_2", expand_raised_alert_names=True) + + assert device.id == "dev_2" + assert device.raised_alert_names == ["alert_1", "alert_2"] + mock_client.get.assert_called_once_with( + "v3/devices/dev_2", params={"expand": "raised_alert_names"} + ) + + +@pytest.mark.asyncio +async def test_list_devices(devices_client, mock_client): + """Test listing devices without expand.""" + mock_response_1 = MagicMock(spec=httpx.Response) + mock_response_1.status_code = 200 + mock_response_1.json.return_value = { + "devices": [create_mock_device_dto("dev_1"), create_mock_device_dto("dev_2")] + } + + mock_response_2 = MagicMock(spec=httpx.Response) + mock_response_2.status_code = 200 + mock_response_2.json.return_value = {"devices": []} + + mock_client.get = AsyncMock(side_effect=[mock_response_1, mock_response_2]) + + devices = [] + async with devices_client.list() as d_gen: + async for d in d_gen: + devices.append(d) + + assert len(devices) == 2 + assert devices[0].id == "dev_1" + assert devices[1].id == "dev_2" + mock_client.get.assert_any_call( + "v3/devices", params={"expand": "", "limit": 50, "offset": 0} + ) + + +@pytest.mark.asyncio +async def test_list_devices_expand_raised_alert_names(devices_client, mock_client): + """Test listing devices with expand_raised_alert_names.""" + mock_response_1 = MagicMock(spec=httpx.Response) + mock_response_1.status_code = 200 + mock_response_1.json.return_value = { + "devices": [ + create_mock_device_dto("dev_1", raised_alert_names=["alert_1"]), + ] + } + + mock_response_2 = MagicMock(spec=httpx.Response) + mock_response_2.status_code = 200 + mock_response_2.json.return_value = {"devices": []} + + mock_client.get = AsyncMock(side_effect=[mock_response_1, mock_response_2]) + + devices = [] + async with devices_client.list(expand_raised_alert_names=True) as d_gen: + async for d in d_gen: + devices.append(d) + + assert len(devices) == 1 + assert devices[0].id == "dev_1" + assert devices[0].raised_alert_names == ["alert_1"] + mock_client.get.assert_any_call( + "v3/devices", params={"expand": "raised_alert_names", "limit": 50, "offset": 0} + ) + + +def test_device_to_dto(): + device = enapter.http.api.devices.Device( + id="dev_1", + blueprint_id="bp_1", + name="Device 1", + site_id="site_1", + updated_at=datetime.datetime.fromisoformat("2023-10-10T10:00:00+00:00"), + slug="dev-1", + type=enapter.http.api.devices.DeviceType.STANDALONE, + authorized_role=enapter.http.api.devices.AuthorizedRole.OWNER, + raised_alert_names=["alert_1"], + ) + + dto = device.to_dto() + assert dto["id"] == "dev_1" + assert dto["updated_at"] == "2023-10-10T10:00:00+00:00" + assert dto["type"] == "STANDALONE" + assert dto["authorized_role"] == "OWNER" + assert dto["raised_alert_names"] == ["alert_1"] diff --git a/tests/unit/test_http/test_api/test_errors.py b/tests/unit/test_http/test_api/test_errors.py new file mode 100644 index 0000000..72114bb --- /dev/null +++ b/tests/unit/test_http/test_api/test_errors.py @@ -0,0 +1,99 @@ +from unittest.mock import AsyncMock + +import httpx +import pytest + +from enapter.http.api.errors import Error, MultiError, check_error + + +@pytest.mark.asyncio +async def test_check_error_success(): + """Test that check_error does nothing when response is successful.""" + response = httpx.Response( + status_code=200, request=httpx.Request("GET", "https://example.com") + ) + await check_error(response) + + +@pytest.mark.asyncio +async def test_check_error_non_json(): + """Test that check_error calls raise_for_status when response is not JSON.""" + response = httpx.Response( + status_code=500, + content=b"Internal Server Error", + request=httpx.Request("GET", "https://example.com"), + ) + with pytest.raises(httpx.HTTPStatusError): + await check_error(response) + + +@pytest.mark.asyncio +async def test_check_error_api_error_single(): + """Test that check_error raises a single Error when one is present in DTO.""" + dto = { + "errors": [ + { + "message": "Device not found", + "code": "DEVICE_NOT_FOUND", + "details": {"device_id": "test_device"}, + } + ] + } + response = httpx.Response( + status_code=404, json=dto, request=httpx.Request("GET", "https://example.com") + ) + with pytest.raises(Error) as exc_info: + await check_error(response) + + assert exc_info.value.message == "Device not found" + assert exc_info.value.code == "DEVICE_NOT_FOUND" + assert exc_info.value.details == {"device_id": "test_device"} + + +@pytest.mark.asyncio +async def test_check_error_api_error_multiple(): + """Test that check_error raises MultiError when multiple are present in DTO.""" + dto = { + "errors": [ + {"message": "Invalid parameter A", "code": "INVALID_PARAM"}, + {"message": "Invalid parameter B", "code": "INVALID_PARAM"}, + ] + } + response = httpx.Response( + status_code=400, json=dto, request=httpx.Request("GET", "https://example.com") + ) + with pytest.raises(MultiError) as exc_info: + await check_error(response) + + assert len(exc_info.value.errors) == 2 + assert exc_info.value.errors[0].message == "Invalid parameter A" + assert exc_info.value.errors[1].message == "Invalid parameter B" + + +def test_error_from_dto_empty(): + """Test Error.from_dto with minimal fields.""" + err = Error.from_dto({}) + assert err.message == "" + assert err.code is None + assert err.details is None + + +def test_multi_error_from_dto_empty_list(): + """Test MultiError.from_dto raises ValueError on empty error list.""" + with pytest.raises(ValueError, match="empty error list"): + MultiError.from_dto({"errors": []}) + + +@pytest.mark.asyncio +async def test_check_error_reads_body(): + """Test that check_error reads the response body.""" + response = httpx.Response( + status_code=500, + content=b"Internal Server Error", + request=httpx.Request("GET", "https://example.com"), + ) + response.aread = AsyncMock() + with pytest.raises(httpx.HTTPStatusError): + await check_error(response) + + response.aread.assert_awaited_once() diff --git a/tests/unit/test_http/test_api/test_telemetry/__init__.py b/tests/unit/test_http/test_api/test_telemetry/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/test_http/test_api/test_telemetry/test_data_type.py b/tests/unit/test_http/test_api/test_telemetry/test_data_type.py new file mode 100644 index 0000000..b8af4ae --- /dev/null +++ b/tests/unit/test_http/test_api/test_telemetry/test_data_type.py @@ -0,0 +1,46 @@ +import pytest + +from enapter.http.api.telemetry.data_type import DataType + + +def test_parse_value_empty() -> None: + assert DataType.FLOAT.parse_value("") is None + assert DataType.INTEGER.parse_value("") is None + assert DataType.STRING.parse_value("") is None + assert DataType.STRING_ARRAY.parse_value("") is None + assert DataType.BOOLEAN.parse_value("") is None + + +def test_parse_value_float() -> None: + assert DataType.FLOAT.parse_value("1.5") == 1.5 + assert DataType.FLOAT.parse_value("-3.14") == -3.14 + with pytest.raises(ValueError): + DataType.FLOAT.parse_value("invalid") + + +def test_parse_value_integer() -> None: + assert DataType.INTEGER.parse_value("42") == 42 + assert DataType.INTEGER.parse_value("-7") == -7 + with pytest.raises(ValueError): + DataType.INTEGER.parse_value("invalid") + + +def test_parse_value_string() -> None: + assert DataType.STRING.parse_value("hello") == "hello" + + +def test_parse_value_string_array() -> None: + assert DataType.STRING_ARRAY.parse_value('["a", "b"]') == ["a", "b"] + with pytest.raises(ValueError): + DataType.STRING_ARRAY.parse_value("invalid") + + +def test_parse_value_boolean() -> None: + assert DataType.BOOLEAN.parse_value("True") is True + assert DataType.BOOLEAN.parse_value("true") is True + assert DataType.BOOLEAN.parse_value("1") is True + assert DataType.BOOLEAN.parse_value("False") is False + assert DataType.BOOLEAN.parse_value("false") is False + assert DataType.BOOLEAN.parse_value("0") is False + with pytest.raises(ValueError, match="invalid boolean value"): + DataType.BOOLEAN.parse_value("invalid") diff --git a/tests/unit/test_http/test_api/test_telemetry/test_labels.py b/tests/unit/test_http/test_api/test_telemetry/test_labels.py new file mode 100644 index 0000000..04abb22 --- /dev/null +++ b/tests/unit/test_http/test_api/test_telemetry/test_labels.py @@ -0,0 +1,16 @@ +from enapter.http.api.telemetry.labels import Labels + + +def test_parse_multiple_labels(): + s = "device=foo telemetry=bar custom=baz" + labels = Labels.parse(s) + assert labels == {"device": "foo", "telemetry": "bar", "custom": "baz"} + assert labels.device == "foo" + assert labels.telemetry == "bar" + + +def test_parse_single_label(): + s = "device=only" + labels = Labels.parse(s) + assert labels == {"device": "only"} + assert labels.device == "only" diff --git a/tests/unit/test_log/test_init.py b/tests/unit/test_log/test_init.py new file mode 100644 index 0000000..2374c58 --- /dev/null +++ b/tests/unit/test_log/test_init.py @@ -0,0 +1,48 @@ +import io +import logging + +import pytest + +import enapter.log + + +@pytest.fixture(autouse=True) +def reset_logger(): + original_level = enapter.log.LOGGER.level + original_handlers = enapter.log.LOGGER.handlers.copy() + yield + enapter.log.LOGGER.setLevel(original_level) + enapter.log.LOGGER.handlers = original_handlers + + +def test_configure_level_none(): + enapter.log.configure(level=None) + assert len(enapter.log.LOGGER.handlers) == 1 + assert isinstance(enapter.log.LOGGER.handlers[0], logging.NullHandler) + + +def test_configure_level_str(): + enapter.log.configure(level="DEBUG") + assert enapter.log.LOGGER.level == logging.DEBUG + assert len(enapter.log.LOGGER.handlers) == 1 + handler = enapter.log.LOGGER.handlers[0] + assert isinstance(handler, logging.StreamHandler) + assert isinstance(handler.formatter, enapter.log.JSONFormatter) + + +def test_configure_level_int(): + enapter.log.configure(level=logging.INFO) + assert enapter.log.LOGGER.level == logging.INFO + assert len(enapter.log.LOGGER.handlers) == 1 + handler = enapter.log.LOGGER.handlers[0] + assert isinstance(handler, logging.StreamHandler) + assert isinstance(handler.formatter, enapter.log.JSONFormatter) + + +def test_configure_custom_stream(): + stream = io.StringIO() + enapter.log.configure(level=logging.INFO, stream=stream) + assert len(enapter.log.LOGGER.handlers) == 1 + handler = enapter.log.LOGGER.handlers[0] + assert isinstance(handler, logging.StreamHandler) + assert handler.stream is stream diff --git a/tests/unit/test_standalone/test_mqtt_adapter.py b/tests/unit/test_standalone/test_mqtt_adapter.py index 53a8004..56f68b7 100644 --- a/tests/unit/test_standalone/test_mqtt_adapter.py +++ b/tests/unit/test_standalone/test_mqtt_adapter.py @@ -50,6 +50,8 @@ async def test_publish_properties(): device = Device() mqtt_api_client = mock.AsyncMock(spec=enapter.mqtt.api.Client) device_channel = mock.AsyncMock(spec=enapter.mqtt.api.device.Channel) + event = asyncio.Event() + device_channel.publish_properties.side_effect = lambda *args, **kwargs: event.set() mqtt_api_client.device_channel.return_value = device_channel async with asyncio.TaskGroup() as tg: async with enapter.standalone.mqtt_adapter.MQTTAdapter( @@ -59,7 +61,7 @@ async def test_publish_properties(): device=device, task_group=tg, ): - await asyncio.sleep(0.02) + await asyncio.sleep(0.1) device_channel.publish_properties.assert_called() last_call = device_channel.publish_properties.call_args published_properties = last_call.kwargs["properties"] @@ -71,6 +73,8 @@ async def test_publish_telemetry(): device = Device() mqtt_api_client = mock.AsyncMock(spec=enapter.mqtt.api.Client) device_channel = mock.AsyncMock(spec=enapter.mqtt.api.device.Channel) + event = asyncio.Event() + device_channel.publish_telemetry.side_effect = lambda *args, **kwargs: event.set() mqtt_api_client.device_channel.return_value = device_channel async with asyncio.TaskGroup() as tg: async with enapter.standalone.mqtt_adapter.MQTTAdapter( @@ -80,7 +84,7 @@ async def test_publish_telemetry(): device=device, task_group=tg, ): - await asyncio.sleep(0.02) + await asyncio.sleep(0.1) device_channel.publish_telemetry.assert_called() last_call = device_channel.publish_telemetry.call_args published_telemetry = last_call.kwargs["telemetry"] @@ -94,6 +98,8 @@ async def test_publish_logs(log_severity, persist_logs) -> None: device = Device(log_severity=log_severity, persist_logs=persist_logs) mqtt_api_client = mock.AsyncMock(spec=enapter.mqtt.api.Client) device_channel = mock.AsyncMock(spec=enapter.mqtt.api.device.Channel) + event = asyncio.Event() + device_channel.publish_log.side_effect = lambda *args, **kwargs: event.set() mqtt_api_client.device_channel.return_value = device_channel async with asyncio.TaskGroup() as tg: async with enapter.standalone.mqtt_adapter.MQTTAdapter( @@ -103,7 +109,7 @@ async def test_publish_logs(log_severity, persist_logs) -> None: device=device, task_group=tg, ): - await asyncio.sleep(0.02) + await asyncio.sleep(0.1) device_channel.publish_log.assert_called() last_call = device_channel.publish_log.call_args published_log = last_call.kwargs["log"] @@ -119,7 +125,13 @@ async def test_publish_properties_exception(): device = Device() mqtt_api_client = mock.AsyncMock(spec=enapter.mqtt.api.Client) device_channel = mock.AsyncMock(spec=enapter.mqtt.api.device.Channel) - device_channel.publish_properties.side_effect = RuntimeError("Publish error") + event = asyncio.Event() + + def publish_properties_mock(*args, **kwargs): + event.set() + raise RuntimeError("Publish error") + + device_channel.publish_properties.side_effect = publish_properties_mock mqtt_api_client.device_channel.return_value = device_channel async with asyncio.TaskGroup() as tg: async with enapter.standalone.mqtt_adapter.MQTTAdapter( @@ -129,7 +141,7 @@ async def test_publish_properties_exception(): device=device, task_group=tg, ): - await asyncio.sleep(0.02) + await asyncio.sleep(0.1) device_channel.publish_properties.assert_called() @@ -137,7 +149,13 @@ async def test_publish_telemetry_exception(): device = Device() mqtt_api_client = mock.AsyncMock(spec=enapter.mqtt.api.Client) device_channel = mock.AsyncMock(spec=enapter.mqtt.api.device.Channel) - device_channel.publish_telemetry.side_effect = RuntimeError("Publish error") + event = asyncio.Event() + + def publish_telemetry_mock(*args, **kwargs): + event.set() + raise RuntimeError("Publish error") + + device_channel.publish_telemetry.side_effect = publish_telemetry_mock mqtt_api_client.device_channel.return_value = device_channel async with asyncio.TaskGroup() as tg: async with enapter.standalone.mqtt_adapter.MQTTAdapter( @@ -147,7 +165,7 @@ async def test_publish_telemetry_exception(): device=device, task_group=tg, ): - await asyncio.sleep(0.02) + await asyncio.sleep(0.1) device_channel.publish_telemetry.assert_called() @@ -155,7 +173,13 @@ async def test_publish_logs_exception(): device = Device(log_severity="error") mqtt_api_client = mock.AsyncMock(spec=enapter.mqtt.api.Client) device_channel = mock.AsyncMock(spec=enapter.mqtt.api.device.Channel) - device_channel.publish_log.side_effect = RuntimeError("Publish error") + event = asyncio.Event() + + def publish_log_mock(*args, **kwargs): + event.set() + raise RuntimeError("Publish error") + + device_channel.publish_log.side_effect = publish_log_mock mqtt_api_client.device_channel.return_value = device_channel async with asyncio.TaskGroup() as tg: async with enapter.standalone.mqtt_adapter.MQTTAdapter( @@ -165,7 +189,7 @@ async def test_publish_logs_exception(): device=device, task_group=tg, ): - await asyncio.sleep(0.02) + await asyncio.sleep(0.1) device_channel.publish_log.assert_called()