Skip to content

Commit b9589a9

Browse files
aaschaerkurtmckee
andauthored
add --include option to globus transfer (#782)
* add --include option to globus transfer * Reword changelog Co-authored-by: Kurt McKee <[email protected]> * Enclose special characters in " Co-authored-by: Kurt McKee <[email protected]> * Fix punctuation Co-authored-by: Kurt McKee <[email protected]> * Enclose special characters in " Co-authored-by: Kurt McKee <[email protected]> * fix variable name in comment * add Breaking Changes section to changelog * require globus-sdk 3.19.0 * additional type information and comments --------- Co-authored-by: Kurt McKee <[email protected]>
1 parent bbfd597 commit b9589a9

File tree

5 files changed

+140
-20
lines changed

5 files changed

+140
-20
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
### Enhancements
2+
3+
* Add `--include` option to `globus transfer` allowing ordered overrides of `--exclude` rules.
4+
5+
### Breaking Changes
6+
7+
* The `--exclude` option for `globus transfer` now only applies to files to better
8+
support excluding files within a directory structure

setup.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ def read_readme():
4545
package_dir={"": "src"},
4646
python_requires=">=3.7",
4747
install_requires=[
48-
"globus-sdk==3.17.0",
48+
"globus-sdk==3.19.0",
4949
"click>=8.0.0,<9",
5050
"jmespath==1.0.1",
5151
"packaging>=17.0",

src/globus_cli/commands/transfer.py

+47-9
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,12 @@
3333

3434
@command(
3535
"transfer",
36+
# the order of filter_rules determines behavior, so we need to combine
37+
# include and exclude options during argument parsing to preserve their ordering
38+
opts_to_combine={
39+
"include": "filter_rules",
40+
"exclude": "filter_rules",
41+
},
3642
short_help="Submit a transfer task (asynchronous)",
3743
adoc_examples="""Transfer a single file:
3844
@@ -160,15 +166,30 @@
160166
show_default=True,
161167
help=("Specify an algorithm for --external-checksum or --verify-checksum"),
162168
)
169+
@click.option(
170+
"--include",
171+
multiple=True,
172+
show_default=True,
173+
expose_value=False, # this is combined into the filter_rules parameter
174+
help=(
175+
"Include files found with names that match the given pattern in "
176+
'recursive transfers. Pattern may include "*", "?", or "[]" for Unix-style '
177+
"globbing. This option can be given multiple times along with "
178+
"--exclude to control which files are transferred, with earlier "
179+
"options having priority."
180+
),
181+
)
163182
@click.option(
164183
"--exclude",
165184
multiple=True,
166185
show_default=True,
186+
expose_value=False, # this is combined into the filter_rules parameter
167187
help=(
168-
"Exclude files and directories found with names that match the given "
169-
"pattern in recursive transfers. Pattern may include * ? or [] for "
170-
"unix style globbing. Give this option multiple times to exclude "
171-
"multiple patterns."
188+
"Exclude files found with names that match the given pattern in "
189+
'recursive transfers. Pattern may include "*", "?", or "[]" for Unix-style '
190+
"globbing. This option can be given multiple times along with "
191+
"--include to control which files are transferred, with earlier "
192+
"options having priority."
172193
),
173194
)
174195
@click.option("--perf-cc", type=int, hidden=True)
@@ -189,7 +210,7 @@ def transfer_command(
189210
external_checksum: str | None,
190211
skip_source_errors: bool,
191212
fail_on_quota_errors: bool,
192-
exclude: tuple[str, ...],
213+
filter_rules: list[tuple[Literal["include", "exclude"], str]],
193214
label: str | None,
194215
preserve_timestamp: bool,
195216
verify_checksum: bool,
@@ -269,6 +290,20 @@ def transfer_command(
269290
If a transfer fails, CHECKSUM must be used to restart the transfer.
270291
All other levels can lead to data corruption.
271292
293+
\b
294+
=== Include and Exclude
295+
296+
The `--include` and `--exclude` options are evaluated in order together
297+
to determine which files are transferred during recursive transfers.
298+
Earlier `--include` and `exclude` options have priority over later such
299+
options, with the first option that matches the name of a file being
300+
applied. A file that does not match any `--include` or `exclude` options
301+
is included by default, making the `--include` option only useful for
302+
overriding later `--exclude` options.
303+
304+
For example, `globus transfer --include *.txt --exclude * ...` will
305+
only transfer files ending in .txt found within the directory structure.
306+
272307
{AUTOMATIC_ACTIVATION}
273308
"""
274309
from globus_cli.services.transfer import add_batch_to_transfer_data, autoactivate
@@ -321,8 +356,9 @@ def transfer_command(
321356
additional_fields={**perf_opts, **notify},
322357
)
323358

324-
for exclude_name in exclude:
325-
transfer_data.add_filter_rule(exclude_name)
359+
for rule in filter_rules:
360+
method, name = rule
361+
transfer_data.add_filter_rule(method=method, name=name, type="file")
326362

327363
if batch:
328364
add_batch_to_transfer_data(
@@ -348,8 +384,10 @@ def transfer_command(
348384
else:
349385
has_recursive_items = False
350386

351-
if exclude and not has_recursive_items:
352-
raise click.UsageError("--exclude can only be used with --recursive transfers")
387+
if filter_rules and not has_recursive_items:
388+
raise click.UsageError(
389+
"--include and --exclude can only be used with --recursive transfers"
390+
)
353391

354392
if dry_run:
355393
display(

src/globus_cli/parsing/commands.py

+43-1
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,27 @@ class GlobusCommand(click.Command):
3030
adoc generator.
3131
3232
It also automatically runs string formatting on command helptext to allow the
33-
inclusion of common strings (e.g. autoactivation help).
33+
inclusion of common strings (e.g. autoactivation help) and handles
34+
custom argument parsing.
35+
36+
opts_to_combine is an interface for combining multiple options while preserving
37+
their original order. Given a dict of original option names as keys
38+
and combined option names as values, options are combined into a list of
39+
tuples of the original option name and value. For example:
40+
41+
@command(
42+
...
43+
opts_to_combine={
44+
"foo": "foo_bar",
45+
"bar": "foo_bar",
46+
},
47+
@click.option("--foo", multiple=True, expose_value=False)
48+
@click.option("--bar", multiple=True, expose_value=False)
49+
def example_command(*, foo_bar: list[tuple[Literal["foo", "bar"], Any]]):
50+
51+
for option in foo_bar:
52+
original_option_name, value = option
53+
3454
"""
3555

3656
AUTOMATIC_ACTIVATION_HELPTEXT = """=== Automatic Endpoint Activation
@@ -46,6 +66,7 @@ def __init__(self, *args, **kwargs):
4666
self.globus_disable_opts = kwargs.pop("globus_disable_opts", [])
4767
self.adoc_exit_status = kwargs.pop("adoc_exit_status", None)
4868
self.adoc_synopsis = kwargs.pop("adoc_synopsis", None)
69+
self.opts_to_combine = kwargs.pop("opts_to_combine", {})
4970

5071
helptext = kwargs.pop("help", None)
5172
if helptext:
@@ -74,7 +95,28 @@ def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]:
7495
# args will be consumed, so check it before super()
7596
had_args = bool(args)
7697
try:
98+
# if we have any opts to be combined in order, do that now
99+
if self.opts_to_combine:
100+
combined_opts: dict[str, list[tuple[str, str]]] = {
101+
combined_name: [] for combined_name in self.opts_to_combine.values()
102+
}
103+
parser: click.parser.OptionParser = self.make_parser(ctx)
104+
values, _, order = parser.parse_args(args=list(args))
105+
# values is a dict of value lists keyed by their option name
106+
# in order for that value and order is a list of option names
107+
# in the order they were given at the command line
108+
# we want a list of (name, value) tuples for multiple options
109+
# in the order they were given at the command line
110+
for opt in order:
111+
if opt.name and opt.name in self.opts_to_combine:
112+
value = values[opt.name].pop(0)
113+
combined_name = self.opts_to_combine[opt.name]
114+
combined_opts[combined_name].append((opt.name, value))
115+
116+
ctx.params.update(combined_opts)
117+
77118
return super().parse_args(ctx, args)
119+
78120
except click.MissingParameter as e:
79121
if not had_args:
80122
click.secho(e.format_message(), fg="yellow", err=True)

tests/functional/task/test_task_submit.py

+41-9
Original file line numberDiff line numberDiff line change
@@ -3,24 +3,47 @@
33
from globus_sdk._testing import load_response_set
44

55

6-
def test_exclude(run_line, go_ep1_id, go_ep2_id):
6+
def test_filter_rules(run_line, go_ep1_id, go_ep2_id):
77
"""
8-
Submits two --exclude options on a transfer, confirms they show up
9-
in --dry-run output
8+
Submits two --exclude and two --include options on a transfer, confirms
9+
they show up the correct order in --dry-run output
1010
"""
1111
# put a submission ID and autoactivate response in place
1212
load_response_set("cli.get_submission_id")
1313
load_response_set("cli.transfer_activate_success")
1414

1515
result = run_line(
1616
"globus transfer -F json --dry-run -r "
17-
"--exclude *.txt --exclude *.pdf "
17+
"--exclude foo --include bar "
18+
"--include baz --exclude qux "
1819
"{}:/ {}:/".format(go_ep1_id, go_ep1_id)
1920
)
2021

2122
expected_filter_rules = [
22-
{"DATA_TYPE": "filter_rule", "method": "exclude", "name": "*.txt"},
23-
{"DATA_TYPE": "filter_rule", "method": "exclude", "name": "*.pdf"},
23+
{
24+
"DATA_TYPE": "filter_rule",
25+
"method": "exclude",
26+
"name": "foo",
27+
"type": "file",
28+
},
29+
{
30+
"DATA_TYPE": "filter_rule",
31+
"method": "include",
32+
"name": "bar",
33+
"type": "file",
34+
},
35+
{
36+
"DATA_TYPE": "filter_rule",
37+
"method": "include",
38+
"name": "baz",
39+
"type": "file",
40+
},
41+
{
42+
"DATA_TYPE": "filter_rule",
43+
"method": "exclude",
44+
"name": "qux",
45+
"type": "file",
46+
},
2447
]
2548

2649
json_output = json.loads(result.output)
@@ -38,7 +61,10 @@ def test_exlude_recursive(run_line, go_ep1_id, go_ep2_id):
3861
"globus transfer --exclude *.txt " "{}:/ {}:/".format(go_ep1_id, go_ep1_id),
3962
assert_exit_code=2,
4063
)
41-
assert "--exclude can only be used with --recursive transfers" in result.stderr
64+
assert (
65+
"--include and --exclude can only be used with --recursive transfers"
66+
in result.stderr
67+
)
4268

4369

4470
def test_exlude_recursive_batch_stdin(run_line, go_ep1_id, go_ep2_id):
@@ -49,7 +75,10 @@ def test_exlude_recursive_batch_stdin(run_line, go_ep1_id, go_ep2_id):
4975
stdin="abc /def\n",
5076
assert_exit_code=2,
5177
)
52-
assert "--exclude can only be used with --recursive transfers" in result.stderr
78+
assert (
79+
"--include and --exclude can only be used with --recursive transfers"
80+
in result.stderr
81+
)
5382

5483

5584
def test_exlude_recursive_batch_file(run_line, go_ep1_id, go_ep2_id, tmp_path):
@@ -69,4 +98,7 @@ def test_exlude_recursive_batch_file(run_line, go_ep1_id, go_ep2_id, tmp_path):
6998
],
7099
assert_exit_code=2,
71100
)
72-
assert "--exclude can only be used with --recursive transfers" in result.stderr
101+
assert (
102+
"--include and --exclude can only be used with --recursive transfers"
103+
in result.stderr
104+
)

0 commit comments

Comments
 (0)