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 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
@@ -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)
@@ -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)