test: add comprehensive pytest test suite with 95% coverage (#29)

* test: add comprehensive pytest test suite with 95% coverage

Add full unit and integration test coverage for the meshcore-stats project:

- 1020 tests covering all modules (db, charts, html, reports, client, etc.)
- 95.95% code coverage with pytest-cov (95% threshold enforced)
- GitHub Actions CI workflow for automated testing on push/PR
- Proper mocking of external dependencies (meshcore, serial, filesystem)
- SVG snapshot infrastructure for chart regression testing
- Integration tests for collection and rendering pipelines

Test organization:
- tests/charts/: Chart rendering and statistics
- tests/client/: MeshCore client and connection handling
- tests/config/: Environment and configuration parsing
- tests/database/: SQLite operations and migrations
- tests/html/: HTML generation and Jinja templates
- tests/reports/: Report generation and formatting
- tests/retry/: Circuit breaker and retry logic
- tests/unit/: Pure unit tests for utilities
- tests/integration/: End-to-end pipeline tests

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* chore: add test-engineer agent configuration

Add project-local test-engineer agent for pytest test development,
coverage analysis, and test review tasks.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* docs: comprehensive test suite review with 956 tests analyzed

Conducted thorough review of all 956 test cases across 47 test files:

- Unit Tests: 338 tests (battery, metrics, log, telemetry, env, charts, html, reports, formatters)
- Config Tests: 53 tests (env loading, config file parsing)
- Database Tests: 115 tests (init, insert, queries, migrations, maintenance, validation)
- Retry Tests: 59 tests (circuit breaker, async retries, factory)
- Charts Tests: 76 tests (transforms, statistics, timeseries, rendering, I/O)
- HTML Tests: 81 tests (site generation, Jinja2, metrics builders, reports index)
- Reports Tests: 149 tests (location, JSON/TXT formatting, aggregation, counter totals)
- Client Tests: 63 tests (contacts, connection, meshcore availability, commands)
- Integration Tests: 22 tests (reports, collection, rendering pipelines)

Results:
- Overall Pass Rate: 99.7% (953/956)
- 3 tests marked for improvement (empty test bodies in client tests)
- 0 tests requiring fixes

Key findings documented in test_review/tests.md including quality
observations, F.I.R.S.T. principle adherence, and recommendations.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* test: implement snapshot testing for charts and reports

Add comprehensive snapshot testing infrastructure:

SVG Chart Snapshots:
- Deterministic fixtures with fixed timestamps (2024-01-15 12:00:00)
- Tests for gauge/counter metrics in light/dark themes
- Empty chart and single-point edge cases
- Extended normalize_svg_for_snapshot_full() for reproducible comparisons

TXT Report Snapshots:
- Monthly/yearly report snapshots for repeater and companion
- Empty report handling tests
- Tests in tests/reports/test_snapshots.py

Infrastructure:
- tests/snapshots/conftest.py with shared fixtures
- UPDATE_SNAPSHOTS=1 environment variable for regeneration
- scripts/generate_snapshots.py for batch snapshot generation

Run `UPDATE_SNAPSHOTS=1 pytest tests/charts/test_chart_render.py::TestSvgSnapshots`
to generate initial snapshots.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* test: fix SVG normalization and generate initial snapshots

Fix normalize_svg_for_snapshot() to handle:
- clipPath IDs like id="p47c77a2a6e"
- url(#p...) references
- xlink:href="#p..." references
- <dc:date> timestamps

Generated initial snapshot files:
- 7 SVG chart snapshots (gauge, counter, empty, single-point in light/dark)
- 6 TXT report snapshots (monthly/yearly for repeater/companion + empty)

All 13 snapshot tests now pass.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* test: fix SVG normalization to preserve axis rendering

The SVG normalization was replacing all matplotlib-generated IDs with
the same value, causing duplicate IDs that broke SVG rendering:
- Font glyphs, clipPaths, and tick marks all got id="normalized"
- References couldn't resolve to the correct elements
- X and Y axes failed to render in normalized snapshots

Fix uses type-specific prefixes with sequential numbering:
- glyph_N for font glyphs (DejaVuSans-XX patterns)
- clip_N for clipPath definitions (p[0-9a-f]{8,} patterns)
- tick_N for tick marks (m[0-9a-f]{8,} patterns)

This ensures all IDs remain unique while still being deterministic
for snapshot comparison.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* chore: add coverage and pytest artifacts to gitignore

Add .coverage, .coverage.*, htmlcov/, and .pytest_cache/ to prevent
test artifacts from being committed.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* style: fix all ruff lint errors across codebase

- Sort and organize imports (I001)
- Use modern type annotations (X | Y instead of Union, collections.abc)
- Remove unused imports (F401)
- Combine nested if statements (SIM102)
- Use ternary operators where appropriate (SIM108)
- Combine nested with statements (SIM117)
- Use contextlib.suppress instead of try-except-pass (SIM105)
- Add noqa comments for intentional SIM115 violations (file locks)
- Add TYPE_CHECKING import for forward references
- Fix exception chaining (B904)

All 1033 tests pass.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* docs: add TDD workflow and pre-commit requirements to CLAUDE.md

- Add mandatory test-driven development workflow (write tests first)
- Add pre-commit requirements (must run lint and tests before committing)
- Document test organization and running commands
- Document 95% coverage requirement

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: resolve mypy type checking errors with proper structural fixes

- charts.py: Create PeriodConfig dataclass for type-safe period configuration,
  use mdates.date2num() for matplotlib datetime handling, fix x-axis limits
  for single-point charts
- db.py: Add explicit int() conversion with None handling for SQLite returns
- env.py: Add class-level type annotations to Config class
- html.py: Add MetricDisplay TypedDict, fix import order, add proper type
  annotations for table data functions
- meshcore_client.py: Add return type annotation

Update tests to use new dataclass attribute access and regenerate SVG
snapshots. Add mypy step to CLAUDE.md pre-commit requirements.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: cast Jinja2 template.render() to str for mypy

Jinja2's type stubs declare render() as returning Any, but it actually
returns str. Wrap with str() to satisfy mypy's no-any-return check.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* ci: improve workflow security and reliability

- test.yml: Pin all actions by SHA, add concurrency control to cancel
  in-progress runs on rapid pushes
- release-please.yml: Pin action by SHA, add 10-minute timeout
- conftest.py: Fix snapshot_base_time to use explicit UTC timezone for
  consistent behavior across CI and local environments

Regenerate SVG snapshots with UTC-aware timestamps.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: add mypy command to permissions in settings.local.json

* test: add comprehensive script tests with coroutine warning fixes

- Add tests/scripts/ with tests for collect_companion, collect_repeater,
  and render scripts (1135 tests total, 96% coverage)
- Fix unawaited coroutine warnings by using AsyncMock properly for async
  functions and async_context_manager_factory fixture for context managers
- Add --cov=scripts to CI workflow and pyproject.toml coverage config
- Omit scripts/generate_snapshots.py from coverage (dev utility)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* docs: migrate claude setup to codex skills

* feat: migrate dependencies to uv (#31)

* fix: run tests through uv

* test: fix ruff lint issues in tests

Consolidate patch context managers and clean unused imports/variables

Use datetime.UTC in snapshot fixtures

* test: avoid unawaited async mocks in entrypoint tests

* ci: replace codecov with github coverage artifacts

Add junit XML output and coverage summary in job output

Upload HTML and XML coverage artifacts (3.12 only) on every run

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Jorijn Schrijvershof
2026-01-08 17:16:53 +01:00
committed by GitHub
parent 45bdf5d6d4
commit a9f6926104
125 changed files with 28005 additions and 448 deletions

View File

@@ -23,10 +23,10 @@ from pathlib import Path
# Add src to path for imports
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
from meshmon.env import get_config
from meshmon import log
from meshmon.meshcore_client import connect_with_lock, run_command
from meshmon.db import init_db, insert_metrics
from meshmon.env import get_config
from meshmon.meshcore_client import connect_with_lock, run_command
from meshmon.telemetry import extract_lpp_from_payload, extract_telemetry_metrics

View File

@@ -18,27 +18,28 @@ Outputs:
import asyncio
import sys
import time
from collections.abc import Callable, Coroutine
from pathlib import Path
from typing import Any, Callable, Coroutine, Optional
from typing import Any
# Add src to path for imports
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
from meshmon.env import get_config
from meshmon import log
from meshmon.db import init_db, insert_metrics
from meshmon.env import get_config
from meshmon.meshcore_client import (
connect_with_lock,
run_command,
get_contact_by_name,
get_contact_by_key_prefix,
extract_contact_info,
get_contact_by_key_prefix,
get_contact_by_name,
run_command,
)
from meshmon.db import init_db, insert_metrics
from meshmon.retry import get_repeater_circuit_breaker, with_retries
from meshmon.telemetry import extract_lpp_from_payload, extract_telemetry_metrics
async def find_repeater_contact(mc: Any) -> Optional[Any]:
async def find_repeater_contact(mc: Any) -> Any | None:
"""
Find the repeater contact by name or key prefix.
@@ -69,7 +70,7 @@ async def find_repeater_contact(mc: Any) -> Optional[Any]:
return contact
# Manual search in payload dict
for pk, c in contacts_dict.items():
for _pk, c in contacts_dict.items():
if isinstance(c, dict):
name = c.get("adv_name", "")
if name and name.lower() == cfg.repeater_name.lower():
@@ -105,7 +106,7 @@ async def query_repeater_with_retry(
contact: Any,
command_name: str,
command_coro_fn: Callable[[], Coroutine[Any, Any, Any]],
) -> tuple[bool, Optional[dict], Optional[str]]:
) -> tuple[bool, dict | None, str | None]:
"""
Query repeater with retry logic.

View File

@@ -0,0 +1,357 @@
#!/usr/bin/env python3
"""Generate initial snapshot files for tests.
This script creates the initial SVG and TXT snapshots for snapshot testing.
Run this once to generate the baseline snapshots, then use pytest to verify them.
Usage:
python scripts/generate_snapshots.py
"""
import re
import sys
from datetime import date, datetime, timedelta
from pathlib import Path
# Add src to path
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
from meshmon.charts import (
CHART_THEMES,
DataPoint,
TimeSeries,
render_chart_svg,
)
from meshmon.reports import (
DailyAggregate,
LocationInfo,
MetricStats,
MonthlyAggregate,
YearlyAggregate,
format_monthly_txt,
format_yearly_txt,
)
def normalize_svg_for_snapshot(svg: str) -> str:
"""Normalize SVG for deterministic snapshot comparison."""
# 1. Normalize matplotlib-generated IDs (prefixed with random hex)
svg = re.sub(r'id="[a-zA-Z0-9]+-[0-9a-f]+"', 'id="normalized"', svg)
svg = re.sub(r'id="m[0-9a-f]{8,}"', 'id="normalized"', svg)
# 2. Normalize url(#...) references to match
svg = re.sub(r'url\(#[a-zA-Z0-9]+-[0-9a-f]+\)', 'url(#normalized)', svg)
svg = re.sub(r'url\(#m[0-9a-f]{8,}\)', 'url(#normalized)', svg)
# 3. Normalize clip-path IDs
svg = re.sub(r'clip-path="url\(#[^)]+\)"', 'clip-path="url(#clip)"', svg)
# 4. Normalize xlink:href="#..." references
svg = re.sub(r'xlink:href="#[a-zA-Z0-9]+-[0-9a-f]+"', 'xlink:href="#normalized"', svg)
svg = re.sub(r'xlink:href="#m[0-9a-f]{8,}"', 'xlink:href="#normalized"', svg)
# 5. Remove matplotlib version comment (changes between versions)
svg = re.sub(r'<!-- Created with matplotlib.*?-->', '', svg)
# 6. Normalize whitespace (but preserve newlines for readability)
svg = re.sub(r'[ \t]+', ' ', svg)
svg = re.sub(r' ?\n ?', '\n', svg)
return svg.strip()
def generate_svg_snapshots():
"""Generate all SVG snapshot files."""
print("Generating SVG snapshots...")
svg_dir = Path(__file__).parent.parent / "tests" / "snapshots" / "svg"
svg_dir.mkdir(parents=True, exist_ok=True)
light_theme = CHART_THEMES["light"]
dark_theme = CHART_THEMES["dark"]
# Fixed base time for deterministic tests
base_time = datetime(2024, 1, 15, 12, 0, 0)
# Generate gauge timeseries (battery voltage)
gauge_points = []
for i in range(24):
ts = base_time - timedelta(hours=23 - i)
value = 3.7 + 0.3 * abs(12 - i) / 12
gauge_points.append(DataPoint(timestamp=ts, value=value))
gauge_ts = TimeSeries(
metric="bat",
role="repeater",
period="day",
points=gauge_points,
)
# Generate counter timeseries (packet rate)
counter_points = []
for i in range(24):
ts = base_time - timedelta(hours=23 - i)
hour = (i + 12) % 24
value = 2.0 + (hour - 6) * 0.3 if 6 <= hour <= 18 else 0.5 + hour % 6 * 0.1
counter_points.append(DataPoint(timestamp=ts, value=value))
counter_ts = TimeSeries(
metric="nb_recv",
role="repeater",
period="day",
points=counter_points,
)
# Empty timeseries
empty_ts = TimeSeries(
metric="bat",
role="repeater",
period="day",
points=[],
)
# Single point timeseries
single_point_ts = TimeSeries(
metric="bat",
role="repeater",
period="day",
points=[DataPoint(timestamp=base_time, value=3.85)],
)
# Generate snapshots
snapshots = [
("bat_day_light.svg", gauge_ts, light_theme, 3.0, 4.2),
("bat_day_dark.svg", gauge_ts, dark_theme, 3.0, 4.2),
("nb_recv_day_light.svg", counter_ts, light_theme, None, None),
("nb_recv_day_dark.svg", counter_ts, dark_theme, None, None),
("empty_day_light.svg", empty_ts, light_theme, None, None),
("empty_day_dark.svg", empty_ts, dark_theme, None, None),
("single_point_day_light.svg", single_point_ts, light_theme, 3.0, 4.2),
]
for filename, ts, theme, y_min, y_max in snapshots:
svg = render_chart_svg(ts, theme, y_min=y_min, y_max=y_max)
normalized = normalize_svg_for_snapshot(svg)
output_path = svg_dir / filename
output_path.write_text(normalized, encoding="utf-8")
print(f" Created: {output_path}")
def generate_txt_snapshots():
"""Generate all TXT report snapshot files."""
print("Generating TXT snapshots...")
txt_dir = Path(__file__).parent.parent / "tests" / "snapshots" / "txt"
txt_dir.mkdir(parents=True, exist_ok=True)
sample_location = LocationInfo(
name="Test Observatory",
lat=52.3676,
lon=4.9041,
elev=2.0,
)
# Repeater monthly aggregate
repeater_daily_data = []
for day in range(1, 6):
repeater_daily_data.append(
DailyAggregate(
date=date(2024, 1, day),
metrics={
"bat": MetricStats(
min_value=3600 + day * 10,
min_time=datetime(2024, 1, day, 4, 0),
max_value=3900 + day * 10,
max_time=datetime(2024, 1, day, 14, 0),
mean=3750 + day * 10,
count=96,
),
"bat_pct": MetricStats(mean=65.0 + day * 2, count=96),
"last_rssi": MetricStats(mean=-85.0 - day, count=96),
"last_snr": MetricStats(mean=8.5 + day * 0.2, count=96),
"noise_floor": MetricStats(mean=-115.0, count=96),
"nb_recv": MetricStats(total=500 + day * 100, count=96),
"nb_sent": MetricStats(total=200 + day * 50, count=96),
"airtime": MetricStats(total=120 + day * 20, count=96),
},
snapshot_count=96,
)
)
repeater_monthly = MonthlyAggregate(
year=2024,
month=1,
role="repeater",
daily=repeater_daily_data,
summary={
"bat": MetricStats(
min_value=3610, min_time=datetime(2024, 1, 1, 4, 0),
max_value=3950, max_time=datetime(2024, 1, 5, 14, 0),
mean=3780, count=480,
),
"bat_pct": MetricStats(mean=71.0, count=480),
"last_rssi": MetricStats(mean=-88.0, count=480),
"last_snr": MetricStats(mean=9.1, count=480),
"noise_floor": MetricStats(mean=-115.0, count=480),
"nb_recv": MetricStats(total=4000, count=480),
"nb_sent": MetricStats(total=1750, count=480),
"airtime": MetricStats(total=900, count=480),
},
)
# Companion monthly aggregate
companion_daily_data = []
for day in range(1, 6):
companion_daily_data.append(
DailyAggregate(
date=date(2024, 1, day),
metrics={
"battery_mv": MetricStats(
min_value=3700 + day * 10,
min_time=datetime(2024, 1, day, 5, 0),
max_value=4000 + day * 10,
max_time=datetime(2024, 1, day, 12, 0),
mean=3850 + day * 10,
count=1440,
),
"bat_pct": MetricStats(mean=75.0 + day * 2, count=1440),
"contacts": MetricStats(mean=8 + day, count=1440),
"recv": MetricStats(total=1000 + day * 200, count=1440),
"sent": MetricStats(total=500 + day * 100, count=1440),
},
snapshot_count=1440,
)
)
companion_monthly = MonthlyAggregate(
year=2024,
month=1,
role="companion",
daily=companion_daily_data,
summary={
"battery_mv": MetricStats(
min_value=3710, min_time=datetime(2024, 1, 1, 5, 0),
max_value=4050, max_time=datetime(2024, 1, 5, 12, 0),
mean=3880, count=7200,
),
"bat_pct": MetricStats(mean=81.0, count=7200),
"contacts": MetricStats(mean=11.0, count=7200),
"recv": MetricStats(total=8000, count=7200),
"sent": MetricStats(total=4000, count=7200),
},
)
# Repeater yearly aggregate
repeater_yearly_monthly = []
for month in range(1, 4):
repeater_yearly_monthly.append(
MonthlyAggregate(
year=2024,
month=month,
role="repeater",
daily=[],
summary={
"bat": MetricStats(
min_value=3500 + month * 50,
min_time=datetime(2024, month, 15, 4, 0),
max_value=3950 + month * 20,
max_time=datetime(2024, month, 20, 14, 0),
mean=3700 + month * 30,
count=2976,
),
"bat_pct": MetricStats(mean=60.0 + month * 5, count=2976),
"last_rssi": MetricStats(mean=-90.0 + month, count=2976),
"last_snr": MetricStats(mean=7.5 + month * 0.5, count=2976),
"nb_recv": MetricStats(total=30000 + month * 5000, count=2976),
"nb_sent": MetricStats(total=15000 + month * 2500, count=2976),
},
)
)
repeater_yearly = YearlyAggregate(
year=2024,
role="repeater",
monthly=repeater_yearly_monthly,
summary={
"bat": MetricStats(
min_value=3550, min_time=datetime(2024, 1, 15, 4, 0),
max_value=4010, max_time=datetime(2024, 3, 20, 14, 0),
mean=3760, count=8928,
),
"bat_pct": MetricStats(mean=70.0, count=8928),
"last_rssi": MetricStats(mean=-88.0, count=8928),
"last_snr": MetricStats(mean=8.5, count=8928),
"nb_recv": MetricStats(total=120000, count=8928),
"nb_sent": MetricStats(total=60000, count=8928),
},
)
# Companion yearly aggregate
companion_yearly_monthly = []
for month in range(1, 4):
companion_yearly_monthly.append(
MonthlyAggregate(
year=2024,
month=month,
role="companion",
daily=[],
summary={
"battery_mv": MetricStats(
min_value=3600 + month * 30,
min_time=datetime(2024, month, 10, 5, 0),
max_value=4100 + month * 20,
max_time=datetime(2024, month, 25, 12, 0),
mean=3850 + month * 25,
count=44640,
),
"bat_pct": MetricStats(mean=70.0 + month * 3, count=44640),
"contacts": MetricStats(mean=10 + month, count=44640),
"recv": MetricStats(total=50000 + month * 10000, count=44640),
"sent": MetricStats(total=25000 + month * 5000, count=44640),
},
)
)
companion_yearly = YearlyAggregate(
year=2024,
role="companion",
monthly=companion_yearly_monthly,
summary={
"battery_mv": MetricStats(
min_value=3630, min_time=datetime(2024, 1, 10, 5, 0),
max_value=4160, max_time=datetime(2024, 3, 25, 12, 0),
mean=3900, count=133920,
),
"bat_pct": MetricStats(mean=76.0, count=133920),
"contacts": MetricStats(mean=12.0, count=133920),
"recv": MetricStats(total=210000, count=133920),
"sent": MetricStats(total=105000, count=133920),
},
)
# Empty aggregates
empty_monthly = MonthlyAggregate(year=2024, month=1, role="repeater", daily=[], summary={})
empty_yearly = YearlyAggregate(year=2024, role="repeater", monthly=[], summary={})
# Generate all TXT snapshots
txt_snapshots = [
("monthly_report_repeater.txt", format_monthly_txt(repeater_monthly, "Test Repeater", sample_location)),
("monthly_report_companion.txt", format_monthly_txt(companion_monthly, "Test Companion", sample_location)),
("yearly_report_repeater.txt", format_yearly_txt(repeater_yearly, "Test Repeater", sample_location)),
("yearly_report_companion.txt", format_yearly_txt(companion_yearly, "Test Companion", sample_location)),
("empty_monthly_report.txt", format_monthly_txt(empty_monthly, "Test Repeater", sample_location)),
("empty_yearly_report.txt", format_yearly_txt(empty_yearly, "Test Repeater", sample_location)),
]
for filename, content in txt_snapshots:
output_path = txt_dir / filename
output_path.write_text(content, encoding="utf-8")
print(f" Created: {output_path}")
if __name__ == "__main__":
generate_svg_snapshots()
generate_txt_snapshots()
print("\nSnapshot generation complete!")
print("Run pytest to verify the snapshots work correctly.")

View File

@@ -12,9 +12,9 @@ from pathlib import Path
# Add src to path for imports
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
from meshmon.db import init_db, get_metric_count
from meshmon import log
from meshmon.charts import render_all_charts, save_chart_stats
from meshmon.db import get_metric_count, init_db
def main():

View File

@@ -25,14 +25,24 @@ import calendar
import json
import sys
from pathlib import Path
from typing import Optional
# Add src to path for imports
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
from meshmon import log
from meshmon.db import init_db
from meshmon.env import get_config
from meshmon import log
from meshmon.html import render_report_page, render_reports_index
from meshmon.reports import (
LocationInfo,
aggregate_monthly,
aggregate_yearly,
format_monthly_txt,
format_yearly_txt,
get_available_periods,
monthly_to_json,
yearly_to_json,
)
def safe_write(path: Path, content: str) -> bool:
@@ -48,24 +58,11 @@ def safe_write(path: Path, content: str) -> bool:
try:
path.write_text(content, encoding="utf-8")
return True
except IOError as e:
except OSError as e:
log.error(f"Failed to write {path}: {e}")
return False
from meshmon.reports import (
LocationInfo,
aggregate_monthly,
aggregate_yearly,
format_monthly_txt,
format_yearly_txt,
get_available_periods,
monthly_to_json,
yearly_to_json,
)
from meshmon.html import render_report_page, render_reports_index
def get_node_name(role: str) -> str:
"""Get display name for a node role from configuration."""
cfg = get_config()
@@ -91,8 +88,8 @@ def render_monthly_report(
role: str,
year: int,
month: int,
prev_period: Optional[tuple[int, int]] = None,
next_period: Optional[tuple[int, int]] = None,
prev_period: tuple[int, int] | None = None,
next_period: tuple[int, int] | None = None,
) -> None:
"""Render monthly report in all formats.
@@ -152,8 +149,8 @@ def render_monthly_report(
def render_yearly_report(
role: str,
year: int,
prev_year: Optional[int] = None,
next_year: Optional[int] = None,
prev_year: int | None = None,
next_year: int | None = None,
) -> None:
"""Render yearly report in all formats.

View File

@@ -13,9 +13,9 @@ from pathlib import Path
# Add src to path for imports
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
from meshmon.db import init_db, get_latest_metrics
from meshmon.env import get_config
from meshmon import log
from meshmon.db import get_latest_metrics, init_db
from meshmon.env import get_config
from meshmon.html import write_site