Compare commits

...

8 Commits

Author SHA1 Message Date
Jedd Morgan c6fc5c6bd4 deprecate file upload api 2026-01-13 13:10:49 +00:00
Mucahit Bilal GOKER 2f84214786 feat(ifc): add parentId to nested objects (#481)
* add parentId to nested objects

* rename to parentApplicationId

* implement jedd's feedback

* ruff check

---------

Co-authored-by: Jedd Morgan <45512892+JR-Morgan@users.noreply.github.com>
2026-01-08 09:04:12 +03:00
Jedd Morgan 0fe1af8e75 Update PostgreSQL connection string in docker-compose (#482) 2026-01-07 15:54:26 +00:00
Gergő Jedlicska 6297943fe1 gergo/version message for ingestion (#480)
* feat: use mise for docs build

* feat(modelingestion): add version message reporting
2026-01-05 11:46:31 +00:00
Gergő Jedlicska 428bbe2c3d gergo/queryIngestionFix (#479)
Publish Python Package / test (push) Has been cancelled
Publish Python Package / Build and Publish Python Package (push) Has been cancelled
* feat: use mise for docs build

* fix: getting the ingestion query needs to use model ingestion id
2025-12-11 10:44:36 +01:00
Jedd Morgan 0ca22891bc fallback to cgal (#476) 2025-12-10 10:09:00 +00:00
Jedd Morgan fd8c2a32f9 chore(speckleifc): changed ifc status messages (#478)
Publish Python Package / test (push) Has been cancelled
Publish Python Package / Build and Publish Python Package (push) Has been cancelled
* remove this function

* Changed progress messages
2025-12-09 17:27:26 +00:00
Jedd Morgan ba8c356d82 chore(speckleifc): Ifc metrics slug tweaks (#477)
Publish Python Package / test (push) Has been cancelled
Publish Python Package / Build and Publish Python Package (push) Has been cancelled
* ifc metrics

* add http server tests for metrics

* clean up tests

* change back to localhost:3000

* comment

* renamed wrapper for clarity

* fix unrelated model_ingestion
2025-12-09 16:18:21 +01:00
15 changed files with 327 additions and 119 deletions
+1 -4
View File
@@ -97,10 +97,7 @@ services:
STRATEGY_LOCAL: "true"
POSTGRES_URL: "postgres"
POSTGRES_USER: "speckle"
POSTGRES_PASSWORD: "speckle"
POSTGRES_DB: "speckle"
POSTGRES_URL: 'postgres://speckle:speckle@postgres:5432/speckle'
ENABLE_MP: "false"
LOG_PRETTY: "true"
+1
View File
@@ -33,6 +33,7 @@ dev = [
"pytest-asyncio>=0.25.2",
"pytest-cov>=6.0.0",
"pytest-ordering>=0.6",
"pytest_httpserver >=1.1.3",
"ruff==0.9.2",
"types-deprecated>=1.2.15.20241117",
"types-requests>=2.32.0.20241016",
@@ -12,12 +12,23 @@ def data_object_to_speckle(
step_element: entity_instance,
children: list[Base],
current_storey: str | None = None,
parent_element: entity_instance | None = None,
) -> DataObject:
guid = cast(str, step_element.GlobalId)
name = cast(str, step_element.Name or guid)
properties = extract_properties(step_element)
# Add parent ID only if element's parent is also a DataObject (not a Collection)
# Collections are: IfcProject and IfcSpatialStructureElement types
if (
parent_element
and hasattr(parent_element, "GlobalId")
and not parent_element.is_a("IfcProject")
and not parent_element.is_a("IfcSpatialStructureElement")
):
properties["parentApplicationId"] = parent_element.GlobalId
# Add building storey information if available and not a building storey itself
if current_storey and not step_element.is_a("IfcBuildingStorey"):
properties["Building Storey"] = current_storey
+7 -1
View File
@@ -51,4 +51,10 @@ def open_ifc(file_path: str) -> file:
def create_geometry_iterator(ifc_file: file | sqlite) -> iterator:
return iterator(_create_iterator_settings(), ifc_file, multiprocessing.cpu_count())
GEOMETRY_LIBRARY = "hybrid-opencascade-cgal" # First OCC then fallback to CGAL
return iterator(
_create_iterator_settings(),
ifc_file,
multiprocessing.cpu_count(),
geometry_library=GEOMETRY_LIBRARY, # type: ignore
)
+18 -6
View File
@@ -44,9 +44,13 @@ class ImportJob:
_display_value_cache: dict[int, list[Base]] = field(default_factory=dict)
"""Maps an instance step ID to a list of instances"""
def convert_element(self, step_element: entity_instance) -> Base:
def convert_element(
self,
step_element: entity_instance,
parent_element: entity_instance | None = None,
) -> Base:
try:
return self._convert_element(step_element)
return self._convert_element(step_element, parent_element)
except SpeckleException:
raise
except Exception as ex:
@@ -54,14 +58,18 @@ class ImportJob:
f"Failed to convert {step_element.is_a()} #{step_element.id()}"
) from ex
def _convert_element(self, step_element: entity_instance) -> Base:
def _convert_element(
self,
step_element: entity_instance,
parent_element: entity_instance | None = None,
) -> Base:
# Track current storey context and store for level proxies
previous_storey_data_object = self._current_storey_data_object
if step_element.is_a("IfcBuildingStorey"):
# Convert the building storey to a DataObject for the level proxy
storey_display_value = self._display_value_cache.get(step_element.id(), [])
self._current_storey_data_object = data_object_to_speckle(
storey_display_value, step_element, []
storey_display_value, step_element, [], parent_element=None
)
children = self._convert_children(step_element)
@@ -86,7 +94,11 @@ class ImportJob:
)
else:
result = data_object_to_speckle(
display_value, step_element, children, current_storey_name
display_value,
step_element,
children,
current_storey_name,
parent_element,
)
# Associate non-spatial elements with current storey for level proxies
if self._current_storey_data_object is not None and result.applicationId:
@@ -100,7 +112,7 @@ class ImportJob:
def _convert_children(self, step_element: entity_instance) -> list[Base]:
return [
self.convert_element(i)
self.convert_element(i, parent_element=step_element)
for i in get_children(step_element)
if self._should_convert(i)
]
+13 -6
View File
@@ -23,7 +23,7 @@ from specklepy.transports.server import ServerTransport
def open_and_convert_file(
file_path: str,
project: Project,
version_message: str | None,
version_message: str,
model_ingestion_id: str,
client: SpeckleClient,
) -> Version:
@@ -41,7 +41,7 @@ def open_and_convert_file(
source_data=SourceDataInput(
file_name=path.name,
file_size_bytes=path.stat().st_size,
source_application_slug="fileimports-ifc",
source_application_slug=metrics.HOST_APP,
source_application_version=specklepy_version,
),
)
@@ -58,7 +58,7 @@ def open_and_convert_file(
ModelIngestionUpdateInput(
project_id=project.id,
ingestion_id=model_ingestion_id,
progress_message="File validated, converting",
progress_message="Converting file",
progress=None,
)
)
@@ -73,7 +73,7 @@ def open_and_convert_file(
ModelIngestionUpdateInput(
project_id=project.id,
ingestion_id=model_ingestion_id,
progress_message="Conversion complete, sending",
progress_message="Uploading objects",
progress=None,
)
)
@@ -87,7 +87,7 @@ def open_and_convert_file(
project_id=project.id,
ingestion_id=model_ingestion_id,
root_object_id=root_id,
# version_message=version_message,
version_message=version_message,
)
)
@@ -103,7 +103,14 @@ def open_and_convert_file(
custom_properties = {"ui": "dui3", "actionSource": "import"}
if project.workspace_id:
custom_properties["workspace_id"] = project.workspace_id
metrics.track(metrics.SEND, account, custom_properties, send_sync=True)
metrics.track(
metrics.SEND,
account,
custom_properties,
send_sync=True,
track_email=True,
)
return version
except Exception as e:
@@ -1,5 +1,6 @@
from pathlib import Path
from deprecated import deprecated
from typing_extensions import override
from specklepy.core.api.inputs import (
@@ -10,7 +11,10 @@ from specklepy.core.api.inputs import (
from specklepy.core.api.models import FileImport, FileUploadUrl
from specklepy.core.api.models.current import ResourceCollection
from specklepy.core.api.resources import FileImportResource as CoreResource
from specklepy.core.api.resources.current.file_import_resource import UploadFileResponse
from specklepy.core.api.resources.current.file_import_resource import (
FILE_UPLOAD_DEPRECATION_WARNING,
UploadFileResponse,
)
from specklepy.logging import metrics
@@ -25,11 +29,6 @@ class FileImportResource(CoreResource):
server_version=server_version,
)
@override
def start_file_import(self, input: StartFileImportInput) -> FileImport:
metrics.track(metrics.SDK, self.account, {"name": "File Import Start"})
return super().start_file_import(input)
@override
def generate_upload_url(self, input: GenerateFileUploadUrlInput) -> FileUploadUrl:
"""
@@ -61,6 +60,13 @@ class FileImportResource(CoreResource):
metrics.track(metrics.SDK, self.account, {"name": "File Import Download File"})
return super().download_file(project_id, file_id, target_file)
@deprecated(**FILE_UPLOAD_DEPRECATION_WARNING)
@override
def start_file_import(self, input: StartFileImportInput) -> FileImport:
metrics.track(metrics.SDK, self.account, {"name": "File Import Start"})
return super().start_file_import(input)
@deprecated(**FILE_UPLOAD_DEPRECATION_WARNING)
@override
def finish_file_import_job(self, input: FinishFileImportInput) -> bool:
"""
@@ -72,6 +78,7 @@ class FileImportResource(CoreResource):
metrics.track(metrics.SDK, self.account, {"name": "File Import Finish Job"})
return super().finish_file_import_job(input)
@deprecated(**FILE_UPLOAD_DEPRECATION_WARNING)
@override
def get_model_file_import_jobs(
self,
@@ -26,6 +26,10 @@ class ModelIngestionResource(CoreResource):
metrics.track(metrics.SDK, self.account, {"name": "Ingestion Create"})
return super().create(input)
def get_ingestion(self, project_id: str, model_ingestion_id: str) -> ModelIngestion:
metrics.track(metrics.SDK, self.account, {"name": "Ingestion Get"})
return super().get_ingestion(project_id, model_ingestion_id)
def update_progress(self, input: ModelIngestionUpdateInput) -> ModelIngestion:
metrics.track(metrics.SDK, self.account, {"name": "Ingestion Update"})
return super().update_progress(input)
@@ -40,11 +44,11 @@ class ModelIngestionResource(CoreResource):
metrics.track(metrics.SDK, self.account, {"name": "Ingestion Update"})
return super().requeue(input)
def complete_successfully(self, input: ModelIngestionSuccessInput) -> str:
def complete(self, input: ModelIngestionSuccessInput) -> str:
metrics.track(metrics.SDK, self.account, {"name": "Ingestion End"})
return super().complete(input)
def complete_failed(self, input: ModelIngestionFailedInput) -> ModelIngestion:
def fail_with_error(self, input: ModelIngestionFailedInput) -> ModelIngestion:
metrics.track(metrics.SDK, self.account, {"name": "Ingestion Error"})
return super().fail_with_error(input)
@@ -40,6 +40,7 @@ class ModelIngestionSuccessInput(GraphQLBaseModel):
ingestion_id: str
project_id: str
root_object_id: str
version_message: str | None
class ModelIngestionFailedInput(GraphQLBaseModel):
@@ -48,18 +49,6 @@ class ModelIngestionFailedInput(GraphQLBaseModel):
error_reason: str
error_stacktrace: str | None
@staticmethod
def from_exception(
ingestion_id: str, project_id: str, exception: Exception, message: str | None
) -> "ModelIngestionFailedInput":
"""test"""
return ModelIngestionFailedInput(
ingestion_id=ingestion_id,
project_id=project_id,
error_reason=message if message else str(exception),
error_stacktrace=str(exception),
)
class ModelIngestionCancelledInput(GraphQLBaseModel):
ingestion_id: str
@@ -2,6 +2,7 @@ from pathlib import Path
from typing import Any
import httpx
from deprecated import deprecated
from gql import Client, gql
from specklepy.core.api.credentials import Account
@@ -23,6 +24,16 @@ class UploadFileResponse(GraphQLBaseModel):
etag: str
FILE_UPLOAD_DEPRECATION_WARNING: dict[str, Any] = {
"version": "3.2.4",
"reason": (
"Part of the old API surface"
"and will be removed in the future. Use the new ingestion API instead."
"Field will be deleted on June 1st, 2026"
),
}
class FileImportResource(ResourceBase):
"""API Access class for file imports"""
@@ -41,59 +52,6 @@ class FileImportResource(ResourceBase):
name=NAME,
)
def finish_file_import_job(self, input: FinishFileImportInput) -> bool:
"""
This is mostly an internal api, that marks a file import job finished.
Only use this if you are writing a file importer, that is responsible for
processing file import jobs.
"""
QUERY = gql(
"""
mutation FinishFileImport($input: FinishFileImportInput!) {
data:fileUploadMutations {
data:finishFileImport(input: $input)
}
}
"""
)
variables = {
"input": input.model_dump(warnings="error", by_alias=True),
}
return self.make_request_and_parse_response(
DataResponse[DataResponse[bool]], QUERY, variables
).data.data
def start_file_import(self, input: StartFileImportInput) -> FileImport:
QUERY = gql(
"""
mutation StartFileImport($input: StartFileImportInput!) {
data:fileUploadMutations {
data:startFileImport(input: $input) {
id
projectId
convertedVersionId
userId
convertedStatus
convertedMessage
modelId
updatedAt
}
}
}
"""
)
variables = {
"input": input.model_dump(warnings="error", by_alias=True),
}
return self.make_request_and_parse_response(
DataResponse[DataResponse[FileImport]], QUERY, variables
).data.data
def generate_upload_url(self, input: GenerateFileUploadUrlInput) -> FileUploadUrl:
"""
Get a file upload url from the Speckle server.
@@ -161,6 +119,62 @@ class FileImportResource(ResourceBase):
_ = f.write(chunk)
return target_file
@deprecated(**FILE_UPLOAD_DEPRECATION_WARNING)
def start_file_import(self, input: StartFileImportInput) -> FileImport:
QUERY = gql(
"""
mutation StartFileImport($input: StartFileImportInput!) {
data:fileUploadMutations {
data:startFileImport(input: $input) {
id
projectId
convertedVersionId
userId
convertedStatus
convertedMessage
modelId
updatedAt
}
}
}
"""
)
variables = {
"input": input.model_dump(warnings="error", by_alias=True),
}
return self.make_request_and_parse_response(
DataResponse[DataResponse[FileImport]], QUERY, variables
).data.data
@deprecated(**FILE_UPLOAD_DEPRECATION_WARNING)
def finish_file_import_job(self, input: FinishFileImportInput) -> bool:
"""
This is mostly an internal api, that marks a file import job finished.
Only use this if you are writing a file importer, that is responsible for
processing file import jobs.
"""
QUERY = gql(
"""
mutation FinishFileImport($input: FinishFileImportInput!) {
data:fileUploadMutations {
data:finishFileImport(input: $input)
}
}
"""
)
variables = {
"input": input.model_dump(warnings="error", by_alias=True),
}
return self.make_request_and_parse_response(
DataResponse[DataResponse[bool]], QUERY, variables
).data.data
@deprecated(**FILE_UPLOAD_DEPRECATION_WARNING)
def get_model_file_import_jobs(
self,
*,
@@ -1,4 +1,4 @@
from typing import Any, Optional, Tuple
from typing import Any, Tuple
from gql import Client, gql
@@ -30,7 +30,7 @@ class ModelIngestionResource(ResourceBase):
account: Account,
basepath: str,
client: Client,
server_version: Optional[Tuple[Any, ...]],
server_version: Tuple[Any, ...] | None,
) -> None:
super().__init__(
account=account,
@@ -40,24 +40,23 @@ class ModelIngestionResource(ResourceBase):
server_version=server_version,
)
def get_ingestion(self, project_id: str, model_id: str) -> ModelIngestion:
def get_ingestion(self, project_id: str, model_ingestion_id: str) -> ModelIngestion:
QUERY = gql(
"""
query Query($projectId: String!, $modelId: String!) {
query Query($projectId: String!, $modelIngestionId: ID!) {
data:project(id: $projectId) {
data:model(id: $modelId) {
data:ingestion {
id
createdAt
modelId
cancellationRequested
statusData {
... on HasModelIngestionStatus {
status
}
... on HasProgressMessage {
progressMessage
}
data:ingestion(id: $modelIngestionId) {
id
createdAt
updatedAt
modelId
cancellationRequested
statusData {
... on HasModelIngestionStatus {
status
}
... on HasProgressMessage {
progressMessage
}
}
}
@@ -68,14 +67,14 @@ class ModelIngestionResource(ResourceBase):
variables = {
"projectId": project_id,
"modelId": model_id,
"modelIngestionId": model_ingestion_id,
}
return self.make_request_and_parse_response(
DataResponse[DataResponse[DataResponse[ModelIngestion]]],
DataResponse[DataResponse[ModelIngestion]],
QUERY,
variables,
).data.data.data
).data.data
def create(self, input: ModelIngestionCreateInput) -> ModelIngestion:
QUERY = gql(
+41 -7
View File
@@ -1,19 +1,20 @@
import contextlib
import getpass
import hashlib
import importlib.metadata
import logging
import platform
import queue
import sys
import threading
from typing import Any
from typing import Any, Literal
import requests
from specklepy.core.api.credentials import Account
"""
Anonymous telemetry to help us understand how to make a better Speckle.
Lightweight usage telemetry to help us understand how to make a better Speckle.
This really helps us to deliver a better open source project and product!
"""
TRACK = True
@@ -22,13 +23,14 @@ HOST_APP_VERSION = f"python {'.'.join(map(str, sys.version_info[:2]))}"
PLATFORMS = {"win32": "Windows", "cygwin": "Windows", "darwin": "Mac OS X"}
LOG = logging.getLogger(__name__)
METRICS_TRACKER = None
METRICS_TRACKER: "MetricsTracker | None" = None
# actions
SDK = "SDK Action"
CONNECTOR = "Connector Action"
RECEIVE = "Receive"
SEND = "Send"
ACTIONS = Literal["SDK Action", "Connector Action", "Receive", "Send"]
def disable():
@@ -48,15 +50,32 @@ def set_host_app(host_app: str, host_app_version: str | None = None):
def track(
action: str,
action: ACTIONS,
account: Account | None = None,
custom_props: dict | None = None,
send_sync: bool = False,
track_email: bool = False,
):
"""
:param action:
:type action: ACTIONS
:param account:
:type account: Account | None
:param custom_props:
:type custom_props: dict | None
:param send_sync: When `True`, the track event is executed synchronously,
and any exceptions will be raised.
When `False`, the track it is deferred to a queue, and any exceptions will be
swallowed and reported as warnings.
:type send_sync: bool
:param track_email: When `True`, the users plain text email address will be included
:type track_email: bool
"""
if not TRACK:
return
tracker = initialise_tracker(account)
event_params: dict[str, Any] = {
"event": action,
"properties": {
@@ -72,6 +91,18 @@ def track(
if custom_props:
event_params["properties"].update(custom_props)
if track_email:
event_params["properties"]["email"] = tracker.last_email
try:
specklepy_version = importlib.metadata.version("specklepy")
event_params["properties"]["core_version"] = specklepy_version
except importlib.metadata.PackageNotFoundError:
if send_sync:
raise
else:
LOG.warning("Failed to read specklepy's version number", exc_info=True)
if send_sync:
tracker.send_event(event_params)
else:
@@ -84,7 +115,7 @@ def initialise_tracker(account: Account | None = None) -> "MetricsTracker":
METRICS_TRACKER = MetricsTracker()
if account:
METRICS_TRACKER.set_last_user(account.userInfo.email)
METRICS_TRACKER.set_last_user_email(account.userInfo.email)
METRICS_TRACKER.set_last_server(account.serverInfo.url)
return METRICS_TRACKER
@@ -103,6 +134,7 @@ class MetricsTracker(metaclass=Singleton):
analytics_url: str = "https://analytics.speckle.systems/track?ip=1"
analytics_token: str = "acd87c5a50b56df91a795e999812a3a4"
last_user: str = ""
last_email: str = ""
last_server: str | None = None
platform: str
@@ -121,17 +153,19 @@ class MetricsTracker(metaclass=Singleton):
if node and user:
self.last_user = f"@{self.hash(f'{node}-{user}')}"
def set_last_user(self, email: str | None) -> None:
def set_last_user_email(self, email: str | None) -> None:
if not email:
return
self.last_user = f"@{self.hash(email)}"
self.last_email = email
def set_last_server(self, server: str | None) -> None:
if not server:
return
self.last_server = self.hash(server)
def hash(self, value: str) -> str:
@staticmethod
def hash(value: str) -> str:
inputList = value.lower().split("://")
input = inputList[len(inputList) - 1].split("/")[0].split("?")[0]
return hashlib.md5(input.encode("utf-8")).hexdigest().upper()
@@ -79,6 +79,15 @@ class TestIngestionResource:
return ingestion
def test_get_ingestion(
self, client: SpeckleClient, project: Project, ingestion: ModelIngestion
):
queried_ingestion = client.model_ingestion.get_ingestion(
project.id, ingestion.id
)
assert queried_ingestion.id == ingestion.id
assert queried_ingestion.status_data.status == ingestion.status_data.status
def test_update_progress(
self, client: SpeckleClient, ingestion: ModelIngestion, project: Project
):
@@ -198,7 +207,7 @@ class TestIngestionResource:
ingestion_id=ingestion.id,
root_object_id=object_id,
project_id=project.id,
# version_message=None,
version_message=None,
)
res = client.model_ingestion.complete(input)
@@ -262,7 +271,7 @@ class TestIngestionResource:
ingestion_id=ingestion.id,
root_object_id="asdfasdfasdfasfd",
project_id=project.id,
# version_message=None,
version_message=None,
)
with pytest.raises(GraphQLException):
_ = client.model_ingestion.complete(input)
+92
View File
@@ -0,0 +1,92 @@
from typing import Any, Callable
import pytest
from pytest_httpserver import HTTPServer
from requests import HTTPError
from werkzeug import Request, Response
from specklepy.core.api.client import SpeckleClient
from specklepy.logging import metrics
PATH = "/"
def assert_common_properties(payload: Any) -> None:
assert payload["event"] == "SDK Action"
assert payload["properties"]["token"] == "acd87c5a50b56df91a795e999812a3a4"
assert payload["properties"]["type"] == "action"
assert payload["properties"]["server_id"]
assert payload["properties"]["distinct_id"]
assert payload["properties"]["hostApp"] == "python"
assert payload["properties"]["hostAppVersion"]
assert payload["properties"]["core_version"]
def handler(extra_check: Callable[[Any], bool]) -> Callable[[Request], Response]:
def inner(request: Request) -> Response:
json = request.get_json()
payload = json[0]
assert_common_properties(payload)
assert extra_check(payload)
return Response("", 200)
return inner
def test_metrics_track(httpserver: HTTPServer, client: SpeckleClient):
with ScopedMetricsSetup(httpserver.url_for(PATH)) as _:
# Test No email
httpserver.expect_oneshot_request(PATH, "post").respond_with_handler(
handler(lambda payload: "email" not in payload["properties"])
)
metrics.track("SDK Action", client.account, None, True, False)
# Test With email
httpserver.expect_oneshot_request(PATH, "post").respond_with_handler(
handler(
lambda payload: payload["properties"]["email"]
== client.account.userInfo.email
)
)
metrics.track("SDK Action", client.account, None, True, True)
# Test With custom value
httpserver.expect_oneshot_request(PATH, "post").respond_with_handler(
handler(
lambda payload: payload["properties"]["myCustomProp"] == "myCustomValue"
)
)
metrics.track(
"SDK Action", client.account, {"myCustomProp": "myCustomValue"}, True, True
)
def test_metrics_errors(httpserver: HTTPServer):
with ScopedMetricsSetup(httpserver.url_for(PATH)) as _:
httpserver.expect_oneshot_request(PATH, "post").respond_with_data("", 400)
# Expect send_sync == true to mean mean it will raise
with pytest.raises(HTTPError):
metrics.track("SDK Action", send_sync=True)
# Expect send_sync == false to mean mean it won't
metrics.track("SDK Action")
class ScopedMetricsSetup:
"""
Scoped setup and tear down for enabling metrics tracking
"""
tracker: metrics.MetricsTracker
def __init__(self, metrics_url: str):
self.tracker = metrics.initialise_tracker()
self.tracker.analytics_url = metrics_url
def __enter__(self):
metrics.enable()
def __exit__(self, exc_type, exc_value, traceback):
metrics.disable()
metrics.METRICS_TRACKER = None
Generated
+26
View File
@@ -1867,6 +1867,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" },
]
[[package]]
name = "pytest-httpserver"
version = "1.1.3"
source = { registry = "https://pypi.org/simple/" }
dependencies = [
{ name = "werkzeug" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f1/d8/def15ba33bd696dd72dd4562a5287c0cba4d18a591eeb82e0b08ab385afc/pytest_httpserver-1.1.3.tar.gz", hash = "sha256:af819d6b533f84b4680b9416a5b3f67f1df3701f1da54924afd4d6e4ba5917ec", size = 68870, upload-time = "2025-04-10T08:17:15.6Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0d/d2/dfc2f25f3905921c2743c300a48d9494d29032f1389fc142e718d6978fb2/pytest_httpserver-1.1.3-py3-none-any.whl", hash = "sha256:5f84757810233e19e2bb5287f3826a71c97a3740abe3a363af9155c0f82fdbb9", size = 21000, upload-time = "2025-04-10T08:17:13.906Z" },
]
[[package]]
name = "pytest-ordering"
version = "0.6"
@@ -2222,6 +2234,7 @@ dev = [
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pytest-cov" },
{ name = "pytest-httpserver" },
{ name = "pytest-ordering" },
{ name = "ruff" },
{ name = "types-deprecated" },
@@ -2259,6 +2272,7 @@ dev = [
{ name = "pytest", specifier = ">=8.3.4" },
{ name = "pytest-asyncio", specifier = ">=0.25.2" },
{ name = "pytest-cov", specifier = ">=6.0.0" },
{ name = "pytest-httpserver", specifier = ">=1.1.3" },
{ name = "pytest-ordering", specifier = ">=0.6" },
{ name = "ruff", specifier = "==0.9.2" },
{ name = "types-deprecated", specifier = ">=1.2.15.20241117" },
@@ -2622,6 +2636,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/47/96/9d5749106ff57629b54360664ae7eb9afd8302fad1680ead385383e33746/websockets-11.0.3-py3-none-any.whl", hash = "sha256:6681ba9e7f8f3b19440921e99efbb40fc89f26cd71bf539e45d8c8a25c976dc6", size = 118056, upload-time = "2023-05-07T14:25:18.508Z" },
]
[[package]]
name = "werkzeug"
version = "3.1.4"
source = { registry = "https://pypi.org/simple/" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/45/ea/b0f8eeb287f8df9066e56e831c7824ac6bab645dd6c7a8f4b2d767944f9b/werkzeug-3.1.4.tar.gz", hash = "sha256:cd3cd98b1b92dc3b7b3995038826c68097dcb16f9baa63abe35f20eafeb9fe5e", size = 864687, upload-time = "2025-11-29T02:15:22.841Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2f/f9/9e082990c2585c744734f85bec79b5dae5df9c974ffee58fe421652c8e91/werkzeug-3.1.4-py3-none-any.whl", hash = "sha256:2ad50fb9ed09cc3af22c54698351027ace879a0b60a3b5edf5730b2f7d876905", size = 224960, upload-time = "2025-11-29T02:15:21.13Z" },
]
[[package]]
name = "wrapt"
version = "2.0.1"