Repository: pjhartzell/raster-footprint (language: python)

Issue:
Add a CLI



Discussion / hints:

Fix patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 86fd302..edf51d5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,7 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
 ### Added
 
+- Added a CLI ([#27](https://github.com/pjhartzell/raster-footprint/issues/27))
 - Added a `precision` option to the densify functions
   ([#25](https://github.com/pjhartzell/raster-footprint/issues/25))
 
diff --git a/README.md b/README.md
index 2b1ddd8..abb8596 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,7 @@
 
 [![GitHub](https://img.shields.io/github/license/pjhartzell/raster-footprint?style=for-the-badge)](https://github.com/pjhartzell/raster-footprint/blob/main/LICENSE)
 
-Create GeoJSON geometries that bound valid (not "nodata" pixels) raster data. Depends on [rasterio](https://rasterio.readthedocs.io/en/stable/) and [shapely](https://shapely.readthedocs.io/en/stable/manual.html).
+Create GeoJSON geometries that bound valid raster data. Depends on [rasterio](https://rasterio.readthedocs.io/en/stable/) and [shapely](https://shapely.readthedocs.io/en/stable/manual.html).
 
 ## Usage
 
@@ -14,9 +14,26 @@ Create GeoJSON geometries that bound valid (not "nodata" pixels) raster data. De
 pip install raster-footprint
 ```
 
-Import and call one of the footprint functions with some options:
+Create or manipulate GeoJSON with the CLI:
 
-```python
+```shell
+raster-footprint --help
+usage: raster-footprint [-h] {create,densify,reproject,simplify} ...
+
+options:
+  -h, --help            show this help message and exit
+
+commands:
+  {create,densify,reproject,simplify}
+    create              Create a raster footprint
+    densify             Densify a Polygon or MultiPolygon
+    reproject           Reproject a Polygon or MultiPolygon
+    simplify            Simplify a Polygon or MultiPolygon
+```
+
+Import `raster_footprint` functions into your Python script:
+
+```Python
 from raster_footprint import footprint_from_href
 
 footprint = footprint_from_href(
diff --git a/pyproject.toml b/pyproject.toml
index f5f0a28..b1db1e2 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -13,6 +13,9 @@ classifiers = [
 ]
 dependencies = ["rasterio>=1.3", "shapely>=2.0"]
 
+[project.scripts]
+raster-footprint = "raster_footprint._cli:cli"
+
 [project.urls]
 Documentation = "https://raster-footprint.readthedocs.io"
 GitHub = "https://github.com/pjhartzell/raster-footprint"
@@ -38,5 +41,8 @@ build-backend = "setuptools.build_meta"
 [tool.ruff]
 line-length = 88
 
+[tool.ruff.per-file-ignores]
+"src/raster_footprint/_cli.py" = ["E501"]
+
 [tool.black]
 line-length = 88
diff --git a/src/raster_footprint/_cli.py b/src/raster_footprint/_cli.py
new file mode 100644
index 0000000..47de2d9
--- /dev/null
+++ b/src/raster_footprint/_cli.py
@@ -0,0 +1,133 @@
+"""This is a "private" module that is not part of our public API. The interfaces in this
+module can change at any time without warning."""
+
+import argparse
+import json
+import os
+import sys
+from typing import Optional
+
+from shapely.geometry import mapping, shape
+
+from raster_footprint import (
+    densify_geometry,
+    footprint_from_href,
+    reproject_geometry,
+    simplify_geometry,
+)
+
+
+def output(footprint, outfile: Optional[str] = None):
+    if outfile is not None:
+        dirname = os.path.dirname(outfile)
+        if dirname:
+            os.makedirs(dirname, exist_ok=True)
+        with open(outfile, "w") as f:
+            json.dump(footprint, f, indent=4)
+    else:
+        print(json.dumps(footprint, indent=4))
+
+
+def create(args) -> None:
+    href = args.pop("infile")
+    outfile = args.pop("outfile", None)
+
+    footprint = footprint_from_href(href, **args)
+    output(footprint, outfile)
+
+
+def densify(args) -> None:
+    infile = args.pop("infile")
+    outfile = args.pop("outfile", None)
+
+    with open(infile, "r") as f:
+        geometry = shape(json.load(f))
+
+    densified = mapping(densify_geometry(geometry, **args))
+    output(densified, outfile)
+
+
+def reproject(args) -> None:
+    infile = args.pop("infile")
+    outfile = args.pop("outfile", None)
+
+    with open(infile, "r") as f:
+        geometry = shape(json.load(f))
+
+    reprojected = mapping(reproject_geometry(geometry, **args))
+    output(reprojected, outfile)
+
+
+def simplify(args) -> None:
+    infile = args.pop("infile")
+    outfile = args.pop("outfile", None)
+
+    with open(infile, "r") as f:
+        geometry = shape(json.load(f))
+
+    simplified = mapping(simplify_geometry(geometry, **args))
+    output(simplified, outfile)
+
+
+def cli() -> None:
+    # fmt: off
+    parser = argparse.ArgumentParser(prog="raster-footprint")
+    subparsers = parser.add_subparsers(title="commands", required=True)
+
+    # create footprint
+    parser_create = subparsers.add_parser("create", help="Create a raster footprint")
+    parser_create.set_defaults(func=create)
+    parser_create.add_argument("infile", help="Input raster filename")
+    parser_create.add_argument("--outfile", help="Output footprint filename")
+    parser_create.add_argument("--destination-crs", help="Footprint coordinate CRS")
+    parser_create.add_argument("--nodata", type=float, help="Input raster nodata value")
+    parser_create.add_argument("--precision", type=int, help="Footprint coordinate precision")
+
+    densify_group_create = parser_create.add_mutually_exclusive_group()
+    densify_group_create.add_argument("--densify-factor", type=int, help="Densification factor")
+    densify_group_create.add_argument("--densify-distance", type=float, help="Densification distance")
+
+    parser_create.add_argument("--simplify-tolerance", type=float, help="Simplification tolerance")
+    parser_create.add_argument("--convex-hull", action="store_true", help="Apply convex hull to footprint")
+    parser_create.add_argument("--holes", action="store_true", help="Include polygon holes in footprint")
+    parser_create.add_argument("--bands", nargs="+", type=int, help="Raster band indices to include in footprint")
+    parser_create.add_argument("--with-nodata", action="store_true", help="Include nodata pixels in the footprint")
+
+    # densify footprint
+    parser_densify = subparsers.add_parser("densify", help="Densify a Polygon or MultiPolygon")
+    parser_densify.set_defaults(func=densify)
+    parser_densify.add_argument("infile", help="Input footprint filename")
+    parser_densify.add_argument("--outfile", help="Output footprint filename")
+
+    densify_group_densify = parser_densify.add_mutually_exclusive_group(required=True)
+    densify_group_densify.add_argument("--factor", type=int, help="Densification factor")
+    densify_group_densify.add_argument("--distance", type=float, help="Densification distance")
+
+    parser_densify.add_argument("--precision", type=int, help="Footprint coordinate precision")
+
+    # reproject footprint
+    parser_simplify = subparsers.add_parser("reproject", help="Reproject a Polygon or MultiPolygon")
+    parser_simplify.set_defaults(func=reproject)
+    parser_simplify.add_argument("infile", help="Input footprint filename")
+    parser_simplify.add_argument("source_crs", type=int, help="Input footprint CRS EPSG code", metavar="source-epsg")
+    parser_simplify.add_argument("destination_crs", type=int, help="Output footprint CRS EPSG code", metavar="destination-epsg")
+    parser_simplify.add_argument("--outfile", help="Output footprint filename")
+    parser_simplify.add_argument("--precision", type=int, help="Footprint coordinate precision")
+
+    # simplify footprint
+    parser_simplify = subparsers.add_parser("simplify", help="Simplify a Polygon or MultiPolygon")
+    parser_simplify.set_defaults(func=simplify)
+    parser_simplify.add_argument("infile", help="Input footprint filename")
+    parser_simplify.add_argument("--outfile", help="Output footprint filename")
+    parser_simplify.add_argument("--tolerance", type=float, help="Simplification tolerance")
+
+    parser.parse_args(sys.argv[1:] or ["--help"])
+    args = vars(parser.parse_args(sys.argv[1:] or ["--help"]))
+    func = args.pop("func")
+    args = {k: v for k, v in args.items() if v is not None}
+    func(args)
+    # fmt: on
+
+
+if __name__ == "__main__":
+    cli()
diff --git a/src/raster_footprint/mask.py b/src/raster_footprint/mask.py
index 6d565c1..4f57a1c 100644
--- a/src/raster_footprint/mask.py
+++ b/src/raster_footprint/mask.py
@@ -66,7 +66,7 @@ def get_mask_geometry(
         convex_hull (bool): Whether to return the convex hull of the created
             polygons. Defaults to False.
         holes (bool): Whether to include holes in the created polygons. Has
-            no effect if ``convex_hull`` is True. Defaults to True.
+            no effect if ``convex_hull`` is True. Defaults to False.
 
     Returns:
         Optional[Union[Polygon, MultiPolygon]: A polygon or multipolygon


Test patch:
diff --git a/tests/test_cli.py b/tests/test_cli.py
new file mode 100644
index 0000000..b1cdb36
--- /dev/null
+++ b/tests/test_cli.py
@@ -0,0 +1,160 @@
+import json
+import os
+import subprocess
+from tempfile import TemporaryDirectory
+
+from shapely.geometry import shape
+
+from tests.conftest import TEST_DATA_DIRECTORY, check_winding, read_geojson
+
+
+def test_cli_create() -> None:
+    infile = os.path.join(
+        TEST_DATA_DIRECTORY,
+        "geotiff",
+        "MCD43A4.A2001055.h25v06.006.2016113010159_B01.reduced.TIF",
+    )
+    densify_distance = 100000
+    simplify_tolerance = 0.01
+
+    with TemporaryDirectory() as tmp_dir:
+        outfile = os.path.join(tmp_dir, "from_href.json")
+        subprocess.run(
+            [
+                "raster-footprint",
+                "create",
+                infile,
+                "--outfile",
+                outfile,
+                "--densify-distance",
+                str(densify_distance),
+                "--simplify-tolerance",
+                str(simplify_tolerance),
+                "--holes",
+            ],
+            check=True,
+        )
+
+        with open(outfile) as f:
+            from_href = shape(json.load(f))
+        expected = shape(
+            read_geojson("modis-densify_distance-100000-simplify_tolerance-0.01.json")
+        )
+
+        check_winding(from_href)
+        assert from_href.normalize() == expected.normalize()
+
+
+def test_cli_densify() -> None:
+    infile = os.path.join(
+        TEST_DATA_DIRECTORY,
+        "geojson",
+        "concave-shell.json",
+    )
+    factor = 2
+    distance = 1
+
+    with TemporaryDirectory() as tmp_dir:
+        outfile_factor = os.path.join(tmp_dir, "densified_by_factor.json")
+        subprocess.run(
+            [
+                "raster-footprint",
+                "densify",
+                infile,
+                "--outfile",
+                outfile_factor,
+                "--factor",
+                str(factor),
+            ],
+            check=True,
+        )
+        with open(outfile_factor) as f:
+            densified_by_factor = shape(json.load(f))
+
+        check_winding(densified_by_factor)
+        assert len(densified_by_factor.exterior.coords) == 25
+
+        outfile_distance = os.path.join(tmp_dir, "densified_by_distance.json")
+        subprocess.run(
+            [
+                "raster-footprint",
+                "densify",
+                infile,
+                "--outfile",
+                outfile_distance,
+                "--distance",
+                str(distance),
+            ],
+            check=True,
+        )
+        with open(outfile_distance) as f:
+            densified_by_distance = shape(json.load(f))
+
+        check_winding(densified_by_distance)
+        assert len(densified_by_distance.exterior.coords) == 29
+
+
+def test_cli_reproject() -> None:
+    infile = os.path.join(
+        TEST_DATA_DIRECTORY,
+        "geojson",
+        "two-concave-shells-each-with-two-holes-epsg-32631.json",
+    )
+    source_epsg = 32631
+    destination_epsg = 4326
+    precision = 5
+
+    with TemporaryDirectory() as tmp_dir:
+        outfile = os.path.join(tmp_dir, "reprojected.json")
+        subprocess.run(
+            [
+                "raster-footprint",
+                "reproject",
+                infile,
+                str(source_epsg),
+                str(destination_epsg),
+                "--outfile",
+                outfile,
+                "--precision",
+                str(precision),
+            ],
+            check=True,
+        )
+
+        with open(outfile) as f:
+            reprojected = shape(json.load(f))
+        expected = shape(read_geojson("two-concave-shells-each-with-two-holes.json"))
+
+        check_winding(reprojected)
+        assert reprojected.normalize() == expected.normalize()
+
+
+def test_cli_simplify() -> None:
+    infile = os.path.join(
+        TEST_DATA_DIRECTORY,
+        "geojson",
+        "concave-shell.json",
+    )
+    tolerance = 1.1
+
+    with TemporaryDirectory() as tmp_dir:
+        outfile = os.path.join(tmp_dir, "simplified.json")
+        subprocess.run(
+            [
+                "raster-footprint",
+                "simplify",
+                infile,
+                "--outfile",
+                outfile,
+                "--tolerance",
+                str(tolerance),
+            ],
+            check=True,
+        )
+
+        with open(outfile) as f:
+            simplified = shape(json.load(f))
+        expected = shape(read_geojson("convex-hull-of-concave-shell.json"))
+
+        check_winding(simplified)
+        assert simplified.normalize() == expected.normalize()