From 9b5e1c956a6cb50f6cb7234cb397023a9a69631a Mon Sep 17 00:00:00 2001 From: Eric Driussi Date: Sat, 6 Sep 2025 16:18:51 +0100 Subject: [PATCH 1/3] operations/files.copy: basic copy functionality --- pyinfra/operations/files.py | 22 +++++++++++++++++++ tests/operations/files.copy/copies_file.json | 17 ++++++++++++++ .../files.copy/copies_file_overwriting.json | 17 ++++++++++++++ tests/operations/files.copy/invalid_dest.json | 20 +++++++++++++++++ .../files.copy/invalid_overwrite.json | 20 +++++++++++++++++ tests/operations/files.copy/invalid_src.json | 20 +++++++++++++++++ 6 files changed, 116 insertions(+) create mode 100644 tests/operations/files.copy/copies_file.json create mode 100644 tests/operations/files.copy/copies_file_overwriting.json create mode 100644 tests/operations/files.copy/invalid_dest.json create mode 100644 tests/operations/files.copy/invalid_overwrite.json create mode 100644 tests/operations/files.copy/invalid_src.json diff --git a/pyinfra/operations/files.py b/pyinfra/operations/files.py index 6f9515f5e..0b6c4a06d 100644 --- a/pyinfra/operations/files.py +++ b/pyinfra/operations/files.py @@ -1312,6 +1312,28 @@ def move(src: str, dest: str, overwrite=False): yield StringCommand("mv", QuoteString(src), QuoteString(dest)) +@operation() +def copy(src: str, dest: str, overwrite=False): + if not host.get_fact(File, src): + raise OperationError(f"src {src} does not exist") + + if not host.get_fact(Directory, dest): + raise OperationError(f"dest {dest} is not an existing directory") + + dest_file_path = os.path.join(dest, os.path.basename(src)) + dest_file_exists = host.get_fact(File, dest_file_path) + + if dest_file_exists and not overwrite: + raise OperationError( + f"dest {dest_file_path} already exists and `overwrite` is unset" + ) + + cp_cmd = ["cp"] + if overwrite: + cp_cmd.append("-f") + yield StringCommand(*cp_cmd, QuoteString(src), QuoteString(dest)) + + def _validate_path(path): try: return os.fspath(path) diff --git a/tests/operations/files.copy/copies_file.json b/tests/operations/files.copy/copies_file.json new file mode 100644 index 000000000..69578add2 --- /dev/null +++ b/tests/operations/files.copy/copies_file.json @@ -0,0 +1,17 @@ +{ + "kwargs": { + "src": "/tmp/src_dir/file", + "dest": "/tmp/dest_dir", + "overwrite": false + }, + "facts": { + "files.File": { + "path=/tmp/src_dir/file": true, + "path=/tmp/dest_dir/file": null + }, + "files.Directory": { + "path=/tmp/dest_dir": true + } + }, + "commands": ["cp /tmp/src_dir/file /tmp/dest_dir"] +} diff --git a/tests/operations/files.copy/copies_file_overwriting.json b/tests/operations/files.copy/copies_file_overwriting.json new file mode 100644 index 000000000..12fbff5f5 --- /dev/null +++ b/tests/operations/files.copy/copies_file_overwriting.json @@ -0,0 +1,17 @@ +{ + "kwargs": { + "src": "/tmp/src_dir/file", + "dest": "/tmp/dest_dir", + "overwrite": true + }, + "facts": { + "files.File": { + "path=/tmp/src_dir/file": true, + "path=/tmp/dest_dir/file": true + }, + "files.Directory": { + "path=/tmp/dest_dir": true + } + }, + "commands": ["cp -f /tmp/src_dir/file /tmp/dest_dir"] +} diff --git a/tests/operations/files.copy/invalid_dest.json b/tests/operations/files.copy/invalid_dest.json new file mode 100644 index 000000000..077bce76f --- /dev/null +++ b/tests/operations/files.copy/invalid_dest.json @@ -0,0 +1,20 @@ +{ + "kwargs": { + "src": "/tmp/src_dir/file", + "dest": "/tmp/dest_dir", + "overwrite": false + }, + "facts": { + "files.File": { + "path=/tmp/src_dir/file": true, + "path=/tmp/dest_dir/file": null + }, + "files.Directory": { + "path=/tmp/dest_dir": null + } + }, + "exception": { + "name": "OperationError", + "message": "dest /tmp/dest_dir is not an existing directory" + } +} diff --git a/tests/operations/files.copy/invalid_overwrite.json b/tests/operations/files.copy/invalid_overwrite.json new file mode 100644 index 000000000..fdf2a9668 --- /dev/null +++ b/tests/operations/files.copy/invalid_overwrite.json @@ -0,0 +1,20 @@ +{ + "kwargs": { + "src": "/tmp/src_dir/file", + "dest": "/tmp/dest_dir", + "overwrite": false + }, + "facts": { + "files.File": { + "path=/tmp/src_dir/file": true, + "path=/tmp/dest_dir/file": true + }, + "files.Directory": { + "path=/tmp/dest_dir": true + } + }, + "exception": { + "name": "OperationError", + "message": "dest /tmp/dest_dir/file already exists and `overwrite` is unset" + } +} diff --git a/tests/operations/files.copy/invalid_src.json b/tests/operations/files.copy/invalid_src.json new file mode 100644 index 000000000..e70d7cc91 --- /dev/null +++ b/tests/operations/files.copy/invalid_src.json @@ -0,0 +1,20 @@ +{ + "kwargs": { + "src": "/tmp/src_dir/file", + "dest": "/tmp/dest_dir", + "overwrite": false + }, + "facts": { + "files.File": { + "path=/tmp/src_dir/file": null, + "path=/tmp/dest_dir/file": null + }, + "files.Directory": { + "path=/tmp/dest_dir": true + } + }, + "exception": { + "name": "OperationError", + "message": "src /tmp/src_dir/file does not exist" + } +} From 5f5ba0d8ebf24d8eaac3aa4b3a47e43cd30ca4a9 Mon Sep 17 00:00:00 2001 From: Eric Driussi Date: Sat, 6 Sep 2025 16:42:58 +0100 Subject: [PATCH 2/3] operations/files.copy: copy dirs --- pyinfra/operations/files.py | 12 +++++------ .../files.copy/copies_directory.json | 21 +++++++++++++++++++ .../copies_directory_overwriting.json | 21 +++++++++++++++++++ tests/operations/files.copy/copies_file.json | 5 +++-- .../files.copy/copies_file_overwriting.json | 5 +++-- tests/operations/files.copy/invalid_dest.json | 3 ++- .../files.copy/invalid_overwrite.json | 3 ++- tests/operations/files.copy/invalid_src.json | 3 ++- 8 files changed, 60 insertions(+), 13 deletions(-) create mode 100644 tests/operations/files.copy/copies_directory.json create mode 100644 tests/operations/files.copy/copies_directory_overwriting.json diff --git a/pyinfra/operations/files.py b/pyinfra/operations/files.py index 0b6c4a06d..dd6b030cc 100644 --- a/pyinfra/operations/files.py +++ b/pyinfra/operations/files.py @@ -1314,7 +1314,8 @@ def move(src: str, dest: str, overwrite=False): @operation() def copy(src: str, dest: str, overwrite=False): - if not host.get_fact(File, src): + src_is_dir = host.get_fact(Directory, src) + if not host.get_fact(File, src) and not src_is_dir: raise OperationError(f"src {src} does not exist") if not host.get_fact(Directory, dest): @@ -1322,15 +1323,14 @@ def copy(src: str, dest: str, overwrite=False): dest_file_path = os.path.join(dest, os.path.basename(src)) dest_file_exists = host.get_fact(File, dest_file_path) - if dest_file_exists and not overwrite: - raise OperationError( - f"dest {dest_file_path} already exists and `overwrite` is unset" - ) + raise OperationError(f"dest {dest_file_path} already exists and `overwrite` is unset") + + cp_cmd = ["cp -r"] - cp_cmd = ["cp"] if overwrite: cp_cmd.append("-f") + yield StringCommand(*cp_cmd, QuoteString(src), QuoteString(dest)) diff --git a/tests/operations/files.copy/copies_directory.json b/tests/operations/files.copy/copies_directory.json new file mode 100644 index 000000000..d09f899d2 --- /dev/null +++ b/tests/operations/files.copy/copies_directory.json @@ -0,0 +1,21 @@ +{ + "kwargs": { + "src": "/tmp/src_dir", + "dest": "/tmp/dest_dir", + "overwrite": false + }, + "facts": { + "files.File": { + "path=/tmp/src_dir/file": true, + + "path=/tmp/src_dir": null, + "path=/tmp/dest_dir/src_dir": null, + "path=/tmp/dest_dir/src_dir/file": null + }, + "files.Directory": { + "path=/tmp/src_dir": true, + "path=/tmp/dest_dir": true + } + }, + "commands": ["cp -r /tmp/src_dir /tmp/dest_dir"] +} diff --git a/tests/operations/files.copy/copies_directory_overwriting.json b/tests/operations/files.copy/copies_directory_overwriting.json new file mode 100644 index 000000000..91a93e663 --- /dev/null +++ b/tests/operations/files.copy/copies_directory_overwriting.json @@ -0,0 +1,21 @@ +{ + "kwargs": { + "src": "/tmp/src_dir", + "dest": "/tmp/dest_dir", + "overwrite": true + }, + "facts": { + "files.File": { + "path=/tmp/src_dir/file": true, + + "path=/tmp/src_dir": null, + "path=/tmp/dest_dir/src_dir": null, + "path=/tmp/dest_dir/src_dir/file": null + }, + "files.Directory": { + "path=/tmp/src_dir": true, + "path=/tmp/dest_dir": true + } + }, + "commands": ["cp -r -f /tmp/src_dir /tmp/dest_dir"] +} diff --git a/tests/operations/files.copy/copies_file.json b/tests/operations/files.copy/copies_file.json index 69578add2..4f46c02ee 100644 --- a/tests/operations/files.copy/copies_file.json +++ b/tests/operations/files.copy/copies_file.json @@ -10,8 +10,9 @@ "path=/tmp/dest_dir/file": null }, "files.Directory": { - "path=/tmp/dest_dir": true + "path=/tmp/dest_dir": true, + "path=/tmp/src_dir/file": null } }, - "commands": ["cp /tmp/src_dir/file /tmp/dest_dir"] + "commands": ["cp -r /tmp/src_dir/file /tmp/dest_dir"] } diff --git a/tests/operations/files.copy/copies_file_overwriting.json b/tests/operations/files.copy/copies_file_overwriting.json index 12fbff5f5..7b7494400 100644 --- a/tests/operations/files.copy/copies_file_overwriting.json +++ b/tests/operations/files.copy/copies_file_overwriting.json @@ -10,8 +10,9 @@ "path=/tmp/dest_dir/file": true }, "files.Directory": { - "path=/tmp/dest_dir": true + "path=/tmp/dest_dir": true, + "path=/tmp/src_dir/file": null } }, - "commands": ["cp -f /tmp/src_dir/file /tmp/dest_dir"] + "commands": ["cp -r -f /tmp/src_dir/file /tmp/dest_dir"] } diff --git a/tests/operations/files.copy/invalid_dest.json b/tests/operations/files.copy/invalid_dest.json index 077bce76f..63399aeff 100644 --- a/tests/operations/files.copy/invalid_dest.json +++ b/tests/operations/files.copy/invalid_dest.json @@ -10,7 +10,8 @@ "path=/tmp/dest_dir/file": null }, "files.Directory": { - "path=/tmp/dest_dir": null + "path=/tmp/dest_dir": null, + "path=/tmp/src_dir/file": null } }, "exception": { diff --git a/tests/operations/files.copy/invalid_overwrite.json b/tests/operations/files.copy/invalid_overwrite.json index fdf2a9668..d9be3cc8c 100644 --- a/tests/operations/files.copy/invalid_overwrite.json +++ b/tests/operations/files.copy/invalid_overwrite.json @@ -10,7 +10,8 @@ "path=/tmp/dest_dir/file": true }, "files.Directory": { - "path=/tmp/dest_dir": true + "path=/tmp/dest_dir": true, + "path=/tmp/src_dir/file": null } }, "exception": { diff --git a/tests/operations/files.copy/invalid_src.json b/tests/operations/files.copy/invalid_src.json index e70d7cc91..b77a44170 100644 --- a/tests/operations/files.copy/invalid_src.json +++ b/tests/operations/files.copy/invalid_src.json @@ -10,7 +10,8 @@ "path=/tmp/dest_dir/file": null }, "files.Directory": { - "path=/tmp/dest_dir": true + "path=/tmp/dest_dir": true, + "path=/tmp/src_dir/file": null } }, "exception": { From 6bc88997705a34976d8b2c47f1e2d94e0f187c4c Mon Sep 17 00:00:00 2001 From: Eric Driussi Date: Sat, 6 Sep 2025 17:18:14 +0100 Subject: [PATCH 3/3] operations/files.copy: docs --- pyinfra/operations/files.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/pyinfra/operations/files.py b/pyinfra/operations/files.py index dd6b030cc..c72d784bd 100644 --- a/pyinfra/operations/files.py +++ b/pyinfra/operations/files.py @@ -1314,6 +1314,13 @@ def move(src: str, dest: str, overwrite=False): @operation() def copy(src: str, dest: str, overwrite=False): + """ + Copy remote file/directory/link into remote directory + + + src: remote file/directory to copy + + dest: remote directory to copy `src` into + + overwrite: whether to overwrite dest, if present + """ src_is_dir = host.get_fact(Directory, src) if not host.get_fact(File, src) and not src_is_dir: raise OperationError(f"src {src} does not exist")