diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2e2ebab..9f53e15 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -8,7 +8,7 @@ default_install_hook_types: repos: - repo: https://github.com/compilerla/conventional-pre-commit - rev: v3.6.0 + rev: v4.0.0 hooks: - id: conventional-pre-commit stages: [commit-msg] diff --git a/compiler_admin/commands/info.py b/compiler_admin/commands/info.py index 9a75e3e..fb6d543 100644 --- a/compiler_admin/commands/info.py +++ b/compiler_admin/commands/info.py @@ -1,17 +1,16 @@ -from compiler_admin import __version__ as version, RESULT_SUCCESS, RESULT_FAILURE -from compiler_admin.services.google import CallGAMCommand, CallGYBCommand +import click +from compiler_admin import __version__ as version +from compiler_admin.services.google import CallGAMCommand, CallGYBCommand -def info(*args, **kwargs) -> int: - """Print information about this package and the GAM environment. - Returns: - A value indicating if the operation succeeded or failed. +@click.command() +def info(): """ - print(f"compiler-admin: {version}") - - res = CallGAMCommand(("version",)) - res += CallGAMCommand(("info", "domain")) - res += CallGYBCommand(("--version",)) + Print information about the configured environment. + """ + click.echo(f"compiler-admin, version {version}") - return RESULT_SUCCESS if res == RESULT_SUCCESS else RESULT_FAILURE + CallGAMCommand(("version",)) + CallGAMCommand(("info", "domain")) + CallGYBCommand(("--version",)) diff --git a/compiler_admin/commands/init.py b/compiler_admin/commands/init.py index e628938..5266d42 100644 --- a/compiler_admin/commands/init.py +++ b/compiler_admin/commands/init.py @@ -1,10 +1,10 @@ -from argparse import Namespace import os from pathlib import Path from shutil import rmtree import subprocess -from compiler_admin import RESULT_FAILURE, RESULT_SUCCESS +import click + from compiler_admin.services.google import USER_ARCHIVE, CallGAMCommand @@ -22,48 +22,37 @@ def _clean_config_dir(config_dir: Path) -> None: rmtree(path) -def init(args: Namespace, *extras) -> int: - """Initialize a new GAM project. - - See https://github.com/taers232c/GAMADV-XTD3/wiki/How-to-Install-Advanced-GAM - - Args: - username (str): The Compiler admin with which to initialize a new project. - - gam (bool): If True, initialize a new GAM project. +@click.command() +@click.option("--gam", "init_gam", is_flag=True) +@click.option("--gyb", "init_gyb", is_flag=True) +@click.argument("username") +def init(username: str, init_gam: bool = False, init_gyb: bool = False): + """Initialize a new GAM and/or GYB project. - gyb (bool): If True, initialize a new GYB project. + See: - Returns: - A value indicating if the operation succeeded or failed. + - https://github.com/taers232c/GAMADV-XTD3/wiki/How-to-Install-Advanced-GAM + - https://github.com/GAM-team/got-your-back/wiki """ - if not hasattr(args, "username"): - raise ValueError("username is required") - - admin_user = args.username - res = RESULT_SUCCESS - - if getattr(args, "gam", False): + if init_gam: _clean_config_dir(GAM_CONFIG_PATH) # GAM is already installed via pyproject.toml - res += CallGAMCommand(("config", "drive_dir", str(GAM_CONFIG_PATH), "verify")) - res += CallGAMCommand(("create", "project")) - res += CallGAMCommand(("oauth", "create")) - res += CallGAMCommand(("user", admin_user, "check", "serviceaccount")) + CallGAMCommand(("config", "drive_dir", str(GAM_CONFIG_PATH), "verify")) + CallGAMCommand(("create", "project")) + CallGAMCommand(("oauth", "create")) + CallGAMCommand(("user", username, "check", "serviceaccount")) - if getattr(args, "gyb", False): + if init_gyb: _clean_config_dir(GYB_CONFIG_PATH) # download GYB installer to config directory gyb = GYB_CONFIG_PATH / "gyb-install.sh" with gyb.open("w+") as dest: - res += subprocess.call(("curl", "-s", "-S", "-L", "https://gyb-shortn.jaylee.us/gyb-install"), stdout=dest) + subprocess.call(("curl", "-s", "-S", "-L", "https://gyb-shortn.jaylee.us/gyb-install"), stdout=dest) - res += subprocess.call(("chmod", "+x", str(gyb.absolute()))) + subprocess.call(("chmod", "+x", str(gyb.absolute()))) # install, giving values to some options # https://github.com/GAM-team/got-your-back/blob/main/install-gyb.sh # # use GYB_CONFIG_PATH.parent for the install directory option, otherwise we get a .config/gyb/gyb directory structure - res += subprocess.call((gyb, "-u", admin_user, "-r", USER_ARCHIVE, "-d", str(GYB_CONFIG_PATH.parent))) - - return RESULT_SUCCESS if res == RESULT_SUCCESS else RESULT_FAILURE + subprocess.call((gyb, "-u", username, "-r", USER_ARCHIVE, "-d", str(GYB_CONFIG_PATH.parent))) diff --git a/compiler_admin/commands/time/__init__.py b/compiler_admin/commands/time/__init__.py index a56c97d..ca8bc21 100644 --- a/compiler_admin/commands/time/__init__.py +++ b/compiler_admin/commands/time/__init__.py @@ -1,16 +1,16 @@ -from argparse import Namespace +import click -from compiler_admin.commands.time.convert import convert # noqa: F401 -from compiler_admin.commands.time.download import download # noqa: F401 +from compiler_admin.commands.time.convert import convert +from compiler_admin.commands.time.download import download -def time(args: Namespace, *extra): - # try to call the subcommand function directly from global (module) symbols - # if the subcommand function was imported above, it should exist in globals() - global_env = globals() +@click.group +def time(): + """ + Work with Compiler time entries. + """ + pass - if args.subcommand in global_env: - cmd_func = global_env[args.subcommand] - cmd_func(args, *extra) - else: - raise NotImplementedError(f"Unknown time subcommand: {args.subcommand}") + +time.add_command(convert) +time.add_command(download) diff --git a/compiler_admin/commands/time/convert.py b/compiler_admin/commands/time/convert.py index 955b1ef..52e24b3 100644 --- a/compiler_admin/commands/time/convert.py +++ b/compiler_admin/commands/time/convert.py @@ -1,6 +1,9 @@ -from argparse import Namespace +import os +import sys +from typing import TextIO + +import click -from compiler_admin import RESULT_SUCCESS from compiler_admin.services.harvest import CONVERTERS as HARVEST_CONVERTERS from compiler_admin.services.toggl import CONVERTERS as TOGGL_CONVERTERS @@ -21,9 +24,46 @@ def _get_source_converter(from_fmt: str, to_fmt: str): ) -def convert(args: Namespace, *extras): - converter = _get_source_converter(args.from_fmt, args.to_fmt) +@click.command() +@click.option( + "--input", + default=os.environ.get("TOGGL_DATA", sys.stdin), + help="The path to the source data for conversion. Defaults to $TOGGL_DATA or stdin.", +) +@click.option( + "--output", + default=os.environ.get("HARVEST_DATA", sys.stdout), + help="The path to the file where converted data should be written. Defaults to $HARVEST_DATA or stdout.", +) +@click.option( + "--from", + "from_fmt", + default="toggl", + help="The format of the source data.", + show_default=True, + type=click.Choice(sorted(CONVERTERS.keys()), case_sensitive=False), +) +@click.option( + "--to", + "to_fmt", + default="harvest", + help="The format of the converted data.", + show_default=True, + type=click.Choice(sorted([to_fmt for sub in CONVERTERS.values() for to_fmt in sub.keys()]), case_sensitive=False), +) +@click.option("--client", help="The name of the client to use in converted data.") +def convert( + input: str | TextIO = os.environ.get("TOGGL_DATA", sys.stdin), + output: str | TextIO = os.environ.get("HARVEST_DATA", sys.stdout), + from_fmt="toggl", + to_fmt="harvest", + client="", +): + """ + Convert a time report from one format into another. + """ + converter = _get_source_converter(from_fmt, to_fmt) - converter(source_path=args.input, output_path=args.output, client_name=args.client) + click.echo(f"Converting data from format: {from_fmt} to format: {to_fmt}") - return RESULT_SUCCESS + converter(source_path=input, output_path=output, client_name=client) diff --git a/compiler_admin/commands/time/download.py b/compiler_admin/commands/time/download.py index 4cb3e3b..d8a6cbc 100644 --- a/compiler_admin/commands/time/download.py +++ b/compiler_admin/commands/time/download.py @@ -1,23 +1,125 @@ -from argparse import Namespace +from datetime import datetime, timedelta +import os +from typing import List + +import click +from pytz import timezone -from compiler_admin import RESULT_SUCCESS from compiler_admin.services.toggl import TOGGL_COLUMNS, download_time_entries -def download(args: Namespace, *extras): - params = dict( - start_date=args.start, end_date=args.end, output_path=args.output, output_cols=TOGGL_COLUMNS, billable=args.billable - ) +TZINFO = timezone(os.environ.get("TZ_NAME", "America/Los_Angeles")) - if args.client_ids: - params.update(dict(client_ids=args.client_ids)) - if args.project_ids: - params.update(dict(project_ids=args.project_ids)) - if args.task_ids: - params.update(dict(task_ids=args.task_ids)) - if args.user_ids: - params.update(dict(user_ids=args.user_ids)) - download_time_entries(**params) +def local_now(): + return datetime.now(tz=TZINFO) + + +def prior_month_end(): + now = local_now() + first = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + return first - timedelta(days=1) + + +def prior_month_start(): + end = prior_month_end() + return end.replace(day=1) - return RESULT_SUCCESS + +@click.command() +@click.option( + "--start", + metavar="YYYY-MM-DD", + default=prior_month_start(), + callback=lambda ctx, param, val: datetime.strptime(val, "%Y-%m-%d %H:%M:%S%z"), + help="The start date of the reporting period. Defaults to the beginning of the prior month.", +) +@click.option( + "--end", + metavar="YYYY-MM-DD", + default=prior_month_end(), + callback=lambda ctx, param, val: datetime.strptime(val, "%Y-%m-%d %H:%M:%S%z"), + help="The end date of the reporting period. Defaults to the end of the prior month.", +) +@click.option( + "--output", + help="The path to the file where downloaded data should be written. Defaults to a path calculated from the date range.", +) +@click.option( + "--all", + "billable", + is_flag=True, + default=True, + help="Download all time entries. The default is to download only billable time entries.", +) +@click.option( + "-c", + "--client", + "client_ids", + envvar="TOGGL_CLIENT_ID", + help="An ID for a Toggl Client to filter for in reports. Can be supplied more than once.", + metavar="CLIENT_ID", + multiple=True, + type=int, +) +@click.option( + "-p", + "--project", + "project_ids", + help="An ID for a Toggl Project to filter for in reports. Can be supplied more than once.", + metavar="PROJECT_ID", + multiple=True, + type=int, +) +@click.option( + "-t", + "--task", + "task_ids", + help="An ID for a Toggl Project Task to filter for in reports. Can be supplied more than once.", + metavar="TASK_ID", + multiple=True, + type=int, +) +@click.option( + "-u", + "--user", + "user_ids", + help="An ID for a Toggl User to filter for in reports. Can be supplied more than once.", + metavar="USER_ID", + multiple=True, + type=int, +) +def download( + start: datetime, + end: datetime, + output: str = "", + billable: bool = True, + client_ids: List[int] = [], + project_ids: List[int] = [], + task_ids: List[int] = [], + user_ids: List[int] = [], +): + """ + Download a Toggl time report in CSV format. + """ + if not output: + output = f"Toggl_time_entries_{start.strftime('%Y-%m-%d')}_{end.strftime('%Y-%m-%d')}.csv" + + params = dict(start_date=start, end_date=end, output_path=output, output_cols=TOGGL_COLUMNS) + + if billable: + params.update(dict(billable=billable)) + if client_ids: + params.update(dict(client_ids=client_ids)) + if project_ids: + params.update(dict(project_ids=project_ids)) + if task_ids: + params.update(dict(task_ids=task_ids)) + if user_ids: + params.update(dict(user_ids=user_ids)) + + click.echo("Downloading Toggl time entries with parameters:") + for k, v in params.items(): + click.echo(f" {k}: {v}") + + download_time_entries(**params) diff --git a/compiler_admin/commands/user/__init__.py b/compiler_admin/commands/user/__init__.py index aeb21b1..855a225 100644 --- a/compiler_admin/commands/user/__init__.py +++ b/compiler_admin/commands/user/__init__.py @@ -1,22 +1,28 @@ -from argparse import Namespace +import click -from compiler_admin.commands.user.alumni import alumni # noqa: F401 -from compiler_admin.commands.user.create import create # noqa: F401 -from compiler_admin.commands.user.convert import convert # noqa: F401 -from compiler_admin.commands.user.delete import delete # noqa: F401 -from compiler_admin.commands.user.offboard import offboard # noqa: F401 -from compiler_admin.commands.user.reset import reset # noqa: F401 -from compiler_admin.commands.user.restore import restore # noqa: F401 -from compiler_admin.commands.user.signout import signout # noqa: F401 +from compiler_admin.commands.user.alumni import alumni +from compiler_admin.commands.user.convert import convert +from compiler_admin.commands.user.create import create +from compiler_admin.commands.user.delete import delete +from compiler_admin.commands.user.offboard import offboard +from compiler_admin.commands.user.reset import reset +from compiler_admin.commands.user.restore import restore +from compiler_admin.commands.user.signout import signout -def user(args: Namespace, *extra): - # try to call the subcommand function directly from global (module) symbols - # if the subcommand function was imported above, it should exist in globals() - global_env = globals() +@click.group +def user(): + """ + Work with users in the Compiler org. + """ + pass - if args.subcommand in global_env: - cmd_func = global_env[args.subcommand] - cmd_func(args, *extra) - else: - raise NotImplementedError(f"Unknown user subcommand: {args.subcommand}") + +user.add_command(alumni) +user.add_command(convert) +user.add_command(create) +user.add_command(delete) +user.add_command(offboard) +user.add_command(reset) +user.add_command(restore) +user.add_command(signout) diff --git a/compiler_admin/commands/user/alumni.py b/compiler_admin/commands/user/alumni.py index bfa72d3..93e6d9a 100644 --- a/compiler_admin/commands/user/alumni.py +++ b/compiler_admin/commands/user/alumni.py @@ -1,10 +1,9 @@ -from argparse import Namespace +import click from compiler_admin import RESULT_FAILURE, RESULT_SUCCESS from compiler_admin.commands.user.reset import reset from compiler_admin.services.google import ( OU_ALUMNI, - USER_HELLO, CallGAMCommand, move_user_ou, user_account_name, @@ -12,105 +11,63 @@ ) -def alumni(args: Namespace) -> int: - """Convert a user to a Compiler alumni. - - Optionally notify an email address with the new randomly generated password. - - Args: - username (str): the user account to convert. - - notify (str): an email address to send the new password notification. - Returns: - A value indicating if the operation succeeded or failed. +@click.command() +@click.option("-f", "--force", is_flag=True, help="Don't ask for confirmation.") +@click.option("-n", "--notify", help="An email address to send the new password notification.") +@click.option( + "-e", + "--recovery-email", + help="An email address to use as the new recovery email. Without a value, clears the recovery email.", +) +@click.option( + "-p", + "--recovery-phone", + help="A phone number to use as the new recovery phone number. Without a value, clears the recovery phone number.", +) +@click.argument("username") +@click.pass_context +def alumni( + ctx: click.Context, username: str, force: bool = False, recovery_email: str = "", recovery_phone: str = "", **kwargs +): """ - if not hasattr(args, "username"): - raise ValueError("username is required") - - account = user_account_name(args.username) + Convert a user to a Compiler alumni. + """ + account = user_account_name(username) if not user_exists(account): - print(f"User does not exist: {account}") - return RESULT_FAILURE + click.echo(f"User does not exist: {account}") + raise SystemExit(RESULT_FAILURE) - if getattr(args, "force", False) is False: - cont = input(f"Convert account to alumni: {account}? (Y/n) ") + if not force: + cont = input(f"Convert account to alumni for {account}? (Y/n): ") if not cont.lower().startswith("y"): - print("Aborting conversion.") - return RESULT_SUCCESS + click.echo("Aborting conversion.") + raise SystemExit(RESULT_SUCCESS) - res = RESULT_SUCCESS + click.echo(f"User exists, converting to alumni: {account}") - print("Removing from groups") - res += CallGAMCommand(("user", account, "delete", "groups")) + click.echo("Removing from groups") + CallGAMCommand(("user", account, "delete", "groups")) - print(f"Moving to OU: {OU_ALUMNI}") - res += move_user_ou(account, OU_ALUMNI) + click.echo(f"Moving to OU: {OU_ALUMNI}") + move_user_ou(account, OU_ALUMNI) # reset password, sign out - res += reset(args) + ctx.forward(reset) - print("Clearing user profile info") + click.echo("Clearing user profile info") for prop in ["address", "location", "otheremail", "phone"]: command = ("update", "user", account, prop, "clear") - res += CallGAMCommand(command) + CallGAMCommand(command) - print("Resetting recovery email") - recovery = getattr(args, "recovery_email", "") - command = ("update", "user", account, "recoveryemail", recovery) - res += CallGAMCommand(command) + click.echo("Resetting recovery email") + command = ("update", "user", account, "recoveryemail", recovery_email) + CallGAMCommand(command) - print("Resetting recovery phone") - recovery = getattr(args, "recovery_phone", "") - command = ("update", "user", account, "recoveryphone", recovery) - res += CallGAMCommand(command) + click.echo("Resetting recovery phone") + command = ("update", "user", account, "recoveryphone", recovery_phone) + CallGAMCommand(command) - print("Turning off 2FA") + click.echo("Turning off 2FA") command = ("user", account, "turnoff2sv") - res += CallGAMCommand(command) - - print("Resetting email signature") - # https://github.com/taers232c/GAMADV-XTD3/wiki/Users-Gmail-Send-As-Signature-Vacation#manage-signature - command = ( - "user", - account, - "signature", - f"Compiler LLC
https://compiler.la
{USER_HELLO}", - "replyto", - USER_HELLO, - "default", - "treatasalias", - "false", - "name", - "Compiler LLC", - "primary", - ) - res += CallGAMCommand(command) - - print("Turning on email autoresponder") - # https://github.com/taers232c/GAMADV-XTD3/wiki/Users-Gmail-Send-As-Signature-Vacation#manage-vacation - message = ( - "Thank you for contacting Compiler. This inbox is no longer actively monitored.

" - + f"Please reach out to {USER_HELLO} if you need to get a hold of us." - ) - command = ( - "user", - account, - "vacation", - "true", - "subject", - "[This inbox is no longer active]", - "message", - message, - "contactsonly", - "false", - "domainonly", - "false", - "start", - "Started", - "end", - "2999-12-31", - ) - res += CallGAMCommand(command) - - return res + CallGAMCommand(command) diff --git a/compiler_admin/commands/user/convert.py b/compiler_admin/commands/user/convert.py index f078ac2..f215beb 100644 --- a/compiler_admin/commands/user/convert.py +++ b/compiler_admin/commands/user/convert.py @@ -1,6 +1,6 @@ -from argparse import Namespace +import click -from compiler_admin import RESULT_SUCCESS, RESULT_FAILURE +from compiler_admin import RESULT_FAILURE from compiler_admin.commands.user.alumni import alumni from compiler_admin.services.google import ( GROUP_PARTNERS, @@ -22,62 +22,63 @@ ACCOUNT_TYPE_OU = {"alumni": OU_ALUMNI, "contractor": OU_CONTRACTORS, "partner": OU_PARTNERS, "staff": OU_STAFF} -def convert(args: Namespace) -> int: - f"""Convert a user of one type to another. - Args: - username (str): The account to convert. Must exist already. - - account_type (str): One of {", ".join(ACCOUNT_TYPE_OU.keys())} - Returns: - A value indicating if the operation succeeded or failed. +@click.command() +@click.option("-f", "--force", is_flag=True, help="Don't ask for confirmation.") +@click.option( + "-n", "--notify", help="An email address to send the new password notification. Only valid for alumni conversion." +) +@click.option( + "-e", + "--recovery-email", + help="An email address to use as the new recovery email. Only valid for alumni conversion.", +) +@click.option( + "-p", + "--recovery-phone", + help="A phone number to use as the new recovery phone number. Only valid for alumni conversion.", +) +@click.argument("username") +@click.argument("account_type", type=click.Choice(ACCOUNT_TYPE_OU.keys(), case_sensitive=False)) +@click.pass_context +def convert(ctx: click.Context, username: str, account_type: str, **kwargs): """ - if not hasattr(args, "username"): - raise ValueError("username is required") - if not hasattr(args, "account_type"): - raise ValueError("account_type is required") - - account = user_account_name(args.username) - account_type = args.account_type + Convert a user of one type to another. + """ + account = user_account_name(username) if not user_exists(account): - print(f"User does not exist: {account}") - return RESULT_FAILURE + click.echo(f"User does not exist: {account}") + raise SystemExit(RESULT_FAILURE) - if account_type not in ACCOUNT_TYPE_OU: - print(f"Unknown account type for conversion: {account_type}") - return RESULT_FAILURE - - print(f"User exists, converting to: {account_type} for {account}") - res = RESULT_SUCCESS + click.echo(f"User exists, converting to: {account_type} for {account}") if account_type == "alumni": - res = alumni(args) + # call the alumni command + ctx.forward(alumni) elif account_type == "contractor": if user_is_partner(account): - res += remove_user_from_group(account, GROUP_PARTNERS) - res += remove_user_from_group(account, GROUP_STAFF) + remove_user_from_group(account, GROUP_PARTNERS) + remove_user_from_group(account, GROUP_STAFF) elif user_is_staff(account): - res = remove_user_from_group(account, GROUP_STAFF) + remove_user_from_group(account, GROUP_STAFF) elif account_type == "staff": if user_is_partner(account): - res += remove_user_from_group(account, GROUP_PARTNERS) + remove_user_from_group(account, GROUP_PARTNERS) elif user_is_staff(account): - print(f"User is already staff: {account}") - return RESULT_FAILURE - res += add_user_to_group(account, GROUP_STAFF) + click.echo(f"User is already staff: {account}") + raise SystemExit(RESULT_FAILURE) + add_user_to_group(account, GROUP_STAFF) elif account_type == "partner": if user_is_partner(account): - print(f"User is already partner: {account}") - return RESULT_FAILURE + click.echo(f"User is already partner: {account}") + raise SystemExit(RESULT_FAILURE) if not user_is_staff(account): - res += add_user_to_group(account, GROUP_STAFF) - res += add_user_to_group(account, GROUP_PARTNERS) - - res += move_user_ou(account, ACCOUNT_TYPE_OU[account_type]) + add_user_to_group(account, GROUP_STAFF) + add_user_to_group(account, GROUP_PARTNERS) - print(f"Account conversion complete for: {account}") + move_user_ou(account, ACCOUNT_TYPE_OU[account_type]) - return RESULT_SUCCESS if res == RESULT_SUCCESS else RESULT_FAILURE + click.echo(f"Account conversion complete for: {account}") diff --git a/compiler_admin/commands/user/create.py b/compiler_admin/commands/user/create.py index 6576e26..06e0dfa 100644 --- a/compiler_admin/commands/user/create.py +++ b/compiler_admin/commands/user/create.py @@ -1,47 +1,46 @@ -from argparse import Namespace -from typing import Sequence - -from compiler_admin import RESULT_SUCCESS, RESULT_FAILURE -from compiler_admin.services.google import GROUP_TEAM, add_user_to_group, CallGAMCommand, user_account_name, user_exists - - -def create(args: Namespace, *extra: Sequence[str]) -> int: - """Create a new user account in Compiler. +import click + +from compiler_admin import RESULT_FAILURE +from compiler_admin.services.google import ( + GROUP_TEAM, + USER_HELLO, + add_user_to_group, + CallGAMCommand, + user_account_name, + user_exists, +) + + +@click.command(context_settings={"ignore_unknown_options": True}) +@click.option("-n", "--notify", help="An email address to send the new password notification.") +@click.argument("username") +@click.argument("gam_args", nargs=-1, type=click.UNPROCESSED) +def create(username: str, notify: str = "", gam_args: list = []): + """Create a new user account. The user's password is randomly generated and requires reset on first login. Extra args are passed along to GAM as options. - See https://github.com/taers232c/GAMADV-XTD3/wiki/Users#create-a-user - for the complete list of options supported. - Args: - username (str): The account to create. Must not exist already. - Returns: - A value indicating if the operation succeeded or failed. + https://github.com/taers232c/GAMADV-XTD3/wiki/Users#create-a-user """ - if not hasattr(args, "username"): - raise ValueError("username is required") - - account = user_account_name(args.username) + account = user_account_name(username) if user_exists(account): - print(f"User already exists: {account}") - return RESULT_FAILURE + click.echo(f"User already exists: {account}") + raise SystemExit(RESULT_FAILURE) - print(f"User does not exist, continuing: {account}") + click.echo(f"User does not exist, continuing: {account}") command = ("create", "user", account, "password", "random", "changepassword") - notify = getattr(args, "notify", None) if notify: - command += ("notify", notify, "from", "hello@compiler.la") - - command += (*extra,) + command += ("notify", notify, "from", USER_HELLO) - res = CallGAMCommand(command) + command += (*gam_args,) - res += add_user_to_group(account, GROUP_TEAM) + CallGAMCommand(command) - print(f"User created successfully: {account}") + add_user_to_group(account, GROUP_TEAM) - return RESULT_SUCCESS if res == RESULT_SUCCESS else RESULT_FAILURE + click.echo(f"User created successfully: {account}") diff --git a/compiler_admin/commands/user/delete.py b/compiler_admin/commands/user/delete.py index ac5efc1..89d17dc 100644 --- a/compiler_admin/commands/user/delete.py +++ b/compiler_admin/commands/user/delete.py @@ -1,34 +1,28 @@ -from argparse import Namespace +import click -from compiler_admin import RESULT_SUCCESS, RESULT_FAILURE +from compiler_admin import RESULT_FAILURE from compiler_admin.services.google import CallGAMCommand, user_account_name, user_exists -def delete(args: Namespace) -> int: - """Delete the user account. - - Args: - username (str): The account to delete. Must exist and not be an alias. - Returns: - A value indicating if the operation succeeded or failed. +@click.command() +@click.option("-f", "--force", is_flag=True, help="Don't ask for confirmation.") +@click.argument("username") +def delete(username: str, force: bool = False): """ - if not hasattr(args, "username"): - raise ValueError("username is required") - - account = user_account_name(args.username) + Delete a user account. + """ + account = user_account_name(username) if not user_exists(account): - print(f"User does not exist: {account}") - return RESULT_FAILURE + click.echo(f"User does not exist: {account}") + raise SystemExit(RESULT_FAILURE) - if getattr(args, "force", False) is False: - cont = input(f"Delete account {account}? (Y/n)") + if not force: + cont = input(f"Delete account {account}? (Y/n): ") if not cont.lower().startswith("y"): - print("Aborting delete.") - return RESULT_SUCCESS - - print(f"User exists, deleting: {account}") + click.echo("Aborting delete.") + return - res = CallGAMCommand(("delete", "user", account, "noactionifalias")) + click.echo(f"User exists, deleting: {account}") - return RESULT_SUCCESS if res == RESULT_SUCCESS else RESULT_FAILURE + CallGAMCommand(("delete", "user", account, "noactionifalias")) diff --git a/compiler_admin/commands/user/offboard.py b/compiler_admin/commands/user/offboard.py index f9bd2c5..d17c385 100644 --- a/compiler_admin/commands/user/offboard.py +++ b/compiler_admin/commands/user/offboard.py @@ -1,7 +1,8 @@ -from argparse import Namespace from tempfile import NamedTemporaryFile -from compiler_admin import RESULT_SUCCESS, RESULT_FAILURE +import click + +from compiler_admin import RESULT_FAILURE, RESULT_SUCCESS from compiler_admin.commands.user.alumni import alumni from compiler_admin.commands.user.delete import delete from compiler_admin.services.google import ( @@ -13,7 +14,13 @@ ) -def offboard(args: Namespace) -> int: +@click.command() +@click.option("-a", "--alias", help="Another account to assign username as an alias.") +@click.option("-f", "--force", is_flag=True, help="Don't ask for confirmation.") +@click.option("-n", "--notify", help="An email address to send the new password notification.") +@click.argument("username") +@click.pass_context +def offboard(ctx: click.Context, username: str, alias: str = "", force: bool = False, **kwargs): """Fully offboard a user from Compiler. Args: @@ -23,55 +30,49 @@ def offboard(args: Namespace) -> int: Returns: A value indicating if the operation succeeded or failed. """ - if not hasattr(args, "username"): - raise ValueError("username is required") - - username = args.username account = user_account_name(username) if not user_exists(account): - print(f"User does not exist: {account}") - return RESULT_FAILURE + click.echo(f"User does not exist: {account}") + raise SystemExit(RESULT_FAILURE) - alias = getattr(args, "alias", None) alias_account = user_account_name(alias) - if alias_account is not None and not user_exists(alias_account): - print(f"Alias target user does not exist: {alias_account}") - return RESULT_FAILURE + if alias_account and not user_exists(alias_account): + click.echo(f"Alias target user does not exist: {alias_account}") + raise SystemExit(RESULT_FAILURE) - if getattr(args, "force", False) is False: - cont = input(f"Offboard account {account} {' (assigning alias to ' + alias_account + ')' if alias else ''}? (Y/n)") + if not force: + cont = input(f"Offboard account {account} {' (assigning alias to ' + alias_account + ')' if alias else ''}? (Y/n): ") if not cont.lower().startswith("y"): - print("Aborting offboard.") - return RESULT_SUCCESS + click.echo("Aborting offboard.") + raise SystemExit(RESULT_SUCCESS) - print(f"User exists, offboarding: {account}") - res = RESULT_SUCCESS + click.echo(f"User exists, offboarding: {account}") - res += alumni(args) + # call the alumni command + ctx.forward(alumni) - print("Backing up email") - res += CallGYBCommand(("--service-account", "--email", account, "--action", "backup")) + click.echo("Backing up email") + CallGYBCommand(("--service-account", "--email", account, "--action", "backup")) - print("Starting Drive and Calendar transfer") - res += CallGAMCommand(("create", "transfer", account, "calendar,drive", USER_ARCHIVE, "all", "releaseresources")) + click.echo("Starting Drive and Calendar transfer") + CallGAMCommand(("create", "transfer", account, "calendar,drive", USER_ARCHIVE, "all", "releaseresources")) status = "" with NamedTemporaryFile("w+") as stdout: while "Overall Transfer Status: completed" not in status: - print("Transfer in progress") - res += CallGAMCommand(("show", "transfers", "olduser", username), stdout=stdout.name, stderr="stdout") + click.echo("Transfer in progress") + CallGAMCommand(("show", "transfers", "olduser", username), stdout=stdout.name, stderr="stdout") status = " ".join(stdout.readlines()) stdout.seek(0) - res += CallGAMCommand(("user", account, "deprovision", "popimap")) + CallGAMCommand(("user", account, "deprovision", "popimap")) - res += delete(args) + # call the delete command + ctx.forward(delete) if alias_account: - print(f"Adding an alias to account: {alias_account}") - res += CallGAMCommand(("create", "alias", account, "user", alias_account)) - - print(f"Offboarding for user complete: {account}") + click.echo(f"Adding an alias to account: {alias_account}") + CallGAMCommand(("create", "alias", account, "user", alias_account)) - return RESULT_SUCCESS if res == RESULT_SUCCESS else RESULT_FAILURE + click.echo(f"Offboarding for user complete: {account}") diff --git a/compiler_admin/commands/user/reset.py b/compiler_admin/commands/user/reset.py index ecdfdda..25792bf 100644 --- a/compiler_admin/commands/user/reset.py +++ b/compiler_admin/commands/user/reset.py @@ -1,46 +1,38 @@ -from argparse import Namespace +import click -from compiler_admin import RESULT_SUCCESS, RESULT_FAILURE +from compiler_admin import RESULT_FAILURE, RESULT_SUCCESS from compiler_admin.commands.user.signout import signout from compiler_admin.services.google import USER_HELLO, CallGAMCommand, user_account_name, user_exists -def reset(args: Namespace) -> int: - """Reset a user's password. - - Optionally notify an email address with the new randomly generated password. - - Args: - username (str): the user account to reset. - - notify (str): an email address to send the new password notification. - Returns: - A value indicating if the operation succeeded or failed. +@click.command() +@click.option("-f", "--force", is_flag=True, help="Don't ask for confirmation.") +@click.option("-n", "--notify", help="An email address to send the new password notification.") +@click.argument("username") +@click.pass_context +def reset(ctx: click.Context, username: str, force: bool = False, notify: str = "", **kwargs): """ - if not hasattr(args, "username"): - raise ValueError("username is required") - - account = user_account_name(args.username) + Reset a user's password. + """ + account = user_account_name(username) if not user_exists(account): - print(f"User does not exist: {account}") - return RESULT_FAILURE + click.echo(f"User does not exist: {account}") + raise SystemExit(RESULT_FAILURE) - if getattr(args, "force", False) is False: - cont = input(f"Reset password for {account}? (Y/n)") + if not force: + cont = input(f"Reset password for {account}? (Y/n): ") if not cont.lower().startswith("y"): - print("Aborting password reset.") - return RESULT_SUCCESS + click.echo("Aborting password reset.") + raise SystemExit(RESULT_SUCCESS) - command = ("update", "user", account, "password", "random", "changepassword") + click.echo(f"User exists, resetting password: {account}") - notify = getattr(args, "notify", None) + command = ("update", "user", account, "password", "random", "changepassword") if notify: command += ("notify", notify, "from", USER_HELLO) - print(f"User exists, resetting password: {account}") - - res = CallGAMCommand(command) - res += signout(args) + CallGAMCommand(command) - return RESULT_SUCCESS if res == RESULT_SUCCESS else RESULT_FAILURE + # call the signout command + ctx.forward(signout) diff --git a/compiler_admin/commands/user/restore.py b/compiler_admin/commands/user/restore.py index 1c56e95..d0fa5da 100644 --- a/compiler_admin/commands/user/restore.py +++ b/compiler_admin/commands/user/restore.py @@ -1,31 +1,27 @@ -from argparse import Namespace import pathlib -from compiler_admin import RESULT_SUCCESS, RESULT_FAILURE -from compiler_admin.services.google import USER_ARCHIVE, CallGYBCommand, user_account_name +import click +from compiler_admin import RESULT_FAILURE +from compiler_admin.services.google import USER_ARCHIVE, CallGYBCommand, user_account_name -def restore(args: Namespace) -> int: - """Restore an email backup from a prior offboarding. - Args: - username (str): the user account with a local email backup to restore. - Returns: - A value indicating if the operation succeeded or failed. +@click.command() +@click.argument("username") +def restore(username: str): """ - if not hasattr(args, "username"): - raise ValueError("username is required") - - account = user_account_name(args.username) + Restore an email backup from a prior offboarding. + """ + account = user_account_name(username) backup_dir = f"GYB-GMail-Backup-{account}" if not pathlib.Path(backup_dir).exists(): - print(f"Couldn't find a local backup: {backup_dir}") - return RESULT_FAILURE + click.echo(f"Couldn't find a local backup: {backup_dir}") + raise SystemExit(RESULT_FAILURE) - print(f"Found backup, starting restore process with dest: {USER_ARCHIVE} for account: {account}") + click.echo(f"Found backup, starting restore process with dest: {USER_ARCHIVE} for account: {account}") - res = CallGYBCommand( + CallGYBCommand( ( "--service-account", "--email", @@ -39,6 +35,4 @@ def restore(args: Namespace) -> int: ) ) - print(f"Email restore complete for: {account}") - - return RESULT_SUCCESS if res == RESULT_SUCCESS else RESULT_FAILURE + click.echo(f"Email restore complete for: {account}") diff --git a/compiler_admin/commands/user/signout.py b/compiler_admin/commands/user/signout.py index e204578..e9c8764 100644 --- a/compiler_admin/commands/user/signout.py +++ b/compiler_admin/commands/user/signout.py @@ -1,34 +1,28 @@ -from argparse import Namespace +import click -from compiler_admin import RESULT_SUCCESS, RESULT_FAILURE +from compiler_admin import RESULT_FAILURE, RESULT_SUCCESS from compiler_admin.services.google import CallGAMCommand, user_account_name, user_exists -def signout(args: Namespace) -> int: - """Signs the user out from all active sessions. - - Args: - username (str): The account to sign out. Must exist already. - Returns: - A value indicating if the operation succeeded or failed. +@click.command() +@click.option("-f", "--force", is_flag=True, help="Don't ask for confirmation.") +@click.argument("username") +def signout(username: str, force: bool = False, **kwargs): """ - if not hasattr(args, "username"): - raise ValueError("username is required") - - account = user_account_name(args.username) + Sign a user out from all active sessions. + """ + account = user_account_name(username) if not user_exists(account): - print(f"User does not exist: {account}") - return RESULT_FAILURE + click.echo(f"User does not exist: {account}") + raise SystemExit(RESULT_FAILURE) - if getattr(args, "force", False) is False: - cont = input(f"Signout account {account} from all active sessions? (Y/n)") + if not force: + cont = input(f"Signout account {account} from all active sessions? (Y/n): ") if not cont.lower().startswith("y"): - print("Aborting signout.") - return RESULT_SUCCESS - - print(f"User exists, signing out from all active sessions: {account}") + click.echo("Aborting signout.") + raise SystemExit(RESULT_SUCCESS) - res = CallGAMCommand(("user", account, "signout")) + click.echo(f"User exists, signing out from all active sessions: {account}") - return RESULT_SUCCESS if res == RESULT_SUCCESS else RESULT_FAILURE + CallGAMCommand(("user", account, "signout")) diff --git a/compiler_admin/main.py b/compiler_admin/main.py index bc16512..86a4340 100644 --- a/compiler_admin/main.py +++ b/compiler_admin/main.py @@ -1,231 +1,24 @@ -from argparse import ArgumentParser, _SubParsersAction -from datetime import datetime, timedelta -import os -import sys +import click -from pytz import timezone +from compiler_admin import __version__ -from compiler_admin import __version__ as version from compiler_admin.commands.info import info from compiler_admin.commands.init import init from compiler_admin.commands.time import time -from compiler_admin.commands.time.convert import CONVERTERS from compiler_admin.commands.user import user -from compiler_admin.commands.user.convert import ACCOUNT_TYPE_OU -TZINFO = timezone(os.environ.get("TZ_NAME", "America/Los_Angeles")) +@click.group +@click.version_option(__version__, prog_name="compiler-admin") +def main(): + """Compiler's command line interface.""" + pass -def local_now(): - return datetime.now(tz=TZINFO) - - -def prior_month_end(): - now = local_now() - first = now.replace(day=1) - return first - timedelta(days=1) - - -def prior_month_start(): - end = prior_month_end() - return end.replace(day=1) - - -def add_sub_cmd_parser(parser: ArgumentParser, dest="subcommand", help=None): - """Helper adds a subparser for the given dest.""" - return parser.add_subparsers(dest=dest, help=help) - - -def add_sub_cmd(cmd: _SubParsersAction, subcmd, help) -> ArgumentParser: - """Helper creates a new subcommand parser.""" - return cmd.add_parser(subcmd, help=help) - - -def add_sub_cmd_with_username_arg(cmd: _SubParsersAction, subcmd, help) -> ArgumentParser: - """Helper creates a new subcommand parser with a required username arg.""" - sub_cmd = add_sub_cmd(cmd, subcmd, help=help) - sub_cmd.add_argument("username", help="A Compiler user account name, sans domain.") - return sub_cmd - - -def setup_info_command(cmd_parsers: _SubParsersAction): - info_cmd = add_sub_cmd(cmd_parsers, "info", help="Print configuration and debugging information.") - info_cmd.set_defaults(func=info) - - -def setup_init_command(cmd_parsers: _SubParsersAction): - init_cmd = add_sub_cmd_with_username_arg( - cmd_parsers, "init", help="Initialize a new admin project. This command should be run once before any others." - ) - init_cmd.add_argument("--gam", action="store_true", help="If provided, initialize a new GAM project.") - init_cmd.add_argument("--gyb", action="store_true", help="If provided, initialize a new GYB project.") - init_cmd.set_defaults(func=init) - - -def setup_time_command(cmd_parsers: _SubParsersAction): - time_cmd = add_sub_cmd(cmd_parsers, "time", help="Work with Compiler time entries.") - time_cmd.set_defaults(func=time) - time_subcmds = add_sub_cmd_parser(time_cmd, help="The time command to run.") - - time_convert = add_sub_cmd(time_subcmds, "convert", help="Convert a time report from one format into another.") - time_convert.add_argument( - "--input", - default=os.environ.get("TOGGL_DATA", sys.stdin), - help="The path to the source data for conversion. Defaults to $TOGGL_DATA or stdin.", - ) - time_convert.add_argument( - "--output", - default=os.environ.get("HARVEST_DATA", sys.stdout), - help="The path to the file where converted data should be written. Defaults to $HARVEST_DATA or stdout.", - ) - time_convert.add_argument( - "--from", - default="toggl", - choices=sorted(CONVERTERS.keys()), - dest="from_fmt", - help="The format of the source data. Defaults to 'toggl'.", - ) - time_convert.add_argument( - "--to", - default="harvest", - choices=sorted([to_fmt for sub in CONVERTERS.values() for to_fmt in sub.keys()]), - dest="to_fmt", - help="The format of the converted data. Defaults to 'harvest'.", - ) - time_convert.add_argument("--client", default=None, help="The name of the client to use in converted data.") - - time_download = add_sub_cmd(time_subcmds, "download", help="Download a Toggl report in CSV format.") - time_download.add_argument( - "--start", - metavar="YYYY-MM-DD", - default=prior_month_start(), - type=lambda s: TZINFO.localize(datetime.strptime(s, "%Y-%m-%d")), - help="The start date of the reporting period. Defaults to the beginning of the prior month.", - ) - time_download.add_argument( - "--end", - metavar="YYYY-MM-DD", - default=prior_month_end(), - type=lambda s: TZINFO.localize(datetime.strptime(s, "%Y-%m-%d")), - help="The end date of the reporting period. Defaults to the end of the prior month.", - ) - time_download.add_argument( - "--output", - default=os.environ.get("TOGGL_DATA", sys.stdout), - help="The path to the file where downloaded data should be written. Defaults to $TOGGL_DATA or stdout.", - ) - time_download.add_argument( - "--all", - default=True, - action="store_false", - dest="billable", - help="Download all time entries. The default is to download only billable time entries.", - ) - time_download.add_argument( - "--client", - dest="client_ids", - metavar="CLIENT_ID", - action="append", - type=int, - help="An ID for a Toggl Client to filter for in reports. Can be supplied more than once.", - ) - time_download.add_argument( - "--project", - dest="project_ids", - metavar="PROJECT_ID", - action="append", - type=int, - help="An ID for a Toggl Project to filter for in reports. Can be supplied more than once.", - ) - time_download.add_argument( - "--task", - dest="task_ids", - metavar="TASK_ID", - action="append", - type=int, - help="An ID for a Toggl Project Task to filter for in reports. Can be supplied more than once.", - ) - time_download.add_argument( - "--user", - dest="user_ids", - metavar="USER_ID", - action="append", - type=int, - help="An ID for a Toggl User to filter for in reports. Can be supplied more than once.", - ) - - -def setup_user_command(cmd_parsers: _SubParsersAction): - user_cmd = add_sub_cmd(cmd_parsers, "user", help="Work with users in the Compiler org.") - user_cmd.set_defaults(func=user) - user_subcmds = add_sub_cmd_parser(user_cmd, help="The user command to run.") - - user_alumni = add_sub_cmd_with_username_arg(user_subcmds, "alumni", help="Convert a user account to a Compiler alumni.") - user_alumni.add_argument("--notify", help="An email address to send the alumni's new password.") - user_alumni.add_argument( - "--force", action="store_true", default=False, help="Don't ask for confirmation before conversion." - ) - - user_create = add_sub_cmd_with_username_arg(user_subcmds, "create", help="Create a new user in the Compiler domain.") - user_create.add_argument("--notify", help="An email address to send the newly created account info.") - - user_convert = add_sub_cmd_with_username_arg(user_subcmds, "convert", help="Convert a user account to a new type.") - user_convert.add_argument("account_type", choices=ACCOUNT_TYPE_OU.keys(), help="Target account type for this conversion.") - user_convert.add_argument( - "--force", action="store_true", default=False, help="Don't ask for confirmation before conversion." - ) - user_convert.add_argument("--notify", help="An email address to send the alumni's new password.") - - user_delete = add_sub_cmd_with_username_arg(user_subcmds, "delete", help="Delete a user account.") - user_delete.add_argument("--force", action="store_true", default=False, help="Don't ask for confirmation before deletion.") - - user_offboard = add_sub_cmd_with_username_arg(user_subcmds, "offboard", help="Offboard a user account.") - user_offboard.add_argument("--alias", help="Account to assign username as an alias.") - user_offboard.add_argument( - "--force", action="store_true", default=False, help="Don't ask for confirmation before offboarding." - ) - - user_reset = add_sub_cmd_with_username_arg( - user_subcmds, "reset", help="Reset a user's password to a randomly generated string." - ) - user_reset.add_argument("--force", action="store_true", default=False, help="Don't ask for confirmation before reset.") - user_reset.add_argument("--notify", help="An email address to send the newly generated password.") - - add_sub_cmd_with_username_arg(user_subcmds, "restore", help="Restore an email backup from a prior offboarding.") - - user_signout = add_sub_cmd_with_username_arg(user_subcmds, "signout", help="Signs a user out from all active sessions.") - user_signout.add_argument("--force", action="store_true", default=False, help="Don't ask for confirmation before signout.") - - -def main(argv=None): - argv = argv if argv is not None else sys.argv[1:] - parser = ArgumentParser(prog="compiler-admin") - - # https://stackoverflow.com/a/8521644/812183 - parser.add_argument( - "-v", - "--version", - action="version", - version=f"%(prog)s {version}", - ) - - cmd_parsers = add_sub_cmd_parser(parser, dest="command", help="The command to run") - setup_info_command(cmd_parsers) - setup_init_command(cmd_parsers) - setup_time_command(cmd_parsers) - setup_user_command(cmd_parsers) - - if len(argv) == 0: - argv = ["info"] - - args, extra = parser.parse_known_args(argv) - - if args.func: - return args.func(args, *extra) - else: - raise ValueError("Unrecognized command") - +main.add_command(init) +main.add_command(info) +main.add_command(time) +main.add_command(user) if __name__ == "__main__": raise SystemExit(main()) diff --git a/compiler_admin/services/toggl.py b/compiler_admin/services/toggl.py index 06c4bfa..84f4d1e 100644 --- a/compiler_admin/services/toggl.py +++ b/compiler_admin/services/toggl.py @@ -192,12 +192,6 @@ def download_time_entries( Returns: None. Either prints the resulting CSV data or writes to output_path. """ - env_client_id = os.environ.get("TOGGL_CLIENT_ID") - if env_client_id: - env_client_id = int(env_client_id) - if ("client_ids" not in kwargs or not kwargs["client_ids"]) and isinstance(env_client_id, int): - kwargs["client_ids"] = [env_client_id] - token = os.environ.get("TOGGL_API_TOKEN") workspace = os.environ.get("TOGGL_WORKSPACE_ID") toggl = Toggl(token, workspace) diff --git a/pyproject.toml b/pyproject.toml index 433e4ba..43ad7fe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,7 @@ authors = [ requires-python = ">=3.11" dependencies = [ "advanced-gam-for-google-workspace @ git+https://github.com/taers232c/GAMADV-XTD3.git@v7.00.38#subdirectory=src", + "click==8.1.7", "pandas==2.2.3", "tzdata", ] diff --git a/tests/commands/test_info.py b/tests/commands/test_info.py index 4f9f3f7..6c705c1 100644 --- a/tests/commands/test_info.py +++ b/tests/commands/test_info.py @@ -1,8 +1,7 @@ import pytest -from compiler_admin import RESULT_SUCCESS +from compiler_admin import RESULT_SUCCESS, __version__ from compiler_admin.commands.info import info, __name__ as MODULE -from compiler_admin.services.google import DOMAIN @pytest.fixture @@ -15,21 +14,10 @@ def mock_google_CallGYBCommand(mock_google_CallGYBCommand): return mock_google_CallGYBCommand(MODULE) -def test_info(mock_google_CallGAMCommand, mock_google_CallGYBCommand): - info() +def test_info(cli_runner, mock_google_CallGAMCommand, mock_google_CallGYBCommand): + result = cli_runner.invoke(info) + assert result.exit_code == RESULT_SUCCESS + assert f"compiler-admin, version {__version__}" in result.output assert mock_google_CallGAMCommand.call_count > 0 mock_google_CallGYBCommand.assert_called_once() - - -@pytest.mark.e2e -def test_info_e2e(capfd): - res = info() - captured = capfd.readouterr() - - assert res == RESULT_SUCCESS - assert "compiler-admin:" in captured.out - assert "GAMADV-XTD3" in captured.out - assert f"Primary Domain: {DOMAIN}" in captured.out - assert "Got Your Back" in captured.out - assert "WARNING: Config File:" not in captured.err diff --git a/tests/commands/test_init.py b/tests/commands/test_init.py index cbe8a8b..c6cfef6 100644 --- a/tests/commands/test_init.py +++ b/tests/commands/test_init.py @@ -1,6 +1,6 @@ -from argparse import Namespace import pytest +from compiler_admin import RESULT_SUCCESS from compiler_admin.commands.init import _clean_config_dir, init, __name__ as MODULE @@ -52,35 +52,28 @@ def test_clean_config_dir(mocker, mock_GAM_CONFIG_PATH, mock_rmtree): assert mock_dir in mock_rmtree.call_args.args -def test_init_admin_user_required(): - args = Namespace() - - with pytest.raises(ValueError, match="username is required"): - init(args) - - -def test_init_default(mock_clean_config_dir, mock_google_CallGAMCommand, mock_subprocess_call): - args = Namespace(username="username") - init(args) +def test_init_default(cli_runner, mock_clean_config_dir, mock_google_CallGAMCommand, mock_subprocess_call): + result = cli_runner.invoke(init, ["username"]) + assert result.exit_code == RESULT_SUCCESS assert mock_clean_config_dir.call_count == 0 assert mock_google_CallGAMCommand.call_count == 0 assert mock_subprocess_call.call_count == 0 -def test_init_gam(mock_GAM_CONFIG_PATH, mock_clean_config_dir, mock_google_CallGAMCommand): - args = Namespace(username="username", gam=True, gyb=False) - init(args) +def test_init_gam(cli_runner, mock_GAM_CONFIG_PATH, mock_clean_config_dir, mock_google_CallGAMCommand): + result = cli_runner.invoke(init, ["--gam", "username"]) + assert result.exit_code == RESULT_SUCCESS mock_clean_config_dir.assert_called_once() assert mock_GAM_CONFIG_PATH in mock_clean_config_dir.call_args.args assert mock_google_CallGAMCommand.call_count > 0 -def test_init_gyb(mock_GYB_CONFIG_PATH, mock_clean_config_dir, mock_subprocess_call): - args = Namespace(username="username", gam=False, gyb=True) - init(args) +def test_init_gyb(cli_runner, mock_GYB_CONFIG_PATH, mock_clean_config_dir, mock_subprocess_call): + result = cli_runner.invoke(init, ["--gyb", "username"]) + assert result.exit_code == RESULT_SUCCESS mock_clean_config_dir.assert_called_once() assert mock_GYB_CONFIG_PATH in mock_clean_config_dir.call_args.args assert mock_subprocess_call.call_count > 0 diff --git a/tests/commands/time/test__init__.py b/tests/commands/time/test__init__.py deleted file mode 100644 index db439fb..0000000 --- a/tests/commands/time/test__init__.py +++ /dev/null @@ -1,25 +0,0 @@ -from argparse import Namespace - -import pytest - -from compiler_admin.commands.time import time - - -def test_time_subcommand_exists(mocker): - args = Namespace(subcommand="subcmd") - subcmd = mocker.patch("compiler_admin.commands.time.globals", return_value={"subcmd": mocker.Mock()}) - - time(args, 1, 2, 3) - - subcmd.assert_called_once() - subcmd.return_value["subcmd"].assert_called_once_with(args, 1, 2, 3) - - -def test_time_subcommand_doesnt_exists(mocker): - args = Namespace(subcommand="subcmd") - subcmd = mocker.patch("compiler_admin.commands.time.globals", return_value={}) - - with pytest.raises(NotImplementedError, match="Unknown time subcommand: subcmd"): - time(args, 1, 2, 3) - - subcmd.assert_called_once() diff --git a/tests/commands/time/test_convert.py b/tests/commands/time/test_convert.py index 68fc580..b5e4276 100644 --- a/tests/commands/time/test_convert.py +++ b/tests/commands/time/test_convert.py @@ -1,4 +1,3 @@ -from argparse import Namespace import pytest from compiler_admin import RESULT_SUCCESS @@ -40,14 +39,15 @@ def test_get_source_converter_mismatch(): _get_source_converter("toggl", "nope") -def test_convert(mock_get_source_converter): - args = Namespace(input="input", output="output", client="client", from_fmt="from", to_fmt="to") - res = convert(args) +def test_convert(cli_runner, mock_get_source_converter): + result = cli_runner.invoke( + convert, ["--input", "input", "--output", "output", "--client", "client", "--from", "harvest", "--to", "toggl"] + ) - assert res == RESULT_SUCCESS - mock_get_source_converter.assert_called_once_with(args.from_fmt, args.to_fmt) + assert result.exit_code == RESULT_SUCCESS + mock_get_source_converter.assert_called_once_with("harvest", "toggl") mock_get_source_converter.return_value.assert_called_once_with( - source_path=args.input, output_path=args.output, client_name=args.client + source_path="input", output_path="output", client_name="client" ) diff --git a/tests/commands/time/test_download.py b/tests/commands/time/test_download.py index fe9f80b..851289c 100644 --- a/tests/commands/time/test_download.py +++ b/tests/commands/time/test_download.py @@ -1,9 +1,32 @@ -from argparse import Namespace from datetime import datetime import pytest from compiler_admin import RESULT_SUCCESS -from compiler_admin.commands.time.download import __name__ as MODULE, download, TOGGL_COLUMNS +from compiler_admin.commands.time.download import ( + __name__ as MODULE, + download, + TOGGL_COLUMNS, + TZINFO, + prior_month_end, + prior_month_start, +) + + +@pytest.fixture +def mock_local_now(mocker): + dt = datetime(2024, 9, 25, tzinfo=TZINFO) + mocker.patch(f"{MODULE}.local_now", return_value=dt) + return dt + + +@pytest.fixture +def mock_start(mock_local_now): + return datetime(2024, 8, 1, tzinfo=TZINFO) + + +@pytest.fixture +def mock_end(mock_local_now): + return datetime(2024, 8, 31, tzinfo=TZINFO) @pytest.fixture @@ -11,31 +34,98 @@ def mock_download_time_entries(mocker): return mocker.patch(f"{MODULE}.download_time_entries") -@pytest.mark.parametrize("billable", [True, False]) -def test_download(mock_download_time_entries, billable): - date = datetime.now() - args = Namespace( - start=date, - end=date, - output="output", - billable=billable, - client_ids=["c1", "c2"], - project_ids=["p1", "p2"], - task_ids=["t1", "t2"], - user_ids=["u1", "u2"], - ) +def test_prior_month_start(mock_start): + start = prior_month_start() + + assert start == mock_start + + +def test_prior_month_end(mock_end): + end = prior_month_end() + + assert end == mock_end + - res = download(args) +def test_download(cli_runner, mock_download_time_entries): + date = datetime.now(tz=TZINFO).replace(hour=0, minute=0, second=0, microsecond=0) + args = [ + "--start", + date.strftime("%Y-%m-%d %H:%M:%S%z"), + "--end", + date.strftime("%Y-%m-%d %H:%M:%S%z"), + "--output", + "output", + "-c", + 1, + "-c", + 2, + "-p", + 3, + "-p", + 4, + "-t", + 5, + "-t", + 6, + "-u", + 7, + "-u", + 8, + ] - assert res == RESULT_SUCCESS + result = cli_runner.invoke(download, args) + + assert result.exit_code == RESULT_SUCCESS mock_download_time_entries.assert_called_once_with( - start_date=args.start, - end_date=args.end, - output_path=args.output, + start_date=date, + end_date=date, + output_path="output", + billable=True, output_cols=TOGGL_COLUMNS, - billable=args.billable, - client_ids=args.client_ids, - project_ids=args.project_ids, - task_ids=args.task_ids, - user_ids=args.user_ids, + client_ids=(1, 2), + project_ids=(3, 4), + task_ids=(5, 6), + user_ids=(7, 8), + ) + + +def test_download_client_envvar(cli_runner, monkeypatch, mock_download_time_entries): + monkeypatch.setenv("TOGGL_CLIENT_ID", 1234) + + date = datetime.now(tz=TZINFO).replace(hour=0, minute=0, second=0, microsecond=0) + args = [ + "--start", + date.strftime("%Y-%m-%d %H:%M:%S%z"), + "--end", + date.strftime("%Y-%m-%d %H:%M:%S%z"), + "--output", + "output", + ] + + result = cli_runner.invoke(download, args) + + assert result.exit_code == RESULT_SUCCESS + mock_download_time_entries.assert_called_once_with( + start_date=date, end_date=date, output_path="output", output_cols=TOGGL_COLUMNS, billable=True, client_ids=(1234,) + ) + + +def test_download_all(cli_runner, monkeypatch, mock_download_time_entries): + monkeypatch.delenv("TOGGL_CLIENT_ID", raising=False) + date = datetime.now(tz=TZINFO).replace(hour=0, minute=0, second=0, microsecond=0) + args = [ + "--start", + date.strftime("%Y-%m-%d %H:%M:%S%z"), + "--end", + date.strftime("%Y-%m-%d %H:%M:%S%z"), + "--output", + "output", + "--all", + ] + + result = cli_runner.invoke(download, args) + + assert result.exit_code == RESULT_SUCCESS + mock_download_time_entries.assert_called_once_with( + start_date=date, end_date=date, output_path="output", output_cols=TOGGL_COLUMNS ) diff --git a/tests/commands/time/test_init.py b/tests/commands/time/test_init.py new file mode 100644 index 0000000..3181ad7 --- /dev/null +++ b/tests/commands/time/test_init.py @@ -0,0 +1,8 @@ +import pytest + +from compiler_admin.commands.time import time + + +@pytest.mark.parametrize("command", ["convert", "download"]) +def test_time_commands(command): + assert command in time.commands diff --git a/tests/commands/user/test__init__.py b/tests/commands/user/test__init__.py deleted file mode 100644 index fd55421..0000000 --- a/tests/commands/user/test__init__.py +++ /dev/null @@ -1,25 +0,0 @@ -from argparse import Namespace - -import pytest - -from compiler_admin.commands.user import user - - -def test_user_subcommand_exists(mocker): - args = Namespace(subcommand="subcmd") - subcmd = mocker.patch("compiler_admin.commands.user.globals", return_value={"subcmd": mocker.Mock()}) - - user(args, 1, 2, 3) - - subcmd.assert_called() - subcmd.return_value["subcmd"].assert_called_once_with(args, 1, 2, 3) - - -def test_time_subcommand_doesnt_exists(mocker): - args = Namespace(subcommand="subcmd") - subcmd = mocker.patch("compiler_admin.commands.user.globals", return_value={}) - - with pytest.raises(NotImplementedError, match="Unknown user subcommand: subcmd"): - user(args, 1, 2, 3) - - subcmd.assert_called_once() diff --git a/tests/commands/user/test_alumni.py b/tests/commands/user/test_alumni.py index 16df231..51db376 100644 --- a/tests/commands/user/test_alumni.py +++ b/tests/commands/user/test_alumni.py @@ -1,4 +1,3 @@ -from argparse import Namespace import pytest from compiler_admin import RESULT_FAILURE, RESULT_SUCCESS @@ -45,58 +44,50 @@ def mock_google_user_exists(mock_google_user_exists): return mock_google_user_exists(MODULE) -def test_alumni_username_required(): - args = Namespace() - - with pytest.raises(ValueError, match="username is required"): - alumni(args) - - -def test_alumni_user_does_not_exists(mock_google_user_exists, mock_google_CallGAMCommand): +def test_alumni_user_does_not_exists(cli_runner, mock_google_user_exists, mock_google_CallGAMCommand): mock_google_user_exists.return_value = False - args = Namespace(username="username") - res = alumni(args) + result = cli_runner.invoke(alumni, ["username"]) - assert res == RESULT_FAILURE + assert result.exit_code == RESULT_FAILURE + assert result.exception + assert "User does not exist: username@compiler.la" in result.output mock_google_CallGAMCommand.assert_not_called() @pytest.mark.usefixtures("mock_input_yes") def test_alumni_confirm_yes( - mock_google_user_exists, mock_google_CallGAMCommand, mock_commands_reset, mock_google_move_user_ou + cli_runner, mock_google_user_exists, mock_google_CallGAMCommand, mock_google_move_user_ou, mock_commands_reset ): mock_google_user_exists.return_value = True - args = Namespace(username="username", force=False) - res = alumni(args) + result = cli_runner.invoke(alumni, ["username"]) - assert res == RESULT_SUCCESS + assert result.exit_code == RESULT_SUCCESS mock_google_CallGAMCommand.assert_called() mock_google_move_user_ou.assert_called_once_with("username@compiler.la", OU_ALUMNI) - mock_commands_reset.assert_called_once_with(args) @pytest.mark.usefixtures("mock_input_no") -def test_alumni_confirm_no(mock_google_user_exists, mock_google_CallGAMCommand, mock_commands_reset, mock_google_move_user_ou): +def test_alumni_confirm_no( + cli_runner, mock_google_user_exists, mock_google_CallGAMCommand, mock_google_move_user_ou, mock_commands_reset +): mock_google_user_exists.return_value = True - args = Namespace(username="username", force=False) - res = alumni(args) + result = cli_runner.invoke(alumni, ["username"]) - assert res == RESULT_SUCCESS + assert result.exit_code == RESULT_SUCCESS mock_google_CallGAMCommand.assert_not_called() - mock_commands_reset.assert_not_called() mock_google_move_user_ou.assert_not_called() -def test_alumni_force(mock_google_user_exists, mock_google_CallGAMCommand, mock_commands_reset, mock_google_move_user_ou): +def test_alumni_force( + cli_runner, mock_google_user_exists, mock_google_CallGAMCommand, mock_google_move_user_ou, mock_commands_reset +): mock_google_user_exists.return_value = True - args = Namespace(username="username", force=True) - res = alumni(args) + result = cli_runner.invoke(alumni, ["--force", "username"]) - assert res == RESULT_SUCCESS + assert result.exit_code == RESULT_SUCCESS mock_google_CallGAMCommand.assert_called() mock_google_move_user_ou.assert_called_once_with("username@compiler.la", OU_ALUMNI) - mock_commands_reset.assert_called_once_with(args) diff --git a/tests/commands/user/test_convert.py b/tests/commands/user/test_convert.py index ddbbc99..8228029 100644 --- a/tests/commands/user/test_convert.py +++ b/tests/commands/user/test_convert.py @@ -1,7 +1,6 @@ -from argparse import Namespace import pytest -from compiler_admin import RESULT_FAILURE, RESULT_SUCCESS +from compiler_admin import RESULT_SUCCESS from compiler_admin.commands.user.convert import convert, __name__ as MODULE @@ -70,76 +69,56 @@ def mock_google_user_is_staff_false(mock_google_user_is_staff): return mock_google_user_is_staff -def test_convert_user_username_required(): - args = Namespace() - - with pytest.raises(ValueError, match="username is required"): - convert(args) - - -def test_convert_user_account_type_required(): - args = Namespace(username="username") - - with pytest.raises(ValueError, match="account_type is required"): - convert(args) - - -def test_convert_user_does_not_exists(mock_google_user_exists, mock_google_move_user_ou): +def test_convert_user_does_not_exists(cli_runner, mock_google_user_exists, mock_google_move_user_ou): mock_google_user_exists.return_value = False - args = Namespace(username="username", account_type="account_type") - res = convert(args) + result = cli_runner.invoke(convert, ["username", "staff"]) - assert res == RESULT_FAILURE + assert result.exit_code != RESULT_SUCCESS assert mock_google_move_user_ou.call_count == 0 @pytest.mark.usefixtures("mock_google_user_exists_true") -def test_convert_user_exists_bad_account_type(mock_google_move_user_ou): - args = Namespace(username="username", account_type="account_type") - res = convert(args) +def test_convert_user_exists_bad_account_type(cli_runner, mock_google_move_user_ou): + result = cli_runner.invoke(convert, ["username", "bad_account_type"]) - assert res == RESULT_FAILURE + assert result.exit_code != RESULT_SUCCESS assert mock_google_move_user_ou.call_count == 0 @pytest.mark.usefixtures("mock_google_user_exists_true") -def test_convert_alumni(mock_commands_alumni, mock_google_move_user_ou): - args = Namespace(username="username", account_type="alumni") - res = convert(args) +def test_convert_alumni(cli_runner, mock_commands_alumni, mock_google_move_user_ou): + result = cli_runner.invoke(convert, ["username", "alumni"]) - assert res == RESULT_SUCCESS - mock_commands_alumni.assert_called_once_with(args) + assert result.exit_code == RESULT_SUCCESS + mock_commands_alumni.callback.assert_called_once() mock_google_move_user_ou.assert_called_once() @pytest.mark.usefixtures( "mock_google_user_exists_true", "mock_google_user_is_partner_false", "mock_google_user_is_staff_false" ) -def test_convert_contractor(mock_google_move_user_ou): - args = Namespace(username="username", account_type="contractor") - res = convert(args) +def test_convert_contractor(cli_runner, mock_google_move_user_ou): + result = cli_runner.invoke(convert, ["username", "contractor"]) - assert res == RESULT_SUCCESS + assert result.exit_code == RESULT_SUCCESS mock_google_move_user_ou.assert_called_once() @pytest.mark.usefixtures("mock_google_user_exists_true", "mock_google_user_is_partner_true", "mock_google_user_is_staff_false") -def test_convert_contractor_user_is_partner(mock_google_remove_user_from_group, mock_google_move_user_ou): - args = Namespace(username="username", account_type="contractor") - res = convert(args) +def test_convert_contractor_user_is_partner(cli_runner, mock_google_remove_user_from_group, mock_google_move_user_ou): + result = cli_runner.invoke(convert, ["username", "contractor"]) - assert res == RESULT_SUCCESS + assert result.exit_code == RESULT_SUCCESS assert mock_google_remove_user_from_group.call_count == 2 mock_google_move_user_ou.assert_called_once() @pytest.mark.usefixtures("mock_google_user_exists_true", "mock_google_user_is_partner_false", "mock_google_user_is_staff_true") -def test_convert_contractor_user_is_staff(mock_google_remove_user_from_group, mock_google_move_user_ou): - args = Namespace(username="username", account_type="contractor") - res = convert(args) +def test_convert_contractor_user_is_staff(cli_runner, mock_google_remove_user_from_group, mock_google_move_user_ou): + result = cli_runner.invoke(convert, ["username", "contractor"]) - assert res == RESULT_SUCCESS + assert result.exit_code == RESULT_SUCCESS mock_google_remove_user_from_group.assert_called_once() mock_google_move_user_ou.assert_called_once() @@ -147,34 +126,31 @@ def test_convert_contractor_user_is_staff(mock_google_remove_user_from_group, mo @pytest.mark.usefixtures( "mock_google_user_exists_true", "mock_google_user_is_partner_false", "mock_google_user_is_staff_false" ) -def test_convert_staff(mock_google_add_user_to_group, mock_google_move_user_ou): - args = Namespace(username="username", account_type="staff") - res = convert(args) +def test_convert_staff(cli_runner, mock_google_add_user_to_group, mock_google_move_user_ou): + result = cli_runner.invoke(convert, ["username", "staff"]) - assert res == RESULT_SUCCESS + assert result.exit_code == RESULT_SUCCESS mock_google_add_user_to_group.assert_called_once() mock_google_move_user_ou.assert_called_once() @pytest.mark.usefixtures("mock_google_user_exists_true", "mock_google_user_is_partner_true", "mock_google_user_is_staff_false") def test_convert_staff_user_is_partner( - mock_google_add_user_to_group, mock_google_remove_user_from_group, mock_google_move_user_ou + cli_runner, mock_google_add_user_to_group, mock_google_remove_user_from_group, mock_google_move_user_ou ): - args = Namespace(username="username", account_type="staff") - res = convert(args) + result = cli_runner.invoke(convert, ["username", "staff"]) - assert res == RESULT_SUCCESS + assert result.exit_code == RESULT_SUCCESS mock_google_remove_user_from_group.assert_called_once() mock_google_add_user_to_group.assert_called_once() mock_google_move_user_ou.assert_called_once() @pytest.mark.usefixtures("mock_google_user_exists_true", "mock_google_user_is_partner_false", "mock_google_user_is_staff_true") -def test_convert_staff_user_is_staff(mock_google_add_user_to_group, mock_google_move_user_ou): - args = Namespace(username="username", account_type="staff") - res = convert(args) +def test_convert_staff_user_is_staff(cli_runner, mock_google_add_user_to_group, mock_google_move_user_ou): + result = cli_runner.invoke(convert, ["username", "staff"]) - assert res == RESULT_FAILURE + assert result.exit_code != RESULT_SUCCESS assert mock_google_add_user_to_group.call_count == 0 assert mock_google_move_user_ou.call_count == 0 @@ -182,30 +158,27 @@ def test_convert_staff_user_is_staff(mock_google_add_user_to_group, mock_google_ @pytest.mark.usefixtures( "mock_google_user_exists_true", "mock_google_user_is_partner_false", "mock_google_user_is_staff_false" ) -def test_convert_partner(mock_google_add_user_to_group, mock_google_move_user_ou): - args = Namespace(username="username", account_type="partner") - res = convert(args) +def test_convert_partner(cli_runner, mock_google_add_user_to_group, mock_google_move_user_ou): + result = cli_runner.invoke(convert, ["username", "partner"]) - assert res == RESULT_SUCCESS + assert result.exit_code == RESULT_SUCCESS assert mock_google_add_user_to_group.call_count == 2 mock_google_move_user_ou.assert_called_once() @pytest.mark.usefixtures("mock_google_user_exists_true", "mock_google_user_is_partner_true", "mock_google_user_is_staff_false") -def test_convert_partner_user_is_partner(mock_google_add_user_to_group, mock_google_move_user_ou): - args = Namespace(username="username", account_type="partner") - res = convert(args) +def test_convert_partner_user_is_partner(cli_runner, mock_google_add_user_to_group, mock_google_move_user_ou): + result = cli_runner.invoke(convert, ["username", "partner"]) - assert res == RESULT_FAILURE + assert result != RESULT_SUCCESS assert mock_google_add_user_to_group.call_count == 0 assert mock_google_move_user_ou.call_count == 0 @pytest.mark.usefixtures("mock_google_user_exists_true", "mock_google_user_is_partner_false", "mock_google_user_is_staff_true") -def test_convert_partner_user_is_staff(mock_google_add_user_to_group, mock_google_move_user_ou): - args = Namespace(username="username", account_type="partner") - res = convert(args) +def test_convert_partner_user_is_staff(cli_runner, mock_google_add_user_to_group, mock_google_move_user_ou): + result = cli_runner.invoke(convert, ["username", "partner"]) - assert res == RESULT_SUCCESS + assert result.exit_code == RESULT_SUCCESS mock_google_add_user_to_group.assert_called_once() mock_google_move_user_ou.assert_called_once() diff --git a/tests/commands/user/test_create.py b/tests/commands/user/test_create.py index 3d839a3..cebe38d 100644 --- a/tests/commands/user/test_create.py +++ b/tests/commands/user/test_create.py @@ -1,7 +1,6 @@ -from argparse import Namespace import pytest -from compiler_admin import RESULT_FAILURE, RESULT_SUCCESS +from compiler_admin import RESULT_SUCCESS from compiler_admin.commands.user.create import create, __name__ as MODULE from compiler_admin.services.google import USER_HELLO @@ -21,29 +20,22 @@ def mock_google_add_user_to_group(mock_google_add_user_to_group): return mock_google_add_user_to_group(MODULE) -def test_create_user_username_required(): - args = Namespace() - - with pytest.raises(ValueError, match="username is required"): - create(args) - - -def test_create_user_exists(mock_google_user_exists): +def test_create_user_exists(cli_runner, mock_google_user_exists): mock_google_user_exists.return_value = True - args = Namespace(username="username") - res = create(args) + result = cli_runner.invoke(create, ["username"]) - assert res == RESULT_FAILURE + assert result.exit_code != RESULT_SUCCESS -def test_create_user_does_not_exists(mock_google_user_exists, mock_google_CallGAMCommand, mock_google_add_user_to_group): +def test_create_user_does_not_exists( + cli_runner, mock_google_user_exists, mock_google_CallGAMCommand, mock_google_add_user_to_group +): mock_google_user_exists.return_value = False - args = Namespace(username="username") - res = create(args) + result = cli_runner.invoke(create, ["username"]) - assert res == RESULT_SUCCESS + assert result.exit_code == RESULT_SUCCESS mock_google_CallGAMCommand.assert_called_once() call_args = " ".join(mock_google_CallGAMCommand.call_args[0][0]) @@ -53,13 +45,12 @@ def test_create_user_does_not_exists(mock_google_user_exists, mock_google_CallGA mock_google_add_user_to_group.assert_called_once() -def test_create_user_notify(mock_google_user_exists, mock_google_CallGAMCommand, mock_google_add_user_to_group): +def test_create_user_notify(cli_runner, mock_google_user_exists, mock_google_CallGAMCommand, mock_google_add_user_to_group): mock_google_user_exists.return_value = False - args = Namespace(username="username", notify="notification@example.com") - res = create(args) + result = cli_runner.invoke(create, ["--notify", "notification@example.com", "username"]) - assert res == RESULT_SUCCESS + assert result.exit_code == RESULT_SUCCESS mock_google_CallGAMCommand.assert_called_once() call_args = " ".join(mock_google_CallGAMCommand.call_args[0][0]) @@ -70,13 +61,12 @@ def test_create_user_notify(mock_google_user_exists, mock_google_CallGAMCommand, mock_google_add_user_to_group.assert_called_once() -def test_create_user_extras(mock_google_user_exists, mock_google_CallGAMCommand, mock_google_add_user_to_group): +def test_create_user_extras(cli_runner, mock_google_user_exists, mock_google_CallGAMCommand, mock_google_add_user_to_group): mock_google_user_exists.return_value = False - args = Namespace(username="username") - res = create(args, "extra1", "extra2") + result = cli_runner.invoke(create, ["username", "extra1", "extra2"]) - assert res == RESULT_SUCCESS + assert result.exit_code == RESULT_SUCCESS mock_google_CallGAMCommand.assert_called_once() call_args = " ".join(mock_google_CallGAMCommand.call_args[0][0]) diff --git a/tests/commands/user/test_delete.py b/tests/commands/user/test_delete.py index 143402a..cbd98ba 100644 --- a/tests/commands/user/test_delete.py +++ b/tests/commands/user/test_delete.py @@ -1,7 +1,6 @@ -from argparse import Namespace import pytest -from compiler_admin import RESULT_FAILURE, RESULT_SUCCESS +from compiler_admin import RESULT_SUCCESS from compiler_admin.commands.user.delete import delete, __name__ as MODULE @@ -29,21 +28,13 @@ def mock_google_CallGAMCommand(mock_google_CallGAMCommand): return mock_google_CallGAMCommand(MODULE) -def test_delete_user_username_required(): - args = Namespace() - - with pytest.raises(ValueError, match="username is required"): - delete(args) - - @pytest.mark.usefixtures("mock_input_yes") -def test_delete_confirm_yes(mock_google_user_exists, mock_google_CallGAMCommand): +def test_delete_confirm_yes(cli_runner, mock_google_user_exists, mock_google_CallGAMCommand): mock_google_user_exists.return_value = True - args = Namespace(username="username") - res = delete(args) + result = cli_runner.invoke(delete, ["username"]) - assert res == RESULT_SUCCESS + assert result.exit_code == RESULT_SUCCESS mock_google_CallGAMCommand.assert_called_once() call_args = mock_google_CallGAMCommand.call_args.args[0] assert "delete" in call_args @@ -52,21 +43,19 @@ def test_delete_confirm_yes(mock_google_user_exists, mock_google_CallGAMCommand) @pytest.mark.usefixtures("mock_input_no") -def test_delete_confirm_no(mock_google_user_exists, mock_google_CallGAMCommand): +def test_delete_confirm_no(cli_runner, mock_google_user_exists, mock_google_CallGAMCommand): mock_google_user_exists.return_value = True - args = Namespace(username="username") - res = delete(args) + result = cli_runner.invoke(delete, ["username"]) - assert res == RESULT_SUCCESS + assert result.exit_code == RESULT_SUCCESS mock_google_CallGAMCommand.assert_not_called() -def test_delete_user_does_not_exist(mock_google_user_exists, mock_google_CallGAMCommand): +def test_delete_user_does_not_exist(cli_runner, mock_google_user_exists, mock_google_CallGAMCommand): mock_google_user_exists.return_value = False - args = Namespace(username="username") - res = delete(args) + result = cli_runner.invoke(delete, ["username"]) - assert res == RESULT_FAILURE + assert result.exit_code != RESULT_SUCCESS assert mock_google_CallGAMCommand.call_count == 0 diff --git a/tests/commands/user/test_init.py b/tests/commands/user/test_init.py new file mode 100644 index 0000000..6faabb6 --- /dev/null +++ b/tests/commands/user/test_init.py @@ -0,0 +1,8 @@ +import pytest + +from compiler_admin.commands.user import user + + +@pytest.mark.parametrize("command", ["alumni", "convert", "create", "delete", "offboard", "reset", "restore", "signout"]) +def test_user_commands(command): + assert command in user.commands diff --git a/tests/commands/user/test_offboard.py b/tests/commands/user/test_offboard.py index 5fa06b0..5ed54d6 100644 --- a/tests/commands/user/test_offboard.py +++ b/tests/commands/user/test_offboard.py @@ -1,7 +1,6 @@ -from argparse import Namespace import pytest -from compiler_admin import RESULT_FAILURE, RESULT_SUCCESS +from compiler_admin import RESULT_SUCCESS from compiler_admin.commands.user.offboard import offboard, __name__ as MODULE @@ -49,15 +48,9 @@ def mock_google_CallGYBCommand(mock_google_CallGYBCommand): return mock_google_CallGYBCommand(MODULE) -def test_offboard_user_username_required(): - args = Namespace() - - with pytest.raises(ValueError, match="username is required"): - offboard(args) - - @pytest.mark.usefixtures("mock_input_yes") def test_offboard_confirm_yes( + cli_runner, mock_google_user_exists, mock_google_CallGAMCommand, mock_google_CallGYBCommand, @@ -67,23 +60,20 @@ def test_offboard_confirm_yes( ): mock_google_user_exists.return_value = True - args = Namespace(username="username") - res = offboard(args) + result = cli_runner.invoke(offboard, ["username"]) - assert res == RESULT_SUCCESS + assert result.exit_code == RESULT_SUCCESS assert mock_google_CallGAMCommand.call_count > 0 mock_google_CallGYBCommand.assert_called_once() mock_NamedTemporaryFile.assert_called_once() - mock_commands_alumni.assert_called_once() - assert args in mock_commands_alumni.call_args.args - - mock_commands_delete.assert_called_once() - assert args in mock_commands_delete.call_args.args + mock_commands_alumni.callback.assert_called_once() + mock_commands_delete.callback.assert_called_once() @pytest.mark.usefixtures("mock_input_no") def test_offboard_confirm_no( + cli_runner, mock_google_user_exists, mock_google_CallGAMCommand, mock_google_CallGYBCommand, @@ -92,35 +82,32 @@ def test_offboard_confirm_no( ): mock_google_user_exists.return_value = True - args = Namespace(username="username") - res = offboard(args) + result = cli_runner.invoke(offboard, ["username"]) - assert res == RESULT_SUCCESS + assert result.exit_code == RESULT_SUCCESS mock_google_CallGAMCommand.assert_not_called() mock_google_CallGYBCommand.assert_not_called() - mock_commands_alumni.assert_not_called() - mock_commands_delete.assert_not_called() + mock_commands_alumni.callback.assert_not_called() + mock_commands_delete.callback.assert_not_called() -def test_offboard_user_does_not_exist(mock_google_user_exists, mock_google_CallGAMCommand): +def test_offboard_user_does_not_exist(cli_runner, mock_google_user_exists, mock_google_CallGAMCommand): mock_google_user_exists.return_value = False - args = Namespace(username="username") - res = offboard(args) + result = cli_runner.invoke(offboard, ["username"]) - assert res == RESULT_FAILURE + assert result.exit_code != RESULT_SUCCESS assert mock_google_CallGAMCommand.call_count == 0 -def test_offboard_alias_user_does_not_exist(mock_google_user_exists, mock_google_CallGAMCommand): +def test_offboard_alias_user_does_not_exist(cli_runner, mock_google_user_exists, mock_google_CallGAMCommand): # the first call returns True (the user exists), the second False (the alias user does not) # https://stackoverflow.com/a/24897297 mock_google_user_exists.side_effect = [True, False] - args = Namespace(username="username", alias="alias_username") - res = offboard(args) + result = cli_runner.invoke(offboard, ["--alias", "alias_username", "username"]) - assert res == RESULT_FAILURE + assert result.exit_code != RESULT_SUCCESS assert mock_google_user_exists.call_count == 2 assert mock_google_CallGAMCommand.call_count == 0 diff --git a/tests/commands/user/test_reset.py b/tests/commands/user/test_reset.py index b6eb98c..b09721e 100644 --- a/tests/commands/user/test_reset.py +++ b/tests/commands/user/test_reset.py @@ -1,7 +1,6 @@ -from argparse import Namespace import pytest -from compiler_admin import RESULT_FAILURE, RESULT_SUCCESS +from compiler_admin import RESULT_SUCCESS from compiler_admin.commands.user.reset import reset, __name__ as MODULE from compiler_admin.services.google import USER_HELLO @@ -35,74 +34,58 @@ def mock_google_CallGAMCommand(mock_google_CallGAMCommand): return mock_google_CallGAMCommand(MODULE) -def test_reset_user_username_required(): - args = Namespace() - - with pytest.raises(ValueError, match="username is required"): - reset(args) - - -def test_reset_user_does_not_exist(mock_google_user_exists): +def test_reset_user_does_not_exist(cli_runner, mock_google_user_exists): mock_google_user_exists.return_value = False - args = Namespace(username="username") - res = reset(args) + result = cli_runner.invoke(reset, ["username"]) - assert res == RESULT_FAILURE + assert result.exit_code != RESULT_SUCCESS @pytest.mark.usefixtures("mock_input_yes") -def test_reset_confirm_yes(mock_google_user_exists, mock_google_CallGAMCommand, mock_commands_signout): +def test_reset_confirm_yes(cli_runner, mock_google_user_exists, mock_google_CallGAMCommand, mock_commands_signout): mock_google_user_exists.return_value = True - args = Namespace(username="username", force=False) - res = reset(args) + result = cli_runner.invoke(reset, ["username"]) - assert res == RESULT_SUCCESS + assert result.exit_code == RESULT_SUCCESS mock_google_CallGAMCommand.assert_called_once() - mock_commands_signout.assert_called_once_with(args) + mock_commands_signout.callback.assert_called_once() @pytest.mark.usefixtures("mock_input_no") -def test_reset_confirm_no(mock_google_user_exists, mock_google_CallGAMCommand, mock_commands_signout): +def test_reset_confirm_no(cli_runner, mock_google_user_exists, mock_google_CallGAMCommand, mock_commands_signout): mock_google_user_exists.return_value = True - args = Namespace(username="username", force=False) - res = reset(args) + result = cli_runner.invoke(reset, ["username"]) - assert res == RESULT_SUCCESS + assert result.exit_code == RESULT_SUCCESS mock_google_CallGAMCommand.assert_not_called() - mock_commands_signout.assert_not_called() + mock_commands_signout.callback.assert_not_called() -def test_reset_user_exists(mock_google_user_exists, mock_google_CallGAMCommand, mock_commands_signout): +def test_reset_user_exists(cli_runner, mock_google_user_exists, mock_google_CallGAMCommand, mock_commands_signout): mock_google_user_exists.return_value = True - args = Namespace(username="username", force=True) - res = reset(args) - - assert res == RESULT_SUCCESS + result = cli_runner.invoke(reset, ["--force", "username"]) + assert result.exit_code == RESULT_SUCCESS mock_google_CallGAMCommand.assert_called_once() call_args = " ".join(mock_google_CallGAMCommand.call_args[0][0]) assert "update user" in call_args assert "password random changepassword" in call_args + mock_commands_signout.callback.assert_called_once() - mock_commands_signout.assert_called_once_with(args) - -def test_reset_notify(mock_google_user_exists, mock_google_CallGAMCommand, mock_commands_signout): +def test_reset_notify(cli_runner, mock_google_user_exists, mock_google_CallGAMCommand, mock_commands_signout): mock_google_user_exists.return_value = True - args = Namespace(username="username", notify="notification@example.com", force=True) - res = reset(args) - - assert res == RESULT_SUCCESS + result = cli_runner.invoke(reset, ["--force", "--notify", "notification@example.com", "username"]) + assert result.exit_code == RESULT_SUCCESS mock_google_CallGAMCommand.assert_called_once() call_args = " ".join(mock_google_CallGAMCommand.call_args[0][0]) assert "update user" in call_args assert "password random changepassword" in call_args assert f"notify notification@example.com from {USER_HELLO}" in call_args - - mock_commands_signout.assert_called_once_with(args) + mock_commands_signout.callback.assert_called_once() diff --git a/tests/commands/user/test_restore.py b/tests/commands/user/test_restore.py index 05366c4..0b8e38b 100644 --- a/tests/commands/user/test_restore.py +++ b/tests/commands/user/test_restore.py @@ -1,7 +1,6 @@ -from argparse import Namespace import pytest -from compiler_admin import RESULT_FAILURE, RESULT_SUCCESS +from compiler_admin import RESULT_SUCCESS from compiler_admin.commands.user.restore import restore, __name__ as MODULE @@ -15,28 +14,19 @@ def mock_google_CallGYBCommand(mock_google_CallGYBCommand): return mock_google_CallGYBCommand(MODULE) -def test_restore_user_username_required(): - args = Namespace() - - with pytest.raises(ValueError, match="username is required"): - restore(args) - - -def test_restore_backup_exists(mock_Path_exists, mock_google_CallGYBCommand): +def test_restore_backup_exists(cli_runner, mock_Path_exists, mock_google_CallGYBCommand): mock_Path_exists.return_value = True - args = Namespace(username="username") - res = restore(args) + result = cli_runner.invoke(restore, ["username"]) - assert res == RESULT_SUCCESS + assert result.exit_code == RESULT_SUCCESS mock_google_CallGYBCommand.assert_called_once() -def test_restore_backup_does_not_exist(mocker, mock_Path_exists, mock_google_CallGYBCommand): +def test_restore_backup_does_not_exist(cli_runner, mock_Path_exists, mock_google_CallGYBCommand): mock_Path_exists.return_value = False - args = Namespace(username="username") - res = restore(args) + result = cli_runner.invoke(restore, ["username"]) - assert res == RESULT_FAILURE + assert result.exit_code != RESULT_SUCCESS assert mock_google_CallGYBCommand.call_count == 0 diff --git a/tests/commands/user/test_signout.py b/tests/commands/user/test_signout.py index 472a1dd..078241d 100644 --- a/tests/commands/user/test_signout.py +++ b/tests/commands/user/test_signout.py @@ -1,7 +1,6 @@ -from argparse import Namespace import pytest -from compiler_admin import RESULT_FAILURE, RESULT_SUCCESS +from compiler_admin import RESULT_SUCCESS from compiler_admin.commands.user.signout import signout, __name__ as MODULE @@ -29,42 +28,32 @@ def mock_google_CallGAMCommand(mock_google_CallGAMCommand): return mock_google_CallGAMCommand(MODULE) -def test_signout_user_username_required(): - args = Namespace() - - with pytest.raises(ValueError, match="username is required"): - signout(args) - - @pytest.mark.usefixtures("mock_input_yes") -def test_signout_confirm_yes(mock_google_user_exists, mock_google_CallGAMCommand): +def test_signout_confirm_yes(cli_runner, mock_google_user_exists, mock_google_CallGAMCommand): mock_google_user_exists.return_value = True - args = Namespace(username="username") - res = signout(args) + result = cli_runner.invoke(signout, ["username"]) - assert res == RESULT_SUCCESS + assert result.exit_code == RESULT_SUCCESS mock_google_CallGAMCommand.assert_called_once() call_args = mock_google_CallGAMCommand.call_args.args[0] assert "user" in call_args and "signout" in call_args @pytest.mark.usefixtures("mock_input_no") -def test_signout_confirm_no(mock_google_user_exists, mock_google_CallGAMCommand): +def test_signout_confirm_no(cli_runner, mock_google_user_exists, mock_google_CallGAMCommand): mock_google_user_exists.return_value = True - args = Namespace(username="username") - res = signout(args) + result = cli_runner.invoke(signout, ["username"]) - assert res == RESULT_SUCCESS + assert result.exit_code == RESULT_SUCCESS mock_google_CallGAMCommand.assert_not_called() -def test_signout_user_does_not_exist(mock_google_user_exists, mock_google_CallGAMCommand): +def test_signout_user_does_not_exist(cli_runner, mock_google_user_exists, mock_google_CallGAMCommand): mock_google_user_exists.return_value = False - args = Namespace(username="username") - res = signout(args) + result = cli_runner.invoke(signout, ["username"]) - assert res == RESULT_FAILURE + assert result.exit_code != RESULT_SUCCESS mock_google_CallGAMCommand.assert_not_called() diff --git a/tests/conftest.py b/tests/conftest.py index 0625742..2457a35 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,3 +1,6 @@ +import click +from click.testing import CliRunner + import pytest from pytest_socket import disable_socket @@ -33,76 +36,44 @@ def mock_input(mock_module_name): return mock_module_name("input") -@pytest.fixture -def mock_commands_alumni(mock_module_name): - """Fixture returns a function that patches the alumni function in a given module.""" - return mock_module_name("alumni") +@click.command +def dummy_command(**kwargs): + return RESULT_SUCCESS @pytest.fixture -def mock_commands_create(mock_module_name): - """Fixture returns a function that patches the create function in a given module.""" - return mock_module_name("create") +def mock_command(mocker): + def _mock_command(command): + def __mock_command(module): + return mocker.patch(f"{module}.{command}", new=mocker.MagicMock(spec=dummy_command)) + return __mock_command -@pytest.fixture -def mock_commands_convert(mock_module_name): - """Fixture returns a function that patches the convert command function in a given module.""" - return mock_module_name("convert") + return _mock_command @pytest.fixture -def mock_commands_delete(mock_module_name): - """Fixture returns a function that patches the delete command function in a given module.""" - return mock_module_name("delete") - - -@pytest.fixture -def mock_commands_info(mock_module_name): - """Fixture returns a function that patches the info command function in a given module.""" - return mock_module_name("info") - - -@pytest.fixture -def mock_commands_init(mock_module_name): - """Fixture returns a function that patches the init command function in a given module.""" - return mock_module_name("init") +def mock_commands_alumni(mock_command): + """Fixture returns a function that patches the alumni function in a given module.""" + return mock_command("alumni") @pytest.fixture -def mock_commands_offboard(mock_module_name): - """Fixture returns a function that patches the offboard command function in a given module.""" - return mock_module_name("offboard") +def mock_commands_delete(mock_command): + """Fixture returns a function that patches the delete command function in a given module.""" + return mock_command("delete") @pytest.fixture -def mock_commands_reset(mock_module_name): +def mock_commands_reset(mock_command): """Fixture returns a function that patches the reset command function in a given module.""" - return mock_module_name("reset") + return mock_command("reset") @pytest.fixture -def mock_commands_restore(mock_module_name): - """Fixture returns a function that patches the restore command function in a given module.""" - return mock_module_name("restore") - - -@pytest.fixture -def mock_commands_signout(mock_module_name): +def mock_commands_signout(mock_command): """Fixture returns a function that patches the signout command function in a given module.""" - return mock_module_name("signout") - - -@pytest.fixture -def mock_commands_time(mock_module_name): - """Fixture returns a function that patches the time command function in a given module.""" - return mock_module_name("time") - - -@pytest.fixture -def mock_commands_user(mock_module_name): - """Fixture returns a function that patches the user command function in a given module.""" - return mock_module_name("user") + return mock_command("signout") @pytest.fixture @@ -186,6 +157,11 @@ def _mock_NamedTemporaryFile(module, readlines=[""], **kwargs): return _mock_NamedTemporaryFile +@pytest.fixture +def cli_runner(): + return CliRunner() + + @pytest.fixture def harvest_file(): return "notebooks/data/harvest-sample.csv" diff --git a/tests/test_main.py b/tests/test_main.py index fcec85f..9830eee 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,644 +1,8 @@ -from argparse import Namespace -from datetime import datetime -import subprocess -import sys - import pytest -import compiler_admin.main -from compiler_admin.main import main, prior_month_start, prior_month_end, TZINFO, __name__ as MODULE -from compiler_admin.services.google import DOMAIN - - -@pytest.fixture(autouse=True) -def reset_env(monkeypatch): - monkeypatch.delenv("HARVEST_DATA", raising=False) - monkeypatch.delenv("TOGGL_DATA", raising=False) - - -@pytest.fixture -def mock_local_now(mocker): - dt = datetime(2024, 9, 25, tzinfo=TZINFO) - mocker.patch(f"{MODULE}.local_now", return_value=dt) - return dt - - -@pytest.fixture -def mock_start(mock_local_now): - return datetime(2024, 8, 1, tzinfo=TZINFO) - - -@pytest.fixture -def mock_end(mock_local_now): - return datetime(2024, 8, 31, tzinfo=TZINFO) - - -@pytest.fixture -def mock_commands_info(mock_commands_info): - return mock_commands_info(MODULE) - - -@pytest.fixture -def mock_commands_init(mock_commands_init): - return mock_commands_init(MODULE) - - -@pytest.fixture -def mock_commands_time(mock_commands_time): - return mock_commands_time(MODULE) - - -@pytest.fixture -def mock_commands_user(mock_commands_user): - return mock_commands_user(MODULE) - - -def test_prior_month_start(mock_start): - start = prior_month_start() - - assert start == mock_start - - -def test_prior_month_end(mock_end): - end = prior_month_end() - - assert end == mock_end - - -def test_main_info(mock_commands_info): - main(argv=["info"]) - - mock_commands_info.assert_called_once() - - -def test_main_info_default(mock_commands_info): - main(argv=[]) - - mock_commands_info.assert_called_once() - - -def test_main_init_default(mock_commands_init): - main(argv=["init", "username"]) - - mock_commands_init.assert_called_once() - call_args = mock_commands_init.call_args.args - assert Namespace(func=mock_commands_init, command="init", username="username", gam=False, gyb=False) in call_args - - -def test_main_init_gam(mock_commands_init): - main(argv=["init", "username", "--gam"]) - - mock_commands_init.assert_called_once() - call_args = mock_commands_init.call_args.args - assert Namespace(func=mock_commands_init, command="init", username="username", gam=True, gyb=False) in call_args - - -def test_main_init_gyb(mock_commands_init): - main(argv=["init", "username", "--gyb"]) - - mock_commands_init.assert_called_once() - call_args = mock_commands_init.call_args.args - assert Namespace(func=mock_commands_init, command="init", username="username", gam=False, gyb=True) in call_args - - -def test_main_init_no_username(mock_commands_init): - with pytest.raises(SystemExit): - main(argv=["init"]) - assert mock_commands_init.call_count == 0 - - -def test_main_time_convert_default(mock_commands_time): - main(argv=["time", "convert"]) - - mock_commands_time.assert_called_once() - call_args = mock_commands_time.call_args.args - assert ( - Namespace( - func=mock_commands_time, - command="time", - subcommand="convert", - client=None, - input=sys.stdin, - output=sys.stdout, - from_fmt="toggl", - to_fmt="harvest", - ) - in call_args - ) - - -def test_main_time_convert_env(monkeypatch, mock_commands_time): - monkeypatch.setenv("HARVEST_DATA", "harvest") - monkeypatch.setenv("TOGGL_DATA", "toggl") - - main(argv=["time", "convert"]) - - mock_commands_time.assert_called_once() - call_args = mock_commands_time.call_args.args - assert ( - Namespace( - func=mock_commands_time, - command="time", - subcommand="convert", - client=None, - input="toggl", - output="harvest", - from_fmt="toggl", - to_fmt="harvest", - ) - in call_args - ) - - -@pytest.mark.usefixtures("mock_local_now") -def test_main_time_download_default(mock_commands_time, mock_start, mock_end): - main(argv=["time", "download"]) - - mock_commands_time.assert_called_once() - call_args = mock_commands_time.call_args.args - assert ( - Namespace( - func=mock_commands_time, - command="time", - subcommand="download", - start=mock_start, - end=mock_end, - output=sys.stdout, - billable=True, - client_ids=None, - project_ids=None, - task_ids=None, - user_ids=None, - ) - in call_args - ) - - -def test_main_time_download_args(mock_commands_time): - main( - argv=[ - "time", - "download", - "--start", - "2024-01-01", - "--end", - "2024-01-31", - "--output", - "file.csv", - "--all", - "--client", - "1", - "--client", - "2", - "--client", - "3", - "--project", - "1", - "--project", - "2", - "--project", - "3", - "--task", - "1", - "--task", - "2", - "--task", - "3", - "--user", - "1", - "--user", - "2", - "--user", - "3", - ] - ) - - expected_start = TZINFO.localize(datetime(2024, 1, 1)) - expected_end = TZINFO.localize(datetime(2024, 1, 31)) - ids = [1, 2, 3] - - mock_commands_time.assert_called_once() - call_args = mock_commands_time.call_args.args - assert ( - Namespace( - func=mock_commands_time, - command="time", - subcommand="download", - start=expected_start, - end=expected_end, - output="file.csv", - billable=False, - client_ids=ids, - project_ids=ids, - task_ids=ids, - user_ids=ids, - ) - in call_args - ) - - -def test_main_time_convert_client(mock_commands_time): - main(argv=["time", "convert", "--client", "client123"]) - - mock_commands_time.assert_called_once() - call_args = mock_commands_time.call_args.args - assert ( - Namespace( - func=mock_commands_time, - command="time", - subcommand="convert", - client="client123", - input=sys.stdin, - output=sys.stdout, - from_fmt="toggl", - to_fmt="harvest", - ) - in call_args - ) - - -def test_main_time_convert_input(mock_commands_time): - main(argv=["time", "convert", "--input", "file.csv"]) - - mock_commands_time.assert_called_once() - call_args = mock_commands_time.call_args.args - assert ( - Namespace( - func=mock_commands_time, - command="time", - subcommand="convert", - client=None, - input="file.csv", - output=sys.stdout, - from_fmt="toggl", - to_fmt="harvest", - ) - in call_args - ) - - -def test_main_time_convert_output(mock_commands_time): - main(argv=["time", "convert", "--output", "file.csv"]) - - mock_commands_time.assert_called_once() - call_args = mock_commands_time.call_args.args - assert ( - Namespace( - func=mock_commands_time, - command="time", - subcommand="convert", - client=None, - input=sys.stdin, - output="file.csv", - from_fmt="toggl", - to_fmt="harvest", - ) - in call_args - ) - - -def test_main_time_convert_from(mock_commands_time): - main(argv=["time", "convert", "--from", "harvest"]) - - mock_commands_time.assert_called_once() - call_args = mock_commands_time.call_args.args - assert ( - Namespace( - func=mock_commands_time, - command="time", - subcommand="convert", - client=None, - input=sys.stdin, - output=sys.stdout, - from_fmt="harvest", - to_fmt="harvest", - ) - in call_args - ) - - with pytest.raises(SystemExit): - main(argv=["time", "convert", "--from", "nope"]) - # it should not have been called an additional time from the first - mock_commands_time.assert_called_once() - - -def test_main_time_convert_to(mock_commands_time): - main(argv=["time", "convert", "--to", "toggl"]) - - mock_commands_time.assert_called_once() - call_args = mock_commands_time.call_args.args - assert ( - Namespace( - func=mock_commands_time, - command="time", - subcommand="convert", - client=None, - input=sys.stdin, - output=sys.stdout, - from_fmt="toggl", - to_fmt="toggl", - ) - in call_args - ) - - with pytest.raises(SystemExit): - main(argv=["time", "convert", "--to", "nope"]) - # it should not have been called an additional time from the first - mock_commands_time.assert_called_once() - - -def test_main_user_alumni(mock_commands_user): - main(argv=["user", "alumni", "username"]) - - mock_commands_user.assert_called_once() - call_args = mock_commands_user.call_args.args - assert ( - Namespace(func=mock_commands_user, command="user", subcommand="alumni", username="username", force=False, notify=None) - in call_args - ) - - -def test_main_user_alumni_notify(mock_commands_user): - main(argv=["user", "alumni", "username", "--notify", "notification"]) - - mock_commands_user.assert_called_once() - call_args = mock_commands_user.call_args.args - assert ( - Namespace( - func=mock_commands_user, - command="user", - subcommand="alumni", - username="username", - force=False, - notify="notification", - ) - in call_args - ) - - -def test_main_user_alumni_force(mock_commands_user): - main(argv=["user", "alumni", "username", "--force"]) - - mock_commands_user.assert_called_once() - call_args = mock_commands_user.call_args.args - assert ( - Namespace( - func=mock_commands_user, - command="user", - subcommand="alumni", - username="username", - force=True, - notify=None, - ) - in call_args - ) - - -def test_main_user_create(mock_commands_user): - main(argv=["user", "create", "username"]) - - mock_commands_user.assert_called_once() - call_args = mock_commands_user.call_args.args - assert ( - Namespace(func=mock_commands_user, command="user", subcommand="create", username="username", notify=None) in call_args - ) - - -def test_main_user_create_notify(mock_commands_user): - main(argv=["user", "create", "username", "--notify", "notification"]) - - mock_commands_user.assert_called_once() - call_args = mock_commands_user.call_args.args - assert ( - Namespace(func=mock_commands_user, command="user", subcommand="create", username="username", notify="notification") - in call_args - ) - - -def test_main_user_create_extras(mock_commands_user): - main(argv=["user", "create", "username", "extra1", "extra2"]) - - mock_commands_user.assert_called_once() - call_args = mock_commands_user.call_args.args - assert ( - Namespace(func=mock_commands_user, command="user", subcommand="create", username="username", notify=None) in call_args - ) - assert "extra1" in call_args - assert "extra2" in call_args - - -def test_main_user_create_no_username(mock_commands_user): - with pytest.raises(SystemExit): - main(argv=["user", "create"]) - assert mock_commands_user.call_count == 0 - - -def test_main_user_convert(mock_commands_user): - main(argv=["user", "convert", "username", "contractor"]) - - mock_commands_user.assert_called_once() - call_args = mock_commands_user.call_args.args - assert ( - Namespace( - func=mock_commands_user, - command="user", - subcommand="convert", - username="username", - account_type="contractor", - force=False, - notify=None, - ) - in call_args - ) - - -def test_main_user_convert_no_username(mock_commands_user): - with pytest.raises(SystemExit): - main(argv=["user", "convert"]) - assert mock_commands_user.call_count == 0 - - -def test_main_user_convert_bad_account_type(mock_commands_user): - with pytest.raises(SystemExit): - main(argv=["user", "convert", "username", "account_type"]) - assert mock_commands_user.call_count == 0 - - -def test_main_user_delete(mock_commands_user): - main(argv=["user", "delete", "username"]) - - mock_commands_user.assert_called_once() - call_args = mock_commands_user.call_args.args - assert ( - Namespace(func=mock_commands_user, command="user", subcommand="delete", username="username", force=False) in call_args - ) - - -def test_main_user_delete_force(mock_commands_user): - main(argv=["user", "delete", "username", "--force"]) - - mock_commands_user.assert_called_once() - call_args = mock_commands_user.call_args.args - assert ( - Namespace(func=mock_commands_user, command="user", subcommand="delete", username="username", force=True) in call_args - ) - - -def test_main_user_delete_no_username(mock_commands_user): - with pytest.raises(SystemExit): - main(argv=["user", "delete"]) - assert mock_commands_user.call_count == 0 - - -def test_main_user_offboard(mock_commands_user): - main(argv=["user", "offboard", "username"]) - - mock_commands_user.assert_called_once() - call_args = mock_commands_user.call_args.args - assert ( - Namespace(func=mock_commands_user, command="user", subcommand="offboard", username="username", alias=None, force=False) - in call_args - ) - - -def test_main_user_offboard_force(mock_commands_user): - main(argv=["user", "offboard", "username", "--force"]) - - mock_commands_user.assert_called_once() - call_args = mock_commands_user.call_args.args - assert ( - Namespace(func=mock_commands_user, command="user", subcommand="offboard", username="username", alias=None, force=True) - in call_args - ) - - -def test_main_user_offboard_with_alias(mock_commands_user): - main(argv=["user", "offboard", "username", "--alias", "anotheruser"]) - - mock_commands_user.assert_called_once() - call_args = mock_commands_user.call_args.args - assert ( - Namespace( - func=mock_commands_user, - command="user", - subcommand="offboard", - username="username", - alias="anotheruser", - force=False, - ) - in call_args - ) - - -def test_main_user_offboard_no_username(mock_commands_user): - with pytest.raises(SystemExit): - main(argv=["user", "offboard"]) - assert mock_commands_user.call_count == 0 - - -def test_main_user_reset(mock_commands_user): - main(argv=["user", "reset", "username"]) - - mock_commands_user.assert_called_once() - call_args = mock_commands_user.call_args.args - assert ( - Namespace(func=mock_commands_user, command="user", subcommand="reset", username="username", force=False, notify=None) - in call_args - ) - - -def test_main_user_reset_notify(mock_commands_user): - main(argv=["user", "reset", "username", "--notify", "notification"]) - - mock_commands_user.assert_called_once() - call_args = mock_commands_user.call_args.args - assert ( - Namespace( - func=mock_commands_user, - command="user", - subcommand="reset", - username="username", - notify="notification", - force=False, - ) - in call_args - ) - - -def test_main_user_reset_force(mock_commands_user): - main(argv=["user", "reset", "username", "--force"]) - - mock_commands_user.assert_called_once() - call_args = mock_commands_user.call_args.args - assert ( - Namespace(func=mock_commands_user, command="user", subcommand="reset", username="username", force=True, notify=None) - in call_args - ) - - -def test_main_user_reset_no_username(mock_commands_user): - with pytest.raises(SystemExit): - main(argv=["user", "reset"]) - assert mock_commands_user.call_count == 0 - - -def test_main_user_restore(mock_commands_user): - main(argv=["user", "restore", "username"]) - - mock_commands_user.assert_called_once() - call_args = mock_commands_user.call_args.args - assert Namespace(func=mock_commands_user, command="user", subcommand="restore", username="username") in call_args - - -def test_main_user_restore_no_username(mock_commands_user): - with pytest.raises(SystemExit): - main(argv=["user", "restore"]) - assert mock_commands_user.call_count == 0 - - -def test_main_user_signout(mock_commands_user): - main(argv=["user", "signout", "username"]) - - mock_commands_user.assert_called_once() - call_args = mock_commands_user.call_args.args - assert ( - Namespace(func=mock_commands_user, command="user", subcommand="signout", username="username", force=False) in call_args - ) - - -def test_main_user_signout_force(mock_commands_user): - main(argv=["user", "signout", "username", "--force"]) - - mock_commands_user.assert_called_once() - call_args = mock_commands_user.call_args.args - assert ( - Namespace(func=mock_commands_user, command="user", subcommand="signout", username="username", force=True) in call_args - ) - - -def test_main_user_signout_no_username(mock_commands_user): - with pytest.raises(SystemExit): - main(argv=["user", "signout"]) - assert mock_commands_user.call_count == 0 - - -@pytest.mark.e2e -def test_main_e2e(mocker): - spy_info = mocker.spy(compiler_admin.main, "info") - res = main(argv=[]) - - assert res == 0 - spy_info.assert_called_once() - +from compiler_admin.main import main -@pytest.mark.e2e -def test_run_compiler_admin(capfd): - # call CLI command as a subprocess - res = subprocess.call(["compiler-admin"]) - captured = capfd.readouterr() - assert res == 0 - assert "compiler-admin:" in captured.out - assert "GAMADV-XTD3" in captured.out - assert f"Primary Domain: {DOMAIN}" in captured.out - assert "WARNING: Config File:" not in captured.err +@pytest.mark.parametrize("command", ["info", "init", "time", "user"]) +def test_main_commands(command): + assert command in main.commands