pr-imports 0.2.0__tar.gz

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.
@@ -0,0 +1,11 @@
1
+ Metadata-Version: 2.3
2
+ Name: pr-imports
3
+ Version: 0.2.0
4
+ Summary: Add your description here
5
+ Requires-Dist: censusforge>=1.2.0
6
+ Requires-Dist: duckdb>=1.4.5
7
+ Requires-Dist: jp-tools>=0.5.8
8
+ Requires-Dist: polars>=1.36.1
9
+ Requires-Python: >=3.12
10
+ Description-Content-Type: text/markdown
11
+
File without changes
@@ -0,0 +1,19 @@
1
+ [project]
2
+ name = "pr-imports"
3
+ version = "0.2.0"
4
+ description = "Add your description here"
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ dependencies = [
8
+ "censusforge>=1.2.0",
9
+ "duckdb>=1.4.5",
10
+ "jp-tools>=0.5.8",
11
+ "polars>=1.36.1",
12
+ ]
13
+
14
+ [project.scripts]
15
+ pr-imports = "pr_imports:main"
16
+
17
+ [build-system]
18
+ requires = ["uv_build>=0.11.30,<0.12.0"]
19
+ build-backend = "uv_build"
@@ -0,0 +1,7 @@
1
+ from .utils import TradeUtils
2
+ #from .jp_imports import JPTrade
3
+
4
+ from importlib.metadata import version
5
+
6
+ __version__ = version("pr_imports")
7
+ __all__ = ["TradeUtils", "JPTrade"]
File without changes
@@ -0,0 +1,359 @@
1
+ import importlib.resources as resources
2
+ from datetime import datetime as dt
3
+
4
+ import polars as pl
5
+
6
+ from .utils import TradeUtils
7
+
8
+ LEVEL_GROUPS = {
9
+ "total": [],
10
+ "naics": ["naics"],
11
+ "hts": ["hts_code"],
12
+ "country": ["country"],
13
+ }
14
+
15
+ TIME_GROUPS = {
16
+ "yearly": ["year"],
17
+ "fiscal": ["fiscal_year"],
18
+ "qtr": ["year", "qtr"],
19
+ "monthly": ["year", "month"],
20
+ }
21
+
22
+
23
+ class JPTrade(TradeUtils):
24
+ """
25
+ Data processing class for the various data sources in DataPull.
26
+ Optimized to dynamically aggregate metrics using a configuration-driven design.
27
+ """
28
+
29
+ def __init__(
30
+ self,
31
+ saving_dir: str = "data/",
32
+ log_file: str = "data.log",
33
+ ):
34
+ """
35
+ Initialize the DataProcess class.
36
+ """
37
+ super().__init__(saving_dir, log_file)
38
+ self.agr_file = str(
39
+ resources.files("jp_imports").joinpath("resources/code_agr.json")
40
+ )
41
+
42
+ def process_int_jp(
43
+ self,
44
+ level: str,
45
+ time_frame: str,
46
+ datetime: str = "",
47
+ agriculture_filter: bool = False,
48
+ level_filter: str = "",
49
+ ) -> pl.DataFrame:
50
+ """
51
+ Process the data for Puerto Rico Statistics Institute provided to JP.
52
+ """
53
+ df = self.pull_int_jp()
54
+
55
+ if agriculture_filter:
56
+ df = df.filter(pl.col("agri_prod") == 1)
57
+
58
+ # Unified taxonomy filtering
59
+ if level in ["hts", "naics", "country"]:
60
+ level_map = {"hts": "hts_code", "naics": "naics", "country": "country"}
61
+ filter_col = level_map[level]
62
+
63
+ df = df.filter(pl.col(filter_col).str.starts_with(level_filter))
64
+ if df.is_empty():
65
+ raise ValueError(f"Invalid {level.upper()} code: {level_filter}")
66
+
67
+ # Streamlined date routing
68
+ if datetime:
69
+ times = datetime.split("+")
70
+ if len(times) == 2:
71
+ start_date = dt.strptime(times[0], "%Y-%m-%d")
72
+ end_date = dt.strptime(times[1], "%Y-%m-%d")
73
+ df = df.filter(pl.col("date").is_between(start_date, end_date))
74
+ elif len(times) == 1:
75
+ df = df.filter(pl.col("date").dt.year() == int(datetime))
76
+ else:
77
+ raise ValueError(
78
+ 'Invalid time format. Use "date" or "start_date+end_date"'
79
+ )
80
+
81
+ df = self.conversion(df)
82
+
83
+ return self.process_data(time_frame=time_frame, level=level, base=df)
84
+
85
+ def process_data(
86
+ self, time_frame: str, level: str, base: pl.DataFrame
87
+ ) -> pl.DataFrame:
88
+ """
89
+ Process the data based on dynamic groupings without logic duplication.
90
+ """
91
+ if time_frame not in TIME_GROUPS or level not in LEVEL_GROUPS:
92
+ raise ValueError(
93
+ f"Invalid combination layout requested: {time_frame=}, {level=}"
94
+ )
95
+
96
+ group_by_keys = TIME_GROUPS[time_frame] + LEVEL_GROUPS[level]
97
+
98
+ df = self.filter_data(base, group_by_keys)
99
+
100
+ coalesce_exprs = []
101
+ for col in group_by_keys:
102
+ right_col = f"{col}_right"
103
+ if right_col in df.columns:
104
+ coalesce_exprs.append(
105
+ pl.when(pl.col(col).is_null())
106
+ .then(pl.col(right_col))
107
+ .otherwise(pl.col(col))
108
+ .alias(col)
109
+ )
110
+
111
+ if coalesce_exprs:
112
+ df = df.with_columns(coalesce_exprs)
113
+
114
+ df = df.drop([c for c in df.columns if c.endswith("_right")])
115
+
116
+ target_metrics = ["imports", "exports", "imports_qty", "exports_qty"]
117
+ df = (
118
+ df.with_columns(pl.col(target_metrics).fill_null(0))
119
+ .sort(group_by_keys)
120
+ .with_columns(
121
+ net_exports=pl.col("exports") - pl.col("imports"),
122
+ net_qty=pl.col("exports_qty") - pl.col("imports_qty"),
123
+ )
124
+ )
125
+
126
+ return df
127
+
128
+ def process_price(self, agriculture_filter: bool = False) -> pl.DataFrame:
129
+ """
130
+ Calculate rolling price statistics on top of monthly item structures.
131
+ """
132
+ df = self.process_int_jp(
133
+ time_frame="monthly", level="hts", agriculture_filter=agriculture_filter
134
+ )
135
+ df = df.with_columns(pl.col("imports_qty", "exports_qty").replace(0, 1))
136
+ df = df.with_columns(hs4=pl.col("hts_code").str.slice(0, 4))
137
+
138
+ df = df.group_by(pl.col("hs4", "month", "year")).agg(
139
+ pl.col("imports").sum().alias("imports"),
140
+ pl.col("exports").sum().alias("exports"),
141
+ pl.col("imports_qty").sum().alias("imports_qty"),
142
+ pl.col("exports_qty").sum().alias("exports_qty"),
143
+ )
144
+
145
+ df = df.with_columns(
146
+ price_imports=pl.col("imports") / pl.col("imports_qty"),
147
+ price_exports=pl.col("exports") / pl.col("exports_qty"),
148
+ )
149
+
150
+ df = df.with_columns(date=pl.datetime(pl.col("year"), pl.col("month"), 1)).sort(
151
+ "date"
152
+ )
153
+
154
+ # Rolling Statistical Engine
155
+ results = df.with_columns(
156
+ pl.col("price_imports")
157
+ .rolling_mean(window_size=3, min_samples=1)
158
+ .over("hs4")
159
+ .alias("moving_price_imports"),
160
+ pl.col("price_exports")
161
+ .rolling_mean(window_size=3, min_samples=1)
162
+ .over("hs4")
163
+ .alias("moving_price_exports"),
164
+ pl.col("price_imports")
165
+ .rolling_std(window_size=3, min_samples=1)
166
+ .over("hs4")
167
+ .alias("moving_price_imports_std"),
168
+ pl.col("price_exports")
169
+ .rolling_std(window_size=3, min_samples=1)
170
+ .over("hs4")
171
+ .alias("moving_price_exports_std"),
172
+ ).with_columns(
173
+ pl.col("moving_price_imports")
174
+ .rank("ordinal")
175
+ .over("date")
176
+ .alias("rank_imports")
177
+ .cast(pl.Int64),
178
+ pl.col("moving_price_exports")
179
+ .rank("ordinal")
180
+ .over("date")
181
+ .alias("rank_exports")
182
+ .cast(pl.Int64),
183
+ upper_band_imports=pl.col("moving_price_imports")
184
+ + 2 * pl.col("moving_price_imports_std"),
185
+ lower_band_imports=pl.col("moving_price_imports")
186
+ - 2 * pl.col("moving_price_imports_std"),
187
+ upper_band_exports=pl.col("moving_price_exports")
188
+ + 2 * pl.col("moving_price_exports_std"),
189
+ lower_band_exports=pl.col("moving_price_exports")
190
+ - 2 * pl.col("moving_price_exports_std"),
191
+ )
192
+
193
+ results = df.join(results, on=["date", "hs4"], how="left", validate="1:1")
194
+
195
+ # Clean up overlap column names after explicit join validation
196
+ results = results.with_columns(
197
+ year=pl.when(pl.col("year").is_null())
198
+ .then(pl.col("year_right"))
199
+ .otherwise(pl.col("year")),
200
+ month=pl.when(pl.col("month").is_null())
201
+ .then(pl.col("month_right"))
202
+ .otherwise(pl.col("month")),
203
+ imports=pl.when(pl.col("imports").is_null())
204
+ .then(pl.col("imports_right"))
205
+ .otherwise(pl.col("imports")),
206
+ exports=pl.when(pl.col("exports").is_null())
207
+ .then(pl.col("exports_right"))
208
+ .otherwise(pl.col("exports")),
209
+ price_imports=pl.when(pl.col("price_imports").is_null())
210
+ .then(pl.col("price_imports_right"))
211
+ .otherwise(pl.col("price_imports")),
212
+ price_exports=pl.when(pl.col("price_exports").is_null())
213
+ .then(pl.col("price_exports_right"))
214
+ .otherwise(pl.col("price_exports")),
215
+ imports_qty=pl.when(pl.col("imports_qty").is_null())
216
+ .then(pl.col("exports_qty_right"))
217
+ .otherwise(pl.col("imports_qty")),
218
+ exports_qty=pl.when(pl.col("exports_qty").is_null())
219
+ .then(pl.col("exports_qty"))
220
+ .otherwise(pl.col("exports_qty")),
221
+ ).drop([c for c in results.columns if c.endswith("_right")])
222
+
223
+ # Track month shifts for sequential year-over-year deltas
224
+ results = results.with_columns(
225
+ pl.col("moving_price_imports")
226
+ .pct_change()
227
+ .over("date", "hs4")
228
+ .alias("pct_change_imports")
229
+ ).sort(by=["date", "hs4"])
230
+
231
+ results = results.with_columns(
232
+ pl.when(pl.col("date").dt.year() > 1)
233
+ .then(pl.col("moving_price_imports").shift(12))
234
+ .otherwise(None)
235
+ .alias("prev_year_imports"),
236
+ pl.when(pl.col("date").dt.year() > 1)
237
+ .then(pl.col("moving_price_exports").shift(12))
238
+ .otherwise(None)
239
+ .alias("prev_year_exports"),
240
+ pl.when(pl.col("date").dt.year() > 1)
241
+ .then(pl.col("rank_imports").shift(12))
242
+ .otherwise(None)
243
+ .alias("prev_year_rank_imports"),
244
+ pl.when(pl.col("date").dt.year() > 1)
245
+ .then(pl.col("rank_exports").shift(12))
246
+ .otherwise(None)
247
+ .alias("prev_year_rank_exports"),
248
+ )
249
+
250
+ results = results.with_columns(
251
+ (
252
+ (pl.col("moving_price_imports") - pl.col("prev_year_imports"))
253
+ / pl.col("prev_year_imports")
254
+ ).alias("pct_change_imports_year_over_year"),
255
+ (
256
+ (pl.col("moving_price_exports") - pl.col("prev_year_exports"))
257
+ / pl.col("prev_year_exports")
258
+ ).alias("pct_change_exports_year_over_year"),
259
+ (pl.col("rank_imports") - pl.col("prev_year_rank_imports")).alias(
260
+ "rank_imports_change_year_over_year"
261
+ ),
262
+ (
263
+ pl.col("rank_exports").cast(pl.Int64)
264
+ - pl.col("prev_year_rank_exports").cast(pl.Int64)
265
+ ).alias("rank_exports_change_year_over_year"),
266
+ ).sort(by=["date", "hs4"])
267
+
268
+ return results
269
+
270
+ def conversion(self, df: pl.DataFrame) -> pl.DataFrame:
271
+ """
272
+ Convert the data to the correct units (kg).
273
+
274
+ Parameters
275
+ ----------
276
+ df: pl.LazyFrame
277
+ Data to convert.
278
+
279
+ Returns
280
+ -------
281
+ pl.LazyFrame
282
+ Converted data.
283
+ """
284
+
285
+ df = df.with_columns(pl.col("qty_1", "qty_2").fill_null(strategy="zero"))
286
+ df = df.with_columns(
287
+ conv_1=pl.when(pl.col("unit_1").str.to_lowercase() == "kg")
288
+ .then(pl.col("qty_1") * 1)
289
+ .when(pl.col("unit_1").str.to_lowercase() == "l")
290
+ .then(pl.col("qty_1") * 1)
291
+ .when(pl.col("unit_1").str.to_lowercase() == "doz")
292
+ .then(pl.col("qty_1") / 0.756)
293
+ .when(pl.col("unit_1").str.to_lowercase() == "m3")
294
+ .then(pl.col("qty_1") * 1560)
295
+ .when(pl.col("unit_1").str.to_lowercase() == "t")
296
+ .then(pl.col("qty_1") * 907.185)
297
+ .when(pl.col("unit_1").str.to_lowercase() == "kts")
298
+ .then(pl.col("qty_1") * 1)
299
+ .when(pl.col("unit_1").str.to_lowercase() == "pfl")
300
+ .then(pl.col("qty_1") * 0.789)
301
+ .when(pl.col("unit_1").str.to_lowercase() == "gm")
302
+ .then(pl.col("qty_1") * 1000)
303
+ .otherwise(pl.col("qty_1")),
304
+ conv_2=pl.when(pl.col("unit_2").str.to_lowercase() == "kg")
305
+ .then(pl.col("qty_2") * 1)
306
+ .when(pl.col("unit_2").str.to_lowercase() == "l")
307
+ .then(pl.col("qty_2") * 1)
308
+ .when(pl.col("unit_2").str.to_lowercase() == "doz")
309
+ .then(pl.col("qty_2") / 0.756)
310
+ .when(pl.col("unit_2").str.to_lowercase() == "m3")
311
+ .then(pl.col("qty_2") * 1560)
312
+ .when(pl.col("unit_2").str.to_lowercase() == "t")
313
+ .then(pl.col("qty_2") * 907.185)
314
+ .when(pl.col("unit_2").str.to_lowercase() == "kts")
315
+ .then(pl.col("qty_2") * 1)
316
+ .when(pl.col("unit_2").str.to_lowercase() == "pfl")
317
+ .then(pl.col("qty_2") * 0.789)
318
+ .when(pl.col("unit_2").str.to_lowercase() == "gm")
319
+ .then(pl.col("qty_2") * 1000)
320
+ .otherwise(pl.col("qty_2")),
321
+ qtr=pl.when(
322
+ (pl.col("date").dt.month() >= 1) & (pl.col("date").dt.month() <= 3)
323
+ )
324
+ .then(1)
325
+ .when((pl.col("date").dt.month() >= 4) & (pl.col("date").dt.month() <= 6))
326
+ .then(2)
327
+ .when((pl.col("date").dt.month() >= 7) & (pl.col("date").dt.month() <= 9))
328
+ .then(3)
329
+ .when((pl.col("date").dt.month() >= 10) & (pl.col("date").dt.month() <= 12))
330
+ .then(4),
331
+ fiscal_year=pl.when(pl.col("date").dt.month() > 6)
332
+ .then(pl.col("date").dt.year() + 1)
333
+ .otherwise(pl.col("date").dt.year())
334
+ .alias("fiscal_year"),
335
+ month=pl.col("date").dt.month(),
336
+ year=pl.col("date").dt.year(),
337
+ ).with_columns(qty=pl.col("conv_1") + pl.col("conv_2"))
338
+ return df
339
+
340
+ def filter_data(self, df: pl.DataFrame, filter: list) -> pl.DataFrame:
341
+ """
342
+ Filter the data based on the filter list.
343
+ """
344
+ df = df.filter(pl.col("hts_code").is_not_null())
345
+ imports = (
346
+ df.filter(pl.col("trade_id") == 1)
347
+ .group_by(filter)
348
+ .agg(pl.sum("data", "qty"))
349
+ .sort(filter)
350
+ .rename({"data": "imports", "qty": "imports_qty"})
351
+ )
352
+ exports = (
353
+ df.filter(pl.col("trade_id") == 2)
354
+ .group_by(filter)
355
+ .agg(pl.sum("data", "qty"))
356
+ .sort(filter)
357
+ .rename({"data": "exports", "qty": "exports_qty"})
358
+ )
359
+ return imports.join(exports, on=filter, how="full")
@@ -0,0 +1,131 @@
1
+ {
2
+ "0": 405,
3
+ "1": 1514,
4
+ "2": 1206,
5
+ "3": 2105,
6
+ "4": 208,
7
+ "5": 302,
8
+ "6": 2104,
9
+ "7": 802,
10
+ "8": 301,
11
+ "9": 1504,
12
+ "10": 1508,
13
+ "11": 804,
14
+ "12": 307,
15
+ "13": 807,
16
+ "14": 708,
17
+ "15": 1105,
18
+ "16": 404,
19
+ "17": 1103,
20
+ "18": 106,
21
+ "19": 2102,
22
+ "20": 908,
23
+ "21": 906,
24
+ "22": 1511,
25
+ "23": 410,
26
+ "24": 2006,
27
+ "25": 1106,
28
+ "26": 703,
29
+ "27": 209,
30
+ "28": 704,
31
+ "29": 2009,
32
+ "30": 713,
33
+ "31": 201,
34
+ "32": 910,
35
+ "33": 1701,
36
+ "34": 1517,
37
+ "35": 710,
38
+ "36": 408,
39
+ "37": 210,
40
+ "38": 1104,
41
+ "39": 1209,
42
+ "40": 206,
43
+ "41": 403,
44
+ "42": 1516,
45
+ "43": 1101,
46
+ "44": 1605,
47
+ "45": 803,
48
+ "46": 706,
49
+ "47": 801,
50
+ "48": 1509,
51
+ "49": 701,
52
+ "50": 1902,
53
+ "51": 907,
54
+ "52": 1806,
55
+ "53": 714,
56
+ "54": 2103,
57
+ "55": 2008,
58
+ "56": 1005,
59
+ "57": 1302,
60
+ "58": 2007,
61
+ "59": 909,
62
+ "60": 1207,
63
+ "61": 810,
64
+ "62": 1602,
65
+ "63": 1202,
66
+ "64": 811,
67
+ "65": 1502,
68
+ "66": 2004,
69
+ "67": 1901,
70
+ "68": 306,
71
+ "69": 202,
72
+ "70": 1601,
73
+ "71": 1518,
74
+ "72": 1109,
75
+ "73": 709,
76
+ "74": 303,
77
+ "75": 712,
78
+ "76": 1805,
79
+ "77": 707,
80
+ "78": 2005,
81
+ "79": 1905,
82
+ "80": 406,
83
+ "81": 904,
84
+ "82": 1214,
85
+ "83": 806,
86
+ "84": 207,
87
+ "85": 808,
88
+ "86": 1507,
89
+ "87": 2001,
90
+ "88": 705,
91
+ "89": 2101,
92
+ "90": 1513,
93
+ "91": 304,
94
+ "92": 1704,
95
+ "93": 203,
96
+ "94": 1212,
97
+ "95": 407,
98
+ "96": 401,
99
+ "97": 1804,
100
+ "98": 813,
101
+ "99": 1102,
102
+ "100": 1211,
103
+ "101": 1702,
104
+ "102": 1904,
105
+ "103": 1008,
106
+ "104": 1604,
107
+ "105": 402,
108
+ "106": 204,
109
+ "107": 1107,
110
+ "108": 305,
111
+ "109": 1108,
112
+ "110": 2002,
113
+ "111": 409,
114
+ "112": 805,
115
+ "113": 809,
116
+ "114": 2106,
117
+ "115": 901,
118
+ "116": 1510,
119
+ "117": 1512,
120
+ "118": 2003,
121
+ "119": 1515,
122
+ "120": 902,
123
+ "121": 1301,
124
+ "122": 1006,
125
+ "123": 1501,
126
+ "124": 1004,
127
+ "125": 711,
128
+ "126": 702,
129
+ "127": 905,
130
+ "128": 1001
131
+ }
@@ -0,0 +1,411 @@
1
+ import hashlib
2
+ import importlib.resources as resources
3
+ import logging
4
+ import shutil
5
+ import subprocess
6
+ import tempfile
7
+ import zipfile
8
+ from pathlib import Path
9
+
10
+ import duckdb
11
+ import polars as pl
12
+ from CensusForge import CensusAPI
13
+ from jp_tools import download
14
+
15
+
16
+ class TradeUtils:
17
+ """
18
+ This class pulls data from the CENSUS and the Puerto Rico Institute of Statistics
19
+
20
+ """
21
+
22
+ def __init__(
23
+ self,
24
+ saving_dir: str = "data/",
25
+ log_file: str = "data.log",
26
+ ):
27
+ self.saving_dir = Path(saving_dir)
28
+ self.conn = duckdb.connect()
29
+
30
+ logging.basicConfig(
31
+ level=logging.INFO,
32
+ format="%(asctime)s - %(levelname)s - %(message)s",
33
+ datefmt="%d-%b-%y %H:%M:%S",
34
+ filename=log_file,
35
+ )
36
+
37
+ def pull_int_jp(self, update: bool = False) -> pl.DataFrame:
38
+ """
39
+ Pulls data from the Puerto Rico Institute of Statistics used by the JP.
40
+ Saved them in the raw directory as parquet files.
41
+
42
+ Parameters
43
+ ----------
44
+ None
45
+
46
+ Returns
47
+ -------
48
+ None
49
+ """
50
+ output_dir = (
51
+ self.saving_dir
52
+ / "raw"
53
+ )
54
+
55
+ output_dir.mkdir(parents=True, exist_ok=True)
56
+ file_path = output_dir / "jp_data"
57
+
58
+
59
+ file_path = self.saving_dir / "raw" / "jp_data.parquet"
60
+ name_hash = hashlib.md5(str(file_path).encode()).hexdigest()
61
+ temp_csv = Path(tempfile.gettempdir()) / f"{name_hash}.csv"
62
+
63
+ if not file_path.exists() or update:
64
+
65
+ headers = {
66
+ "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
67
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
68
+ }
69
+
70
+ download(
71
+ url="https://datos.estadisticas.pr/dataset/027ddbe1-c51c-46bf-aec3-a62d5d7e8539/resource/b8367825-a3de-41cf-8794-e42c10987b6f/download/ftrade_all_iepr.csv",
72
+ filename=str(temp_csv),
73
+ verify=False,
74
+ timeout=120,
75
+ headers=headers,
76
+ )
77
+ df = pl.read_csv(temp_csv, ignore_errors=True)
78
+
79
+ agri_prod = pl.read_json(
80
+ str(resources.files("pr_imports").joinpath("resources/code_agr.json"))
81
+ ).transpose()
82
+ agri_prod = (
83
+ agri_prod.with_columns(pl.nth(0).cast(pl.String).str.zfill(4))
84
+ .to_series()
85
+ .to_list()
86
+ )
87
+ df = df.rename({col: col.lower() for col in df.collect_schema().names()})
88
+ df = df.with_columns(
89
+ date=pl.col("year").cast(pl.String)
90
+ + "-"
91
+ + pl.col("month").cast(pl.String)
92
+ + "-01",
93
+ unit_1=pl.col("unit_1").str.to_lowercase(),
94
+ unit_2=pl.col("unit_2").str.to_lowercase(),
95
+ hts_code=pl.col("commodity_code")
96
+ .cast(pl.String)
97
+ .str.zfill(10)
98
+ .str.replace("'", ""),
99
+ trade_id=pl.when(pl.col("trade") == "i").then(1).otherwise(2),
100
+ )
101
+
102
+ df = df.with_columns(pl.col("date").cast(pl.Date))
103
+
104
+ df = df.with_columns(
105
+ agri_prod=pl.when(pl.col("hts_code").is_in(agri_prod))
106
+ .then(1)
107
+ .otherwise(0)
108
+ )
109
+ df = df.with_columns(
110
+ sitc=pl.when(pl.col("sitc_short_desc").str.starts_with("Civilian"))
111
+ .then(9998)
112
+ .when(pl.col("sitc_short_desc").str.starts_with("-"))
113
+ .then(9999)
114
+ .otherwise(pl.col("sitc"))
115
+ )
116
+ df = df.filter(pl.col("hts_code").is_not_null())
117
+ df = df.select(
118
+ pl.col(
119
+ "date",
120
+ "country",
121
+ "trade_id",
122
+ "agri_prod",
123
+ "hts_code",
124
+ "hts_desc",
125
+ "data",
126
+ "qty_1",
127
+ "unit_1",
128
+ "qty_2",
129
+ "unit_2",
130
+ "sitc",
131
+ "naics",
132
+ )
133
+ )
134
+
135
+ df.write_parquet(file_path)
136
+
137
+ return pl.read_parquet(file_path)
138
+
139
+ def pull_int_org(self, update: bool = False, export: bool = True) -> pl.DataFrame:
140
+ """
141
+ Downloads, extracts, and unifies organizational data from the Puerto Rico
142
+ Institute of Statistics.
143
+
144
+ The process involves a nested extraction: a parent ZIP contains multiple
145
+ inner ZIPs, which in turn contain the target CSV files. Due to known
146
+ formatting issues with the source ZIP files, the method employs a fallback
147
+ to the '7z' system utility if the standard zipfile library fails.
148
+
149
+ Args:
150
+ update (bool): If True, ignores existing local parquet files and
151
+ re-downloads/processes the data. Defaults to False.
152
+
153
+ Returns:
154
+ pl.DataFrame: A Polars DataFrame containing the unified data from all
155
+ extracted CSVs.
156
+
157
+ Note:
158
+ Requires '7z' to be installed in the system PATH as a fallback for
159
+ malformed archive handling.
160
+ """
161
+
162
+ # Define Output file and hash
163
+ parquet_export_path = self.saving_dir / "raw" / "jp_org_exports.parquet"
164
+ parquet_import_path = self.saving_dir / "raw" / "jp_org_imports.parquet"
165
+ name_hash = hashlib.md5(str(parquet_export_path).encode()).hexdigest()
166
+
167
+ # Define Temporary directories
168
+ temp_zip = Path(tempfile.gettempdir()) / f"jp_org_zip_{name_hash}.zip"
169
+ stage1_dir = Path(tempfile.gettempdir()) / f"stage1_{name_hash}"
170
+ stage2_dir = Path(tempfile.gettempdir()) / f"stage2_{name_hash}"
171
+
172
+ if not parquet_export_path.exists() or update:
173
+
174
+ download(
175
+ url="http://apps.estadisticas.pr/iepr/LinkClick.aspx?fileticket=JVyYmIHqbqc%3d&tabid=284&mid=244930",
176
+ filename=(str(temp_zip)),
177
+ )
178
+
179
+ # Stage 1: Extract all Parent zip file
180
+ # WARNING: Added the use of subprocess due to mal formated zip from estadisticas.pr.gov
181
+
182
+ stage1_dir.mkdir(parents=True, exist_ok=True)
183
+
184
+ try:
185
+ with zipfile.ZipFile(temp_zip, "r") as zip_ref:
186
+ zip_ref.extractall(stage1_dir)
187
+
188
+ except zipfile.BadZipFile:
189
+ print("Warning: Used 7z subprocess due to mall formated file")
190
+ subprocess.run(
191
+ ["7z", "x", str(temp_zip), f"-o{stage1_dir}", "-y"],
192
+ capture_output=True,
193
+ )
194
+
195
+ stage2_dir.mkdir(parents=True, exist_ok=True)
196
+ inner_zips = list(stage1_dir.rglob("*.zip"))
197
+
198
+ for izip in inner_zips:
199
+ try:
200
+ with zipfile.ZipFile(izip, "r") as zip_ref:
201
+ # Extracting to our second temp directory
202
+ zip_ref.extractall(stage2_dir)
203
+ except zipfile.BadZipFile:
204
+ print(
205
+ f"Warning: {izip.name} was also malformed. Switching back to 7-zip for this file."
206
+ )
207
+ subprocess.run(
208
+ ["7z", "x", str(izip), f"-o{stage2_dir}", "-y"],
209
+ capture_output=True,
210
+ )
211
+
212
+ # Stage 3: unify data into a single file
213
+ csv_files = list(stage2_dir.rglob("*.csv"))
214
+ if not csv_files:
215
+ raise FileNotFoundError(
216
+ "Extraction successful, but no CSV files were found to process."
217
+ )
218
+
219
+ df_list = [pl.read_csv(f, ignore_errors=True) for f in csv_files]
220
+ df = pl.concat(df_list)
221
+ df = df.with_columns(
222
+ country=pl.col("country").str.to_lowercase(),
223
+ hts=pl.col("HTS").str.replace("'", ""),
224
+ date=(
225
+ pl.col("year").cast(pl.String)
226
+ + "-"
227
+ + pl.col("month").cast(pl.String).str.zfill(2)
228
+ + "-01"
229
+ ).str.to_datetime("%Y-%m-%d"),
230
+ )
231
+ df = df.select(
232
+ pl.col(
233
+ "date",
234
+ "country",
235
+ "hts",
236
+ "unit_1",
237
+ "qty_1",
238
+ "unit_2",
239
+ "qty_2",
240
+ "value",
241
+ "import_export",
242
+ )
243
+ )
244
+
245
+ # Save Export Data
246
+ df_exports = df.filter(pl.col("import_export") == "e").drop("import_export")
247
+ df_exports.write_parquet(parquet_export_path)
248
+
249
+ # Save import data
250
+ df_imports = df.filter(pl.col("import_export") == "i").drop("import_export")
251
+ df_imports.write_parquet(parquet_import_path)
252
+
253
+ # Stage 4: Cleanup
254
+ shutil.rmtree(stage1_dir)
255
+ shutil.rmtree(stage2_dir)
256
+ temp_zip.unlink(missing_ok=True)
257
+
258
+ if export:
259
+ return pl.read_parquet(parquet_export_path)
260
+ else:
261
+ return pl.read_parquet(parquet_import_path)
262
+
263
+ def pull_census_hts(self, exports: bool, state: str) -> pl.DataFrame:
264
+ """
265
+ Pulls HTS data from the Census and saves them in a parquet file.
266
+
267
+ Parameters
268
+ ----------
269
+ end_year: int
270
+ The last year to pull data from.
271
+ start_year: int
272
+ The first year to pull data from.
273
+ exports: bool
274
+ If True, pulls exports data. If False, pulls imports data.
275
+ state: str
276
+ The state to pull data from (e.g. "PR" for Puerto Rico).
277
+
278
+ Returns
279
+ -------
280
+ None
281
+ """
282
+
283
+ for _year in range(2010, 2023):
284
+ exports_path = Path(
285
+ f"{self.saving_dir}raw/census-hts-exports-{state}-{_year}.parquet"
286
+ )
287
+ imports_path = Path(
288
+ f"{self.saving_dir}raw/census-hts-imports-{state}-{_year}.parquet"
289
+ )
290
+
291
+ if exports_path.exists() and imports_path.exists():
292
+ continue
293
+
294
+ req_exports = CensusAPI().timeseries_query(
295
+ dataset="timeseries-intltrade-exports-statehs",
296
+ params_list=[
297
+ "CTY_CODE",
298
+ "CTY_NAME",
299
+ "ALL_VAL_MO",
300
+ "COMM_LVL",
301
+ "E_COMMODITY",
302
+ ],
303
+ year=_year,
304
+ extra=f"STATE={state}",
305
+ skip_checks=True,
306
+ )
307
+
308
+ req_imports = CensusAPI().timeseries_query(
309
+ dataset="timeseries-intltrade-imports-statehs",
310
+ params_list=[
311
+ "CTY_CODE",
312
+ "CTY_NAME",
313
+ "GEN_VAL_MO",
314
+ "COMM_LVL",
315
+ "I_COMMODITY",
316
+ ],
317
+ year=_year,
318
+ extra=f"STATE={state}",
319
+ skip_checks=True,
320
+ )
321
+
322
+ df_exports = pl.DataFrame(req_exports)
323
+ df_exports.write_parquet(exports_path)
324
+
325
+ df_imports = pl.DataFrame(req_imports)
326
+ df_imports.write_parquet(imports_path)
327
+ if exports:
328
+ return self.conn.execute(
329
+ f"SELECT * FROM '{self.saving_dir}raw/census-hts-exports-{state}-*.parquet';"
330
+ ).pl()
331
+ else:
332
+ return self.conn.execute(
333
+ f"SELECT * FROM '{self.saving_dir}raw/census-hts-imports-{state}-*.parquet';"
334
+ ).pl()
335
+
336
+ def pull_census_naics(self, exports: bool, state: str) -> pl.DataFrame:
337
+ """
338
+ Pulls NAICS data from the Census and saves them in a parquet file.
339
+
340
+ Parameters
341
+ ----------
342
+ end_year: int
343
+ The last year to pull data from.
344
+ start_year: int
345
+ The first year to pull data from.
346
+ exports: bool
347
+ If True, pulls exports data. If False, pulls imports data.
348
+ state: str
349
+ The state to pull data from (e.g. "PR" for Puerto Rico).
350
+
351
+ Returns
352
+ -------
353
+ None
354
+ """
355
+
356
+ for _year in range(2010, 2023):
357
+ exports_path = Path(
358
+ f"{self.saving_dir}raw/census-naics-exports-{state}-{_year}.parquet"
359
+ )
360
+ imports_path = Path(
361
+ f"{self.saving_dir}raw/census-naics-imports-{state}-{_year}.parquet"
362
+ )
363
+
364
+ if exports_path.exists() and imports_path.exists():
365
+ continue
366
+
367
+ req_exports = CensusAPI().timeseries_query(
368
+ dataset="timeseries-intltrade-exports-statenaics",
369
+ params_list=[
370
+ "CTY_CODE",
371
+ "CTY_NAME",
372
+ "ALL_VAL_MO",
373
+ "COMM_LVL",
374
+ "NAICS",
375
+ ],
376
+ year=_year,
377
+ extra=f"STATE={state}",
378
+ skip_checks=True,
379
+ )
380
+
381
+ req_imports = CensusAPI().timeseries_query(
382
+ dataset="timeseries-intltrade-imports-statenaics",
383
+ params_list=[
384
+ "CTY_CODE",
385
+ "CTY_NAME",
386
+ "GEN_VAL_MO",
387
+ "COMM_LVL",
388
+ "NAICS",
389
+ ],
390
+ year=_year,
391
+ extra=f"STATE={state}",
392
+ skip_checks=True,
393
+ )
394
+
395
+ df_exports = pl.DataFrame(req_exports)
396
+ df_exports = df_exports.rename(df_exports.row(0, named=True))
397
+ df_exports = df_exports.slice(1)
398
+ df_exports.write_parquet(exports_path)
399
+
400
+ df_imports = pl.DataFrame(req_imports)
401
+ df_imports = df_imports.rename(df_imports.row(0, named=True))
402
+ df_imports = df_imports.slice(1)
403
+ df_imports.write_parquet(imports_path)
404
+ if exports:
405
+ return self.conn.execute(
406
+ f"SELECT * FROM '{self.saving_dir}raw/census-naics-exports-{state}-*.parquet';"
407
+ ).pl()
408
+ else:
409
+ return self.conn.execute(
410
+ f"SELECT * FROM '{self.saving_dir}raw/census-naics-imports-{state}-*.parquet';"
411
+ ).pl()