Skip to content

Commit

Permalink
Add "minimal" hidden 'timer create' command (#672)
Browse files Browse the repository at this point in the history
* Initial draft of 'globus timer create transfer'

Introduce the `globus timer create` command as a hidden command.
In support of this new command, several broader changes are included:

- some `globus transfer` options are now declared as shared option
  decorators for reuse on the timer create command
- a new `TimedeltaType(ParamType)` is created
- the login manager is adjusted to get the requisite timer scopes
- `globus transfer --batch` handling is now abstracted into a helper
  for reuse on the timer command

* Allow sync_level_option to be applied directly

Allow `@sync_level_option` as the decorator, rather than
`@sync_level_option()`

Also, move `sync_level_option` to a dedicated module for easier
maintenance. In the process of doing this rewrite, adjust
`globus_cli.parsing` to use local relative imports for brevity.

* Add tests for TimedeltaType and allow whitespace

Upon review, it's easy enough to add support for whitespace in the
expression for parsing timedeltas. This will allow quoted usages with
spaces. Add tests which exercise the TimedeltaType in all of its
various usages.

* Add tests for timer create transfer

- some basic end-to-end tests ensure the command can run
- explicit tests for various bad usages which are caught
- make the timezone resolution a function so that it can be tested in
  unit tests more thoroughly, and patched over more easily for
  relevant functional tests

* Adjust timedelta: reject bare integers

Bare int support is ambiguous, it is not costly for users to specify
the unit, and it exposes us to odd inputs like `1d4`, which is either
the damage roll on a normal dagger, someon really wanting "a day plus
four seconds", or an accidental truncation.

* Remove unused constant

* Refactor sharing of Transfer task submit options

Move these all into a dedicated module, and make sure more are shared
between `globus transfer` and `globus timer create transfer`.

In cases where the timer option name appeared to be more correct in
some way (e.g. `--encrypt-data` over `--encrypt`, which matches the
API fieldname), both names are preserved for the `globus transfer`
command only, by means of aliases.
  • Loading branch information
sirosen authored Aug 31, 2022
1 parent 62fae70 commit 195d477
Show file tree
Hide file tree
Showing 15 changed files with 888 additions and 151 deletions.
1 change: 1 addition & 0 deletions src/globus_cli/commands/timer/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
@group(
"timer",
lazy_subcommands={
"create": (".create", "create_command"),
"delete": (".delete", "delete_command"),
"list": (".list", "list_command"),
"show": (".show", "show_command"),
Expand Down
20 changes: 0 additions & 20 deletions src/globus_cli/commands/timer/_common.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
from __future__ import annotations

import datetime
import re
from typing import Any
from urllib.parse import urlparse

Expand Down Expand Up @@ -84,22 +83,3 @@ def isoformat_to_local(
("Stop After Date", _get_stop_date),
("Stop After Number of Runs", _get_stop_n_runs),
]

START_HELP = """
Start time for the job. Defaults to current time. (The example above shows the allowed
formats using Python's datetime formatters; see:
https://docs.python.org/3/library/datetime.html #strftime-and-strptime-format-codes
"""

timedelta_regex = re.compile(
r"""\
^
((?P<weeks>\d+)w)?
((?P<days>\d+)d)?
((?P<hours>\d+)h)?
((?P<minutes>\d+)m)?
((?P<seconds>\d+)s?)?
$
""",
flags=re.VERBOSE,
)
11 changes: 11 additions & 0 deletions src/globus_cli/commands/timer/create/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
from globus_cli.parsing import group


@group(
"create",
short_help="Submit a Timer job",
hidden=True,
lazy_subcommands={"transfer": (".transfer", "transfer_command")},
)
def create_command():
pass
208 changes: 208 additions & 0 deletions src/globus_cli/commands/timer/create/transfer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
from __future__ import annotations

import datetime
import sys
from typing import TextIO

import click
import globus_sdk

from globus_cli.login_manager import LoginManager
from globus_cli.parsing import (
ENDPOINT_PLUS_OPTPATH,
TimedeltaType,
command,
encrypt_data_option,
fail_on_quota_errors_option,
preserve_timestamp_option,
skip_source_errors_option,
sync_level_option,
task_notify_option,
transfer_batch_option,
transfer_recursive_option,
verify_checksum_option,
)
from globus_cli.termio import FORMAT_TEXT_RECORD, formatted_print

from .._common import DATETIME_FORMATS, JOB_FORMAT_FIELDS

if sys.version_info >= (3, 8):
from typing import Literal
else:
from typing_extensions import Literal

# XXX: this may need to be parametrized over data_access scopes?
_TRANSFER_AP_SCOPE = (
"https://auth.globus.org/scopes/actions.globus.org/transfer/transfer"
)


INTERVAL_HELP = """\
Interval at which the job should run. Expressed in weeks, days, hours, minutes, and
seconds. Use 'w', 'd', 'h', 'm', and 's' as suffixes to specify.
e.g. '1h30m', '500s', '10d'
"""


def resolve_start_time(start: datetime.datetime | None) -> datetime.datetime:
# handle the default start time (now)
start_ = start or datetime.datetime.now()
# set the timezone to local system time if the timezone input is not aware
start_with_tz = start_.astimezone() if start_.tzinfo is None else start_
return start_with_tz


@command("transfer", short_help="Create a recurring transfer job in Timer")
@click.argument(
"source", metavar="SOURCE_ENDPOINT_ID[:SOURCE_PATH]", type=ENDPOINT_PLUS_OPTPATH
)
@click.argument(
"destination", metavar="DEST_ENDPOINT_ID[:DEST_PATH]", type=ENDPOINT_PLUS_OPTPATH
)
@transfer_batch_option
@sync_level_option
@transfer_recursive_option
@encrypt_data_option
@verify_checksum_option
@preserve_timestamp_option
@skip_source_errors_option
@fail_on_quota_errors_option
@task_notify_option
@click.option(
"--start",
type=click.DateTime(formats=DATETIME_FORMATS),
help="Start time for the job. Defaults to current time",
)
@click.option(
"--interval",
type=TimedeltaType(),
help=INTERVAL_HELP,
)
@click.option("--name", type=str, help="A name for the Timer job")
@click.option(
"--label",
type=str,
help="A label for the Transfer tasks submitted by the Timer job",
)
@click.option(
"--stop-after-date",
type=click.DateTime(formats=DATETIME_FORMATS),
help="Stop running the transfer after this date",
)
@click.option(
"--stop-after-runs",
type=click.IntRange(min=1),
help="Stop running the transfer after this number of runs have happened",
)
@LoginManager.requires_login(LoginManager.TIMER_RS, LoginManager.TRANSFER_RS)
def transfer_command(
login_manager: LoginManager,
name: str | None,
source: tuple[str, str | None],
destination: tuple[str, str | None],
batch: TextIO | None,
recursive: bool,
start: datetime.datetime | None,
interval: int | None,
label: str | None,
stop_after_date: datetime.datetime | None,
stop_after_runs: int | None,
sync_level: Literal["exists", "size", "mtime", "checksum"] | None,
encrypt_data: bool,
verify_checksum: bool,
preserve_timestamp: bool,
skip_source_errors: bool,
fail_on_quota_errors: bool,
notify: dict[str, bool],
):
"""
Create a Timer job which will run a transfer on a recurring schedule
according to the parameters provided.
For example, to create a job which runs a Transfer from /foo/ on one endpoint to
/bar/ on another endpoint every day, with no end condition:
\b
globus timer create transfer --interval 1d --recursive $ep1:/foo/ $ep2:/bar/
"""
from globus_cli.services.transfer import add_batch_to_transfer_data, autoactivate

timer_client = login_manager.get_timer_client()
transfer_client = login_manager.get_transfer_client()

source_endpoint, cmd_source_path = source
dest_endpoint, cmd_dest_path = destination

# avoid 'mutex_option_group', emit a custom error message
if recursive and batch:
raise click.UsageError(
"You cannot use --recursive in addition to --batch. "
"Instead, use --recursive on lines of --batch input "
"which need it"
)
if (cmd_source_path is None or cmd_dest_path is None) and (not batch):
raise click.UsageError(
"transfer requires either SOURCE_PATH and DEST_PATH or --batch"
)

# Interval must be null iff the job is non-repeating, i.e. stop-after-runs == 1.
if stop_after_runs != 1:
if interval is None:
raise click.UsageError(
"'--interval' is required unless `--stop-after-runs=1` is used."
)

# default name, dynamically computed from the current time
if name is None:
now = datetime.datetime.now().isoformat()
name = f"CLI Created Timer [{now}]"

# Check endpoint activation, figure out scopes needed.

# Note this will provide help text on activating endpoints.
autoactivate(transfer_client, source_endpoint, if_expires_in=86400)
autoactivate(transfer_client, dest_endpoint, if_expires_in=86400)

# XXX: this section needs to be re-evaluated when 'timer create transfer' gets more
# capabilities to handle endpoints with scope requirements
# this would likely be the place to insert scope related checks

transfer_data = globus_sdk.TransferData(
source_endpoint=source_endpoint,
destination_endpoint=dest_endpoint,
label=label,
sync_level=sync_level,
verify_checksum=verify_checksum,
preserve_timestamp=preserve_timestamp,
encrypt_data=encrypt_data,
skip_source_errors=skip_source_errors,
fail_on_quota_errors=fail_on_quota_errors,
# mypy can't understand kwargs expansion very well
**notify, # type: ignore[arg-type]
)

if batch:
add_batch_to_transfer_data(
cmd_source_path, cmd_dest_path, None, transfer_data, batch
)
elif cmd_source_path is not None and cmd_dest_path is not None:
transfer_data.add_item(
cmd_source_path,
cmd_dest_path,
recursive=recursive,
)
else: # unreachable
raise NotImplementedError()

response = timer_client.create_job(
globus_sdk.TimerJob.from_transfer_data(
transfer_data,
resolve_start_time(start),
interval,
name=name,
stop_after=stop_after_date,
stop_after_n=stop_after_runs,
scope=_TRANSFER_AP_SCOPE,
)
)
formatted_print(response, text_format=FORMAT_TEXT_RECORD, fields=JOB_FORMAT_FIELDS)
Loading

0 comments on commit 195d477

Please sign in to comment.