gdeltforge 0.4.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.
gdeltforge/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ try:
2
+ from gdeltforge._version import __version__
3
+ except ImportError:
4
+ # _version.py is generated at build/install time by hatch-vcs and
5
+ # won't exist yet in a fresh checkout that hasn't been built.
6
+ __version__ = "0.0.0.dev0"
gdeltforge/_version.py ADDED
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.4.0'
22
+ __version_tuple__ = version_tuple = (0, 4, 0)
23
+
24
+ __commit_id__ = commit_id = None
gdeltforge/cli.py ADDED
@@ -0,0 +1,567 @@
1
+ import argparse
2
+ import json
3
+ import sys
4
+ from datetime import date
5
+ from pathlib import Path
6
+
7
+ import pandas as pd
8
+
9
+ from gdeltforge.conversion.converter import run_converter
10
+ from gdeltforge.crossref.crossref import crossref_events_gkg_v1, crossref_events_gkg_v2
11
+ from gdeltforge.filtering.filter import run_filter
12
+
13
+ # Samplers
14
+ from gdeltforge.sampling import cameo_codes
15
+ from gdeltforge.sampling.samplers import (
16
+ DailySampler,
17
+ FilteredSampler,
18
+ IndexedSampler,
19
+ )
20
+
21
+ # Pipeline stages
22
+ from gdeltforge.scraping.scraper import run_scraping_pipeline
23
+ from gdeltforge.utils.config import dataset_path_key, load_config
24
+ from gdeltforge.utils.io import ensure_exists, write_parquet_atomic
25
+ from gdeltforge.utils.logging import get_logger
26
+
27
+ # ======================================================================
28
+ # Utilities
29
+ # ======================================================================
30
+
31
+ logger = get_logger(__name__, log_to_file=True)
32
+
33
+ # CLI-facing --dataset choices, mapped to the dataset keys used throughout
34
+ # config (columns / columns_numeric / filter.columns_to_check / paths.*
35
+ # via dataset_path_key). GKG's two format generations (pre-2015 "v1" and
36
+ # the current, actively-produced "v2") are different schemas with
37
+ # different files, so they're exposed as distinct choices rather than
38
+ # one ambiguous "gkg".
39
+ _DATASET_CHOICES = ["events", "gkg-v1", "gkg-v1-counts", "gkg-v2", "mentions"]
40
+ _DATASET_CLI_TO_CONFIG = {
41
+ "events": "gdelt_event",
42
+ "gkg-v1": "gdelt_gkg_v1",
43
+ "gkg-v1-counts": "gdelt_gkg_v1_counts",
44
+ "gkg-v2": "gdelt_gkg_v2",
45
+ "mentions": "gdelt_mentions",
46
+ }
47
+
48
+ # crossref's own choices: a GKG generation to join against, not a dataset
49
+ # to read directly (v2 pulls in Mentions internally as the join bridge).
50
+ _CROSSREF_GKG_CHOICES = ["v1", "v1-counts", "v2"]
51
+ _CROSSREF_GKG_TO_CONFIG = {
52
+ "v1": "gdelt_gkg_v1",
53
+ "v1-counts": "gdelt_gkg_v1_counts",
54
+ }
55
+
56
+
57
+ def _historical_folder(config: dict, path_key: str) -> str | None:
58
+ """Return the historical directory path when partitioning is enabled, else None."""
59
+ part_cfg = config.get("converter", {}).get("partitioning", {})
60
+ if not part_cfg.get("enabled", False):
61
+ return None
62
+ return config["paths"].get(path_key)
63
+
64
+ # ======================================================================
65
+ # Subcommand Runners
66
+ # ======================================================================
67
+
68
+ def _parse_date(value: str, arg_name: str) -> date:
69
+ try:
70
+ return date.fromisoformat(value)
71
+ except ValueError as e:
72
+ raise ValueError(f"Invalid date for {arg_name}: '{value}'. Expected YYYY-MM-DD.") from e
73
+
74
+
75
+ def run_scrape_cmd(config: dict, args: argparse.Namespace) -> None:
76
+ start_date = _parse_date(args.start_date, "--start-date") if args.start_date else None
77
+ end_date = _parse_date(args.end_date, "--end-date") if args.end_date else None
78
+
79
+ if start_date and end_date and start_date > end_date:
80
+ raise ValueError(f"--start-date ({start_date}) must not be after --end-date ({end_date}).")
81
+
82
+ dataset = _DATASET_CLI_TO_CONFIG[args.dataset]
83
+ logger.info("Starting scraping stage...")
84
+ result = run_scraping_pipeline(
85
+ config, start_date=start_date, end_date=end_date, dataset=dataset
86
+ )
87
+ logger.info("Scraping completed.")
88
+
89
+ failed = result["failed"]
90
+ if failed:
91
+ raise RuntimeError(
92
+ f"Scraping finished with {len(failed)} failed download(s): {', '.join(failed)}"
93
+ )
94
+
95
+
96
+ def run_convert_cmd(config: dict, args: argparse.Namespace) -> None:
97
+ start_date = _parse_date(args.start_date, "--start-date") if args.start_date else None
98
+ end_date = _parse_date(args.end_date, "--end-date") if args.end_date else None
99
+
100
+ if start_date and end_date and start_date > end_date:
101
+ raise ValueError(f"--start-date ({start_date}) must not be after --end-date ({end_date}).")
102
+
103
+ dataset = _DATASET_CLI_TO_CONFIG[args.dataset]
104
+ logger.info("Starting conversion stage...")
105
+ outputs, failed = run_converter(
106
+ config, dataset=dataset, start_date=start_date, end_date=end_date
107
+ )
108
+ logger.info(f"Created {len(outputs)} parquet files.")
109
+
110
+ if failed:
111
+ raise RuntimeError(
112
+ f"Conversion finished with {len(failed)} failed file(s): {', '.join(failed)}"
113
+ )
114
+
115
+
116
+ def run_filter_cmd(config: dict, args: argparse.Namespace) -> None:
117
+ start_date = _parse_date(args.start_date, "--start-date") if args.start_date else None
118
+ end_date = _parse_date(args.end_date, "--end-date") if args.end_date else None
119
+
120
+ if start_date and end_date and start_date > end_date:
121
+ raise ValueError(f"--start-date ({start_date}) must not be after --end-date ({end_date}).")
122
+
123
+ dataset = _DATASET_CLI_TO_CONFIG[args.dataset]
124
+ logger.info("Starting filtering stage...")
125
+ files_processed, files_failed = run_filter(
126
+ config, dataset=dataset, start_date=start_date, end_date=end_date
127
+ )
128
+ logger.info("Filtering completed.")
129
+
130
+ if files_failed:
131
+ raise RuntimeError(
132
+ f"Filtering finished with {files_failed} failed file(s) out of "
133
+ f"{files_processed + files_failed}."
134
+ )
135
+
136
+
137
+ def run_sampling_cmd(config: dict, args: argparse.Namespace) -> None:
138
+ dataset = _DATASET_CLI_TO_CONFIG[args.dataset]
139
+ source_key, historical_key = (
140
+ ("filtered_data_directory", "filtered_historical_directory")
141
+ if args.source == "filtered"
142
+ else ("parquet_data_directory", "parquet_historical_directory")
143
+ )
144
+ source_key = dataset_path_key(dataset, source_key)
145
+ historical_key = dataset_path_key(dataset, historical_key)
146
+ source_folder = ensure_exists(config["paths"][source_key], source_key)
147
+
148
+ out = Path(args.out)
149
+
150
+ # Create parent folder if it does not exist
151
+ out.parent.mkdir(parents=True, exist_ok=True)
152
+
153
+ hist_folder = _historical_folder(config, historical_key)
154
+ columns = set(args.columns) if args.columns else None
155
+
156
+ # -----------------------------
157
+ # Indexed Sampling
158
+ # -----------------------------
159
+ if args.mode == "indexed":
160
+ sampler = IndexedSampler(
161
+ folder_path=str(source_folder),
162
+ historical_folder=hist_folder,
163
+ random_state=args.seed,
164
+ columns=columns,
165
+ )
166
+ df = sampler.get_random_sample(args.n)
167
+ write_parquet_atomic(df, out)
168
+ logger.info(f"Saved indexed sample ({len(df)} rows) -> {out}")
169
+ return
170
+
171
+ # -----------------------------
172
+ # Daily Sampling
173
+ # -----------------------------
174
+ if args.mode == "daily":
175
+ sampler = DailySampler(
176
+ folder_path=str(source_folder),
177
+ historical_folder=hist_folder,
178
+ random_state=args.seed,
179
+ columns=columns,
180
+ )
181
+ df = sampler.get_daily_samples(samples_per_day=args.per_day)
182
+ write_parquet_atomic(df, out)
183
+ logger.info(f"Saved daily sample ({len(df)} rows) -> {out}")
184
+ return
185
+
186
+ # -----------------------------
187
+ # Filtered Sampling
188
+ # -----------------------------
189
+ if args.mode == "filtered":
190
+ if args.filter is None:
191
+ raise ValueError(
192
+ "--filter is required when mode == 'filtered' "
193
+ "(must be JSON string)"
194
+ )
195
+
196
+ try:
197
+ filter_dict = json.loads(args.filter)
198
+ except json.JSONDecodeError as e:
199
+ raise ValueError(f"Invalid JSON passed to --filter: {e}") from e
200
+
201
+ sampler = FilteredSampler(
202
+ folder_path=str(source_folder),
203
+ gdelt_columns=config["columns"][dataset],
204
+ columns=columns,
205
+ filter_dict=filter_dict,
206
+ random_state=args.seed,
207
+ historical_folder=hist_folder,
208
+ )
209
+
210
+ if args.stratify:
211
+ if args.n_per_group is None:
212
+ raise ValueError("--n-per-group is required when --stratify is set")
213
+ df = sampler.get_stratified_sample(args.stratify, args.n_per_group)
214
+ write_parquet_atomic(df, out)
215
+ logger.info(
216
+ f"Saved stratified sample ({len(df)} rows) "
217
+ f"stratified by '{args.stratify}' ({args.n_per_group} per group) -> {out}"
218
+ )
219
+ else:
220
+ df = sampler.get_random_sample(args.n)
221
+ write_parquet_atomic(df, out)
222
+ logger.info(
223
+ f"Saved filtered sample ({len(df)} rows) "
224
+ f"using filter={filter_dict} -> {out}"
225
+ )
226
+ return
227
+
228
+ raise ValueError(f"Unknown sampling mode: {args.mode}")
229
+
230
+
231
+ def run_crossref_cmd(config: dict, args: argparse.Namespace) -> None:
232
+ events_df = pd.read_parquet(args.events)
233
+ columns = set(args.columns) if args.columns else None
234
+ source_key = (
235
+ "filtered_data_directory" if args.source == "filtered" else "parquet_data_directory"
236
+ )
237
+
238
+ out = Path(args.out)
239
+ out.parent.mkdir(parents=True, exist_ok=True)
240
+
241
+ if args.gkg_version == "v2":
242
+ mentions_folder = ensure_exists(
243
+ config["paths"][dataset_path_key("gdelt_mentions", source_key)],
244
+ "Mentions directory",
245
+ )
246
+ gkg_v2_folder = ensure_exists(
247
+ config["paths"][dataset_path_key("gdelt_gkg_v2", source_key)],
248
+ "GKG 2.1 directory",
249
+ )
250
+ result = crossref_events_gkg_v2(
251
+ events_df,
252
+ str(mentions_folder),
253
+ str(gkg_v2_folder),
254
+ config["columns"]["gdelt_gkg_v2"],
255
+ columns=columns,
256
+ )
257
+ else:
258
+ dataset = _CROSSREF_GKG_TO_CONFIG[args.gkg_version]
259
+ gkg_folder = ensure_exists(
260
+ config["paths"][dataset_path_key(dataset, source_key)],
261
+ f"{args.gkg_version} directory",
262
+ )
263
+ result = crossref_events_gkg_v1(
264
+ events_df, str(gkg_folder), config["columns"][dataset], columns=columns,
265
+ )
266
+
267
+ write_parquet_atomic(result, out)
268
+ logger.info(f"Saved cross-referenced sample ({len(result)} rows) -> {out}")
269
+
270
+
271
+ _CAMEO_COLUMN_GROUPS = [
272
+ cameo_codes.CAMEO_ACTOR_COUNTRY_COLUMNS,
273
+ cameo_codes.FIPS_GEO_COLUMNS,
274
+ cameo_codes.CAMEO_ETHNIC_COLUMNS,
275
+ cameo_codes.CAMEO_KNOWN_GROUP_COLUMNS,
276
+ cameo_codes.CAMEO_RELIGION_COLUMNS,
277
+ cameo_codes.CAMEO_TYPE_COLUMNS,
278
+ cameo_codes.CAMEO_EVENT_COLUMNS,
279
+ ]
280
+
281
+
282
+ def run_codes_cmd(args: argparse.Namespace) -> None:
283
+ if args.column is None:
284
+ print("CAMEO-coded columns with a reference list:\n")
285
+ for group in _CAMEO_COLUMN_GROUPS:
286
+ columns = sorted(group)
287
+ print(f" {cameo_codes.family_name_for_column(columns[0])}:")
288
+ for c in columns:
289
+ print(f" {c}")
290
+ print()
291
+ print("Run `gdeltforge codes <column>` to list that column's codes.")
292
+ return
293
+
294
+ code_family = cameo_codes.code_family_for_column(args.column)
295
+ if code_family is None:
296
+ known = sorted(c for group in _CAMEO_COLUMN_GROUPS for c in group)
297
+ raise ValueError(
298
+ f"'{args.column}' has no CAMEO code reference list. Known columns: "
299
+ f"{', '.join(known)}"
300
+ )
301
+
302
+ entries = sorted(code_family.items())
303
+ if args.search:
304
+ term = args.search.lower()
305
+ entries = [
306
+ (code, name) for code, name in entries
307
+ if term in code.lower() or term in name.lower()
308
+ ]
309
+
310
+ if not entries:
311
+ print(f"No codes matching '{args.search}' in {args.column}.")
312
+ return
313
+
314
+ width = max(len(code) for code, _ in entries)
315
+ for code, name in entries:
316
+ print(f" {code.ljust(width)} {name}")
317
+ print(f"\n{len(entries)} code(s).")
318
+
319
+
320
+ # ======================================================================
321
+ # Argument Parser
322
+ # ======================================================================
323
+
324
+ def build_parser() -> argparse.ArgumentParser:
325
+ parser = argparse.ArgumentParser(
326
+ description="GdeltForge: a data pipeline for the GDELT 2.0 Events Database"
327
+ )
328
+ parser.add_argument(
329
+ "--config",
330
+ metavar="PATH",
331
+ help="Path to settings.yaml. Defaults to the GDELTFORGE_CONFIG "
332
+ "environment variable, then ./config/settings.yaml.",
333
+ )
334
+
335
+ subparsers = parser.add_subparsers(dest="command", required=True)
336
+
337
+ # ----------------------------------------------------
338
+ # scrape
339
+ # ----------------------------------------------------
340
+ scrape = subparsers.add_parser("scrape", help="Download and extract raw GDELT data")
341
+ scrape.add_argument(
342
+ "--dataset",
343
+ choices=_DATASET_CHOICES,
344
+ default="events",
345
+ help="Which GDELT dataset to scrape (default: events)"
346
+ )
347
+ scrape.add_argument(
348
+ "--start-date",
349
+ metavar="YYYY-MM-DD",
350
+ help="Only download files whose period starts on or after this date",
351
+ )
352
+ scrape.add_argument(
353
+ "--end-date",
354
+ metavar="YYYY-MM-DD",
355
+ help="Only download files whose period ends on or before this date",
356
+ )
357
+
358
+ # ----------------------------------------------------
359
+ # convert
360
+ # ----------------------------------------------------
361
+ convert = subparsers.add_parser("convert", help="Convert raw data to parquet")
362
+ convert.add_argument(
363
+ "--dataset",
364
+ choices=_DATASET_CHOICES,
365
+ default="events",
366
+ help="Which GDELT dataset to convert (default: events)"
367
+ )
368
+ convert.add_argument(
369
+ "--start-date",
370
+ metavar="YYYY-MM-DD",
371
+ help="Only convert files whose period starts on or after this date",
372
+ )
373
+ convert.add_argument(
374
+ "--end-date",
375
+ metavar="YYYY-MM-DD",
376
+ help="Only convert files whose period ends on or before this date",
377
+ )
378
+
379
+ # ----------------------------------------------------
380
+ # filter
381
+ # ----------------------------------------------------
382
+ filter_ = subparsers.add_parser("filter", help="Filter parquet files")
383
+ filter_.add_argument(
384
+ "--dataset",
385
+ choices=_DATASET_CHOICES,
386
+ default="events",
387
+ help="Which GDELT dataset to filter (default: events)"
388
+ )
389
+ filter_.add_argument(
390
+ "--start-date",
391
+ metavar="YYYY-MM-DD",
392
+ help="Only filter files whose period starts on or after this date",
393
+ )
394
+ filter_.add_argument(
395
+ "--end-date",
396
+ metavar="YYYY-MM-DD",
397
+ help="Only filter files whose period ends on or before this date",
398
+ )
399
+
400
+ # ----------------------------------------------------
401
+ # sample
402
+ # ----------------------------------------------------
403
+ sample = subparsers.add_parser(
404
+ "sample", help="Sampling utilities (indexed, filtered, daily)"
405
+ )
406
+
407
+ sample.add_argument(
408
+ "--dataset",
409
+ choices=_DATASET_CHOICES,
410
+ default="events",
411
+ help="Which GDELT dataset to sample from (default: events)"
412
+ )
413
+ sample.add_argument(
414
+ "--mode",
415
+ required=True,
416
+ choices=["indexed", "filtered", "daily"],
417
+ help="Sampling strategy"
418
+ )
419
+ sample.add_argument(
420
+ "--source",
421
+ choices=["filtered", "converted"],
422
+ default="filtered",
423
+ help="Which stage's output to sample from: 'filtered' (default, "
424
+ "after the filter command) or 'converted' (raw parquet, "
425
+ "before filtering)"
426
+ )
427
+ sample.add_argument(
428
+ "-n", type=int, default=1000,
429
+ help="Number of rows to sample"
430
+ )
431
+ sample.add_argument(
432
+ "--seed", type=int, default=42,
433
+ help="RNG seed"
434
+ )
435
+ sample.add_argument(
436
+ "--per-day", type=int, default=10,
437
+ help="Rows per day (daily mode only)"
438
+ )
439
+ sample.add_argument(
440
+ "--filter",
441
+ help="JSON dictionary for filtered sampling "
442
+ "(e.g. '{\"QuadClass\": [1,2]}')"
443
+ )
444
+ sample.add_argument(
445
+ "--columns",
446
+ nargs="*",
447
+ help="Restrict output to these columns (all modes); cuts both I/O "
448
+ "and memory use on the full archive"
449
+ )
450
+ sample.add_argument(
451
+ "--stratify",
452
+ metavar="COLUMN",
453
+ help="Column to stratify by (filtered mode only); requires --n-per-group"
454
+ )
455
+ sample.add_argument(
456
+ "--n-per-group",
457
+ type=int,
458
+ metavar="N",
459
+ help="Rows per stratum when --stratify is set"
460
+ )
461
+ sample.add_argument(
462
+ "--out",
463
+ default="sample.parquet",
464
+ help="Output parquet file"
465
+ )
466
+
467
+ # ----------------------------------------------------
468
+ # crossref
469
+ # ----------------------------------------------------
470
+ crossref = subparsers.add_parser(
471
+ "crossref", help="Cross-reference a sampled Events output against GKG"
472
+ )
473
+ crossref.add_argument(
474
+ "--events",
475
+ required=True,
476
+ metavar="PATH",
477
+ help="Parquet file of Events rows to enrich, e.g. the output of `gdeltforge sample`"
478
+ )
479
+ crossref.add_argument(
480
+ "--gkg-version",
481
+ required=True,
482
+ choices=_CROSSREF_GKG_CHOICES,
483
+ help="Which GKG generation to join against: v1 (direct join on EventIds, main GKG "
484
+ "1.0 file), v1-counts (same join, GKG 1.0's separate Counts file), or v2 "
485
+ "(two-hop join through Mentions, GKG 2.1)"
486
+ )
487
+ crossref.add_argument(
488
+ "--source",
489
+ choices=["filtered", "converted"],
490
+ default="filtered",
491
+ help="Which stage's GKG/Mentions output to read from (default: filtered)"
492
+ )
493
+ crossref.add_argument(
494
+ "--columns",
495
+ nargs="*",
496
+ help="Restrict GKG-side output to these columns; cuts I/O and memory. The join key "
497
+ "column is always included regardless"
498
+ )
499
+ crossref.add_argument(
500
+ "--out",
501
+ default="crossref.parquet",
502
+ help="Output parquet file"
503
+ )
504
+
505
+ # ----------------------------------------------------
506
+ # codes
507
+ # ----------------------------------------------------
508
+ codes = subparsers.add_parser(
509
+ "codes", help="Look up valid GDELT country codes for filter values"
510
+ )
511
+ codes.add_argument(
512
+ "column",
513
+ nargs="?",
514
+ help="A country-code column (e.g. ActionGeo_CountryCode). "
515
+ "Omit to list which columns have a reference list.",
516
+ )
517
+ codes.add_argument(
518
+ "--search",
519
+ metavar="TERM",
520
+ help="Filter to codes/names containing this substring (case-insensitive)",
521
+ )
522
+
523
+ return parser
524
+
525
+
526
+ # ======================================================================
527
+ # Entrypoint
528
+ # ======================================================================
529
+
530
+ def main() -> None:
531
+ parser = build_parser()
532
+ args = parser.parse_args()
533
+
534
+ try:
535
+ if args.command == "codes":
536
+ run_codes_cmd(args)
537
+ return
538
+
539
+ config = load_config(args.config)
540
+
541
+ logger.info(f"Running command: {args.command}")
542
+
543
+ if args.command == "scrape":
544
+ run_scrape_cmd(config, args)
545
+
546
+ elif args.command == "convert":
547
+ run_convert_cmd(config, args)
548
+
549
+ elif args.command == "filter":
550
+ run_filter_cmd(config, args)
551
+
552
+ elif args.command == "sample":
553
+ run_sampling_cmd(config, args)
554
+
555
+ elif args.command == "crossref":
556
+ run_crossref_cmd(config, args)
557
+
558
+ except KeyboardInterrupt:
559
+ print("Interrupted.", file=sys.stderr)
560
+ sys.exit(130)
561
+ except Exception as e:
562
+ print(f"Error: {e}", file=sys.stderr)
563
+ sys.exit(1)
564
+
565
+
566
+ if __name__ == "__main__":
567
+ main()
File without changes