Skip to content

Commit 8a96b21

Browse files
committed
refactor!: use a configuration object
1 parent d5dc861 commit 8a96b21

File tree

10 files changed

+168
-153
lines changed

10 files changed

+168
-153
lines changed

src/stac_asset/__init__.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,12 @@
1111

1212

1313
from .client import Client
14+
from .config import Config
1415
from .earthdata_client import EarthdataClient
1516
from .errors import (
1617
AssetDownloadError,
1718
AssetOverwriteError,
18-
CantIncludeAndExclude,
19+
CannotIncludeAndExclude,
1920
DownloadError,
2021
DownloadWarning,
2122
)
@@ -34,8 +35,9 @@
3435
"DownloadWarning",
3536
"AssetDownloadError",
3637
"AssetOverwriteError",
37-
"CantIncludeAndExclude",
38+
"CannotIncludeAndExclude",
3839
"Client",
40+
"Config",
3941
"DownloadError",
4042
"EarthdataClient",
4143
"FileNameStrategy",

src/stac_asset/_cli.py

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import click
88
from pystac import Item, ItemCollection
99

10-
from . import functions
10+
from . import Config, functions
1111

1212

1313
@click.group()
@@ -30,6 +30,7 @@ def cli() -> None:
3030
help="Asset keys to exclude (can't be used with include)",
3131
multiple=True,
3232
)
33+
@click.option("-f", "--file-name", help="The output file name")
3334
@click.option(
3435
"-q",
3536
"--quiet",
@@ -58,6 +59,7 @@ def download(
5859
directory: Optional[str],
5960
include: List[str],
6061
exclude: List[str],
62+
file_name: Optional[str],
6163
quiet: bool,
6264
s3_requester_pays: bool,
6365
warn: bool,
@@ -88,6 +90,14 @@ def download(
8890
if directory is None:
8991
directory = os.getcwd()
9092

93+
config = Config(
94+
include=include,
95+
exclude=exclude,
96+
file_name=file_name,
97+
s3_requester_pays=s3_requester_pays,
98+
warn=warn,
99+
)
100+
91101
type_ = input_dict.get("type")
92102
if type_ is None:
93103
print("ERROR: missing 'type' field on input dictionary", file=sys.stderr)
@@ -101,11 +111,7 @@ def download(
101111
functions.download_item(
102112
item,
103113
directory,
104-
include=include or None,
105-
exclude=exclude or None,
106-
item_file_name=None,
107-
s3_requester_pays=s3_requester_pays,
108-
warn_on_download_error=warn,
114+
config=config,
109115
)
110116
)
111117
elif type_ == "FeatureCollection":
@@ -114,11 +120,7 @@ def download(
114120
functions.download_item_collection(
115121
item_collection,
116122
directory,
117-
include=include or None,
118-
exclude=exclude or None,
119-
item_collection_file_name=None,
120-
s3_requester_pays=s3_requester_pays,
121-
warn_on_download_error=warn,
123+
config=config,
122124
)
123125
)
124126
else:

src/stac_asset/client.py

Lines changed: 34 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,10 @@
1414
from pystac import Asset, Item, ItemCollection
1515
from yarl import URL
1616

17+
from .config import Config
1718
from .errors import (
1819
AssetDownloadError,
1920
AssetOverwriteError,
20-
CantIncludeAndExclude,
2121
DownloadError,
2222
DownloadWarning,
2323
)
@@ -123,70 +123,52 @@ async def download_item(
123123
self,
124124
item: Item,
125125
directory: PathLikeObject,
126-
*,
127-
make_directory: bool = False,
128-
include: Optional[List[str]] = None,
129-
exclude: Optional[List[str]] = None,
130-
item_file_name: Optional[str] = "item.json",
131-
include_self_link: bool = True,
132-
asset_file_name_strategy: FileNameStrategy = FileNameStrategy.FILE_NAME,
133-
warn_on_download_error: bool = False,
126+
config: Optional[Config] = None,
134127
) -> Item:
135128
"""Downloads an item and all of its assets to the given directory.
136129
137130
Args:
138131
item: The item to download
139132
directory: The root location of the downloaded files
140-
make_directory: If true and the directory doesn't exist, create the
141-
output directory before downloading
142-
include: Asset keys to download. If not provided, all asset keys
143-
will be downloaded.
144-
exclude: Asset keys to not download. If not provided, all asset keys
145-
will be downloaded.
146-
item_file_name: The name of the item file. If not provided, the item
147-
will not be written to the filesystem (only the assets will be
148-
downloaded).
149-
include_self_link: Whether to include a self link on the item.
150-
Unused if ``item_file_name=None``.
151-
asset_file_name_strategy: The :py:class:`FileNameStrategy` to use
152-
for naming asset files
153-
warn_on_download_error: Instead of raising any errors encountered
154-
while downloading, warn and delete the asset from the item
133+
config: Configuration for downloading the item
155134
156135
Returns:
157136
Item: The :py:class:`~pystac.Item`, with updated asset hrefs
158-
159-
Raises:
160-
CantIncludeAndExclude: Raised if both include and exclude are not None.
161137
"""
162-
if include is not None and exclude is not None:
163-
raise CantIncludeAndExclude()
138+
if config is None:
139+
config = Config()
140+
else:
141+
config.validate()
164142

165143
directory_as_path = Path(directory)
166144
if not directory_as_path.exists():
167-
if make_directory:
145+
if config.make_directory:
168146
directory_as_path.mkdir()
169147
else:
170148
raise FileNotFoundError(f"output directory does not exist: {directory}")
171149

172-
if item_file_name:
173-
item_path = directory_as_path / item_file_name
150+
if config.file_name:
151+
item_path = directory_as_path / config.file_name
174152
else:
175-
item_path = None
153+
self_href = item.get_self_href()
154+
if self_href:
155+
item_path = directory_as_path / os.path.basename(self_href)
156+
else:
157+
item_path = None
176158

177159
tasks: List[Task[Any]] = list()
178160
file_names: Dict[str, str] = dict()
179161
item.make_asset_hrefs_absolute()
180162
for key, asset in (
181163
(k, a)
182164
for k, a in item.assets.items()
183-
if (include is None or k in include)
184-
and (exclude is None or k not in exclude)
165+
if (not config.include or k in config.include)
166+
and (not config.exclude or k not in config.exclude)
185167
):
186168
# TODO strategy should be auto-guessable
187-
if asset_file_name_strategy == FileNameStrategy.FILE_NAME:
169+
if config.asset_file_name_strategy == FileNameStrategy.FILE_NAME:
188170
file_name = os.path.basename(URL(asset.href).path)
189-
elif asset_file_name_strategy == FileNameStrategy.KEY:
171+
elif config.asset_file_name_strategy == FileNameStrategy.KEY:
190172
file_name = key + Path(asset.href).suffix
191173
path = directory_as_path / file_name
192174
if file_name in file_names:
@@ -212,7 +194,7 @@ async def download_item(
212194
if isinstance(result, Exception):
213195
exceptions.append(result)
214196
if exceptions:
215-
if warn_on_download_error:
197+
if config.warn:
216198
for exception in exceptions:
217199
warnings.warn(str(exception), DownloadWarning)
218200
if isinstance(exception, AssetDownloadError):
@@ -230,7 +212,7 @@ async def download_item(
230212

231213
if item_path:
232214
item.set_self_href(str(item_path))
233-
item.save_object(include_self_link=include_self_link)
215+
item.save_object(include_self_link=True)
234216
else:
235217
item.set_self_href(None)
236218

@@ -240,32 +222,14 @@ async def download_item_collection(
240222
self,
241223
item_collection: ItemCollection,
242224
directory: PathLikeObject,
243-
*,
244-
make_directory: bool = False,
245-
include: Optional[List[str]] = None,
246-
exclude: Optional[List[str]] = None,
247-
item_collection_file_name: Optional[str] = "item-collection.json",
248-
asset_file_name_strategy: FileNameStrategy = FileNameStrategy.FILE_NAME,
249-
warn_on_download_error: bool = False,
225+
config: Optional[Config] = None,
250226
) -> ItemCollection:
251227
"""Downloads an item collection and all of its assets to the given directory.
252228
253229
Args:
254230
item_collection: The item collection to download
255231
directory: The root location of the downloaded files
256-
make_directory: If true and and the directory does not exist, create
257-
the output directory before downloading
258-
include: Asset keys to download. If not provided, all asset keys
259-
will be downloaded.
260-
exclude: Asset keys to not download. If not provided, all asset keys
261-
will be downloaded.
262-
item_collection_file_name: The name of the item collection file in the
263-
directory. If not provided, the item collection will not be
264-
written to the filesystem (only the assets will be downloaded).
265-
asset_file_name_strategy: The :py:class:`FileNameStrategy` to use
266-
for naming asset files
267-
warn_on_download_error: Instead of raising any errors encountered
268-
while downloading, warn and delete the asset from the item
232+
config: Configuration for downloading the item
269233
270234
Returns:
271235
ItemCollection: The :py:class:`~pystac.ItemCollection`, with the
@@ -274,9 +238,13 @@ async def download_item_collection(
274238
Raises:
275239
CantIncludeAndExclude: Raised if both include and exclude are not None.
276240
"""
241+
if config is None:
242+
config = Config()
243+
# Config validation happens at the download_item level
244+
277245
directory_as_path = Path(directory)
278246
if not directory_as_path.exists():
279-
if make_directory:
247+
if config.make_directory:
280248
directory_as_path.mkdir()
281249
else:
282250
raise FileNotFoundError(f"output directory does not exist: {directory}")
@@ -285,17 +253,15 @@ async def download_item_collection(
285253
for item in item_collection.items:
286254
# TODO what happens if items share ids?
287255
item_directory = directory_as_path / item.id
256+
item_config = config.copy()
257+
item_config.make_directory = True
258+
item_config.file_name = None
288259
tasks.append(
289260
asyncio.create_task(
290261
self.download_item(
291262
item=item,
292263
directory=item_directory,
293-
make_directory=True,
294-
include=include,
295-
exclude=exclude,
296-
item_file_name=None,
297-
asset_file_name_strategy=asset_file_name_strategy,
298-
warn_on_download_error=warn_on_download_error,
264+
config=item_config,
299265
)
300266
)
301267
)
@@ -307,9 +273,9 @@ async def download_item_collection(
307273
if exceptions:
308274
raise DownloadError(exceptions)
309275
item_collection.items = results
310-
if item_collection_file_name:
276+
if config.file_name:
311277
item_collection.save_object(
312-
dest_href=str(directory_as_path / item_collection_file_name)
278+
dest_href=str(directory_as_path / config.file_name)
313279
)
314280
return item_collection
315281

src/stac_asset/config.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
from __future__ import annotations
2+
3+
import copy
4+
from dataclasses import dataclass, field
5+
from typing import List, Optional
6+
7+
from .errors import CannotIncludeAndExclude
8+
from .strategy import FileNameStrategy
9+
10+
11+
@dataclass
12+
class Config:
13+
"""Configuration for downloading items and their assets."""
14+
15+
asset_file_name_strategy: FileNameStrategy = FileNameStrategy.FILE_NAME
16+
"""The file name strategy to use when downloading assets."""
17+
18+
exclude: List[str] = field(default_factory=list)
19+
"""Assets to exclude from the download.
20+
21+
Mutually exclusive with ``include``.
22+
"""
23+
24+
include: List[str] = field(default_factory=list)
25+
"""Assets to include in the download.
26+
27+
Mutually exclusive with ``exclude``.
28+
"""
29+
30+
file_name: Optional[str] = None
31+
"""The file name of the output item.
32+
33+
If not provided, the output item will not be saved.
34+
"""
35+
36+
make_directory: bool = True
37+
"""Whether to create the output directory.
38+
39+
If False, and the output directory does not exist, an error will be raised.
40+
"""
41+
42+
warn: bool = False
43+
"""When downloading, warn instead of erroring."""
44+
45+
s3_requester_pays: bool = False
46+
"""If using the s3 client, enable requester pays."""
47+
48+
def validate(self) -> None:
49+
"""Validates this configuration.
50+
51+
Raises:
52+
CannotIncludeAndExclude: ``include`` and ``exclude`` are mutually exclusive
53+
"""
54+
if self.include and self.exclude:
55+
raise CannotIncludeAndExclude(include=self.include, exclude=self.exclude)
56+
57+
def copy(self) -> Config:
58+
"""Returns a deep copy of this config.
59+
60+
Returns:
61+
Config: A deep copy of this config.
62+
"""
63+
return copy.deepcopy(self)

src/stac_asset/errors.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,12 +33,17 @@ class DownloadWarning(Warning):
3333
"""
3434

3535

36-
class CantIncludeAndExclude(Exception):
36+
class CannotIncludeAndExclude(Exception):
3737
"""Raised if both include and exclude are passed to download."""
3838

39-
def __init__(self, *args: Any, **kwargs: Any) -> None:
39+
def __init__(
40+
self, include: List[str], exclude: List[str], *args: Any, **kwargs: Any
41+
) -> None:
4042
super().__init__(
41-
"can't use include and exclude in the same download call", *args, **kwargs
43+
"can't use include and exclude in the same download call: "
44+
f"include={include}, exclude={exclude}",
45+
*args,
46+
**kwargs,
4247
)
4348

4449

0 commit comments

Comments
 (0)