diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a513cbf..b231524 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,10 +1,74 @@ -name: Tests +name: CI on: + push: + branches: [main] pull_request: branches: [main] jobs: + ruff: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.14" + + - name: Install ruff + run: pip install ruff + + - name: Ruff check + run: | + set +e + output=$(ruff check . --statistics 2>&1) + exit_code=$? + echo "## Ruff Lint Report" >> $GITHUB_STEP_SUMMARY + echo "\`\`\`" >> $GITHUB_STEP_SUMMARY + echo "$output" >> $GITHUB_STEP_SUMMARY + echo "\`\`\`" >> $GITHUB_STEP_SUMMARY + exit $exit_code + + - name: Ruff format check + run: | + set +e + output=$(ruff format --check . 2>&1) + exit_code=$? + echo "## Ruff Format Report" >> $GITHUB_STEP_SUMMARY + echo "\`\`\`" >> $GITHUB_STEP_SUMMARY + echo "$output" >> $GITHUB_STEP_SUMMARY + echo "\`\`\`" >> $GITHUB_STEP_SUMMARY + exit $exit_code + + mypy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.14" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" + pip install -e ".[fastapi]" + + - name: mypy + run: | + set +e + output=$(mypy metricsqlite 2>&1) + exit_code=$? + echo "## mypy Report" >> $GITHUB_STEP_SUMMARY + echo "\`\`\`" >> $GITHUB_STEP_SUMMARY + echo "$output" >> $GITHUB_STEP_SUMMARY + echo "\`\`\`" >> $GITHUB_STEP_SUMMARY + exit $exit_code + test: runs-on: ubuntu-latest strategy: @@ -12,10 +76,10 @@ jobs: python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0fb023f..e393cb1 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -5,7 +5,8 @@ repos: - id: check-yaml - id: end-of-file-fixer - id: trailing-whitespace - - repo: https://github.com/psf/black - rev: 26.3.1 + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.15.12 hooks: - - id: black + - id: ruff-format # Formatter diff --git a/default.nix b/default.nix new file mode 100644 index 0000000..5726cff --- /dev/null +++ b/default.nix @@ -0,0 +1,18 @@ +{ lib, buildPythonPackage, fetchPypi, setuptools, setuptools-scm, fastapi +, pydantic, pymodbus }: + +buildPythonPackage { + pname = "metricsqlite"; + version = "0.0.0"; + format = "pyproject"; + + src = ./.; + + nativeBuildInputs = [ setuptools setuptools-scm ]; + propagatedBuildInputs = [ fastapi pydantic ]; + + meta = with lib; { + description = "MetricSQLite - MetricsQL api with SQLite as backend"; + license = licenses.mit; + }; +} diff --git a/docs/dev/testing.md b/docs/dev/testing.md index 456c7a4..6771ed7 100644 --- a/docs/dev/testing.md +++ b/docs/dev/testing.md @@ -1,5 +1,19 @@ +To set up a dev environment run; + ```bash +python3 -m venv .venv +source .venv/bin/activate +pip install --upgrade pip +pip install -e . pip install -e ".[dev]" + +pre-commit install +pre-commit autoupdate +``` + +### pytest + +```bash pytest # Or run a specific test file: @@ -7,4 +21,24 @@ pytest tests/test_client.py # Or a specific test: pytest tests/test_client.py::TestMetricsQLite::test_init_in_memory + +# For a code coverage report: +pytest --cov=metricsqlite --cov-report=term-missing +``` + +### ruff + +```bash +ruff check . # Lint +ruff check . --fix # Lint + auto-fix +ruff format . # Format + +# pyproject.toml sets `output-format = "concise"`. So show more details run; +ruff check --output-format=full . +``` + +### mypy + +```bash +mypy metricsqlite ``` diff --git a/metricsqlite/__init__.py b/metricsqlite/__init__.py index 8fd6a9b..3e65ac0 100644 --- a/metricsqlite/__init__.py +++ b/metricsqlite/__init__.py @@ -2,4 +2,4 @@ from metricsqlite.exceptions import CompactedRangeError from metricsqlite.lineprotocol import LineProtocolError -__all__ = ["MetricsQLiteClient", "CompactedRangeError", "LineProtocolError", "fastapi"] +__all__ = ["CompactedRangeError", "LineProtocolError", "MetricsQLiteClient", "fastapi"] diff --git a/metricsqlite/client.py b/metricsqlite/client.py index 75b00b6..f299032 100644 --- a/metricsqlite/client.py +++ b/metricsqlite/client.py @@ -4,7 +4,7 @@ from datetime import datetime from pathlib import Path -from metricsqlite.engine import QueryEngine, QueryResult, MatrixResult +from metricsqlite.engine import MatrixResult, QueryEngine, QueryResult from metricsqlite.exceptions import CompactedRangeError from metricsqlite.util import parse_interval, parse_timestamp @@ -46,6 +46,18 @@ def __init__( self._connection: sqlite3.Connection | None = None self._engine: QueryEngine | None = None + def _get_connection(self) -> sqlite3.Connection: + """Get the database connection, raising if not connected.""" + if self._connection is None: + raise RuntimeError("Not connected. Call connect() first.") + return self._connection + + def _get_engine(self) -> QueryEngine: + """Get the query engine, raising if not connected.""" + if self._engine is None: + raise RuntimeError("Not connected. Call connect() first.") + return self._engine + @property def series_table_name(self) -> str: return f"{self._tables_prefix}_series" @@ -72,32 +84,32 @@ def _get_parameters(self) -> dict[str, str]: "counter_insert": f"{self.counter_view_name}_insert", } - def connect(self): + def connect(self) -> None: if self._connection is not None: return db_path = self._db_path if self._db_path is not None else ":memory:" self._connection = sqlite3.connect(db_path, check_same_thread=False) self._connection.row_factory = sqlite3.Row if self._enable_wal: - self._connection.execute("PRAGMA journal_mode=WAL") + self._get_connection().execute("PRAGMA journal_mode=WAL") self._engine = QueryEngine( self._connection, self.series_table_name, self.data_table_name, ) - def create_tables(self): + def create_tables(self) -> None: with self._lock: self._create_tables_unlocked() - def _create_tables_unlocked(self): - self._connection.execute("PRAGMA foreign_keys = ON") + def _create_tables_unlocked(self) -> None: + self._get_connection().execute("PRAGMA foreign_keys = ON") gauge_insert = f"{self.gauge_view_name}_insert" counter_insert = f"{self.counter_view_name}_insert" # Series table - self._connection.execute(f""" + self._get_connection().execute(f""" CREATE TABLE IF NOT EXISTS {self.series_table_name} ( series_id INTEGER PRIMARY KEY, type TEXT NOT NULL, @@ -107,7 +119,7 @@ def _create_tables_unlocked(self): """) # Data table - self._connection.execute(f""" + self._get_connection().execute(f""" CREATE TABLE IF NOT EXISTS {self.data_table_name} ( series_id INTEGER NOT NULL, start INTEGER NOT NULL, @@ -123,7 +135,7 @@ def _create_tables_unlocked(self): """) # Gauge view on data table - self._connection.execute(f""" + self._get_connection().execute(f""" CREATE VIEW IF NOT EXISTS {self.gauge_view_name} AS SELECT s.name, @@ -138,7 +150,7 @@ def _create_tables_unlocked(self): """) # INSERT trigger on gauge view - self._connection.execute(f""" + self._get_connection().execute(f""" CREATE TRIGGER IF NOT EXISTS {gauge_insert} INSTEAD OF INSERT ON {self.gauge_view_name} BEGIN @@ -157,7 +169,7 @@ def _create_tables_unlocked(self): """) # Counter view on data table - self._connection.execute(f""" + self._get_connection().execute(f""" CREATE VIEW IF NOT EXISTS {self.counter_view_name} AS SELECT s.name, @@ -173,7 +185,7 @@ def _create_tables_unlocked(self): # INSERT trigger on counter view # If the new value matches the last value, extend that row instead of inserting - self._connection.execute(f""" + self._get_connection().execute(f""" CREATE TRIGGER IF NOT EXISTS {counter_insert} INSTEAD OF INSERT ON {self.counter_view_name} BEGIN @@ -203,9 +215,9 @@ def _create_tables_unlocked(self): END """) - self._connection.commit() + self._get_connection().commit() - def close(self): + def close(self) -> None: with self._lock: if self._connection is not None: self._connection.close() @@ -215,7 +227,7 @@ def __enter__(self) -> "MetricsQLiteClient": self.connect() return self - def __exit__(self, exc_type: object, exc_val: object, exc_tb: object): + def __exit__(self, exc_type: object, exc_val: object, exc_tb: object) -> None: self.close() def query( @@ -241,7 +253,7 @@ def query( Query result (InstantVector, RangeVectorResult, or ScalarResult). """ with self._lock: - return self._engine.query(query, eval_time=time, step=step, timeout=timeout) + return self._get_engine().query(query, eval_time=time, step=step, timeout=timeout) def query_range( self, @@ -266,7 +278,7 @@ def query_range( MatrixResult containing series with multiple samples over time. """ with self._lock: - return self._engine.query_range(query, start=start, end=end, step=step, timeout=timeout) + return self._get_engine().query_range(query, start=start, end=end, step=step, timeout=timeout) @staticmethod def _build_time_filter( @@ -315,9 +327,9 @@ def get_labels( JOIN {self.data_table_name} d USING (series_id) WHERE {time_filter} """ - cursor = self._connection.execute(query, time_params) + cursor = self._get_connection().execute(query, time_params) else: - cursor = self._connection.execute(f"SELECT DISTINCT name, labels FROM {self.series_table_name}") + cursor = self._get_connection().execute(f"SELECT DISTINCT name, labels FROM {self.series_table_name}") rows = cursor.fetchall() if not rows: @@ -359,9 +371,9 @@ def get_label_values( JOIN {self.data_table_name} d USING (series_id) WHERE {time_filter} """ - cursor = self._connection.execute(query, time_params) + cursor = self._get_connection().execute(query, time_params) else: - cursor = self._connection.execute(f"SELECT DISTINCT name FROM {self.series_table_name}") + cursor = self._get_connection().execute(f"SELECT DISTINCT name FROM {self.series_table_name}") return sorted(row["name"] for row in cursor.fetchall()) if time_filter: @@ -371,9 +383,9 @@ def get_label_values( JOIN {self.data_table_name} d USING (series_id) WHERE json_extract(s.labels, '$.{label_name}') IS NOT NULL AND {time_filter} """ - cursor = self._connection.execute(query, time_params) + cursor = self._get_connection().execute(query, time_params) else: - cursor = self._connection.execute( + cursor = self._get_connection().execute( f"SELECT DISTINCT json_extract(labels, '$.{label_name}') AS value " f"FROM {self.series_table_name} " f"WHERE json_extract(labels, '$.{label_name}') IS NOT NULL" @@ -407,7 +419,7 @@ def get_series( seen = set() for selector in match: - series_list = self._engine.find_series(selector, start_ms, end_ms) + series_list = self._get_engine().find_series(selector, start_ms, end_ms) for name, labels_json in series_list: key = (name, labels_json) if key not in seen: @@ -418,7 +430,9 @@ def get_series( return results - def insert_gauge(self, name: str, value: float, timestamp: float | str | datetime, labels: dict | None = None): + def insert_gauge( + self, name: str, value: float, timestamp: float | str | datetime, labels: dict | None = None + ) -> None: """Insert a gauge metric sample. Args: @@ -430,11 +444,11 @@ def insert_gauge(self, name: str, value: float, timestamp: float | str | datetim Raises: CompactedRangeError: If the timestamp falls within a compacted time range. """ - timestamp = parse_timestamp(timestamp) + ts = parse_timestamp(timestamp) labels_json = json.dumps(labels or {}, sort_keys=True) with self._lock: # Check if timestamp falls within a compacted bucket - cursor = self._connection.execute( + cursor = self._get_connection().execute( f""" SELECT d.start, d.end FROM {self.data_table_name} d @@ -444,19 +458,21 @@ def insert_gauge(self, name: str, value: float, timestamp: float | str | datetim AND d.start <= ? AND d.end > ? LIMIT 1 """, - (name, labels_json, timestamp, timestamp), + (name, labels_json, ts, ts), ) row = cursor.fetchone() if row: raise CompactedRangeError(f"Cannot insert into compacted range [{row['start']}, {row['end']})") - self._connection.execute( + self._get_connection().execute( f"INSERT INTO {self.gauge_view_name}(name, labels, start, value) VALUES (?, ?, ?, ?)", - (name, labels_json, timestamp, value), + (name, labels_json, ts, value), ) - self._connection.commit() + self._get_connection().commit() - def insert_counter(self, name: str, value: float, timestamp: float | str | datetime, labels: dict | None = None): + def insert_counter( + self, name: str, value: float, timestamp: float | str | datetime, labels: dict | None = None + ) -> None: """Insert a counter metric sample. Args: @@ -465,14 +481,14 @@ def insert_counter(self, name: str, value: float, timestamp: float | str | datet timestamp: Unix timestamp in milliseconds or datetime. labels: Optional dict of labels. """ - timestamp = parse_timestamp(timestamp) + ts = parse_timestamp(timestamp) labels_json = json.dumps(labels or {}, sort_keys=True) with self._lock: - self._connection.execute( + self._get_connection().execute( f"INSERT INTO {self.counter_view_name}(name, labels, start, value) VALUES (?, ?, ?, ?)", - (name, labels_json, timestamp, value), + (name, labels_json, ts, value), ) - self._connection.commit() + self._get_connection().commit() def write_line_protocol(self, data: str, precision: str = "ns") -> int: """Write data using InfluxDB line protocol. @@ -506,10 +522,7 @@ def write_line_protocol(self, data: str, precision: str = "ns") -> int: continue # Construct metric name - if field_name == "value": - metric_name = point.measurement - else: - metric_name = f"{point.measurement}_{field_name}" + metric_name = point.measurement if field_name == "value" else f"{point.measurement}_{field_name}" self.insert_gauge(metric_name, float(field_value), timestamp_ms, point.tags or None) count += 1 @@ -545,8 +558,20 @@ def compact_gauges( ValueError: If the new interval is not compatible with existing bucket sizes. """ - older_than_ms = int(parse_timestamp(older_than)) - interval_seconds = round(parse_interval(interval)) + older_than_ms = parse_timestamp(older_than) + if older_than_ms is None: + raise ValueError( + f"Invalid timestamp for 'older_than': {older_than!r}. " + "Expected Unix timestamp (ms), ISO 8601 string, or datetime object." + ) + older_than_ms = int(older_than_ms) + interval_seconds = parse_interval(interval) + if interval_seconds is None: + raise ValueError( + f"Invalid interval: {interval!r}. " + "Expected number of seconds or duration string (e.g., '1h', '30m', '1d')." + ) + interval_seconds = round(interval_seconds) interval_ms = interval_seconds * 1000 # Align cutoff to interval boundary (in ms) @@ -586,14 +611,14 @@ def _validate_compaction_interval( if filter_clause: where += f" AND {filter_clause}" - cursor = self._connection.execute( + cursor = self._get_connection().execute( f""" SELECT DISTINCT (d.end - d.start) AS bucket_size FROM {self.data_table_name} AS d JOIN {self.series_table_name} AS s USING (series_id) WHERE {where} """, - [cutoff] + filter_params, + [cutoff, *filter_params], ) existing_sizes = cursor.fetchall() @@ -601,7 +626,7 @@ def _validate_compaction_interval( existing_size = row["bucket_size"] if interval_ms % existing_size != 0: raise ValueError( - f"New interval {interval_ms}ms is not a multiple of " f"existing bucket size {existing_size}ms." + f"New interval {interval_ms}ms is not a multiple of existing bucket size {existing_size}ms." ) def _compact_gauges_unlocked( @@ -626,7 +651,7 @@ def _compact_gauges_unlocked( HAVING row_count > 1 ORDER BY bucket """ - cursor = self._connection.execute(bucket_query, [interval_ms, interval_ms, cutoff] + filter_params) + cursor = self._get_connection().execute(bucket_query, [interval_ms, interval_ms, cutoff, *filter_params]) buckets = cursor.fetchall() total_sample_count = 0 @@ -639,7 +664,7 @@ def _compact_gauges_unlocked( total_samples = row["total_samples"] # Fetch all samples in this bucket - cursor = self._connection.execute( + cursor = self._get_connection().execute( f""" SELECT start, end, sample_count, positive, negative, min, max FROM {self.data_table_name} @@ -672,11 +697,11 @@ def _compact_gauges_unlocked( avg_negative = total_negative / sample_count # Replace multiple rows with single averaged row - self._connection.execute( + self._get_connection().execute( f"DELETE FROM {self.data_table_name} WHERE series_id = ? AND start >= ? AND start < ?", (series_id, bucket_start, bucket_end), ) - self._connection.execute( + self._get_connection().execute( f"INSERT INTO {self.data_table_name} (series_id, start, end, sample_count, positive, negative, min, max) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", ( series_id, @@ -693,5 +718,5 @@ def _compact_gauges_unlocked( total_sample_count += sample_count total_bucket_count += 1 - self._connection.commit() + self._get_connection().commit() return total_sample_count, total_bucket_count diff --git a/metricsqlite/engine/__init__.py b/metricsqlite/engine/__init__.py index 65ef45d..28a54d7 100644 --- a/metricsqlite/engine/__init__.py +++ b/metricsqlite/engine/__init__.py @@ -1,64 +1,57 @@ """MetricsQL query parsing and execution.""" from metricsqlite.engine.executor import ( - Executor, ExecutionError, + Executor, InstantVector, - RangeVectorResult, MatrixResult, - ScalarResult, QueryResult, + RangeVectorResult, Sample, + ScalarResult, ) from metricsqlite.engine.parser import ( - # AST nodes + BinaryExpr, Expr, - MetricSelector, - RangeVector, FunctionCall, - NumberLiteral, - StringLiteral, - BinaryExpr, - UnaryExpr, LabelMatcher, LabelMatchType, - # Lexer - tokenize, + MetricSelector, + NumberLiteral, + ParseError, + RangeVector, + StringLiteral, Token, TokenType, - # Parser + UnaryExpr, parse, - ParseError, + tokenize, ) from metricsqlite.engine.query import QueryEngine __all__ = [ - # Lexer - "tokenize", - "Token", - "TokenType", - # Parser - "parse", - "ParseError", - # AST nodes + "BinaryExpr", + "ExecutionError", + "Executor", "Expr", - "MetricSelector", - "RangeVector", "FunctionCall", - "NumberLiteral", - "StringLiteral", - "BinaryExpr", - "UnaryExpr", + "InstantVector", "LabelMatcher", "LabelMatchType", - # Executor - "Executor", - "ExecutionError", - "InstantVector", - "RangeVectorResult", "MatrixResult", - "ScalarResult", + "MetricSelector", + "NumberLiteral", + "ParseError", + "QueryEngine", "QueryResult", + "RangeVector", + "RangeVectorResult", "Sample", - "QueryEngine", + "ScalarResult", + "StringLiteral", + "Token", + "TokenType", + "UnaryExpr", + "parse", + "tokenize", ] diff --git a/metricsqlite/engine/executor.py b/metricsqlite/engine/executor.py index 2c7ae34..01c8ec6 100644 --- a/metricsqlite/engine/executor.py +++ b/metricsqlite/engine/executor.py @@ -7,36 +7,39 @@ """ import sqlite3 +from collections.abc import Callable from dataclasses import dataclass from metricsqlite.engine.parser import ( + BinaryExpr, Expr, - MetricSelector, - RangeVector, FunctionCall, + MetricSelector, NumberLiteral, + RangeVector, StringLiteral, - BinaryExpr, UnaryExpr, ) from metricsqlite.engine.sqlite import ( - SQLiteAdapter, - RawSeriesSet, RawSeries, + RawSeriesSet, + SQLiteAdapter, +) +from metricsqlite.engine.sqlite import ( Sample as RawSample, ) __all__ = [ - "Executor", "ExecutionError", - "Sample", - "Series", - "Labels", + "Executor", "InstantVector", - "RangeVectorResult", - "ScalarResult", + "Labels", "MatrixResult", "QueryResult", + "RangeVectorResult", + "Sample", + "ScalarResult", + "Series", "raw_to_matrix", ] @@ -500,6 +503,7 @@ def _apply_transformation( inner = self._evaluate(args[0], ctx) # Get the transformation function + fn: Callable[[float], float] if name == "abs": fn = abs elif name == "clamp_min": @@ -508,14 +512,24 @@ def _apply_transformation( min_val = self._evaluate(args[1], ctx) if not isinstance(min_val, (int, float)): raise ExecutionError("clamp_min second argument must be a scalar") - fn = lambda v, m=min_val: max(v, m) + threshold = float(min_val) + + def _clamp_min(v: float, m: float = threshold) -> float: + return max(v, m) + + fn = _clamp_min elif name == "clamp_max": if len(args) != 2: raise ExecutionError("clamp_max requires 2 arguments") max_val = self._evaluate(args[1], ctx) if not isinstance(max_val, (int, float)): raise ExecutionError("clamp_max second argument must be a scalar") - fn = lambda v, m=max_val: min(v, m) + threshold = float(max_val) + + def _clamp_max(v: float, m: float = threshold) -> float: + return min(v, m) + + fn = _clamp_max else: raise ExecutionError(f"Unknown transformation function: {name}") @@ -588,7 +602,7 @@ def _apply_aggregation( def _map_windowed( self, ws: _WindowedSeriesSet, - fn, + fn: Callable[[float], float], ) -> _WindowedSeriesSet: """Apply a function to all values in a _WindowedSeriesSet.""" result = [] @@ -830,7 +844,7 @@ def _apply_binary_op(op: str, left: float, right: float) -> float: elif op == "%": return left % right if right != 0 else float("nan") elif op == "^": - return left**right + return float(left**right) elif op == ">": return float(left > right) elif op == "<": diff --git a/metricsqlite/engine/parser/__init__.py b/metricsqlite/engine/parser/__init__.py index dc6093b..4df02dc 100644 --- a/metricsqlite/engine/parser/__init__.py +++ b/metricsqlite/engine/parser/__init__.py @@ -1,39 +1,36 @@ """MetricsQL parser - lexer, AST, and parser.""" from .ast import ( + BinaryExpr, Expr, - MetricSelector, - RangeVector, FunctionCall, + LabelMatcher, + LabelMatchType, + MetricSelector, NumberLiteral, + RangeVector, StringLiteral, - BinaryExpr, UnaryExpr, - LabelMatcher, - LabelMatchType, ) -from .lexer import tokenize, Token, TokenType, LexerError -from .parser import parse, ParseError, parse_duration_string +from .lexer import LexerError, Token, TokenType, tokenize +from .parser import ParseError, parse, parse_duration_string __all__ = [ - # AST nodes + "BinaryExpr", "Expr", - "MetricSelector", - "RangeVector", "FunctionCall", - "NumberLiteral", - "StringLiteral", - "BinaryExpr", - "UnaryExpr", "LabelMatcher", "LabelMatchType", - # Lexer - "tokenize", + "LexerError", + "MetricSelector", + "NumberLiteral", + "ParseError", + "RangeVector", + "StringLiteral", "Token", "TokenType", - "LexerError", - # Parser + "UnaryExpr", "parse", - "ParseError", "parse_duration_string", + "tokenize", ] diff --git a/metricsqlite/engine/parser/parser.py b/metricsqlite/engine/parser/parser.py index bdacb67..0035178 100644 --- a/metricsqlite/engine/parser/parser.py +++ b/metricsqlite/engine/parser/parser.py @@ -1,16 +1,16 @@ """Parser for MetricsQL queries.""" from .ast import ( + BinaryExpr, Expr, - MetricSelector, - RangeVector, FunctionCall, + LabelMatcher, + LabelMatchType, + MetricSelector, NumberLiteral, + RangeVector, StringLiteral, - BinaryExpr, UnaryExpr, - LabelMatcher, - LabelMatchType, ) from .lexer import Token, TokenType, tokenize, unescape_string @@ -152,13 +152,13 @@ def parse_primary(self) -> Expr: # Number literal if self.match(TokenType.NUMBER): - value = float(self.advance().value) - return NumberLiteral(value) + num_value = float(self.advance().value) + return NumberLiteral(num_value) # String literal if self.match(TokenType.STRING): - value = unescape_string(self.advance().value) - return StringLiteral(value) + str_value = unescape_string(self.advance().value) + return StringLiteral(str_value) # Identifier: could be metric selector or function call if self.match(TokenType.IDENT): diff --git a/metricsqlite/engine/query.py b/metricsqlite/engine/query.py index 792cba9..309d221 100644 --- a/metricsqlite/engine/query.py +++ b/metricsqlite/engine/query.py @@ -10,8 +10,8 @@ QueryResult, raw_to_matrix, ) -from metricsqlite.engine.parser import MetricSelector, LabelMatchType, parse -from metricsqlite.util import parse_timestamp, parse_interval +from metricsqlite.engine.parser import LabelMatchType, MetricSelector, parse +from metricsqlite.util import parse_interval, parse_timestamp class QueryEngine: @@ -119,6 +119,10 @@ def query_range( result = self._executor.execute_range(ast, start_ms, end_ms, step_seconds) + if isinstance(result, float): + # Scalar result - return empty matrix (scalar range queries not supported) + return MatrixResult(series=[]) + return raw_to_matrix(result) def find_series( diff --git a/metricsqlite/engine/sqlite.py b/metricsqlite/engine/sqlite.py index 3f2c97d..8ac9baa 100644 --- a/metricsqlite/engine/sqlite.py +++ b/metricsqlite/engine/sqlite.py @@ -2,9 +2,10 @@ import json import sqlite3 +from collections.abc import Callable from dataclasses import dataclass, field -from .parser import MetricSelector, LabelMatchType +from .parser import LabelMatchType, MetricSelector Labels = dict[str, str] @@ -36,7 +37,7 @@ class RawSeriesSet: series: list[RawSeries] = field(default_factory=list) - def map_values(self, fn) -> "RawSeriesSet": + def map_values(self, fn: Callable[[float], float]) -> "RawSeriesSet": """Apply a function to each sample value.""" return RawSeriesSet( [ @@ -227,10 +228,7 @@ def fetch_instant( sample_end = float(row["end"]) if row["end"] is not None else None # For compacted data, clamp the timestamp to eval_time - if sample_end is not None: - timestamp = min(sample_end, time) - else: - timestamp = sample_start + timestamp = min(sample_end, time) if sample_end is not None else sample_start series_list.append( RawSeries( diff --git a/metricsqlite/fastapi/__init__.py b/metricsqlite/fastapi/__init__.py index ed0ff12..7b6f4a1 100644 --- a/metricsqlite/fastapi/__init__.py +++ b/metricsqlite/fastapi/__init__.py @@ -1 +1,3 @@ from .routes import create_router + +__all__ = ["create_router"] diff --git a/metricsqlite/fastapi/models.py b/metricsqlite/fastapi/models.py index fdac978..26875f4 100644 --- a/metricsqlite/fastapi/models.py +++ b/metricsqlite/fastapi/models.py @@ -1,4 +1,4 @@ -from typing import Annotated, Literal, Generic, TypeVar +from typing import Annotated, Generic, Literal, TypeVar from pydantic import BaseModel, Field @@ -72,6 +72,4 @@ class ErrorResponse(BaseModel): class Response(Generic[T]): def __class_getitem__(cls, item: type) -> type: - return Annotated[ - SuccessResponse[item] | ErrorResponse, Field(discriminator="status") - ] # type: ignore[valid-type] + return Annotated[SuccessResponse[item] | ErrorResponse, Field(discriminator="status")] # type: ignore[return-value, valid-type] diff --git a/metricsqlite/fastapi/routes.py b/metricsqlite/fastapi/routes.py index 8833474..1098dcd 100644 --- a/metricsqlite/fastapi/routes.py +++ b/metricsqlite/fastapi/routes.py @@ -21,18 +21,18 @@ from typing import TYPE_CHECKING +from metricsqlite.engine.executor import ExecutionError, InstantVector, MatrixResult, RangeVectorResult, ScalarResult +from metricsqlite.engine.parser import LexerError, ParseError from metricsqlite.exceptions import QueryError, StorageError from metricsqlite.lineprotocol import LineProtocolError -from metricsqlite.engine.executor import ExecutionError -from metricsqlite.engine.parser import LexerError, ParseError -from metricsqlite.engine.executor import InstantVector, ScalarResult, MatrixResult -from .models import Response, QueryData, RangeQueryData, LabelsData, SeriesData + +from .models import LabelsData, QueryData, RangeQueryData, Response, SeriesData if TYPE_CHECKING: from metricsqlite.client import MetricsQLiteClient -def _error_response(error_type: str, message: str, status_code: int = 400): +def _error_response(error_type: str, message: str, status_code: int = 400) -> JSONResponse: """Create a VictoriaMetrics-compatible error response.""" return JSONResponse( status_code=status_code, @@ -40,49 +40,55 @@ def _error_response(error_type: str, message: str, status_code: int = 400): ) -def _format_query_result(result): +def _format_query_result(result: InstantVector | RangeVectorResult | ScalarResult) -> JSONResponse: """Convert executor result to VictoriaMetrics API format.""" if isinstance(result, InstantVector): - return { + return JSONResponse( + { + "status": "success", + "data": { + "resultType": "vector", + "result": [ + { + "metric": labels, + "value": [sample.timestamp / 1000, str(sample.value)], + } + for labels, sample in result.series + ], + }, + } + ) + elif isinstance(result, ScalarResult): + return JSONResponse( + { + "status": "success", + "data": { + "resultType": "scalar", + "result": [result.timestamp / 1000, str(result.value)], + }, + } + ) + else: + raise ValueError(f"Unexpected result type: {type(result)}") + + +def _format_range_result(result: MatrixResult) -> JSONResponse: + """Convert range query result to VictoriaMetrics API format.""" + return JSONResponse( + { "status": "success", "data": { - "resultType": "vector", + "resultType": "matrix", "result": [ { "metric": labels, - "value": [sample.timestamp / 1000, str(sample.value)], + "values": [[sample.timestamp / 1000, str(sample.value)] for sample in values], } - for labels, sample in result.series + for labels, values in result.series ], }, } - elif isinstance(result, ScalarResult): - return { - "status": "success", - "data": { - "resultType": "scalar", - "result": [result.timestamp / 1000, str(result.value)], - }, - } - else: - raise ValueError(f"Unexpected result type: {type(result)}") - - -def _format_range_result(result: MatrixResult): - """Convert range query result to VictoriaMetrics API format.""" - return { - "status": "success", - "data": { - "resultType": "matrix", - "result": [ - { - "metric": labels, - "values": [[sample.timestamp / 1000, str(sample.value)] for sample in values], - } - for labels, values in result.series - ], - }, - } + ) def create_router(client: "MetricsQLiteClient", enable_writes: bool = False): # type: ignore[no-untyped-def] @@ -108,7 +114,7 @@ def get_query( time: float | str | None = Query(None, description="Evaluation timestamp"), step: float | str | None = Query(None, description="Interval"), timeout: float | str | None = Query(None, description="Query timeout"), - ): + ) -> JSONResponse: try: result = client.query(query, time=time, step=step, timeout=timeout) return _format_query_result(result) @@ -128,7 +134,7 @@ def get_query_range( end: float | str | None = Query(None, description="End timestamp"), step: float | str | None = Query(None, description="Query resolution step in seconds"), timeout: float | str | None = Query(None, description="Query timeout"), - ): + ) -> JSONResponse: try: result = client.query_range(query, start, end=end, step=step, timeout=timeout) return _format_range_result(result) @@ -146,9 +152,9 @@ def get_labels( start: float | str | None = Query(None, description="Start timestamp"), end: float | str | None = Query(None, description="End timestamp"), match: list[str] | None = Query(None, alias="match[]", description="Series selector"), - ): + ) -> JSONResponse: try: - return {"status": "success", "data": client.get_labels(start=start, end=end, match=match)} + return JSONResponse({"status": "success", "data": client.get_labels(start=start, end=end, match=match)}) except StorageError as e: return _error_response("internal", str(e), 500) except ValueError as e: @@ -160,9 +166,11 @@ def get_label_values( start: float | str | None = Query(None, description="Start timestamp"), end: float | str | None = Query(None, description="End timestamp"), match: list[str] | None = Query(None, alias="match[]", description="Series selector"), - ): + ) -> JSONResponse: try: - return {"status": "success", "data": client.get_label_values(label_name, start=start, end=end, match=match)} + return JSONResponse( + {"status": "success", "data": client.get_label_values(label_name, start=start, end=end, match=match)} + ) except StorageError as e: return _error_response("internal", str(e), 500) except ValueError as e: @@ -173,9 +181,9 @@ def get_series( match: list[str] = Query(..., alias="match[]", description="Series selector (required)"), start: float | str | None = Query(None, description="Start timestamp"), end: float | str | None = Query(None, description="End timestamp"), - ): + ) -> JSONResponse: try: - return {"status": "success", "data": client.get_series(match=match, start=start, end=end)} + return JSONResponse({"status": "success", "data": client.get_series(match=match, start=start, end=end)}) except (ParseError, ExecutionError, LexerError) as e: return _error_response("bad_data", str(e), 400) except StorageError as e: @@ -190,7 +198,7 @@ async def influx_write( request: Request, db: str | None = Query(None, description="Database name (ignored)"), precision: str = Query("ns", description="Timestamp precision: ns, us, ms, s"), - ): + ) -> PlainTextResponse: """Write data using InfluxDB line protocol. Compatible with VictoriaMetrics /influx/write endpoint. diff --git a/metricsqlite/lineprotocol.py b/metricsqlite/lineprotocol.py index 506c5d8..385954f 100644 --- a/metricsqlite/lineprotocol.py +++ b/metricsqlite/lineprotocol.py @@ -55,21 +55,21 @@ def _parse_field_value(value: str) -> float | int | str | bool: if value.endswith("i"): try: return int(value[:-1]) - except ValueError: - raise LineProtocolError(f"Invalid integer: {value}") + except ValueError as e: + raise LineProtocolError(f"Invalid integer: {value}") from e # Unsigned integer (suffix u) - treat as int if value.endswith("u"): try: return int(value[:-1]) - except ValueError: - raise LineProtocolError(f"Invalid unsigned integer: {value}") + except ValueError as e: + raise LineProtocolError(f"Invalid unsigned integer: {value}") from e # Float (default) try: return float(value) - except ValueError: - raise LineProtocolError(f"Invalid field value: {value}") + except ValueError as e: + raise LineProtocolError(f"Invalid field value: {value}") from e def _find_field_separator(s: str) -> int: @@ -218,8 +218,8 @@ def parse_line(line: str) -> Point | None: if timestamp_str: try: timestamp = int(timestamp_str) - except ValueError: - raise LineProtocolError(f"Invalid timestamp: {timestamp_str}") + except ValueError as e: + raise LineProtocolError(f"Invalid timestamp: {timestamp_str}") from e return Point(measurement=measurement, tags=tags, fields=fields, timestamp=timestamp) diff --git a/metricsqlite/util.py b/metricsqlite/util.py index c545a2f..f6e1cbf 100644 --- a/metricsqlite/util.py +++ b/metricsqlite/util.py @@ -50,8 +50,8 @@ def parse_timestamp(timestamp: float | str | datetime | None) -> float | None: if value > 4_102_444_800: return value return value * 1000 - except ValueError: - raise ValueError(f"Invalid timestamp format: {timestamp}") + except ValueError as e: + raise ValueError(f"Invalid timestamp format: {timestamp}") from e _INTERVAL_PATTERN = re.compile(r"^(\d+(?:\.\d+)?)(ms|s|m|h|d|w|y)$") @@ -98,5 +98,5 @@ def parse_interval(interval: float | str | None) -> float | None: interval = interval.replace("_", "") try: return float(interval) - except ValueError: - raise ValueError(f"Invalid interval format: {interval}") + except ValueError as e: + raise ValueError(f"Invalid interval format: {interval}") from e diff --git a/pyproject.toml b/pyproject.toml index 91efc0e..17775cb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,12 +8,9 @@ build-backend = "setuptools.build_meta" [tool.setuptools.packages.find] include = ["metricsqlite*"] -[tool.black] -line-length = 120 - [project] name = "metricsqlite" -description = "MetricsQLite - MetricsQL api with SQLite as backend" +description = "MetricSQLite - MetricsQL api with SQLite as backend" version = "0.0.0" authors = [{ name = "David van 't Wout", email = "david@vtwout.com" }] requires-python = ">=3.10" @@ -25,4 +22,45 @@ dependencies = [ fastapi = [ "fastapi>=0.100.0", ] -dev = ["black", "pre-commit", "pytest>=8.0", "mypy>=1.10", "httpx"] +dev = ["pre-commit", "pytest>=8.0", "pytest-cov", "mypy>=1.10", "ruff>=0.4", "httpx"] + + +[tool.ruff] +line-length = 120 +target-version = "py310" +output-format = "concise" + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "F", # pyflakes (unused imports, undefined names) + "I", # isort (import sorting) + "UP", # pyupgrade (modern Python syntax) + "B", # flake8-bugbear (common bugs) + "SIM", # flake8-simplify + "RUF", # Ruff-specific rules +] +ignore = [ + "E501", # line too long (handled by formatter) + "RUF022", # __all__ sorting - alphabetical is fine +] + +[tool.ruff.lint.isort] +known-first-party = ["metricsqlite"] + +[tool.ruff.lint.per-file-ignores] +"metricsqlite/fastapi/*" = ["B008"] # Query() in defaults is standard FastAPI pattern + +[tool.ruff.format] +quote-style = "double" + + +[tool.mypy] +python_version = "3.10" +warn_return_any = true +warn_unused_ignores = true +disallow_untyped_defs = true + +[[tool.mypy.overrides]] +module = "tests.*" +disallow_untyped_defs = false # Tests don't need full type annotations diff --git a/shell.nix b/shell.nix new file mode 100644 index 0000000..37bb3f1 --- /dev/null +++ b/shell.nix @@ -0,0 +1,27 @@ +{ pkgs ? import { } }: + +# Note: ruff is dynamically linked and the version installed by pip won't work on NixOS. +# This can be fixed by adding `programs.nix-ld.enable = true;` to your NixOS config. + +let metricsqlite = pkgs.python3.pkgs.callPackage ./default.nix { }; +in pkgs.mkShell { + packages = with pkgs; [ + (python3.withPackages (pp: + metricsqlite.propagatedBuildInputs ++ (with pp; [ + # Dev tools; + pre-commit-hooks + mypy + ruff + # pytest and dependencies; + pytest + pytest-cov + fastapi + httpx + ]))) + pre-commit + ]; + + shellHook = '' + export PYTHONPATH="$PWD:$PYTHONPATH" + ''; +} diff --git a/tests/engine/test_ast.py b/tests/engine/test_ast.py index d39247e..d3e0f0a 100644 --- a/tests/engine/test_ast.py +++ b/tests/engine/test_ast.py @@ -1,15 +1,15 @@ """Tests for the MetricsQL AST nodes.""" from metricsqlite.engine.parser import ( - MetricSelector, - RangeVector, + BinaryExpr, FunctionCall, + LabelMatcher, + LabelMatchType, + MetricSelector, NumberLiteral, + RangeVector, StringLiteral, - BinaryExpr, UnaryExpr, - LabelMatcher, - LabelMatchType, ) from metricsqlite.engine.parser.ast import _format_duration diff --git a/tests/engine/test_executor.py b/tests/engine/test_executor.py index 6109f0b..c7725c2 100644 --- a/tests/engine/test_executor.py +++ b/tests/engine/test_executor.py @@ -4,13 +4,13 @@ from metricsqlite import MetricsQLiteClient from metricsqlite.engine import ( - parse, + ExecutionError, Executor, InstantVector, + QueryEngine, RangeVectorResult, ScalarResult, - ExecutionError, - QueryEngine, + parse, ) EVAL_TIME = 946_681_200_000 # 2000-01-01 00:00:00 UTC diff --git a/tests/engine/test_lexer.py b/tests/engine/test_lexer.py index e22c652..b40e66e 100644 --- a/tests/engine/test_lexer.py +++ b/tests/engine/test_lexer.py @@ -2,7 +2,7 @@ import pytest -from metricsqlite.engine.parser import tokenize, TokenType, LexerError +from metricsqlite.engine.parser import LexerError, TokenType, tokenize class TestTokenize: @@ -40,7 +40,7 @@ def test_selector_multiple_labels(self): def test_single_quoted_string(self): tokens = tokenize("metric{label='value'}") - string_token = [t for t in tokens if t.type == TokenType.STRING][0] + string_token = next(t for t in tokens if t.type == TokenType.STRING) assert string_token.value == "'value'" def test_duration_simple(self): @@ -84,7 +84,7 @@ def test_function_call(self): def test_function_with_number_arg(self): tokens = tokenize("clamp_min(metric, 0)") - number_token = [t for t in tokens if t.type == TokenType.NUMBER][0] + number_token = next(t for t in tokens if t.type == TokenType.NUMBER) assert number_token.value == "0" def test_negative_number(self): @@ -98,12 +98,12 @@ def test_negative_number(self): def test_decimal_number(self): tokens = tokenize("metric * 3.14") - number_token = [t for t in tokens if t.type == TokenType.NUMBER][0] + number_token = next(t for t in tokens if t.type == TokenType.NUMBER) assert number_token.value == "3.14" def test_scientific_notation(self): tokens = tokenize("metric * 1e6") - number_token = [t for t in tokens if t.type == TokenType.NUMBER][0] + number_token = next(t for t in tokens if t.type == TokenType.NUMBER) assert number_token.value == "1e6" def test_operators(self): diff --git a/tests/engine/test_parser.py b/tests/engine/test_parser.py index e75af9f..41be179 100644 --- a/tests/engine/test_parser.py +++ b/tests/engine/test_parser.py @@ -3,16 +3,16 @@ import pytest from metricsqlite.engine.parser import ( - MetricSelector, - RangeVector, + BinaryExpr, FunctionCall, + LabelMatchType, + MetricSelector, NumberLiteral, + ParseError, + RangeVector, StringLiteral, - BinaryExpr, UnaryExpr, - LabelMatchType, parse, - ParseError, parse_duration_string, ) diff --git a/tests/fastapi/test_routes.py b/tests/fastapi/test_routes.py index 83aefd1..b9c21d8 100644 --- a/tests/fastapi/test_routes.py +++ b/tests/fastapi/test_routes.py @@ -1,5 +1,4 @@ import pytest - from fastapi import FastAPI from fastapi.testclient import TestClient @@ -51,7 +50,7 @@ def test_query_with_labels(self, test_client): assert data["data"]["result"][0]["metric"]["location"] == "kitchen" def test_query_no_results(self, test_client): - http, db = test_client + http, _db = test_client response = http.get("/api/v1/query", params={"query": "nonexistent", "time": EVAL_TIME}) @@ -61,7 +60,7 @@ def test_query_no_results(self, test_client): assert data["data"]["result"] == [] def test_query_parse_error(self, test_client): - http, db = test_client + http, _db = test_client response = http.get("/api/v1/query", params={"query": "invalid{{{", "time": EVAL_TIME}) @@ -72,7 +71,7 @@ def test_query_parse_error(self, test_client): assert "error" in data def test_query_missing_required_param(self, test_client): - http, db = test_client + http, _db = test_client response = http.get("/api/v1/query") @@ -104,7 +103,7 @@ def test_query_range_success(self, test_client): assert len(data["data"]["result"][0]["values"]) >= 1 def test_query_range_parse_error(self, test_client): - http, db = test_client + http, _db = test_client response = http.get( "/api/v1/query_range", @@ -132,7 +131,7 @@ def test_labels_success(self, test_client): assert "floor" in data["data"] def test_labels_empty(self, test_client): - http, db = test_client + http, _db = test_client response = http.get("/api/v1/labels") @@ -241,14 +240,14 @@ def test_series_no_match(self, test_client): assert data["data"] == [] def test_series_missing_match_param(self, test_client): - http, db = test_client + http, _db = test_client response = http.get("/api/v1/series") assert response.status_code == 422 # FastAPI validation error def test_series_parse_error(self, test_client): - http, db = test_client + http, _db = test_client response = http.get("/api/v1/series", params={"match[]": "invalid{{{"}) @@ -276,7 +275,7 @@ def write_client(): class TestInfluxWriteEndpoint: def test_write_disabled_by_default(self, test_client): """Write endpoint should not exist when writes are disabled.""" - http, db = test_client + http, _db = test_client response = http.post("/api/v1/influx/write", content="temperature value=25.5") @@ -299,7 +298,7 @@ def test_write_simple(self, write_client): assert rows[0]["name"] == "temperature" def test_write_with_tags(self, write_client): - http, db = write_client + http, _db = write_client response = http.post( "/api/v1/influx/write", @@ -323,7 +322,7 @@ def test_write_multiple_lines(self, write_client): assert cursor.fetchone()["cnt"] == 2 def test_write_precision_seconds(self, write_client): - http, db = write_client + http, _db = write_client response = http.post( "/api/v1/influx/write", @@ -334,7 +333,7 @@ def test_write_precision_seconds(self, write_client): assert response.status_code == 204 def test_write_parse_error(self, write_client): - http, db = write_client + http, _db = write_client response = http.post("/api/v1/influx/write", content="invalid line protocol!!!") @@ -342,7 +341,7 @@ def test_write_parse_error(self, write_client): def test_write_db_param_ignored(self, write_client): """The db parameter should be accepted but ignored.""" - http, db = write_client + http, _db = write_client response = http.post( "/api/v1/influx/write", diff --git a/tests/queries/test_query_range.py b/tests/queries/test_query_range.py index 6358466..c534bf2 100644 --- a/tests/queries/test_query_range.py +++ b/tests/queries/test_query_range.py @@ -54,7 +54,7 @@ def test_query_range_lookback(self, client: MetricsQLiteClient): result = client.query_range(query="metric", start=START, end=START + 3_600_000, step="5m") assert isinstance(result, MatrixResult) - labels, series = result.series[0] + _labels, series = result.series[0] assert [s.value for s in series] == [2, 3, 4, 5, 5] assert series[0].timestamp == START assert series[3].timestamp == START + 900_000 @@ -70,7 +70,7 @@ def test_query_range_unaligned_samples(self, client: MetricsQLiteClient): result = client.query_range(query="metric", start=START, end=START + 3_600_000, step="5m") assert isinstance(result, MatrixResult) - labels, series = result.series[0] + _labels, series = result.series[0] assert [s.value for s in series] == [1, 2, 3, 4] assert series[0].timestamp == START assert series[3].timestamp == START + 900_000 @@ -80,7 +80,7 @@ def test_only_latest_sample_in_result(self, client: MetricsQLiteClient): client.insert_gauge("metric", minute, START + 60_000 * minute) result = client.query_range(query="metric", start=START, end=START + 3_600_000, step="10m") - labels, series = result.series[0] + _labels, series = result.series[0] assert len(series) == 7 assert series[0].timestamp == START assert series[6].timestamp == START + 3_600_000 diff --git a/tests/queries/test_selectors.py b/tests/queries/test_selectors.py index 3d8cd37..cdff5f4 100644 --- a/tests/queries/test_selectors.py +++ b/tests/queries/test_selectors.py @@ -1,5 +1,7 @@ -import pytest import time + +import pytest + from metricsqlite import MetricsQLiteClient from metricsqlite.engine import InstantVector, RangeVectorResult, Sample @@ -26,8 +28,8 @@ def test_selector(self, client: MetricsQLiteClient): result = client.query(query="metric", time=EVAL_TIME) assert isinstance(result, InstantVector) assert len(result.series) == 1 - labels, sample = result.series[0] - assert labels == {"__name__": "metric"} + _labels, sample = result.series[0] + assert _labels == {"__name__": "metric"} assert isinstance(sample, Sample) assert sample.timestamp == EVAL_TIME assert sample.value == value @@ -51,7 +53,7 @@ def test_selector_timestamp(self, client: MetricsQLiteClient): client.insert_gauge("metric", 42, EVAL_TIME - 1_000) result = client.query(query="metric", time=EVAL_TIME) - labels, sample = result.series[0] + _labels, sample = result.series[0] assert sample.timestamp == EVAL_TIME - 1_000 def test_selector_correct_sample(self, client: MetricsQLiteClient): @@ -59,17 +61,17 @@ def test_selector_correct_sample(self, client: MetricsQLiteClient): client.insert_gauge("metric", 1, EVAL_TIME - 2_000) client.insert_gauge("metric", 2, EVAL_TIME - 1_000) result = client.query(query="metric", time=EVAL_TIME) - labels, sample = result.series[0] + _labels, sample = result.series[0] assert sample.value == 2 client.insert_gauge("metric", 3, EVAL_TIME) result = client.query(query="metric", time=EVAL_TIME) - labels, sample = result.series[0] + _labels, sample = result.series[0] assert sample.value == 3 client.insert_gauge("metric", 4, EVAL_TIME + 1) result = client.query(query="metric", time=EVAL_TIME) - labels, sample = result.series[0] + _labels, sample = result.series[0] assert sample.value == 3 def test_query_without_time(self, client: MetricsQLiteClient): @@ -79,7 +81,7 @@ def test_query_without_time(self, client: MetricsQLiteClient): client.insert_gauge("metric", 42, insertion_time) result = client.query(query="metric") - labels, sample = result.series[0] + _labels, sample = result.series[0] assert sample.timestamp == insertion_time def test_selector_on_compacted_gauge(self, client: MetricsQLiteClient): @@ -99,19 +101,19 @@ def test_selector_on_compacted_gauge(self, client: MetricsQLiteClient): # Should return sample from bucket [t-10:t] result = client.query(query="metric", time=EVAL_TIME - 1_000) - labels, sample = result.series[0] + _labels, sample = result.series[0] assert sample.timestamp == EVAL_TIME - 1_000 assert sample.value == 3.5 # Exactly on bucket boundary. Should still return sample from bucket [t-10:t] # result = client.query(query="metric", time=EVAL_TIME) - # labels, sample = result.series[0] + # _labels, sample = result.series[0] # assert sample.timestamp == EVAL_TIME # assert sample.value == 3.5 # Should return sample from bucket [t:t+10] result = client.query(query="metric", time=EVAL_TIME + 1_000) - labels, sample = result.series[0] + _labels, sample = result.series[0] assert sample.timestamp == EVAL_TIME + 1_000 assert sample.value == 5.5 @@ -122,7 +124,7 @@ def test_counters(self, client: MetricsQLiteClient): client.insert_counter("counter", 42, EVAL_TIME - 15_000) result = client.query("counter", time=EVAL_TIME) assert isinstance(result, InstantVector) - labels, sample = result.series[0] + _labels, sample = result.series[0] assert sample.timestamp == EVAL_TIME - 15_000 assert sample.value == 42 @@ -130,7 +132,7 @@ def test_counters(self, client: MetricsQLiteClient): client.insert_counter("counter", 42, EVAL_TIME - 5_000) result = client.query("counter", time=EVAL_TIME) assert isinstance(result, InstantVector) - labels, sample = result.series[0] + _labels, sample = result.series[0] assert sample.timestamp == EVAL_TIME - 5_000 assert sample.value == 42 assert sample.sample_count == 2 @@ -139,7 +141,7 @@ def test_counters(self, client: MetricsQLiteClient): client.insert_counter("counter", 42, EVAL_TIME + 5_000) result = client.query("counter", time=EVAL_TIME) assert isinstance(result, InstantVector) - labels, sample = result.series[0] + _labels, sample = result.series[0] assert sample.timestamp == EVAL_TIME assert sample.value == 42 assert sample.sample_count == 3 @@ -147,7 +149,7 @@ def test_counters(self, client: MetricsQLiteClient): client.insert_counter("counter", 67, EVAL_TIME + 15_000) result = client.query("counter", time=EVAL_TIME + 20_000) assert isinstance(result, InstantVector) - labels, sample = result.series[0] + _labels, sample = result.series[0] assert sample.timestamp == EVAL_TIME + 15_000 assert sample.value == 67 @@ -163,7 +165,7 @@ def test_stale_counters(self, client: MetricsQLiteClient): result = client.query("counter", time=EVAL_TIME, step="5m") assert isinstance(result, InstantVector) assert len(result.series) == 1 - labels, sample = result.series[0] + _labels, sample = result.series[0] assert sample.timestamp == EVAL_TIME - 240_000 assert sample.value == 42 @@ -176,7 +178,7 @@ def test_sample_completely_spans_lookback_window(self, client: MetricsQLiteClien result = client.query("counter", time=EVAL_TIME, step="5m") assert isinstance(result, InstantVector) assert len(result.series) == 1 - labels, sample = result.series[0] + _labels, sample = result.series[0] assert sample.value == 42 # Timestamp should be clamped to eval_time since sample_end > eval_time assert sample.timestamp == EVAL_TIME @@ -199,7 +201,7 @@ def test_selector_compacted_gauge_spans_lookback_window(self, client: MetricsQLi result = client.query("gauge", time=EVAL_TIME - 60_000, step="5m") assert isinstance(result, InstantVector) - labels, sample = result.series[0] + _labels, sample = result.series[0] assert sample.timestamp == EVAL_TIME - 60_000 # clamped to query eval time (T-1m) assert sample.value == 42 @@ -217,7 +219,7 @@ def test_range_selector(self, client: MetricsQLiteClient): result = client.query(query="metric[10s]", time=EVAL_TIME) assert isinstance(result, RangeVectorResult) assert len(result.series) == 1 - labels, samples = result.series[0] + _labels, samples = result.series[0] assert len(samples) == 2 assert samples[0].timestamp == EVAL_TIME - 7_000 assert samples[0].value == 2 @@ -235,7 +237,7 @@ def test_range_selector_counter_fully_within_range(self, client: MetricsQLiteCli assert isinstance(result, RangeVectorResult) assert len(result.series) == 1 - labels, samples = result.series[0] + _labels, samples = result.series[0] assert len(samples) == 2 assert samples[0].timestamp == EVAL_TIME - 240_000 # start assert samples[1].timestamp == EVAL_TIME - 120_000 # end @@ -253,7 +255,7 @@ def test_range_selector_counter_start_before_range(self, client: MetricsQLiteCli assert isinstance(result, RangeVectorResult) assert len(result.series) == 1 - labels, samples = result.series[0] + _labels, samples = result.series[0] assert len(samples) == 2 assert samples[0].timestamp == EVAL_TIME - 300_000 # clamped to query_start (T-5m) assert samples[1].timestamp == EVAL_TIME - 120_000 # actual end (T-2m) @@ -271,7 +273,7 @@ def test_range_selector_counter_end_after_range(self, client: MetricsQLiteClient assert isinstance(result, RangeVectorResult) assert len(result.series) == 1 - labels, samples = result.series[0] + _labels, samples = result.series[0] assert len(samples) == 2 assert samples[0].timestamp == EVAL_TIME - 240_000 # actual start (T-4m) assert samples[1].timestamp == EVAL_TIME # clamped to query_end (T) @@ -289,7 +291,7 @@ def test_range_selector_counter_spans_entire_range(self, client: MetricsQLiteCli assert isinstance(result, RangeVectorResult) assert len(result.series) == 1 - labels, samples = result.series[0] + _labels, samples = result.series[0] assert len(samples) == 2 assert samples[0].timestamp == EVAL_TIME - 300_000 # clamped to query_start (T-5m) assert samples[1].timestamp == EVAL_TIME # clamped to query_end (T) @@ -305,7 +307,7 @@ def test_range_selector_singular_counter(self, client: MetricsQLiteClient): assert isinstance(result, RangeVectorResult) assert len(result.series) == 1 - labels, samples = result.series[0] + _labels, samples = result.series[0] assert len(samples) == 1 assert samples[0].timestamp == EVAL_TIME - 120_000 assert samples[0].value == 42 @@ -324,7 +326,7 @@ def test_range_selector_compacted_gauge_fully_within_range(self, client: Metrics assert isinstance(result, RangeVectorResult) assert len(result.series) == 1 - labels, samples = result.series[0] + _labels, samples = result.series[0] assert len(samples) == 2 assert samples[0].timestamp == EVAL_TIME - 240_000 # bucket start assert samples[1].timestamp == EVAL_TIME - 120_000 # bucket end (start + 2m) @@ -345,7 +347,7 @@ def test_range_selector_compacted_gauge_start_before_range(self, client: Metrics assert isinstance(result, RangeVectorResult) assert len(result.series) == 1 - labels, samples = result.series[0] + _labels, samples = result.series[0] # First sample should be clamped to query_start (T-5m) assert samples[0].timestamp == EVAL_TIME - 300_000 # clamped to query_start assert samples[1].timestamp == EVAL_TIME - 240_000 # actual bucket end @@ -369,7 +371,7 @@ def test_range_selector_compacted_gauge_spans_entire_range(self, client: Metrics assert isinstance(result, RangeVectorResult) assert len(result.series) == 1 - labels, samples = result.series[0] + _labels, samples = result.series[0] assert len(samples) == 2 assert samples[0].timestamp == EVAL_TIME - 360_000 # clamped to query_start (T-6m) assert samples[0].value == 42 @@ -384,7 +386,7 @@ def test_range_selector_singular_gauge(self, client: MetricsQLiteClient): assert isinstance(result, RangeVectorResult) assert len(result.series) == 1 - labels, samples = result.series[0] + _labels, samples = result.series[0] assert len(samples) == 1 assert samples[0].timestamp == EVAL_TIME - 120_000 assert samples[0].value == 42 diff --git a/tests/test_client.py b/tests/test_client.py index d9710bb..1b2b1bf 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -2,7 +2,7 @@ import pytest -from metricsqlite import MetricsQLiteClient, CompactedRangeError +from metricsqlite import CompactedRangeError, MetricsQLiteClient EVAL_TIME = 946_681_200_000 # 2000-01-01 00:00:00 UTC diff --git a/tests/test_lineprotocol.py b/tests/test_lineprotocol.py index 30582a6..add60ab 100644 --- a/tests/test_lineprotocol.py +++ b/tests/test_lineprotocol.py @@ -2,7 +2,6 @@ from metricsqlite.lineprotocol import ( LineProtocolError, - Point, parse_line, parse_lines, timestamp_to_ms,