From 04ecd0e8d4b54484b1c3a41e338fae7c253eca53 Mon Sep 17 00:00:00 2001 From: Sebastian Quintero Date: Mon, 11 Dec 2023 12:22:59 -0500 Subject: [PATCH] ENG-4278 Submit remote runs to Nextmv Cloud API --- nextmv-py.code-workspace | 2 +- nextmv/base_model.py | 20 ++++ nextmv/cloud/__init__.py | 4 + nextmv/cloud/application.py | 136 +++++++++++++++++++++++++++ nextmv/cloud/client.py | 92 ++++++++++++++++++ nextmv/nextroute/schema/base.py | 33 ------- nextmv/nextroute/schema/input.py | 16 ++-- nextmv/nextroute/schema/location.py | 7 +- nextmv/nextroute/schema/stop.py | 10 +- nextmv/nextroute/schema/vehicle.py | 12 +-- requirements.txt | 2 + tests/nextroute/schema/test_input.py | 56 +++++++++-- tests/test_base_model.py | 61 ++++++++++++ 13 files changed, 377 insertions(+), 74 deletions(-) create mode 100644 nextmv/base_model.py create mode 100644 nextmv/cloud/__init__.py create mode 100644 nextmv/cloud/application.py create mode 100644 nextmv/cloud/client.py delete mode 100644 nextmv/nextroute/schema/base.py create mode 100644 tests/test_base_model.py diff --git a/nextmv-py.code-workspace b/nextmv-py.code-workspace index 8234f0a..44e1113 100644 --- a/nextmv-py.code-workspace +++ b/nextmv-py.code-workspace @@ -5,7 +5,7 @@ } ], "settings": { - "python.testing.unittestArgs": ["-v", "-s", "./tests", "-p", "test*.py"], + "python.testing.unittestArgs": ["-v", "-s", ".", "-p", "test*.py"], "python.testing.pytestEnabled": false, "python.testing.unittestEnabled": true } diff --git a/nextmv/base_model.py b/nextmv/base_model.py new file mode 100644 index 0000000..0f62656 --- /dev/null +++ b/nextmv/base_model.py @@ -0,0 +1,20 @@ +"""JSON class for data wrangling JSON objects.""" + +from typing import Any + +from pydantic import BaseModel + + +class BaseModel(BaseModel): + """Base class for data wrangling tasks with JSON.""" + + @classmethod + def from_dict(cls, data: dict[str, Any]): + """Instantiates the class from a dict.""" + + return cls(**data) + + def to_dict(self) -> dict[str, Any]: + """Converts the class to a dict.""" + + return self.model_dump(mode="json", exclude_none=True) diff --git a/nextmv/cloud/__init__.py b/nextmv/cloud/__init__.py new file mode 100644 index 0000000..3bdb72f --- /dev/null +++ b/nextmv/cloud/__init__.py @@ -0,0 +1,4 @@ +"""Functionality for interacting with the Nextmv Cloud.""" + +from .application import Application as Application +from .client import Client as Client diff --git a/nextmv/cloud/application.py b/nextmv/cloud/application.py new file mode 100644 index 0000000..77575bb --- /dev/null +++ b/nextmv/cloud/application.py @@ -0,0 +1,136 @@ +"""This module contains the application class.""" + +from dataclasses import dataclass +from datetime import datetime +from typing import Any + +from nextmv.base_model import BaseModel +from nextmv.cloud.client import Client + + +class Metadata(BaseModel): + """Metadata of a run, whether it was successful or not.""" + + status: str + """Status of the run.""" + created_at: datetime + """Date and time when the run was created.""" + duration: float + """Duration of the run in milliseconds.""" + input_size: float + """Size of the input in bytes.""" + output_size: float + """Size of the output in bytes.""" + error: str + """Error message if the run failed.""" + application_id: str + """ID of the application where the run was submitted to.""" + application_instance_id: str + """ID of the instance where the run was submitted to.""" + application_version_id: str + """ID of the version of the application where the run was submitted to.""" + + +class RunResult(BaseModel): + """Result of a run, wheter it was successful or not.""" + + id: str + """ID of the run.""" + user_email: str + """Email of the user who submitted the run.""" + name: str + """Name of the run.""" + description: str + """Description of the run.""" + metadata: Metadata + """Metadata of the run.""" + output: dict[str, Any] + """Output of the run.""" + + +@dataclass +class Application: + """An application is a published decision model that can be executed.""" + + client: Client + """Client to use for interacting with the Nextmv Cloud API.""" + id: str + """ID of the application.""" + endpoint: str = "v1/applications/{id}" + """Base endpoint for the application.""" + default_instance_id: str = "devint" + """Default instance ID to use for submitting runs.""" + + def __post_init__(self): + """Logic to run after the class is initialized.""" + + self.endpoint = self.endpoint.format(id=self.id) + + def new_run( + self, + input: dict[str, Any] = None, + instance_id: str | None = None, + name: str | None = None, + description: str | None = None, + upload_id: str | None = None, + options: dict[str, Any] | None = None, + ) -> str: + """ + Submit an input to start a new run of the application. Returns the + run_id of the submitted run. + + Args: + input: Input to use for the run. + instance_id: ID of the instance to use for the run. If not + provided, the default_instance_id will be used. + name: Name of the run. + description: Description of the run. + upload_id: ID to use when running a large input. + options: Options to use for the run. + + Returns: + ID of the submitted run. + """ + + payload = {} + if input is not None: + payload["input"] = input + if name is not None: + payload["name"] = name + if description is not None: + payload["description"] = description + if upload_id is not None: + payload["upload_id"] = upload_id + if options is not None: + payload["options"] = options + + query_params = { + "instance_id": instance_id if instance_id is not None else self.default_instance_id, + } + response = self.client.post( + endpoint=f"{self.endpoint}/runs", + payload=payload, + query_params=query_params, + ) + + return response.json()["run_id"] + + def run_result( + self, + run_id: str, + ) -> RunResult: + """ + Get the result of a run. + + Args: + run_id: ID of the run. + + Returns: + Result of the run. + """ + + response = self.client.get( + endpoint=f"{self.endpoint}/runs/{run_id}", + ) + + return RunResult.from_dict(response.json()) diff --git a/nextmv/cloud/client.py b/nextmv/cloud/client.py new file mode 100644 index 0000000..e25d00d --- /dev/null +++ b/nextmv/cloud/client.py @@ -0,0 +1,92 @@ +"""Module with the client class.""" + +import os +from dataclasses import dataclass +from typing import Any + +import requests + + +@dataclass +class Client: + """Client that interacts directly with the Nextmv Cloud API. The API key + must be provided either in the constructor or via the NEXTMV_API_KEY""" + + api_key: str | None = None + """API key to use for authenticating with the Nextmv Cloud API. If not + provided, the client will look for the NEXTMV_API_KEY environment + variable.""" + url: str = "https://api.cloud.nextmv.io" + """URL of the Nextmv Cloud API.""" + headers: dict[str, str] | None = None + """Headers to use for requests to the Nextmv Cloud API.""" + + def __post_init__(self): + """Logic to run after the class is initialized.""" + + if self.api_key is None: + api_key = os.getenv("NEXTMV_API_KEY") + if api_key is None: + raise ValueError( + "no API key provided. Either set it in the constructor or " + "set the NEXTMV_API_KEY environment variable." + ) + self.api_key = api_key + + self.headers = { + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + } + + def post( + self, + endpoint: str, + payload: dict[str, Any], + query_params: dict[str, Any] | None = None, + ) -> requests.Response: + """ + Send a POST request to the Nextmv Cloud API. + + Args: + endpoint: Endpoint to send the request to. + payload: Payload to send with the request. + query_params: Query parameters to send with the request. + + Returns: + Response from the Nextmv Cloud API. + """ + + response = requests.post( + url=f"{self.url}/{endpoint}", + json=payload, + headers=self.headers, + params=query_params, + ) + response.raise_for_status() + + return response + + def get( + self, + endpoint: str, + query_params: dict[str, Any] | None = None, + ) -> requests.Response: + """ + Send a GET request to the Nextmv Cloud API. + + Args: + endpoint: Endpoint to send the request to. + query_params: Query parameters to send with the request. + + Returns: + Response from the Nextmv Cloud API. + """ + + response = requests.get( + url=f"{self.url}/{endpoint}", + headers=self.headers, + params=query_params, + ) + response.raise_for_status() + + return response diff --git a/nextmv/nextroute/schema/base.py b/nextmv/nextroute/schema/base.py deleted file mode 100644 index b5bb0dd..0000000 --- a/nextmv/nextroute/schema/base.py +++ /dev/null @@ -1,33 +0,0 @@ -"""Base class for data wrangling.""" - -import json -from dataclasses import asdict, dataclass -from typing import Any - - -@dataclass -class _Base: - """Base class for data wrangling tasks.""" - - @classmethod - def from_dict(cls, data: dict[str, Any]): - """Instantiates the class from a dict.""" - - return cls(**data) - - @classmethod - def from_json(cls, filepath: str): - """Instantiates the class from a JSON file.""" - - with open(filepath) as f: - data = json.load(f) - - return cls.from_dict(data) - - def to_dict(self) -> dict[str, Any]: - """Converts the class to a dict.""" - - return asdict( - self, - dict_factory=lambda x: {k: v for (k, v) in x if v is not None}, - ) diff --git a/nextmv/nextroute/schema/input.py b/nextmv/nextroute/schema/input.py index 1413290..7b58fff 100644 --- a/nextmv/nextroute/schema/input.py +++ b/nextmv/nextroute/schema/input.py @@ -1,15 +1,13 @@ """Defines the input class""" -from dataclasses import dataclass from typing import Any -from .base import _Base -from .stop import AlternateStop, Stop, StopDefaults -from .vehicle import Vehicle, VehicleDefaults +from nextmv.base_model import BaseModel +from nextmv.nextroute.schema.stop import AlternateStop, Stop, StopDefaults +from nextmv.nextroute.schema.vehicle import Vehicle, VehicleDefaults -@dataclass -class Defaults(_Base): +class Defaults(BaseModel): """Default values for vehicles and stops.""" stops: StopDefaults | None = None @@ -18,8 +16,7 @@ class Defaults(_Base): """Default values for vehicles.""" -@dataclass -class DurationGroup(_Base): +class DurationGroup(BaseModel): """Represents a group of stops that get additional duration whenever a stop of the group is approached for the first time.""" @@ -29,8 +26,7 @@ class DurationGroup(_Base): """Stop IDs contained in the group.""" -@dataclass -class Input(_Base): +class Input(BaseModel): """Input schema for Nextroute.""" stops: list[Stop] diff --git a/nextmv/nextroute/schema/location.py b/nextmv/nextroute/schema/location.py index a2f7360..015c389 100644 --- a/nextmv/nextroute/schema/location.py +++ b/nextmv/nextroute/schema/location.py @@ -1,13 +1,10 @@ """Defines the location class.""" -from dataclasses import dataclass +from nextmv.base_model import BaseModel -from .base import _Base - -@dataclass -class Location(_Base): +class Location(BaseModel): """Location represents a geographical location.""" lon: float diff --git a/nextmv/nextroute/schema/stop.py b/nextmv/nextroute/schema/stop.py index 5ddd396..daf24ca 100644 --- a/nextmv/nextroute/schema/stop.py +++ b/nextmv/nextroute/schema/stop.py @@ -1,16 +1,14 @@ """Defines the stop class.""" -from dataclasses import dataclass from datetime import datetime from typing import Any -from .base import _Base -from .location import Location +from nextmv.base_model import BaseModel +from nextmv.nextroute.schema.location import Location -@dataclass -class StopDefaults(_Base): +class StopDefaults(BaseModel): """Default values for a stop.""" compatibility_attributes: list[str] | None = None @@ -33,7 +31,6 @@ class StopDefaults(_Base): """Penalty for not planning a stop.""" -@dataclass(kw_only=True) class Stop(StopDefaults): """Stop is a location that must be visited by a vehicle in a Vehicle Routing Problem (VRP.)""" @@ -53,7 +50,6 @@ class Stop(StopDefaults): """Stops that must be visited before this one on the same route.""" -@dataclass(kw_only=True) class AlternateStop(StopDefaults): """An alternate stop can be serviced instead of another stop.""" diff --git a/nextmv/nextroute/schema/vehicle.py b/nextmv/nextroute/schema/vehicle.py index dddad9e..eae9873 100644 --- a/nextmv/nextroute/schema/vehicle.py +++ b/nextmv/nextroute/schema/vehicle.py @@ -1,16 +1,14 @@ """Defines the vehicle class.""" -from dataclasses import dataclass from datetime import datetime from typing import Any -from .base import _Base -from .location import Location +from nextmv.base_model import BaseModel +from nextmv.nextroute.schema.location import Location -@dataclass -class InitialStop(_Base): +class InitialStop(BaseModel): """Represents a stop that is already planned on a vehicle.""" id: str @@ -20,8 +18,7 @@ class InitialStop(_Base): """Whether the stop is fixed on the route.""" -@dataclass -class VehicleDefaults(_Base): +class VehicleDefaults(BaseModel): """Default values for vehicles.""" activation_penalty: int | None = None @@ -58,7 +55,6 @@ class VehicleDefaults(_Base): """Time when the vehicle starts its route.""" -@dataclass(kw_only=True) class Vehicle(VehicleDefaults): """A vehicle services stops in a Vehicle Routing Problem (VRP).""" diff --git a/requirements.txt b/requirements.txt index b3485fe..263bed1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,5 @@ +pydantic==2.5.2 +requests==2.31.0 ruff==0.1.7 setuptools==69.0.2 wheel==0.42.0 diff --git a/tests/nextroute/schema/test_input.py b/tests/nextroute/schema/test_input.py index 51e3ae4..0906a2d 100644 --- a/tests/nextroute/schema/test_input.py +++ b/tests/nextroute/schema/test_input.py @@ -2,20 +2,56 @@ import os import unittest -from nextmv.nextroute.schema import Input +from nextmv.nextroute.schema import Input, Stop, Vehicle class TestInput(unittest.TestCase): + filepath = os.path.join(os.path.dirname(__file__), "input.json") + def test_from_json(self): - filepath = os.path.join(os.path.dirname(__file__), "input.json") - input = Input.from_json(filepath) - parsed = input.to_dict() - with open(filepath) as f: - expected = json.load(f) + with open(self.filepath) as f: + json_data = json.load(f) + + nextroute_input = Input.from_dict(json_data) + parsed = nextroute_input.to_dict() + + for s, stop in enumerate(parsed["stops"]): + original_stop = json_data["stops"][s] + self.assertEqual( + stop, + original_stop, + f"stop: parsed({stop}) and original ({original_stop}) should be equal", + ) + + for v, vehicle in enumerate(parsed["vehicles"]): + original_vehicle = json_data["vehicles"][v] + self.assertEqual( + vehicle, + original_vehicle, + f"vehicle: parsed ({vehicle}) and original ({original_vehicle}) should be equal", + ) - self.maxDiff = None self.assertEqual( - parsed, - expected, - "Parsing the JSON into the class and back should yield the same result.", + parsed["defaults"], + json_data["defaults"], + f"defaults: parsed ({parsed['defaults']}) and original ({json_data['defaults']}) should be equal", ) + + def test_from_dict(self): + with open(self.filepath) as f: + json_data = json.load(f) + + nextroute_input = Input.from_dict(json_data) + stops = nextroute_input.stops + for stop in stops: + self.assertTrue( + isinstance(stop, Stop), + f"Stop ({stop}) should be of type Stop.", + ) + + vehicles = nextroute_input.vehicles + for vehicle in vehicles: + self.assertTrue( + isinstance(vehicle, Vehicle), + f"Vehicle ({vehicle}) should be of type Vehicle.", + ) diff --git a/tests/test_base_model.py b/tests/test_base_model.py new file mode 100644 index 0000000..2e7c552 --- /dev/null +++ b/tests/test_base_model.py @@ -0,0 +1,61 @@ +import unittest + +from nextmv.base_model import BaseModel + + +class Foo(BaseModel): + bar: str + baz: int | None = None + + +class Roh(BaseModel): + foo: Foo + qux: list[str] | None = None + lorem: str | None = None + + +class TestBaseModel(unittest.TestCase): + valid_dict = { + "foo": { + "bar": "bar", + "baz": 1, + }, + "qux": ["qux"], + "lorem": "lorem", + } + + def test_from_dict(self): + roh = Roh.from_dict(self.valid_dict) + self.assertTrue(isinstance(roh, Roh)) + self.assertTrue(isinstance(roh.foo, Foo)) + + def test_change_attributes(self): + roh = Roh.from_dict(self.valid_dict) + self.assertEqual(roh.foo.bar, "bar") + + different_str = "different_str" + roh.foo.bar = different_str + self.assertEqual(roh.foo.bar, different_str) + + def test_invalid_dict(self): + invalid = { + "foo": { + "bar": "bar", + "baz": "1", + }, + "qux": "qux", + "lorem": "lorem", + } + with self.assertRaises(ValueError): + Roh.from_dict(invalid) + + def test_to_dict(self): + some_none = { + "foo": { + "bar": "bar", + }, + "lorem": "lorem", + } + roh = Roh.from_dict(some_none) + parsed = roh.to_dict() + self.assertEqual(parsed, some_none)