Releases: Vinicius-Teixeirac/GdeltForge
Releases · Vinicius-Teixeirac/GdeltForge
Release list
GdeltForge 0.9.0
[0.9.0] - 2026-09-09
Fixed
- A hand-written
settings.yamlthat omitted a whole top-level section (columns,columns_numeric) or a nested one (filter.columns_to_check) crashed deep insideconverter.py/filter.py/cli.pywith a bareError: 'columns'orError: 'columns_to_check', naming the missing key with no indication of which file or section was at fault, since every one of those reads its section with a directconfig["..."][...]access on the assumption the section is always present the way the bundled default always has it. New_deep_merge_defaultsfills in whatever a real, user-supplied config doesn't mention from the bundled default, recursing into nested dicts, so a partial config now behaves like the bundled default for anything it doesn't set; a key already present in the user's config is never touched regardless of type, and only dict values recurse, so a user's own (possibly empty) list for one dataset is never padded with the default's entries for that same dataset. Found via a live comprehensive QA pass sample --mode filtered --stratify, with no--filter, failed withError: --filter is required when mode == 'filtered', even though--stratify's own--helptext only documented--n-per-groupas a requirement.--stratifytargets rows by group membership on its own and never needed a separate row-level--filtercondition the wayget_random_sampledoes; an omitted--filternow defaults to no filtering when--stratifyis set, and only still raises for a plain--mode filteredcall with no--stratify. Found via a live comprehensive QA pass- Converting the real
events-reduceddump failed with a rawWinError, the system cannot find the path specified, from a moderately nested project directory: the generated.tmppartition path landed at 265 characters, past Windows' default 260-characterMAX_PATH, confirmed directly by reproducing success from a short path and failure only from a deep one. Every part filename repeated the full source zip stem (GDELT.MASTERREDUCEDV2.1979-2013, 32 characters) for no functional reason, since there is only ever one source file for this dataset; part filenames are now justpart{chunk_idx}.parquet, still deterministic from the chunk index alone. Found via a live comprehensive QA pass crossref --gkg-version v1-countscould crash with a Rust-level memory allocation failure, or hang indefinitely with no progress, joining against realgkg-v1-countsdata: one real row'sEventIdsfield held 13,051 comma-separated ids against a same-file mean of ~37, and exploding an entire 64,000-row batch in one step let that single row dominate the whole batch's peak memory. New_iter_row_slices_bounded_by_explosioncaps each explode step by its own output size (200,000 rows), not the outer batch's row count. A pathological row is processed alone when needed, never left to hold up or balloon the batch around it; verified a real join forced through several bounded steps produces byte-identical results to the unbounded case. Found via a live comprehensive QA pass- Every gdeltforge command, including
--help, crashed with an unhandledPermissionErrorin a working directory the process can't write to:cli.pybuilds its own logger withlog_to_file=Trueunconditionally at import time, before argparse or any command dispatch ever runs, andget_logger()created the log directory with no error handling at all.get_logger()now falls back to console-only logging with a warning when the log directory or file can't be created. Found via a live comprehensive QA pass - SIGKILL of a running
scrape/convert/filterprocess left its already-dispatchedProcessPoolExecutorworkers running as orphans, still pulling from the shared task queue, for minutes after the parent process was gone, since SIGKILL can't be caught by anything.gdeltforgenow makes itself its own process group leader on POSIX at startup, so killing the whole group (kill -KILL -$(pgid)) reliably stops every worker too. A real SIGTERM handler also now cancels queued work immediately, the same way Ctrl+C already does, before a process manager's escalation to SIGKILL ever becomes necessary. Found via a live comprehensive QA pass - Two concurrent
filterorconvertinvocations writing to the same output path (a retry launched before realizing the first was still going, an overlapping cron run) could both fail with a rawFileNotFoundError, one process'sos.replace()finding its own temp file already renamed away by the other:filter_single_file's and_write_partition_file's own hand-rolled temp-file naming never adopted the PID-suffix fixwrite_parquet_atomic/write_dataframe_atomicalready carry for the identical race. Both now use a PID-suffixed name too,_write_partition_fileby delegating towrite_parquet_atomicdirectly, not duplicating the pattern. Found via a live comprehensive QA pass converter.partitioning.enabled, documented as an Events-only opt-in, brokeconvertfor every other dataset (gkg-v2,mentions,gkg-v1,gkg-v1-counts) the moment it was turned on for Events' own yearly/monthly split, demanding a*_parquet_historical_directorypath none of them could ever actually write to, since none of them can produce a yearly- or monthly-typed file. The historical-directory requirement is now scoped to whether a configured rule's file type could actually apply to the dataset being converted. Found via a live comprehensive QA pass- Invalid YAML in a config file surfaced PyYAML's own raw parser traceback, not a clear, crafted message naming the config file, unlike every other malformed-config case
load_configalready handles (a missing file, an empty file, a directory, not a file). Found via a live comprehensive QA pass - A
columns_numericvalue that couldn't be parsed as numeric (an out-of-range integer, unparseable garbage, or a whitespace-padded numeric string) was silently coerced to null duringconvert, indistinguishable in both the output and the log from a field that was genuinely blank to begin with._read_csvandprocess_reduced_filenow warn, naming the column and how many new nulls appeared, whenever the cast actually introduces one. Found via a live comprehensive QA pass - A temp file orphaned by a killed or crashed
write_parquet_atomic/write_dataframe_atomiccall (asample/crossrefwrite, in practice) was never detected or cleaned up on a later run: the leftover-detection warning only ever checked the exact current-process PID-suffixed path, which a genuinely different, now-dead process's own leftover essentially never is. It now globs for any PID's leftover at the same destination and removes the ones whose process is confirmed no longer running, leaving a still-live PID's file (a genuinely concurrent writer) untouched. Found via a live comprehensive QA pass crossreflisted and date-filtered each configured GKG/Mentions directory twice per run (crossref_events_gkg_auto, which calls straight into both single-version joins, up to six times), doubling the real I/O costcli-reference.md's own capacity-planning numbers document:warn_if_directory_is_largeand the actual scan each redid the identical listing pass independently, not sharing one. Both now consume one already-listed file set per directory, computed exactly once per run. Found via a live comprehensive QA passsample(all three modes) andcrossrefcrashed reading a real multi-file archive whose files agree on a column's name but disagree on its dtype, e.g. events' ownActor2Geo_Type(Float64through 2007-10,Int64from 2007-11 onward) or GKG 2.1'sV2.1DATE(Float64in 441 files scattered across six years,Int64everywhere else). The existing schema union only reconciled a column missing entirely from some files; a column present everywhere but typed differently raiseddata type mismatch ... incoming: X != target: Ythe moment a read crossed the boundary.sample's calendar/filtered scans,IndexedSampler.get_random_sample, andcrossref's GKG/Mentions scans now detect a genuine dtype conflict across files and widen it (Int64toFloat64, mixed integer widths toInt64) before reading, falling back to a plain error naming the column and both dtypes for a non-numeric conflict that can't be widened safely.- The
Int64-to-Float64widening above had no check againstFloat64's own precision limit: it represents an integer exactly only up to 2**53. A genuine ID/count column hit by the identical dtype drift, holding a value past that bound, would have been silently corrupted: a wrong value, not a crash. Every real GDELT column currently affected (small geo-type enums, a 14-digit datetime-as-integer, a 1-2 range) stays far under the limit, but the fix's correctness rested entirely on that fact holding, with nothing in the code checking it. Widening now reads the affected file's own min/max first (a cheap, parquet-statistics-only check, not a full column scan); a value past the safe bound raises a clear error naming the column, file, and value. - A
Ctrl+C/SIGINT arriving during any ofsample's tqdm-wrapped loops (indexed loading, calendar/filtered/stratified sampling) orcrossref's GKG 1.0 cross-referencing loop leaked a raw "Exception ignored in: <generator object tqdm.iter ...>" traceback fragment to stderr before the documented cleanInterrupted.message. Each loop now drives its owntqdmobject manually inside an explicitwithblock, not by iterating it directly: the barefor x in tqdm(iterable):form creates a second, separate generator internally whose own implicit close, on interrupt, has no legitimate way to propagate a further exception raised during it. - A
Ctrl+Carriving duringscrape,convert, orfilter's executor loop (downloading, converting, or filtering files in parallel) leaked the same kind of traceback fragment. Each of the three now drives its own `tq...
v0.8.0
GdeltForge 0.8.0: events-15min dataset, required --dataset (breaking)…
GdeltForge 0.7.0
[0.7.0] - 2026-08-19
Added
convert/filtergain--delete-source:convertdeletes each source zip once its parquet output is written and confirmed done,filterdeletes each source (unfiltered, converted) parquet once its filtered output is written and confirmed done. Off by default, never deletes on a failed conversion/filter, and never runs ahead of the existing.donemarker.sampledeliberately doesn't get an equivalent option: a sample is many-to-one against its source, so deleting the source after one sample would discard the ability to draw another. Combined with any setting that narrows the output (output_columnsfor convert;columns_to_check/output_columns/float32_columnsfor filter), whatever that dropped or changed has nothing left to recover it from except redoing an earlier stage, so a warning fires once at the start of a run configured that way, shared between both commands (warn_if_delete_source_drops_recoverable_data)scrape/convert/filtergain--verbose.convert/filterused to log unconditional per-file lines (convert: "Processing ZIP"/"Skipping already converted";filter: rows-kept summary/"Skipping already filtered") atINFO, which atgkg-v2/mentionsscale (hundreds of thousands of 15-minute files) meant hundreds of thousands of terminal lines fighting thetqdmprogress bar for the screen;scrapenever had this problem, since its own per-attempt detail was alreadyDEBUG-only. Those lines are nowDEBUGtoo, so all three commands default to the same shape (setup line, progress bar, end-of-run summary);--verboseraises the relevant module's logger back toDEBUGfor whoever actually wants per-file detail.convert/filterboth run their per-file work inside aProcessPoolExecutorworker, a genuinely separate process that re-imports the module fresh, so a level change made in the main process alone never reaches it;verboseis threaded through as a real instance attribute and re-applied independently inside each worker, not just set once where the flag is parsedscrape/convert/filtergain--quiet/-q, mutually exclusive with--verbose: raises the relevant module's logger toWARNING, suppressing the setup and end-of-run summary lines each command otherwise always prints, for scripted or cron use that only cares about problems.cli.pykeeps its own logger separate from the stage modules (its own "Starting X stage..."/"X completed." lines are logged from there, not fromscraper.py/converter.py/filter.py), so--quietapplies to it too via a small_apply_verbosityhelper shared by all three commandsscrape/convert/filtergain--force: bypasses the existing resumability check (scrape's already-downloaded-file check,convert/filter's.donemarker) for files that would otherwise be skipped, reprocessing and overwriting them. Off by default, unrelated to--delete-source, which still only fires from a successful run's own success branchscrape/convert/filtergain--dry-run: reports how many files would be processed and how many would be skipped, without downloading, converting, or filtering anything. Runs after--force's own skip-list logic, so--force --dry-runtogether preview what a real forced run would actually do, not what an unforced one would.scrape's preview mirrors its own per-file existing-file check without making any network request;convert/filtershort-circuit after building their to-process list, before any worker is submittedsample/crossrefgain--export-format {parquet,csv}(defaultparquet): writes the finished, already-in-memory sample or join result as CSV instead of Parquet, for handing it to a tool that doesn't read Parquet.csvrewrites--out's extension to.csvregardless of what was passed, so the flag is authoritative over the file's own name.convert/filter/sample --mode filtered/crossrefthemselves stay Parquet-only: their streaming reads andpyarrow.datasetpredicate-pushdown scanning of the input side have no equivalent in CSV, so only the single final-result write this flag touches is affected, not how a sample or join is computed. Newwrite_dataframe_atomicinutils/io.pyadds the CSV write path alongside the existingwrite_parquet_atomic, same atomic tmp-then-rename guarantee
GdeltForge 0.6.1
Fixed
filtersilently wrote nothing whenevercolumns_to_checkwas empty, exactly the bundled default config's own value for every dataset.filter_single_filetreated an emptycolumns_to_checkthe same as "columns were configured but none exist in this file's schema", logging an error and returning before ever writing the output file, while the batch summary still reported every file processed successfully with 100% retention.sampleandcrossrefthen correctly found nothing in the (empty) output directory, surfacing as confusing downstream errors with no indication the real failure was upstream and silent. The early-return now only fires whencolumns_to_checkis actually non-empty; an empty list falls through to the existing, already-correct no-op path (dropna(subset=[])is a documented pandas no-op)crossref --eventscrashed with a confusingArrowInvalid: ... magic bytes not founderror when pointed at a directory instead of a single file, if that directory contained aconvert/filter.doneresumability marker (<name>.parquet.done, a real sibling of the data by design in every one of their output directories).pd.read_parquetwas handing the raw path straight to pandas with no awareness of that convention.--eventsnow accepts a directory properly: every*.parquetfile in it is read and concatenated,.donemarkers and any other non-parquet sibling are never handed to the parquet reader
GdeltForge 0.6.0
Added
load_config()gains a fourth fallback tier: GdeltForge's own built-in default, bundled inside the installed package viaimportlib.resources, used only when neither--confignorGDELTFORGE_CONFIGwas given at all and./config/settings.yamldoesn't exist either. Previously this situation was a hardFileNotFoundErroron every single run, painful specifically for apip install gdeltforgein an ephemeral environment like a Colab session, where nothing dropsconfig/settings.example.yamlinto the working directory the way a git clone does, so the file had to be reconstructed by hand every session reset. The bundled default is written out to the resolved config path on first use (best-effort; a read-only working directory still gets a working, in-memory-only config rather than an error), so it becomes a normal editable file for the rest of that session. An explicit--config/GDELTFORGE_CONFIGpointing at a missing path still raises, unchanged: that's almost always a typo, not a request for the built-in default- The bundled default (
src/gdeltforge/config/default_settings.yaml) is deliberately more conservative thansettings.example.yaml, not the same content with the paths changed: real./data/...paths (notsettings.example.yaml's./path_example/...placeholders) but nofilter.columns_to_checkvalues,output_columns, orfloat32_columnsfor any dataset, so a first run's output is never silently shaped by row-dropping or column-pruning choices the user never made. Confirmeddropna(subset=[])is a documented pandas no-op before relying on it, not assumed crossref_events_gkg_v1/_v2(and, by extension,crossref_events_gkg_auto, which calls both) now warn, once--eventscrosses 1,000,000 rows, that the module is designed for a bounded sample, not the full Events archive. Never blocks: an archive-scale join is a legitimate thing to ask for, just an expensive one that nothing previously flagged. The threshold and the numbers in the warning are measured, not guessed: building the join key set alone (set()->list()-> a pyarrowisin()filter, the exact sequence both join paths run) was measured viatracemallocat ~100 MB/1s per million events, ~800 MB/5s at 10M, ~5.2 GB/13s at 50M, before the archive scan itself even starts, since predicate pushdown prunes rows within a file, not which files get opened- The same two functions now separately warn when the configured Mentions/GKG directory itself has more than 50,000 files, independent of
--eventssize: crossref lists and opens every file in that directory on each run (Mentions/GKG 2.1/GKG 1.0checked independently, so either side gets named specifically). Measured directly against real local data (3,127 real GKG 2.1 files: 0.20s; 33,303 real Mentions files: 2.47s), a roughly linear ~75 microseconds/file; extrapolated to the full historical GKG 2.1/Mentions archive (~385,728 files, seedocs/configuration.md's "Capacity planning" section), that's ~29s just to list and open every file before a single row is read crossref_events_gkg_v2/crossref_events_gkg_autogainon_duplicate_document("all"/"latest"/"earliest", default"all"): GKG 2.1 can carry more than one record for the same document URL, confirmed against real data to sometimes hold genuinely different content across visits (a tag/listing page recrawled years apart), not always just a stale reprocessing of the same article, so picking a single winner is a real editorial choice rather than noise removal, and defaults to keeping every record rather than silently discarding one."latest"/"earliest"remain available for a caller who specifically wants a single row per URL. CLI:crossref --on-duplicate-document {all,latest,earliest}crossref_events_gkg_v2/crossref_events_gkg_autogaindedupe_mentions(defaultFalse). Mentions records one row per sentence that references an event, so an event quoted in several sentences of the same article produces several near-identical raw rows for one (event, article) relationship; by default every one of those rows is kept, so row count still reflects real mention frequency. Set toTrue(CLI:--collapse-duplicate-mentions) to instead collapse them into one row per (event, article), keeping the highest-Confidenceversion whenConfidenceis available, with a newMention_Countcolumn recording how many raw rows collapsed into it. Confirmed against real data: about 1.9% of (event, article) pairs have more than one raw Mentions row, and where they do,Confidencediffers across them about 23% of the time, so collapsing picks a representative row rather than removing true duplicatescrossref_events_gkg_v1/_v2/_autogainstart_date/end_date(CLI:--start-date/--end-date), narrowing which files in the configured Mentions/GKG directories get listed and opened at all, the same leverscrape/convert/filteralready had and previously the one thing crossref's own large-directory warning said didn't exist. Reusesscrape's ownfilter_paths_by_dateand filename date parsers rather than a separate mechanism. Only narrows the Mentions/GKG corpus, not--events: a Mentions row is timestamped by when it was recorded, not by its event'sDATEADDED, so this is a real scope decision that can exclude a legitimate late mention of an in-range event, not a risk-free filter
v0.5.0
Added
converter.output_columns: restricts pandas to materializing only the configured columns while parsing CSV, viaread_csv'susecols, instead of parsing every column and discarding the unwanted ones afterward.converter.max_workers_by_datasetoverrides the scalarconverter.max_workersfor a single dataset, since a safe worker count depends on peak per-worker memory and that varies a lot by datasetfilter.output_columns: per-dataset column projection on the filtered output, independent ofcolumns_to_check's row-filtering.filter.compressionalso becomes a per-dataset configurable write codec (pyarrow already ships zstd/gzip/brotli/lz4, no new dependency needed)filter.float32_columns: opt-in, per-dataset, per-column narrowing offloat64output columns tofloat32. Off by default: real GDELT float data,AvgToneespecially, has been observed with up to 15 significant figures, well pastfloat32's ~7, and a round-trip test against 6.5M real rows changed the value on 31 to 100% of rows depending on the column. Narrowing low-cardinality integer columns was evaluated too and not implemented, since Parquet already dictionary-encodes those regardless of declared type and the measured gain was under 1%converter.compression: configurable Parquet codec forconvert's own writes, same per-dataset shapefilter.compressionalready had, defaulting to zstd for the same measured reason that default was chosen therecrossrefwarns before a configuredoutput_columnsstrips a dataset's required join key (GlobalEventIDforgdelt_event,EventIdsfor both GKG 1.0 datasets,V2DOCUMENTIDENTIFIERforgdelt_gkg_v2,GLOBALEVENTID/MentionIdentifierforgdelt_mentions), checked by bothrun_filterandrun_converterat their own configure time against a singleREQUIRED_JOIN_COLUMNSmapping.crossrefalready raised a clear error for this, but only at join time, possibly after an unrelatedsamplerun had already completed on the pruned outputcrossref_events_gkg_v1/_v2warn when some or all sampled events predate that generation's real coverage start (GKG_V1_COVERAGE_START2013-04-01,GKG_V2_COVERAGE_START2015-02-18, both confirmed against GDELT's real file listings), the other way a join can legitimately find nothing besides a missing required column. Checked againstDATEADDED, notDay(which reflects when an event is reported to have occurred and can be far in the past for retrospective reporting). Diagnostic only: events within coverage in the same sample still join normallycrossref_events_gkg_auto(--gkg-version auto): attempts every eligible event against bothcrossref_events_gkg_v1andcrossref_events_gkg_v2, for a sample spanning both eras, e.g. the 2013-2015 window where only GKG 1.0 exists. Output concatenates both paths' results with aCrossrefSourcecolumn (v1/v2); an event matching both contributes one row per source. Events beforeGKG_V1_COVERAGE_STARTare skipped and logged, since neither generation has any data for them.--columnsisn't supported in this mode, since GKG 1.0's 11 fields and GKG 2.1's 27 share no common nameconvertgains a.donemarker for flat/daily output (Events daily archives, GKG 1.0, GKG 2.1, Mentions), matching the resumability the historical (Hive-partitioned) path already had and the skip-already-downloaded behaviorscrapealready had. Found necessary against a real 30,137-file Mentions batch: two independent runs each died to an OS-level kill around the same ~51% mark, having made no net progress relaunch to relaunch, because every attempt reprocessed every zip from file 1filtergains equivalent resumability, for the first time; its correctness surface is wider thanconvert's, sincecolumns_to_check,output_columns,float32_columns, andcompressionall change what the filtered output contains
Changed
filter.compressionnow defaults to zstd instead of snappy. Measured on real Events data (5.8M rows, all 58 columns): roughly 30% smaller than snappy, at comparable or faster write speed. Lossless, so no accuracy tradeoff in taking it as the default; per-dataset override remains available- README,
docs/index.md, anddocs/getting-started.mdnow lead withpip install gdeltforgeinstead of "install from a clone," now that 0.4.0 is actually on PyPI; the clone/uv syncpath is kept as a clearly-labeled "installing from source" option for contributors. Checked off the PyPI roadmap item, and fixed a leftover↔indocs/cli-reference.md(should have been<->, missed in the arrow-notation pass this session).pyproject.toml'sdescriptionwas also still the old Events-only tagline; fixed for the next release, though it won't retroactively change what's already published for 0.4.0
Fixed
_detect_file_typenever recognized real 15-minuteYYYYMMDDHHMMSSGKG 2.1/Mentions filenames as any known cadence, since the file-type patterns in place at the time GKG 2.1/Mentions support shipped all assumed 8-digit daily or longer. Nothing surfaced the gap in practice, only because partitioning is never enabled for those two datasets. Added a realquarter_hourlyfile type; the non-detecting partitioning-off shortcut, previously mislabeled"daily"regardless of a file's real cadence, is renamed"flat", and routing to the historical (Hive-partitioned) path is now decided by whetherpartitioning.rulesdefines an entry for the detected file type rather than by a hardcoded"daily"string comparison- The
.donemarkerconvert's historical (Hive-partitioned) path already had only ever recorded that a file had been processed, not under what configuration. Rerunningconvertafter changingoutput_columnswould be silently skipped by a marker left from the old configuration, serving output shaped by settings that no longer match the current run.config_fingerprint/is_marked_done/mark_donenow write the relevant config into the marker itself and compare its content, not just presence; a mismatch, including a pre-fingerprint empty marker from before this existed, is treated as not done crossref_events_gkg_v2hardcodedMentionTimeDateandConfidenceas required when reading Mentions, even though neither participates in the join itself (onlyGLOBALEVENTIDandMentionIdentifierdo). A Mentions dataset missing either one failed outright instead of joining successfully minus that payload field; both are now read only if presentcrossref_events_gkg_auto's routing excluded an event fromv2whenever its ownDATEADDEDpredatedGKG_V2_COVERAGE_START, on the reasoning that such an event could only match through GKG 1.0. Confirmed for real that reasoning doesn't hold: a Mentions row is timestamped by when it was created, not by its event'sDATEADDED(a 2019-origin event was found referenced by a real Mentions row dated 2020), and a direct test against the complete 2015-02-18 through 2015-12-31 Mentions history (211.8M rows) plus a further scattered sample found zero matches for any pre-2015-02-18 event, not because none could exist but because the old routing never gave them the chance. The same asymmetry ran the other direction too, since GKG 1.0 remains live and daily-published today. Every eligible event is now attempted against both generations rather than routed to exactly onecrossref_events_gkg_v2's dedup (drop_duplicates(keep="last"), keeping the most recently reprocessed article) silently depended onPath.glob's return order matching each file's real chronological position, true on NTFS by coincidence but not on Linux/ext4, where GitHub Actions CI failed by keeping the stale record instead of the reprocessed one._dataset()now sorts its file list explicitly
v0.4.0
Added
gdeltforge codescommand: looks up valid codes across seven CAMEO/FIPS-coded column families (CAMEO actor-country, FIPS geo-country, CAMEO ethnic, CAMEO known-group, CAMEO religion, CAMEO actor-type, and CAMEO event), with a--searchfilter. Needs no config fileFilteredSamplernow warns (not raises) when a filter value on a CAMEO/FIPS-coded column isn't recognized for that column's code family, e.g. a 3-letter CAMEO code used against a 2-letter FIPS column. Covers all seven families above, comparing case-insensitively since GDELT stores ethnic codes lowercase in real data while the source codebook uses uppercase- Bundled CAMEO/FIPS reference data verified against every distinct value across a full archive scan (~542M rows, all seven families); added 7 previously-missing FIPS Pacific-island codes, 28 previously-missing CAMEO known-group codes for organizations absent from the public CAMEO manual, and 2 previously-missing CAMEO event codes (
1213/1214, undocumented in the public manual's "reject material cooperation" branch but confirmed via GDELT's own TABARI/PETRARCH verb-pattern dictionary), each confirmed against real data rather than guessed. A handful of real event-code values ("X","--","---") are GDELT's own markers for unclassifiable rows, not CAMEO codes, and are deliberately excluded so a filter using one still warns gdeltforge sample --source {filtered,converted}: sampling can now read from the raw converted Parquet directory instead of always requiring thefilterstage first--columnsnow applies toindexedanddailysampling too, not justfiltered; both previously always read every column of every file they touched--dataset {gkg-v2,mentions}support forscrape/convert/filter/sample: GKG 2.1 (the current, actively-produced Global Knowledge Graph: themes, tone, GCAM, people, organizations) and Mentions (every re-report of an Event by a different article over time) alongside Events. Discovery uses GDELT's v2 master file list rather than Events' HTML directory listing, since GKG 2.1/Mentions publish every 15 minutes, not daily; a warning logs before a scrape that would download an unusually large number of files given that granularity. Column schemas and field order confirmed against the real production parser inaamend/spark-gdelt, not just the codebook--dataset {gkg-v1,gkg-v1-counts}support forscrape/convert/filter/sample: the legacy GKG 1.0 format (the primary GKG feed April 2013 through February 2015, still published daily since for backwards compatibility) and its separate, narrower Counts file (one row per count mention rather than per document). Unlike GKG 2.1, GKG 1.0 rows carryEventIdsdirectly, so joining to Events skips the two-hop trip through Mentions that GKG 2.1 needs. Discovery reuses Events' HTML-directory-listing approach, not GKG 2.1/Mentions' master-file-list mechanism, since GKG 1.0 publishes daily like Events rather than every 15 minutes; the two paths are inferred (not yet directly confirmed) to share the same markup, based on both hitting an identical TLS certificate mismatch, i.e. the same underlying GCS bucket. Column schemas confirmed against the same real production parser used for GKG 2.1/Mentionsgdeltforge crossrefcommand: enriches a sampled Events output with GKG (themes, tone, people, organizations).--gkg-version {v1,v1-counts,v2}picks the join strategy, not just the data source, since GKG 1.0 carriesEventIdsdirectly (a direct join) while GKG 2.1 carries no event id at all and needs a two-hop join through Mentions on the source article's URL. Both pyarrow filter-pushdown scan only the rows relevant to the input sample, never materializing the full Mentions/GKG archive; both preserve the real many-to-many structure (one event can produce several output rows, one article covering several events contributes one row per event) rather than silently collapsing it, and dedupe GKG 2.1 rows on document URL so an article reprocessed across batches contributes exactly one row. GKG-side output columns are prefixedGKG_/Mention_to avoid colliding with an identically-named Events column (NumArticlesexists on both Events and GKG 1.0)--start-date/--end-dateforconvertandfilter, matching the flagsscrapealready had. Both narrow which files get touched by filename, reusing the same date parsersscrapeuses (now also matching the equivalent converted-Parquet names, e.g.20200315.export.parquetalongside the raw20200315.export.CSV.zip) rather than a new date-parsing path; a file whose date can't be determined is still processed rather than silently skipped, since a local directory can hold pre-daily yearly/monthly archives with no single day to compare against a range, unlike a remote listing where an unparseable entry is usually just noise.filter's version restricts which input files get read, not a row-level date filter, since filtering itself is row-wise NaN-dropping unrelated to dates
Changed
- README hero section: real CI/license/release badges, a tighter pitch, and a terminal-demo screenshot of
codesandsamplerunning against the live dataset - The CLI now catches failures at the top level: any error prints
Error: <message>and exits 1, and Ctrl+C printsInterrupted.and exits 130, instead of a raw Python traceback either way - Breaking config change, foundational work for multi-dataset support (GKG, Mentions):
columns_numericandfilter.columns_to_checkare now nested under the dataset name (gdelt_event), matching howcolumnswas already structured, instead of being flat lists assuming a single dataset. Updatesettings.yaml: wrap your existingcolumns_numeric:list ascolumns_numeric: {gdelt_event: [...]}, and likewise forfilter.columns_to_check.scrape/convert/filter/samplealso gain a--dataset {events,gkg-v1,gkg-v1-counts,gkg-v2,mentions}flag (defaultevents, matching current behavior exactly) filternow runs across a worker pool (config:filter.max_workers, same shape and default asconverter.max_workers) instead of processing files one at a time. Found running a real full historical Events archive throughfilter: 4,748 files, 866M rows, over an hour single-threaded, despitescrapeandconvertalready being parallel- README,
docs/index.md, anddocs/comparison.mdno longer describe GdeltForge as Events-only: both explicitly said GKG/Mentions support was "on the roadmap, not built yet," which stopped being true oncecrossrefshipped. Updated the tagline, the pipeline-stage diagrams and tables (both were also missingcrossrefandcodesentirely), and the "reach for something else" comparison table and bullet list, which used to point GKG/Mentions/join needs elsewhere. Added a GKG-enriched-Events recipe todocs/recipes.mdand a matching example to the README - Breaking config change, no code changes required: the example
paths.*values insettings.example.yamlnow nest every dataset's four stages under one directory (data/events/raw,data/events/parquet, ...,data/gkg_v2/raw, ...) instead ofdata/raw,data/parquet,data/gkg_v2_raw, and so on sitting flat side by side. Events previously sat unprefixed at the top level while every other dataset already got its own directory; this was the one dataset left out of that pattern rather than a new one. Config keys are unchanged (dataset_path_keyonly ever resolves a key name, never a path value), so existing installs keep working with their current layout unless you choose to adopt the new one: update yoursettings.yamlpath values and move your existingdata/subdirectories to match
Fixed
- GKG 1.0 and its Counts file both ship with a literal header row (
DATE\tNUMARTS\t...); the converter read every dataset as headerless unconditionally, so that line was misread as a garbage data row on every converted file. Found by converting a real downloaded file rather than only a synthetic fixture crossref_events_gkg_v1'sEventIdssplit assumed a semicolon-delimited list; real data uses commas. Any GKG 1.0 row naming more than one event had its wholeEventIdsstring treated as a single token that could never match a real event, silently contributing zero rows for that row instead of one per eventcolumns.gdelt_mentionswas missing two real trailing columns (MentionDocTranslationInfo,Extras), confirmed live against the official GDELT Event Codebook V2.0. Every column in every converted Mentions file was silently reading the wrong value as a result (pandas absorbs the shortfall as an unnamed leading index rather than raising), includingGLOBALEVENTIDandMentionIdentifier, whichcrossref_events_gkg_v2's join depends on entirely- A single file's download failure could crash an entire scrape instead of just failing that file: found running a real ~4,850-file full-history scrape, where a transient Windows file lock made the retry loop's own cleanup step raise, and that second exception went unhandled all the way up through the batch. Both the cleanup step and the per-file result handling in
download_gdelt_filesare now guarded, matching the resilienceconvertalready had - Converting a large batch could leave a truncated, corrupt parquet file at its final path if the process was interrupted mid-write, since both
_save_parquetand_save_historical_parquetwrote straight to that path instead of through a temp file. Found for real converting a ~3,000-file GKG 2.1 batch. Both now write atomically, matching the pattern already used for scrape downloads and sample output - The initial fix for the above (bulk-assigning all accepted rows in one indexed write) let pandas resolve same-batch slot collisions independently per column block, silently desyncing string columns from numeric ones when both were written together
- A later fix for that (per-column assignment via
DataFrame.iloc) still r...
v0.3.0: Hardening pass
Added
- Documentation site (MkDocs Material), deployed to GitHub Pages
docs/filtered-sampling.mdanddocs/recipes.md, replacing the old standalone guide and example scripts- GitHub Actions CI: Ruff + Pyright, tests on Python 3.10 and 3.12, and a package-build check on every push/PR to
main - Pre-commit hooks: Ruff on every commit, Pyright and pytest on every push
- Unit test coverage for
filter.py,samplers.py,indexer.py, andrng.py, including the reservoir-sampling and filter AND/OR DSL logic, which previously had none - Community health files: Code of Conduct, Contributing guide, Security policy (private vulnerability reporting), issue templates, pull request template
- World-on-anvil emblem as the project's brand mark
- PyPI-readiness metadata in
pyproject.toml(authors, keywords, classifiers, project URLs) hatch-vcs-based versioning
Changed
- Relicensed from MIT to Apache License 2.0
- README's "Known Limitations" and "Roadmap" sections consolidated into a single docs-site page instead of two independently-drifting copies
scrape,convert, andfilternow exit non-zero and report failed files instead of silently discarding partial failures
Removed
filtered_sampling_guide.md,sample.example.sh,sample.example.cmd(content moved into the docs site)
Fixed
- CI silently installed the wrong Python version, since
setup-uvhas nopython-versioninput (now passed viauv sync --python) - Various Ruff and Pyright findings across the codebase
_is_gdelt_dataset_filematched same-length non-ZIP files (e.g.2020.csv) as monthly/yearly GDELT archives, since it only checked the digit-prefix length and never verified the.zipsuffix- Sample output (
gdeltforge sample) was written directly to its destination path, so a process killed mid-write could leave a corrupt or empty file there with no indication anything was wrong; sample output and scraped downloads now write atomically via a temp file, and warn instead of silently overwriting a leftover incomplete file from a previous interrupted run
Full Changelog: v0.2.0...v0.3.0