diff --git a/.github/workflows/linting.yml b/.github/workflows/linting.yml index 912a5ba..757a76f 100644 --- a/.github/workflows/linting.yml +++ b/.github/workflows/linting.yml @@ -13,24 +13,25 @@ jobs: steps: - name: Check out Git repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + - name: Set up Python + uses: actions/setup-python@v5 with: python-version: "3.10" - - name: Install linting dependencies - run: | - pip install black flake8 - - - name: Run linters - uses: wearerequired/lint-action@v2 + - name: Set up Poetry + uses: snok/install-poetry@v1 with: - auto_fix: true - black: true - black_auto_fix: true - flake8: true - flake8_auto_fix: false + version: 2.1.4 + virtualenvs-create: true + virtualenvs-in-project: true - - uses: isort/isort-action@v1.0.0 + - name: Install dependencies + run: poetry install + + - name: Run linters + run: | + poetry run isort --check-only --diff . + poetry run black --check --diff . + poetry run flake8 . diff --git a/Dockerfile.dev b/Dockerfile.dev index 5b5876b..16080bd 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -1,12 +1,12 @@ FROM python:3.11-slim WORKDIR /app -# Install all dependencies (including dev) -RUN pip install --no-cache-dir poetry +# Install the same Poetry version used in CI +RUN pip install --no-cache-dir poetry==2.1.4 COPY pyproject.toml poetry.lock ./ RUN poetry config virtualenvs.create false \ && poetry install --no-root --no-interaction --no-ansi ENV PYTHONPATH="${PYTHONPATH}:/app" -CMD ["python"] \ No newline at end of file +CMD ["python"] diff --git a/README.md b/README.md index 27be492..6a43387 100644 --- a/README.md +++ b/README.md @@ -241,6 +241,16 @@ For developers who prefer not to install Python locally, a Docker-based developm Inside the container local code changes are automatically synced and you can run standard Python development commands. +### Development tools + +The same lint commands used in CI can be run through Docker Compose: + +```bash +docker compose -f docker-compose.dev.yml run --rm --build pycheckwatt-dev poetry run isort --check-only --diff . +docker compose -f docker-compose.dev.yml run --rm --build pycheckwatt-dev poetry run black --check --diff . +docker compose -f docker-compose.dev.yml run --rm --build pycheckwatt-dev poetry run flake8 . +``` + # Acknowledgements diff --git a/examples/history_inject.py b/examples/history_inject.py index 12c28d0..fad2f08 100644 --- a/examples/history_inject.py +++ b/examples/history_inject.py @@ -44,7 +44,7 @@ async def main(): return async with CheckWattRankManager() as cwr: - (status, stored_items, total_items) = ( + status, stored_items, total_items = ( await cwr.push_history_to_checkwatt_rank( display_name=( DISPLAY_NAME_OVERRIDE diff --git a/poetry.lock b/poetry.lock index e70c859..84a3c4c 100644 --- a/poetry.lock +++ b/poetry.lock @@ -496,6 +496,22 @@ files = [ {file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"}, ] +[[package]] +name = "isort" +version = "6.0.1" +description = "A Python utility / library to sort Python imports." +optional = false +python-versions = ">=3.9.0" +groups = ["dev"] +files = [ + {file = "isort-6.0.1-py3-none-any.whl", hash = "sha256:2dc5d7f65c9678d94c88dfc29161a320eec67328bc97aad576874cb4be1e9615"}, + {file = "isort-6.0.1.tar.gz", hash = "sha256:1cb5df28dfbc742e490c5e41bad6da41b805b0a8be7bc93cd0fb2a8a890ac450"}, +] + +[package.extras] +colors = ["colorama"] +plugins = ["setuptools"] + [[package]] name = "mccabe" version = "0.7.0" @@ -1075,4 +1091,4 @@ multidict = ">=4.0" [metadata] lock-version = "2.1" python-versions = ">=3.10,<3.13" -content-hash = "17bd3e9ee46733a0417a765832535112c9d8eb922ad442b8695b12f29f2c9c7f" +content-hash = "2bde59aa5ea952b87cc29c4df95fcf3b13cad226637cc355b553901a37b74590" diff --git a/pycheckwatt/__init__.py b/pycheckwatt/__init__.py index 77fb534..00105ab 100644 --- a/pycheckwatt/__init__.py +++ b/pycheckwatt/__init__.py @@ -143,7 +143,7 @@ def _extract_content_and_logbook(self, input_string): def _extract_fcr_d_state(self): pattern = re.compile( - r"\[ FCR-D (ACTIVATED|DEACTIVATE|FAIL ACTIVATION) \] (?:(?:\d+x)?\s?(\S+) --(\d+)-- | (?:(?:UP|DOWN) (?:\d+,\d+) Hz ))((?:(\d+,\d+)\/(\d+,\d+)\/)?(\d+,\d+|[A-Z]+) %)\s+\((\d+,\d+\/\d+,\d+|\d+\/\d+|\d+) kW\)\s*-?\s*.*?(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})" # noqa: E501 + r"\[ FCR-D (ACTIVATED|DEACTIVATE|FAIL ACTIVATION) \] (?:(?:\d+x)?\s?(\S+) --(\d+)-- | (?:(?:UP|DOWN) (?:\d+,\d+) Hz ))((?:(\d+,\d+)\/(\d+,\d+)\/)?(\d+,\d+|[A-Z]+) %)\s+\((\d+,\d+\/\d+,\d+|\d+\/\d+|\d+) kW\)\s*-?\s*.*?(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})" # noqa: E501 ) for entry in self.logbook_entries: match = pattern.search(entry) @@ -385,7 +385,7 @@ async def get_site_id(self): response_data = json.loads(raw_response) self.site_id = str(response_data["SiteId"]) return self.site_id - except json.JSONDecodeError as e: + except json.JSONDecodeError: # Fallback - maybe it's just the number as a string self.site_id = raw_response.strip('"') return self.site_id @@ -545,7 +545,10 @@ async def get_fcrd_year_net_revenue(self): year_date = datetime.now().strftime("%Y") to_date = year_date + months[loop + 1] from_date = year_date + months[loop] - endpoint = f"/revenue/{site_id}?from={from_date}&to={to_date}&resolution=day" + endpoint = ( + f"/revenue/{site_id}?from={from_date}" + f"&to={to_date}&resolution=day" + ) # Define headers with the JwtToken headers = { **self._get_headers(), diff --git a/pyproject.toml b/pyproject.toml index 8f3cc93..c0974d5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,14 @@ responses = "~=0.25.0" aioresponses = "~=0.7.0" black = "~=25.1.0" flake8 = "~=7.3.0" +isort = "~=6.0.0" + +[tool.black] +line-length = 88 +target-version = ["py310"] + +[tool.isort] +profile = "black" [build-system] requires = ["poetry-core"] diff --git a/setup.cfg b/setup.cfg index 69a3904..39ba467 100644 --- a/setup.cfg +++ b/setup.cfg @@ -2,6 +2,5 @@ description-file = README.md [flake8] max-line-length = 88 -exclude = .git,__pycache__,venv -[tool.black] -line-length = 88 +extend-ignore = E203,W503 +exclude = .git,__pycache__,.venv,venv,.tox,.mypy_cache,.pytest_cache,build,dist diff --git a/tests/unit/test_checkwatt_manager.py b/tests/unit/test_checkwatt_manager.py index ed00255..1089545 100644 --- a/tests/unit/test_checkwatt_manager.py +++ b/tests/unit/test_checkwatt_manager.py @@ -54,9 +54,10 @@ async def test_login_success(self): """Test successful login flow.""" async with CheckwattManager("test_user", "test_pass") as manager: - with patch("aiohttp.ClientSession.post") as mock_post, patch( - "aiohttp.ClientSession.get" - ) as mock_get: + with ( + patch("aiohttp.ClientSession.post") as mock_post, + patch("aiohttp.ClientSession.get") as mock_get, + ): # Mock kill switch check (always called first) mock_killswitch = AsyncMock() @@ -322,9 +323,10 @@ async def test_fcrd_revenue_methods_success(self): await manager.get_customer_details() # Mock FCR-D revenue calls - with patch.object( - manager, "get_site_id", return_value="test_site_123" - ), patch("aiohttp.ClientSession.get") as mock_get: + with ( + patch.object(manager, "get_site_id", return_value="test_site_123"), + patch("aiohttp.ClientSession.get") as mock_get, + ): mock_response = AsyncMock() mock_response.status = 200 @@ -380,9 +382,10 @@ async def test_example_py_workflow(self): async with CheckwattManager("test_user", "test_pass") as manager: # Step 1: Login - with patch("aiohttp.ClientSession.post") as mock_post, patch( - "aiohttp.ClientSession.get" - ) as mock_get_ks: + with ( + patch("aiohttp.ClientSession.post") as mock_post, + patch("aiohttp.ClientSession.get") as mock_get_ks, + ): mock_killswitch = AsyncMock() mock_killswitch.status = 200 @@ -410,9 +413,10 @@ async def test_example_py_workflow(self): await manager.get_customer_details() # Step 3: Get FCR-D revenue data - with patch.object(manager, "get_site_id", return_value="test_site"), patch( - "aiohttp.ClientSession.get" - ) as mock_get: + with ( + patch.object(manager, "get_site_id", return_value="test_site"), + patch("aiohttp.ClientSession.get") as mock_get, + ): mock_response = AsyncMock() mock_response.status = 200 @@ -545,12 +549,16 @@ def setup_method(self): self.manager = CheckwattManager("test_user", "test_pass") def test_fail_activation_with_retry_count_and_complex_power(self): - """Test parsing of FAIL ACTIVATION entries with retry count and complex power format.""" - log_entry = "[ FCR-D FAIL ACTIVATION ] 54x test@example.com --12345-- 85,9/0,6/97,0 % (10,0/10,0 kW) 2025-04-24 00:02:57 API-BACKEND" - + """Test FAIL ACTIVATION with retry count and complex power.""" + log_entry = ( + "[ FCR-D FAIL ACTIVATION ] 54x test@example.com --12345-- " + "85,9/0,6/97,0 % (10,0/10,0 kW) " + "2025-04-24 00:02:57 API-BACKEND" + ) + self.manager.logbook_entries = [log_entry] self.manager._extract_fcr_d_state() - + assert self.manager.fcrd_state == "FAIL ACTIVATION" assert self.manager.fcrd_percentage_up == "85,9" assert self.manager.fcrd_percentage_response == "0,6" @@ -560,11 +568,15 @@ def test_fail_activation_with_retry_count_and_complex_power(self): def test_activated_with_complex_power_format(self): """Test parsing of ACTIVATED entries with complex power format.""" - log_entry = "[ FCR-D ACTIVATED ] test@example.com --12345-- 96,5/4,0/106,3 % (10,0/10,0 kW) 2025-08-07 00:04:45 API-BACKEND" - + log_entry = ( + "[ FCR-D ACTIVATED ] test@example.com --12345-- " + "96,5/4,0/106,3 % (10,0/10,0 kW) " + "2025-08-07 00:04:45 API-BACKEND" + ) + self.manager.logbook_entries = [log_entry] self.manager._extract_fcr_d_state() - + assert self.manager.fcrd_state == "ACTIVATED" assert self.manager.fcrd_percentage_up == "96,5" assert self.manager.fcrd_percentage_response == "4,0" @@ -574,11 +586,14 @@ def test_activated_with_complex_power_format(self): def test_deactivate_with_frequency_up_hz(self): """Test parsing of DEACTIVATE entries with UP frequency.""" - log_entry = "[ FCR-D DEACTIVATE ] UP 49,83 Hz 0,0 % (10 kW) - 2025-08-06 17:58:07 API-BACKEND" - + log_entry = ( + "[ FCR-D DEACTIVATE ] UP 49,83 Hz 0,0 % (10 kW) - " + "2025-08-06 17:58:07 API-BACKEND" + ) + self.manager.logbook_entries = [log_entry] self.manager._extract_fcr_d_state() - + assert self.manager.fcrd_state == "DEACTIVATE" # For DEACTIVATE, the percentage info goes to fcrd_info assert self.manager.fcrd_power == "10" @@ -587,13 +602,16 @@ def test_deactivate_with_frequency_up_hz(self): def test_multiple_entries_first_match_used(self): """Test that only the first matching entry is processed.""" log_entries = [ - "[ FCR-D ACTIVATED ] test@example.com --12345-- 97,7/0,5/99,3 % (7 kW) 2024-07-07 00:08:19 API-BACKEND", - "[ FCR-D FAIL ACTIVATION ] 54x test@example.com --12345-- 85,9/0,6/97,0 % (10,0/10,0 kW) 2025-04-24 00:02:57 API-BACKEND", + "[ FCR-D ACTIVATED ] test@example.com --12345-- " + "97,7/0,5/99,3 % (7 kW) 2024-07-07 00:08:19 API-BACKEND", + "[ FCR-D FAIL ACTIVATION ] 54x test@example.com --12345-- " + "85,9/0,6/97,0 % (10,0/10,0 kW) " + "2025-04-24 00:02:57 API-BACKEND", ] - + self.manager.logbook_entries = log_entries self.manager._extract_fcr_d_state() - + # Should use the first entry (ACTIVATED) assert self.manager.fcrd_state == "ACTIVATED" assert self.manager.fcrd_power == "7"