diff --git a/scripts/CMakeLists.txt b/scripts/CMakeLists.txt index 22fd18a10e2..6587fa5fd03 100644 --- a/scripts/CMakeLists.txt +++ b/scripts/CMakeLists.txt @@ -67,6 +67,7 @@ set(script_DIRS v.db.univar v.db.update v.dissolve + v.geometry v.import v.in.e00 v.in.geonames diff --git a/scripts/Makefile b/scripts/Makefile index dc3d80f4209..a171274b07b 100644 --- a/scripts/Makefile +++ b/scripts/Makefile @@ -69,6 +69,7 @@ SUBDIRS = \ v.db.univar \ v.db.update \ v.dissolve \ + v.geometry \ v.import \ v.in.e00 \ v.in.geonames \ diff --git a/scripts/v.geometry/Makefile b/scripts/v.geometry/Makefile new file mode 100644 index 00000000000..0abe9b749e7 --- /dev/null +++ b/scripts/v.geometry/Makefile @@ -0,0 +1,7 @@ +MODULE_TOPDIR = ../.. + +PGM = v.geometry + +include $(MODULE_TOPDIR)/include/Make/Script.make + +default: script diff --git a/scripts/v.geometry/tests/conftest.py b/scripts/v.geometry/tests/conftest.py new file mode 100644 index 00000000000..da609e83147 --- /dev/null +++ b/scripts/v.geometry/tests/conftest.py @@ -0,0 +1,116 @@ +"""Fixtures for v.geometry tests.""" + +import io +import os + +import pytest + +import grass.script as gs +from grass.tools import Tools + + +LINE_ASCII = """\ +ORGANIZATION: GRASS Test +DIGIT DATE: today +DIGIT NAME: test +MAP NAME: line +MAP DATE: today +MAP SCALE: 1 +OTHER INFO: +ZONE: 0 +MAP THRESH: 0.500000 +VERTI: +L 2 1 + 0 0 + 100 100 + 1 1 +""" + + +# Three non-overlapping areas (two sharing cat 2, one cat 1) plus a line +# with cat 10. Boundaries carry no categories, so v.to.db option=count +# emits spurious cat=-1 records for them. Used to check that aligning +# count to the other metric's cat set drops the line and the cat=-1 +# records, and that counts for repeated cats aggregate correctly. +MIXED_ASCII = """\ +ORGANIZATION: GRASS Test +DIGIT DATE: today +DIGIT NAME: test +MAP NAME: mixed +MAP DATE: today +MAP SCALE: 1 +OTHER INFO: +ZONE: 0 +MAP THRESH: 0.500000 +VERTI: +B 5 + 0 0 + 50 0 + 50 50 + 0 50 + 0 0 +C 1 1 + 25 25 + 1 1 +B 5 + 60 0 + 100 0 + 100 50 + 60 50 + 60 0 +C 1 1 + 80 25 + 1 2 +B 5 + 0 60 + 50 60 + 50 100 + 0 100 + 0 60 +C 1 1 + 25 80 + 1 2 +L 2 1 + 70 70 + 90 90 + 1 10 +""" + + +@pytest.fixture(scope="module") +def session(tmp_path_factory): + """Session with a 2x2 rectangle grid, a set of points, and a straight line.""" + tmp_path = tmp_path_factory.mktemp("v_geometry") + project = tmp_path / "test" + gs.create_project(project) + with gs.setup.init(project, env=os.environ.copy()) as grass_session: + tools = Tools(session=grass_session) + tools.g_region(s=0, n=100, w=0, e=100, res=1) + + # 2x2 grid of 50x50 rectangles over (0,0)-(100,100). + tools.v_mkgrid(map="grid", grid=(2, 2)) + + # Three points at known coordinates. + points_ascii = "10|20\n30|40\n50|60\n" + tools.v_in_ascii( + input=io.StringIO(points_ascii), + output="points", + format="point", + separator="pipe", + ) + + # Single straight line from (0,0) to (100,100) with category 1. + tools.v_in_ascii( + input=io.StringIO(LINE_ASCII), + output="line", + format="standard", + ) + + # Mixed map: a line (cat 1) and a 50x50 area (cat 2). + tools.v_in_ascii( + input=io.StringIO(MIXED_ASCII), + output="mixed", + format="standard", + ) + + yield grass_session diff --git a/scripts/v.geometry/tests/v_geometry_test.py b/scripts/v.geometry/tests/v_geometry_test.py new file mode 100644 index 00000000000..5f3d927f50c --- /dev/null +++ b/scripts/v.geometry/tests/v_geometry_test.py @@ -0,0 +1,167 @@ +"""Tests for v.geometry.""" + +import math + +import pytest + +from grass.tools import Tools + + +def test_area_metrics(session): + """Area-family metrics on a 2x2 grid of 50x50 squares. + + Exercises area, perimeter, compactness, fractal_dimension, bbox in + one call so the merged-record and key-rename behaviors are both + covered without paying for extra subprocess spawns. + """ + tools = Tools(session=session) + result = tools.v_geometry( + map="grid", metric="area,perimeter,compactness,fractal_dimension,bbox" + ) + assert len(result["records"]) == 4 + expected_compactness = 200.0 / (2.0 * math.sqrt(math.pi * 2500.0)) + for record in result["records"]: + assert record["area"] == pytest.approx(2500.0) + assert record["perimeter"] == pytest.approx(200.0) + assert record["compactness"] == pytest.approx(expected_compactness, rel=1e-6) + assert "compact" not in record + assert "fractal_dimension" in record + assert "fd" not in record + assert {"north", "south", "east", "west"} <= set(record.keys()) + assert result["units"]["area"] == "square meters" + assert result["units"]["perimeter"] == "meters" + + +def test_area_hectares(session): + tools = Tools(session=session) + result = tools.v_geometry(map="grid", metric="area", units="hectares") + for record in result["records"]: + assert record["area"] == pytest.approx(0.25) + assert result["units"]["area"] == "hectares" + + +def test_line_metrics(session): + """Line-family metrics on a straight line from (0,0) to (100,100).""" + tools = Tools(session=session) + result = tools.v_geometry(map="line", metric="length,sinuosity,azimuth") + record = result["records"][0] + assert record["length"] == pytest.approx(math.sqrt(2.0) * 100.0, rel=1e-6) + # A straight line has sinuosity 1. + assert record["sinuosity"] == pytest.approx(1.0, rel=1e-6) + assert "sinuous" not in record + assert "azimuth" in record + + +def test_count_totals(session): + tools = Tools(session=session) + result = tools.v_geometry(map="points", metric="count") + assert result["totals"]["count"] == 3 + + +def test_coordinates(session): + tools = Tools(session=session) + result = tools.v_geometry(map="points", metric="coordinates") + coords = {(record["x"], record["y"]) for record in result["records"]} + assert coords == {(10.0, 20.0), (30.0, 40.0), (50.0, 60.0)} + + +def test_multiple_metrics_totals(session): + """Totals from different metrics are merged.""" + tools = Tools(session=session) + result = tools.v_geometry(map="grid", metric="area,count", type="centroid") + assert result["totals"]["area"] == pytest.approx(10000.0) + assert result["totals"]["count"] == 4 + + +def test_per_metric_units(session): + """Each metric gets its own unit via positional correspondence.""" + tools = Tools(session=session) + result = tools.v_geometry( + map="grid", metric="area,perimeter", units="hectares,kilometers" + ) + assert result["units"]["area"] == "hectares" + assert result["units"]["perimeter"] == "kilometers" + for record in result["records"]: + assert record["area"] == pytest.approx(0.25) + assert record["perimeter"] == pytest.approx(0.2) + + +def test_partial_units(session): + """Fewer units than metrics: extra metrics use defaults.""" + tools = Tools(session=session) + result = tools.v_geometry(map="grid", metric="area,perimeter", units="hectares") + assert result["units"]["area"] == "hectares" + assert result["units"]["perimeter"] == "meters" + + +def test_plain_format(session): + tools = Tools(session=session) + result = tools.v_geometry(map="grid", metric="area", format="plain") + lines = result.stdout.splitlines() + assert lines[0] == "category|area" + assert len(lines) == 5 + + +def test_csv_format(session): + """Also verifies that nprocs does not affect output.""" + tools = Tools(session=session) + serial = tools.v_geometry( + map="grid", + metric="area,perimeter,compactness", + format="csv", + nprocs=1, + ) + lines = serial.stdout.splitlines() + assert lines[0] == "category,area,perimeter,compactness" + assert len(lines) == 5 + parallel = tools.v_geometry( + map="grid", + metric="area,perimeter,compactness", + format="csv", + nprocs=2, + ) + assert serial.stdout == parallel.stdout + + +def test_csv_rejects_multichar_separator(session): + tools = Tools(session=session) + with pytest.raises(Exception, match="CSV separator"): + tools.v_geometry(map="grid", metric="area", format="csv", separator="--") + + +def test_mixed_feature_types_rejected(session): + """Mixing metrics from different feature-type families fails cleanly.""" + tools = Tools(session=session) + with pytest.raises(Exception, match="different feature types"): + tools.v_geometry(map="grid", metric="area,length") + + +def test_count_combined_aligns_with_family(session): + """count combined with another metric filters to its cat set. + + Mixed map features: + - Area cat 1 (50x50 = 2500): one feature. + - Area cat 2: two non-overlapping 40x50 features (total area 4000, + count 2) — verifies that repeated cats aggregate correctly. + - Line cat 10: must be dropped from count (not in area's cats). + - Three un-categorized boundaries: v.to.db option=count reports + them as cat=-1, which must also be dropped. + + Expected after alignment: + - Records for cats 1 and 2 only, both carrying area and count. + - totals["count"] = 3 (1 for cat 1, 2 for cat 2), not polluted by + the line or the cat=-1 boundaries. + """ + tools = Tools(session=session) + result = tools.v_geometry(map="mixed", metric="area,count") + + records_by_cat = {record["category"]: record for record in result["records"]} + assert set(records_by_cat) == {1, 2} + + assert records_by_cat[1]["area"] == pytest.approx(2500.0) + assert records_by_cat[1]["count"] == 1 + assert records_by_cat[2]["area"] == pytest.approx(4000.0) + assert records_by_cat[2]["count"] == 2 + + assert result["totals"]["area"] == pytest.approx(6500.0) + assert result["totals"]["count"] == 3 diff --git a/scripts/v.geometry/v.geometry.html b/scripts/v.geometry/v.geometry.html new file mode 100644 index 00000000000..9d4b8a42d11 --- /dev/null +++ b/scripts/v.geometry/v.geometry.html @@ -0,0 +1,93 @@ +

DESCRIPTION

+ +v.geometry prints geometry metrics of vector features. Output +is available as JSON, CSV, or plain text. + +

One or more metric values can be requested per invocation. When +multiple metrics are given, they are computed in parallel and the +results are merged by category. Supported metrics are: + +

+ +

Values are aggregated per category. + +

Measures of lengths and areas are reported in meters by default; use +the units option to change this. + +

NOTES

+ +v.geometry is a read-only front-end to +v.to.db and accepts the same set of +units. It does not read from or write to the attribute table, so no +table needs to be attached to the input map. + +

For writing metrics back into an attribute table, use +v.to.db directly. Features of +v.to.db that are not purely geometric, such as cross-layer +attribute queries (option=query), are intentionally not +exposed here. + +

Records are keyed by category. When multiple metrics are requested, +the values for each category are merged into one record. If a category +is shared across features of different types (for example a line and +an area with the same cat), their metrics would end up in the same +record even though they describe different features. To avoid this, +v.geometry rejects combinations of metrics that belong to +different feature-type families (area, line, point); the +count metric is universal and can be combined with any of +them. Run v.geometry separately for each feature type if you +need metrics from more than one family. + +

When count is combined with another metric (for example +metric=area,count), the count is aligned to the categories +covered by that metric. Categories that only appear in the count result +are dropped so every merged record carries both values, and the reported +count total reflects only the aligned features. + +

EXAMPLES

+ +Report area sizes of geology polygons in hectares: + +
+v.geometry map=geology metric=area units=hectares
+
+ +Compute area, perimeter, and compactness in a single call: + +
+v.geometry map=geology metric=area,perimeter,compactness
+
+ +Consume metrics from Python: + +
+import grass.script as gs
+
+data = gs.parse_command("v.geometry", map="geology", metric="compactness")
+for record in data["records"]:
+    print(record["category"], record["compactness"])
+
+ +

SEE ALSO

+ + +r.object.geometry, +v.category, +v.db.join, +v.report, +v.to.db, +v.univar + + +

AUTHORS

+ +Anna Petrasova, NCSU GeoForAll Laboratory diff --git a/scripts/v.geometry/v.geometry.md b/scripts/v.geometry/v.geometry.md new file mode 100644 index 00000000000..98b5a0265fe --- /dev/null +++ b/scripts/v.geometry/v.geometry.md @@ -0,0 +1,105 @@ +## DESCRIPTION + +*v.geometry* prints geometry metrics of vector features. Output is +available as JSON, CSV, or plain text. + +One or more **metric** values can be requested per invocation. When +multiple metrics are given, they are computed in parallel and the +results are merged by category. Supported metrics are: + +- `area`, `perimeter`, `compactness`, `fractal_dimension`, `bbox` - + for areas (and boundaries that form areas) +- `length`, `slope`, `sinuosity`, `azimuth`, `start`, `end` - for + lines and boundaries +- `coordinates` - for points and centroids +- `count` - number of features per category + +Values are aggregated per category. + +Measures of lengths and areas are reported in meters by default; use +the **units** option to change this. + +## NOTES + +*v.geometry* is a read-only front-end to *[v.to.db](v.to.db.md)* and +accepts the same set of units. It does not read from or write to the +attribute table, so no table needs to be attached to the input map. + +For writing metrics back into an attribute table, use +*[v.to.db](v.to.db.md)* directly. Features of *v.to.db* that are not +purely geometric, such as cross-layer attribute queries +(`option=query`), are intentionally not exposed here. + +Records are keyed by category. When multiple metrics are requested, the +values for each category are merged into one record. If a category is +shared across features of different types (for example a line and an +area with the same cat), their metrics would end up in the same record +even though they describe different features. To avoid this, *v.geometry* +rejects combinations of metrics that belong to different feature-type +families (area, line, point); the `count` metric is universal and can +be combined with any of them. Run *v.geometry* separately for each +feature type if you need metrics from more than one family. + +When `count` is combined with another metric (for example +`metric=area,count`), the count is aligned to the categories covered +by that metric. Categories that only appear in the count result are +dropped so every merged record carries both values, and the reported +count total reflects only the aligned features. + +## EXAMPLES + +Report area sizes of geology polygons in hectares: + +```sh +v.geometry map=geology metric=area units=hectares +``` + +Compute area, perimeter, and compactness in a single call: + +```sh +v.geometry map=geology metric=area,perimeter,compactness +``` + +Consume metrics from Python: + +```python +import grass.script as gs + +data = gs.parse_command("v.geometry", map="geology", metric="compactness") +for record in data["records"]: + print(record["category"], record["compactness"]) +``` + +Sample JSON output for `metric=length`: + +```json +{ + "units": { + "length": "meters" + }, + "totals": { + "length": 10426.657857419743 + }, + "records": [ + { + "category": 1, + "length": 4554.943058982206 + }, + { + "category": 2, + "length": 5871.714798437538 + } + ] +} +``` + +## SEE ALSO + +*[r.object.geometry](r.object.geometry.md), +[v.category](v.category.md), [v.db.join](v.db.join.md), +[v.report](v.report.md), [v.to.db](v.to.db.md), +[v.univar](v.univar.md)* + +## AUTHORS + +Anna Petrasova, NCSU GeoForAll Laboratory diff --git a/scripts/v.geometry/v.geometry.py b/scripts/v.geometry/v.geometry.py new file mode 100644 index 00000000000..eeb95b10138 --- /dev/null +++ b/scripts/v.geometry/v.geometry.py @@ -0,0 +1,309 @@ +#!/usr/bin/env python3 + +############################################################################ +# +# MODULE: v.geometry +# AUTHOR: Anna Petrasova +# PURPOSE: Print geometry metrics of vector features as JSON +# COPYRIGHT: (C) 2026 by Anna Petrasova and the GRASS Development Team +# This program is free software under the GNU General +# Public License (>=v2). Read the file COPYING that +# comes with GRASS for details. +# +############################################################################# + +# %module +# % description: Prints geometry metrics of vector features. +# % keyword: vector +# % keyword: geometry +# % keyword: metric +# % keyword: parallel +# %end + +# %option G_OPT_V_MAP +# %end + +# %option +# % key: metric +# % type: string +# % required: yes +# % multiple: yes +# % options: area,perimeter,length,count,compactness,fractal_dimension,slope,sinuosity,azimuth,coordinates,start,end,bbox +# % description: Geometry metric(s) to compute +# % descriptions: area;area size;perimeter;perimeter length of an area;length;line length;count;number of features for each category;compactness;compactness of an area, calculated as perimeter / (2 * sqrt(PI * area));fractal_dimension;fractal dimension of boundary defining a polygon, calculated as 2 * (log(perimeter) / log(area));slope;slope steepness of vector line or boundary;sinuosity;line sinuosity, calculated as line length / distance between end points;azimuth;line azimuth, calculated as angle between North direction and endnode direction at startnode;coordinates;point coordinates, X,Y or X,Y,Z;start;line/boundary starting point coordinates, X,Y or X,Y,Z;end;line/boundary end point coordinates, X,Y or X,Y,Z;bbox;bounding box of area, N,S,E,W +# %end + +# %option G_OPT_V_TYPE +# % options: point,line,boundary,centroid +# % answer: point,line,boundary,centroid +# %end + +# %option G_OPT_V_FIELD +# %end + +# %option G_OPT_M_UNITS +# % multiple: yes +# % options: miles,feet,meters,kilometers,acres,hectares,radians,degrees +# % description: Units (one per metric, positional; unspecified metrics use defaults) +# %end + +# %option G_OPT_M_NPROCS +# %end + +# %option G_OPT_F_SEP +# % answer: {NULL} +# %end + +# %option G_OPT_F_FORMAT +# % options: plain,json,csv +# % answer: json +# % descriptions: plain;Plain text with pipe separator by default;json;JSON (JavaScript Object Notation);csv;CSV (Comma Separated Values) +# %end + +import csv +import json +import os +import sys +from concurrent.futures import ThreadPoolExecutor + +import grass.script as gs +from grass.tools import Tools + + +# Map v.geometry metric names to v.to.db option names. Most are identical; +# a few are renamed for clarity (e.g. "compact" -> "compactness"). +METRIC_TO_VTODB_OPTION = { + "area": "area", + "perimeter": "perimeter", + "length": "length", + "count": "count", + "compactness": "compact", + "fractal_dimension": "fd", + "slope": "slope", + "sinuosity": "sinuous", + "azimuth": "azimuth", + "coordinates": "coor", + "start": "start", + "end": "end", + "bbox": "bbox", +} + +# Keys that v.to.db emits in its JSON output which v.geometry renames to +# match the user-facing metric names above. +_VTODB_KEY_RENAMES = { + "compact": "compactness", + "fd": "fractal_dimension", + "sinuous": "sinuosity", +} + +# Group each metric by the feature type it describes. Records from different +# metrics are merged by category, so mixing metrics from different groups +# (e.g. a line's sinuosity and an area's perimeter) would silently combine +# unrelated features that happen to share a category. "count" applies to any +# feature type and may be combined with any group. +METRIC_GROUPS = { + "area": "area", + "perimeter": "area", + "compactness": "area", + "fractal_dimension": "area", + "bbox": "area", + "length": "line", + "slope": "line", + "sinuosity": "line", + "azimuth": "line", + "start": "line", + "end": "line", + "coordinates": "point", + "count": "any", +} + + +def _rename_keys(mapping): + return {_VTODB_KEY_RENAMES.get(k, k): v for k, v in mapping.items()} + + +def _available_cpus(): + """Number of CPUs this process may actually use. + + Prefers affinity-aware sources over ``os.cpu_count()``, which reports + the host total and overcounts in containers and cgroup-limited jobs. + """ + if hasattr(os, "process_cpu_count"): # Python 3.13+ + return os.process_cpu_count() or 1 + if hasattr(os, "sched_getaffinity"): # Linux + return len(os.sched_getaffinity(0)) + return os.cpu_count() or 1 + + +def _resolve_nprocs(nprocs): + """Resolve G_OPT_M_NPROCS into a worker count for ThreadPoolExecutor. + + Mirrors the semantics of G_set_omp_num_threads() in + lib/gis/omp_threads.c: 0 means use all available cores, a positive + number is used as-is, a negative number means cpu_count + nprocs + (clamped to at least 1). Belongs in a library helper eventually. + """ + nprocs = int(nprocs) + if nprocs > 0: + return nprocs + available = _available_cpus() + if nprocs == 0: + return available + return max(1, available + nprocs) + + +def _run_vtodb(metric, unit, common_kwargs): + """Run v.to.db for a single metric and return the parsed JSON result.""" + vtodb_option = METRIC_TO_VTODB_OPTION[metric] + kwargs = dict(common_kwargs) + if unit: + kwargs["units"] = unit + result = Tools().v_to_db(option=vtodb_option, format="json", **kwargs) + return result.json + + +def _merge_results(results): + """Merge per-metric v.to.db results into a single JSON structure. + + Each metric contributes its keys to every record (matched by category), + and its entries to the shared ``units`` and ``totals`` dicts. The + ``results`` list must be in the caller's metric order so that the + resulting record field order is deterministic. + """ + merged_units = {} + merged_totals = {} + # category -> merged record dict + records_by_cat = {} + + for result in results: + merged_units.update(_rename_keys(result.get("units", {}))) + merged_totals.update(_rename_keys(result.get("totals", {}))) + for record in result.get("records", []): + record = _rename_keys(record) + cat = record["category"] + if cat in records_by_cat: + records_by_cat[cat].update(record) + else: + records_by_cat[cat] = dict(record) + + # Preserve category order. + records = [records_by_cat[cat] for cat in sorted(records_by_cat)] + return {"units": merged_units, "totals": merged_totals, "records": records} + + +def main(): + options, _flags = gs.parser() + + metrics = options["metric"].split(",") + groups = {METRIC_GROUPS[m] for m in metrics} - {"any"} + if len(groups) > 1: + gs.fatal( + _( + "Cannot mix metrics from different feature types: {}. " + "Results are merged by category, so combining e.g. line and " + "area metrics would produce misleading records. Run " + "v.geometry separately for each feature type." + ).format( + ", ".join( + "{} ({})".format(m, METRIC_GROUPS[m]) + for m in metrics + if METRIC_GROUPS[m] != "any" + ) + ) + ) + + units_list = options["units"].split(",") if options["units"] else [] + if len(units_list) > len(metrics): + gs.fatal( + _("More units ({}) than metrics ({}) specified").format( + len(units_list), len(metrics) + ) + ) + # Pad with None so every metric has a corresponding entry. + units_list.extend([None] * (len(metrics) - len(units_list))) + + output_format = options["format"] + separator = gs.separator(options["separator"]) + if output_format == "csv": + if not separator: + separator = "," + elif len(separator) > 1: + gs.fatal( + _( + "A standard CSV separator (delimiter) is only one character " + "long, got: {}" + ).format(separator) + ) + elif output_format == "plain" and not separator: + separator = "|" + + # v.to.db requires the "columns" parameter even in print-only mode, but + # does not use it for JSON or plain output; any valid name works. + common_kwargs = { + "map": options["map"], + "type": options["type"], + "layer": options["layer"], + "columns": "unused", + "flags": "p", + } + + if len(metrics) == 1: + results = [_run_vtodb(metrics[0], units_list[0], common_kwargs)] + else: + # Submit all metrics concurrently but collect results in metric + # order so downstream column/field ordering is deterministic. Cap + # at len(metrics); extra workers just sit idle. + max_workers = min(_resolve_nprocs(options["nprocs"]), len(metrics)) + with ThreadPoolExecutor(max_workers=max_workers) as executor: + futures = [ + executor.submit(_run_vtodb, m, u, common_kwargs) + for m, u in zip(metrics, units_list, strict=True) + ] + results = [f.result() for f in futures] + + # count's records cover every feature; family metrics emit records + # only for cat-bearing features of their family. Align count's cat + # set to the other metrics so merged records share one schema. + if "count" in metrics: + count_idx = metrics.index("count") + other_cats = { + record["category"] + for m, r in zip(metrics, results, strict=True) + if m != "count" + for record in r.get("records", []) + } + aligned = [ + record + for record in results[count_idx].get("records", []) + if record["category"] in other_cats + ] + results[count_idx]["records"] = aligned + results[count_idx]["totals"]["count"] = sum(r["count"] for r in aligned) + + result = _merge_results(results) + + if output_format == "json": + print(json.dumps(result, indent=4)) + return 0 + + records = result["records"] + if not records: + return 0 + columns = list(records[0].keys()) + + if output_format == "csv": + # Force LF endings; csv.writer defaults to CRLF, which compounds + # with text-mode stdout's newline translation on some platforms. + writer = csv.writer(sys.stdout, delimiter=separator, lineterminator="\n") + writer.writerow(columns) + writer.writerows([record.get(c, "") for c in columns] for record in records) + else: # plain + print(separator.join(columns)) + for record in records: + print(separator.join(str(record.get(c, "")) for c in columns)) + + return 0 + + +if __name__ == "__main__": + sys.exit(main())