Skip to content

Commit 04ecd0e

Browse files
ENG-4278 Submit remote runs to Nextmv Cloud API
1 parent ca6ac17 commit 04ecd0e

13 files changed

+377
-74
lines changed

nextmv-py.code-workspace

+1-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
}
66
],
77
"settings": {
8-
"python.testing.unittestArgs": ["-v", "-s", "./tests", "-p", "test*.py"],
8+
"python.testing.unittestArgs": ["-v", "-s", ".", "-p", "test*.py"],
99
"python.testing.pytestEnabled": false,
1010
"python.testing.unittestEnabled": true
1111
}

nextmv/base_model.py

+20
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
"""JSON class for data wrangling JSON objects."""
2+
3+
from typing import Any
4+
5+
from pydantic import BaseModel
6+
7+
8+
class BaseModel(BaseModel):
9+
"""Base class for data wrangling tasks with JSON."""
10+
11+
@classmethod
12+
def from_dict(cls, data: dict[str, Any]):
13+
"""Instantiates the class from a dict."""
14+
15+
return cls(**data)
16+
17+
def to_dict(self) -> dict[str, Any]:
18+
"""Converts the class to a dict."""
19+
20+
return self.model_dump(mode="json", exclude_none=True)

nextmv/cloud/__init__.py

+4
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
"""Functionality for interacting with the Nextmv Cloud."""
2+
3+
from .application import Application as Application
4+
from .client import Client as Client

nextmv/cloud/application.py

+136
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
"""This module contains the application class."""
2+
3+
from dataclasses import dataclass
4+
from datetime import datetime
5+
from typing import Any
6+
7+
from nextmv.base_model import BaseModel
8+
from nextmv.cloud.client import Client
9+
10+
11+
class Metadata(BaseModel):
12+
"""Metadata of a run, whether it was successful or not."""
13+
14+
status: str
15+
"""Status of the run."""
16+
created_at: datetime
17+
"""Date and time when the run was created."""
18+
duration: float
19+
"""Duration of the run in milliseconds."""
20+
input_size: float
21+
"""Size of the input in bytes."""
22+
output_size: float
23+
"""Size of the output in bytes."""
24+
error: str
25+
"""Error message if the run failed."""
26+
application_id: str
27+
"""ID of the application where the run was submitted to."""
28+
application_instance_id: str
29+
"""ID of the instance where the run was submitted to."""
30+
application_version_id: str
31+
"""ID of the version of the application where the run was submitted to."""
32+
33+
34+
class RunResult(BaseModel):
35+
"""Result of a run, wheter it was successful or not."""
36+
37+
id: str
38+
"""ID of the run."""
39+
user_email: str
40+
"""Email of the user who submitted the run."""
41+
name: str
42+
"""Name of the run."""
43+
description: str
44+
"""Description of the run."""
45+
metadata: Metadata
46+
"""Metadata of the run."""
47+
output: dict[str, Any]
48+
"""Output of the run."""
49+
50+
51+
@dataclass
52+
class Application:
53+
"""An application is a published decision model that can be executed."""
54+
55+
client: Client
56+
"""Client to use for interacting with the Nextmv Cloud API."""
57+
id: str
58+
"""ID of the application."""
59+
endpoint: str = "v1/applications/{id}"
60+
"""Base endpoint for the application."""
61+
default_instance_id: str = "devint"
62+
"""Default instance ID to use for submitting runs."""
63+
64+
def __post_init__(self):
65+
"""Logic to run after the class is initialized."""
66+
67+
self.endpoint = self.endpoint.format(id=self.id)
68+
69+
def new_run(
70+
self,
71+
input: dict[str, Any] = None,
72+
instance_id: str | None = None,
73+
name: str | None = None,
74+
description: str | None = None,
75+
upload_id: str | None = None,
76+
options: dict[str, Any] | None = None,
77+
) -> str:
78+
"""
79+
Submit an input to start a new run of the application. Returns the
80+
run_id of the submitted run.
81+
82+
Args:
83+
input: Input to use for the run.
84+
instance_id: ID of the instance to use for the run. If not
85+
provided, the default_instance_id will be used.
86+
name: Name of the run.
87+
description: Description of the run.
88+
upload_id: ID to use when running a large input.
89+
options: Options to use for the run.
90+
91+
Returns:
92+
ID of the submitted run.
93+
"""
94+
95+
payload = {}
96+
if input is not None:
97+
payload["input"] = input
98+
if name is not None:
99+
payload["name"] = name
100+
if description is not None:
101+
payload["description"] = description
102+
if upload_id is not None:
103+
payload["upload_id"] = upload_id
104+
if options is not None:
105+
payload["options"] = options
106+
107+
query_params = {
108+
"instance_id": instance_id if instance_id is not None else self.default_instance_id,
109+
}
110+
response = self.client.post(
111+
endpoint=f"{self.endpoint}/runs",
112+
payload=payload,
113+
query_params=query_params,
114+
)
115+
116+
return response.json()["run_id"]
117+
118+
def run_result(
119+
self,
120+
run_id: str,
121+
) -> RunResult:
122+
"""
123+
Get the result of a run.
124+
125+
Args:
126+
run_id: ID of the run.
127+
128+
Returns:
129+
Result of the run.
130+
"""
131+
132+
response = self.client.get(
133+
endpoint=f"{self.endpoint}/runs/{run_id}",
134+
)
135+
136+
return RunResult.from_dict(response.json())

nextmv/cloud/client.py

+92
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
"""Module with the client class."""
2+
3+
import os
4+
from dataclasses import dataclass
5+
from typing import Any
6+
7+
import requests
8+
9+
10+
@dataclass
11+
class Client:
12+
"""Client that interacts directly with the Nextmv Cloud API. The API key
13+
must be provided either in the constructor or via the NEXTMV_API_KEY"""
14+
15+
api_key: str | None = None
16+
"""API key to use for authenticating with the Nextmv Cloud API. If not
17+
provided, the client will look for the NEXTMV_API_KEY environment
18+
variable."""
19+
url: str = "https://api.cloud.nextmv.io"
20+
"""URL of the Nextmv Cloud API."""
21+
headers: dict[str, str] | None = None
22+
"""Headers to use for requests to the Nextmv Cloud API."""
23+
24+
def __post_init__(self):
25+
"""Logic to run after the class is initialized."""
26+
27+
if self.api_key is None:
28+
api_key = os.getenv("NEXTMV_API_KEY")
29+
if api_key is None:
30+
raise ValueError(
31+
"no API key provided. Either set it in the constructor or "
32+
"set the NEXTMV_API_KEY environment variable."
33+
)
34+
self.api_key = api_key
35+
36+
self.headers = {
37+
"Authorization": f"Bearer {self.api_key}",
38+
"Content-Type": "application/json",
39+
}
40+
41+
def post(
42+
self,
43+
endpoint: str,
44+
payload: dict[str, Any],
45+
query_params: dict[str, Any] | None = None,
46+
) -> requests.Response:
47+
"""
48+
Send a POST request to the Nextmv Cloud API.
49+
50+
Args:
51+
endpoint: Endpoint to send the request to.
52+
payload: Payload to send with the request.
53+
query_params: Query parameters to send with the request.
54+
55+
Returns:
56+
Response from the Nextmv Cloud API.
57+
"""
58+
59+
response = requests.post(
60+
url=f"{self.url}/{endpoint}",
61+
json=payload,
62+
headers=self.headers,
63+
params=query_params,
64+
)
65+
response.raise_for_status()
66+
67+
return response
68+
69+
def get(
70+
self,
71+
endpoint: str,
72+
query_params: dict[str, Any] | None = None,
73+
) -> requests.Response:
74+
"""
75+
Send a GET request to the Nextmv Cloud API.
76+
77+
Args:
78+
endpoint: Endpoint to send the request to.
79+
query_params: Query parameters to send with the request.
80+
81+
Returns:
82+
Response from the Nextmv Cloud API.
83+
"""
84+
85+
response = requests.get(
86+
url=f"{self.url}/{endpoint}",
87+
headers=self.headers,
88+
params=query_params,
89+
)
90+
response.raise_for_status()
91+
92+
return response

nextmv/nextroute/schema/base.py

-33
This file was deleted.

nextmv/nextroute/schema/input.py

+6-10
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,13 @@
11
"""Defines the input class"""
22

3-
from dataclasses import dataclass
43
from typing import Any
54

6-
from .base import _Base
7-
from .stop import AlternateStop, Stop, StopDefaults
8-
from .vehicle import Vehicle, VehicleDefaults
5+
from nextmv.base_model import BaseModel
6+
from nextmv.nextroute.schema.stop import AlternateStop, Stop, StopDefaults
7+
from nextmv.nextroute.schema.vehicle import Vehicle, VehicleDefaults
98

109

11-
@dataclass
12-
class Defaults(_Base):
10+
class Defaults(BaseModel):
1311
"""Default values for vehicles and stops."""
1412

1513
stops: StopDefaults | None = None
@@ -18,8 +16,7 @@ class Defaults(_Base):
1816
"""Default values for vehicles."""
1917

2018

21-
@dataclass
22-
class DurationGroup(_Base):
19+
class DurationGroup(BaseModel):
2320
"""Represents a group of stops that get additional duration whenever a stop
2421
of the group is approached for the first time."""
2522

@@ -29,8 +26,7 @@ class DurationGroup(_Base):
2926
"""Stop IDs contained in the group."""
3027

3128

32-
@dataclass
33-
class Input(_Base):
29+
class Input(BaseModel):
3430
"""Input schema for Nextroute."""
3531

3632
stops: list[Stop]

nextmv/nextroute/schema/location.py

+2-5
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,10 @@
11
"""Defines the location class."""
22

33

4-
from dataclasses import dataclass
4+
from nextmv.base_model import BaseModel
55

6-
from .base import _Base
76

8-
9-
@dataclass
10-
class Location(_Base):
7+
class Location(BaseModel):
118
"""Location represents a geographical location."""
129

1310
lon: float

nextmv/nextroute/schema/stop.py

+3-7
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,14 @@
11
"""Defines the stop class."""
22

33

4-
from dataclasses import dataclass
54
from datetime import datetime
65
from typing import Any
76

8-
from .base import _Base
9-
from .location import Location
7+
from nextmv.base_model import BaseModel
8+
from nextmv.nextroute.schema.location import Location
109

1110

12-
@dataclass
13-
class StopDefaults(_Base):
11+
class StopDefaults(BaseModel):
1412
"""Default values for a stop."""
1513

1614
compatibility_attributes: list[str] | None = None
@@ -33,7 +31,6 @@ class StopDefaults(_Base):
3331
"""Penalty for not planning a stop."""
3432

3533

36-
@dataclass(kw_only=True)
3734
class Stop(StopDefaults):
3835
"""Stop is a location that must be visited by a vehicle in a Vehicle
3936
Routing Problem (VRP.)"""
@@ -53,7 +50,6 @@ class Stop(StopDefaults):
5350
"""Stops that must be visited before this one on the same route."""
5451

5552

56-
@dataclass(kw_only=True)
5753
class AlternateStop(StopDefaults):
5854
"""An alternate stop can be serviced instead of another stop."""
5955

0 commit comments

Comments
 (0)