pyardent 1.0.0__py3-none-any.whl
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.
- pyardent/__init__.py +10 -0
- pyardent/client.py +95 -0
- pyardent/exceptions.py +56 -0
- pyardent/models/__init__.py +7 -0
- pyardent/models/commodity.py +320 -0
- pyardent/models/market.py +82 -0
- pyardent/models/meta.py +137 -0
- pyardent/models/station.py +218 -0
- pyardent/models/system.py +376 -0
- pyardent/modules/__init__.py +6 -0
- pyardent/modules/commodity.py +67 -0
- pyardent/modules/meta.py +65 -0
- pyardent/modules/station.py +69 -0
- pyardent/modules/system.py +96 -0
- pyardent/py.typed +0 -0
- pyardent/types.py +29 -0
- pyardent-1.0.0.dist-info/METADATA +125 -0
- pyardent-1.0.0.dist-info/RECORD +20 -0
- pyardent-1.0.0.dist-info/WHEEL +4 -0
- pyardent-1.0.0.dist-info/licenses/LICENSE.md +21 -0
pyardent/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
from .client import ArdentClient
|
|
4
|
+
from .exceptions import PyArdentError, ResourceNotFoundError, CommodityNotFoundError, SystemNotFoundError, ServiceNotFoundError
|
|
5
|
+
from .types import StationServices, LandingPad
|
|
6
|
+
from .models import System, Station, Commodity, CommodityMarket
|
|
7
|
+
|
|
8
|
+
logging.getLogger("pyardent").addHandler(logging.NullHandler())
|
|
9
|
+
|
|
10
|
+
__all__ = ['ArdentClient', 'ResourceNotFoundError', 'CommodityNotFoundError', 'SystemNotFoundError', 'ServiceNotFoundError', 'PyArdentError', "StationServices", "LandingPad", "System", "Station", "Commodity", "CommodityMarket"]
|
pyardent/client.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Top-level client for the Ardent Insight API.
|
|
2
|
+
|
|
3
|
+
Wires up an `httpx.Client` shared by all `*Module` instances and installs the
|
|
4
|
+
response hook that translates HTTP error responses into `PyArdentError`
|
|
5
|
+
subclasses.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
from .modules import MetaModule, CommodityModule, SystemModule
|
|
12
|
+
from .exceptions import PyArdentError, ResourceNotFoundError, CommodityNotFoundError, SystemNotFoundError, \
|
|
13
|
+
ServiceNotFoundError
|
|
14
|
+
from .modules.station import StationModule
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger("pyardent.client")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _handle_response_errors(response: httpx.Response):
|
|
20
|
+
"""Translate failed HTTP responses into `PyArdentError` subclasses.
|
|
21
|
+
|
|
22
|
+
Registered as an `httpx.Client` "response" event hook, so it runs on
|
|
23
|
+
every request made through `ArdentClient`. Successful responses pass
|
|
24
|
+
through unchanged.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
response: The `httpx.Response` returned by the underlying request.
|
|
28
|
+
|
|
29
|
+
Raises:
|
|
30
|
+
CommodityNotFoundError: On a 404 whose API error message mentions a commodity.
|
|
31
|
+
SystemNotFoundError: On a 404 whose API error message mentions a system.
|
|
32
|
+
ServiceNotFoundError: On a 404 whose API error message mentions a service.
|
|
33
|
+
ResourceNotFoundError: On a 404 with no recognizable error message.
|
|
34
|
+
PyArdentError: On any other HTTP error status, a 404 with an
|
|
35
|
+
unrecognized message, or a network-level error.
|
|
36
|
+
"""
|
|
37
|
+
response.read()
|
|
38
|
+
try:
|
|
39
|
+
response.raise_for_status()
|
|
40
|
+
except httpx.HTTPStatusError as e:
|
|
41
|
+
|
|
42
|
+
logger.error(f"HTTP error {e.response.status_code} for URL: {response.url}")
|
|
43
|
+
|
|
44
|
+
if e.response.status_code == 404:
|
|
45
|
+
error_payload = e.response.json()
|
|
46
|
+
api_msg = error_payload.get("message")
|
|
47
|
+
if api_msg:
|
|
48
|
+
if "commodity" in api_msg.lower():
|
|
49
|
+
raise CommodityNotFoundError(str(response.url)) from e
|
|
50
|
+
elif "system" in api_msg.lower():
|
|
51
|
+
raise SystemNotFoundError(str(response.url)) from e
|
|
52
|
+
elif "service" in api_msg.lower():
|
|
53
|
+
raise ServiceNotFoundError(str(response.url)) from e
|
|
54
|
+
else:
|
|
55
|
+
raise PyArdentError(api_msg) from e
|
|
56
|
+
raise ResourceNotFoundError(str(response.url)) from e
|
|
57
|
+
else:
|
|
58
|
+
raise PyArdentError(f"HTTP error {e.response.status_code} for URL: {response.url}") from e
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class ArdentClient:
|
|
62
|
+
"""Entry point for the Ardent Insight API client.
|
|
63
|
+
|
|
64
|
+
Holds the shared `httpx.Client` and exposes one module per API resource
|
|
65
|
+
area (`meta`, `commodity`, `system`, `station`). Methods on those modules
|
|
66
|
+
return rich pydantic models (e.g. `System`, `Station`, `Commodity`,
|
|
67
|
+
`CommodityMarket`) that carry a reference back to this client so they can
|
|
68
|
+
make further API calls themselves (e.g. `system.get_stations()`).
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
DEFAULT_BASE_URL = "https://api.ardent-insight.com/v2"
|
|
72
|
+
_client: httpx.Client
|
|
73
|
+
|
|
74
|
+
meta: MetaModule
|
|
75
|
+
commodity: CommodityModule
|
|
76
|
+
system: SystemModule
|
|
77
|
+
station: StationModule
|
|
78
|
+
|
|
79
|
+
def __init__(self, base_url: str | None = None):
|
|
80
|
+
"""Create a client and its underlying HTTP session.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
base_url: Override for the API base URL. Defaults to
|
|
84
|
+
`ArdentClient.DEFAULT_BASE_URL` when omitted.
|
|
85
|
+
"""
|
|
86
|
+
self._base_url = base_url or self.DEFAULT_BASE_URL
|
|
87
|
+
|
|
88
|
+
self._client = httpx.Client(base_url=self._base_url, event_hooks={"response": [_handle_response_errors, ]})
|
|
89
|
+
|
|
90
|
+
self.meta = MetaModule(self._client)
|
|
91
|
+
self.commodity = CommodityModule(self._client)
|
|
92
|
+
self.system = SystemModule(self._client)
|
|
93
|
+
self.station = StationModule(self._client)
|
|
94
|
+
|
|
95
|
+
logger.info(f"Initialized ArdentClient pointing to {self._base_url}")
|
pyardent/exceptions.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Exception hierarchy raised by pyardent.
|
|
2
|
+
|
|
3
|
+
All errors raised by the client are `PyArdentError` or one of its subclasses
|
|
4
|
+
below, translated from HTTP responses by `client._handle_response_errors`.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class PyArdentError(Exception):
|
|
9
|
+
"""Base class for all errors raised by this library.
|
|
10
|
+
|
|
11
|
+
Args:
|
|
12
|
+
message: Human-readable description of the failure.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
def __init__(self, message: str):
|
|
16
|
+
super().__init__(message)
|
|
17
|
+
|
|
18
|
+
class ResourceNotFoundError(PyArdentError):
|
|
19
|
+
"""Raised on a 404 whose API error message didn't match a more specific case.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
url: The request URL that returned the 404.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, url: str):
|
|
26
|
+
super().__init__(f"Resource not found at endpoint: {url}")
|
|
27
|
+
|
|
28
|
+
class CommodityNotFoundError(PyArdentError):
|
|
29
|
+
"""Raised when the requested commodity does not exist.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
url: The request URL that returned the 404.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(self, url: str):
|
|
36
|
+
super().__init__(f"Commodity does not exist. Endpoint: {url}")
|
|
37
|
+
|
|
38
|
+
class SystemNotFoundError(PyArdentError):
|
|
39
|
+
"""Raised when the requested system does not exist.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
url: The request URL that returned the 404.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(self, url: str):
|
|
46
|
+
super().__init__(f"System does not exist. Endpoint: {url}")
|
|
47
|
+
|
|
48
|
+
class ServiceNotFoundError(PyArdentError):
|
|
49
|
+
"""Raised when the requested station service type is unknown to the API.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
url: The request URL that returned the 404.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def __init__(self, url: str):
|
|
56
|
+
super().__init__(f"Service does not exist. Endpoint: {url}")
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
from .meta import APIVersion, APIStats, APIEconomies, APIStations
|
|
2
|
+
from .commodity import Commodity
|
|
3
|
+
from .system import System
|
|
4
|
+
from .station import Station
|
|
5
|
+
from .market import CommodityMarket
|
|
6
|
+
|
|
7
|
+
__all__ = ["APIVersion", "APIStats", "APIEconomies", "APIStations", "Commodity", "System", "Station", "CommodityMarket"]
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
"""Models for the API's commodity resource (`/commodity/...`).
|
|
2
|
+
|
|
3
|
+
`Commodity` is a graph-traversal node, not a flat DTO: every `get_*` method
|
|
4
|
+
makes a further API call rooted at `self.commodity_name` and returns other
|
|
5
|
+
rich models (`Station`, `CommodityMarket`).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
from pydantic import BaseModel, ConfigDict, PrivateAttr, Field
|
|
14
|
+
from pydantic.alias_generators import to_camel
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from .station import Station
|
|
18
|
+
from .market import CommodityMarket
|
|
19
|
+
from .system import System
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger("pyardent.models.commodity")
|
|
22
|
+
|
|
23
|
+
class CommodityData(BaseModel):
|
|
24
|
+
"""Plain fields of a commodity summary report, as returned by the API.
|
|
25
|
+
|
|
26
|
+
The `min`/`max`/`avg`/`total` price and stock/demand fields are
|
|
27
|
+
aggregates across every known market, excluding fleet carriers. Split
|
|
28
|
+
from `Commodity` so the rich subclass can carry a `_client` reference
|
|
29
|
+
without it leaking into the field-validation schema.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
|
|
33
|
+
|
|
34
|
+
commodity_name: str
|
|
35
|
+
rare: bool = False
|
|
36
|
+
rare_station_id: int | None = Field(alias="rareMarketId", default=None)
|
|
37
|
+
rare_max_count: int | None = None
|
|
38
|
+
"""For rare commodities, the cargo quantity threshold beyond which
|
|
39
|
+
selling more at once starts degrading the price."""
|
|
40
|
+
min_buy_price: int | None = None
|
|
41
|
+
max_buy_price: int | None = None
|
|
42
|
+
avg_buy_price: int | None = None
|
|
43
|
+
total_stock: int | None = None
|
|
44
|
+
min_sell_price: int | None = None
|
|
45
|
+
max_sell_price: int | None = None
|
|
46
|
+
avg_sell_price: int | None = None
|
|
47
|
+
total_demand: int | None = None
|
|
48
|
+
timestamp: datetime
|
|
49
|
+
|
|
50
|
+
class Commodity(CommodityData):
|
|
51
|
+
"""A tradeable commodity, with methods to find where it's bought, sold, or made."""
|
|
52
|
+
|
|
53
|
+
_client: httpx.Client = PrivateAttr()
|
|
54
|
+
|
|
55
|
+
@classmethod
|
|
56
|
+
def from_json(cls, client: httpx.Client, payload: dict) -> "Commodity":
|
|
57
|
+
"""Construct a `Commodity` from a JSON payload and attach the client.
|
|
58
|
+
|
|
59
|
+
This is the only supported way to build a `Commodity`: constructing
|
|
60
|
+
one via `model_validate` directly would leave `_client` unset,
|
|
61
|
+
breaking every `get_*` method on the instance.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
client: The shared `httpx.Client` to attach for further requests.
|
|
65
|
+
payload: A single commodity object as returned by the API.
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
The constructed, traversable `Commodity`.
|
|
69
|
+
"""
|
|
70
|
+
instance = cls.model_validate(payload)
|
|
71
|
+
instance._client = client
|
|
72
|
+
return instance
|
|
73
|
+
|
|
74
|
+
def get_rare_station(self) -> "Station | None":
|
|
75
|
+
"""Fetch the station where this rare commodity is sold, if it is one.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
The rare commodity's home station, or None if this commodity
|
|
79
|
+
isn't rare (or has no recorded home station).
|
|
80
|
+
"""
|
|
81
|
+
if not self.rare or self.rare_station_id is None:
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
from .station import Station
|
|
85
|
+
logger.debug(f"GET /market/{self.rare_station_id}")
|
|
86
|
+
response = self._client.get(f"/market/{self.rare_station_id}")
|
|
87
|
+
station = response.json()
|
|
88
|
+
return Station.from_json(self._client, station)
|
|
89
|
+
|
|
90
|
+
def get_importers(self, min_volume: int = 1, min_price: int = 1, fleet_carriers: bool | None = None, max_days_ago: int = 30) -> list["CommodityMarket"]:
|
|
91
|
+
"""Fetch places importing this commodity — places you can sell to.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
min_volume: Minimum demand to include. Demand of 0 means
|
|
95
|
+
infinite demand and is always included.
|
|
96
|
+
min_price: Minimum sell price to include.
|
|
97
|
+
fleet_carriers: If True, only fleet carriers; if False, exclude
|
|
98
|
+
them; if None (default), include all station types.
|
|
99
|
+
max_days_ago: Exclude trade data older than this many days.
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
Up to 100 matching import orders, ordered by highest price
|
|
103
|
+
(the best place to sell first).
|
|
104
|
+
|
|
105
|
+
Raises:
|
|
106
|
+
ValueError: If `min_volume`, `min_price`, or `max_days_ago` is negative.
|
|
107
|
+
"""
|
|
108
|
+
if min_volume < 0:
|
|
109
|
+
raise ValueError(f"min_volume cannot be negative. received: {min_volume}")
|
|
110
|
+
if min_price < 0:
|
|
111
|
+
raise ValueError(f"min_price cannot be negative. received: {min_price}")
|
|
112
|
+
if max_days_ago < 0:
|
|
113
|
+
raise ValueError(f"max_days_ago cannot be negative. received: {max_days_ago}")
|
|
114
|
+
|
|
115
|
+
from .market import CommodityMarket
|
|
116
|
+
logger.debug(f"GET /commodity/name/{self.commodity_name}/imports")
|
|
117
|
+
|
|
118
|
+
response = self._client.get(f"/commodity/name/{self.commodity_name}/imports", params={
|
|
119
|
+
"minVolume": min_volume,
|
|
120
|
+
"minPrice": min_price,
|
|
121
|
+
"fleetCarriers": fleet_carriers,
|
|
122
|
+
"maxDaysAgo": max_days_ago,
|
|
123
|
+
})
|
|
124
|
+
importers = response.json()
|
|
125
|
+
logger.debug(f"Parsing {len(importers)} importers")
|
|
126
|
+
return [CommodityMarket.from_json(self._client, importer) for importer in importers]
|
|
127
|
+
|
|
128
|
+
def get_exporters(self, min_volume: int = 1, max_price: int | None = None, fleet_carriers: bool | None = None, max_days_ago: int = 30) -> list["CommodityMarket"]:
|
|
129
|
+
"""Fetch places exporting this commodity — places you can buy from.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
min_volume: Minimum stock to include.
|
|
133
|
+
max_price: If given, exclude entries with a buy price above this.
|
|
134
|
+
fleet_carriers: If True, only fleet carriers; if False, exclude
|
|
135
|
+
them; if None (default), include all station types.
|
|
136
|
+
max_days_ago: Exclude trade data older than this many days.
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
Up to 100 matching export orders, ordered by lowest price
|
|
140
|
+
(the best place to buy first).
|
|
141
|
+
|
|
142
|
+
Raises:
|
|
143
|
+
ValueError: If `min_volume` or `max_price` is negative, or if
|
|
144
|
+
`max_days_ago` is negative.
|
|
145
|
+
"""
|
|
146
|
+
if min_volume < 0:
|
|
147
|
+
raise ValueError(f"min_volume cannot be negative. received: {min_volume}")
|
|
148
|
+
if max_price and max_price < 0:
|
|
149
|
+
raise ValueError(f"max_price cannot be negative. received: {max_price}")
|
|
150
|
+
if max_days_ago < 0:
|
|
151
|
+
raise ValueError(f"max_days_ago cannot be negative. received: {max_days_ago}")
|
|
152
|
+
|
|
153
|
+
from .market import CommodityMarket
|
|
154
|
+
logger.debug(f"GET /commodity/name/{self.commodity_name}/exports")
|
|
155
|
+
if max_price is None:
|
|
156
|
+
params = {
|
|
157
|
+
"minVolume": min_volume,
|
|
158
|
+
"fleetCarriers": fleet_carriers,
|
|
159
|
+
"maxDaysAgo": max_days_ago,
|
|
160
|
+
}
|
|
161
|
+
else:
|
|
162
|
+
params = {
|
|
163
|
+
"minVolume": min_volume,
|
|
164
|
+
"maxPrice": max_price,
|
|
165
|
+
"fleetCarriers": fleet_carriers,
|
|
166
|
+
"maxDaysAgo": max_days_ago,
|
|
167
|
+
}
|
|
168
|
+
response = self._client.get(f"/commodity/name/{self.commodity_name}/exports", params=params)
|
|
169
|
+
exporters = response.json()
|
|
170
|
+
logger.debug(f"Parsing {len(exporters)} exporters")
|
|
171
|
+
return [CommodityMarket.from_json(self._client, exporter) for exporter in exporters]
|
|
172
|
+
|
|
173
|
+
def get_system_market(self, system: "System", max_days_ago: int = 30) -> list["CommodityMarket"]:
|
|
174
|
+
"""Fetch all buy/sell orders for this commodity across stations in a system.
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
system: The system to look up.
|
|
178
|
+
max_days_ago: Exclude trade data older than this many days.
|
|
179
|
+
|
|
180
|
+
Returns:
|
|
181
|
+
One `CommodityMarket` entry per station in `system` trading this
|
|
182
|
+
commodity (empty if none do).
|
|
183
|
+
|
|
184
|
+
Raises:
|
|
185
|
+
ValueError: If `max_days_ago` is negative.
|
|
186
|
+
"""
|
|
187
|
+
if max_days_ago < 0:
|
|
188
|
+
raise ValueError(f"max_days_ago cannot be negative. received: {max_days_ago}")
|
|
189
|
+
|
|
190
|
+
from .market import CommodityMarket
|
|
191
|
+
logger.debug(f"GET /system/address/{system.system_address}/commodity/name/{self.commodity_name}")
|
|
192
|
+
response = self._client.get(f"/system/address/{system.system_address}/commodity/name/{self.commodity_name}", params={
|
|
193
|
+
"maxDaysAgo": max_days_ago,
|
|
194
|
+
})
|
|
195
|
+
commodities = response.json()
|
|
196
|
+
logger.debug(f"Parsing {len(commodities)} commodities")
|
|
197
|
+
return [
|
|
198
|
+
CommodityMarket.from_json(self._client, entry) for entry in commodities
|
|
199
|
+
]
|
|
200
|
+
|
|
201
|
+
def get_nearby_importers(self, system: "System", min_volume: int = 1, min_price: int = 1, fleet_carriers: bool | None = None, max_distance: int = 100, max_days_ago: int = 30) -> list["CommodityMarket"]:
|
|
202
|
+
"""Fetch nearby places importing this commodity — places you can sell to near a system.
|
|
203
|
+
|
|
204
|
+
Args:
|
|
205
|
+
system: The system to search around.
|
|
206
|
+
min_volume: Minimum demand to include. Demand of 0 means
|
|
207
|
+
infinite demand and is always included.
|
|
208
|
+
min_price: Minimum sell price to include.
|
|
209
|
+
fleet_carriers: If True, only fleet carriers; if False, exclude
|
|
210
|
+
them; if None (default), include all station types.
|
|
211
|
+
max_distance: Search radius in light-years from `system`. Must
|
|
212
|
+
be between 0 and 500 (the API's own cap).
|
|
213
|
+
max_days_ago: Exclude trade data older than this many days.
|
|
214
|
+
|
|
215
|
+
Returns:
|
|
216
|
+
Up to 1000 matching import orders, ordered by highest price
|
|
217
|
+
(the best place to sell first).
|
|
218
|
+
|
|
219
|
+
Raises:
|
|
220
|
+
ValueError: If `min_volume` or `min_price` is negative, if
|
|
221
|
+
`max_distance` is outside `[0, 500]`, or if `max_days_ago`
|
|
222
|
+
is negative.
|
|
223
|
+
"""
|
|
224
|
+
if min_volume < 0:
|
|
225
|
+
raise ValueError(f"min_volume cannot be negative. received: {min_volume}")
|
|
226
|
+
if min_price < 0:
|
|
227
|
+
raise ValueError(f"min_price cannot be negative. received: {min_price}")
|
|
228
|
+
if max_distance < 0 or max_distance > 500:
|
|
229
|
+
raise ValueError(f"max_distance cannot be negative or greater than 500. received: {max_distance}")
|
|
230
|
+
if max_days_ago < 0:
|
|
231
|
+
raise ValueError(f"max_days_ago cannot be negative. received: {max_days_ago}")
|
|
232
|
+
|
|
233
|
+
from .market import CommodityMarket
|
|
234
|
+
logger.debug(f"GET /system/address/{system.system_address}/commodity/name/{self.commodity_name}/nearby/imports")
|
|
235
|
+
response = self._client.get(f"/system/address/{system.system_address}/commodity/name/{self.commodity_name}/nearby/imports",
|
|
236
|
+
params={
|
|
237
|
+
"minVolume": min_volume,
|
|
238
|
+
"minPrice": min_price,
|
|
239
|
+
"fleetCarriers": fleet_carriers,
|
|
240
|
+
"maxDistance": max_distance,
|
|
241
|
+
"maxDaysAgo": max_days_ago,
|
|
242
|
+
})
|
|
243
|
+
commodities = response.json()
|
|
244
|
+
logger.debug(f"Parsing {len(commodities)} commodities")
|
|
245
|
+
return [
|
|
246
|
+
CommodityMarket.from_json(self._client, entry) for entry in commodities
|
|
247
|
+
]
|
|
248
|
+
|
|
249
|
+
def get_nearby_exporters(self, system: "System", min_volume: int = 1, max_price: int | None = None,
|
|
250
|
+
fleet_carriers: bool | None = None, max_distance: int = 100, max_days_ago: int = 30) -> \
|
|
251
|
+
list["CommodityMarket"]:
|
|
252
|
+
"""Fetch nearby places exporting this commodity — places you can buy from near a system.
|
|
253
|
+
|
|
254
|
+
Args:
|
|
255
|
+
system: The system to search around.
|
|
256
|
+
min_volume: Minimum stock to include.
|
|
257
|
+
max_price: If given, exclude entries with a buy price above this.
|
|
258
|
+
fleet_carriers: If True, only fleet carriers; if False, exclude
|
|
259
|
+
them; if None (default), include all station types.
|
|
260
|
+
max_distance: Search radius in light-years from `system`. Must
|
|
261
|
+
be between 0 and 500 (the API's own cap).
|
|
262
|
+
max_days_ago: Exclude trade data older than this many days.
|
|
263
|
+
|
|
264
|
+
Returns:
|
|
265
|
+
Up to 1000 matching export orders, ordered by lowest price
|
|
266
|
+
(the best place to buy first).
|
|
267
|
+
|
|
268
|
+
Raises:
|
|
269
|
+
ValueError: If `min_volume` or `max_price` is negative, if
|
|
270
|
+
`max_distance` is outside `[0, 500]`, or if `max_days_ago`
|
|
271
|
+
is negative.
|
|
272
|
+
"""
|
|
273
|
+
if min_volume < 0:
|
|
274
|
+
raise ValueError(f"min_volume cannot be negative. received: {min_volume}")
|
|
275
|
+
if max_price and max_price < 0:
|
|
276
|
+
raise ValueError(f"max_price cannot be negative. received: {max_price}")
|
|
277
|
+
if max_distance < 0 or max_distance > 500:
|
|
278
|
+
raise ValueError(f"max_distance cannot be negative or greater than 500. received: {max_distance}")
|
|
279
|
+
if max_days_ago < 0:
|
|
280
|
+
raise ValueError(f"max_days_ago cannot be negative. received: {max_days_ago}")
|
|
281
|
+
|
|
282
|
+
from .market import CommodityMarket
|
|
283
|
+
logger.debug(f"GET /system/address/{system.system_address}/commodity/name/{self.commodity_name}/nearby/exports")
|
|
284
|
+
if max_price is None:
|
|
285
|
+
params = {
|
|
286
|
+
"minVolume": min_volume,
|
|
287
|
+
"fleetCarriers": fleet_carriers,
|
|
288
|
+
"maxDaysAgo": max_days_ago,
|
|
289
|
+
}
|
|
290
|
+
else:
|
|
291
|
+
params = {
|
|
292
|
+
"minVolume": min_volume,
|
|
293
|
+
"maxPrice": max_price,
|
|
294
|
+
"fleetCarriers": fleet_carriers,
|
|
295
|
+
"maxDaysAgo": max_days_ago,
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
response = self._client.get(
|
|
299
|
+
f"/system/address/{system.system_address}/commodity/name/{self.commodity_name}/nearby/exports",
|
|
300
|
+
params=params)
|
|
301
|
+
commodities = response.json()
|
|
302
|
+
logger.debug(f"Parsing {len(commodities)} commodities")
|
|
303
|
+
return [
|
|
304
|
+
CommodityMarket.from_json(self._client, entry) for entry in commodities
|
|
305
|
+
]
|
|
306
|
+
|
|
307
|
+
def get_station_market(self, station: "Station") -> "CommodityMarket":
|
|
308
|
+
"""Fetch the buy/sell order for this commodity at one specific station.
|
|
309
|
+
|
|
310
|
+
Args:
|
|
311
|
+
station: The station to look up.
|
|
312
|
+
|
|
313
|
+
Returns:
|
|
314
|
+
The matching `CommodityMarket` entry.
|
|
315
|
+
"""
|
|
316
|
+
from .market import CommodityMarket
|
|
317
|
+
logger.debug(f"GET /market/{station.station_id}/commodity/name/{self.commodity_name}")
|
|
318
|
+
response = self._client.get(f"/market/{station.station_id}/commodity/name/{self.commodity_name}")
|
|
319
|
+
commodity = response.json()
|
|
320
|
+
return CommodityMarket.from_json(self._client, commodity)
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Model for a single commodity buy/sell order at a market.
|
|
2
|
+
|
|
3
|
+
`CommodityMarket` is the common return type for nearly every trade-related
|
|
4
|
+
endpoint in this library (`System`/`Commodity`/`Station` import and export
|
|
5
|
+
lookups) since the API returns this same shape across all of them.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
from pydantic import BaseModel, PrivateAttr, ConfigDict, Field
|
|
14
|
+
from pydantic.alias_generators import to_camel
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from .station import Station
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger("pyardent.models.market")
|
|
20
|
+
|
|
21
|
+
class CommodityMarketData(BaseModel):
|
|
22
|
+
"""Plain fields of a single commodity order at a market, as returned by the API.
|
|
23
|
+
|
|
24
|
+
`buy_price` is what you pay to buy from this station (i.e. it's
|
|
25
|
+
exporting); `sell_price` is what this station pays you to sell to it
|
|
26
|
+
(i.e. it's importing). `demand_bracket`/`stock_bracket` are the game's
|
|
27
|
+
coarse low/medium/high indicators (0-3); the API occasionally returns an
|
|
28
|
+
empty string instead of an int for these, hence the `int | str` type.
|
|
29
|
+
|
|
30
|
+
Split from `CommodityMarket` so the rich subclass can carry a `_client`
|
|
31
|
+
reference without it leaking into the field-validation schema.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
|
|
35
|
+
|
|
36
|
+
commodity_name: str | None = None
|
|
37
|
+
buy_price: int | None = None
|
|
38
|
+
demand: int | None = None
|
|
39
|
+
demand_bracket: int | str | None = None
|
|
40
|
+
mean_price: int | None = None
|
|
41
|
+
sell_price: int | None = None
|
|
42
|
+
stock: int | None = None
|
|
43
|
+
stock_bracket: int | str | None = None
|
|
44
|
+
updated_at: datetime | None = None
|
|
45
|
+
|
|
46
|
+
station_id: int | None = Field(alias="marketId", default=None)
|
|
47
|
+
|
|
48
|
+
class CommodityMarket(CommodityMarketData):
|
|
49
|
+
"""A single commodity's buy/sell order at one market, with a method to traverse to its station."""
|
|
50
|
+
|
|
51
|
+
_client: httpx.Client = PrivateAttr()
|
|
52
|
+
|
|
53
|
+
@classmethod
|
|
54
|
+
def from_json(cls, client: httpx.Client, payload: dict) -> "CommodityMarket":
|
|
55
|
+
"""Construct a `CommodityMarket` from a JSON payload and attach the client.
|
|
56
|
+
|
|
57
|
+
This is the only supported way to build a `CommodityMarket`:
|
|
58
|
+
constructing one via `model_validate` directly would leave `_client`
|
|
59
|
+
unset, breaking `get_station`.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
client: The shared `httpx.Client` to attach for further requests.
|
|
63
|
+
payload: A single commodity-order object as returned by the API.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
The constructed, traversable `CommodityMarket`.
|
|
67
|
+
"""
|
|
68
|
+
instance = cls.model_validate(payload)
|
|
69
|
+
instance._client = client
|
|
70
|
+
return instance
|
|
71
|
+
|
|
72
|
+
def get_station(self) -> "Station":
|
|
73
|
+
"""Fetch the station this order belongs to.
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
The `Station` identified by `station_id`.
|
|
77
|
+
"""
|
|
78
|
+
from .station import Station
|
|
79
|
+
logger.debug(f"GET /market/{self.station_id}")
|
|
80
|
+
response = self._client.get(f"/market/{self.station_id}")
|
|
81
|
+
station = response.json()
|
|
82
|
+
return Station.from_json(self._client, station)
|