Skip to content

Commit 51df4b0

Browse files
jsignellgadomski
andauthored
Implement migrate command (#443)
* Implement migrate command * Update changelog * Add recursive and old version of planet-disaster * Raise if no-op * Move changelog entry * Update src/stactools/cli/commands/migrate.py --------- Co-authored-by: Pete Gadomski <[email protected]>
1 parent 28252f4 commit 51df4b0

File tree

14 files changed

+1132
-10
lines changed

14 files changed

+1132
-10
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- Add `stac migrate` command ([#443](https://github.com/stac-utils/stactools/pull/443))
13+
1014
### Fixed
1115

1216
- `reproject_shape` without a precision ([#454](https://github.com/stac-utils/stactools/pull/454))

src/stactools/cli/__init__.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,15 +37,13 @@ def register_plugin(registry: "Registry") -> None:
3737
registry.register_subcommand(layout.create_layout_command)
3838
registry.register_subcommand(lint.create_lint_command)
3939
registry.register_subcommand(merge.create_merge_command)
40+
registry.register_subcommand(migrate.create_migrate_command)
4041
registry.register_subcommand(summary.create_summary_command)
4142
registry.register_subcommand(validate.create_validate_command)
4243
registry.register_subcommand(version.create_version_command)
4344
registry.register_subcommand(update_extent.create_update_extent_command)
4445
registry.register_subcommand(update_geometry.create_update_geometry_command)
4546

46-
# TODO
47-
# registry.register_subcommand(migrate.create_migrate_command)
48-
4947

5048
from stactools.cli.registry import Registry
5149

Lines changed: 46 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,54 @@
11
import click
2+
import pystac
3+
from stactools.core import migrate_object
24

35

4-
def migrate(option: click.Option) -> None:
5-
# TODO
6-
print(option)
6+
def _migrate(
7+
href: str, save: bool = False, recursive: bool = False, show_diff: bool = True
8+
) -> pystac.STACObject:
9+
if save is False and show_diff is False:
10+
raise click.BadArgumentUsage(
11+
"It is only valid to use 'hide-diff' when 'save' is enabled "
12+
"otherwise there would be no output."
13+
)
14+
15+
stac_object = pystac.read_file(href)
16+
if recursive and not isinstance(stac_object, (pystac.Catalog, pystac.Collection)):
17+
raise click.BadArgumentUsage(
18+
"'recursive' is only a valid option for "
19+
"pystac.Catalogs and pystac.Collections"
20+
)
21+
return migrate_object(
22+
stac_object, save=save, recursive=recursive, show_diff=show_diff
23+
)
724

825

926
def create_migrate_command(cli: click.Group) -> click.Command:
10-
@cli.command("migrate", short_help="Migrate a STAC catalog (TODO)")
11-
@click.option("--option", "-o", default=1, help="A test option")
12-
def migrate_command(option: click.Option) -> None:
13-
migrate(option)
27+
@cli.command("migrate", short_help="Migrate a STAC object to the latest version")
28+
@click.argument("href")
29+
@click.option(
30+
"-s",
31+
"--save",
32+
is_flag=True,
33+
help="Save migrated STAC object in original location.",
34+
)
35+
@click.option(
36+
"-r",
37+
"--recursive",
38+
is_flag=True,
39+
help="Recurse through all child objects and migrate them as well.",
40+
)
41+
@click.option(
42+
"--show-diff/--hide-diff",
43+
default=True,
44+
help=(
45+
"Whether to dump diff between original and migrated object to stdout. "
46+
"Defaults to --show-diff. "
47+
),
48+
)
49+
def migrate_command(
50+
href: str, save: bool, recursive: bool, show_diff: bool
51+
) -> None:
52+
_migrate(href, save=save, recursive=recursive, show_diff=show_diff)
1453

1554
return migrate_command

src/stactools/core/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from stactools.core.io import use_fsspec
1212
from stactools.core.layout import layout_catalog
1313
from stactools.core.merge import merge_all_items, merge_items
14+
from stactools.core.migrate import migrate_object
1415

1516
__all__ = [
1617
"add_item",
@@ -21,6 +22,7 @@
2122
"layout_catalog",
2223
"merge_all_items",
2324
"merge_items",
25+
"migrate_object",
2426
"move_asset_file",
2527
"move_asset_file_to_item",
2628
"move_assets",

src/stactools/core/migrate.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import difflib
2+
import json
3+
from itertools import chain
4+
5+
import pystac
6+
from pystac.stac_io import DefaultStacIO
7+
8+
9+
def _print_diff(stac_object: pystac.STACObject) -> None:
10+
href = stac_object.get_self_href()
11+
if href is None:
12+
raise ValueError(f"Could not determine diff for {stac_object}, missing href")
13+
input = DefaultStacIO().read_text(href)
14+
output = json.dumps(stac_object.to_dict(), indent=2)
15+
for diffs in difflib.unified_diff(
16+
input.splitlines(), output.splitlines(), fromfile=f"a{href}", tofile=f"b{href}"
17+
):
18+
print(diffs)
19+
20+
21+
def migrate_object(
22+
stac_object: pystac.STACObject,
23+
save: bool = False,
24+
recursive: bool = False,
25+
show_diff: bool = True,
26+
) -> pystac.STACObject:
27+
"""Migrate a STAC object and all its extensions to the latest version of STAC
28+
29+
Note:
30+
Migrating a STAC object will set the keys to be in the standard order.
31+
This might result in a diff even for a valid and up-to-date STAC object.
32+
33+
Args:
34+
stac_object : STAC object to migrate
35+
save : Whether to save the object back to its original location.
36+
Defaults to False.
37+
recursive : Whether to recurse through all the child object and migrate
38+
them as well. Defaults to False.
39+
show_diff : Whether to print the diff between the original and new STAC
40+
object. Defaults to True.
41+
Returns:
42+
STACObject : The migrated object - modified inplace.
43+
"""
44+
# migration happens implicitly on load
45+
if show_diff:
46+
_print_diff(stac_object)
47+
48+
if recursive:
49+
if not isinstance(stac_object, (pystac.Catalog, pystac.Collection)):
50+
raise KeyError(
51+
"'recursive' is only a valid option for "
52+
"pystac.Catalogs and pystac.Collections"
53+
)
54+
stac_object.fully_resolve()
55+
if show_diff:
56+
for _, children, items in stac_object.walk():
57+
for obj in chain(children, items):
58+
_print_diff(obj)
59+
if save:
60+
stac_object.save()
61+
else:
62+
if save:
63+
stac_object.save_object()
64+
65+
return stac_object

tests/cli/commands/test_migrate.py

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import shutil
2+
from pathlib import Path
3+
4+
import pystac
5+
import pytest
6+
from click.testing import CliRunner
7+
from stactools.cli.cli import cli
8+
9+
from tests import test_data
10+
11+
12+
@pytest.fixture(scope="function")
13+
def tmp_planet_disaster_path(tmp_path: Path) -> str:
14+
src = test_data.get_path("data-files/planet-disaster-v1.0.0-beta.2")
15+
dst = tmp_path / "planet-disaster"
16+
shutil.copytree(src, str(dst))
17+
return str(dst / "collection.json")
18+
19+
20+
def test_migrate_no_save_by_default(tmp_planet_disaster_path: str):
21+
path = tmp_planet_disaster_path
22+
with open(path) as f:
23+
before = f.readlines()
24+
25+
runner = CliRunner()
26+
result = runner.invoke(cli, ["migrate", path])
27+
assert result.exit_code == 0
28+
29+
with open(path) as f:
30+
after = f.readlines()
31+
32+
assert before == after
33+
34+
35+
def test_migrate_with_save_no_recursive(tmp_planet_disaster_path: str):
36+
path = tmp_planet_disaster_path
37+
root = pystac.Collection.from_file(path)
38+
child_path = next(root.get_children()).get_self_href()
39+
item_path = next(root.get_all_items()).get_self_href()
40+
41+
with open(path) as f:
42+
root_before = f.readlines()
43+
44+
with open(child_path) as f:
45+
child_before = f.readlines()
46+
47+
with open(item_path) as f:
48+
item_before = f.readlines()
49+
50+
runner = CliRunner()
51+
result = runner.invoke(cli, ["migrate", path, "--save"])
52+
assert result.exit_code == 0
53+
54+
with open(path) as f:
55+
root_after = f.readlines()
56+
57+
with open(child_path) as f:
58+
child_after = f.readlines()
59+
60+
with open(item_path) as f:
61+
item_after = f.readlines()
62+
63+
assert root_before != root_after, path
64+
assert child_before == child_after, child_path
65+
assert item_before == item_after, item_path
66+
67+
68+
def test_migrate_with_save_and_recursive(tmp_planet_disaster_path: str):
69+
path = tmp_planet_disaster_path
70+
root = pystac.Collection.from_file(path)
71+
child_path = next(root.get_children()).get_self_href()
72+
item_path = next(root.get_all_items()).get_self_href()
73+
74+
with open(path) as f:
75+
root_before = f.readlines()
76+
77+
with open(child_path) as f:
78+
child_before = f.readlines()
79+
80+
with open(item_path) as f:
81+
item_before = f.readlines()
82+
83+
runner = CliRunner()
84+
result = runner.invoke(cli, ["migrate", path, "-r", "-s"])
85+
assert result.exit_code == 0
86+
87+
with open(path) as f:
88+
root_after = f.readlines()
89+
90+
with open(child_path) as f:
91+
child_after = f.readlines()
92+
93+
with open(item_path) as f:
94+
item_after = f.readlines()
95+
96+
assert root_before != root_after, path
97+
assert child_before != child_after, child_path
98+
assert item_before != item_after, item_path
99+
100+
101+
def test_migrate_show_diff(tmp_planet_disaster_path: str):
102+
path = tmp_planet_disaster_path
103+
root = pystac.Collection.from_file(path)
104+
child_path = next(root.get_children()).get_self_href()
105+
item_path = next(root.get_all_items()).get_self_href()
106+
107+
runner = CliRunner()
108+
result = runner.invoke(cli, ["migrate", path, "--show-diff"])
109+
assert result.exit_code == 0
110+
111+
assert result.output.startswith("--- a")
112+
assert path in result.output
113+
assert child_path not in result.output
114+
assert item_path not in result.output
115+
116+
117+
def test_migrate_show_diff_and_recursive(tmp_planet_disaster_path: str):
118+
path = tmp_planet_disaster_path
119+
root = pystac.Collection.from_file(path)
120+
child_path = next(root.get_children()).get_self_href()
121+
item_path = next(root.get_all_items()).get_self_href()
122+
123+
runner = CliRunner()
124+
result = runner.invoke(cli, ["migrate", path, "--show-diff", "--recursive"])
125+
assert result.exit_code == 0
126+
127+
assert result.output.startswith("--- a")
128+
assert path in result.output
129+
assert child_path in result.output
130+
assert item_path in result.output
131+
132+
133+
def test_migrate_hide_diff_with_no_save_raises(tmp_planet_disaster_path: str):
134+
path = tmp_planet_disaster_path
135+
136+
runner = CliRunner()
137+
result = runner.invoke(cli, ["migrate", path, "--hide-diff"])
138+
assert result.exit_code == 2
139+
assert (
140+
"Error: It is only valid to use 'hide-diff' when 'save' is enabled "
141+
"otherwise there would be no output." in result.output
142+
)
143+
144+
145+
def test_migrate_hide_diff(tmp_planet_disaster_path: str):
146+
path = tmp_planet_disaster_path
147+
148+
runner = CliRunner()
149+
result = runner.invoke(cli, ["migrate", path, "--hide-diff", "--save"])
150+
assert not result.output
151+
152+
153+
def test_migrate_recursive_invalid_for_items(tmp_planet_disaster_path: str):
154+
path = tmp_planet_disaster_path
155+
root = pystac.Collection.from_file(path)
156+
item_path = next(root.get_all_items()).get_self_href()
157+
158+
runner = CliRunner()
159+
result = runner.invoke(cli, ["migrate", item_path, "-r"])
160+
assert result.exit_code == 2
161+
assert (
162+
"Error: 'recursive' is only a valid option for "
163+
"pystac.Catalogs and pystac.Collections" in result.output
164+
)

0 commit comments

Comments
 (0)