plansolve 0.25.2__tar.gz → 0.25.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. {plansolve-0.25.2/plansolve.egg-info → plansolve-0.25.4}/PKG-INFO +1 -1
  2. {plansolve-0.25.2 → plansolve-0.25.4}/plansolve/__init__.py +1 -5
  3. plansolve-0.25.4/plansolve/errors.py +91 -0
  4. {plansolve-0.25.2 → plansolve-0.25.4}/plansolve/field_service/__init__.py +0 -5
  5. {plansolve-0.25.2 → plansolve-0.25.4}/plansolve/field_service/api_client.py +5 -4
  6. {plansolve-0.25.2 → plansolve-0.25.4}/plansolve/field_service/models.py +10 -94
  7. {plansolve-0.25.2 → plansolve-0.25.4}/plansolve/professional_services/api_client.py +5 -4
  8. {plansolve-0.25.2 → plansolve-0.25.4}/plansolve/shift/api_client.py +5 -4
  9. {plansolve-0.25.2 → plansolve-0.25.4/plansolve.egg-info}/PKG-INFO +1 -1
  10. {plansolve-0.25.2 → plansolve-0.25.4}/plansolve.egg-info/SOURCES.txt +4 -2
  11. {plansolve-0.25.2 → plansolve-0.25.4}/pyproject.toml +1 -1
  12. plansolve-0.25.4/tests/test_error_messages.py +81 -0
  13. plansolve-0.25.4/tests/test_field_service_weights.py +99 -0
  14. plansolve-0.25.2/plansolve/field_service/constraint_weight.py +0 -143
  15. {plansolve-0.25.2 → plansolve-0.25.4}/LICENSE +0 -0
  16. {plansolve-0.25.2 → plansolve-0.25.4}/MANIFEST.in +0 -0
  17. {plansolve-0.25.2 → plansolve-0.25.4}/README.md +0 -0
  18. {plansolve-0.25.2 → plansolve-0.25.4}/plansolve/client.py +0 -0
  19. {plansolve-0.25.2 → plansolve-0.25.4}/plansolve/professional_services/__init__.py +0 -0
  20. {plansolve-0.25.2 → plansolve-0.25.4}/plansolve/professional_services/availability_time_span.py +0 -0
  21. {plansolve-0.25.2 → plansolve-0.25.4}/plansolve/professional_services/contract.py +0 -0
  22. {plansolve-0.25.2 → plansolve-0.25.4}/plansolve/professional_services/models.py +0 -0
  23. {plansolve-0.25.2 → plansolve-0.25.4}/plansolve/py.typed +0 -0
  24. {plansolve-0.25.2 → plansolve-0.25.4}/plansolve/routes.py +0 -0
  25. {plansolve-0.25.2 → plansolve-0.25.4}/plansolve/shift/__init__.py +0 -0
  26. {plansolve-0.25.2 → plansolve-0.25.4}/plansolve/shift/constraint_weight_overrides.py +0 -0
  27. {plansolve-0.25.2 → plansolve-0.25.4}/plansolve/shift/contract.py +0 -0
  28. {plansolve-0.25.2 → plansolve-0.25.4}/plansolve/shift/day_off_request.py +0 -0
  29. {plansolve-0.25.2 → plansolve-0.25.4}/plansolve/shift/fairness.py +0 -0
  30. {plansolve-0.25.2 → plansolve-0.25.4}/plansolve/shift/fairness_bucket.py +0 -0
  31. {plansolve-0.25.2 → plansolve-0.25.4}/plansolve/shift/models.py +0 -0
  32. {plansolve-0.25.2 → plansolve-0.25.4}/plansolve/shift/options.py +0 -0
  33. {plansolve-0.25.2 → plansolve-0.25.4}/plansolve/shift/period_rule.py +0 -0
  34. {plansolve-0.25.2 → plansolve-0.25.4}/plansolve/shift/planning_period.py +0 -0
  35. {plansolve-0.25.2 → plansolve-0.25.4}/plansolve/shift/shift_assignment.py +0 -0
  36. {plansolve-0.25.2 → plansolve-0.25.4}/plansolve/shift/shift_employee.py +0 -0
  37. {plansolve-0.25.2 → plansolve-0.25.4}/plansolve/shift/shift_off_request.py +0 -0
  38. {plansolve-0.25.2 → plansolve-0.25.4}/plansolve/shift/weights.py +0 -0
  39. {plansolve-0.25.2 → plansolve-0.25.4}/plansolve/solver_status.py +0 -0
  40. {plansolve-0.25.2 → plansolve-0.25.4}/plansolve.egg-info/dependency_links.txt +0 -0
  41. {plansolve-0.25.2 → plansolve-0.25.4}/plansolve.egg-info/requires.txt +0 -0
  42. {plansolve-0.25.2 → plansolve-0.25.4}/plansolve.egg-info/top_level.txt +0 -0
  43. {plansolve-0.25.2 → plansolve-0.25.4}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plansolve
3
- Version: 0.25.2
3
+ Version: 0.25.4
4
4
  Summary: Python SDK for PlanSolve optimization API
5
5
  Author-email: PlanSolve <support@plansolve.app>
6
6
  License: MIT
@@ -5,7 +5,6 @@ A Python client library for the PlanSolve optimization API.
5
5
  """
6
6
 
7
7
  from .client import PlanSolveClient
8
- from .field_service.constraint_weight import ConstraintWeight
9
8
  from .field_service.api_client import FieldServiceApiClient
10
9
  from .professional_services.api_client import ProfessionalServicesApiClient
11
10
 
@@ -16,7 +15,6 @@ from .field_service.models import (
16
15
  Vehicle,
17
16
  Visit,
18
17
  SolverOptions,
19
- Weights,
20
18
  FieldServiceRequest,
21
19
  ScheduledVehicle,
22
20
  ScheduledVisit,
@@ -35,20 +33,18 @@ from .professional_services.models import (
35
33
  ProfessionalServicesResultResponse,
36
34
  )
37
35
 
38
- __version__ = "0.25.2"
36
+ __version__ = "0.25.4"
39
37
 
40
38
  __all__ = [
41
39
  "PlanSolveClient",
42
40
  "FieldServiceApiClient",
43
41
  "ProfessionalServicesApiClient",
44
- "ConstraintWeight",
45
42
  # Field Service Models
46
43
  "Shift",
47
44
  "TimeWindow",
48
45
  "Vehicle",
49
46
  "Visit",
50
47
  "SolverOptions",
51
- "Weights",
52
48
  "FieldServiceRequest",
53
49
  "ScheduledVehicle",
54
50
  "ScheduledVisit",
@@ -0,0 +1,91 @@
1
+ """HTTP error handling helpers for the PlanSolve SDK.
2
+
3
+ The PlanSolve API documents three distinct error body shapes:
4
+
5
+ - ``ValidationProblemDetails`` (400)::
6
+
7
+ {"type", "title", "status", "detail", "instance",
8
+ "errors": {"<field>": ["<msg>", ...]}}
9
+
10
+ - ``ErrorResponse`` (402/422)::
11
+
12
+ {"error": "<string>"}
13
+
14
+ - ``ProblemDetails`` (401/403/502)::
15
+
16
+ {"type", "title", "status", "detail", "instance"}
17
+
18
+ ``extract_error_message`` turns any of these into a short human-readable
19
+ string instead of dumping the raw JSON body at the caller.
20
+ """
21
+
22
+ import json
23
+
24
+ import requests
25
+
26
+
27
+ def extract_error_message(body: str) -> str:
28
+ """Extract a clean, human-readable message from an HTTP error body.
29
+
30
+ Priority order (highest first): validation ``errors`` > ``error`` >
31
+ ``detail`` > ``title`` > the raw body.
32
+
33
+ Args:
34
+ body: The raw response body text.
35
+
36
+ Returns:
37
+ A concise error message derived from the body, or the raw body when
38
+ no recognized field is present or the body is not valid JSON.
39
+ """
40
+ try:
41
+ parsed = json.loads(body)
42
+ except (ValueError, TypeError):
43
+ return body
44
+
45
+ if isinstance(parsed, dict):
46
+ errors = parsed.get("errors")
47
+ if isinstance(errors, dict):
48
+ messages = []
49
+ for field, msgs in errors.items():
50
+ if isinstance(msgs, list):
51
+ for msg in msgs:
52
+ messages.append(f"{field}: {msg}")
53
+ messages.sort()
54
+ if messages:
55
+ return "; ".join(messages)
56
+
57
+ error = parsed.get("error")
58
+ if isinstance(error, str) and error:
59
+ return error
60
+
61
+ detail = parsed.get("detail")
62
+ if isinstance(detail, str) and detail:
63
+ return detail
64
+
65
+ title = parsed.get("title")
66
+ if isinstance(title, str) and title:
67
+ return title
68
+
69
+ return body
70
+
71
+
72
+ def raise_for_status(response: requests.Response) -> None:
73
+ """Raise ``requests.HTTPError`` with a clean message for non-2xx responses.
74
+
75
+ Behaves like ``response.raise_for_status()`` but replaces the raw JSON body
76
+ in the exception message with the output of :func:`extract_error_message`,
77
+ while preserving the HTTP status code.
78
+
79
+ Args:
80
+ response: The response to validate.
81
+
82
+ Raises:
83
+ requests.HTTPError: If the response has a 4xx or 5xx status code.
84
+ """
85
+ if response.status_code < 400:
86
+ return
87
+
88
+ message = extract_error_message(response.text)
89
+ raise requests.HTTPError(
90
+ f"HTTP {response.status_code}: {message}", response=response
91
+ )
@@ -1,14 +1,12 @@
1
1
  """Field service optimization module."""
2
2
 
3
3
  from .api_client import FieldServiceApiClient
4
- from .constraint_weight import ConstraintWeight
5
4
  from .models import (
6
5
  Shift,
7
6
  TimeWindow,
8
7
  Vehicle,
9
8
  Visit,
10
9
  SolverOptions,
11
- Weights,
12
10
  FieldServiceRequest,
13
11
  ScheduledVehicle,
14
12
  ScheduledVisit,
@@ -18,17 +16,14 @@ from .models import (
18
16
 
19
17
  __all__ = [
20
18
  "FieldServiceApiClient",
21
- "ConstraintWeight",
22
19
  "Shift",
23
20
  "TimeWindow",
24
21
  "Vehicle",
25
22
  "Visit",
26
23
  "SolverOptions",
27
- "Weights",
28
24
  "FieldServiceRequest",
29
25
  "ScheduledVehicle",
30
26
  "ScheduledVisit",
31
27
  "FieldServiceResponse",
32
28
  "FieldServiceResultResponse",
33
29
  ]
34
-
@@ -5,6 +5,7 @@ from typing import Any, Dict, Optional, Union
5
5
 
6
6
  import requests
7
7
 
8
+ from ..errors import raise_for_status
8
9
  from ..routes import Routes
9
10
  from ..solver_status import SOLVER_STATUS, SolverStatusResponse
10
11
  from .models import (
@@ -58,7 +59,7 @@ class FieldServiceApiClient:
58
59
  response = requests.post(
59
60
  self.base_url, json=api_request, headers=self._get_headers()
60
61
  )
61
- response.raise_for_status()
62
+ raise_for_status(response)
62
63
  return FieldServiceResponse.from_dict(response.json())
63
64
 
64
65
  def get_result(self, job_id: str) -> FieldServiceResultResponse:
@@ -79,7 +80,7 @@ class FieldServiceApiClient:
79
80
  if self.api_key:
80
81
  headers["X-API-KEY"] = self.api_key
81
82
  response = requests.get(url, headers=headers)
82
- response.raise_for_status()
83
+ raise_for_status(response)
83
84
  return FieldServiceResultResponse.from_dict(response.json())
84
85
 
85
86
  def get_status(self, job_id: str) -> SolverStatusResponse:
@@ -100,7 +101,7 @@ class FieldServiceApiClient:
100
101
  if self.api_key:
101
102
  headers["X-API-KEY"] = self.api_key
102
103
  response = requests.get(url, headers=headers)
103
- response.raise_for_status()
104
+ raise_for_status(response)
104
105
  return response.json()
105
106
 
106
107
  def analyze(self, job_id: str) -> Dict[str, Any]:
@@ -121,7 +122,7 @@ class FieldServiceApiClient:
121
122
  if self.api_key:
122
123
  headers["X-API-KEY"] = self.api_key
123
124
  response = requests.get(url, headers=headers)
124
- response.raise_for_status()
125
+ raise_for_status(response)
125
126
  return response.json()
126
127
 
127
128
  def start_and_wait_for_completion(
@@ -3,8 +3,6 @@
3
3
  from dataclasses import dataclass, field
4
4
  from typing import List, Optional, Tuple, Dict, Any, Literal
5
5
 
6
- from .constraint_weight import ConstraintWeight
7
-
8
6
 
9
7
  @dataclass
10
8
  class Shift:
@@ -184,99 +182,16 @@ class SolverOptions:
184
182
  )
185
183
 
186
184
 
187
- @dataclass
188
- class Weights:
189
- """Configuration weights for field service optimization constraints."""
190
-
191
- pinned_visit_vehicle_assignment: Optional[ConstraintWeight] = None
192
- pinned_visit_service_time: Optional[ConstraintWeight] = None
193
- no_missing_skills: Optional[ConstraintWeight] = None
194
- service_time_missing: Optional[ConstraintWeight] = None
195
- vehicle_unassigned: Optional[ConstraintWeight] = None
196
- prefer_high_priority: Optional[ConstraintWeight] = None
197
- minimize_travel_time: Optional[ConstraintWeight] = None
198
- prefer_using_idle_vehicles: Optional[ConstraintWeight] = None
199
- minimize_early_arrival_wait: Optional[ConstraintWeight] = None
200
- prefer_initial_vehicle_assignment: Optional[ConstraintWeight] = None
201
- prefer_earlier_visit_dates: Optional[ConstraintWeight] = None
202
-
203
- def to_dict(self) -> Dict[str, str]:
204
- """Convert to dictionary for API serialization."""
205
- result = {}
206
- if self.pinned_visit_vehicle_assignment is not None:
207
- result["pinnedVisitVehicleAssignment"] = str(
208
- self.pinned_visit_vehicle_assignment
209
- )
210
- if self.pinned_visit_service_time is not None:
211
- result["pinnedVisitServiceTime"] = str(self.pinned_visit_service_time)
212
- if self.no_missing_skills is not None:
213
- result["noMissingSkills"] = str(self.no_missing_skills)
214
- if self.service_time_missing is not None:
215
- result["serviceTimeMissing"] = str(self.service_time_missing)
216
- if self.vehicle_unassigned is not None:
217
- result["vehicleUnassigned"] = str(self.vehicle_unassigned)
218
- if self.prefer_high_priority is not None:
219
- result["preferHighPriority"] = str(self.prefer_high_priority)
220
- if self.minimize_travel_time is not None:
221
- result["minimizeTravelTime"] = str(self.minimize_travel_time)
222
- if self.prefer_using_idle_vehicles is not None:
223
- result["preferUsingIdleVehicles"] = str(self.prefer_using_idle_vehicles)
224
- if self.minimize_early_arrival_wait is not None:
225
- result["minimizeEarlyArrivalWait"] = str(self.minimize_early_arrival_wait)
226
- if self.prefer_initial_vehicle_assignment is not None:
227
- result["preferInitialVehicleAssignment"] = str(
228
- self.prefer_initial_vehicle_assignment
229
- )
230
- if self.prefer_earlier_visit_dates is not None:
231
- result["PreferEarlierVisitDates"] = str(self.prefer_earlier_visit_dates)
232
- return result
233
-
234
- @classmethod
235
- def from_dict(cls, data: Dict[str, Any]) -> "Weights":
236
- """Create from dictionary."""
237
- from .constraint_weight import ConstraintWeight
238
-
239
- def parse_weight(value: Any) -> Optional[ConstraintWeight]:
240
- if value is None:
241
- return None
242
- if isinstance(value, ConstraintWeight):
243
- return value
244
- if isinstance(value, str):
245
- return ConstraintWeight.try_parse(value)
246
- return None
247
-
248
- return cls(
249
- pinned_visit_vehicle_assignment=parse_weight(
250
- data.get("pinnedVisitVehicleAssignment")
251
- ),
252
- pinned_visit_service_time=parse_weight(data.get("pinnedVisitServiceTime")),
253
- no_missing_skills=parse_weight(data.get("noMissingSkills")),
254
- service_time_missing=parse_weight(data.get("serviceTimeMissing")),
255
- vehicle_unassigned=parse_weight(data.get("vehicleUnassigned")),
256
- prefer_high_priority=parse_weight(data.get("preferHighPriority")),
257
- minimize_travel_time=parse_weight(data.get("minimizeTravelTime")),
258
- prefer_using_idle_vehicles=parse_weight(
259
- data.get("preferUsingIdleVehicles")
260
- ),
261
- minimize_early_arrival_wait=parse_weight(
262
- data.get("minimizeEarlyArrivalWait")
263
- ),
264
- prefer_initial_vehicle_assignment=parse_weight(
265
- data.get("preferInitialVehicleAssignment")
266
- ),
267
- prefer_earlier_visit_dates=parse_weight(
268
- data.get("PreferEarlierVisitDates")
269
- ),
270
- )
271
-
272
-
273
185
  @dataclass
274
186
  class FieldServiceRequest:
275
187
  """Request for field service optimization."""
276
188
 
277
189
  vehicles: List[Vehicle]
278
190
  visits: List[Visit]
279
- weights: Optional[Weights] = None
191
+ # Open map of constraint name -> score string, e.g.
192
+ # {"minimizeTravelTime": "0hard/0medium/1soft"}. Keys are arbitrary
193
+ # (mostly camelCase, but some capitalized) and the key set is open-ended.
194
+ weights: Optional[Dict[str, str]] = None
280
195
  options: Optional[SolverOptions] = None
281
196
 
282
197
  def to_dict(self) -> Dict[str, Any]:
@@ -285,10 +200,8 @@ class FieldServiceRequest:
285
200
  "vehicles": [v.to_dict() for v in self.vehicles],
286
201
  "visits": [v.to_dict() for v in self.visits],
287
202
  }
288
- if self.weights is not None:
289
- weights_dict = self.weights.to_dict()
290
- if weights_dict:
291
- result["weights"] = weights_dict
203
+ if self.weights:
204
+ result["weights"] = {str(k): str(v) for k, v in self.weights.items()}
292
205
  if self.options is not None:
293
206
  options_dict = self.options.to_dict()
294
207
  if options_dict:
@@ -298,10 +211,13 @@ class FieldServiceRequest:
298
211
  @classmethod
299
212
  def from_dict(cls, data: Dict[str, Any]) -> "FieldServiceRequest":
300
213
  """Create from dictionary."""
214
+ weights = data.get("weights")
301
215
  return cls(
302
216
  vehicles=[Vehicle.from_dict(v) for v in data["vehicles"]],
303
217
  visits=[Visit.from_dict(v) for v in data["visits"]],
304
- weights=Weights.from_dict(data["weights"]) if data.get("weights") else None,
218
+ weights=(
219
+ {str(k): str(v) for k, v in weights.items()} if weights else None
220
+ ),
305
221
  options=(
306
222
  SolverOptions.from_dict(data["options"])
307
223
  if data.get("options")
@@ -5,6 +5,7 @@ from typing import Any, Dict, Optional, Union
5
5
 
6
6
  import requests
7
7
 
8
+ from ..errors import raise_for_status
8
9
  from ..routes import Routes
9
10
  from ..solver_status import SOLVER_STATUS, SolverStatusResponse
10
11
  from .models import (
@@ -58,7 +59,7 @@ class ProfessionalServicesApiClient:
58
59
  response = requests.post(
59
60
  self.base_url, json=api_request, headers=self._get_headers()
60
61
  )
61
- response.raise_for_status()
62
+ raise_for_status(response)
62
63
  return ProfessionalServicesResponse.from_dict(response.json())
63
64
 
64
65
  def get_result(self, job_id: str) -> ProfessionalServicesResultResponse:
@@ -79,7 +80,7 @@ class ProfessionalServicesApiClient:
79
80
  if self.api_key:
80
81
  headers["X-API-KEY"] = self.api_key
81
82
  response = requests.get(url, headers=headers)
82
- response.raise_for_status()
83
+ raise_for_status(response)
83
84
  return ProfessionalServicesResultResponse.from_dict(response.json())
84
85
 
85
86
  def get_status(self, job_id: str) -> SolverStatusResponse:
@@ -100,7 +101,7 @@ class ProfessionalServicesApiClient:
100
101
  if self.api_key:
101
102
  headers["X-API-KEY"] = self.api_key
102
103
  response = requests.get(url, headers=headers)
103
- response.raise_for_status()
104
+ raise_for_status(response)
104
105
  return response.json()
105
106
 
106
107
  def analyze(self, job_id: str) -> Dict[str, Any]:
@@ -121,7 +122,7 @@ class ProfessionalServicesApiClient:
121
122
  if self.api_key:
122
123
  headers["X-API-KEY"] = self.api_key
123
124
  response = requests.get(url, headers=headers)
124
- response.raise_for_status()
125
+ raise_for_status(response)
125
126
  return response.json()
126
127
 
127
128
  def start_and_wait_for_completion(
@@ -5,6 +5,7 @@ from typing import Any, Dict, Optional, Union
5
5
 
6
6
  import requests
7
7
 
8
+ from ..errors import raise_for_status
8
9
  from ..routes import Routes
9
10
  from ..solver_status import SOLVER_STATUS, SolverStatusResponse
10
11
  from .models import (
@@ -58,7 +59,7 @@ class ShiftApiClient:
58
59
  response = requests.post(
59
60
  self.base_url, json=api_request, headers=self._get_headers()
60
61
  )
61
- response.raise_for_status()
62
+ raise_for_status(response)
62
63
  return ShiftResponse.from_dict(response.json())
63
64
 
64
65
  def get_result(self, job_id: str) -> ShiftResultResponse:
@@ -79,7 +80,7 @@ class ShiftApiClient:
79
80
  if self.api_key:
80
81
  headers["X-API-KEY"] = self.api_key
81
82
  response = requests.get(url, headers=headers)
82
- response.raise_for_status()
83
+ raise_for_status(response)
83
84
  return ShiftResultResponse.from_dict(response.json())
84
85
 
85
86
  def get_status(self, job_id: str) -> SolverStatusResponse:
@@ -100,7 +101,7 @@ class ShiftApiClient:
100
101
  if self.api_key:
101
102
  headers["X-API-KEY"] = self.api_key
102
103
  response = requests.get(url, headers=headers)
103
- response.raise_for_status()
104
+ raise_for_status(response)
104
105
  return response.json()
105
106
 
106
107
  def analyze(self, job_id: str) -> Dict[str, Any]:
@@ -121,7 +122,7 @@ class ShiftApiClient:
121
122
  if self.api_key:
122
123
  headers["X-API-KEY"] = self.api_key
123
124
  response = requests.get(url, headers=headers)
124
- response.raise_for_status()
125
+ raise_for_status(response)
125
126
  return response.json()
126
127
 
127
128
  def start_and_wait_for_completion(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plansolve
3
- Version: 0.25.2
3
+ Version: 0.25.4
4
4
  Summary: Python SDK for PlanSolve optimization API
5
5
  Author-email: PlanSolve <support@plansolve.app>
6
6
  License: MIT
@@ -4,6 +4,7 @@ README.md
4
4
  pyproject.toml
5
5
  plansolve/__init__.py
6
6
  plansolve/client.py
7
+ plansolve/errors.py
7
8
  plansolve/py.typed
8
9
  plansolve/routes.py
9
10
  plansolve/solver_status.py
@@ -14,7 +15,6 @@ plansolve.egg-info/requires.txt
14
15
  plansolve.egg-info/top_level.txt
15
16
  plansolve/field_service/__init__.py
16
17
  plansolve/field_service/api_client.py
17
- plansolve/field_service/constraint_weight.py
18
18
  plansolve/field_service/models.py
19
19
  plansolve/professional_services/__init__.py
20
20
  plansolve/professional_services/api_client.py
@@ -35,4 +35,6 @@ plansolve/shift/planning_period.py
35
35
  plansolve/shift/shift_assignment.py
36
36
  plansolve/shift/shift_employee.py
37
37
  plansolve/shift/shift_off_request.py
38
- plansolve/shift/weights.py
38
+ plansolve/shift/weights.py
39
+ tests/test_error_messages.py
40
+ tests/test_field_service_weights.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "plansolve"
7
- version = "0.25.2"
7
+ version = "0.25.4"
8
8
  description = "Python SDK for PlanSolve optimization API"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.8"
@@ -0,0 +1,81 @@
1
+ """Tests for HTTP error message extraction."""
2
+
3
+ import json
4
+
5
+ from plansolve.errors import extract_error_message
6
+
7
+
8
+ def test_validation_problem_details_joins_sorted_field_messages():
9
+ body = json.dumps(
10
+ {
11
+ "type": "https://httpstatuses.io/400",
12
+ "title": "One or more validation errors occurred.",
13
+ "status": 400,
14
+ "detail": "See the errors property for details.",
15
+ "instance": "/api/field-service",
16
+ "errors": {
17
+ "vehicles": ["The vehicles field is required.", "At least one vehicle is needed."],
18
+ "visits": ["The visits field is required."],
19
+ },
20
+ }
21
+ )
22
+
23
+ message = extract_error_message(body)
24
+
25
+ # Every field/message pair rendered as "field: msg", sorted ascending,
26
+ # joined with "; ". errors wins over detail/title.
27
+ assert message == (
28
+ "vehicles: At least one vehicle is needed.; "
29
+ "vehicles: The vehicles field is required.; "
30
+ "visits: The visits field is required."
31
+ )
32
+
33
+
34
+ def test_error_response_returns_error_string():
35
+ body = json.dumps({"error": "Quota exceeded for this API key."})
36
+
37
+ assert extract_error_message(body) == "Quota exceeded for this API key."
38
+
39
+
40
+ def test_problem_details_returns_detail():
41
+ body = json.dumps(
42
+ {
43
+ "type": "https://httpstatuses.io/401",
44
+ "title": "Unauthorized",
45
+ "status": 401,
46
+ "detail": "The provided API key is invalid.",
47
+ "instance": "/api/field-service",
48
+ }
49
+ )
50
+
51
+ assert extract_error_message(body) == "The provided API key is invalid."
52
+
53
+
54
+ def test_problem_details_falls_back_to_title_when_no_detail():
55
+ body = json.dumps(
56
+ {
57
+ "type": "https://httpstatuses.io/403",
58
+ "title": "Forbidden",
59
+ "status": 403,
60
+ }
61
+ )
62
+
63
+ assert extract_error_message(body) == "Forbidden"
64
+
65
+
66
+ def test_non_json_body_returns_raw_text():
67
+ body = "502 Bad Gateway"
68
+
69
+ assert extract_error_message(body) == "502 Bad Gateway"
70
+
71
+
72
+ def test_error_takes_priority_over_detail_and_title():
73
+ body = json.dumps({"error": "boom", "detail": "ignored", "title": "ignored"})
74
+
75
+ assert extract_error_message(body) == "boom"
76
+
77
+
78
+ def test_empty_errors_dict_falls_through_to_detail():
79
+ body = json.dumps({"errors": {}, "detail": "fallback detail"})
80
+
81
+ assert extract_error_message(body) == "fallback detail"
@@ -0,0 +1,99 @@
1
+ """Tests for the field-service open-map weights model."""
2
+
3
+ from plansolve.field_service.models import FieldServiceRequest, Vehicle, Visit, Shift, TimeWindow
4
+
5
+
6
+ def _minimal_vehicle() -> Vehicle:
7
+ return Vehicle(
8
+ id="tech1",
9
+ location=(40.7128, -74.0060),
10
+ shifts=[
11
+ Shift(
12
+ id="morning-shift",
13
+ min_start_time="2024-01-15T08:00:00",
14
+ max_end_time="2024-01-15T17:00:00",
15
+ )
16
+ ],
17
+ skills=["repair"],
18
+ )
19
+
20
+
21
+ def _minimal_visit() -> Visit:
22
+ return Visit(
23
+ id="visit1",
24
+ name="Times Square Repair",
25
+ location=(40.7589, -73.9851),
26
+ time_windows=[
27
+ TimeWindow(
28
+ min_start_time="2024-01-15T09:00:00",
29
+ max_end_time="2024-01-15T17:00:00",
30
+ )
31
+ ],
32
+ service_duration="PT30M",
33
+ priority="HIGH",
34
+ required_skills=["repair"],
35
+ )
36
+
37
+
38
+ # The exact weights block from the PlanSolve API contract: an open string map
39
+ # with mostly camelCase keys plus a capitalized "PreferEarlierVisitDates" key.
40
+ EXAMPLE_WEIGHTS = {
41
+ "pinnedVisitVehicleAssignment": "1hard/0medium/0soft",
42
+ "vehicleUnassigned": "0hard/1medium/0soft",
43
+ "minimizeTravelTime": "0hard/0medium/1soft",
44
+ "PreferEarlierVisitDates": "0hard/0medium/20soft",
45
+ }
46
+
47
+
48
+ def test_weights_serialize_as_exact_string_map():
49
+ request = FieldServiceRequest(
50
+ vehicles=[_minimal_vehicle()],
51
+ visits=[_minimal_visit()],
52
+ weights=dict(EXAMPLE_WEIGHTS),
53
+ )
54
+
55
+ payload = request.to_dict()
56
+
57
+ # Weights survive round-trip serialization exactly, preserving arbitrary
58
+ # and capitalized keys and the raw score strings.
59
+ assert payload["weights"] == EXAMPLE_WEIGHTS
60
+ assert "PreferEarlierVisitDates" in payload["weights"]
61
+ assert payload["weights"]["PreferEarlierVisitDates"] == "0hard/0medium/20soft"
62
+
63
+ # Every value must serialize as a plain JSON string, not a nested object.
64
+ for key, value in payload["weights"].items():
65
+ assert isinstance(key, str)
66
+ assert isinstance(value, str)
67
+
68
+
69
+ def test_weights_accept_arbitrary_unknown_keys():
70
+ weights = {"someBrandNewConstraint": "5hard/0medium/0soft"}
71
+ request = FieldServiceRequest(
72
+ vehicles=[_minimal_vehicle()],
73
+ visits=[_minimal_visit()],
74
+ weights=weights,
75
+ )
76
+
77
+ assert request.to_dict()["weights"] == weights
78
+
79
+
80
+ def test_weights_round_trip_from_dict():
81
+ data = {
82
+ "vehicles": [_minimal_vehicle().to_dict()],
83
+ "visits": [_minimal_visit().to_dict()],
84
+ "weights": dict(EXAMPLE_WEIGHTS),
85
+ }
86
+
87
+ request = FieldServiceRequest.from_dict(data)
88
+
89
+ assert request.weights == EXAMPLE_WEIGHTS
90
+ assert request.to_dict()["weights"] == EXAMPLE_WEIGHTS
91
+
92
+
93
+ def test_weights_omitted_when_none():
94
+ request = FieldServiceRequest(
95
+ vehicles=[_minimal_vehicle()],
96
+ visits=[_minimal_visit()],
97
+ )
98
+
99
+ assert "weights" not in request.to_dict()
@@ -1,143 +0,0 @@
1
- """Constraint weight representation for field service optimization."""
2
-
3
- import re
4
- from typing import Optional
5
-
6
-
7
- class ConstraintWeight:
8
- """
9
- Represents a constraint weight in the format "Xhard/Ymedium/Zsoft" where X, Y, Z are numeric values.
10
- Only one of hard, medium, or soft can be non-zero at a time.
11
- Examples: "1hard/0medium/0soft", "0hard/1medium/0soft", "0hard/0medium/1soft"
12
- """
13
-
14
- def __init__(self, hard: int, medium: int, soft: int):
15
- """
16
- Create a new ConstraintWeight with the specified values.
17
- Only one of hard, medium, or soft can be non-zero.
18
-
19
- Args:
20
- hard: Hard constraint weight value
21
- medium: Medium constraint weight value
22
- soft: Soft constraint weight value
23
-
24
- Raises:
25
- ValueError: When more than one value is non-zero
26
- """
27
- non_zero_count = sum(1 for x in [hard, medium, soft] if x != 0)
28
- if non_zero_count > 1:
29
- raise ValueError(
30
- f"Only one of hard, medium, or soft can be non-zero. "
31
- f"Received: hard={hard}, medium={medium}, soft={soft}"
32
- )
33
-
34
- self.hard = hard
35
- self.medium = medium
36
- self.soft = soft
37
-
38
- @staticmethod
39
- def parse(value: str) -> "ConstraintWeight":
40
- """
41
- Parse a weight string in the format "Xhard/Ymedium/Zsoft".
42
- Only one of X, Y, or Z can be non-zero.
43
-
44
- Args:
45
- value: The weight string to parse
46
-
47
- Returns:
48
- A ConstraintWeight instance
49
-
50
- Raises:
51
- ValueError: When the string format is invalid or more than one value is non-zero
52
- """
53
- if not value or not value.strip():
54
- raise ValueError("Weight value cannot be null or empty")
55
-
56
- # Pattern: (\d+)hard/(\d+)medium/(\d+)soft
57
- pattern = r"^(\d+)hard/(\d+)medium/(\d+)soft$"
58
- match = re.match(pattern, value, re.IGNORECASE)
59
-
60
- if not match:
61
- raise ValueError(
62
- f"Invalid weight format: '{value}'. Expected format: 'Xhard/Ymedium/Zsoft'"
63
- )
64
-
65
- hard = int(match.group(1))
66
- medium = int(match.group(2))
67
- soft = int(match.group(3))
68
-
69
- return ConstraintWeight(hard, medium, soft)
70
-
71
- @staticmethod
72
- def try_parse(value: Optional[str]) -> Optional["ConstraintWeight"]:
73
- """
74
- Attempt to parse a weight string. Returns the ConstraintWeight if successful, None otherwise.
75
-
76
- Args:
77
- value: The weight string to parse, or None
78
-
79
- Returns:
80
- ConstraintWeight instance if successful, None otherwise
81
- """
82
- if not value or not value.strip():
83
- return None
84
-
85
- try:
86
- return ConstraintWeight.parse(value)
87
- except (ValueError, AttributeError):
88
- return None
89
-
90
- def __str__(self) -> str:
91
- """
92
- Return the string representation in the format "Xhard/Ymedium/Zsoft".
93
-
94
- Returns:
95
- String representation of the weight
96
- """
97
- return f"{self.hard}hard/{self.medium}medium/{self.soft}soft"
98
-
99
- def __repr__(self) -> str:
100
- """Return a string representation of the ConstraintWeight."""
101
- return f"ConstraintWeight(hard={self.hard}, medium={self.medium}, soft={self.soft})"
102
-
103
- @staticmethod
104
- def create_hard(value: int) -> "ConstraintWeight":
105
- """
106
- Create a hard constraint weight (Xhard/0medium/0soft).
107
-
108
- Args:
109
- value: The hard constraint value
110
-
111
- Returns:
112
- ConstraintWeight instance
113
- """
114
- return ConstraintWeight(value, 0, 0)
115
-
116
- @staticmethod
117
- def create_medium(value: int) -> "ConstraintWeight":
118
- """
119
- Create a medium constraint weight (0hard/Xmedium/0soft).
120
-
121
- Args:
122
- value: The medium constraint value
123
-
124
- Returns:
125
- ConstraintWeight instance
126
- """
127
- return ConstraintWeight(0, value, 0)
128
-
129
- @staticmethod
130
- def create_soft(value: int) -> "ConstraintWeight":
131
- """
132
- Create a soft constraint weight (0hard/0medium/Xsoft).
133
-
134
- Args:
135
- value: The soft constraint value
136
-
137
- Returns:
138
- ConstraintWeight instance
139
- """
140
- return ConstraintWeight(0, 0, value)
141
-
142
-
143
-
File without changes
File without changes
File without changes
File without changes