agmarknet 0.1.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.
- agmarknet/__init__.py +23 -0
- agmarknet/client.py +250 -0
- agmarknet/constants.py +35 -0
- agmarknet/dates.py +45 -0
- agmarknet/exceptions.py +36 -0
- agmarknet/filters.py +107 -0
- agmarknet/historical.py +162 -0
- agmarknet/lookup.py +404 -0
- agmarknet/models.py +147 -0
- agmarknet/pagination.py +60 -0
- agmarknet/py.typed +1 -0
- agmarknet/report.py +79 -0
- agmarknet/session.py +111 -0
- agmarknet/validators.py +90 -0
- agmarknet-0.1.0.dist-info/METADATA +116 -0
- agmarknet-0.1.0.dist-info/RECORD +18 -0
- agmarknet-0.1.0.dist-info/WHEEL +4 -0
- agmarknet-0.1.0.dist-info/licenses/LICENSE +21 -0
agmarknet/__init__.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Python SDK for the official AGMARKNET REST API."""
|
|
2
|
+
|
|
3
|
+
from agmarknet.client import Agmarknet
|
|
4
|
+
from agmarknet.exceptions import (
|
|
5
|
+
AgmarknetAPIError,
|
|
6
|
+
AgmarknetError,
|
|
7
|
+
AgmarknetLookupError,
|
|
8
|
+
AgmarknetResponseError,
|
|
9
|
+
AgmarknetValidationError,
|
|
10
|
+
)
|
|
11
|
+
from agmarknet.historical import HistoricalDownloader
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"Agmarknet",
|
|
15
|
+
"AgmarknetAPIError",
|
|
16
|
+
"AgmarknetError",
|
|
17
|
+
"AgmarknetLookupError",
|
|
18
|
+
"AgmarknetResponseError",
|
|
19
|
+
"AgmarknetValidationError",
|
|
20
|
+
"HistoricalDownloader",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
__version__ = "0.1.0"
|
agmarknet/client.py
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"""Public client for the agmarknet package."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from datetime import date
|
|
7
|
+
|
|
8
|
+
import pandas as pd
|
|
9
|
+
import requests
|
|
10
|
+
|
|
11
|
+
from agmarknet.constants import (
|
|
12
|
+
BASE_URL,
|
|
13
|
+
DEFAULT_GRADE_ID,
|
|
14
|
+
DEFAULT_TIMEOUT,
|
|
15
|
+
DEFAULT_VARIETY_ID,
|
|
16
|
+
FILTERS_ENDPOINT,
|
|
17
|
+
REPORT_ENDPOINT,
|
|
18
|
+
)
|
|
19
|
+
from agmarknet.dates import resolve_date_range
|
|
20
|
+
from agmarknet.exceptions import AgmarknetLookupError
|
|
21
|
+
from agmarknet.filters import FilterTables
|
|
22
|
+
from agmarknet.historical import HistoricalDownloader, ProgressCallback
|
|
23
|
+
from agmarknet.lookup import Lookup, LookupValue, LookupValues
|
|
24
|
+
from agmarknet.models import ReportRequest
|
|
25
|
+
from agmarknet.pagination import PaginatedReportDownloader
|
|
26
|
+
from agmarknet.session import AgmarknetSession
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Agmarknet:
|
|
30
|
+
"""Client for the official AGMARKNET REST API.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
base_url: API base URL. Override mainly for tests.
|
|
34
|
+
timeout: Request timeout in seconds.
|
|
35
|
+
session: Optional existing :class:`requests.Session`.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
*,
|
|
41
|
+
base_url: str = BASE_URL,
|
|
42
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
43
|
+
session: requests.Session | None = None,
|
|
44
|
+
) -> None:
|
|
45
|
+
self._session = AgmarknetSession(
|
|
46
|
+
base_url=base_url,
|
|
47
|
+
timeout=timeout,
|
|
48
|
+
session=session,
|
|
49
|
+
)
|
|
50
|
+
self._filters: FilterTables | None = None
|
|
51
|
+
self._page_downloader = PaginatedReportDownloader(
|
|
52
|
+
lambda payload: self._session.post_json(REPORT_ENDPOINT, payload)
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
def filters(self) -> FilterTables:
|
|
56
|
+
"""Return all AGMARKNET lookup tables as pandas DataFrames."""
|
|
57
|
+
|
|
58
|
+
payload = self._session.get_json(FILTERS_ENDPOINT)
|
|
59
|
+
self._filters = FilterTables.from_response(payload)
|
|
60
|
+
return self._filters
|
|
61
|
+
|
|
62
|
+
def lookup(self) -> Lookup:
|
|
63
|
+
"""Return a lookup engine backed by the official filters endpoint."""
|
|
64
|
+
|
|
65
|
+
return Lookup(self._get_filters())
|
|
66
|
+
|
|
67
|
+
def report(
|
|
68
|
+
self,
|
|
69
|
+
*,
|
|
70
|
+
from_date: str | date | None = None,
|
|
71
|
+
to_date: str | date | None = None,
|
|
72
|
+
start: str | date | None = None,
|
|
73
|
+
end: str | date | None = None,
|
|
74
|
+
data_type: str | int = "price",
|
|
75
|
+
commodity: LookupValue,
|
|
76
|
+
state: LookupValues,
|
|
77
|
+
district: LookupValues | None = None,
|
|
78
|
+
market: LookupValues | None = None,
|
|
79
|
+
grade: LookupValues | None = None,
|
|
80
|
+
variety: LookupValues | None = None,
|
|
81
|
+
group: LookupValue | None = None,
|
|
82
|
+
page: int | str = 1,
|
|
83
|
+
limit: int | str = 1000,
|
|
84
|
+
progress: bool | ProgressCallback = False,
|
|
85
|
+
timeout: float | None = None,
|
|
86
|
+
) -> pd.DataFrame:
|
|
87
|
+
"""Return a daily price and arrival report as a pandas DataFrame.
|
|
88
|
+
|
|
89
|
+
Filter arguments accept AGMARKNET IDs or names. Names are resolved from
|
|
90
|
+
the official filters endpoint using exact, case-insensitive, then fuzzy
|
|
91
|
+
matching.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
start_date, end_date = resolve_date_range(
|
|
95
|
+
from_date=from_date,
|
|
96
|
+
to_date=to_date,
|
|
97
|
+
start=start,
|
|
98
|
+
end=end,
|
|
99
|
+
)
|
|
100
|
+
resolved = self._resolve_report_filters(
|
|
101
|
+
group=group,
|
|
102
|
+
commodity=commodity,
|
|
103
|
+
state=state,
|
|
104
|
+
district=district,
|
|
105
|
+
market=market,
|
|
106
|
+
grade=grade or DEFAULT_GRADE_ID,
|
|
107
|
+
variety=variety or DEFAULT_VARIETY_ID,
|
|
108
|
+
)
|
|
109
|
+
request = ReportRequest.from_user_input(
|
|
110
|
+
from_date=start_date,
|
|
111
|
+
to_date=end_date,
|
|
112
|
+
data_type=data_type,
|
|
113
|
+
group=resolved["group"],
|
|
114
|
+
commodity=resolved["commodity"],
|
|
115
|
+
state=resolved["state"],
|
|
116
|
+
district=resolved["district"],
|
|
117
|
+
market=resolved["market"],
|
|
118
|
+
grade=resolved["grade"],
|
|
119
|
+
variety=resolved["variety"],
|
|
120
|
+
page=1,
|
|
121
|
+
limit=limit,
|
|
122
|
+
)
|
|
123
|
+
original_timeout = self._session.timeout
|
|
124
|
+
if timeout is not None:
|
|
125
|
+
self._session.timeout = timeout
|
|
126
|
+
try:
|
|
127
|
+
downloader = HistoricalDownloader(self._fetch_paginated_report)
|
|
128
|
+
return downloader.download(request, progress=progress)
|
|
129
|
+
finally:
|
|
130
|
+
self._session.timeout = original_timeout
|
|
131
|
+
|
|
132
|
+
def _fetch_paginated_report(
|
|
133
|
+
self,
|
|
134
|
+
request: ReportRequest,
|
|
135
|
+
progress_callback: Callable[[int, int], None] | None = None,
|
|
136
|
+
) -> pd.DataFrame:
|
|
137
|
+
return self._page_downloader.download(
|
|
138
|
+
request, progress_callback=progress_callback
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
def _get_filters(self) -> FilterTables:
|
|
142
|
+
if self._filters is None:
|
|
143
|
+
return self.filters()
|
|
144
|
+
return self._filters
|
|
145
|
+
|
|
146
|
+
def _resolve_report_filters(
|
|
147
|
+
self,
|
|
148
|
+
*,
|
|
149
|
+
group: LookupValue | None,
|
|
150
|
+
commodity: LookupValue,
|
|
151
|
+
state: LookupValues,
|
|
152
|
+
district: LookupValues | None,
|
|
153
|
+
market: LookupValues | None,
|
|
154
|
+
grade: LookupValues,
|
|
155
|
+
variety: LookupValues,
|
|
156
|
+
) -> dict[str, str | list[str]]:
|
|
157
|
+
if not _needs_lookup(group, commodity, state, district, market, grade, variety):
|
|
158
|
+
return {
|
|
159
|
+
"group": str(group),
|
|
160
|
+
"commodity": str(commodity),
|
|
161
|
+
"state": _to_text_list(state),
|
|
162
|
+
"district": _to_text_list(district),
|
|
163
|
+
"market": _to_text_list(market),
|
|
164
|
+
"grade": _to_text_list(grade),
|
|
165
|
+
"variety": _to_text_list(variety),
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
lookup = self.lookup()
|
|
169
|
+
commodity_result = lookup.commodity(commodity)
|
|
170
|
+
resolved_states = lookup.states(state)
|
|
171
|
+
resolved_districts = (
|
|
172
|
+
lookup.districts_for_state_ids(resolved_states)
|
|
173
|
+
if _is_all(district)
|
|
174
|
+
else lookup.districts(district, state_ids=resolved_states)
|
|
175
|
+
)
|
|
176
|
+
resolved_markets = (
|
|
177
|
+
lookup.markets_for_district_ids(resolved_districts)
|
|
178
|
+
if _is_all(market)
|
|
179
|
+
else lookup.markets(market, district_ids=resolved_districts)
|
|
180
|
+
)
|
|
181
|
+
resolved_group = (
|
|
182
|
+
lookup.commodity_group(group).value
|
|
183
|
+
if group is not None
|
|
184
|
+
else _infer_group_id(commodity_result.row)
|
|
185
|
+
)
|
|
186
|
+
return {
|
|
187
|
+
"group": resolved_group,
|
|
188
|
+
"commodity": commodity_result.value,
|
|
189
|
+
"state": resolved_states,
|
|
190
|
+
"district": _require_values(resolved_districts, "district"),
|
|
191
|
+
"market": _require_values(resolved_markets, "market"),
|
|
192
|
+
"grade": lookup.grades(grade),
|
|
193
|
+
"variety": lookup.varieties(variety),
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _needs_lookup(*values: object) -> bool:
|
|
198
|
+
return any(_value_needs_lookup(value) for value in values)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _value_needs_lookup(value: object) -> bool:
|
|
202
|
+
if value is None:
|
|
203
|
+
return True
|
|
204
|
+
if isinstance(value, int):
|
|
205
|
+
return False
|
|
206
|
+
if isinstance(value, str):
|
|
207
|
+
return not value.strip().isdigit()
|
|
208
|
+
try:
|
|
209
|
+
return any(
|
|
210
|
+
_value_needs_lookup(item)
|
|
211
|
+
for item in value # type: ignore[arg-type]
|
|
212
|
+
)
|
|
213
|
+
except TypeError:
|
|
214
|
+
return True
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _to_text_list(values: LookupValues) -> list[str]:
|
|
218
|
+
if isinstance(values, str | int):
|
|
219
|
+
return [str(values)]
|
|
220
|
+
return [str(value) for value in values]
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _is_all(value: object) -> bool:
|
|
224
|
+
if value is None:
|
|
225
|
+
return True
|
|
226
|
+
return isinstance(value, str) and value.strip().casefold() in {"all", "*"}
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _require_values(values: list[str], field_name: str) -> list[str]:
|
|
230
|
+
if not values:
|
|
231
|
+
raise AgmarknetLookupError(f"No {field_name} values matched the filters.")
|
|
232
|
+
return values
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _infer_group_id(row: dict[str, object]) -> str:
|
|
236
|
+
candidates = (
|
|
237
|
+
"group",
|
|
238
|
+
"group_id",
|
|
239
|
+
"cmdt_grp_id",
|
|
240
|
+
"cmdt_group_id",
|
|
241
|
+
"commodity_group_id",
|
|
242
|
+
)
|
|
243
|
+
for candidate in candidates:
|
|
244
|
+
value = row.get(candidate)
|
|
245
|
+
if value is not None and str(value).strip():
|
|
246
|
+
return str(value).strip()
|
|
247
|
+
raise AgmarknetLookupError(
|
|
248
|
+
"group is required because the commodity lookup row does not include "
|
|
249
|
+
"a commodity group ID."
|
|
250
|
+
)
|
agmarknet/constants.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Constants for AGMARKNET API access."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
BASE_URL = "https://api.agmarknet.gov.in/v1"
|
|
6
|
+
PORTAL_URL = "https://agmarknet.gov.in"
|
|
7
|
+
|
|
8
|
+
FILTERS_ENDPOINT = "/daily-price-arrival/filters"
|
|
9
|
+
REPORT_ENDPOINT = "/daily-price-arrival/report"
|
|
10
|
+
|
|
11
|
+
DEFAULT_TIMEOUT = 60.0
|
|
12
|
+
DEFAULT_PAGE = 1
|
|
13
|
+
DEFAULT_LIMIT = 1000
|
|
14
|
+
DEFAULT_GRADE_ID = "100003"
|
|
15
|
+
DEFAULT_VARIETY_ID = "100007"
|
|
16
|
+
|
|
17
|
+
DATA_TYPE_PRICE = "100004"
|
|
18
|
+
DATA_TYPE_ARRIVAL = "100005"
|
|
19
|
+
DATA_TYPE_BOTH = "100006"
|
|
20
|
+
DEFAULT_DATA_TYPE = DATA_TYPE_PRICE
|
|
21
|
+
|
|
22
|
+
DATA_TYPES = {
|
|
23
|
+
"price": DATA_TYPE_PRICE,
|
|
24
|
+
"arrival": DATA_TYPE_ARRIVAL,
|
|
25
|
+
"both": DATA_TYPE_BOTH,
|
|
26
|
+
DATA_TYPE_PRICE: DATA_TYPE_PRICE,
|
|
27
|
+
DATA_TYPE_ARRIVAL: DATA_TYPE_ARRIVAL,
|
|
28
|
+
DATA_TYPE_BOTH: DATA_TYPE_BOTH,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
USER_AGENT = (
|
|
32
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
|
33
|
+
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
34
|
+
"Chrome/149.0.0.0 Safari/537.36"
|
|
35
|
+
)
|
agmarknet/dates.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Date range helpers for public report arguments."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import date
|
|
6
|
+
|
|
7
|
+
from agmarknet.exceptions import AgmarknetValidationError
|
|
8
|
+
from agmarknet.validators import normalize_date
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def resolve_date_range(
|
|
12
|
+
*,
|
|
13
|
+
from_date: str | date | None,
|
|
14
|
+
to_date: str | date | None,
|
|
15
|
+
start: str | date | None,
|
|
16
|
+
end: str | date | None,
|
|
17
|
+
) -> tuple[str, str]:
|
|
18
|
+
"""Resolve legacy and friendly date aliases into ISO date strings."""
|
|
19
|
+
|
|
20
|
+
resolved_start = _resolve_date_alias(from_date, start, "from_date", "start")
|
|
21
|
+
resolved_end = _resolve_date_alias(to_date, end, "to_date", "end")
|
|
22
|
+
start_text = normalize_date(resolved_start, "start")
|
|
23
|
+
end_text = normalize_date(resolved_end, "end")
|
|
24
|
+
if date.fromisoformat(start_text) > date.fromisoformat(end_text):
|
|
25
|
+
raise AgmarknetValidationError("start must be on or before end.")
|
|
26
|
+
return start_text, end_text
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _resolve_date_alias(
|
|
30
|
+
primary: str | date | None,
|
|
31
|
+
alias: str | date | None,
|
|
32
|
+
primary_name: str,
|
|
33
|
+
alias_name: str,
|
|
34
|
+
) -> str | date:
|
|
35
|
+
if primary is None and alias is None:
|
|
36
|
+
raise AgmarknetValidationError(f"{primary_name} or {alias_name} is required.")
|
|
37
|
+
if primary is not None and alias is not None:
|
|
38
|
+
primary_text = normalize_date(primary, primary_name)
|
|
39
|
+
alias_text = normalize_date(alias, alias_name)
|
|
40
|
+
if primary_text != alias_text:
|
|
41
|
+
raise AgmarknetValidationError(
|
|
42
|
+
f"{primary_name} and {alias_name} cannot disagree."
|
|
43
|
+
)
|
|
44
|
+
return primary_text
|
|
45
|
+
return primary if primary is not None else alias
|
agmarknet/exceptions.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Exception hierarchy for agmarknet."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class AgmarknetError(Exception):
|
|
7
|
+
"""Base exception for all package-specific errors."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class AgmarknetValidationError(AgmarknetError, ValueError):
|
|
11
|
+
"""Raised when user input cannot be converted to a valid API request."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class AgmarknetLookupError(AgmarknetValidationError):
|
|
15
|
+
"""Raised when a lookup value cannot be resolved unambiguously."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class AgmarknetAPIError(AgmarknetError):
|
|
19
|
+
"""Raised when the AGMARKNET API returns an HTTP or transport error."""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
message: str,
|
|
24
|
+
*,
|
|
25
|
+
status_code: int | None = None,
|
|
26
|
+
url: str | None = None,
|
|
27
|
+
response_text: str | None = None,
|
|
28
|
+
) -> None:
|
|
29
|
+
super().__init__(message)
|
|
30
|
+
self.status_code = status_code
|
|
31
|
+
self.url = url
|
|
32
|
+
self.response_text = response_text
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class AgmarknetResponseError(AgmarknetError):
|
|
36
|
+
"""Raised when the AGMARKNET API response has an unexpected shape."""
|
agmarknet/filters.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Filter lookup table parsing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterator, Mapping
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import pandas as pd
|
|
10
|
+
|
|
11
|
+
from agmarknet.exceptions import AgmarknetResponseError
|
|
12
|
+
|
|
13
|
+
FILTER_TABLES = {
|
|
14
|
+
"commodity_groups": "cmdt_group_data",
|
|
15
|
+
"commodities": "cmdt_data",
|
|
16
|
+
"states": "state_data",
|
|
17
|
+
"districts": "district_data",
|
|
18
|
+
"markets": "market_data",
|
|
19
|
+
"varieties": "variety_data",
|
|
20
|
+
"grades": "grade_data",
|
|
21
|
+
"types": "type_data",
|
|
22
|
+
"ranges": "range_data",
|
|
23
|
+
"notes": "note_data",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
RAW_FILTER_TABLES = {
|
|
27
|
+
api_name: public_name for public_name, api_name in FILTER_TABLES.items()
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class FilterTables:
|
|
33
|
+
"""Container for AGMARKNET lookup tables as pandas DataFrames."""
|
|
34
|
+
|
|
35
|
+
commodity_groups: pd.DataFrame
|
|
36
|
+
commodities: pd.DataFrame
|
|
37
|
+
states: pd.DataFrame
|
|
38
|
+
districts: pd.DataFrame
|
|
39
|
+
markets: pd.DataFrame
|
|
40
|
+
varieties: pd.DataFrame
|
|
41
|
+
grades: pd.DataFrame
|
|
42
|
+
types: pd.DataFrame
|
|
43
|
+
ranges: pd.DataFrame
|
|
44
|
+
notes: pd.DataFrame
|
|
45
|
+
|
|
46
|
+
def __getitem__(self, key: str) -> pd.DataFrame:
|
|
47
|
+
"""Return a lookup table by friendly name or raw API key."""
|
|
48
|
+
|
|
49
|
+
return getattr(self, _normalize_key(key))
|
|
50
|
+
|
|
51
|
+
def __iter__(self) -> Iterator[str]:
|
|
52
|
+
"""Iterate over raw AGMARKNET filter table keys."""
|
|
53
|
+
|
|
54
|
+
return iter(RAW_FILTER_TABLES)
|
|
55
|
+
|
|
56
|
+
def __len__(self) -> int:
|
|
57
|
+
"""Return the number of lookup tables."""
|
|
58
|
+
|
|
59
|
+
return len(FILTER_TABLES)
|
|
60
|
+
|
|
61
|
+
def keys(self) -> list[str]:
|
|
62
|
+
"""Return raw AGMARKNET filter table keys."""
|
|
63
|
+
|
|
64
|
+
return list(RAW_FILTER_TABLES)
|
|
65
|
+
|
|
66
|
+
@classmethod
|
|
67
|
+
def from_response(cls, payload: Mapping[str, Any]) -> FilterTables:
|
|
68
|
+
"""Parse a filters endpoint response."""
|
|
69
|
+
|
|
70
|
+
data = payload.get("data")
|
|
71
|
+
if not isinstance(data, Mapping):
|
|
72
|
+
raise AgmarknetResponseError("Filters response missing data object.")
|
|
73
|
+
|
|
74
|
+
tables = {
|
|
75
|
+
public_name: _records_to_frame(data.get(api_name), api_name)
|
|
76
|
+
for public_name, api_name in FILTER_TABLES.items()
|
|
77
|
+
}
|
|
78
|
+
return cls(**tables)
|
|
79
|
+
|
|
80
|
+
def as_dict(self) -> dict[str, pd.DataFrame]:
|
|
81
|
+
"""Return lookup tables keyed by friendly table names."""
|
|
82
|
+
|
|
83
|
+
return {name: getattr(self, name) for name in FILTER_TABLES}
|
|
84
|
+
|
|
85
|
+
def as_raw_dict(self) -> dict[str, pd.DataFrame]:
|
|
86
|
+
"""Return lookup tables keyed by raw AGMARKNET API names."""
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
api_name: getattr(self, public_name)
|
|
90
|
+
for api_name, public_name in RAW_FILTER_TABLES.items()
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _records_to_frame(records: Any, table_name: str) -> pd.DataFrame:
|
|
95
|
+
if records is None:
|
|
96
|
+
return pd.DataFrame()
|
|
97
|
+
if not isinstance(records, list):
|
|
98
|
+
raise AgmarknetResponseError(f"{table_name} must be a list.")
|
|
99
|
+
return pd.DataFrame.from_records(records)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _normalize_key(key: str) -> str:
|
|
103
|
+
if key in FILTER_TABLES:
|
|
104
|
+
return key
|
|
105
|
+
if key in RAW_FILTER_TABLES:
|
|
106
|
+
return RAW_FILTER_TABLES[key]
|
|
107
|
+
raise KeyError(key)
|
agmarknet/historical.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Historical report downloading by yearly chunks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from datetime import date
|
|
8
|
+
from sys import stderr
|
|
9
|
+
|
|
10
|
+
import pandas as pd
|
|
11
|
+
|
|
12
|
+
from agmarknet.models import ReportRequest
|
|
13
|
+
from agmarknet.report import merge_report_frames
|
|
14
|
+
|
|
15
|
+
ProgressCallback = Callable[[int, int, "DateChunk"], None]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class DateChunk:
|
|
20
|
+
"""Inclusive date range used for one API request window."""
|
|
21
|
+
|
|
22
|
+
from_date: date
|
|
23
|
+
to_date: date
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class HistoricalDownloader:
|
|
27
|
+
"""Download reports sequentially across yearly API request chunks.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
fetch_report: Callable that downloads one request, including pagination.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
fetch_report: Callable[
|
|
36
|
+
[ReportRequest, Callable[[int, int], None] | None], pd.DataFrame
|
|
37
|
+
],
|
|
38
|
+
) -> None:
|
|
39
|
+
self._fetch_report = fetch_report
|
|
40
|
+
|
|
41
|
+
def download(
|
|
42
|
+
self,
|
|
43
|
+
request: ReportRequest,
|
|
44
|
+
*,
|
|
45
|
+
progress: bool | ProgressCallback = False,
|
|
46
|
+
) -> pd.DataFrame:
|
|
47
|
+
"""Download all yearly chunks and return one de-duplicated DataFrame."""
|
|
48
|
+
|
|
49
|
+
chunks = split_yearly_chunks(
|
|
50
|
+
date.fromisoformat(request.from_date),
|
|
51
|
+
date.fromisoformat(request.to_date),
|
|
52
|
+
)
|
|
53
|
+
frames: list[pd.DataFrame] = []
|
|
54
|
+
total = len(chunks)
|
|
55
|
+
for index, chunk in enumerate(chunks, start=1):
|
|
56
|
+
_emit_progress(progress, index, total, chunk)
|
|
57
|
+
|
|
58
|
+
def make_page_progress(idx=index, tot=total, chk=chunk):
|
|
59
|
+
def page_progress(current_page: int, total_pages: int):
|
|
60
|
+
if progress and not callable(progress):
|
|
61
|
+
is_last = current_page == total_pages
|
|
62
|
+
print(
|
|
63
|
+
"Downloading "
|
|
64
|
+
f"{chk.from_date.isoformat()} to {chk.to_date.isoformat()} "
|
|
65
|
+
f"({idx}/{tot}) - Page {current_page}/{total_pages}",
|
|
66
|
+
file=stderr,
|
|
67
|
+
end="\n" if is_last else "\r",
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
return page_progress
|
|
71
|
+
|
|
72
|
+
page_cb = make_page_progress() if progress else None
|
|
73
|
+
|
|
74
|
+
frames.append(
|
|
75
|
+
self._fetch_report(
|
|
76
|
+
request.with_dates(
|
|
77
|
+
from_date=chunk.from_date.isoformat(),
|
|
78
|
+
to_date=chunk.to_date.isoformat(),
|
|
79
|
+
),
|
|
80
|
+
page_cb,
|
|
81
|
+
)
|
|
82
|
+
)
|
|
83
|
+
return finalize_historical_frame(frames)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def split_yearly_chunks(start: date, end: date) -> list[DateChunk]:
|
|
87
|
+
"""Split an inclusive date range into calendar-year chunks."""
|
|
88
|
+
|
|
89
|
+
chunks: list[DateChunk] = []
|
|
90
|
+
current = start
|
|
91
|
+
while current <= end:
|
|
92
|
+
year_end = date(current.year, 12, 31)
|
|
93
|
+
chunk_end = min(year_end, end)
|
|
94
|
+
chunks.append(DateChunk(from_date=current, to_date=chunk_end))
|
|
95
|
+
current = date(current.year + 1, 1, 1)
|
|
96
|
+
return chunks
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def finalize_historical_frame(frames: list[pd.DataFrame]) -> pd.DataFrame:
|
|
100
|
+
"""Merge, sort chronologically when possible, and remove duplicates."""
|
|
101
|
+
|
|
102
|
+
merged = merge_report_frames(frames)
|
|
103
|
+
if merged.empty:
|
|
104
|
+
return merged
|
|
105
|
+
|
|
106
|
+
attrs = dict(merged.attrs)
|
|
107
|
+
merged = _sort_chronologically(merged)
|
|
108
|
+
merged = merged.drop_duplicates(ignore_index=True)
|
|
109
|
+
merged.attrs.update(attrs)
|
|
110
|
+
return merged
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _sort_chronologically(frame: pd.DataFrame) -> pd.DataFrame:
|
|
114
|
+
date_column = _date_column(frame)
|
|
115
|
+
if date_column is None:
|
|
116
|
+
return frame.reset_index(drop=True)
|
|
117
|
+
|
|
118
|
+
sorted_frame = frame.copy()
|
|
119
|
+
helper_column = "__agmarknet_sort_date"
|
|
120
|
+
sorted_frame[helper_column] = pd.to_datetime(
|
|
121
|
+
sorted_frame[date_column],
|
|
122
|
+
errors="coerce",
|
|
123
|
+
dayfirst=True,
|
|
124
|
+
)
|
|
125
|
+
sorted_frame = sorted_frame.sort_values(
|
|
126
|
+
by=[helper_column],
|
|
127
|
+
kind="stable",
|
|
128
|
+
na_position="last",
|
|
129
|
+
)
|
|
130
|
+
return sorted_frame.drop(columns=[helper_column]).reset_index(drop=True)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _date_column(frame: pd.DataFrame) -> str | None:
|
|
134
|
+
normalized = {_normalize_column(column): column for column in frame.columns}
|
|
135
|
+
candidates = (
|
|
136
|
+
"arrival_date",
|
|
137
|
+
"date",
|
|
138
|
+
"report_date",
|
|
139
|
+
"price_date",
|
|
140
|
+
"modal_date",
|
|
141
|
+
)
|
|
142
|
+
for candidate in candidates:
|
|
143
|
+
if candidate in normalized:
|
|
144
|
+
return str(normalized[candidate])
|
|
145
|
+
for column in frame.columns:
|
|
146
|
+
if "date" in _normalize_column(column):
|
|
147
|
+
return str(column)
|
|
148
|
+
return None
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _normalize_column(value: object) -> str:
|
|
152
|
+
return str(value).strip().casefold().replace(" ", "_")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _emit_progress(
|
|
156
|
+
progress: bool | ProgressCallback,
|
|
157
|
+
index: int,
|
|
158
|
+
total: int,
|
|
159
|
+
chunk: DateChunk,
|
|
160
|
+
) -> None:
|
|
161
|
+
if callable(progress):
|
|
162
|
+
progress(index, total, chunk)
|