From 07e9b97e1caad3caaa8f6ebe982532d65920f376 Mon Sep 17 00:00:00 2001 From: andig Date: Fri, 16 Oct 2026 14:23:09 +0200 Subject: [PATCH] feat: votes on explanations, stored with the plan in an Azure table The service stays stateless until a user votes. /optimize/explain/feedback takes the explain payload plus the explanation, the vote and a comment, and stores that as one row in the table explanations of the storage account stoptimizerfeedback. --- client/client.gen.go | 199 +++++++++++++++++++++++++++++++++ infra/main.bicep | 39 +++++++ openapi.yaml | 57 ++++++++++ pyproject.toml | 2 + src/optimizer/app.py | 45 +++++++- src/optimizer/feedback.py | 98 ++++++++++++++++ src/optimizer/settings.py | 7 ++ tests/conftest.py | 50 +++++++++ tests/test_explain.py | 47 +------- tests/test_feedback.py | 89 +++++++++++++++ uv.lock | 227 ++++++++++++++++++++++++++++++++++++++ 11 files changed, 808 insertions(+), 52 deletions(-) create mode 100644 src/optimizer/feedback.py create mode 100644 tests/conftest.py create mode 100644 tests/test_feedback.py diff --git a/client/client.gen.go b/client/client.gen.go index 0a842ef56..e53ab4dd4 100644 --- a/client/client.gen.go +++ b/client/client.gen.go @@ -15,6 +15,12 @@ import ( "time" ) +// Defines values for ExplainFeedbackVote. +const ( + Down ExplainFeedbackVote = "down" + Up ExplainFeedbackVote = "up" +) + // Defines values for OptimizationResultFlowDirection. const ( N0 OptimizationResultFlowDirection = 0 @@ -122,6 +128,41 @@ type Error struct { Message string `json:"message,omitempty"` } +// ExplainFeedback defines model for ExplainFeedback. +type ExplainFeedback struct { + // Comment What was wrong or missing, optional + Comment string `json:"comment,omitempty"` + + // Currency Currency of the prices + Currency string `json:"currency,omitempty"` + + // Details Request details as evcc publishes them in its state, a timestamp per step and batteryDetails with title, type and capacity per battery + Details struct { + BatteryDetails []struct { + // Capacity kWh + Capacity float32 `json:"capacity,omitempty"` + Name string `json:"name,omitempty"` + Title string `json:"title,omitempty"` + Type string `json:"type,omitempty"` + } `json:"batteryDetails,omitempty"` + Timestamp []time.Time `json:"timestamp,omitempty"` + } `json:"details,omitempty"` + + // Explanation The explanation as returned by /optimize/explain + Explanation string `json:"explanation"` + + // Language Language of the explanation as an IETF tag + Language string `json:"language,omitempty"` + Req OptimizationInput `json:"req"` + Res OptimizationResult `json:"res"` + + // Vote Whether the explanation helped + Vote ExplainFeedbackVote `json:"vote"` +} + +// ExplainFeedbackVote Whether the explanation helped +type ExplainFeedbackVote string + // ExplainInput defines model for ExplainInput. type ExplainInput struct { // Currency Currency of the prices @@ -296,6 +337,9 @@ type PostOptimizeChargeScheduleJSONRequestBody = OptimizationInput // PostOptimizeExplainJSONRequestBody defines body for PostOptimizeExplain for application/json ContentType. type PostOptimizeExplainJSONRequestBody = ExplainInput +// PostOptimizeExplainFeedbackJSONRequestBody defines body for PostOptimizeExplainFeedback for application/json ContentType. +type PostOptimizeExplainFeedbackJSONRequestBody = ExplainFeedback + // RequestEditorFn is the function signature for the RequestEditor callback function type RequestEditorFn func(ctx context.Context, req *http.Request) error @@ -379,6 +423,11 @@ type ClientInterface interface { PostOptimizeExplain(ctx context.Context, body PostOptimizeExplainJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + // PostOptimizeExplainFeedbackWithBody request with any body + PostOptimizeExplainFeedbackWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + PostOptimizeExplainFeedback(ctx context.Context, body PostOptimizeExplainFeedbackJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + // GetOptimizeHealth request GetOptimizeHealth(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) } @@ -431,6 +480,30 @@ func (c *Client) PostOptimizeExplain(ctx context.Context, body PostOptimizeExpla return c.Client.Do(req) } +func (c *Client) PostOptimizeExplainFeedbackWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewPostOptimizeExplainFeedbackRequestWithBody(c.Server, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) PostOptimizeExplainFeedback(ctx context.Context, body PostOptimizeExplainFeedbackJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewPostOptimizeExplainFeedbackRequest(c.Server, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + func (c *Client) GetOptimizeHealth(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) { req, err := NewGetOptimizeHealthRequest(c.Server) if err != nil { @@ -523,6 +596,46 @@ func NewPostOptimizeExplainRequestWithBody(server string, contentType string, bo return req, nil } +// NewPostOptimizeExplainFeedbackRequest calls the generic PostOptimizeExplainFeedback builder with application/json body +func NewPostOptimizeExplainFeedbackRequest(server string, body PostOptimizeExplainFeedbackJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewPostOptimizeExplainFeedbackRequestWithBody(server, "application/json", bodyReader) +} + +// NewPostOptimizeExplainFeedbackRequestWithBody generates requests for PostOptimizeExplainFeedback with any type of body +func NewPostOptimizeExplainFeedbackRequestWithBody(server string, contentType string, body io.Reader) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/optimize/explain/feedback") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + // NewGetOptimizeHealthRequest generates requests for GetOptimizeHealth func NewGetOptimizeHealthRequest(server string) (*http.Request, error) { var err error @@ -603,6 +716,11 @@ type ClientWithResponsesInterface interface { PostOptimizeExplainWithResponse(ctx context.Context, body PostOptimizeExplainJSONRequestBody, reqEditors ...RequestEditorFn) (*PostOptimizeExplainResponse, error) + // PostOptimizeExplainFeedbackWithBodyWithResponse request with any body + PostOptimizeExplainFeedbackWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*PostOptimizeExplainFeedbackResponse, error) + + PostOptimizeExplainFeedbackWithResponse(ctx context.Context, body PostOptimizeExplainFeedbackJSONRequestBody, reqEditors ...RequestEditorFn) (*PostOptimizeExplainFeedbackResponse, error) + // GetOptimizeHealthWithResponse request GetOptimizeHealthWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*GetOptimizeHealthResponse, error) } @@ -656,6 +774,30 @@ func (r PostOptimizeExplainResponse) StatusCode() int { return 0 } +type PostOptimizeExplainFeedbackResponse struct { + Body []byte + HTTPResponse *http.Response + JSON400 *Error + JSON502 *Error + JSON503 *Error +} + +// Status returns HTTPResponse.Status +func (r PostOptimizeExplainFeedbackResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r PostOptimizeExplainFeedbackResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + type GetOptimizeHealthResponse struct { Body []byte HTTPResponse *http.Response @@ -715,6 +857,23 @@ func (c *ClientWithResponses) PostOptimizeExplainWithResponse(ctx context.Contex return ParsePostOptimizeExplainResponse(rsp) } +// PostOptimizeExplainFeedbackWithBodyWithResponse request with arbitrary body returning *PostOptimizeExplainFeedbackResponse +func (c *ClientWithResponses) PostOptimizeExplainFeedbackWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*PostOptimizeExplainFeedbackResponse, error) { + rsp, err := c.PostOptimizeExplainFeedbackWithBody(ctx, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParsePostOptimizeExplainFeedbackResponse(rsp) +} + +func (c *ClientWithResponses) PostOptimizeExplainFeedbackWithResponse(ctx context.Context, body PostOptimizeExplainFeedbackJSONRequestBody, reqEditors ...RequestEditorFn) (*PostOptimizeExplainFeedbackResponse, error) { + rsp, err := c.PostOptimizeExplainFeedback(ctx, body, reqEditors...) + if err != nil { + return nil, err + } + return ParsePostOptimizeExplainFeedbackResponse(rsp) +} + // GetOptimizeHealthWithResponse request returning *GetOptimizeHealthResponse func (c *ClientWithResponses) GetOptimizeHealthWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*GetOptimizeHealthResponse, error) { rsp, err := c.GetOptimizeHealth(ctx, reqEditors...) @@ -811,6 +970,46 @@ func ParsePostOptimizeExplainResponse(rsp *http.Response) (*PostOptimizeExplainR return response, nil } +// ParsePostOptimizeExplainFeedbackResponse parses an HTTP response from a PostOptimizeExplainFeedbackWithResponse call +func ParsePostOptimizeExplainFeedbackResponse(rsp *http.Response) (*PostOptimizeExplainFeedbackResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &PostOptimizeExplainFeedbackResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 502: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON502 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 503: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON503 = &dest + + } + + return response, nil +} + // ParseGetOptimizeHealthResponse parses an HTTP response from a GetOptimizeHealthWithResponse call func ParseGetOptimizeHealthResponse(rsp *http.Response) (*GetOptimizeHealthResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) diff --git a/infra/main.bicep b/infra/main.bicep index 0a64c1672..ba16b8b61 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -28,6 +28,43 @@ resource logAnalytics 'Microsoft.OperationalInsights/workspaces@2025-02-01' = { } } +// explanations and the votes on them, read by the prompt tuning skill. Table storage: one +// entity per explanation, a few thousand a month, no server to run. +resource feedbackStorage 'Microsoft.Storage/storageAccounts@2023-05-01' = { + name: 'stoptimizerfeedback' + location: location + kind: 'StorageV2' + sku: { + name: 'Standard_LRS' + } + properties: { + minimumTlsVersion: 'TLS1_2' + allowBlobPublicAccess: false + supportsHttpsTrafficOnly: true + } +} + +resource feedbackTables 'Microsoft.Storage/storageAccounts/tableServices@2023-05-01' = { + parent: feedbackStorage + name: 'default' +} + +resource feedbackTable 'Microsoft.Storage/storageAccounts/tableServices/tables@2023-05-01' = { + parent: feedbackTables + name: 'explanations' +} + +// Storage Table Data Contributor for the container app's managed identity +resource feedbackWriter 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(feedbackStorage.id, containerApp.id, 'table-data-contributor') + scope: feedbackStorage + properties: { + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '0a9a7e1f-b9d0-4cc4-a60d-0319b160aebd') + principalId: containerApp.identity.principalId + principalType: 'ServicePrincipal' + } +} + @description('Custom hostname served by the container app') param customHostname string = 'optimizer.evcc.io' @@ -127,6 +164,8 @@ resource containerApp 'Microsoft.App/containerApps@2025-01-01' = { // plan explanations, /optimize/explain { name: 'OPTIMIZER_AZURE_OPENAI_ENDPOINT', value: 'https://evcc-ai-resource.cognitiveservices.azure.com/' } { name: 'OPTIMIZER_AZURE_OPENAI_KEY', secretRef: 'azure-openai-key' } + // explanations and votes, /optimize/explain/feedback, written with the managed identity + { name: 'OPTIMIZER_FEEDBACK_TABLE_ENDPOINT', value: feedbackStorage.properties.primaryEndpoints.table } ] probes: [ { diff --git a/openapi.yaml b/openapi.yaml index dc6461039..51dc82b5e 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -168,6 +168,44 @@ paths: schema: $ref: "#/components/schemas/Error" + /optimize/explain/feedback: + post: + tags: + - optimization + summary: Vote on an explanation + description: | + Stores a thumbs up or down and an optional comment together with the plan and the explanation + /optimize/explain gave for it, to improve the prompt. Nothing is stored until a user votes. + + The endpoint is only available where the service is configured with a feedback store. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ExplainFeedback" + responses: + "200": + description: The vote was stored + "400": + description: Bad request - plan, explanation or vote missing or malformed + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "502": + description: The feedback store could not be reached + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "503": + description: Feedback is not configured on this service + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + /optimize/health: get: tags: @@ -566,6 +604,25 @@ components: type: string description: Explanation of the plan, markdown + ExplainFeedback: + allOf: + - $ref: "#/components/schemas/ExplainInput" + - type: object + required: + - explanation + - vote + properties: + explanation: + type: string + description: The explanation as returned by /optimize/explain + vote: + type: string + enum: [up, down] + description: Whether the explanation helped + comment: + type: string + description: What was wrong or missing, optional + Error: type: object properties: diff --git a/pyproject.toml b/pyproject.toml index 6c24d2fe6..7f4090cc8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,6 +6,8 @@ readme = "README.md" authors = [{ name = "andig", email = "andi@evcc.io" }, { name = "ekke" }] requires-python = "~=3.13.0" dependencies = [ + "azure-data-tables>=12.7.0", + "azure-identity>=1.26.0", "flask>=3.0.3", "flask-restx>=1.3.0", "gunicorn>=22.0.0", diff --git a/src/optimizer/app.py b/src/optimizer/app.py index fda7f6f9d..b761a06b8 100644 --- a/src/optimizer/app.py +++ b/src/optimizer/app.py @@ -10,7 +10,7 @@ from flask_restx import Api, Resource, fields from werkzeug.exceptions import BadRequest -from . import explain +from . import explain, feedback from .optimizer import CHARGING_STRATEGIES, DISCHARGING_STRATEGIES, BatteryConfig, GridConfig, OptimizationStrategy, Optimizer, TimeSeriesData from .settings import OptimizerSettings @@ -390,6 +390,21 @@ def log(): 'explanation': fields.String(description='Explanation of the plan, markdown'), }) +explain_feedback_model = api.inherit('ExplainFeedback', explain_input_model, { + 'explanation': fields.String(required=True, description='The explanation as returned by /optimize/explain'), + 'vote': fields.String(required=True, enum=['up', 'down'], description='Whether the explanation helped'), + 'comment': fields.String(required=False, description='What was wrong or missing, optional'), +}) + + +def plan_payload(): + """The validated payload; the result model documents its fields but requires none of them.""" + data = api.payload + for key in ('status', 'batteries'): + if key not in data['res']: + api.abort(400, f"res.{key} is a required property") + return data + @ns.route('/explain') class ExplainPlan(Resource): @@ -404,11 +419,7 @@ def post(self): if not settings.azure_openai_endpoint or not settings.azure_openai_key: api.abort(503, "Plan explanations are not configured") - data = api.payload - # the result model documents its fields but requires none of them - for key in ('status', 'batteries'): - if key not in data['res']: - api.abort(400, f"res.{key} is a required property") + data = plan_payload() started = time.perf_counter() try: @@ -427,6 +438,28 @@ def post(self): return {'explanation': text} +@ns.route('/explain/feedback') +class ExplainFeedback(Resource): + @api.expect(explain_feedback_model, validate=True) + def post(self): + """ + Vote on an explanation + + Stores the plan, the explanation it got and the thumbs up or down with an optional comment. + Nothing is stored until a user votes. + """ + if not feedback.configured(settings): + api.abort(503, "Explanation feedback is not configured") + + data = plan_payload() + try: + feedback.store(settings, data, explain.digest(data), explain.SYSTEM_PROMPT, g.get('subject')) + except Exception as e: + api.abort(502, f"feedback not stored: {str(e)[:200]}") + + return {} + + @ns.route('/health') class Health(Resource): def get(self): diff --git a/src/optimizer/feedback.py b/src/optimizer/feedback.py new file mode 100644 index 000000000..afbbc0927 --- /dev/null +++ b/src/optimizer/feedback.py @@ -0,0 +1,98 @@ +"""Explanations a user voted on, in an Azure table, the raw material for tuning the prompt. + +Nothing is stored for an explanation by itself: the service stays stateless until the user votes. +The vote brings the explain payload and the answer back, and one entity keeps what the model saw +(the digest), which prompt and model, the answer, the vote and a few plan features to group by. +PartitionKey is the month of the vote. +""" +import gzip +import hashlib +import uuid +from datetime import datetime, timezone + +from azure.data.tables import TableClient +from azure.identity import DefaultAzureCredential + +TABLE = 'explanations' + +# a string or binary property holds 64 KiB at most, the digest of a long horizon is larger +CHUNK = 60_000 + + +def configured(settings): + return bool(settings.feedback_connection_string or settings.feedback_table_endpoint) + + +def table(settings): + if settings.feedback_connection_string: + return TableClient.from_connection_string(settings.feedback_connection_string, TABLE) + return TableClient(settings.feedback_table_endpoint, TABLE, credential=DefaultAzureCredential()) + + +def subject_hash(subject): + """Account pseudonym: lets one voter's rows be grouped or removed without naming the account.""" + return hashlib.sha256(subject.encode()).hexdigest()[:16] if subject else None + + +def prompt_hash(prompt): + return hashlib.sha256(prompt.encode()).hexdigest()[:12] + + +def pack(text): + """gzip the text and split it into properties digest0..digestN below the property size limit.""" + data = gzip.compress(text.encode()) + parts = [data[i:i + CHUNK] for i in range(0, len(data), CHUNK)] or [b''] + return {'digestParts': len(parts), **{f'digest{i}': part for i, part in enumerate(parts)}} + + +def unpack(entity): + data = b''.join(bytes(entity[f'digest{i}']) for i in range(entity.get('digestParts', 0))) + return gzip.decompress(data).decode() if data else '' + + +def features(payload): + """Plan features worth grouping votes by.""" + req, res = payload['req'], payload['res'] + details = payload.get('details') or {} + kinds = [d.get('type') for d in details.get('batteryDetails') or []] + return { + 'status': res.get('status'), + 'steps': len(req['time_series']['dt']), + 'horizonHours': round(sum(req['time_series']['dt']) / 3600), + 'vehicles': kinds.count('vehicle'), + 'batteries': kinds.count('battery'), + 'strategy': (req.get('strategy') or {}).get('charging_strategy'), + 'departure': any(b.get('r_departure') for b in req['batteries']), + 'goals': any(any(v > 0 for v in b.get('s_goal') or []) for b in req['batteries']), + 'demands': any(any(v > 0 for v in b.get('p_demand') or []) for b in req['batteries']), + } + + +def store(settings, payload, digest, prompt, subject=None): + """Store a vote with the explanation it was given on. payload is the explain payload plus + explanation, vote and comment.""" + now = datetime.now(timezone.utc) + entity = { + 'PartitionKey': f'{now:%Y-%m}', + 'RowKey': uuid.uuid4().hex, + 'created': now, + 'language': payload.get('language') or 'en', + 'promptHash': prompt_hash(prompt), + 'model': settings.azure_openai_deployment, + 'answer': payload['explanation'], + 'vote': payload['vote'], + 'comment': (payload.get('comment') or '')[:4000], + 'subject': subject_hash(subject), + **features(payload), + **pack(digest), + } + table(settings).create_entity(entity) + + +def rows(settings, since=None): + """All votes, oldest first, with the digest unpacked. since limits to months from YYYY-MM on.""" + query = f"PartitionKey ge '{since}'" if since else None + for entity in table(settings).query_entities(query) if query else table(settings).list_entities(): + row = {k: v for k, v in entity.items() if not k.startswith('digest')} + row['digest'] = unpack(entity) + yield row diff --git a/src/optimizer/settings.py b/src/optimizer/settings.py index 0e418308b..690dec7fa 100644 --- a/src/optimizer/settings.py +++ b/src/optimizer/settings.py @@ -46,3 +46,10 @@ class OptimizerSettings(BaseSettings): "https://name.openai.azure.com/. Unset disables /optimize/explain") azure_openai_deployment: str = Field(default="gpt-6-luna", description="Deployment name of the chat model behind /optimize/explain") azure_openai_key: str | None = Field(default=None, description="API key of the Azure OpenAI resource") + feedback_table_endpoint: str | None = Field(default=None, + description="Azure table service the explanations and the votes on them are stored in, " + "such as https://name.table.core.windows.net, accessed with the managed identity. " + "Unset without a connection string disables /optimize/explain/feedback") + feedback_connection_string: str | None = Field(default=None, + description="Storage account connection string for the explanations table, for " + "local runs and Azurite. Takes precedence over the endpoint") diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 000000000..1c0a46230 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,50 @@ +"""Fixtures shared by the explain and feedback tests.""" +import pytest + +from optimizer.app import settings + + +@pytest.fixture +def payload(): + # four quarter hours and one full hour, soc read at the end of each hour + return { + 'req': { + 'batteries': [{ + 's_min': 0, 's_max': 10000, 's_initial': 5000, + 'c_min': 0, 'c_max': 5000, 'd_max': 5000, 'p_a': 0.0002, + 'charge_from_grid': True, + }], + 'time_series': { + 'dt': [900, 900, 900, 900, 3600], + 'gt': [250, 250, 250, 250, 1000], + 'ft': [0, 0, 0, 0, 2000], + 'p_N': [0.0003, 0.0003, 0.0001, 0.0001, 0.0002], + 'p_E': [0.0001] * 5, + }, + }, + 'res': { + 'status': 'Optimal', + 'objective_value': -1.5, + 'batteries': [{ + 'charging_power': [0, 0, 1250, 1250, 0], + 'discharging_power': [250, 250, 0, 0, 0], + 'state_of_charge': [4750, 4500, 5750, 7000, 7000], + }], + 'grid_import': [0, 0, 1500, 1500, 0], + 'grid_export': [0, 0, 0, 0, 1000], + }, + 'details': { + 'timestamp': ['2026-10-15T10:00:00+02:00', '2026-10-15T10:15:00+02:00', '2026-10-15T10:30:00+02:00', + '2026-10-15T10:45:00+02:00', '2026-10-15T11:00:00+02:00'], + 'batteryDetails': [{'name': 'bat', 'title': 'Home battery', 'type': 'battery', 'capacity': 10}], + }, + 'currency': 'EUR', + 'language': 'de', + } + + +@pytest.fixture +def configured(monkeypatch): + monkeypatch.setattr(settings, 'azure_openai_endpoint', 'https://example.invalid/') + monkeypatch.setattr(settings, 'azure_openai_key', 'test-key') + diff --git a/tests/test_explain.py b/tests/test_explain.py index 14105ad57..224ad4f43 100644 --- a/tests/test_explain.py +++ b/tests/test_explain.py @@ -4,51 +4,6 @@ from optimizer.app import app, settings -@pytest.fixture -def payload(): - # four quarter hours and one full hour, soc read at the end of each hour - return { - 'req': { - 'batteries': [{ - 's_min': 0, 's_max': 10000, 's_initial': 5000, - 'c_min': 0, 'c_max': 5000, 'd_max': 5000, 'p_a': 0.0002, - 'charge_from_grid': True, - }], - 'time_series': { - 'dt': [900, 900, 900, 900, 3600], - 'gt': [250, 250, 250, 250, 1000], - 'ft': [0, 0, 0, 0, 2000], - 'p_N': [0.0003, 0.0003, 0.0001, 0.0001, 0.0002], - 'p_E': [0.0001] * 5, - }, - }, - 'res': { - 'status': 'Optimal', - 'objective_value': -1.5, - 'batteries': [{ - 'charging_power': [0, 0, 1250, 1250, 0], - 'discharging_power': [250, 250, 0, 0, 0], - 'state_of_charge': [4750, 4500, 5750, 7000, 7000], - }], - 'grid_import': [0, 0, 1500, 1500, 0], - 'grid_export': [0, 0, 0, 0, 1000], - }, - 'details': { - 'timestamp': ['2026-10-15T10:00:00+02:00', '2026-10-15T10:15:00+02:00', '2026-10-15T10:30:00+02:00', - '2026-10-15T10:45:00+02:00', '2026-10-15T11:00:00+02:00'], - 'batteryDetails': [{'name': 'bat', 'title': 'Home battery', 'type': 'battery', 'capacity': 10}], - }, - 'currency': 'EUR', - 'language': 'de', - } - - -@pytest.fixture -def configured(monkeypatch): - monkeypatch.setattr(settings, 'azure_openai_endpoint', 'https://example.invalid/') - monkeypatch.setattr(settings, 'azure_openai_key', 'test-key') - - def test_step_labels_without_timestamps_count_from_the_start(payload): assert explain.step_labels(payload['req']['time_series']['dt']) == ['+0:00', '+0:15', '+0:30', '+0:45', '+1:00', '+2:00'] @@ -116,7 +71,7 @@ def fake_chat(_settings, messages): response = app.test_client().post('/optimize/explain', json=payload) assert response.status_code == 200, response.json - assert response.json == {'explanation': 'Because the price drops at 10:30.'} + assert response.json['explanation'] == 'Because the price drops at 10:30.' system, user = seen['messages'] assert system['content'] == explain.SYSTEM_PROMPT + '\nAnswer in the language with IETF tag de.' assert 'today 10:00 | 15 | 30.0' in user['content'] diff --git a/tests/test_feedback.py b/tests/test_feedback.py new file mode 100644 index 000000000..49ecdca39 --- /dev/null +++ b/tests/test_feedback.py @@ -0,0 +1,89 @@ +import pytest + +from optimizer import explain, feedback +from optimizer.app import app, settings + + +class FakeTable: + """The two table calls the module makes, on a list.""" + + def __init__(self): + self.rows = [] + + def create_entity(self, entity): + self.rows.append(dict(entity)) + + def list_entities(self): + return list(self.rows) + + +@pytest.fixture +def store(monkeypatch): + fake = FakeTable() + monkeypatch.setattr(settings, 'feedback_connection_string', 'UseDevelopmentStorage=true') + monkeypatch.setattr(feedback, 'table', lambda _settings: fake) + return fake + + +@pytest.fixture +def voted(payload): + return {**payload, 'explanation': 'because', 'vote': 'down', 'comment': 'wrong time'} + + +def test_pack_round_trips_long_digests(): + text = 'row | 1.0 | 2.0\n' * 20000 + packed = feedback.pack(text) + + assert packed['digestParts'] >= 1 + assert all(len(packed[f'digest{i}']) <= feedback.CHUNK for i in range(packed['digestParts'])) + assert feedback.unpack(packed) == text + assert feedback.unpack(feedback.pack('')) == '' + + +def test_feedback_stores_the_vote_with_the_plan_and_explanation(voted, store): + client = app.test_client() + response = client.post('/optimize/explain/feedback', json=voted) + assert response.status_code == 200, response.json + + row, = store.rows + assert (row['vote'], row['comment'], row['answer']) == ('down', 'wrong time', 'because') + assert row['language'] == 'de' + assert row['promptHash'] == feedback.prompt_hash(explain.SYSTEM_PROMPT) + assert row['status'] == 'Optimal' + assert (row['steps'], row['horizonHours'], row['vehicles'], row['batteries']) == (5, 2, 0, 1) + assert row['PartitionKey'] == row['created'].strftime('%Y-%m') + assert 'today 10:30 | 15' in feedback.unpack(row) + + # a second vote on the same explanation is another row + assert client.post('/optimize/explain/feedback', json={**voted, 'vote': 'up', 'comment': ''}).status_code == 200 + assert [(r['vote'], r['comment']) for r in feedback.rows(settings)] == [('down', 'wrong time'), ('up', '')] + + +@pytest.mark.parametrize('change', [{'vote': 'meh'}, {'vote': None}, {'explanation': None}, {'res': {}}]) +def test_feedback_rejects_incomplete_votes(voted, store, change): + assert app.test_client().post('/optimize/explain/feedback', json={**voted, **change}).status_code == 400 + assert store.rows == [] + + +def test_explain_stores_nothing(payload, configured, store, monkeypatch): + monkeypatch.setattr(explain, 'chat', lambda _settings, _messages: ('because', {})) + response = app.test_client().post('/optimize/explain', json=payload) + + assert response.status_code == 200 + assert response.json == {'explanation': 'because'} + assert store.rows == [] + + +def test_feedback_without_store_is_503(voted): + assert app.test_client().post('/optimize/explain/feedback', json=voted).status_code == 503 + + +def test_store_failure_is_502(voted, store, monkeypatch): + def broken(*_args, **_kwargs): + raise RuntimeError('table offline') + + monkeypatch.setattr(feedback, 'store', broken) + response = app.test_client().post('/optimize/explain/feedback', json=voted) + + assert response.status_code == 502 + assert 'table offline' in response.json['message'] diff --git a/uv.lock b/uv.lock index 4dfb0ff2c..511006725 100644 --- a/uv.lock +++ b/uv.lock @@ -41,6 +41,50 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9e/43/53afb8ba17218f19b77c7834128566c5bbb100a0ad9ba2e8e89d089d7079/autopep8-2.3.2-py2.py3-none-any.whl", hash = "sha256:ce8ad498672c845a0c3de2629c15b635ec2b05ef8177a6e7c91c74f3e9b51128", size = 45807, upload-time = "2025-01-14T14:46:15.466Z" }, ] +[[package]] +name = "azure-core" +version = "1.41.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a6/f3/b416179e408990df5db0d516283022dde0f5d0111d98c1a848e41853e81c/azure_core-1.41.0.tar.gz", hash = "sha256:f46ff5dfcd230f25cf1c19e8a34b8dc08a337b2503e268bb600a16c00db8ad5a", size = 381042, upload-time = "2026-05-07T23:30:54.302Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/db/325c6d7312d2200251c52323878281045aaffcb5586612296484e4280eaa/azure_core-1.41.0-py3-none-any.whl", hash = "sha256:522b4011e8180b1a3dcd2024396a4e7fe9ac37fb8597db47163d230b5efe892d", size = 220920, upload-time = "2026-05-07T23:30:56.357Z" }, +] + +[[package]] +name = "azure-data-tables" +version = "12.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "azure-core" }, + { name = "isodate" }, + { name = "typing-extensions" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ad/b8/e76cfa7f5f847722664c6f8108c391deadcb9d9aad579650c128affd4669/azure_data_tables-12.7.0.tar.gz", hash = "sha256:b14fc94a3223a2835ff5688e17d8e107b27c7cd7c4114138f2ac81373723705d", size = 258650, upload-time = "2025-05-06T17:51:06.323Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/3e/2b8da274b49402659cd732def2ae217f2e86312278feb56b55ccbffbbc26/azure_data_tables-12.7.0-py3-none-any.whl", hash = "sha256:24ed9b5690aa46c213182e32bb1b39a68dd9f526d84f447c287e3a401b437c10", size = 133178, upload-time = "2025-05-06T17:51:07.65Z" }, +] + +[[package]] +name = "azure-identity" +version = "1.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "azure-core" }, + { name = "cryptography" }, + { name = "msal" }, + { name = "msal-extensions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e6/87/e88098a024d74b7434de5b110c261da024fe5d6c139f1847f6f8a2b76678/azure_identity-1.26.0.tar.gz", hash = "sha256:61b4538b87df10d2d6d44b77cd5ea28a8929fd4929d58b45bf3f230f7daba02e", size = 310151, upload-time = "2026-10-01T14:17:47.825Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/d5/ae45ce8009d0d015304b206c7c4a6cbab02c03f8f07eedefb32b099ccee3/azure_identity-1.26.0-py3-none-any.whl", hash = "sha256:3c60d9682b3ac01507388d474cb5a5ffbe54152a96b6cdcaa7013da9315cf186", size = 195958, upload-time = "2026-10-01T14:17:49.705Z" }, +] + [[package]] name = "bidict" version = "0.23.1" @@ -209,6 +253,56 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/15/6e/2c65834041468bcabb956c5e4465ad240af7f312b2d8b54d7c9a51b26885/cpsat_logutils-1.0.0-py3-none-any.whl", hash = "sha256:02112cc460d30aeab3a591592bfa09cc5705fa3745d5d418a7339e8843c14e5d", size = 37338, upload-time = "2026-09-06T16:09:13.47Z" }, ] +[[package]] +name = "cryptography" +version = "50.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/af/182eb91b0df3fe75c4d9f26fe70684569566745f6ba7e5c9c73a862c5252/cryptography-50.0.2.tar.gz", hash = "sha256:7b46165bb56eb4704e2eaaf86f3c940d19154535d9b0ca7d6d590b04060e00d5", size = 880623, upload-time = "2026-09-30T15:30:04.884Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/56/d194340cc4a57535e82e1bee9e89667ac4b7c13b5d3f59686deae3094dd5/cryptography-50.0.2-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:fa8f5efb344d6908a1ce62f4a24e2e5780f825d6f53f5f50ec5ffacac72936cb", size = 3914904, upload-time = "2026-09-30T14:43:44.339Z" }, + { url = "https://files.pythonhosted.org/packages/d9/69/c9bd862c3bf43d6399c433caf002df16e2dffd4be49bdf515cda38038711/cryptography-50.0.2-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:79def8d059362e7831389ed3be0ecdf58a89386e1271e35dd9f5af84e81bffd0", size = 4731146, upload-time = "2026-09-30T14:43:47.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/69/64cef1f702bf6657e0cc186ed1a2891d50d29fb41586b254e1c07adea261/cryptography-50.0.2-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:630ebfea3bf689d075f82316324ff7433dc447fe6bc1bfc76524b74b4a9567d2", size = 4719841, upload-time = "2026-09-30T14:43:49.01Z" }, + { url = "https://files.pythonhosted.org/packages/38/6b/61a3f8d8c5e1e49a6cddccafc4015cc1c0021360ab0acb4080e7a423644a/cryptography-50.0.2-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f9f6143a8c75945eb960d9eb98905a441394abfa24afaae239d514ffb2586480", size = 4738340, upload-time = "2026-09-30T14:43:50.932Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2e/7212ca32fd43dc91f2f41db20160b268098874b4c9a0e7be94d6835f5b2e/cryptography-50.0.2-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:a582ab2ae1d34f67112cadc86702774c9ea4374df6bca6afe672817203c99134", size = 5367029, upload-time = "2026-09-30T14:43:52.911Z" }, + { url = "https://files.pythonhosted.org/packages/1a/f1/b474e930c4d910328780e3940da76f5aa5cbc48ce1fc14e44d239d9ea9db/cryptography-50.0.2-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:4061c0079120205fb760c58acab6443e217307dcf05e3702cf970e0689972856", size = 4753050, upload-time = "2026-09-30T14:43:55.272Z" }, + { url = "https://files.pythonhosted.org/packages/7c/52/9af10e80ac16b0fcc2123f9cbd5e7afbd0fd5075bb7a607c592258a39cda/cryptography-50.0.2-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:ac9ed99d81760c62fe89d5f0815cdfa1ba9a35141cf30f1c2d044f04b4803d2e", size = 4376724, upload-time = "2026-09-30T14:43:57.24Z" }, + { url = "https://files.pythonhosted.org/packages/71/37/6202e488cc1eb625ea110c292c6bda92823176e023f427d8d5660ce8d632/cryptography-50.0.2-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:87e9ce85beb6b328ba370cc6e6aea483c92617b4c95b1d33a49297eb662bfb04", size = 4737859, upload-time = "2026-09-30T14:43:59.541Z" }, + { url = "https://files.pythonhosted.org/packages/8f/30/e86d7d518489b0ae2497091a35287abcb1a2ce4037837a34afbe9b1d6964/cryptography-50.0.2-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f265528741e048bce55c3463ed721fb0aa45a5888d8add8cfeccb3035451bbdc", size = 5324103, upload-time = "2026-09-30T14:44:01.901Z" }, + { url = "https://files.pythonhosted.org/packages/d3/69/2c833a049475e0a3444e94c7d0aca0aa51d166374a449b09e92ac98138de/cryptography-50.0.2-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dab55f57c74c3cad24c323bacbbd04be4705ba6eb0d92e920b1fc4837ed5079", size = 4752576, upload-time = "2026-09-30T14:44:04.545Z" }, + { url = "https://files.pythonhosted.org/packages/6c/5d/906970b83bbfc1f5bbfb677a143c181f2801f23b6a7204a3b47c42c97e65/cryptography-50.0.2-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:25784ce8b9621c90c643efb9e1e2162ab3b0224cae446ad5e70e7fcb1ce18b51", size = 4870819, upload-time = "2026-09-30T14:44:06.884Z" }, + { url = "https://files.pythonhosted.org/packages/68/e3/f2298d3bb55e0c4a91841ec4d01b3f020ba8c5fbf15ccdcc6dcf03f97025/cryptography-50.0.2-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:85d0d9a31b9098e98534226d5686b47264b95e62ce459dc2e62fdfc809f9fe93", size = 5030152, upload-time = "2026-09-30T14:44:09.443Z" }, + { url = "https://files.pythonhosted.org/packages/9a/4f/adfc442765721292fff86d314ce385d3249d22db42295c0dd057727b60f3/cryptography-50.0.2-cp311-abi3-win_amd64.whl", hash = "sha256:7afa5a6602a9f29af1f3a2965f831bae7c9d5d597b7cbb716d41ab3b7d89879c", size = 3824692, upload-time = "2026-09-30T14:44:11.671Z" }, + { url = "https://files.pythonhosted.org/packages/2d/49/93f6a6e7a87c9aa68d44d3e1cdb5fe8f60c90d5d2f46acae9a56892816b8/cryptography-50.0.2-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:edc3342adf8f697fc5f59c887a304356f147b397809440ed64e2fa6af2f50f37", size = 4133708, upload-time = "2026-09-30T14:44:41.807Z" }, + { url = "https://files.pythonhosted.org/packages/8c/75/32ac2a56243d778805c16ca6a32b8f74fb757df7e28d7ecb560afafb59cf/cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d370b8d1dfcdf7130178137f6fbee6140774a1acc6cacefc4b42643ec11d0a3a", size = 4956267, upload-time = "2026-09-30T14:44:43.693Z" }, + { url = "https://files.pythonhosted.org/packages/aa/a4/2c8d734e43d97f0842ee9f1b7b4bfb3d0cf5e19edebf43c2afe6675c2320/cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f2f9bd7f90c64fe89253f0a2c05e3c4856072660429ce8831b4235bf29403a67", size = 4966465, upload-time = "2026-09-30T14:44:45.769Z" }, + { url = "https://files.pythonhosted.org/packages/c2/58/ee288c829a6f41f6235ae9dd33d82fd19b45442b65b4c8a3da36963d9f7a/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_aarch64.whl", hash = "sha256:e275096ea1e60cc595cda2836fd4a6c725d1125108b868be17f53684d164e2cc", size = 4959356, upload-time = "2026-09-30T14:44:48.211Z" }, + { url = "https://files.pythonhosted.org/packages/92/20/9ded6d51ddd9897f6b6e81fb9ebea7951d7cc5d6c890b0ed8abf77a51a80/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_ppc64le.whl", hash = "sha256:b13478603dcd0a2479ff8e87e2c19a7d525734686fe3c49542472293a204212d", size = 5548822, upload-time = "2026-09-30T14:44:50.86Z" }, + { url = "https://files.pythonhosted.org/packages/02/a8/8df951850d6b31d2a00218f19e2b3f999523437ed7a819df7fa427942fca/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_x86_64.whl", hash = "sha256:58a0c478eeca76fe5e07993c5a0703def34a6dc6a0cda4f5564639b33112ffe7", size = 5001199, upload-time = "2026-09-30T14:44:53.379Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f9/36b3022218ce75b7cdf068fb95f809f9bd0d820e4955ef43b90c255cc7ac/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_31_armv7l.whl", hash = "sha256:d38cdff612d06fa6a32840d5e1b1f7a27cee4a349aa9085d94a67789d6bfd408", size = 4629333, upload-time = "2026-09-30T14:44:55.635Z" }, + { url = "https://files.pythonhosted.org/packages/8c/72/20f99a219f6af47cdd1cbd978c243b92d71496e168a746138af44ded4f29/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_aarch64.whl", hash = "sha256:fdd28f912fccfec1846a94e2e1e8f9b0012f557f0c46fe4f3eb0d7a87afcf90b", size = 4958822, upload-time = "2026-09-30T14:44:59.639Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/196f112617fb08eb4d608a2a6c422373d46f9cc2857f38fc0667033c0899/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_ppc64le.whl", hash = "sha256:cbc8738fd8526d80f35cb3a40d41f41a2e7030bb3b18b09a6778ef63d291c2fd", size = 5506351, upload-time = "2026-09-30T14:45:02.267Z" }, + { url = "https://files.pythonhosted.org/packages/24/95/83378121ef3eaaaf71d4b781577ff794acb39b9e1b87a3f156898c8497ed/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_x86_64.whl", hash = "sha256:e105ab60406787da31fccc883fc0f733af1efd78f0136a4599692c4083a73d0c", size = 5000859, upload-time = "2026-09-30T14:45:05.009Z" }, + { url = "https://files.pythonhosted.org/packages/22/f7/70fd7ae4d1dbfa7ba29b02e1b9068771519a86027756510b700ce81086a8/cryptography-50.0.2-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:6f8700550aa1474a91e5dc07049c46f98b423b5b1ddd0483e0b51362eeeaf5be", size = 5092151, upload-time = "2026-09-30T15:29:15.932Z" }, + { url = "https://files.pythonhosted.org/packages/d4/be/688367b74de86984bd58d8efacfc7c9e68b89a6a22ced0fb4f38db50254a/cryptography-50.0.2-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:c71be1cbfa5cd9a41ee452acf1eccd82b2c05950358b106ec8ceb83411d1a020", size = 5286120, upload-time = "2026-09-30T15:29:18.309Z" }, + { url = "https://files.pythonhosted.org/packages/39/d1/55f8a3f2ef5d1529e16835ef10cf0fe3d559ce237b46dddc440c0bba3649/cryptography-50.0.2-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:c423ab384a46c4dff7217b2ea5ba2e11cffdeab6441acd04cf65a369caf0366c", size = 4111557, upload-time = "2026-09-30T15:29:20.155Z" }, + { url = "https://files.pythonhosted.org/packages/23/ad/ac987755d00e1e64273760228d2635ae38dae2be83e3c6e0d3289d91dec3/cryptography-50.0.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:0ec5f09541743261e66e291b4a0cbf0fb2997aeaab6d9e9c740b9dba1b58d1c2", size = 3943588, upload-time = "2026-09-30T15:29:22.265Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8d/6d585339bedf85d45044c85d8412dac53f2bb6f918e8b7777efba1787844/cryptography-50.0.2-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c5e67125c7dca78d199ec4e116aa93dbb83494808ecbb8211a2cb09b1bf41dbd", size = 4756166, upload-time = "2026-09-30T15:29:24.58Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f1/1c1f6874e8550cfddd4b688ceb38cefb6ed15ceed224d56f133f3d88c214/cryptography-50.0.2-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ee247f5c245c9a2fe7c8e2214e295918838e44e00a45a6718451e4004219e767", size = 4749145, upload-time = "2026-09-30T15:29:26.807Z" }, + { url = "https://files.pythonhosted.org/packages/c1/63/61b15dc1a8de03fe0adbe3fd7608b3ad5c73bf50993bbcb1faaa930afe33/cryptography-50.0.2-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:dfe9763530994147d9af1def057a5b9658b00e8f8fe8743d144d1e0911c2e454", size = 4763638, upload-time = "2026-09-30T15:29:28.588Z" }, + { url = "https://files.pythonhosted.org/packages/fc/35/b345bdfa40c9126df1a9d33236aa98418367931b8725f84fc3ae2b98dc59/cryptography-50.0.2-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:58ddb5a8e3179d12f19e4ea34d2d32e9d63a4baa142c875c1eb59f41b7243acd", size = 5382217, upload-time = "2026-09-30T15:29:30.589Z" }, + { url = "https://files.pythonhosted.org/packages/4f/87/ef344a9e616871f2519c22d6afcda79ddd5d35e9592d95eb6e677608d055/cryptography-50.0.2-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f21e8a22c8605750c7af886bab299a363721264061b4ac0a30efb73cfd58efc5", size = 4781387, upload-time = "2026-09-30T15:29:32.605Z" }, + { url = "https://files.pythonhosted.org/packages/90/5b/f2fdb13cd0b96f6f932c8627bb292a45f11c64d21620a8e120aee9a3b848/cryptography-50.0.2-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:9c8402a82ea0dc4ceeab793db05f0fafa8ca139ca34fcde5df0f596103c74107", size = 4403790, upload-time = "2026-09-30T15:29:34.374Z" }, + { url = "https://files.pythonhosted.org/packages/bc/ce/7e4f662b1e3c393513569e402cfc85ac7da0bd3d5435e122a3140219eb2d/cryptography-50.0.2-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:0ddc924c04591c2811ca024d62ecad4f7f6f08af8939c211438f48a16bd23602", size = 4764319, upload-time = "2026-09-30T15:29:36.149Z" }, + { url = "https://files.pythonhosted.org/packages/3c/3f/86ff33ce34cc0de6847fb96e035a1a760d81652e38643f617c02ad32ef7a/cryptography-50.0.2-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a6557e5f38e065ca9fbdaf7cfc7435ecb1d113aa81a022d1b51921ee7432e227", size = 5338560, upload-time = "2026-09-30T15:29:39.053Z" }, + { url = "https://files.pythonhosted.org/packages/40/cf/6b5c8e2fd9202d98988ab7cb5cc5c991704c4ad55f492ff408e4969f83f1/cryptography-50.0.2-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:1981f1db4630889b9ef7803fadef12b056f428cb6b85c27ba57b774793b6093c", size = 4780973, upload-time = "2026-09-30T15:29:41.251Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/8d6ebc7dded797bd0f0160d52188021211f011a2b164ef0ae1dac4587465/cryptography-50.0.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7a8701d6b584d76e909e3d305b7d126b41439876a5aaf76cddc67fc230eafa2e", size = 4897738, upload-time = "2026-09-30T15:29:43.106Z" }, + { url = "https://files.pythonhosted.org/packages/d4/aa/f3f6e0de7e6253b8baa8b2d8fb9d50924fa75cee3d4624bd4bc1208ee923/cryptography-50.0.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ce47f66801c20ec6c6632453bb5960fe38939e9306970b48b3a5a26de7745d94", size = 5058280, upload-time = "2026-09-30T15:29:44.827Z" }, + { url = "https://files.pythonhosted.org/packages/f6/b6/a1faf3a27ae9405fb34b1713cc73b2d8a26b04d5c561578fa2e6ef3e5bb9/cryptography-50.0.2-cp39-abi3-win_amd64.whl", hash = "sha256:4e81d95e5bafc2d6e34e4bed780e53e4d5b9a2f928573428aa4d35fbec1eb0de", size = 3854095, upload-time = "2026-09-30T15:29:46.782Z" }, +] + [[package]] name = "cycler" version = "0.12.1" @@ -430,6 +524,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "isodate" +version = "0.7.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/4d/e940025e2ce31a8ce1202635910747e5a87cc3a6a6bb2d00973375014749/isodate-0.7.2.tar.gz", hash = "sha256:4cd1aa0f43ca76f4a6c6c0292a85f40b35ec2e43e315b59f06e6d32171a953e6", size = 29705, upload-time = "2024-10-08T23:04:11.5Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/aa/0aca39a37d3c7eb941ba736ede56d689e7be91cab5d9ca846bde3999eba6/isodate-0.7.2-py3-none-any.whl", hash = "sha256:28009937d8031054830160fce6d409ed342816b543597cece116d966c6d99e15", size = 22320, upload-time = "2024-10-08T23:04:09.501Z" }, +] + [[package]] name = "itsdangerous" version = "2.2.0" @@ -617,6 +720,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f5/9e/6aefebdc9f8235c12bdeeda44cc0383d89c1e41da2c400caf3ee2073a3ce/matplotlib-3.10.7-cp313-cp313t-win_arm64.whl", hash = "sha256:aebed7b50aa6ac698c90f60f854b47e48cd2252b30510e7a1feddaf5a3f72cbf", size = 8042131, upload-time = "2025-10-09T00:27:21.608Z" }, ] +[[package]] +name = "msal" +version = "1.39.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/85/747d28986a44b715cc51cf57aa021c8fd94e9614064261ceec77320e8b0b/msal-1.39.0.tar.gz", hash = "sha256:6ab7de335e6d7f5717e2c7e1dbf86e4dda2f6acf3c56773b78dc53ebc6395b5f", size = 205360, upload-time = "2026-09-17T16:07:45.1Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/32/178457385925ce301c7b3232bd85665d4df57da39568b62dcfc74031873f/msal-1.39.0-py3-none-any.whl", hash = "sha256:2d2577886906cd7293850dffa2da29119966c213bfc6ec0cecf8bf7621e1ca77", size = 131629, upload-time = "2026-09-17T16:07:46.722Z" }, +] + +[[package]] +name = "msal-extensions" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "msal" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/01/99/5d239b6156eddf761a636bded1118414d161bd6b7b37a9335549ed159396/msal_extensions-1.3.1.tar.gz", hash = "sha256:c5b0fd10f65ef62b5f1d62f4251d51cbcaf003fcedae8c91b040a488614be1a4", size = 23315, upload-time = "2025-03-14T23:51:03.902Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/75/bd9b7bb966668920f06b200e84454c8f3566b102183bc55c5473d96cb2b9/msal_extensions-1.3.1-py3-none-any.whl", hash = "sha256:96d3de4d034504e969ac5e85bae8106c8373b5c6568e4c8fa7af2eca9dbe6bca", size = 20583, upload-time = "2025-03-14T23:51:03.016Z" }, +] + [[package]] name = "msgpack" version = "1.1.2" @@ -634,6 +763,38 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e5/db/0314e4e2db56ebcf450f277904ffd84a7988b9e5da8d0d61ab2d057df2b6/msgpack-1.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:e69b39f8c0aa5ec24b57737ebee40be647035158f14ed4b40e6f150077e21a84", size = 64118, upload-time = "2025-10-08T09:15:23.402Z" }, ] +[[package]] +name = "multidict" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/79/84ddb5ba16c4eb2c69c71db76ae3c579fe546e511f7170c7e27eedbab7c1/multidict-7.1.0.tar.gz", hash = "sha256:61a4e5d81b8d4e4ad61964b230129e7a2b914793d96289029078fc9009f074ec", size = 362341, upload-time = "2026-10-09T20:31:38.279Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/34/22ee5f60d704e8edfe6e0a37ca1e2d1efe276560dc8f6f8caee35ea7a4f5/multidict-7.1.0-cp313-cp313-android_24_x86_64.whl", hash = "sha256:51d7f33be9a4a1a2801430846d72841deea0894eae8381a07e7d90e0f71b3c4b", size = 101454, upload-time = "2026-10-09T20:27:43.102Z" }, + { url = "https://files.pythonhosted.org/packages/3b/6d/7652943397171fd624de163d25fc6d2807852931c40c24776cfee3f857cc/multidict-7.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:2a964dfeb2aba3663f0536c809aa1ff385f065e89fae57e883fb7edfb4067c2f", size = 95823, upload-time = "2026-10-09T20:27:44.912Z" }, + { url = "https://files.pythonhosted.org/packages/a2/57/717d1aa4e901f58a7e93b945ed741c860d950f456b75be47123fef507ac2/multidict-7.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d02cd23b5af182a49d635ee72be38053767711987a9fd82625b16b93828a0d8c", size = 97841, upload-time = "2026-10-09T20:27:46.616Z" }, + { url = "https://files.pythonhosted.org/packages/a8/74/d2d22d306225f3c53ea5a682abb8a43bc30057d6c78b5c94a74035450bfd/multidict-7.1.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:0179698c3c913eb64f32397083747fad20ed0f0a2b7469a08cd1a8a95d14d90e", size = 164661, upload-time = "2026-10-09T20:27:48.495Z" }, + { url = "https://files.pythonhosted.org/packages/55/f4/1e63fea41ba86768e18dbc1743e6780f75ad32d7a45e418740c6fb64589e/multidict-7.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ccf98ee859fe29f874ddd8e637f14ba59108a333492b521acb885a9095244a9c", size = 95489, upload-time = "2026-10-09T20:27:50.055Z" }, + { url = "https://files.pythonhosted.org/packages/8c/55/477c351b21b34fe948ffffa8b64cd0c246efd998fed3de922b217e632e59/multidict-7.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5fa1484f74d011addf2e5f5a0378ec41521989839a05d6051d8067d8ce732423", size = 97931, upload-time = "2026-10-09T20:27:51.598Z" }, + { url = "https://files.pythonhosted.org/packages/cc/dd/288508d7deb9489dd7c8e0b172d62dc1da8f3cb24877293ead42e6cc2047/multidict-7.1.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1fed3d721f75c25a9fcdd0e362af53f4b20acbcdc63081112f85419ba0ce3444", size = 453816, upload-time = "2026-10-09T20:27:53.217Z" }, + { url = "https://files.pythonhosted.org/packages/60/ed/172447dd09f06111f69d2cf22a018020b34a93a39ddf72cddfdb48323449/multidict-7.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df03e392cae1e05462918abbae06d6100f1e53f67db971ff0ac6c07d9edf7321", size = 477779, upload-time = "2026-10-09T20:27:54.972Z" }, + { url = "https://files.pythonhosted.org/packages/e4/7d/e5b7755e84611ee846f0dc830cb1363ce10764b29247a01b857722fda653/multidict-7.1.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:379f477b98a1e9a77ddc3ccaa8c709d3fb4a288ff54b96e171e637b55b4adbae", size = 441299, upload-time = "2026-10-09T20:27:56.901Z" }, + { url = "https://files.pythonhosted.org/packages/1b/d6/e9c93da1644491610f09258349283421a279fdb3b383929b4d963698691c/multidict-7.1.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:40f586bc8a084a3671ddcae9e5fbd3228a596bfb63d9f0380f153f9a65b69f08", size = 496193, upload-time = "2026-10-09T20:27:58.788Z" }, + { url = "https://files.pythonhosted.org/packages/22/49/7fe19efed1b1c73e2f6ae65eba4e6528eeb26e10c970dc0bb0fc009a2aae/multidict-7.1.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6e7f70d912a589e30290ed926f90ddbc3160998359cbad7c9ede1bcee481748c", size = 511469, upload-time = "2026-10-09T20:28:00.689Z" }, + { url = "https://files.pythonhosted.org/packages/4d/2e/7b708a72001323dc6b2930834d63e355870ea5b1ee8d1450e0fe1a24752b/multidict-7.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5129cc1f5fec6888e2db0be936dab67242e32c738811c8769aeea93aab4257a8", size = 481377, upload-time = "2026-10-09T20:28:03.011Z" }, + { url = "https://files.pythonhosted.org/packages/8b/60/3d23e7d2ebc7e7e6b1b205de0d5c2ce0652a9b2edef65eea3528758b5699/multidict-7.1.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1401caec21fd7f002e79ab6806bbfd1f54bb3de6d5e12bd91c6685dce16ad2be", size = 429992, upload-time = "2026-10-09T20:28:04.846Z" }, + { url = "https://files.pythonhosted.org/packages/f6/e6/8e3bef78ea1929a3f2c395aa292799d4effd6ce1b5cfce42c1986a33e2b5/multidict-7.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c81062e947f4b5a624135a843f6ac4b3c7fe6508300c9fb27347f022ba0c513d", size = 465635, upload-time = "2026-10-09T20:28:06.527Z" }, + { url = "https://files.pythonhosted.org/packages/44/6a/55fe5dd90c0e9ce60f5c4fa8ace27eeb1190d9cbb151929b04412f0c673a/multidict-7.1.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:c53be0dd676484a660acc56e4f1cd0dd74bc1255d12fa285e86a3fa9d5f22bf9", size = 441693, upload-time = "2026-10-09T20:28:08.443Z" }, + { url = "https://files.pythonhosted.org/packages/c2/a9/628913f71537dce7dbd6c4ee1ae5019976264427c55354ecc593b4b921b9/multidict-7.1.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:41e0c3350d08994ee8640c39884e16514e282f70ba40f5b2299582509a327774", size = 453808, upload-time = "2026-10-09T20:28:10.871Z" }, + { url = "https://files.pythonhosted.org/packages/25/d0/3ae3af653b5776d045f460582006d0b4d7dd41b078b3ab2e874bf4d1e22f/multidict-7.1.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:abeec7a89d698aa1c9b4c36bd5e3c746faef0867076e6a2ca27fa5077c4ece26", size = 483160, upload-time = "2026-10-09T20:28:12.892Z" }, + { url = "https://files.pythonhosted.org/packages/56/3e/2c13e111b9f4c6216aee0ac57ac39c53db7f97e42bb8490e60a06eaca352/multidict-7.1.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:23f6d325241b0db006ca2841309ed17622137e134930a740a8f1331ec4404791", size = 425896, upload-time = "2026-10-09T20:28:14.65Z" }, + { url = "https://files.pythonhosted.org/packages/45/13/15b4d614cf0d6838a7d89eb14f4820fdecfd1b7f529061d172a674912d41/multidict-7.1.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:034b0dc1b7fb8279599c5d8563f86abb4d2454735b06544ecab23c54572ad2bd", size = 491674, upload-time = "2026-10-09T20:28:16.508Z" }, + { url = "https://files.pythonhosted.org/packages/4f/97/3a6f8c75f12a607ff047e3db9a45dff6a558b1e31b89b2f0f27da0fedb6f/multidict-7.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5c8074ad4d67067c87bd0663dfda654f786336078c8fd7d2f6c1aa41de8494cc", size = 473989, upload-time = "2026-10-09T20:28:18.284Z" }, + { url = "https://files.pythonhosted.org/packages/3f/20/5e7726631e50b6afd26324a234851a06104b8ee30f01bc209140788b76c6/multidict-7.1.0-cp313-cp313-win32.whl", hash = "sha256:7b25c335fc53acf29d4d21dbc19fe39d2824201cdda0448623152cc5917bd259", size = 80952, upload-time = "2026-10-09T20:28:20.044Z" }, + { url = "https://files.pythonhosted.org/packages/4f/19/b26bdbef5bf5071ea5a3ba37c963af187fddb8b0a925bd39c0c5dd0ad04a/multidict-7.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:7de54b49e6da811b0321e412d14efdaa1ee0c0b6609296ea5b9022bc5b2bd843", size = 93554, upload-time = "2026-10-09T20:28:21.657Z" }, + { url = "https://files.pythonhosted.org/packages/35/d5/e9a8600d3868e3fdc4a4be1d15e594048508511c0398423df0ed18c11b5a/multidict-7.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:aeba2c750102051aa51e087c2ccbc79f2724a41c94168f8731e36f54c453551a", size = 92039, upload-time = "2026-10-09T20:28:23.464Z" }, + { url = "https://files.pythonhosted.org/packages/d0/86/a3de309c5e28ee85b314d0e3ba0e0dea6fd361c313322a05e67be4656e1e/multidict-7.1.0-py3-none-any.whl", hash = "sha256:d9ef29cfd98e17085b4f91bba8fa1570bec6787d5c52ce653ed33a58785585d0", size = 28087, upload-time = "2026-10-09T20:31:35.945Z" }, +] + [[package]] name = "numpy" version = "2.3.5" @@ -669,6 +830,8 @@ name = "optimizer" version = "0.2.0" source = { editable = "." } dependencies = [ + { name = "azure-data-tables" }, + { name = "azure-identity" }, { name = "flask" }, { name = "flask-restx" }, { name = "gunicorn" }, @@ -694,6 +857,8 @@ dev = [ [package.metadata] requires-dist = [ + { name = "azure-data-tables", specifier = ">=12.7.0" }, + { name = "azure-identity", specifier = ">=1.26.0" }, { name = "flask", specifier = ">=3.0.3" }, { name = "flask-restx", specifier = ">=1.3.0" }, { name = "gunicorn", specifier = ">=22.0.0" }, @@ -816,6 +981,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "propcache" +version = "0.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/9a/9fbf4e4ec0c2d7f1c32519fff782ef467859b8faa9fbc5331a96f6395d43/propcache-0.5.4.tar.gz", hash = "sha256:ff6b113f50bc066a698db5d944d2c6dc7507168dd3341e255a8892fd0715a558", size = 61545, upload-time = "2026-09-16T00:17:14.386Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/4c/3b1365d58a667689e067e13d055fcd92bdf8d9a2fca3d9201b47ed5b3631/propcache-0.5.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:36c0d9db44b523ef93d03341b1c42d69ff01d673c053d1b1c6c3a363bcaa39ba", size = 85290, upload-time = "2026-09-16T00:14:52.342Z" }, + { url = "https://files.pythonhosted.org/packages/8f/61/5f9c29c3aa67c30238c4eadf95149b1d983a48f69b86b0cff927a7d6df13/propcache-0.5.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1d52a05dc417279f7e5c7618c5dfbbc29923aaf9bc0a5c1802ddcebf54c61a0", size = 50027, upload-time = "2026-09-16T00:14:53.67Z" }, + { url = "https://files.pythonhosted.org/packages/25/7d/c1ab1ef09e9d4d835be5d58c0a32a1e1de8397abaa4e502a9d4141328cad/propcache-0.5.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:44149f46500a0a41b95b4d99c2e586a77319539730607b9892974a092788b111", size = 51425, upload-time = "2026-09-16T00:14:54.826Z" }, + { url = "https://files.pythonhosted.org/packages/73/36/0093091ebb270fcd1bc1f6e095f93b2e0ed7f1011c28837dc2dbe5f96b99/propcache-0.5.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dbab5f5ff6897c81f355d079010cdae85b02e5a0b518b5251523b8ad8ae9ac3c", size = 233595, upload-time = "2026-09-16T00:14:56.09Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/0de9d4c8e05ce0be71b436919a216bd7fc5cc6e2691c0602295efb22b9ed/propcache-0.5.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c3e98c55bde2bcf7db3c70d1aed7ae9aa8aebbf19a250c66645cde44cdb8b867", size = 240318, upload-time = "2026-09-16T00:14:57.674Z" }, + { url = "https://files.pythonhosted.org/packages/7d/71/2b35e91455209b85ee98f7859583e0814fab57d3af0f2381aaee34c37304/propcache-0.5.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:db3ae52ccc150dbc84704e9d642743897f3e1c54742ff34cacb661e52e3818a9", size = 246649, upload-time = "2026-09-16T00:14:59.352Z" }, + { url = "https://files.pythonhosted.org/packages/ed/74/08e6c1faf26ee2732023a3828787ba535557122774f4a386b1f715cbd8e0/propcache-0.5.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f85915e00dcb1cd9f2f890ead064ed40a27df06f0db65be427b29482ae357572", size = 234316, upload-time = "2026-09-16T00:15:00.696Z" }, + { url = "https://files.pythonhosted.org/packages/5c/9a/08385733c9321c9bb78039d3ff31045e4fca962d9665023c4eb70f998819/propcache-0.5.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c2ba30a89035b57b73e00475de948521602f543d79ce01db10b04b36c4c76fc8", size = 204666, upload-time = "2026-09-16T00:15:02.019Z" }, + { url = "https://files.pythonhosted.org/packages/1d/f4/e87bc7629af9a14a752b218764a78742d73c2c563ac58315da6841f0cbe4/propcache-0.5.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ae58f361bd5dae942717c65d3413b478c70aea9c462599e7b9adad3731db3894", size = 225900, upload-time = "2026-09-16T00:15:03.394Z" }, + { url = "https://files.pythonhosted.org/packages/d9/6d/11014938d3fe9bea2ea2dcf930f26ed565bfb2f5be3c756362ea48c92636/propcache-0.5.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:96f7c5c15656040ddcbc51e56dc59b58aa25999d743c126abd425b9766ab43e9", size = 219988, upload-time = "2026-09-16T00:15:04.811Z" }, + { url = "https://files.pythonhosted.org/packages/dc/72/fbf17c589f92c0b3bbf6709a425661f8ef2ed0d46b38985a7d7b5a0f6b91/propcache-0.5.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7cc528e760a8af06f2b13e9b9f362cd90c7c718ea61228a96dbd31ba16ed7f47", size = 233611, upload-time = "2026-09-16T00:15:06.498Z" }, + { url = "https://files.pythonhosted.org/packages/55/7e/dbd637572a279692e5518d117274a9331bf5faac59f191d30e82521a3ec7/propcache-0.5.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:425f8cc86ab5018b4b8d4a23bc8e74d964bd3d757c3702e301aa79be76c53f6c", size = 204333, upload-time = "2026-09-16T00:15:07.961Z" }, + { url = "https://files.pythonhosted.org/packages/ba/5a/f99c92068f1e0f5c886899ce0e4a619db376ca98c5279d93f95bd86906af/propcache-0.5.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:a5793c7698a53f56f4a1889a4737c7eeb1b7ad0842fa6b1abca22913ff79c8c1", size = 235177, upload-time = "2026-09-16T00:15:09.334Z" }, + { url = "https://files.pythonhosted.org/packages/ee/28/95456fabd2daf6be89049a13fbf03341756014d2959c83d12957d4c49694/propcache-0.5.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c02c0e570c5c7e077b0181a9f3cdb7d4c3617d1cda6b5c95bd5d34022923d82c", size = 228982, upload-time = "2026-09-16T00:15:10.729Z" }, + { url = "https://files.pythonhosted.org/packages/b1/bb/df90f62c9cf7c93ea235f6f9405143bba802914607317266dd81fc8d737e/propcache-0.5.4-cp313-cp313-win32.whl", hash = "sha256:3e413d7a4a9b4866b7a761d6060d434b64d23cd35122eda3b026a0bbe8196b25", size = 42611, upload-time = "2026-09-16T00:15:12.111Z" }, + { url = "https://files.pythonhosted.org/packages/01/bc/e0a7b84af04ec02d73a48aa71f091e1e4a2107e3074b7ce12195b66901f4/propcache-0.5.4-cp313-cp313-win_amd64.whl", hash = "sha256:0c889f6fa84957bc7e8b4eab71fd16a0455068d5045e3aa40c733071d2b2fd77", size = 45342, upload-time = "2026-09-16T00:15:13.519Z" }, + { url = "https://files.pythonhosted.org/packages/9a/70/50b031cafe72a5c1878b903ee87303f71313345566bf3d6ec202e5ddc9ec/propcache-0.5.4-cp313-cp313-win_arm64.whl", hash = "sha256:69fc35c0779522da366c563e5faf203ffc1f8ff0021d5b1337fa4efa5be73177", size = 42408, upload-time = "2026-09-16T00:15:14.788Z" }, + { url = "https://files.pythonhosted.org/packages/f5/cd/785c64ed382f3f04201870267b02783f63b4678c2acfddc177a3ebcc2727/propcache-0.5.4-py3-none-any.whl", hash = "sha256:62c60aec739ed00124573cce1178138fd690c7676352d67a37328c1cf51d7468", size = 16338, upload-time = "2026-09-16T00:17:13.106Z" }, +] + [[package]] name = "psutil" version = "7.1.3" @@ -952,6 +1143,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload-time = "2024-11-28T03:43:27.893Z" }, ] +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + [[package]] name = "pyparsing" version = "3.2.5" @@ -1274,6 +1470,37 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8f/da/539c2d24b13025e54a86ce3215eb9b6297b023937a087db9ef2a436cc7b4/wsproto-1.3.1-py3-none-any.whl", hash = "sha256:297ce79322989c0d286cc158681641cd18bc7632dfb38cf4054696a89179b993", size = 24402, upload-time = "2025-11-12T07:50:47.178Z" }, ] +[[package]] +name = "yarl" +version = "1.25.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/75/16/e8be8e2fb175bbf41a0680381a319f1199fae256588241a2ac8677eafb49/yarl-1.25.1.tar.gz", hash = "sha256:03dd38de09bc213e9a8b29761eec33ee1d5318dac0e49d8af36e4d27830e23a7", size = 246245, upload-time = "2026-09-15T19:35:02.264Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/ed/2f3129bbcc9a5c8ba12cc2b29d8060a3bab9c8043c456cfd4b5ca3188890/yarl-1.25.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:25868beca8b6765f8f7d0e11fe6dd7c66dd4b0793b9500286d20cc92352126a5", size = 143623, upload-time = "2026-09-15T19:31:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/17/e1/f1bc3390fdca352826676b531d0712736f156919090206700421d46b2c37/yarl-1.25.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:10b2fd95332f0d716d5eee3c9fb2ce8eada19082de7fee83d32e37992fd75c26", size = 104011, upload-time = "2026-09-15T19:31:40.25Z" }, + { url = "https://files.pythonhosted.org/packages/a8/aa/50acc5c3e5da04172ae3c281c75405af4d2ca911e16120ab0563f4dffb66/yarl-1.25.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0f12afda4eea8c8994a76d4df1875c765194f5fbe8a9d197929ea303caee29ec", size = 103677, upload-time = "2026-09-15T19:31:42.46Z" }, + { url = "https://files.pythonhosted.org/packages/30/d2/7d1e0ab9f8390e1fbcede5a6dbf70d23c96ad09b8c5567f3a514d1ddb0e2/yarl-1.25.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:14b79a30a93a3ce2e8832603fd0ab780ada281b0ba5110b519a634f2d7d7d1fc", size = 115392, upload-time = "2026-09-15T19:31:44.371Z" }, + { url = "https://files.pythonhosted.org/packages/71/e1/5ba1e3a2a22139213655e760919038e8ed7e2d4a99826d0bbddb3beb96e5/yarl-1.25.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4bd6340d20ae2c7ca719b87b426e808e90743b676d05d4c26c4fb5ca71f41184", size = 107493, upload-time = "2026-09-15T19:31:46.273Z" }, + { url = "https://files.pythonhosted.org/packages/f5/53/780653d5e0f73831f467cf13548912e5eec97f21dc49fc8daf21da027df4/yarl-1.25.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:126a2533570c554719ca40a1288fdee1700b6bc82e7131aa69fa85252d92e651", size = 122537, upload-time = "2026-09-15T19:31:48.654Z" }, + { url = "https://files.pythonhosted.org/packages/03/92/d54fa70236c6036271c9c9c09fd978df5cbe3ef49ef6c46e9b833476d215/yarl-1.25.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a3faadac7d812ddac258feb57b9846b60c1b437c4f4b9ad42595c6f6fe4390df", size = 126170, upload-time = "2026-09-15T19:31:50.872Z" }, + { url = "https://files.pythonhosted.org/packages/0e/b7/a82a49bf88340b837ef6972b508a1604ae377b9e6904b46b10cf5f1cf925/yarl-1.25.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:be80550d9bfe83d9b62398a37081a90434e6df2d978ec345c3d2820de6beddab", size = 117012, upload-time = "2026-09-15T19:31:53.189Z" }, + { url = "https://files.pythonhosted.org/packages/ef/78/5d684b411e3f3602464ee9b538db48205038f8605872985f61efb809ced0/yarl-1.25.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e07595c7d6f4db270ceede356a1bd1c07a34f1c26f958d1ed0cd7b48e0d2bba3", size = 114950, upload-time = "2026-09-15T19:31:55.694Z" }, + { url = "https://files.pythonhosted.org/packages/2f/11/51d82b852c64f7fad0fc7a7ff3031517204887e874c722bbca839c0b23ac/yarl-1.25.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:eb96ed1ae6c7d072d60840c0434aef07a2df611812810807fbc54263a6053e9a", size = 115428, upload-time = "2026-09-15T19:31:57.966Z" }, + { url = "https://files.pythonhosted.org/packages/e4/49/9d1978049bf646b9ea918313926453c6901b71c92f097467777d47d36a88/yarl-1.25.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:3feb99222553a8cbedfa52c2f59dd84c3f50d5b582c728d522caf8d72769a54b", size = 108428, upload-time = "2026-09-15T19:32:00.048Z" }, + { url = "https://files.pythonhosted.org/packages/43/35/7b8f1ebb45d7ec3dda7d1909bf44f458de41ef91e2937f107733582a5166/yarl-1.25.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a2ed0ba415ccdf08f14bf544cb78346d0f76086707ffee24921a2c84dbf1305a", size = 121961, upload-time = "2026-09-15T19:32:02.436Z" }, + { url = "https://files.pythonhosted.org/packages/63/d6/d8b689ab7ca26edeb85f6ff28812aac7a25376eefc1780e303a7bfbaceff/yarl-1.25.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2b49375d22299b0a834c2bca72f39aaecc270d96fb24c30424899676f487b22a", size = 114961, upload-time = "2026-09-15T19:32:04.456Z" }, + { url = "https://files.pythonhosted.org/packages/cf/d5/1a1798ea4dc6b7ee3260010a27907ebc697c95dae99817d817ed446d24aa/yarl-1.25.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ef74070ac553c59eb4f04258722066d6c6135b7baa03b2e9f2da65c096e96d98", size = 120036, upload-time = "2026-09-15T19:32:06.5Z" }, + { url = "https://files.pythonhosted.org/packages/91/8d/b1b35ed7903da6669b1d367cb2c09436acd4ff508029b4f39a0c0c2058fc/yarl-1.25.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0a66db89ea473abeac4b70523cafd94db3772380e565f9d28af7a179b7af71fa", size = 117276, upload-time = "2026-09-15T19:32:09.401Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8f/4db01cef62caff0d7a4593ed694fb8a41a27a11158cab80d290221f13e57/yarl-1.25.1-cp313-cp313-win_amd64.whl", hash = "sha256:1f51020b2eb8a003c84925638ec63c21a750a4bddd3a22ec8eac6a742dadf1b9", size = 101945, upload-time = "2026-09-15T19:32:11.545Z" }, + { url = "https://files.pythonhosted.org/packages/c0/5e/3ce00497c5c0babb74d4130c10c3828ccd215b4819d12020c42429f991ac/yarl-1.25.1-cp313-cp313-win_arm64.whl", hash = "sha256:b10dd0557ba422715b5206b3743192135a6022acca8baec51aa127d0a75db8fe", size = 97270, upload-time = "2026-09-15T19:32:14.127Z" }, + { url = "https://files.pythonhosted.org/packages/54/22/318c7980066769c6bcd9221ed2248294f5698811da099013098c670565ed/yarl-1.25.1-py3-none-any.whl", hash = "sha256:681c758b0490f9e96b78e5fa8e8dc6e648e9185bb6eaebe73183c33ea0c445f3", size = 63617, upload-time = "2026-09-15T19:34:59.616Z" }, +] + [[package]] name = "zope-event" version = "6.1"