From 7bd79593c825b541bbdb4ca41a9bbeaf068e9bb3 Mon Sep 17 00:00:00 2001 From: Stef Pletinck Date: Fri, 10 Oct 2025 09:58:19 +0200 Subject: [PATCH 1/4] Integrate ruff linting Also removes existing errors --- .github/workflows/ci.yml | 1 + src/obelisk/asynchronous/base.py | 3 ++- src/obelisk/asynchronous/client.py | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a4f0ce2..85b0838 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,4 +33,5 @@ jobs: - uses: actions/checkout@v4 - uses: astral-sh/setup-uv@v6 - run: uv python install python3.13 + - run: uv run ruff check src/obelisk/ - run: uv run pytest diff --git a/src/obelisk/asynchronous/base.py b/src/obelisk/asynchronous/base.py index 66d4dde..e722c9b 100644 --- a/src/obelisk/asynchronous/base.py +++ b/src/obelisk/asynchronous/base.py @@ -94,7 +94,8 @@ async def _verify_token(self): try: await self._get_token() return - except: + except: # noqa: E722 + self.log.info("excepted, Retrying token fetch") continue async def http_post(self, url: str, data: Any = None, diff --git a/src/obelisk/asynchronous/client.py b/src/obelisk/asynchronous/client.py index 2cd656a..a46d047 100644 --- a/src/obelisk/asynchronous/client.py +++ b/src/obelisk/asynchronous/client.py @@ -1,7 +1,7 @@ import json from datetime import datetime, timedelta from math import floor -from typing import AsyncGenerator, Generator, List, Literal, Optional +from typing import AsyncGenerator, List, Literal, Optional import httpx from pydantic import ValidationError From 81f6ebecf30cce055c440961669662f1d14e5991 Mon Sep 17 00:00:00 2001 From: Stef Pletinck Date: Fri, 10 Oct 2025 10:00:49 +0200 Subject: [PATCH 2/4] Integrate ruff format checking --- .github/workflows/ci.yml | 1 + src/obelisk/asynchronous/__init__.py | 3 +- src/obelisk/asynchronous/base.py | 86 ++++++++++-------- src/obelisk/asynchronous/client.py | 4 +- src/obelisk/asynchronous/core.py | 128 ++++++++++++++++----------- src/obelisk/exceptions.py | 3 + src/obelisk/strategies/retry.py | 21 ++++- src/obelisk/sync/__init__.py | 1 + src/obelisk/types/__init__.py | 44 ++++----- src/obelisk/types/core.py | 10 ++- 10 files changed, 180 insertions(+), 121 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 85b0838..eab655a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,4 +34,5 @@ jobs: - uses: astral-sh/setup-uv@v6 - run: uv python install python3.13 - run: uv run ruff check src/obelisk/ + - run: uv run ruff format --check src/obelisk/ - run: uv run pytest diff --git a/src/obelisk/asynchronous/__init__.py b/src/obelisk/asynchronous/__init__.py index 852a74b..eaff72d 100644 --- a/src/obelisk/asynchronous/__init__.py +++ b/src/obelisk/asynchronous/__init__.py @@ -5,6 +5,7 @@ Relevant entrance points are :class:`client.Obelisk`. It can be imported from the :mod:`.client` module, or directly from this one. """ -__all__= ['Obelisk', 'core'] + +__all__ = ["Obelisk", "core"] from .client import Obelisk from . import core diff --git a/src/obelisk/asynchronous/base.py b/src/obelisk/asynchronous/base.py index e722c9b..6a72a95 100644 --- a/src/obelisk/asynchronous/base.py +++ b/src/obelisk/asynchronous/base.py @@ -6,8 +6,7 @@ import httpx from obelisk.exceptions import AuthenticationError -from obelisk.strategies.retry import RetryStrategy, \ - NoRetryStrategy +from obelisk.strategies.retry import RetryStrategy, NoRetryStrategy from obelisk.types import ObeliskKind @@ -32,27 +31,33 @@ class BaseClient: log: logging.Logger - def __init__(self, client: str, secret: str, - retry_strategy: RetryStrategy = NoRetryStrategy(), - kind: ObeliskKind = ObeliskKind.CLASSIC) -> None: + def __init__( + self, + client: str, + secret: str, + retry_strategy: RetryStrategy = NoRetryStrategy(), + kind: ObeliskKind = ObeliskKind.CLASSIC, + ) -> None: self._client = client self._secret = secret self.retry_strategy = retry_strategy self.kind = kind - self.log = logging.getLogger('obelisk') + self.log = logging.getLogger("obelisk") async def _get_token(self): - auth_string = str(base64.b64encode( - f'{self._client}:{self._secret}'.encode('utf-8')), 'utf-8') + auth_string = str( + base64.b64encode(f"{self._client}:{self._secret}".encode("utf-8")), "utf-8" + ) headers = { - 'Authorization': f'Basic {auth_string}', - 'Content-Type': ('application/json' - if self.kind.use_json_auth else 'application/x-www-form-urlencoded') - } - payload = { - 'grant_type': 'client_credentials' + "Authorization": f"Basic {auth_string}", + "Content-Type": ( + "application/json" + if self.kind.use_json_auth + else "application/x-www-form-urlencoded" + ), } + payload = {"grant_type": "client_credentials"} async with httpx.AsyncClient() as client: response = None @@ -64,7 +69,8 @@ async def _get_token(self): self.kind.token_url, json=payload if self.kind.use_json_auth else None, data=payload if not self.kind.use_json_auth else None, - headers=headers) + headers=headers, + ) response = request.json() except Exception as e: @@ -76,17 +82,19 @@ async def _get_token(self): raise last_error if request.status_code != 200: - if 'error' in response: + if "error" in response: self.log.warning(f"Could not authenticate, {response['error']}") raise AuthenticationError - self._token = response['access_token'] - self._token_expires = (datetime.now() - + timedelta(seconds=response['expires_in'])) + self._token = response["access_token"] + self._token_expires = datetime.now() + timedelta( + seconds=response["expires_in"] + ) async def _verify_token(self): - if (self._token is None - or self._token_expires < (datetime.now() - self.grace_period)): + if self._token is None or self._token_expires < ( + datetime.now() - self.grace_period + ): retry = self.retry_strategy.make() first = True while first or await retry.should_retry(): @@ -94,12 +102,13 @@ async def _verify_token(self): try: await self._get_token() return - except: # noqa: E722 + except: # noqa: E722 self.log.info("excepted, Retrying token fetch") continue - async def http_post(self, url: str, data: Any = None, - params: Optional[dict] = None) -> httpx.Response: + async def http_post( + self, url: str, data: Any = None, params: Optional[dict] = None + ) -> httpx.Response: """ Send an HTTP POST request to Obelisk, with proper auth. @@ -114,8 +123,8 @@ async def http_post(self, url: str, data: Any = None, await self._verify_token() headers = { - 'Authorization': f'Bearer {self._token}', - 'Content-Type': 'application/json' + "Authorization": f"Bearer {self._token}", + "Content-Type": "application/json", } if params is None: params = {} @@ -128,11 +137,12 @@ async def http_post(self, url: str, data: Any = None, self.log.debug(f"Retrying, last response: {response.status_code}") try: - response = await client.post(url, - json=data, - params={k: v for k, v in params.items() if - v is not None}, - headers=headers) + response = await client.post( + url, + json=data, + params={k: v for k, v in params.items() if v is not None}, + headers=headers, + ) if response.status_code // 100 == 2: return response @@ -145,7 +155,6 @@ async def http_post(self, url: str, data: Any = None, raise last_error return response - async def http_get(self, url: str, params: Optional[dict] = None) -> httpx.Response: """ Send an HTTP GET request to Obelisk, @@ -161,8 +170,8 @@ async def http_get(self, url: str, params: Optional[dict] = None) -> httpx.Respo await self._verify_token() headers = { - 'Authorization': f'Bearer {self._token}', - 'Content-Type': 'application/json' + "Authorization": f"Bearer {self._token}", + "Content-Type": "application/json", } if params is None: params = {} @@ -175,10 +184,11 @@ async def http_get(self, url: str, params: Optional[dict] = None) -> httpx.Respo self.log.debug(f"Retrying, last response: {response.status_code}") try: - response = await client.get(url, - params={k: v for k, v in params.items() if - v is not None}, - headers=headers) + response = await client.get( + url, + params={k: v for k, v in params.items() if v is not None}, + headers=headers, + ) if response.status_code // 100 == 2: return response diff --git a/src/obelisk/asynchronous/client.py b/src/obelisk/asynchronous/client.py index a46d047..263928d 100644 --- a/src/obelisk/asynchronous/client.py +++ b/src/obelisk/asynchronous/client.py @@ -90,7 +90,8 @@ async def fetch_single_chunk( "limitBy": limit_by, } response = await self.http_post( - self.kind.query_url, data={k: v for k, v in payload.items() if v is not None} + self.kind.query_url, + data={k: v for k, v in payload.items() if v is not None}, ) if response.status_code != 200: self.log.warning(f"Unexpected status code: {response.status_code}") @@ -188,7 +189,6 @@ async def query( return result_set - async def query_time_chunked( self, datasets: List[str], diff --git a/src/obelisk/asynchronous/core.py b/src/obelisk/asynchronous/core.py index 85a18fb..d6e2ca3 100644 --- a/src/obelisk/asynchronous/core.py +++ b/src/obelisk/asynchronous/core.py @@ -7,6 +7,7 @@ This API vaguely resembles that of clients to previous Obelisk versions, but also significantly diverts from it where the underlying Obelisk CORE API does so. """ + from obelisk.asynchronous.base import BaseClient from obelisk.exceptions import ObeliskError from obelisk.types.core import FieldName, Filter @@ -14,8 +15,26 @@ from datetime import datetime, timedelta import httpx import json -from pydantic import BaseModel, AwareDatetime, ConfigDict, Field, ValidationError, model_validator -from typing import Annotated, AsyncIterator, Dict, Iterator, List, Literal, Optional, Any, cast, get_args +from pydantic import ( + BaseModel, + AwareDatetime, + ConfigDict, + Field, + ValidationError, + model_validator, +) +from typing import ( + Annotated, + AsyncIterator, + Dict, + Iterator, + List, + Literal, + Optional, + Any, + cast, + get_args, +) from typing_extensions import Self from numbers import Number @@ -23,7 +42,7 @@ from obelisk.types import ObeliskKind -DataType = Literal['number', 'number[]', 'json', 'bool', 'string'] +DataType = Literal["number", "number[]", "json", "bool", "string"] """The possible types of data Obelisk can accept""" @@ -35,18 +54,20 @@ def type_suffix(metric: str) -> DataType: Throws a :py:exc:`ValueError` if the provided string does not appear to be a typed metric, or the found type suffix is not a known one. """ - split = metric.split('::') + split = metric.split("::") if len(split) != 2: raise ValueError("Incorrect amount of type qualifiers") suffix = split[1] if suffix not in get_args(DataType): - raise ValueError(f"Invalid type suffix, should be one of {', '.join(get_args(DataType))}") + raise ValueError( + f"Invalid type suffix, should be one of {', '.join(get_args(DataType))}" + ) return cast(DataType, suffix) -Aggregator = Literal['last', 'min', 'mean', 'max', 'count', 'stddev'] +Aggregator = Literal["last", "min", "mean", "max", "count", "stddev"] """Type of aggregation Obelisk can process""" @@ -67,33 +88,42 @@ class ObeliskPosition(BaseModel): class IncomingDatapoint(BaseModel): - """ A datapoint to be submitted to Obelisk. These are validated quite extensively, but not fully. + """A datapoint to be submitted to Obelisk. These are validated quite extensively, but not fully. .. automethod:: check_metric_type(self) """ + timestamp: Optional[AwareDatetime] = None metric: str value: Any labels: Optional[Dict[str, str]] = None location: Optional[ObeliskPosition] = None - @model_validator(mode='after') + @model_validator(mode="after") def check_metric_type(self) -> Self: suffix = type_suffix(self.metric) - if suffix == 'number' and not isinstance(self.value, Number): - raise ValueError(f"Type suffix mismatch, expected number, got {type(self.value)}") + if suffix == "number" and not isinstance(self.value, Number): + raise ValueError( + f"Type suffix mismatch, expected number, got {type(self.value)}" + ) - if suffix == 'number[]': - if type(self.value) is not list or any([not isinstance(x, Number) for x in self.value]): + if suffix == "number[]": + if type(self.value) is not list or any( + [not isinstance(x, Number) for x in self.value] + ): raise ValueError("Type suffix mismatch, expected value of number[]") # Do not check json, most things should be serialisable - if suffix == 'bool' and type(self.value) is not bool: - raise ValueError(f"Type suffix mismatch, expected bool, got {type(self.value)}") + if suffix == "bool" and type(self.value) is not bool: + raise ValueError( + f"Type suffix mismatch, expected bool, got {type(self.value)}" + ) - if suffix == 'string' and type(self.value) is not str: - raise ValueError(f"Type suffix mismatch, expected bool, got {type(self.value)}") + if suffix == "string" and type(self.value) is not str: + raise ValueError( + f"Type suffix mismatch, expected bool, got {type(self.value)}" + ) return self @@ -103,9 +133,13 @@ class QueryParams(BaseModel): groupBy: Optional[List[FieldName]] = None aggregator: Optional[Aggregator] = None fields: Optional[List[FieldName]] = None - orderBy: Optional[List[str]] = None # More complex than just FieldName, can be prefixed with - to invert sort + orderBy: Optional[List[str]] = ( + None # More complex than just FieldName, can be prefixed with - to invert sort + ) dataType: Optional[DataType] = None - filter_: Annotated[Optional[str|Filter], Field(serialization_alias='filter')] = None + filter_: Annotated[Optional[str | Filter], Field(serialization_alias="filter")] = ( + None + ) """ Obelisk CORE handles filtering in `RSQL format `__ , to make it easier to also programatically write these filters, we provide the :class:`Filter` option as well. @@ -117,9 +151,9 @@ class QueryParams(BaseModel): model_config = ConfigDict(arbitrary_types_allowed=True) - @model_validator(mode='after') + @model_validator(mode="after") def check_datatype_needed(self) -> Self: - if self.fields is None or 'value' in self.fields: + if self.fields is None or "value" in self.fields: if self.dataType is None: raise ValueError("Value field requested, must specify datatype") @@ -134,7 +168,9 @@ class ChunkedParams(BaseModel): groupBy: Optional[List[FieldName]] = None aggregator: Optional[Aggregator] = None fields: Optional[List[FieldName]] = None - orderBy: Optional[List[str]] = None # More complex than just FieldName, can be prefixed with - to invert sort + orderBy: Optional[List[str]] = ( + None # More complex than just FieldName, can be prefixed with - to invert sort + ) dataType: Optional[DataType] = None filter_: Optional[str | Filter] = None """Underscore suffix to avoid name collisions""" @@ -144,9 +180,9 @@ class ChunkedParams(BaseModel): model_config = ConfigDict(arbitrary_types_allowed=True) - @model_validator(mode='after') + @model_validator(mode="after") def check_datatype_needed(self) -> Self: - if self.fields is None or 'value' in self.fields: + if self.fields is None or "value" in self.fields: if self.dataType is None: raise ValueError("Value field requested, must specify datatype") @@ -156,9 +192,9 @@ def chunks(self) -> Iterator[QueryParams]: current_start = self.start while current_start < self.end: current_end = current_start + self.jump - filter_=f'timestamp>={current_start.isoformat()};timestamp<{current_end.isoformat()}' + filter_ = f"timestamp>={current_start.isoformat()};timestamp<{current_end.isoformat()}" if self.filter_: - filter_ += f';{self.filter_}' + filter_ += f";{self.filter_}" yield QueryParams( dataset=self.dataset, @@ -167,13 +203,12 @@ def chunks(self) -> Iterator[QueryParams]: fields=self.fields, orderBy=self.orderBy, dataType=self.dataType, - filter_=filter_ + filter_=filter_, ) current_start += self.jump - class QueryResult(BaseModel): cursor: Optional[str] = None items: List[Datapoint] @@ -183,14 +218,17 @@ class Client(BaseClient): page_limit: int = 250 """How many datapoints to request per page in a cursored fetch""" - - def __init__(self, client: str, secret: str, - retry_strategy: RetryStrategy = NoRetryStrategy()) -> None: + def __init__( + self, + client: str, + secret: str, + retry_strategy: RetryStrategy = NoRetryStrategy(), + ) -> None: BaseClient.__init__( client=client, secret=secret, retry_strategy=retry_strategy, - kind=ObeliskKind.CORE + kind=ObeliskKind.CORE, ) async def send( @@ -218,7 +256,8 @@ async def send( """ response = await self.http_post( - f"{self.kind.root_url}/{dataset}/data/ingest", data=[x.model_dump(mode='json') for x in data] + f"{self.kind.root_url}/{dataset}/data/ingest", + data=[x.model_dump(mode="json") for x in data], ) if response.status_code != 204: msg = f"An error occured during data ingest. Status {response.status_code}, message: {response.text}" @@ -226,13 +265,9 @@ async def send( raise ObeliskError(msg) return response - async def fetch_single_chunk( - self, - params: QueryParams - ) -> QueryResult: + async def fetch_single_chunk(self, params: QueryParams) -> QueryResult: response = await self.http_get( - f"{self.kind.root_url}/{params.dataset}/data/query", - params=params.to_dict() + f"{self.kind.root_url}/{params.dataset}/data/query", params=params.to_dict() ) if response.status_code != 200: @@ -251,10 +286,7 @@ async def fetch_single_chunk( self.log.warning(msg) raise ObeliskError(msg) - async def query( - self, - params: QueryParams - ) -> List[Datapoint]: + async def query(self, params: QueryParams) -> List[Datapoint]: params.cursor = None result_set: List[Datapoint] = [] result_limit = params.limit @@ -263,9 +295,7 @@ async def query( params.limit = self.page_limit while True: - result: QueryResult = await self.fetch_single_chunk( - params - ) + result: QueryResult = await self.fetch_single_chunk(params) result_set.extend(result.items) params.cursor = result.cursor @@ -275,11 +305,7 @@ async def query( return result_set async def query_time_chunked( - self, - params: ChunkedParams + self, params: ChunkedParams ) -> AsyncIterator[List[Datapoint]]: for chunk in params.chunks(): - yield await self.query( - chunk - ) - + yield await self.query(chunk) diff --git a/src/obelisk/exceptions.py b/src/obelisk/exceptions.py index f076839..aad4e54 100644 --- a/src/obelisk/exceptions.py +++ b/src/obelisk/exceptions.py @@ -3,10 +3,13 @@ class AuthenticationError(Exception): Error thrown specifically when an authentication call fails, usually this indicates an invalid token. """ + pass + class ObeliskError(Exception): """ Catch-all Exception for any issue in this library code. """ + pass diff --git a/src/obelisk/strategies/retry.py b/src/obelisk/strategies/retry.py index 4dd87ea..b64dfd4 100644 --- a/src/obelisk/strategies/retry.py +++ b/src/obelisk/strategies/retry.py @@ -6,6 +6,7 @@ from abc import ABC, abstractmethod from datetime import timedelta + class RetryEvaluator(ABC): """ This class performs the actual retry handling. @@ -21,6 +22,7 @@ async def should_retry(self) -> bool: """ pass + class RetryStrategy(ABC): """ Base class for all retry strategies, whether predefined or user-made. @@ -38,10 +40,12 @@ def make(self) -> RetryEvaluator: """ pass + class NoRetryStrategy(RetryStrategy): """ Retry strategy that simply does not retry. """ + def make(self) -> RetryEvaluator: class NoRetryEvaluator(RetryEvaluator): async def should_retry(self) -> bool: @@ -49,11 +53,13 @@ async def should_retry(self) -> bool: return NoRetryEvaluator() + class ImmediateRetryStrategy(RetryStrategy): """ Retry strategy that tries again without delay, up to a user defined maximum amount of times. """ + def __init__(self, max_retries: int) -> None: self.max_retries = max_retries @@ -61,6 +67,7 @@ def make(self) -> RetryEvaluator: class ImmediateRetryEvaluator(RetryEvaluator): count: int = 0 max_retries: int = self.max_retries + async def should_retry(self) -> bool: if self.count >= self.max_retries: return False @@ -69,6 +76,7 @@ async def should_retry(self) -> bool: return ImmediateRetryEvaluator() + class ExponentialBackoffStrategy(RetryStrategy): """ Retry strategy implementing the exponential backoff algorithm. @@ -77,13 +85,17 @@ class ExponentialBackoffStrategy(RetryStrategy): Note that backoff values less than one second will count as zero. """ + max_retries: int backoff: timedelta max_backoff: timedelta - def __init__(self, max_retries: int = 5, - backoff: timedelta = timedelta(seconds=2), - max_backoff: timedelta = timedelta(hours=24)) -> None: + def __init__( + self, + max_retries: int = 5, + backoff: timedelta = timedelta(seconds=2), + max_backoff: timedelta = timedelta(hours=24), + ) -> None: self.max_retries = max_retries self.backoff = backoff self.max_backoff = max_backoff @@ -100,7 +112,8 @@ async def should_retry(self) -> bool: return False self.count += 1 await sleep( - min(self.max_backoff.seconds, self.backoff.seconds ** self.count)) + min(self.max_backoff.seconds, self.backoff.seconds**self.count) + ) return True return ExponentialBackoffEvaluator() diff --git a/src/obelisk/sync/__init__.py b/src/obelisk/sync/__init__.py index 66cf554..141147c 100644 --- a/src/obelisk/sync/__init__.py +++ b/src/obelisk/sync/__init__.py @@ -9,5 +9,6 @@ This is because it is internally nothing more than a wrapper over the asynchronous implementation. Use the asynchronous implementation in these situations. """ + __all__ = ["Obelisk"] from .client import Obelisk diff --git a/src/obelisk/types/__init__.py b/src/obelisk/types/__init__.py index 05e8b81..d23628e 100644 --- a/src/obelisk/types/__init__.py +++ b/src/obelisk/types/__init__.py @@ -17,9 +17,9 @@ class IngestMode(str, Enum): Does not apply to HFS """ - DEFAULT = 'default' - STREAM_ONLY = 'stream_only' - STORE_ONLY = 'store_only' + DEFAULT = "default" + STREAM_ONLY = "stream_only" + STORE_ONLY = "store_only" class TimestampPrecision(str, Enum): @@ -29,14 +29,14 @@ class TimestampPrecision(str, Enum): but interpreted by Obelisk as milliseconds, it would erroneously be somewhere in the past. """ - __choices__ = ('SECONDS', 'MILLISECONDS', 'MICROSECONDS') + __choices__ = ("SECONDS", "MILLISECONDS", "MICROSECONDS") - SECONDS = 'seconds' - MILLISECONDS = 'milliseconds' - MICROSECONDS = 'microseconds' + SECONDS = "seconds" + MILLISECONDS = "milliseconds" + MICROSECONDS = "microseconds" -class Datapoint(BaseModel, extra='allow'): +class Datapoint(BaseModel, extra="allow"): timestamp: int value: Any dataset: Optional[str] = None @@ -51,37 +51,37 @@ class QueryResult(BaseModel): class ObeliskKind(str, Enum): - CLASSIC = 'classic' - HFS = 'hfs' - CORE = 'core' + CLASSIC = "classic" + HFS = "hfs" + CORE = "core" @property def token_url(self) -> str: match self: case ObeliskKind.CLASSIC: - return 'https://obelisk.ilabt.imec.be/api/v3/auth/token' + return "https://obelisk.ilabt.imec.be/api/v3/auth/token" case ObeliskKind.HFS: - return 'https://obelisk-hfs.discover.ilabt.imec.be/auth/realms/obelisk-hfs/protocol/openid-connect/token' + return "https://obelisk-hfs.discover.ilabt.imec.be/auth/realms/obelisk-hfs/protocol/openid-connect/token" case ObeliskKind.CORE: - return 'https://auth.obelisk.discover.ilabt.imec.be/realms/obelisk/protocol/openid-connect/token' + return "https://auth.obelisk.discover.ilabt.imec.be/realms/obelisk/protocol/openid-connect/token" @property def root_url(self) -> str: match self: case ObeliskKind.CLASSIC: - return 'https://obelisk.ilabt.imec.be/api/v3' + return "https://obelisk.ilabt.imec.be/api/v3" case ObeliskKind.HFS: - return 'https://obelisk-hfs.discover.ilabt.imec.be' + return "https://obelisk-hfs.discover.ilabt.imec.be" case ObeliskKind.CORE: - return 'https://obelisk.discover.ilabt.imec.be/datasets' + return "https://obelisk.discover.ilabt.imec.be/datasets" @property def query_url(self) -> str: match self: case ObeliskKind.CLASSIC: - return 'https://obelisk.ilabt.imec.be/api/v3/data/query/events' + return "https://obelisk.ilabt.imec.be/api/v3/data/query/events" case ObeliskKind.HFS: - return 'https://obelisk-hfs.discover.ilabt.imec.be/data/query/events' + return "https://obelisk-hfs.discover.ilabt.imec.be/data/query/events" case ObeliskKind.CORE: raise NotImplementedError() @@ -89,9 +89,9 @@ def query_url(self) -> str: def ingest_url(self) -> str: match self: case ObeliskKind.CLASSIC: - return 'https://obelisk.ilabt.imec.be/api/v3/data/ingest' + return "https://obelisk.ilabt.imec.be/api/v3/data/ingest" case ObeliskKind.HFS: - return 'https://obelisk-hfs.discover.ilabt.imec.be/data/ingest' + return "https://obelisk-hfs.discover.ilabt.imec.be/data/ingest" case ObeliskKind.CORE: raise NotImplementedError() @@ -99,7 +99,7 @@ def ingest_url(self) -> str: def stream_url(self) -> str | None: match self: case ObeliskKind.CLASSIC: - return 'https://obelisk.ilabt.imec.be/api/v3/data/streams' + return "https://obelisk.ilabt.imec.be/api/v3/data/streams" case ObeliskKind.HFS: return None case ObeliskKind.CORE: diff --git a/src/obelisk/types/core.py b/src/obelisk/types/core.py index 9c534d9..3630e01 100644 --- a/src/obelisk/types/core.py +++ b/src/obelisk/types/core.py @@ -14,6 +14,7 @@ >>> print(f) (('source'=='test source';'metricType'=in=('number', 'number[]')),'timestamp'<'2025-09-09T14:48:48') """ + from __future__ import annotations from abc import ABC from datetime import datetime @@ -34,10 +35,11 @@ class Constraint(ABC): These constraints always enclose their contents in parentheses, to avoid confusing precendence situations in serialised format. """ + pass -class Comparison(): +class Comparison: """ Comparisons are the basic items of a :class:`Filter`. They consist of a field name, operator, and possibly a value on the right. @@ -49,6 +51,7 @@ class Comparison(): each argument is single quoted as to accept any otherwise reserved characters, and serialised using :func:`str`. """ + left: FieldName right: Any op: str @@ -60,7 +63,7 @@ def __init__(self, left: FieldName, right: Any, op: str): def __str__(self) -> str: right = self._sstr(self.right) - if not right.startswith('(') and len(right) > 0: + if not right.startswith("(") and len(right) > 0: right = f"'{right}'" return f"'{self.left}'{self.op}{right}" @@ -141,7 +144,7 @@ def __str__(self) -> str: return "(" + ",".join([str(x) for x in self.content]) + ")" -class Filter(): +class Filter: """ Filter is an easier way to programatically create filters for the Obelisk CORE platform. @@ -154,6 +157,7 @@ class Filter(): As this field is not checked, we also do not check whether the type of left operand and right operand make sense in the provided comparison. """ + content: Item | None = None def __init__(self, content: Constraint | None = None): From 9c43815b38aa0cfebac692e7aaeefc042f57b0d5 Mon Sep 17 00:00:00 2001 From: Stef Pletinck Date: Fri, 10 Oct 2025 10:05:32 +0200 Subject: [PATCH 3/4] Add pre-commit hook to check formatting Uses core.hooksPath to read from a tracked folder --- .github/workflows/ci.yml | 26 +++++++++----------------- README.md | 5 +++++ hooks/pre-commit | 13 +++++++++++++ 3 files changed, 27 insertions(+), 17 deletions(-) create mode 100755 hooks/pre-commit diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eab655a..f6b012b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,21 +8,15 @@ on: - main jobs: - # Do not lint for now, as the code is not yet compliant - #lint: - # name: Lint - # runs-on: ubuntu-latest - # steps: - # - uses: actions/checkout@v4 - # - uses: astral-sh/setup-uv@v3 - # - name: Ruff lint - # run: uv run ruff check . - # - name: Ruff format - # run: uv run ruff format --diff . - # # This isn't a general Python lint, this style is just used in this repository - # - name: Prettier format - # run: npx prettier --prose-wrap always --check "**/*.md" - + lint: + name: Run linting and format + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v6 + - run: uv python install python3.13 + - run: uv run ruff check src/obelisk/ + - run: uv run ruff format --check src/obelisk/ test: name: Run tests strategy: @@ -33,6 +27,4 @@ jobs: - uses: actions/checkout@v4 - uses: astral-sh/setup-uv@v6 - run: uv python install python3.13 - - run: uv run ruff check src/obelisk/ - - run: uv run ruff format --check src/obelisk/ - run: uv run pytest diff --git a/README.md b/README.md index 99a6820..3cf41d5 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,11 @@ In case of major restructuring, it may be needed to clean up the contents of `do followed by re-running the build. Manually triggering sphinx-apidoc is unnecessary. +### Hooks + +We use some simple Git hooks to avoid fighting the CI too often. +These are stored in the `hooks/` directory, and can be enabled by setting `git config core.hooksPath hooks/`. + ## Credits Base implementation originally by Pieter Moens , diff --git a/hooks/pre-commit b/hooks/pre-commit new file mode 100755 index 0000000..68dcaed --- /dev/null +++ b/hooks/pre-commit @@ -0,0 +1,13 @@ +#!/bin/sh +# +# An example hook script to verify what is about to be committed. +# Called by "git commit" with no arguments. The hook should +# exit with non-zero status after issuing an appropriate message if +# it wants to stop the commit. +# +# To enable this hook, rename this file to "pre-commit". + +# Redirect output to stderr. +exec 1>&2 + +exec uv run ruff format --check src/obelisk/ From 70169cf825cbaac605fdc5976a170f29421c008e Mon Sep 17 00:00:00 2001 From: Stef Pletinck Date: Fri, 10 Oct 2025 10:18:18 +0200 Subject: [PATCH 4/4] Integrate type checking with mypy Adds mypy into the pre-commit hooks and CI, and fixes some minor type errors --- .github/workflows/ci.yml | 1 + hooks/pre-commit | 3 +- pyproject.toml | 1 + src/__init__.py | 0 src/obelisk/asynchronous/core.py | 1 + src/obelisk/sync/client.py | 4 +- uv.lock | 65 ++++++++++++++++++++++++++++++++ 7 files changed, 72 insertions(+), 3 deletions(-) delete mode 100644 src/__init__.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f6b012b..1c8c50c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,6 +17,7 @@ jobs: - run: uv python install python3.13 - run: uv run ruff check src/obelisk/ - run: uv run ruff format --check src/obelisk/ + - run: uv run mypy src/obelisk/ test: name: Run tests strategy: diff --git a/hooks/pre-commit b/hooks/pre-commit index 68dcaed..f85e1ca 100755 --- a/hooks/pre-commit +++ b/hooks/pre-commit @@ -10,4 +10,5 @@ # Redirect output to stderr. exec 1>&2 -exec uv run ruff format --check src/obelisk/ +uv run ruff format --check src/obelisk/ +uv run mypy src/obelisk/ diff --git a/pyproject.toml b/pyproject.toml index bb07fe4..d5ad22e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,7 @@ dependencies = [ "pydantic>=2.10.6", "pytest>=8.3.5", "pytest-asyncio>=0.25.3", + "mypy>=1.18.2", ] authors = [ { name="Stef Pletinck", email="Stef.Pletinck@ugent.be"}, diff --git a/src/__init__.py b/src/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/obelisk/asynchronous/core.py b/src/obelisk/asynchronous/core.py index d6e2ca3..b8c1dc3 100644 --- a/src/obelisk/asynchronous/core.py +++ b/src/obelisk/asynchronous/core.py @@ -225,6 +225,7 @@ def __init__( retry_strategy: RetryStrategy = NoRetryStrategy(), ) -> None: BaseClient.__init__( + self, client=client, secret=secret, retry_strategy=retry_strategy, diff --git a/src/obelisk/sync/client.py b/src/obelisk/sync/client.py index 9aa4508..d145660 100644 --- a/src/obelisk/sync/client.py +++ b/src/obelisk/sync/client.py @@ -46,7 +46,7 @@ def fetch_single_chunk( self, datasets: List[str], metrics: Optional[List[str]] = None, - fields: Optional[dict] = None, + fields: Optional[List[str]] = None, from_timestamp: Optional[int] = None, to_timestamp: Optional[int] = None, order_by: Optional[dict] = None, @@ -116,7 +116,7 @@ def query( self, datasets: List[str], metrics: Optional[List[str]] = None, - fields: Optional[dict] = None, + fields: Optional[List[str]] = None, from_timestamp: Optional[int] = None, to_timestamp: Optional[int] = None, order_by: Optional[dict] = None, diff --git a/uv.lock b/uv.lock index a54210d..fed5c13 100644 --- a/uv.lock +++ b/uv.lock @@ -330,6 +330,60 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/73/085399401383ce949f727afec55ec3abd76648d04b9f22e1c0e99cb4bec3/MarkupSafe-3.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:6e296a513ca3d94054c2c881cc913116e90fd030ad1c656b3869762b754f5f8a", size = 15506 }, ] +[[package]] +name = "mypy" +version = "1.18.2" +source = { registry = "https://pypi.org/simple/" } +dependencies = [ + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/77/8f0d0001ffad290cef2f7f216f96c814866248a0b92a722365ed54648e7e/mypy-1.18.2.tar.gz", hash = "sha256:06a398102a5f203d7477b2923dda3634c36727fa5c237d8f859ef90c42a9924b", size = 3448846 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/6f/657961a0743cff32e6c0611b63ff1c1970a0b482ace35b069203bf705187/mypy-1.18.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c1eab0cf6294dafe397c261a75f96dc2c31bffe3b944faa24db5def4e2b0f77c", size = 12807973 }, + { url = "https://files.pythonhosted.org/packages/10/e9/420822d4f661f13ca8900f5fa239b40ee3be8b62b32f3357df9a3045a08b/mypy-1.18.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7a780ca61fc239e4865968ebc5240bb3bf610ef59ac398de9a7421b54e4a207e", size = 11896527 }, + { url = "https://files.pythonhosted.org/packages/aa/73/a05b2bbaa7005f4642fcfe40fb73f2b4fb6bb44229bd585b5878e9a87ef8/mypy-1.18.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:448acd386266989ef11662ce3c8011fd2a7b632e0ec7d61a98edd8e27472225b", size = 12507004 }, + { url = "https://files.pythonhosted.org/packages/4f/01/f6e4b9f0d031c11ccbd6f17da26564f3a0f3c4155af344006434b0a05a9d/mypy-1.18.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f9e171c465ad3901dc652643ee4bffa8e9fef4d7d0eece23b428908c77a76a66", size = 13245947 }, + { url = "https://files.pythonhosted.org/packages/d7/97/19727e7499bfa1ae0773d06afd30ac66a58ed7437d940c70548634b24185/mypy-1.18.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:592ec214750bc00741af1f80cbf96b5013d81486b7bb24cb052382c19e40b428", size = 13499217 }, + { url = "https://files.pythonhosted.org/packages/9f/4f/90dc8c15c1441bf31cf0f9918bb077e452618708199e530f4cbd5cede6ff/mypy-1.18.2-cp310-cp310-win_amd64.whl", hash = "sha256:7fb95f97199ea11769ebe3638c29b550b5221e997c63b14ef93d2e971606ebed", size = 9766753 }, + { url = "https://files.pythonhosted.org/packages/88/87/cafd3ae563f88f94eec33f35ff722d043e09832ea8530ef149ec1efbaf08/mypy-1.18.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:807d9315ab9d464125aa9fcf6d84fde6e1dc67da0b6f80e7405506b8ac72bc7f", size = 12731198 }, + { url = "https://files.pythonhosted.org/packages/0f/e0/1e96c3d4266a06d4b0197ace5356d67d937d8358e2ee3ffac71faa843724/mypy-1.18.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:776bb00de1778caf4db739c6e83919c1d85a448f71979b6a0edd774ea8399341", size = 11817879 }, + { url = "https://files.pythonhosted.org/packages/72/ef/0c9ba89eb03453e76bdac5a78b08260a848c7bfc5d6603634774d9cd9525/mypy-1.18.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1379451880512ffce14505493bd9fe469e0697543717298242574882cf8cdb8d", size = 12427292 }, + { url = "https://files.pythonhosted.org/packages/1a/52/ec4a061dd599eb8179d5411d99775bec2a20542505988f40fc2fee781068/mypy-1.18.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1331eb7fd110d60c24999893320967594ff84c38ac6d19e0a76c5fd809a84c86", size = 13163750 }, + { url = "https://files.pythonhosted.org/packages/c4/5f/2cf2ceb3b36372d51568f2208c021870fe7834cf3186b653ac6446511839/mypy-1.18.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3ca30b50a51e7ba93b00422e486cbb124f1c56a535e20eff7b2d6ab72b3b2e37", size = 13351827 }, + { url = "https://files.pythonhosted.org/packages/c8/7d/2697b930179e7277529eaaec1513f8de622818696857f689e4a5432e5e27/mypy-1.18.2-cp311-cp311-win_amd64.whl", hash = "sha256:664dc726e67fa54e14536f6e1224bcfce1d9e5ac02426d2326e2bb4e081d1ce8", size = 9757983 }, + { url = "https://files.pythonhosted.org/packages/07/06/dfdd2bc60c66611dd8335f463818514733bc763e4760dee289dcc33df709/mypy-1.18.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:33eca32dd124b29400c31d7cf784e795b050ace0e1f91b8dc035672725617e34", size = 12908273 }, + { url = "https://files.pythonhosted.org/packages/81/14/6a9de6d13a122d5608e1a04130724caf9170333ac5a924e10f670687d3eb/mypy-1.18.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a3c47adf30d65e89b2dcd2fa32f3aeb5e94ca970d2c15fcb25e297871c8e4764", size = 11920910 }, + { url = "https://files.pythonhosted.org/packages/5f/a9/b29de53e42f18e8cc547e38daa9dfa132ffdc64f7250e353f5c8cdd44bee/mypy-1.18.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d6c838e831a062f5f29d11c9057c6009f60cb294fea33a98422688181fe2893", size = 12465585 }, + { url = "https://files.pythonhosted.org/packages/77/ae/6c3d2c7c61ff21f2bee938c917616c92ebf852f015fb55917fd6e2811db2/mypy-1.18.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01199871b6110a2ce984bde85acd481232d17413868c9807e95c1b0739a58914", size = 13348562 }, + { url = "https://files.pythonhosted.org/packages/4d/31/aec68ab3b4aebdf8f36d191b0685d99faa899ab990753ca0fee60fb99511/mypy-1.18.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a2afc0fa0b0e91b4599ddfe0f91e2c26c2b5a5ab263737e998d6817874c5f7c8", size = 13533296 }, + { url = "https://files.pythonhosted.org/packages/9f/83/abcb3ad9478fca3ebeb6a5358bb0b22c95ea42b43b7789c7fb1297ca44f4/mypy-1.18.2-cp312-cp312-win_amd64.whl", hash = "sha256:d8068d0afe682c7c4897c0f7ce84ea77f6de953262b12d07038f4d296d547074", size = 9828828 }, + { url = "https://files.pythonhosted.org/packages/5f/04/7f462e6fbba87a72bc8097b93f6842499c428a6ff0c81dd46948d175afe8/mypy-1.18.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:07b8b0f580ca6d289e69209ec9d3911b4a26e5abfde32228a288eb79df129fcc", size = 12898728 }, + { url = "https://files.pythonhosted.org/packages/99/5b/61ed4efb64f1871b41fd0b82d29a64640f3516078f6c7905b68ab1ad8b13/mypy-1.18.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ed4482847168439651d3feee5833ccedbf6657e964572706a2adb1f7fa4dfe2e", size = 11910758 }, + { url = "https://files.pythonhosted.org/packages/3c/46/d297d4b683cc89a6e4108c4250a6a6b717f5fa96e1a30a7944a6da44da35/mypy-1.18.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3ad2afadd1e9fea5cf99a45a822346971ede8685cc581ed9cd4d42eaf940986", size = 12475342 }, + { url = "https://files.pythonhosted.org/packages/83/45/4798f4d00df13eae3bfdf726c9244bcb495ab5bd588c0eed93a2f2dd67f3/mypy-1.18.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a431a6f1ef14cf8c144c6b14793a23ec4eae3db28277c358136e79d7d062f62d", size = 13338709 }, + { url = "https://files.pythonhosted.org/packages/d7/09/479f7358d9625172521a87a9271ddd2441e1dab16a09708f056e97007207/mypy-1.18.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7ab28cc197f1dd77a67e1c6f35cd1f8e8b73ed2217e4fc005f9e6a504e46e7ba", size = 13529806 }, + { url = "https://files.pythonhosted.org/packages/71/cf/ac0f2c7e9d0ea3c75cd99dff7aec1c9df4a1376537cb90e4c882267ee7e9/mypy-1.18.2-cp313-cp313-win_amd64.whl", hash = "sha256:0e2785a84b34a72ba55fb5daf079a1003a34c05b22238da94fcae2bbe46f3544", size = 9833262 }, + { url = "https://files.pythonhosted.org/packages/3f/a6/490ff491d8ecddf8ab91762d4f67635040202f76a44171420bcbe38ceee5/mypy-1.18.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:25a9c8fb67b00599f839cf472713f54249a62efd53a54b565eb61956a7e3296b", size = 12807230 }, + { url = "https://files.pythonhosted.org/packages/eb/2e/60076fc829645d167ece9e80db9e8375648d210dab44cc98beb5b322a826/mypy-1.18.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c2b9c7e284ee20e7598d6f42e13ca40b4928e6957ed6813d1ab6348aa3f47133", size = 11895666 }, + { url = "https://files.pythonhosted.org/packages/97/4a/1e2880a2a5dda4dc8d9ecd1a7e7606bc0b0e14813637eeda40c38624e037/mypy-1.18.2-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d6985ed057513e344e43a26cc1cd815c7a94602fb6a3130a34798625bc2f07b6", size = 12499608 }, + { url = "https://files.pythonhosted.org/packages/00/81/a117f1b73a3015b076b20246b1f341c34a578ebd9662848c6b80ad5c4138/mypy-1.18.2-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22f27105f1525ec024b5c630c0b9f36d5c1cc4d447d61fe51ff4bd60633f47ac", size = 13244551 }, + { url = "https://files.pythonhosted.org/packages/9b/61/b9f48e1714ce87c7bf0358eb93f60663740ebb08f9ea886ffc670cea7933/mypy-1.18.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:030c52d0ea8144e721e49b1f68391e39553d7451f0c3f8a7565b59e19fcb608b", size = 13491552 }, + { url = "https://files.pythonhosted.org/packages/c9/66/b2c0af3b684fa80d1b27501a8bdd3d2daa467ea3992a8aa612f5ca17c2db/mypy-1.18.2-cp39-cp39-win_amd64.whl", hash = "sha256:aa5e07ac1a60a253445797e42b8b2963c9675563a94f11291ab40718b016a7a0", size = 9765635 }, + { url = "https://files.pythonhosted.org/packages/87/e3/be76d87158ebafa0309946c4a73831974d4d6ab4f4ef40c3b53a385a66fd/mypy-1.18.2-py3-none-any.whl", hash = "sha256:22a1748707dd62b58d2ae53562ffc4d7f8bcc727e8ac7cbc69c053ddc874d47e", size = 2352367 }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple/" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963 }, +] + [[package]] name = "numpydoc" version = "1.8.0" @@ -351,6 +405,7 @@ name = "obelisk-py" source = { editable = "." } dependencies = [ { name = "httpx" }, + { name = "mypy" }, { name = "pydantic" }, { name = "pytest" }, { name = "pytest-asyncio" }, @@ -368,6 +423,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "httpx", specifier = ">=0.28.1" }, + { name = "mypy", specifier = ">=1.18.2" }, { name = "pydantic", specifier = ">=2.10.6" }, { name = "pytest", specifier = ">=8.3.5" }, { name = "pytest-asyncio", specifier = ">=0.25.3" }, @@ -389,6 +445,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451 }, ] +[[package]] +name = "pathspec" +version = "0.12.1" +source = { registry = "https://pypi.org/simple/" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191 }, +] + [[package]] name = "pluggy" version = "1.5.0"