Skip to content

Commit

Permalink
[PT-5055] Processing cost evaluation (#624)
Browse files Browse the repository at this point in the history
* Processing cost evaluation

* Clean up

* Simplify job templates

* Drop unneeded property
  • Loading branch information
javidq authored Jun 7, 2024
1 parent e7b13d6 commit e5f34e2
Show file tree
Hide file tree
Showing 4 changed files with 139 additions and 17 deletions.
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ You can check your current version with the following command:
```

For more information, see [UP42 Python package description](https://pypi.org/project/up42-py/).
## 1.0.4a9

**Jun 7, 2024**

- Added cost evaluation to `JobTemplate` class with number comparison support.

## 1.0.4a8

**Jun 6, 2024**
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "up42-py"
version = "1.0.4a8"
version = "1.0.4a9"
description = "Python SDK for UP42, the geospatial marketplace and developer platform."
authors = ["UP42 GmbH <[email protected]>"]
license = "https://github.com/up42/up42-py/blob/master/LICENSE"
Expand Down
95 changes: 84 additions & 11 deletions tests/test_processing.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,19 @@
import dataclasses
import random
from typing import List
from unittest import mock

import pystac
import pytest
import requests
import requests_mock as req_mock

from tests import helpers
from tests.fixtures import fixtures_globals as constants
from up42 import processing

from . import helpers
from .fixtures import fixtures_globals as constants

PROCESS_ID = "process-id"
VALIDATION_URL = f"{constants.API_HOST}/v2/processing/processes/{PROCESS_ID}/validation"
COST_URL = f"{constants.API_HOST}/v2/processing/processes/{PROCESS_ID}/cost"
TITLE = "title"
ITEM_URL = "https://item-url"
ITEM = pystac.Item.from_dict(
Expand All @@ -41,6 +40,19 @@ def workspace():
yield


class TestCost:
@pytest.mark.parametrize("high_value", [11, 11.0, processing.Cost(strategy="strategy", credits=11)])
@pytest.mark.parametrize("low_value", [9, 9.0, processing.Cost(strategy="strategy", credits=9)])
def test_should_compare_with_numbers_and_costs_using_credits(
self,
high_value: processing.CostType,
low_value: processing.CostType,
):
cost = processing.Cost(strategy="strategy", credits=10)
assert cost > low_value and cost >= low_value
assert cost < high_value and cost <= high_value


@dataclasses.dataclass
class SampleJobTemplate(processing.JobTemplate):
title: str
Expand All @@ -62,6 +74,7 @@ def test_fails_to_construct_if_validation_fails(self, requests_mock: req_mock.Mo
with pytest.raises(requests.exceptions.HTTPError) as error:
_ = SampleJobTemplate(title=TITLE)
assert error.value.response.status_code == error_code
assert requests_mock.call_count == 1

def test_should_be_invalid_if_inputs_are_malformed(self, requests_mock: req_mock.Mocker):
error = processing.ValidationError(name="InvalidSchema", message="data.inputs must contain ['item'] properties")
Expand Down Expand Up @@ -91,52 +104,112 @@ def test_should_be_invalid_if_inputs_are_invalid(self, requests_mock: req_mock.M
assert not template.is_valid
assert template.errors == {error}

def test_should_construct(self, requests_mock: req_mock.Mocker):
def test_fails_to_construct_if_evaluation_fails(self, requests_mock: req_mock.Mocker):
error_code = random.randint(400, 599)
requests_mock.post(
VALIDATION_URL,
status_code=200,
additional_matcher=helpers.match_request_body({"inputs": {"title": TITLE}}),
)
requests_mock.post(
COST_URL,
status_code=error_code,
additional_matcher=helpers.match_request_body({"inputs": {"title": TITLE}}),
)

with pytest.raises(requests.exceptions.HTTPError) as error:
_ = SampleJobTemplate(title=TITLE)
assert error.value.response.status_code == error_code
assert requests_mock.call_count == 2

@pytest.mark.parametrize(
"cost",
[
processing.Cost(strategy="none", credits=1),
processing.Cost(strategy="area", credits=1, size=5, unit="SKM"),
],
)
def test_should_construct(self, requests_mock: req_mock.Mocker, cost: processing.Cost):
requests_mock.post(
VALIDATION_URL,
status_code=200,
additional_matcher=helpers.match_request_body({"inputs": {"title": TITLE}}),
)
cost_payload = {
key: value
for key, value in {
"pricingStrategy": cost.strategy,
"totalCredits": cost.credits,
"totalSize": cost.size,
"unit": cost.unit,
}.items()
if value
}
requests_mock.post(
COST_URL,
status_code=200,
json=cost_payload,
additional_matcher=helpers.match_request_body({"inputs": {"title": TITLE}}),
)
template = SampleJobTemplate(title=TITLE)
assert template.is_valid
assert not template.errors
assert template.cost == cost


@dataclasses.dataclass
class SampleSingleItemJobTemplate(processing.SingleItemJobTemplate):
item: pystac.Item
title: str
process_id = PROCESS_ID


class TestSingleItemJobTemplate:
def test_should_provide_inputs(self, requests_mock: req_mock.Mocker):
cost = processing.Cost(strategy="discount", credits=-1)
body_matcher = helpers.match_request_body({"inputs": {"title": TITLE, "item": ITEM_URL}})
requests_mock.post(
VALIDATION_URL,
status_code=200,
additional_matcher=helpers.match_request_body({"inputs": {"title": TITLE, "item": ITEM_URL}}),
additional_matcher=body_matcher,
)
requests_mock.post(
COST_URL,
status_code=200,
json={"pricingStrategy": cost.strategy, "totalCredits": cost.credits},
additional_matcher=body_matcher,
)
template = SampleSingleItemJobTemplate(
item=ITEM,
title=TITLE,
)
assert template.is_valid
assert template.cost == cost
assert template.inputs == {"title": TITLE, "item": ITEM_URL}


@dataclasses.dataclass
class SampleMultiItemJobTemplate(processing.MultiItemJobTemplate):
items: List[pystac.Item]
title: str
process_id = PROCESS_ID


class TestMultiItemJobTemplate:
def test_should_provide_inputs(self, requests_mock: req_mock.Mocker):
requests_mock.post(VALIDATION_URL, status_code=200)
cost = processing.Cost(strategy="discount", credits=-1)
body_matcher = helpers.match_request_body({"inputs": {"title": TITLE, "items": [ITEM_URL]}})
requests_mock.post(
VALIDATION_URL,
status_code=200,
additional_matcher=body_matcher,
)
requests_mock.post(
COST_URL,
status_code=200,
json={"pricingStrategy": cost.strategy, "totalCredits": cost.credits},
additional_matcher=body_matcher,
)
template = SampleMultiItemJobTemplate(
items=[ITEM],
title=TITLE,
)
assert template.is_valid
assert template.cost == cost
assert template.inputs == {"title": TITLE, "items": [ITEM_URL]}
53 changes: 48 additions & 5 deletions up42/processing.py
Original file line number Diff line number Diff line change
@@ -1,23 +1,51 @@
import abc
import dataclasses
from typing import List, Union
from typing import ClassVar, List, Optional, Union

import pystac
import requests

from up42 import base, host


@dataclasses.dataclass(eq=True, frozen=True)
@dataclasses.dataclass(frozen=True)
class ValidationError:
message: str
name: str


CostType = Union[int, float, "Cost"]


@dataclasses.dataclass(frozen=True)
class Cost:
strategy: str
credits: int
size: Optional[int] = None
unit: Optional[str] = None

def __le__(self, other: CostType):
if isinstance(other, Cost):
return self.credits <= other.credits
else:
return self.credits <= other

def __lt__(self, other: CostType):
if isinstance(other, Cost):
return self.credits < other.credits
else:
return self.credits < other

def __ge__(self, other: CostType):
return not self < other

def __gt__(self, other: CostType):
return not self <= other


class JobTemplate:
session = base.Session()
process_id: str
title: str
process_id: ClassVar[str]
workspace_id: Union[str, base.WorkspaceId]
errors: set[ValidationError] = set()

Expand All @@ -28,7 +56,8 @@ def inputs(self) -> dict:

def __post_init__(self):
self.__validate()
# TODO: compute cost if valid
if self.is_valid:
self.__evaluate()

def __validate(self) -> None:
url = host.endpoint(f"/v2/processing/processes/{self.process_id}/validation")
Expand All @@ -49,22 +78,36 @@ def __validate(self) -> None:
else:
raise err

def __evaluate(self):
url = host.endpoint(f"/v2/processing/processes/{self.process_id}/cost")
payload = self.session.post(url, json={"inputs": self.inputs}).json()
self.cost = Cost(
strategy=payload["pricingStrategy"],
credits=payload["totalCredits"],
size=payload.get("totalSize"),
unit=payload.get("unit"),
)

@property
def is_valid(self) -> bool:
return not self.errors

# TODO: def create_job(self) -> Job:


@dataclasses.dataclass
class SingleItemJobTemplate(JobTemplate):
title: str
item: pystac.Item

@property
def inputs(self) -> dict:
return {"title": self.title, "item": self.item.get_self_href()}


@dataclasses.dataclass
class MultiItemJobTemplate(JobTemplate):
title: str
items: List[pystac.Item]

@property
Expand Down

0 comments on commit e5f34e2

Please sign in to comment.