From 8e1ccdc0408a0b63cb5cdec9440252419149b4bb Mon Sep 17 00:00:00 2001 From: Michael Milton Date: Tue, 20 Aug 2024 18:03:27 +1000 Subject: [PATCH 1/8] Set up logging CLI arguments --- filesender/main.py | 32 +++++++++++++++++++------------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/filesender/main.py b/filesender/main.py index be67ad0..056edda 100644 --- a/filesender/main.py +++ b/filesender/main.py @@ -1,4 +1,5 @@ from __future__ import annotations +import logging from typing import Any, List, Optional, Callable, Coroutine, Dict from typing_extensions import Annotated, ParamSpec, TypeVar from filesender.api import FileSenderClient @@ -10,6 +11,10 @@ from functools import wraps from asyncio import run from importlib.metadata import version +from rich.logging import RichHandler +from filesender.log import LogParam, LogLevel + +logger = logging.getLogger(__name__) from filesender.response_types import Guest, Transfer @@ -46,11 +51,18 @@ def common_args( context: Context, version: Annotated[ Optional[bool], Option("--version", callback=version_callback) - ] = None + ] = None, + log_level: Annotated[ + LogLevel, Option("--log-level", click_type=LogParam(), help="Logging verbosity") + ] = LogLevel.WARNING ): context.obj = { "base_url": base_url } + logging.basicConfig( + level=log_level.value, format= "%(message)s", datefmt="[%X]", handlers=[RichHandler()] + ) + @app.command(context_settings=context) def invite( @@ -58,7 +70,6 @@ def invite( apikey: Annotated[str, Option(help="Your API token. This is the token of the person doing the inviting, not the person being invited.")], recipient: Annotated[str, Argument(help="The email address of the person to invite")], context: Context, - verbose: Verbose = False, # Although these parameters are exact duplicates of those in GuestOptions, # typer doesn't support re-using argument lists: https://github.com/tiangolo/typer/discussions/665 one_time: Annotated[bool, Option(help="If true, this voucher is only valid for one use, otherwise it can be re-used.")] = True, @@ -99,9 +110,8 @@ def invite( } } })) - if verbose: - print(result) - print("Invitation successfully sent") + logger.info(result) + logger.info("Invitation successfully sent") @app.command(context_settings=context) @typer_async @@ -113,7 +123,6 @@ async def upload_voucher( concurrent_files: ConcurrentFiles = None, concurrent_chunks: ConcurrentChunks = None, chunk_size: ChunkSize = None, - verbose: Verbose = False ): """ Uploads files to a voucher that you have been invited to @@ -129,9 +138,8 @@ async def upload_voucher( await auth.prepare(client.http_client) await client.prepare() result: Transfer = await client.upload_workflow(files, {"from": email, "recipients": []}) - if verbose: - print(result) - print("Upload completed successfully") + logger.info(result) + logger.info("Upload completed successfully") @app.command(context_settings=context) @typer_async @@ -141,7 +149,6 @@ async def upload( files: UploadFiles, recipients: Annotated[List[str], Option(show_default=False, help="One or more email addresses to send the files")], context: Context, - verbose: Verbose = False, concurrent_files: ConcurrentFiles = None, concurrent_chunks: ConcurrentChunks = None, chunk_size: ChunkSize = None, @@ -163,9 +170,8 @@ async def upload( ) await client.prepare() result: Transfer = await client.upload_workflow(files, {"recipients": recipients, "from": username}) - if verbose: - print(result) - print("Upload completed successfully") + logger.info(result) + logger.info("Upload completed successfully") @app.command(context_settings=context) def download( From 100541a6d5a50b429a2f3d0ee05bc3037e99fed4 Mon Sep 17 00:00:00 2001 From: Michael Milton Date: Wed, 21 Aug 2024 18:07:07 +1000 Subject: [PATCH 2/8] Logging module and download module; download progress bar --- filesender/api.py | 93 +++++++++++++++++++++++++----------------- filesender/download.py | 50 +++++++++++++++++++++++ filesender/log.py | 28 +++++++++++++ filesender/main.py | 34 +++++++-------- 4 files changed, 150 insertions(+), 55 deletions(-) create mode 100644 filesender/download.py create mode 100644 filesender/log.py diff --git a/filesender/api.py b/filesender/api.py index bd04701..5990374 100644 --- a/filesender/api.py +++ b/filesender/api.py @@ -1,5 +1,5 @@ from typing import Any, Iterable, List, Optional, Tuple, AsyncIterator, Set -from bs4 import BeautifulSoup +from filesender.download import files_from_page, DownloadFile import filesender.response_types as response import filesender.request_types as request from urllib.parse import urlparse, urlunparse, unquote @@ -10,7 +10,7 @@ import aiofiles from aiostream import stream from contextlib import contextmanager -from tenacity import retry, stop_after_attempt, wait_fixed, retry_if_exception +from tenacity import RetryCallState, retry, stop_after_attempt, wait_fixed, retry_if_exception import logging from tqdm.asyncio import tqdm @@ -25,9 +25,13 @@ def should_retry(e: BaseException) -> bool: # Seems to be just a bug in the backend # https://github.com/encode/httpx/discussions/2941 return True - elif isinstance(e, HTTPStatusError) and e.response.status_code == 500 and e.response.json()["message"] == "auth_remote_too_late": + elif isinstance(e, HTTPStatusError) and e.response.status_code == 500: + message = e.response.json()["message"] + if message == "auth_remote_too_late": + return True + if message == "auth_remote_signature_check_failed": + return True # These errors are caused by lag between creating the response and it being received - return True return False @@ -40,6 +44,13 @@ def url_without_scheme(url: str) -> str: """ return unquote(urlunparse(urlparse(url)._replace(scheme="")).lstrip("/")) +def exception_to_message(e: BaseException) -> str: + if isinstance(e, HTTPStatusError): + return f"Request failed with content {e.response.text} for request {e.request.method} {e.request.url}." + elif isinstance(e, RequestError): + return f"Request failed for request {e.request.method} {e.request.url}. {repr(e)}" + else: + return repr(e) @contextmanager def raise_status(): @@ -49,16 +60,8 @@ def raise_status(): """ try: yield - except HTTPStatusError as e: - raise Exception( - f"Request failed with content {e.response.text} for request {e.request.method} {e.request.url}" - ) from e - except RequestError as e: - # TODO: check for SSL read error - raise Exception( - f"Request failed for request {e.request.method} {e.request.url}" - ) from e - + except BaseException as e: + raise Exception(exception_to_message(e)) from e async def yield_chunks(path: Path, chunk_size: int) -> AsyncIterator[Tuple[bytes, int]]: """ @@ -166,11 +169,20 @@ async def _sign_send(self, request: Request) -> Any: with raise_status(): return await self._sign_send_inner(request) + @staticmethod + def on_retry(state: RetryCallState) -> None: + message = str(state.outcome) + if state.outcome is not None and state.outcome.failed: + e = state.outcome.exception() + message = exception_to_message(e) + + logger.warn(f"Attempt {state.attempt_number}. {message}") + @retry( retry=retry_if_exception(should_retry), wait=wait_fixed(0.1), stop=stop_after_attempt(5), - before_sleep=lambda x: logger.warn(f"Attempt {x.attempt_number}.{x.outcome}") + before_sleep=on_retry ) async def _sign_send_inner(self, request: Request) -> Any: # Needs to be a separate function to handle retry policy correctly @@ -313,19 +325,14 @@ async def create_guest(self, body: request.Guest) -> response.Guest: self.http_client.build_request("POST", f"{self.base_url}/guest", json=body) ) - async def _files_from_token(self, token: str) -> Set[int]: + async def _files_from_token(self, token: str) -> Iterable[DownloadFile]: """ Internal function that returns a list of file IDs for a given guest token """ download_page = await self.http_client.get( "https://filesender.aarnet.edu.au", params={"s": "download", "token": token} ) - files: Set[int] = set() - for file in BeautifulSoup(download_page.content, "html.parser").find_all( - class_="file" - ): - files.add(int(file.attrs["data-id"])) - return files + return files_from_page(download_page.content) async def download_files( self, @@ -342,12 +349,12 @@ async def download_files( out_dir: The path to write the downloaded files. """ - file_ids = await self._files_from_token(token) + file_meta = await self._files_from_token(token) - async def _download_args() -> AsyncIterator[Tuple[str, Any, Path]]: + async def _download_args() -> AsyncIterator[Tuple[str, Any, Path, int, str]]: "Yields tuples of arguments to pass to download_file" - for file_id in file_ids: - yield token, file_id, out_dir + for file in file_meta: + yield token, file["id"], out_dir, file["size"], file["name"] # Each file is downloaded in parallel # Pyright messes this up @@ -358,8 +365,8 @@ async def download_file( token: str, file_id: int, out_dir: Path, - key: Optional[bytes] = None, - algorithm: Optional[str] = None, + file_size: int | float = float("inf"), + file_name: Optional[str] = None ) -> None: """ Downloads a single file. @@ -368,6 +375,8 @@ async def download_file( token: Obtained from the transfer email. The same as [`GuestAuth`][filesender.GuestAuth]'s `guest_token`. file_id: A single file ID indicating the file to be downloaded. out_dir: The path to write the downloaded file. + file_size: The file size in bytes, optionally + file_name: The file name of the file being downloaded. This will impact the name by which it's saved. """ download_endpoint = urlunparse( urlparse(self.base_url)._replace(path="/download.php") @@ -375,16 +384,24 @@ async def download_file( async with self.http_client.stream( "GET", download_endpoint, params={"files_ids": file_id, "token": token} ) as res: - for content_param in res.headers["Content-Disposition"].split(";"): - if "filename" in content_param: - filename = content_param.split("=")[1].lstrip('"').rstrip('"') - break - else: - raise Exception("No filename found") - - async with aiofiles.open(out_dir / filename, "wb") as fp: - async for chunk in res.aiter_raw(chunk_size=8192): - await fp.write(chunk) + # Determine filename from response, if not provided + if file_name is None: + for content_param in res.headers["Content-Disposition"].split(";"): + if "filename" in content_param: + file_name = content_param.split("=")[1].lstrip('"').rstrip('"') + break + else: + raise Exception("No filename found") + + chunk_size = 8192 + chunk_size_mb = chunk_size / 1024 / 1024 + with tqdm(desc=file_name, unit="MB", total=int(file_size / 1024 / 1024)) as progress: + async with aiofiles.open(out_dir / file_name, "wb") as fp: + # We can't add the total here, because we don't know it: + # https://github.com/filesender/filesender/issues/1555 + async for chunk in res.aiter_raw(chunk_size=chunk_size): + await fp.write(chunk) + progress.update(chunk_size_mb) async def get_server_info(self) -> response.ServerInfo: """ diff --git a/filesender/download.py b/filesender/download.py new file mode 100644 index 0000000..aed9b93 --- /dev/null +++ b/filesender/download.py @@ -0,0 +1,50 @@ +from typing import Iterable, TypedDict + +from bs4 import BeautifulSoup + + +class DownloadFile(TypedDict): + client_entropy: str + encrypted: str + encrypted_size: int + fileaead: str + fileiv: str + id: int + key_salt: str + key_version: int + mime: str + #: filename + name: str + password_encoding: str + password_hash_iterations: int + password_version: int + size: int + transferid: int + +def files_from_page(content: bytes) -> Iterable[DownloadFile]: + """ + Yields dictionaries describing the files listed on a FileSender web page + + Params: + content: The HTML content of the FileSender download page + """ + for file in BeautifulSoup(content, "html.parser").find_all( + class_="file" + ): + yield { + "client_entropy": file.attrs[f"data-client-entropy"], + "encrypted": file.attrs["data-encrypted"], + "encrypted_size": int(file.attrs["data-encrypted-size"]), + "fileaead": file.attrs["data-fileaead"], + "fileiv": file.attrs["data-fileiv"], + "id": int(file.attrs["data-id"]), + "key_salt": file.attrs["data-key-salt"], + "key_version": int(file.attrs["data-key-version"]), + "mime": file.attrs["data-mime"], + "name": file.attrs["data-name"], + "password_encoding": file.attrs["data-password-encoding"], + "password_hash_iterations": int(file.attrs["data-password-hash-iterations"]), + "password_version": int(file.attrs["data-password-version"]), + "size": int(file.attrs["data-size"]), + "transferid": int(file.attrs["data-transferid"]), + } diff --git a/filesender/log.py b/filesender/log.py new file mode 100644 index 0000000..8047ee4 --- /dev/null +++ b/filesender/log.py @@ -0,0 +1,28 @@ +from click import ParamType, Context, Parameter +from enum import Enum + +class LogLevel(Enum): + NOTSET = 0 + DEBUG = 10 + VERBOSE = 15 + INFO = 20 + WARNING = 30 + ERROR = 40 + CRITICAL = 50 + +class LogParam(ParamType): + name = "LogParam" + + def convert(self, value: int | str, param: Parameter | None, ctx: Context | None) -> int: + if isinstance(value, int): + return value + + # Convert string representation to int + if not hasattr(LogLevel, value): + self.fail(f"{value!r} is not a valid log level", param, ctx) + + return LogLevel[value].value + + def get_metavar(self, param: Parameter) -> str | None: + # Print out the choices + return "|".join(LogLevel._member_map_) diff --git a/filesender/main.py b/filesender/main.py index 056edda..3752f2a 100644 --- a/filesender/main.py +++ b/filesender/main.py @@ -47,20 +47,20 @@ def version_callback(value: bool): @app.callback(context_settings=context) def common_args( - base_url: Annotated[str, Option(help="The URL of the FileSender REST API")], context: Context, + base_url: Annotated[str, Option(help="The URL of the FileSender REST API")], + log_level: Annotated[ + int, Option(click_type=LogParam(), help="Logging verbosity", ) + ] = LogLevel.INFO.value, version: Annotated[ Optional[bool], Option("--version", callback=version_callback) - ] = None, - log_level: Annotated[ - LogLevel, Option("--log-level", click_type=LogParam(), help="Logging verbosity") - ] = LogLevel.WARNING + ] = None ): context.obj = { "base_url": base_url } logging.basicConfig( - level=log_level.value, format= "%(message)s", datefmt="[%X]", handlers=[RichHandler()] + level=log_level, format= "%(message)s", datefmt="[%X]", handlers=[RichHandler()] ) @@ -110,7 +110,7 @@ def invite( } } })) - logger.info(result) + logger.log(LogLevel.VERBOSE.value, result) logger.info("Invitation successfully sent") @app.command(context_settings=context) @@ -120,8 +120,8 @@ async def upload_voucher( guest_token: Annotated[str, Option(help="The guest token. This is the part of the upload URL after 'vid='")], email: Annotated[str, Option(help="The email address that was invited to upload files")], context: Context, - concurrent_files: ConcurrentFiles = None, - concurrent_chunks: ConcurrentChunks = None, + concurrent_files: ConcurrentFiles = 1, + concurrent_chunks: ConcurrentChunks = 2, chunk_size: ChunkSize = None, ): """ @@ -138,8 +138,8 @@ async def upload_voucher( await auth.prepare(client.http_client) await client.prepare() result: Transfer = await client.upload_workflow(files, {"from": email, "recipients": []}) - logger.info(result) - logger.info("Upload completed successfully") + logger.log(LogLevel.VERBOSE.value, result) + logger.log(LogLevel.INFO.value, "Upload completed successfully") @app.command(context_settings=context) @typer_async @@ -149,8 +149,8 @@ async def upload( files: UploadFiles, recipients: Annotated[List[str], Option(show_default=False, help="One or more email addresses to send the files")], context: Context, - concurrent_files: ConcurrentFiles = None, - concurrent_chunks: ConcurrentChunks = None, + concurrent_files: ConcurrentFiles = 1, + concurrent_chunks: ConcurrentChunks = 2, chunk_size: ChunkSize = None, delay: Delay = 0 ): @@ -170,8 +170,8 @@ async def upload( ) await client.prepare() result: Transfer = await client.upload_workflow(files, {"recipients": recipients, "from": username}) - logger.info(result) - logger.info("Upload completed successfully") + logger.log(LogLevel.VERBOSE.value, result) + logger.log(LogLevel.INFO.value, "Upload completed successfully") @app.command(context_settings=context) def download( @@ -188,7 +188,7 @@ def download( token=token, out_dir=out_dir )) - print(f"Download completed successfully. Files can be found in {out_dir}") + logger.log(LogLevel.INFO.value, f"Download completed successfully. Files can be found in {out_dir}") @app.command(context_settings=context) @typer_async @@ -198,7 +198,7 @@ async def server_info( """Prints out information about the FileSender server you are interfacing with""" client = FileSenderClient(base_url=context.obj["base_url"]) result = await client.get_server_info() - print(result) + logger.log(LogLevel.INFO.value, result) if __name__ == "__main__": app() From 9d911989e126734e809d795123d874e93a3c9eb4 Mon Sep 17 00:00:00 2001 From: Michael Milton Date: Wed, 21 Aug 2024 18:12:20 +1000 Subject: [PATCH 3/8] Type checking --- filesender/api.py | 7 ++++--- filesender/log.py | 3 ++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/filesender/api.py b/filesender/api.py index 5990374..f640bcc 100644 --- a/filesender/api.py +++ b/filesender/api.py @@ -1,4 +1,4 @@ -from typing import Any, Iterable, List, Optional, Tuple, AsyncIterator, Set +from typing import Any, Iterable, List, Optional, Tuple, AsyncIterator from filesender.download import files_from_page, DownloadFile import filesender.response_types as response import filesender.request_types as request @@ -172,9 +172,10 @@ async def _sign_send(self, request: Request) -> Any: @staticmethod def on_retry(state: RetryCallState) -> None: message = str(state.outcome) - if state.outcome is not None and state.outcome.failed: + if state.outcome is not None: e = state.outcome.exception() - message = exception_to_message(e) + if e is not None: + message = exception_to_message(e) logger.warn(f"Attempt {state.attempt_number}. {message}") diff --git a/filesender/log.py b/filesender/log.py index 8047ee4..589896e 100644 --- a/filesender/log.py +++ b/filesender/log.py @@ -1,3 +1,4 @@ +from typing import Union from click import ParamType, Context, Parameter from enum import Enum @@ -13,7 +14,7 @@ class LogLevel(Enum): class LogParam(ParamType): name = "LogParam" - def convert(self, value: int | str, param: Parameter | None, ctx: Context | None) -> int: + def convert(self, value: Union[int, str], param: Union[Parameter, None], ctx: Union[Context, None]) -> int: if isinstance(value, int): return value From ccd52b7acb8b8b189ba538fb156099d7b6931a8c Mon Sep 17 00:00:00 2001 From: Michael Milton Date: Wed, 21 Aug 2024 18:15:02 +1000 Subject: [PATCH 4/8] More type checking --- filesender/api.py | 4 ++-- filesender/log.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/filesender/api.py b/filesender/api.py index f640bcc..9af421d 100644 --- a/filesender/api.py +++ b/filesender/api.py @@ -1,4 +1,4 @@ -from typing import Any, Iterable, List, Optional, Tuple, AsyncIterator +from typing import Any, Iterable, List, Optional, Tuple, AsyncIterator, Union from filesender.download import files_from_page, DownloadFile import filesender.response_types as response import filesender.request_types as request @@ -366,7 +366,7 @@ async def download_file( token: str, file_id: int, out_dir: Path, - file_size: int | float = float("inf"), + file_size: Union[int, float] = float("inf"), file_name: Optional[str] = None ) -> None: """ diff --git a/filesender/log.py b/filesender/log.py index 589896e..e802529 100644 --- a/filesender/log.py +++ b/filesender/log.py @@ -24,6 +24,6 @@ def convert(self, value: Union[int, str], param: Union[Parameter, None], ctx: Un return LogLevel[value].value - def get_metavar(self, param: Parameter) -> str | None: + def get_metavar(self, param: Parameter) -> Union[str, None]: # Print out the choices return "|".join(LogLevel._member_map_) From ce071c899626624da33edbe0412b5535ff100e6e Mon Sep 17 00:00:00 2001 From: Michael Milton Date: Tue, 3 Sep 2024 18:02:32 +1000 Subject: [PATCH 5/8] Update changelog and package version --- docs/changelog.md | 11 +++++++++++ pyproject.toml | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/docs/changelog.md b/docs/changelog.md index e4a31ad..b2bd574 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,5 +1,16 @@ # Changelog +## Version 2.1.0 + +### Added + +* A progress bar for file downloads + +### Changed + +* All terminal output is now through the `logging` module. You can use the new `--log-level` CLI parameter to configure the amount of info that is printed out. +* Update the CLI default concurrency to 2 for chunks and 1 for files. This seems to be moderately performant without ever failing + ## Version 2.0.0 ### Added diff --git a/pyproject.toml b/pyproject.toml index 7c15c92..f38d3e0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "filesender-client" description = "FileSender Python CLI and API client" -version = "2.0.0" +version = "2.1.0" readme = "README.md" requires-python = ">=3.8" keywords = ["one", "two"] From add5f2718e47ef7760f8764a2ff76eaf8fc803c6 Mon Sep 17 00:00:00 2001 From: Michael Milton Date: Wed, 4 Sep 2024 14:23:40 +1000 Subject: [PATCH 6/8] Add verbose log level name --- filesender/main.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/filesender/main.py b/filesender/main.py index 3752f2a..a6699ab 100644 --- a/filesender/main.py +++ b/filesender/main.py @@ -60,8 +60,12 @@ def common_args( "base_url": base_url } logging.basicConfig( - level=log_level, format= "%(message)s", datefmt="[%X]", handlers=[RichHandler()] + level=log_level, + format= "%(message)s", + datefmt="[%X]", + handlers=[RichHandler()] ) + logging.addLevelName(LogLevel.VERBOSE.value, 'VERBOSE') @app.command(context_settings=context) From e47ccfcc62465ba3b3406df6b2a30f41c667fa28 Mon Sep 17 00:00:00 2001 From: Michael Milton Date: Wed, 4 Sep 2024 18:01:16 +1000 Subject: [PATCH 7/8] Fix for unknown file size, fix for nested output files --- filesender/api.py | 8 +++++--- test/test_client.py | 11 ++++++++--- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/filesender/api.py b/filesender/api.py index 9af421d..ca0bbb3 100644 --- a/filesender/api.py +++ b/filesender/api.py @@ -366,7 +366,7 @@ async def download_file( token: str, file_id: int, out_dir: Path, - file_size: Union[int, float] = float("inf"), + file_size: Union[int, float, None] = None, file_name: Optional[str] = None ) -> None: """ @@ -376,7 +376,7 @@ async def download_file( token: Obtained from the transfer email. The same as [`GuestAuth`][filesender.GuestAuth]'s `guest_token`. file_id: A single file ID indicating the file to be downloaded. out_dir: The path to write the downloaded file. - file_size: The file size in bytes, optionally + file_size: The file size in bytes, optionally. file_name: The file name of the file being downloaded. This will impact the name by which it's saved. """ download_endpoint = urlunparse( @@ -394,9 +394,11 @@ async def download_file( else: raise Exception("No filename found") + file_path = out_dir / file_name + file_path.parent.mkdir(parents=True, exist_ok=True) chunk_size = 8192 chunk_size_mb = chunk_size / 1024 / 1024 - with tqdm(desc=file_name, unit="MB", total=int(file_size / 1024 / 1024)) as progress: + with tqdm(desc=file_name, unit="MB", total=None if file_size is None else int(file_size / 1024 / 1024)) as progress: async with aiofiles.open(out_dir / file_name, "wb") as fp: # We can't add the total here, because we don't know it: # https://github.com/filesender/filesender/issues/1555 diff --git a/test/test_client.py b/test/test_client.py index cca61dd..4d36d0f 100644 --- a/test/test_client.py +++ b/test/test_client.py @@ -6,6 +6,11 @@ from filesender.request_types import GuestOptions from filesender.benchmark import make_tempfile, make_tempfiles, benchmark +def count_files_recursively(path: Path) -> int: + """ + Returns a recursive count of the number of files within a directory. Subdirectories are not counted. + """ + return sum([1 if child.is_file() else 0 for child in path.rglob("*")]) @pytest.mark.asyncio async def test_round_trip(base_url: str, username: str, apikey: str, recipient: str): @@ -34,7 +39,7 @@ async def test_round_trip(base_url: str, username: str, apikey: str, recipient: file_id=transfer["files"][0]["id"], out_dir=Path(download_dir), ) - assert len(list(Path(download_dir).iterdir())) == 1 + assert count_files_recursively(Path(download_dir)) == 1 @pytest.mark.asyncio @@ -62,7 +67,7 @@ async def test_round_trip_dir(base_url: str, username: str, apikey: str, recipie token=transfer["recipients"][0]["token"], out_dir=Path(download_dir), ) - assert len(list(Path(download_dir).iterdir())) == 2 + assert count_files_recursively(Path(download_dir)) == 2 @pytest.mark.asyncio @@ -113,7 +118,7 @@ async def test_voucher_round_trip( file_id=transfer["files"][0]["id"], out_dir=Path(download_dir), ) - assert len(list(Path(download_dir).iterdir())) == 1 + assert count_files_recursively(Path(download_dir)) == 1 @pytest.mark.asyncio From 86a0d03997d9df7566ec66b88b0bd49ed8b7574d Mon Sep 17 00:00:00 2001 From: Michael Milton Date: Thu, 5 Sep 2024 18:03:39 +1000 Subject: [PATCH 8/8] Use FEEDBACK custom log level for the standard logs, and set that as the default --- filesender/log.py | 17 +++++++++++++++++ filesender/main.py | 23 ++++++++++++----------- 2 files changed, 29 insertions(+), 11 deletions(-) diff --git a/filesender/log.py b/filesender/log.py index e802529..ce0e2f5 100644 --- a/filesender/log.py +++ b/filesender/log.py @@ -1,16 +1,33 @@ from typing import Union from click import ParamType, Context, Parameter from enum import Enum +import logging class LogLevel(Enum): NOTSET = 0 DEBUG = 10 + #: Used for verbose logging that the average user wouldn't want VERBOSE = 15 INFO = 20 + #: Used for basic feedback that a CLI user would expect + FEEDBACK = 25 WARNING = 30 ERROR = 40 CRITICAL = 50 + def configure_label(self): + """ + Configures the logging module to understand this log level + """ + logging.addLevelName(self.value, self.name) + +def configure_extra_levels(): + """ + Configures the logging module to understand the additional log levels + """ + for level in (LogLevel.VERBOSE, LogLevel.FEEDBACK): + level.configure_label() + class LogParam(ParamType): name = "LogParam" diff --git a/filesender/main.py b/filesender/main.py index a6699ab..21299fb 100644 --- a/filesender/main.py +++ b/filesender/main.py @@ -5,6 +5,7 @@ from filesender.api import FileSenderClient from typer import Typer, Option, Argument, Context, Exit from rich import print +from rich.pretty import pretty_repr from pathlib import Path from filesender.auth import Auth, UserAuth, GuestAuth from filesender.config import get_defaults @@ -12,7 +13,7 @@ from asyncio import run from importlib.metadata import version from rich.logging import RichHandler -from filesender.log import LogParam, LogLevel +from filesender.log import LogParam, LogLevel, configure_extra_levels logger = logging.getLogger(__name__) @@ -51,7 +52,7 @@ def common_args( base_url: Annotated[str, Option(help="The URL of the FileSender REST API")], log_level: Annotated[ int, Option(click_type=LogParam(), help="Logging verbosity", ) - ] = LogLevel.INFO.value, + ] = LogLevel.FEEDBACK.value, version: Annotated[ Optional[bool], Option("--version", callback=version_callback) ] = None @@ -59,13 +60,13 @@ def common_args( context.obj = { "base_url": base_url } + configure_extra_levels() logging.basicConfig( level=log_level, format= "%(message)s", datefmt="[%X]", handlers=[RichHandler()] ) - logging.addLevelName(LogLevel.VERBOSE.value, 'VERBOSE') @app.command(context_settings=context) @@ -114,8 +115,8 @@ def invite( } } })) - logger.log(LogLevel.VERBOSE.value, result) - logger.info("Invitation successfully sent") + logger.log(LogLevel.VERBOSE.value, pretty_repr(result)) + logger.log(LogLevel.FEEDBACK.value, "Invitation successfully sent") @app.command(context_settings=context) @typer_async @@ -142,8 +143,8 @@ async def upload_voucher( await auth.prepare(client.http_client) await client.prepare() result: Transfer = await client.upload_workflow(files, {"from": email, "recipients": []}) - logger.log(LogLevel.VERBOSE.value, result) - logger.log(LogLevel.INFO.value, "Upload completed successfully") + logger.log(LogLevel.VERBOSE.value, pretty_repr(result)) + logger.log(LogLevel.FEEDBACK.value, "Upload completed successfully") @app.command(context_settings=context) @typer_async @@ -174,8 +175,8 @@ async def upload( ) await client.prepare() result: Transfer = await client.upload_workflow(files, {"recipients": recipients, "from": username}) - logger.log(LogLevel.VERBOSE.value, result) - logger.log(LogLevel.INFO.value, "Upload completed successfully") + logger.log(LogLevel.VERBOSE.value, pretty_repr(result)) + logger.log(LogLevel.FEEDBACK.value, "Upload completed successfully") @app.command(context_settings=context) def download( @@ -192,7 +193,7 @@ def download( token=token, out_dir=out_dir )) - logger.log(LogLevel.INFO.value, f"Download completed successfully. Files can be found in {out_dir}") + logger.log(LogLevel.FEEDBACK.value, f"Download completed successfully. Files can be found in {out_dir}") @app.command(context_settings=context) @typer_async @@ -202,7 +203,7 @@ async def server_info( """Prints out information about the FileSender server you are interfacing with""" client = FileSenderClient(base_url=context.obj["base_url"]) result = await client.get_server_info() - logger.log(LogLevel.INFO.value, result) + logger.log(LogLevel.FEEDBACK.value, pretty_repr(result)) if __name__ == "__main__": app()