Metadata-Version: 2.4
Name: plansolve
Version: 0.29.0
Summary: Python SDK for PlanSolve optimization API
Author-email: PlanSolve <support@plansolve.app>
License: Apache-2.0
Project-URL: Homepage, https://plansolve.app
Project-URL: Documentation, https://plansolve.app/docs
Project-URL: Repository, https://github.com/plansolve/plansolve-python-sdk
Project-URL: Issues, https://github.com/plansolve/plansolve-python-sdk/issues
Keywords: optimization,routing,scheduling,field-service,professional-services
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: Apache Software License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.8
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Requires-Python: >=3.8
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: requests>=2.31.0
Provides-Extra: dev
Requires-Dist: pytest>=7.0.0; extra == "dev"
Requires-Dist: pytest-asyncio>=0.21.0; extra == "dev"
Requires-Dist: black>=23.0.0; extra == "dev"
Requires-Dist: mypy>=1.0.0; extra == "dev"
Requires-Dist: ruff>=0.1.0; extra == "dev"
Dynamic: license-file

# PlanSolve for Python

Official Python client for the [PlanSolve](https://getplansolve.com) optimization API. One typed client covers three solvers (field service routing, professional-services task assignment, and shift scheduling) with built-in polling and clean error messages.

## Installation

```bash
pip install plansolve
```

Requires Python 3.8+ and `requests` 2.31+. Type hints are bundled.

## Quick start

```python
from plansolve import PlanSolveClient

client = PlanSolveClient(api_key="YOUR_API_KEY")

request = {
    "vehicles": [
        {
            "id": "tech1",
            "location": [40.7128, -74.0060],
            "skills": ["repair"],
            "shifts": [
                {"id": "morning", "minStartTime": "2026-04-02T08:00:00", "maxEndTime": "2026-04-02T17:00:00"}
            ],
        }
    ],
    "visits": [
        {
            "id": "visit1",
            "name": "AC Repair - Downtown Office",
            "location": [40.7589, -73.9851],
            "serviceDuration": "PT60M",
            "priority": "HIGH",
            "requiredSkills": ["repair"],
            "timeWindows": [
                {"minStartTime": "2026-04-02T09:00:00", "maxEndTime": "2026-04-02T17:00:00"}
            ],
        }
    ],
}

# Submit and block until the optimized plan is ready
result = client.field_service.start_and_wait_for_completion(request)

for vehicle in result.vehicles:
    print(f"Vehicle {vehicle.id}: {len(vehicle.visits)} visits")
```

Requests accept a plain `dict` (as above) or the typed dataclasses (`FieldServiceRequest`, `Vehicle`, `Visit`, ...).

## Solvers

One client, three solvers. All share the same submit, poll, result workflow:

| Solver | Accessor | Use for |
|--------|----------|---------|
| Field Service | `client.field_service` | Vehicle routing with travel time, time windows, and skills |
| Professional Services | `client.professional_services` | Task assignment by skill, availability, priority, and deadlines |
| Shift | `client.shift` | Shift scheduling across contracts, availability, and fairness |

Every solver client exposes the same methods:

| Method | HTTP | Returns |
|--------|------|---------|
| `start(request)` | `POST /api/v1/{solver}` | Start response with `job_id` (`solver_job_id` and `result` are `None` unless the server provides them) |
| `get_status(job_id)` | `GET /api/v1/{solver}/{job_id}/status` | Status `dict` (`jobId`, `solverStatus`, `score`, `feasible`, `solving`) |
| `get_result(job_id)` | `GET /api/v1/{solver}/{job_id}` | The solver's result type |
| `stop(job_id)` | `DELETE /api/v1/{solver}/{job_id}` | Stops the solve and returns the best solution found so far (same type as `get_result`) |
| `analyze(job_id)` | `GET /api/v1/{solver}/{job_id}/analyze` | Constraint analysis as a `dict` |
| `wait_for_completion(job_id, poll_interval_ms=5000, max_attempts=150)` | polls status, then fetches the result | The solver's result type |
| `start_and_wait_for_completion(request, poll_interval_ms=5000, max_attempts=150)` | `start` + `wait_for_completion` | The solver's result type |

`{solver}` is `fieldservice`, `professionalservices` or `shift`. The result types are `FieldServiceResultResponse`, `ProfessionalServicesResultResponse` and `ShiftResultResponse`; each gets a client-side `job_id` stamped after parsing.

### Polling

The wait helpers sleep `poll_interval_ms` before each status check and stop as soon as the status reports `solving` false and `solverStatus` `NOT_SOLVING` (a score is not required). The defaults are 5000 ms between polls and 150 polls (12.5 minutes), which covers the server's 10-minute cap on a solve. A value of 0 or less for either argument falls back to the default.

```python
# Stop early and keep the best plan found so far
best = client.shift.stop(job_id)

# Inspect which constraints drive the score
analysis = client.field_service.analyze(job_id)
```

Unassigned work parses cleanly: a field service visit the solver could not place comes back with `vehicle`, `arrival_time`, `departure_time`, `start_service_time` and `driving_time_seconds_from_previous_standstill` set to `None`.

## Configuration

Pass your API key to the constructor: `PlanSolveClient(api_key="...")`. It is sent as the `X-API-KEY` header on every request.

## Error handling

The SDK never prints to stdout or stderr; everything is reported through exceptions:

| Exception | When |
|-----------|------|
| `requests.HTTPError` | Any non-2xx response. The message is `HTTP <status>: <detail>`, built from the error body (validation errors, `error`, `detail` or `title`), e.g. `HTTP 400: vehicles: At least one vehicle is required.` The original response is on `e.response`. A failed solve answers the status endpoint with HTTP 422, so the wait helpers raise this too. |
| `ValueError("JobId was not returned from wait_for_completion.")` | `wait_for_completion` was called with an empty job id, or the start response carried none. |
| `ValueError("Solver still running after N polls; raise max_attempts or lower options.spent_limit")` | The solver was still running after `max_attempts` status polls. |
| `requests.RequestException` | Network failures (connection errors, timeouts) raised by `requests`. |

```python
import requests

try:
    result = client.field_service.start_and_wait_for_completion(request)
except requests.HTTPError as e:
    print(e.response.status_code, e)
except ValueError as e:
    print(e)
```

## Documentation

Full guides, per-solver data models, and parameter reference live on the docs site:

- Field Service: https://getplansolve.com/docs/fieldservice/sdk/python
- Professional Services: https://getplansolve.com/docs/professionalservices/sdk/python
- Shift: https://getplansolve.com/docs/shiftsolver/sdk/python

Package: [PyPI](https://pypi.org/project/plansolve/)

## License

Apache-2.0. See [LICENSE](LICENSE).
