Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement fs copy from HTTP #669

Merged
merged 35 commits into from
Dec 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
9f165c8
Add requests library
maddenp-noaa Dec 4, 2024
840a021
Work on _check_destination_paths()
maddenp-noaa Dec 5, 2024
e02d346
Work on tests
maddenp-noaa Dec 5, 2024
e74d243
Remove unneeded test
maddenp-noaa Dec 5, 2024
d447b5d
Improve error messages
maddenp-noaa Dec 5, 2024
4c09249
Parametrize tests
maddenp-noaa Dec 5, 2024
6182577
More tests
maddenp-noaa Dec 5, 2024
a9f7daa
Formatting
maddenp-noaa Dec 5, 2024
314c871
Generalize Copier
maddenp-noaa Dec 5, 2024
fa5a0c7
Generalize Copier
maddenp-noaa Dec 5, 2024
9d6654b
Generalize Copier
maddenp-noaa Dec 5, 2024
4ccb95d
Push scheme handling into filecopy()
maddenp-noaa Dec 5, 2024
9b573fa
Work on existing() HTTP support
maddenp-noaa Dec 5, 2024
e0c211b
Accept HTTP 301
maddenp-noaa Dec 5, 2024
dbf8f4f
Work on existing() HTTP support
maddenp-noaa Dec 5, 2024
2e7916f
Improve tests
maddenp-noaa Dec 5, 2024
ac91f61
Improve tests
maddenp-noaa Dec 5, 2024
adf7422
Add test
maddenp-noaa Dec 5, 2024
7a15247
Work on HTTP GET
maddenp-noaa Dec 5, 2024
c02c2c9
Work on HTTP GET
maddenp-noaa Dec 5, 2024
9877c3c
Work on HTTP GET tests
maddenp-noaa Dec 5, 2024
4ba3d99
Use uwtools log object in tests
maddenp-noaa Dec 7, 2024
46c34ab
Always make directory for dst file
maddenp-noaa Dec 7, 2024
89941b5
Update docs
maddenp-noaa Dec 7, 2024
7f05b41
Only make dst dir when src req is satisfied
maddenp-noaa Dec 7, 2024
e873834
Doc fixes
maddenp-noaa Dec 7, 2024
ada3aa7
Update
maddenp-noaa Dec 7, 2024
7316d60
Use STR for "file" URL scheme string
maddenp-noaa Dec 7, 2024
f9a0fb2
Simplify
maddenp-noaa Dec 7, 2024
ae39438
Rename
maddenp-noaa Dec 7, 2024
076e443
Merge branch 'main' into gh-667-fs-copy-http
maddenp-noaa Dec 10, 2024
ac7a90f
Fix test
maddenp-noaa Dec 10, 2024
3a74abe
More tests
maddenp-noaa Dec 10, 2024
d0ecc19
Fix
maddenp-noaa Dec 10, 2024
a05d302
More tests
maddenp-noaa Dec 10, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions docs/sections/user_guide/cli/tools/fs.rst
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ The ``uw`` mode for handling filesystem items (files and directories).

The ``copy`` action stages files in a target directory by copying files. Any ``KEY`` positional arguments are used to navigate, in the order given, from the top of the config to the :ref:`file block <files_yaml>`.

Source paths prefixed with ``http://`` or ``https://`` will be copied from their upstream network locations to the local filesystem.

.. literalinclude:: fs/copy-help.cmd
:emphasize-lines: 1
.. literalinclude:: fs/copy-help.out
Expand All @@ -23,7 +25,7 @@ The ``copy`` action stages files in a target directory by copying files. Any ``K
Examples
^^^^^^^^

Given ``copy-config.yaml`` containing
Given ``copy-config.yaml`` containing a mapping from local-filesystem destination paths to source paths

.. literalinclude:: fs/copy-config.yaml
:language: yaml
Expand All @@ -32,7 +34,7 @@ Given ``copy-config.yaml`` containing
.. literalinclude:: fs/copy-exec.out
:language: text

Here, ``foo`` and ``bar`` are copies of their respective source files.
Here, ``foo`` and ``bar`` are copies of their respective local-filesystem source files, and ``gpl`` is a copy of the upstream network source.

The ``--cycle`` and ``--leadtime`` options can be used to make Python ``datetime`` and ``timedelta`` objects, respectively, available for use in Jinja2 expression in the config. For example:

Expand Down
1 change: 1 addition & 0 deletions docs/sections/user_guide/cli/tools/fs/copy-config.yaml
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
config:
files:
foo: src/foo
licenses/gpl: https://www.gnu.org/licenses/gpl-3.0.txt
subdir/bar: src/bar
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
[2024-08-26T23:03:40] INFO Validating config against internal schema: files-to-stage
[2024-08-26T23:03:40] INFO 0 UW schema-validation errors found in fs config
[2024-08-26T23:03:40] ERROR Relative path 'foo' requires the target directory to be specified
[2024-12-07T01:01:51] INFO Validating config against internal schema: files-to-stage
[2024-12-07T01:01:53] INFO 0 UW schema-validation errors found in fs config
[2024-12-07T01:01:53] ERROR Relative path 'foo' requires target directory to be specified
39 changes: 23 additions & 16 deletions docs/sections/user_guide/cli/tools/fs/copy-exec.out
Original file line number Diff line number Diff line change
@@ -1,22 +1,29 @@
[2024-08-26T23:03:41] INFO Validating config against internal schema: files-to-stage
[2024-08-26T23:03:41] INFO 0 UW schema-validation errors found in fs config
[2024-08-26T23:03:41] INFO File copies: Initial state: Not Ready
[2024-08-26T23:03:41] INFO File copies: Checking requirements
[2024-08-26T23:03:41] INFO Copy src/foo -> copy-dst/foo: Initial state: Not Ready
[2024-08-26T23:03:41] INFO Copy src/foo -> copy-dst/foo: Checking requirements
[2024-08-26T23:03:41] INFO Copy src/foo -> copy-dst/foo: Requirement(s) ready
[2024-08-26T23:03:41] INFO Copy src/foo -> copy-dst/foo: Executing
[2024-08-26T23:03:41] INFO Copy src/foo -> copy-dst/foo: Final state: Ready
[2024-08-26T23:03:41] INFO Copy src/bar -> copy-dst/subdir/bar: Initial state: Not Ready
[2024-08-26T23:03:41] INFO Copy src/bar -> copy-dst/subdir/bar: Checking requirements
[2024-08-26T23:03:41] INFO Copy src/bar -> copy-dst/subdir/bar: Requirement(s) ready
[2024-08-26T23:03:41] INFO Copy src/bar -> copy-dst/subdir/bar: Executing
[2024-08-26T23:03:41] INFO Copy src/bar -> copy-dst/subdir/bar: Final state: Ready
[2024-08-26T23:03:41] INFO File copies: Final state: Ready
[2024-12-07T01:01:56] INFO Validating config against internal schema: files-to-stage
[2024-12-07T01:01:56] INFO 0 UW schema-validation errors found in fs config
[2024-12-07T01:01:56] INFO File copies: Initial state: Not Ready
[2024-12-07T01:01:56] INFO File copies: Checking requirements
[2024-12-07T01:01:56] INFO Copy src/foo -> copy-dst/foo: Initial state: Not Ready
[2024-12-07T01:01:56] INFO Copy src/foo -> copy-dst/foo: Checking requirements
[2024-12-07T01:01:56] INFO Copy src/foo -> copy-dst/foo: Requirement(s) ready
[2024-12-07T01:01:56] INFO Copy src/foo -> copy-dst/foo: Executing
[2024-12-07T01:01:56] INFO Copy src/foo -> copy-dst/foo: Final state: Ready
[2024-12-07T01:01:56] INFO Copy https://www.gnu.org/licenses/gpl-3.0.txt -> copy-dst/licenses/gpl: Initial state: Not Ready
[2024-12-07T01:01:56] INFO Copy https://www.gnu.org/licenses/gpl-3.0.txt -> copy-dst/licenses/gpl: Checking requirements
[2024-12-07T01:01:58] INFO Copy https://www.gnu.org/licenses/gpl-3.0.txt -> copy-dst/licenses/gpl: Requirement(s) ready
[2024-12-07T01:01:58] INFO Copy https://www.gnu.org/licenses/gpl-3.0.txt -> copy-dst/licenses/gpl: Executing
[2024-12-07T01:01:58] INFO Copy https://www.gnu.org/licenses/gpl-3.0.txt -> copy-dst/licenses/gpl: Final state: Ready
[2024-12-07T01:01:58] INFO Copy src/bar -> copy-dst/subdir/bar: Initial state: Not Ready
[2024-12-07T01:01:58] INFO Copy src/bar -> copy-dst/subdir/bar: Checking requirements
[2024-12-07T01:01:58] INFO Copy src/bar -> copy-dst/subdir/bar: Requirement(s) ready
[2024-12-07T01:01:58] INFO Copy src/bar -> copy-dst/subdir/bar: Executing
[2024-12-07T01:01:58] INFO Copy src/bar -> copy-dst/subdir/bar: Final state: Ready
[2024-12-07T01:01:58] INFO File copies: Final state: Ready

copy-dst
├── foo
├── licenses
│   └── gpl
└── subdir
└── bar

2 directories, 2 files
3 directories, 3 files
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
[2024-08-26T23:03:41] INFO Validating config against internal schema: files-to-stage
[2024-08-26T23:03:41] INFO 0 UW schema-validation errors found in fs config
[2024-08-26T23:03:41] ERROR Relative path 'foo' requires the target directory to be specified
[2024-12-07T01:01:55] INFO Validating config against internal schema: files-to-stage
[2024-12-07T01:01:55] INFO 0 UW schema-validation errors found in fs config
[2024-12-07T01:01:55] ERROR Relative path 'foo' requires target directory to be specified
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
[2024-08-26T23:03:44] INFO Validating config against internal schema: makedirs
[2024-08-26T23:03:45] INFO 0 UW schema-validation errors found in fs config
[2024-08-26T23:03:45] ERROR Relative path 'foo' requires the target directory to be specified
[2024-12-07T01:01:55] INFO Validating config against internal schema: makedirs
[2024-12-07T01:01:55] INFO 0 UW schema-validation errors found in fs config
[2024-12-07T01:01:55] ERROR Relative path 'foo' requires target directory to be specified
4 changes: 3 additions & 1 deletion recipe/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"pytest-xdist =3.6.*",
"python >=3.9,<3.13",
"pyyaml =6.0.*",
"requests =2.32.*",
"setuptools"
],
"run": [
Expand All @@ -31,7 +32,8 @@
"jsonschema >=4.18,<4.24",
"lxml =5.3.*",
"python >=3.9,<3.13",
"pyyaml =6.0.*"
"pyyaml =6.0.*",
"requests =2.32.*"
]
},
"version": "2.5.0"
Expand Down
1 change: 1 addition & 0 deletions recipe/meta.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ requirements:
- lxml 5.3.*
- python >=3.9,<3.13
- pyyaml 6.0.*
- requests 2.32.*
test:
requires:
- black 24.8.*
Expand Down
3 changes: 2 additions & 1 deletion src/uwtools/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,8 @@ def main() -> None:
modes = {**tools, **drivers}
sys.exit(0 if modes[args[STR.mode]](args) else 1)
except UWError as e:
log.error(str(e))
for line in str(e).split("\n"):
log.error(line)
maddenp-noaa marked this conversation as resolved.
Show resolved Hide resolved
sys.exit(1)


Expand Down
60 changes: 48 additions & 12 deletions src/uwtools/fs.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from abc import ABC, abstractmethod
from pathlib import Path
from typing import Optional, Union
from urllib.parse import urlparse

from iotaa import dryrun, tasks

Expand Down Expand Up @@ -56,20 +57,44 @@ def __init__(
)
self._config, _ = walk_key_path(yaml_config.data, key_path or [])
self._validate()
self._check_paths()
self._check_target_dir()
self._check_destination_paths()

def _check_paths(self) -> None:
def _check_destination_paths(self) -> None:
maddenp-noaa marked this conversation as resolved.
Show resolved Hide resolved
"""
Check that all paths are absolute if no target directory is specified.
Check that destination paths are valid.

:parm paths: The paths to check.
:raises: UWConfigError if no target directory is specified and a relative path is.
:raises: UWConfigError when a bad path is detected.
"""
if not self._target_dir:
errmsg = "Relative path '%s' requires the target directory to be specified"
for dst in self._dst_paths:
if not Path(dst).is_absolute():
raise UWConfigError(errmsg % dst)
for dst in self._dst_paths:
scheme = urlparse(dst).scheme
absolute = scheme or Path(dst).is_absolute()
if scheme and scheme != STR.url_scheme_file:
msg = "Non-filesystem destination path '%s' not currently supported"
raise UWConfigError(msg % dst)
if self._target_dir and scheme:
msg = "Non-filesystem path '%s' invalid when target directory is specified"
raise UWConfigError(msg % dst)
if self._target_dir and absolute:
msg = "Path '%s' must be relative when target directory is specified"
raise UWConfigError(msg % dst)
if not self._target_dir and not absolute:
msg = "Relative path '%s' requires target directory to be specified"
raise UWConfigError(msg % dst)

def _check_target_dir(self) -> None:
maddenp-noaa marked this conversation as resolved.
Show resolved Hide resolved
"""
Check that target directory is valid.

:raises: UWConfigError when a bad path is detected.
"""
if (
self._target_dir
and (scheme := urlparse(str(self._target_dir)).scheme)
and scheme != STR.url_scheme_file
):
msg = "Non-filesystem path '%s' invalid as target directory"
raise UWConfigError(msg % self._target_dir)

@property
@abstractmethod
Expand Down Expand Up @@ -124,9 +149,20 @@ def go(self):
"""
Copy files.
"""
dst = lambda k: Path(self._target_dir / k if self._target_dir else k)
yield "File copies"
yield [filecopy(src=Path(v), dst=dst(k)) for k, v in self._config.items()]
yield [
filecopy(src=src, dst=self._simple(self._target_dir) / self._simple(dst))
for dst, src in self._config.items()
]

@staticmethod
def _simple(path: Union[Path, str]) -> Path:
"""
Convert a path, potentially prefixed with scheme file://, into a simple filesystem path.

:param path: The path to convert.
"""
return Path(urlparse(str(path)).path)


class Linker(FileStager):
Expand Down
1 change: 1 addition & 0 deletions src/uwtools/strings.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ class STR:
updatefmt: str = "update_format"
updatevalues: str = "update_values"
upp: str = "upp"
url_scheme_file: str = "file"
validate: str = "validate"
valsfile: str = "values_file"
valsfmt: str = "values_format"
Expand Down
2 changes: 1 addition & 1 deletion src/uwtools/tests/api/test_fs.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ def kwargs(tmp_path):
f.touch()
config = {"a": {"b": {str(dstdir / "f1"): str(srcfile1), str(dstdir / "f2"): str(srcfile2)}}}
return {
"target_dir": dstdir,
"target_dir": None,
"config": config,
"cycle": dt.datetime.now(),
"leadtime": dt.timedelta(hours=6),
Expand Down
89 changes: 81 additions & 8 deletions src/uwtools/tests/test_fs.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
# pylint: disable=missing-class-docstring
# pylint: disable=missing-function-docstring
# pylint: disable=protected-access
# pylint: disable=redefined-outer-name

from pathlib import Path
from unittest.mock import Mock, patch

import iotaa
import yaml
from pytest import fixture, mark, raises
Expand Down Expand Up @@ -48,8 +52,19 @@ def _schema(self):
# Tests


@mark.parametrize("src_fn", [str, Path])
@mark.parametrize("dst_fn", [str, Path])
@mark.parametrize("td_fn", [str, Path])
def test_fs_Copier_go(src_fn, dst_fn, td_fn):
src, td, dst = src_fn("/src/file"), td_fn("/dst"), dst_fn("file")
obj = Mock(_config={dst: src}, _simple=fs.Copier._simple, _target_dir=td)
with patch.object(fs, "filecopy") as filecopy:
fs.Copier.go(obj)
filecopy.assert_called_once_with(src=src, dst=Path("/dst/file"))


@mark.parametrize("source", ("dict", "file"))
def test_Copier(assets, source):
def test_fs_Copier_go_live(assets, source):
dstdir, cfgdict, cfgfile = assets
config = cfgdict if source == "dict" else cfgfile
assert not (dstdir / "foo").exists()
Expand All @@ -59,7 +74,7 @@ def test_Copier(assets, source):
assert (dstdir / "subdir" / "bar").is_file()


def test_Copier_config_file_dry_run(assets):
def test_fs_Copier_go_live_config_file_dry_run(assets):
dstdir, cfgdict, _ = assets
assert not (dstdir / "foo").exists()
assert not (dstdir / "subdir" / "bar").exists()
Expand All @@ -69,7 +84,7 @@ def test_Copier_config_file_dry_run(assets):
iotaa.dryrun(False)


def test_Copier_no_targetdir_abspath_pass(assets):
def test_fs_Copier_go_live_no_targetdir_abspath_pass(assets):
dstdir, cfgdict, _ = assets
old = cfgdict["a"]["b"]
cfgdict = {str(dstdir / "foo"): old["foo"], str(dstdir / "bar"): old["subdir/bar"]}
Expand All @@ -81,19 +96,26 @@ def test_Copier_no_targetdir_relpath_fail(assets):
_, cfgdict, _ = assets
with raises(UWConfigError) as e:
fs.Copier(config=cfgdict, key_path=["a", "b"]).go()
errmsg = "Relative path '%s' requires the target directory to be specified"
errmsg = "Relative path '%s' requires target directory to be specified"
assert errmsg % "foo" in str(e.value)


def test_fs_Copier__simple():
assert fs.Copier._simple("relative/path") == Path("relative/path")
assert fs.Copier._simple("/absolute/path") == Path("/absolute/path")
assert fs.Copier._simple("file:///absolute/path") == Path("/absolute/path")
assert fs.Copier._simple("") == Path("")


@mark.parametrize("source", ("dict", "file"))
def test_FilerStager(assets, source):
def test_fs_FilerStager(assets, source):
dstdir, cfgdict, cfgfile = assets
config = cfgdict if source == "dict" else cfgfile
assert fs.FileStager(target_dir=dstdir, config=config, key_path=["a", "b"])


@mark.parametrize("source", ("dict", "file"))
def test_Linker(assets, source):
def test_fs_Linker(assets, source):
dstdir, cfgdict, cfgfile = assets
config = cfgdict if source == "dict" else cfgfile
assert not (dstdir / "foo").exists()
Expand All @@ -103,8 +125,59 @@ def test_Linker(assets, source):
assert (dstdir / "subdir" / "bar").is_symlink()


@mark.parametrize(
"path,target_dir,msg,fail_expected",
[
(
"/other/path",
"/some/path",
"Path '%s' must be relative when target directory is specified",
True,
),
(
"foo://bucket/a/b",
None,
"Non-filesystem destination path '%s' not currently supported",
True,
),
(
"relpath",
None,
"Relative path '%s' requires target directory to be specified",
True,
),
(
"file://foo.com/a/b",
"/some/path",
"Non-filesystem path '%s' invalid when target directory is specified",
True,
),
("other/path", "/some/path", None, False),
("other/path", "file:///some/path", None, False),
],
)
def test_fs_Stager__check_destination_paths_fail(path, target_dir, msg, fail_expected):
obj = Mock(_dst_paths=[path], _target_dir=target_dir)
if fail_expected:
with raises(UWConfigError) as e:
fs.Stager._check_destination_paths(obj)
assert str(e.value) == msg % path


@mark.parametrize(
"path,fail_expected",
[("foo://bucket/a/b", True), ("/some/path", False), ("file:///some/path", False)],
)
def test_fs_Stager__check_target_dir_fail_bad_scheme(path, fail_expected):
obj = Mock(_target_dir="foo://bucket/a/b")
if fail_expected:
with raises(UWConfigError) as e:
fs.Stager._check_target_dir(obj)
assert str(e.value) == "Non-filesystem path '%s' invalid as target directory" % path


@mark.parametrize("source", ("dict", "file"))
def test_Stager__config_block_fail_bad_key_path(assets, source):
def test_fs_Stager__config_block_fail_bad_key_path(assets, source):
dstdir, cfgdict, cfgfile = assets
config = cfgdict if source == "dict" else cfgfile
with raises(UWConfigError) as e:
Expand All @@ -113,7 +186,7 @@ def test_Stager__config_block_fail_bad_key_path(assets, source):


@mark.parametrize("val", [None, True, False, "str", 42, 3.14, [], tuple()])
def test_Stager__config_block_fails_bad_type(assets, val):
def test_fs_Stager__config_block_fails_bad_type(assets, val):
dstdir, cfgdict, _ = assets
cfgdict["a"]["b"] = val
with raises(UWConfigError) as e:
Expand Down
Loading
Loading