ilostat 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.
ilostat/__init__.py ADDED
@@ -0,0 +1,21 @@
1
+ from importlib.metadata import version
2
+
3
+ from .cache import clean_cache, get_cache_dir
4
+ from .data import get_df
5
+ from .dictionary import get_dataset_dic, get_dic
6
+ from .metadata import get_metadata, get_toc
7
+ from .sdmx import get_sdmx
8
+
9
+ __version__ = version("ilostat")
10
+
11
+ __all__ = [
12
+ "__version__",
13
+ "get_dataset_dic",
14
+ "get_df",
15
+ "get_dic",
16
+ "get_metadata",
17
+ "get_toc",
18
+ "clean_cache",
19
+ "get_cache_dir",
20
+ "get_sdmx",
21
+ ]
ilostat/cache.py ADDED
@@ -0,0 +1,183 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from platformdirs import user_cache_dir
6
+
7
+
8
+ def _format_last_update(
9
+ last_update: str,
10
+ ) -> str:
11
+ """
12
+ Convert an ILOSTAT last.update value to a cache timestamp.
13
+
14
+ Example
15
+ -------
16
+ '20/09/2026 07:34:56' -> '20260920T073456'
17
+ """
18
+ date_part, time_part = last_update.split(" ")
19
+
20
+ day, month, year = date_part.split("/")
21
+ hour, minute, second = time_part.split(":")
22
+
23
+ return f"{year}{month}{day}T{hour}{minute}{second}"
24
+
25
+
26
+ def _versioned_cache_filename(
27
+ dataset_id: str,
28
+ last_update: str,
29
+ suffix: str,
30
+ ) -> str:
31
+ """
32
+ Build a versioned cache filename.
33
+
34
+ Examples
35
+ --------
36
+ Dataset:
37
+ UNE_TUNE_SEX_AGE_NB_A-20260930T070804.parquet
38
+
39
+ Dictionary:
40
+ UNE_TUNE_SEX_AGE_NB_A-20260930T070804_dic.json
41
+ """
42
+ timestamp = _format_last_update(
43
+ last_update
44
+ )
45
+
46
+ return (
47
+ f"{dataset_id}-"
48
+ f"{timestamp}"
49
+ f"{suffix}"
50
+ )
51
+
52
+
53
+ def _cache_filename(
54
+ dataset_id: str,
55
+ last_update: str,
56
+ ) -> str:
57
+ """
58
+ Build the filename for a cached ILOSTAT dataset.
59
+ """
60
+ return _versioned_cache_filename(
61
+ dataset_id=dataset_id,
62
+ last_update=last_update,
63
+ suffix=".parquet",
64
+ )
65
+
66
+
67
+ def _remove_old_cache_files(
68
+ cache_dir: Path,
69
+ dataset_id: str,
70
+ keep_file: Path,
71
+ suffix: str = ".parquet",
72
+ ) -> None:
73
+ """
74
+ Remove obsolete cached versions of an ILOSTAT resource.
75
+
76
+ Parameters
77
+ ----------
78
+ cache_dir : pathlib.Path
79
+ Segment cache directory.
80
+
81
+ dataset_id : str
82
+ ILOSTAT dataset identifier.
83
+
84
+ keep_file : pathlib.Path
85
+ Current cached file that must be preserved.
86
+
87
+ suffix : str, default ".parquet"
88
+ Resource filename suffix.
89
+ """
90
+ pattern = (
91
+ f"{dataset_id}-"
92
+ f"*{suffix}"
93
+ )
94
+
95
+ for file in cache_dir.glob(
96
+ pattern
97
+ ):
98
+ if file != keep_file:
99
+ file.unlink()
100
+
101
+
102
+ def get_cache_dir(
103
+ cache_dir: str | Path | None = None,
104
+ ) -> Path:
105
+ """
106
+ Return the ILOSTAT cache directory.
107
+
108
+ Parameters
109
+ ----------
110
+ cache_dir : str, pathlib.Path or None
111
+ Custom cache directory. If None, use the operating
112
+ system's standard user cache directory.
113
+
114
+ Returns
115
+ -------
116
+ pathlib.Path
117
+ Cache directory.
118
+ """
119
+ if cache_dir is None:
120
+ path = Path(
121
+ user_cache_dir("ilostat")
122
+ )
123
+ else:
124
+ path = Path(
125
+ cache_dir
126
+ ).expanduser()
127
+
128
+ path.mkdir(
129
+ parents=True,
130
+ exist_ok=True,
131
+ )
132
+
133
+ return path
134
+
135
+
136
+ def clean_cache(
137
+ cache_dir: str | Path | None = None,
138
+ ) -> None:
139
+ """
140
+ Remove all files from the ILOSTAT cache.
141
+
142
+ Parameters
143
+ ----------
144
+ cache_dir : str, pathlib.Path or None
145
+ ILOSTAT cache directory to clean.
146
+
147
+ If None, clean the default ILOSTAT user cache
148
+ directory.
149
+
150
+ If provided, clean the specified cache directory.
151
+
152
+ Notes
153
+ -----
154
+ The cache directory itself is preserved.
155
+ """
156
+ path = get_cache_dir(
157
+ cache_dir
158
+ )
159
+
160
+ for file in path.rglob("*"):
161
+ if file.is_file():
162
+ file.unlink()
163
+
164
+ # Remove empty subdirectories, deepest first.
165
+ directories = sorted(
166
+ (
167
+ directory
168
+ for directory in path.rglob("*")
169
+ if directory.is_dir()
170
+ ),
171
+ key=lambda directory: len(
172
+ directory.parts
173
+ ),
174
+ reverse=True,
175
+ )
176
+
177
+ for directory in directories:
178
+ try:
179
+ directory.rmdir()
180
+ except OSError:
181
+ # Keep non-empty or temporarily locked
182
+ # directories.
183
+ pass
ilostat/data.py ADDED
@@ -0,0 +1,260 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ import pandas as pd
6
+ import requests
7
+
8
+ from .cache import (
9
+ _cache_filename,
10
+ _remove_old_cache_files,
11
+ get_cache_dir,
12
+ )
13
+ from .dictionary import get_dataset_dic
14
+ from .filters import apply_filters
15
+ from .labels import add_labels, labels_only
16
+ from .metadata import get_last_update
17
+
18
+ BASE_URL = "https://rplumber.ilo.org/files"
19
+
20
+
21
+ def _get_df_single(
22
+ dataset_id: str,
23
+ segment: str = "indicator",
24
+ type: str = "code",
25
+ lang: str = "en",
26
+ filters: dict | None = None,
27
+ best_source: str = "yes",
28
+ fixed: bool = True,
29
+ cache_dir: str | Path | None = None,
30
+ refresh: bool = False,
31
+ ) -> pd.DataFrame:
32
+ """
33
+ Retrieve an ILOSTAT dataset as a pandas DataFrame.
34
+
35
+ Parameters
36
+ ----------
37
+ dataset_id : str
38
+ ILOSTAT dataset identifier.
39
+
40
+ segment : str, default "indicator"
41
+ ILOSTAT bulk-data segment.
42
+
43
+ type : {"code", "label", "both"}, default "code"
44
+ Output representation for coded dimensions.
45
+
46
+ "code" returns codes only.
47
+ "both" returns codes and labels.
48
+ "label" returns labelled dimensions.
49
+
50
+ lang : {"en", "fr", "es"}, default "en"
51
+ Language used for labels.
52
+
53
+ filters : dict or None, default None
54
+ Filters applied to the dataset.
55
+
56
+ best_source : {"yes", "no", "all"}, default "yes"
57
+ Select best-source observations.
58
+
59
+ fixed : bool, default True
60
+ If True, treat filter values as literal strings.
61
+
62
+ cache_dir : str, pathlib.Path or None, default None
63
+ Directory used to cache downloaded files.
64
+
65
+ refresh : bool, default False
66
+ If True, refresh the table of contents before checking
67
+ the cached dataset.
68
+
69
+ Returns
70
+ -------
71
+ pandas.DataFrame
72
+ Requested ILOSTAT dataset.
73
+ """
74
+ type = type.lower()
75
+
76
+ if type not in {"code", "label", "both"}:
77
+ raise ValueError(
78
+ "Invalid type value. "
79
+ "Choose from 'code', 'label', or 'both'."
80
+ )
81
+
82
+ cache_path = get_cache_dir(cache_dir)
83
+
84
+ last_update = get_last_update(
85
+ dataset_id=dataset_id,
86
+ segment=segment,
87
+ cache_dir=cache_dir,
88
+ refresh=refresh,
89
+ )
90
+
91
+ if last_update is None:
92
+ raise ValueError(
93
+ f"Dataset '{dataset_id}' was not found "
94
+ f"in the '{segment}' table of contents."
95
+ )
96
+
97
+ filename = _cache_filename(
98
+ dataset_id=dataset_id,
99
+ last_update=last_update,
100
+ )
101
+
102
+ segment_dir = cache_path / segment
103
+ segment_dir.mkdir(
104
+ parents=True,
105
+ exist_ok=True,
106
+ )
107
+
108
+ dataset_file = segment_dir / filename
109
+
110
+ if not dataset_file.exists():
111
+ url = (
112
+ f"{BASE_URL}/{segment}/"
113
+ f"{dataset_id}.parquet"
114
+ )
115
+
116
+ response = requests.get(
117
+ url,
118
+ timeout=60,
119
+ )
120
+ response.raise_for_status()
121
+
122
+ dataset_file.write_bytes(
123
+ response.content
124
+ )
125
+
126
+ _remove_old_cache_files(
127
+ cache_dir=segment_dir,
128
+ dataset_id=dataset_id,
129
+ keep_file=dataset_file,
130
+ )
131
+
132
+ df = pd.read_parquet(dataset_file)
133
+
134
+ best_source = best_source.lower()
135
+
136
+ if best_source not in {"yes", "no", "all"}:
137
+ raise ValueError(
138
+ "Invalid best_source value. "
139
+ "Choose from 'yes', 'no', or 'all'."
140
+ )
141
+
142
+ if "best_source" in df.columns:
143
+ if best_source == "yes":
144
+ df = df.loc[
145
+ df["best_source"] == 1
146
+ ]
147
+ elif best_source == "no":
148
+ df = df.loc[
149
+ df["best_source"] == 0
150
+ ]
151
+
152
+ df = apply_filters(
153
+ df,
154
+ filters=filters,
155
+ fixed=fixed,
156
+ )
157
+
158
+ if type in {"label", "both"}:
159
+ dic = get_dataset_dic(
160
+ dataset_id=dataset_id,
161
+ segment=segment,
162
+ cache_dir=cache_dir,
163
+ refresh=refresh,
164
+ )
165
+
166
+ df = add_labels(
167
+ df,
168
+ dic,
169
+ lang=lang,
170
+ )
171
+
172
+ if type == "label":
173
+ df = labels_only(df)
174
+
175
+ if best_source == "yes":
176
+ df = df.drop(
177
+ columns=["best_source"],
178
+ errors="ignore",
179
+ )
180
+
181
+ return df
182
+
183
+ def get_df(
184
+ dataset_id: str | list[str] | tuple[str, ...],
185
+ segment: str = "indicator",
186
+ type: str = "code",
187
+ lang: str = "en",
188
+ filters: dict | None = None,
189
+ best_source: str = "yes",
190
+ fixed: bool = True,
191
+ cache_dir: str | Path | None = None,
192
+ refresh: bool = False,
193
+ ) -> pd.DataFrame:
194
+ """
195
+ Retrieve one or more ILOSTAT datasets.
196
+
197
+ Parameters
198
+ ----------
199
+ dataset_id : str, list of str, or tuple of str
200
+ One or more ILOSTAT dataset identifiers.
201
+
202
+ segment : str, default "indicator"
203
+ ILOSTAT bulk-data segment.
204
+
205
+ type : {"code", "label", "both"}, default "code"
206
+ Output representation for coded dimensions.
207
+
208
+ lang : {"en", "fr", "es"}, default "en"
209
+ Language used for labels.
210
+
211
+ filters : dict or None, default None
212
+ Filters applied to each dataset.
213
+
214
+ best_source : {"yes", "no", "all"}, default "yes"
215
+ Select best-source observations.
216
+
217
+ fixed : bool, default True
218
+ If True, treat filter values as literal strings.
219
+
220
+ cache_dir : str, pathlib.Path or None, default None
221
+ Directory used to cache downloaded files.
222
+
223
+ refresh : bool, default False
224
+ If True, refresh metadata before checking cached data.
225
+
226
+ Returns
227
+ -------
228
+ pandas.DataFrame
229
+ Requested ILOSTAT data.
230
+ """
231
+ if isinstance(dataset_id, str):
232
+ dataset_ids = [dataset_id]
233
+ else:
234
+ dataset_ids = list(dataset_id)
235
+
236
+ if not dataset_ids:
237
+ raise ValueError(
238
+ "dataset_id must contain at least one dataset identifier."
239
+ )
240
+
241
+ frames = [
242
+ _get_df_single(
243
+ dataset_id=current_id,
244
+ segment=segment,
245
+ type=type,
246
+ lang=lang,
247
+ filters=filters,
248
+ best_source=best_source,
249
+ fixed=fixed,
250
+ cache_dir=cache_dir,
251
+ refresh=refresh,
252
+ )
253
+ for current_id in dataset_ids
254
+ ]
255
+
256
+ return pd.concat(
257
+ frames,
258
+ ignore_index=True,
259
+ sort=False,
260
+ )
ilostat/dictionary.py ADDED
@@ -0,0 +1,193 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ import pandas as pd
8
+ import requests
9
+
10
+ from .cache import (
11
+ _remove_old_cache_files,
12
+ _versioned_cache_filename,
13
+ get_cache_dir,
14
+ )
15
+ from .metadata import get_last_update
16
+
17
+ BASE_URL = "https://rplumber.ilo.org/files/dic"
18
+ DATA_BASE_URL = "https://rplumber.ilo.org/files"
19
+
20
+ VALID_LANGUAGES = {"en", "fr", "es"}
21
+
22
+ _REFRESHED_DICS: set[tuple[str, str, str]] = set()
23
+
24
+
25
+ def get_dic(
26
+ dic: str,
27
+ lang: str = "en",
28
+ cache_dir: str | Path | None = None,
29
+ refresh: bool = False,
30
+ ) -> pd.DataFrame:
31
+ """
32
+ Retrieve an ILOSTAT dictionary.
33
+
34
+ Parameters
35
+ ----------
36
+ dic : str
37
+ Dictionary name, for example "ref_area", "sex",
38
+ or "classif_type".
39
+
40
+ lang : {"en", "fr", "es"}, default "en"
41
+ Dictionary language.
42
+
43
+ cache_dir : str, pathlib.Path or None, default None
44
+ Directory used to cache downloaded dictionaries.
45
+
46
+ refresh : bool, default False
47
+ If True, force a new download of the dictionary.
48
+
49
+ Returns
50
+ -------
51
+ pandas.DataFrame
52
+ Requested ILOSTAT dictionary.
53
+ """
54
+ lang = lang.lower()
55
+
56
+ if lang not in VALID_LANGUAGES:
57
+ raise ValueError(
58
+ f"Unsupported language '{lang}'. "
59
+ f"Choose from {sorted(VALID_LANGUAGES)}."
60
+ )
61
+
62
+ cache_path = get_cache_dir(cache_dir)
63
+
64
+ dic_dir = cache_path / "dic"
65
+ dic_dir.mkdir(
66
+ parents=True,
67
+ exist_ok=True,
68
+ )
69
+
70
+ dic_file = dic_dir / f"{dic}_{lang}.parquet"
71
+
72
+ cache_key = (
73
+ str(cache_path.resolve()),
74
+ dic,
75
+ lang,
76
+ )
77
+
78
+ should_download = (
79
+ refresh
80
+ or cache_key not in _REFRESHED_DICS
81
+ or not dic_file.exists()
82
+ )
83
+
84
+ if should_download:
85
+ url = (
86
+ f"{BASE_URL}/"
87
+ f"{dic}_{lang}.parquet"
88
+ )
89
+
90
+ response = requests.get(
91
+ url,
92
+ timeout=60,
93
+ )
94
+ response.raise_for_status()
95
+
96
+ dic_file.write_bytes(
97
+ response.content
98
+ )
99
+
100
+ _REFRESHED_DICS.add(cache_key)
101
+
102
+ return pd.read_parquet(dic_file)
103
+
104
+
105
+ def get_dataset_dic(
106
+ dataset_id: str,
107
+ segment: str = "indicator",
108
+ cache_dir: str | Path | None = None,
109
+ refresh: bool = False,
110
+ ) -> dict[str, Any]:
111
+ """
112
+ Retrieve the dataset-specific ILOSTAT dictionary.
113
+
114
+ The cached dictionary is versioned using the dataset
115
+ last.update value from the table of contents.
116
+
117
+ Parameters
118
+ ----------
119
+ dataset_id : str
120
+ ILOSTAT dataset identifier.
121
+
122
+ segment : str, default "indicator"
123
+ ILOSTAT data segment.
124
+
125
+ cache_dir : str, pathlib.Path or None, default None
126
+ Directory used to cache downloaded files.
127
+
128
+ refresh : bool, default False
129
+ If True, refresh the table of contents before checking
130
+ the cached dictionary.
131
+
132
+ Returns
133
+ -------
134
+ dict
135
+ Dataset-specific dictionary.
136
+ """
137
+ cache_path = get_cache_dir(cache_dir)
138
+
139
+ last_update = get_last_update(
140
+ dataset_id=dataset_id,
141
+ segment=segment,
142
+ cache_dir=cache_dir,
143
+ refresh=refresh,
144
+ )
145
+
146
+ if last_update is None:
147
+ raise ValueError(
148
+ f"Dataset '{dataset_id}' was not found "
149
+ f"in the '{segment}' table of contents."
150
+ )
151
+
152
+ segment_dir = cache_path / segment
153
+ segment_dir.mkdir(
154
+ parents=True,
155
+ exist_ok=True,
156
+ )
157
+
158
+ filename = _versioned_cache_filename(
159
+ dataset_id=dataset_id,
160
+ last_update=last_update,
161
+ suffix="_dic.json",
162
+ )
163
+
164
+ dic_file = segment_dir / filename
165
+
166
+ if not dic_file.exists():
167
+ url = (
168
+ f"{DATA_BASE_URL}/{segment}/"
169
+ f"{dataset_id}_dic.json"
170
+ )
171
+
172
+ response = requests.get(
173
+ url,
174
+ timeout=60,
175
+ )
176
+ response.raise_for_status()
177
+
178
+ dic_file.write_bytes(
179
+ response.content
180
+ )
181
+
182
+ _remove_old_cache_files(
183
+ cache_dir=segment_dir,
184
+ dataset_id=dataset_id,
185
+ keep_file=dic_file,
186
+ suffix="_dic.json",
187
+ )
188
+
189
+ with dic_file.open(
190
+ "r",
191
+ encoding="utf-8",
192
+ ) as file:
193
+ return json.load(file)