diff --git a/.gitignore b/.gitignore index 28596654..e3d5dd87 100644 --- a/.gitignore +++ b/.gitignore @@ -149,3 +149,6 @@ dmypy.json # JetBrains IDEs /.idea/ + +# Added by cargo +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 00000000..861decef --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,544 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "bumpalo" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "console" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03e45a4a8926227e4197636ba97a9fc9b00477e9f4bd711395687c5f0734bec4" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "unicode-width", + "windows-sys", +] + +[[package]] +name = "craft-cli" +version = "0.0.0" +dependencies = [ + "console", + "dirs", + "indicatif", + "jiff", + "mio", + "pyo3", + "regex", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys", +] + +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "indicatif" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9375e112e4b463ec1b1c6c011953545c65a30164fbab5b581df32b3abf0dcb88" +dependencies = [ + "console", + "portable-atomic", + "unicode-segmentation", + "unicode-width", + "unit-prefix", + "web-time", +] + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "jiff" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67e8da4c49d6d9909fe03361f9b620f58898859f5c7aded68351e85e71ecf50" +dependencies = [ + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-sys", +] + +[[package]] +name = "jiff-static" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0c84ee7f197eca9a86c6fd6cb771e55eb991632f15f2bc3ca6ec838929e6e78" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68971ebff725b9e2ca27a601c5eb38a4c5d64422c4cbab0c535f248087eda5c2" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + +[[package]] +name = "js-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.180" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" + +[[package]] +name = "libredox" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +dependencies = [ + "bitflags", + "libc", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "portable-atomic" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f89776e4d69bb58bc6993e99ffa1d11f228b839984854c7daeb5d37f87cbe950" + +[[package]] +name = "portable-atomic-util" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pyo3" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab53c047fcd1a1d2a8820fe84f05d6be69e9526be40cb03b73f86b6b03e6d87d" +dependencies = [ + "indoc", + "libc", + "memoffset", + "once_cell", + "portable-atomic", + "pyo3-build-config", + "pyo3-ffi", + "pyo3-macros", + "unindent", +] + +[[package]] +name = "pyo3-build-config" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b455933107de8642b4487ed26d912c2d899dec6114884214a0b3bb3be9261ea6" +dependencies = [ + "target-lexicon", +] + +[[package]] +name = "pyo3-ffi" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c85c9cbfaddf651b1221594209aed57e9e5cff63c4d11d1feead529b872a089" +dependencies = [ + "libc", + "pyo3-build-config", +] + +[[package]] +name = "pyo3-macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a5b10c9bf9888125d917fb4d2ca2d25c8df94c7ab5a52e13313a07e050a3b02" +dependencies = [ + "proc-macro2", + "pyo3-macros-backend", + "quote", + "syn", +] + +[[package]] +name = "pyo3-macros-backend" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03b51720d314836e53327f5871d4c0cfb4fb37cc2c4a11cc71907a86342c40f9" +dependencies = [ + "heck", + "proc-macro2", + "pyo3-build-config", + "quote", + "syn", +] + +[[package]] +name = "quote" +version = "1.0.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74d9a594b72ae6656596548f56f667211f8a97b3d4c3d467150794690dc40a" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom", + "libredox", + "thiserror", +] + +[[package]] +name = "regex" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "syn" +version = "2.0.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "target-lexicon" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1dd07eb858a2067e2f3c7155d54e929265c264e6f37efe3ee7a8d1b5a1dd0ba" + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unindent" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3" + +[[package]] +name = "unit-prefix" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 00000000..b077f3f1 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "craft-cli" +edition = "2024" + +[lib] +name = "craft_cli_extensions" +crate-type = ["cdylib"] + +[workspace] +members = ["."] + +[workspace.dependencies] +# pyo3 is always needed, but tests additionally need the "auto-initialize" feature. To avoid +# having two disparate version specifications, it's instead specified here exactly once and then +# retrieved via "workspace = true". +pyo3 = "0.27.2" + +[dependencies] +console = "0.16.2" +dirs = "6.0.0" +indicatif = { version = "0.18.0", features = ["improved_unicode"] } +jiff = "0.2.15" +mio = { version = "1.1.1", features = ["os-poll", "os-ext"] } +pyo3 = { features = ["extension-module"], workspace = true } + +[dev-dependencies] +pyo3 = { features = ["auto-initialize"], workspace = true } +regex = "1.11.1" diff --git a/Makefile b/Makefile index c341dec8..a4513257 100644 --- a/Makefile +++ b/Makefile @@ -13,7 +13,7 @@ include common.mk format: format-ruff format-codespell format-prettier ## Run all automatic formatters .PHONY: lint -lint: lint-ruff lint-ty lint-codespell lint-mypy lint-prettier lint-pyright lint-shellcheck lint-docs lint-twine ## Run all linters +lint: lint-ruff lint-ty lint-clippy lint-codespell lint-mypy lint-prettier lint-pyright lint-shellcheck lint-docs lint-twine ## Run all linters .PHONY: pack pack: pack-pip ## Build all packages @@ -45,6 +45,22 @@ endif .PHONY: install-lint-build-deps install-lint-build-deps: install-ty +.PHONY: lint-clippy +lint-clippy: install-rust + cargo clippy --locked + +.PHONY: install-rust +install-rust: +ifneq ($(shell which cargo),) +else ifneq ($(shell which snap),) + sudo snap install rustup --classic + rustup default stable +else ifneq ($(shell which apt-get),) + sudo apt install rustup + rustup default stable +endif + + .PHONY: lint-ty lint-ty: install-ty ty check diff --git a/craft_cli/__init__.py b/craft_cli/__init__.py index b1cfb5d4..42406aa4 100644 --- a/craft_cli/__init__.py +++ b/craft_cli/__init__.py @@ -29,7 +29,7 @@ # names included here only to be exposed as external API; the particular order of imports # is to break cyclic dependencies -from .messages import EmitterMode, emit # isort:skip +from ._rs.emitter import Verbosity as EmitterMode from .dispatcher import BaseCommand, CommandGroup, Dispatcher, GlobalArgument from .errors import ( ArgumentParsingError, @@ -50,5 +50,4 @@ "GlobalArgument", "HIDDEN", "ProvideHelpException", - "emit", ] diff --git a/craft_cli/dispatcher.py b/craft_cli/dispatcher.py index 283d2ac5..4ba83d9c 100644 --- a/craft_cli/dispatcher.py +++ b/craft_cli/dispatcher.py @@ -20,14 +20,16 @@ import argparse import dataclasses import difflib +import logging from collections.abc import Callable, Sequence from typing import Any, Literal, NamedTuple, NoReturn -from craft_cli import EmitterMode, emit from craft_cli.errors import ArgumentParsingError, ProvideHelpException from craft_cli.helptexts import HelpBuilder, OutputFormat from craft_cli.utils import humanize_list +logger = logging.getLogger(__file__) + class CommandGroup(NamedTuple): """Definition of a command group. @@ -90,6 +92,15 @@ def __post_init__(self) -> None: self.choices = [choice.lower() for choice in self.choices] +_VERBOSITIES = ("quiet", "brief", "verbose", "debug", "trace") + + +def _validate_verbosity(verbosity: str) -> str: + if verbosity in _VERBOSITIES: + return verbosity + raise ValueError(f"Invalid verbosity level {verbosity}") + + _DEFAULT_GLOBAL_ARGS = [ GlobalArgument( "help", @@ -118,8 +129,9 @@ def __post_init__(self) -> None: None, "--verbosity", "Set the verbosity level to 'quiet', 'brief', 'verbose', 'debug' or 'trace'", - choices=[mode.name.lower() for mode in EmitterMode], - validator=lambda mode: EmitterMode[mode.upper()], + choices=list(_VERBOSITIES), + # The "else None" is dead, but required code. + validator=_validate_verbosity, case_sensitive=False, ), ] @@ -286,7 +298,7 @@ def load_command(self, app_config: Any) -> BaseCommand: # noqa: ANN401 ) self._loaded_command.fill_parser(parser) self._parsed_command_args = parser.parse_args(self._command_args) - emit.trace(f"Command parsed sysargs: {self._parsed_command_args}") + logger.debug(f"Command parsed sysargs: {self._parsed_command_args}") return self._loaded_command def parsed_args(self) -> argparse.Namespace: @@ -507,13 +519,7 @@ def pre_parse_args( raise self._build_usage_exc( "The 'verbose', 'quiet' and 'verbosity' options are mutually exclusive." ) - if global_args["quiet"]: - emit.set_mode(EmitterMode.QUIET) - elif global_args["verbose"]: - emit.set_mode(EmitterMode.VERBOSE) - elif verbosity := global_args["verbosity"]: - emit.set_mode(verbosity) - emit.trace( + logger.debug( f"Raw pre-parsed sysargs: args={global_args} filtered={filtered_sysargs}" ) @@ -527,10 +533,9 @@ def pre_parse_args( if self._default_command is None: help_text = self._get_general_help(detailed=False) raise ArgumentParsingError(help_text) - emit.progress( + logger.warning( f"Running {self._app_name} without a command will not be possible in future releases. " f"Use '{self._app_name} {self._default_command.name}' instead.", - permanent=True, ) # validated by BaseCommand assert self._default_command.name is not None # noqa: S101 (use of assert) @@ -551,7 +556,7 @@ def pre_parse_args( help_text = self._build_no_command_error(command) raise ArgumentParsingError(help_text) from None - emit.trace(f"General parsed sysargs: command={command!r} args={cmd_args}") + logger.debug(f"General parsed sysargs: command={command!r} args={cmd_args}") return global_args def run(self) -> int | None: diff --git a/craft_cli/messages.py b/craft_cli/messages.py deleted file mode 100644 index 76dfc4c1..00000000 --- a/craft_cli/messages.py +++ /dev/null @@ -1,907 +0,0 @@ -# Copyright 2021-2024 Canonical Ltd. -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU Lesser General Public -# License version 3 as published by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -# Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with this program; if not, write to the Free Software Foundation, -# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. - -"""Support for all messages, ok or after errors, to screen and log file.""" - -from __future__ import annotations - -__all__ = [ - "EmitterMode", - "TESTMODE", - "emit", -] - -import enum -import functools -import getpass -import logging -import os -import pathlib -import select -import sys -import threading -import traceback -from collections.abc import Callable, Generator -from contextlib import contextmanager -from datetime import datetime -from typing import TYPE_CHECKING, Any, Literal, TextIO, TypeVar, cast - -import platformdirs - -from craft_cli import errors -from craft_cli.printer import Printer - -if TYPE_CHECKING: - from types import TracebackType - - from typing_extensions import Self - - -EmitterMode = enum.Enum("EmitterMode", "QUIET BRIEF VERBOSE DEBUG TRACE") -"""The different modes the Emitter can be set.""" - -# the limit to how many log files to have -_MAX_LOG_FILES = 5 - -# the size of bytes chunk that the pipe reader will read at once -_PIPE_READER_CHUNK_SIZE = 4096 - -# set to true when running *application* tests so some behaviours change (see -# craft_cli/pytest_plugin.py ) -TESTMODE = False - - -def _get_log_filepath(appname: str) -> pathlib.Path: - """Provide a unique filepath for logging. - - The app name is used for both the directory where the logs are located and each log name. - - Rules: - - use an platformdirs provided directory - - base filename is ..log - - it rotates until it gets to reaches :data:`._MAX_LOG_FILES` - - after limit is achieved, remove the exceeding files - - ignore other non-log files in the directory - - Existing files are not renamed (no need, as each name is unique) nor gzipped (they may - be currently in use by another process). - """ - basedir = pathlib.Path(platformdirs.user_log_dir(appname)) - filename = f"{appname}-{datetime.now():%Y%m%d-%H%M%S.%f}.log" - - # ensure the basedir is there - basedir.mkdir(exist_ok=True, parents=True) - - # check if we have too many logs in the dir, and remove the exceeding ones (note - # that the defined limit includes the about-to-be-created file, that's why the "-1") - present_files = list(basedir.glob(f"{appname}-*.log")) - limit = _MAX_LOG_FILES - 1 - if len(present_files) > limit: - for fpath in sorted(present_files)[:-limit]: - # ignore if it's not there anymore, which can happen if this code is exercised in - # parallel or when tearing down instances - fpath.unlink(missing_ok=True) - - return basedir / filename - - -def _get_traceback_lines(exc: BaseException) -> Generator[str, None, None]: - """Get the traceback lines (if any) from an exception.""" - tback_lines = traceback.format_exception(type(exc), exc, exc.__traceback__) - for tback_line in tback_lines: - yield from tback_line.rstrip().split("\n") - - -class _Progresser: - """A context manager to follow progress on any specific action.""" - - def __init__( - self, - printer: Printer, - total: float, - text: str, - stream: TextIO | None, - delta: bool, # noqa: FBT001 (boolean positional arg) - use_timestamp: bool, # noqa: FBT001 (boolean positional arg) - ephemeral_context: bool, # noqa: FBT001 (boolean positional arg) - ) -> None: - self.printer = printer - self.total = total - self.text = text - self.accumulated: int | float = 0 - self.stream = stream - self.delta = delta - self.use_timestamp = use_timestamp - - # this is only for the "before" and "after" messages; the progress itself - # is always ephemeral - self.ephemeral_context = ephemeral_context - - def __enter__(self) -> Self: - text = f"{self.text} (--->)" - self.printer.show( - self.stream, - text, - ephemeral=self.ephemeral_context, - use_timestamp=self.use_timestamp, - ) - return self - - def __exit__( - self, - exc_type: type[BaseException] | None, - exc_val: BaseException | None, - exc_tb: TracebackType | None, - ) -> Literal[False]: - text = f"{self.text} (<---)" - self.printer.show( - self.stream, - text, - ephemeral=self.ephemeral_context, - use_timestamp=self.use_timestamp, - ) - return False # do not consume any exception - - def advance(self, amount: float) -> None: - """Show a progress bar according to the informed advance.""" - if amount < 0: - raise ValueError("The advance amount cannot be negative") - if self.delta: - self.accumulated += amount - else: - self.accumulated = amount - self.printer.progress_bar( - self.stream, - self.text, - progress=self.accumulated, - total=self.total, - use_timestamp=self.use_timestamp, - ) - - -class _PipeReaderThread(threading.Thread): - """A thread that reads bytes from a pipe and write lines to the Printer. - - The core part of reading the pipe and stopping work differently according to the platform: - - - posix: use `select` with a timeout: if has data write it to Printer, if the stop flag - is set just quit - - - windows: read in a blocking way, so the `stop` method will write a byte to unblock it - after setting the stop flag (this extra byte is handled by the reading code) - """ - - # byte used to unblock the reading (under Windows) - UNBLOCK_BYTE = b"\x00" - - def __init__( - self, printer: Printer, stream: TextIO | None, printer_flags: dict[str, bool] - ) -> None: - super().__init__() - self.printer_flags = printer_flags - - # declare the types to satisfy mypy - self.read_pipe: int - self.write_pipe: int - - # prepare the pipe pair: the one to read (used in the thread core loop) and the - # one which is to be written externally (and also used internally under windows - # to unblock the reading); also note that the pipe pair themselves depend - # on the platform - if sys.platform == "win32": - import win32pipe # noqa: PLC0415 - - # parameters: default security, default buffer size, binary mode - binary_mode = os.O_BINARY - self.read_pipe, self.write_pipe = win32pipe.FdCreatePipe( - None, 0, binary_mode - ) - else: - self.read_pipe, self.write_pipe = os.pipe() - - # special flag used to stop the pipe reader thread - self.stop_flag = False - - # where to collect the content that is being read but yet not written (waiting for - # a newline) - self.remaining_content = b"" - - # printer and stream to write the assembled lines - self.printer = printer - self.stream = stream - - def _write(self, data: bytes) -> None: - """Convert the byte stream into unicode lines and send it to the printer.""" - pointer = 0 - data = self.remaining_content + data - while True: - # get the position of next newline (find starts in pointer position) - newline_position = data.find(b"\n", pointer) - - # no more newlines, store the rest of data for the next time and break - if newline_position == -1: - self.remaining_content = data[pointer:] - break - - # get the useful line and update pointer for next cycle (plus one, to - # skip the new line itself) - useful_line = data[pointer:newline_position] - pointer = newline_position + 1 - - # write the useful line to intended outputs. Decode with errors="replace" - # here because we don't know where this line is coming from. - unicode_line = useful_line.decode("utf8", errors="replace") - # replace tabs with a set number of spaces so that the printer - # can correctly count the characters. - unicode_line = unicode_line.replace("\t", " ") - text = f":: {unicode_line}" - self.printer.show(self.stream, text, **self.printer_flags) - - def _run_posix(self) -> None: - """Run the thread, handling pipes in the POSIX way.""" - poller = select.poll() - poller.register(self.read_pipe, select.POLLIN) - while True: - rlist = poller.poll(0.1) - if len(rlist) != 0: - data = os.read(self.read_pipe, _PIPE_READER_CHUNK_SIZE) - self._write(data) - elif self.stop_flag: - # only quit when nothing left to read - break - poller.unregister(self.read_pipe) - - def _run_windows(self) -> None: - """Run the thread, handling pipes in the Windows way.""" - while True: - data = os.read(self.read_pipe, _PIPE_READER_CHUNK_SIZE) # blocking! - - # data is sliced to get bytes (if checked the last position we get a number) - if self.stop_flag and data[-1:] == self.UNBLOCK_BYTE: - # we are flagged to stop and did read until the unblock byte: write any - # remaining data and quit - data = data[:-1] - if data: - self._write(data) - break - - if data: - self._write(data) - - def run(self) -> None: - """Run the thread.""" - if sys.platform == "win32": - self._run_windows() - else: - self._run_posix() - - def stop(self) -> None: - """Stop the thread. - - This flag ourselves to quit, but then makes the main thread (which is the one calling - this method) to wait ourselves to finish. - - Under Windows it inserts an extra byte in the pipe to unblock the reading. - """ - self.stop_flag = True - if sys.platform == "win32": - os.write(self.write_pipe, self.UNBLOCK_BYTE) - self.join() - os.close(self.read_pipe) - os.close(self.write_pipe) - - -class _StreamContextManager: - """A context manager that provides a pipe for subprocess to write its output.""" - - def __init__( - self, - printer: Printer, - text: str | None, - stream: TextIO | None, - use_timestamp: bool, # noqa: FBT001 (boolean positional arg) - ephemeral_mode: bool, # noqa: FBT001 (boolean positional arg) - ) -> None: - # prepare the printer flags for the initial message and everything produced - # by the pipe reader - printer_flags = { - "use_timestamp": use_timestamp, - "ephemeral": ephemeral_mode, - "end_line": not ephemeral_mode, - } - - if text is not None: - # show the intended text (explicitly asking for a complete line) before - # passing the output command to the pipe-reading thread - printer.show(stream, text, **printer_flags) - - # enable the thread to read and show what comes through the provided pipe - self.pipe_reader = _PipeReaderThread(printer, stream, printer_flags) - - def __enter__(self) -> int: - self.pipe_reader.start() - return self.pipe_reader.write_pipe - - def __exit__( - self, - exc_type: type[BaseException] | None, - exc_val: BaseException | None, - exc_tb: TracebackType | None, - ) -> Literal[False]: - self.pipe_reader.stop() - return False # do not consume any exception - - -class _Handler(logging.Handler): - """A logging handler that emits messages through the core Printer.""" - - def __init__( - self, - printer: Printer, - streaming_brief: bool = False, # noqa: FBT001, FBT002 - ) -> None: - """Init the handler. - - :param printer: - The Printer to emit captured log messages. - :param bool streaming_brief: - Whether log records of levels higher than DEBUG should be print (ephemerally) - when in BRIEF mode. - """ - super().__init__() - self.printer = printer - self.streaming_brief = streaming_brief - - # level is 0 so we get EVERYTHING (as we need to send it all to the log file), and - # will decide on "emit" if also goes to screen using the custom mode - self.level = 0 - self.mode = EmitterMode.QUIET - - def emit(self, record: logging.LogRecord) -> None: - """Send the message in the LogRecord to the printer.""" - # under DEBUG level only in trace mode, the rest is not even logged - if record.levelno < logging.DEBUG and self.mode != EmitterMode.TRACE: - return - - if self.mode in (EmitterMode.QUIET, EmitterMode.BRIEF): - # no stream in more quietish modes - stream = None - elif self.mode == EmitterMode.VERBOSE: - # in verbose, only info, warning, error, etc - stream = sys.stderr if record.levelno > logging.DEBUG else None - elif self.mode == EmitterMode.DEBUG: - # in debug mode, also include debug log level - stream = sys.stderr if record.levelno >= logging.DEBUG else None - else: - # in trace, everything - stream = sys.stderr - - text = record.getMessage() - - ephemeral = False - if self.mode == EmitterMode.BRIEF and self.streaming_brief: - stream = sys.stderr if record.levelno > logging.DEBUG else None - ephemeral = True - - use_timestamp = self.mode in (EmitterMode.DEBUG, EmitterMode.TRACE) - self.printer.show( - stream, text, use_timestamp=use_timestamp, ephemeral=ephemeral - ) - - -FuncT = TypeVar("FuncT", bound=Callable[..., Any]) - - -def _active_guard(ignore_when_stopped: bool = False) -> Callable[..., Any]: # noqa: FBT001, FBT002 - """Decorate Emitter methods to be called when active. - - It will check that the emitter is initiated and that is not stopped (except when - ignore_when_stopped=True, in that case the call will be ignored, to support - double-ending). - """ - - def decorator(wrapped_func: FuncT) -> FuncT: - @functools.wraps(wrapped_func) - def func(self: Emitter, *args: Any, **kwargs: Any) -> Any: # noqa: ANN401 - if not self._initiated: # type: ignore[reportPrivateUsage] - raise RuntimeError("Emitter needs to be initiated first") - if self._stopped: # type: ignore[reportPrivateUsage] - if ignore_when_stopped: - return None - raise RuntimeError("Emitter is stopped already") - return wrapped_func(self, *args, **kwargs) - - return cast("FuncT", func) - - return decorator - - -class Emitter: - """Main interface to all the messages emitting functionality. - - This handles everything that goes to screen and to the log file, even interfacing - with the formal logging infrastructure to get messages from it. - - This class is not meant to be instantiated by the application, just use `emit` from - this module. - - The user of this object will select any of the following methods according to what - to show: - - - `message`: for the final output of the running command; if there is important information - that needs to be shown to the user in the middle of the execution (and not overwritten - by other messages) this method can be also used but passing intermediate=True. - - - `progress`: for all the progress messages intended to provide information that the - machinery is running and doing what. - - - `trace`: for all the messages that may used by the *developers* to do any debugging on - the application behaviour and/or logs forensics. - """ - - def __init__(self) -> None: - # these attributes will be set at "real init time", with the `init` method below - self._greeting: str = None # type: ignore[assignment] - self._printer: Printer = None # type: ignore[assignment] - self._mode: EmitterMode = None # type: ignore[assignment] - self._initiated = False - self._stopped = False - self._log_filepath: pathlib.Path = None # type: ignore[assignment] - self._log_handler: _Handler = None # type: ignore[assignment] - self._streaming_brief = False - self._docs_base_url: str | None = None - - def init( - self, - mode: EmitterMode, - appname: str, - greeting: str, - log_filepath: pathlib.Path | None = None, - *, - streaming_brief: bool = False, - docs_base_url: str | None = None, - ) -> None: - """Initialize the emitter; this must be called once and before emitting any messages. - - :param streaming_brief: Whether informational messages should be streamed with - progress messages when using BRIEF mode (see example 29). - :param docs_base_url: The base address of the documentation, for error reporting - purposes. - """ - if self._initiated: - if TESTMODE: - self._stop() - else: - raise RuntimeError("Double Emitter init detected!") - - self._greeting = greeting - self._streaming_brief = streaming_brief - - self._docs_base_url = docs_base_url - if docs_base_url and docs_base_url.endswith("/"): - self._docs_base_url = docs_base_url[:-1] - - # create a log file, bootstrap the printer, and before anything else send the greeting - # to the file - self._log_filepath = ( - _get_log_filepath(appname) if log_filepath is None else log_filepath - ) - self._printer = Printer(self._log_filepath) - self._printer.show(None, greeting) - - # hook into the logging system - logger = logging.getLogger() - self._log_handler = _Handler(self._printer, streaming_brief=streaming_brief) - logger.addHandler(self._log_handler) - - self._initiated = True - self._stopped = False - self.set_mode(mode) - - @_active_guard() - def get_mode(self) -> EmitterMode: - """Return the mode of the emitter.""" - return self._mode - - @_active_guard() - def set_mode(self, mode: EmitterMode) -> None: - """Set the mode of the emitter.""" - if mode == self._mode: - return - - self._mode = mode - self._log_handler.mode = mode - - if mode in (EmitterMode.VERBOSE, EmitterMode.DEBUG, EmitterMode.TRACE): - use_timestamp = mode in (EmitterMode.DEBUG, EmitterMode.TRACE) - - # send the greeting to the screen before any further messages - msgs = [ - self._greeting, - f"Logging execution to {str(self._log_filepath)!r}", - ] - for msg in msgs: - self._printer.show( - sys.stderr, - msg, - use_timestamp=use_timestamp, - avoid_logging=True, - end_line=True, - ) - - @_active_guard() - def message(self, text: str) -> None: - """Show an important message to the user. - - Normally used as the final message, to show the result of a command. - """ - stream = None if self._mode == EmitterMode.QUIET else sys.stdout - if self._streaming_brief: - # Clear the message prefix, as this message stands alone - self._printer.set_terminal_prefix("") - self._printer.show(stream, text) - - @_active_guard() - def verbose(self, text: str) -> None: - """Verbose information. - - Useful to provide more information to the user that shouldn't be exposed - when in brief mode for clarity and simplicity. - """ - if self._mode in (EmitterMode.QUIET, EmitterMode.BRIEF): - stream = None - use_timestamp = False - elif self._mode == EmitterMode.VERBOSE: - stream = sys.stderr - use_timestamp = False - else: - stream = sys.stderr - use_timestamp = True - self._printer.show(stream, text, use_timestamp=use_timestamp) - - @_active_guard() - def warning(self, text: str, *, prefix: str = "Warning: ") -> None: - """Show an important warning to the user. - - To show warnings to the user, which are not errors but may - indicate that something is not right and the user should pay attention to it. - - :param prefix: Display text prepended to warnings, defaults to "Warning: " - """ - text = f"{prefix}{text}" - if self._mode == EmitterMode.QUIET: - stream = None - use_timestamp = False - elif self._mode in (EmitterMode.DEBUG, EmitterMode.TRACE): - stream = sys.stderr - use_timestamp = True - else: - stream = sys.stderr - use_timestamp = False - self._printer.show(stream, text, use_timestamp=use_timestamp) - - @_active_guard() - def debug(self, text: str) -> None: - """Debug information. - - To record everything that the user may not want to normally see but useful - for the app developers to understand why things are failing or performing - forensics on the produced logs. - """ - if self._mode in (EmitterMode.QUIET, EmitterMode.BRIEF, EmitterMode.VERBOSE): - stream = None - else: - stream = sys.stderr - self._printer.show(stream, text, use_timestamp=True) - - @_active_guard() - def trace(self, text: str) -> None: - """Trace information. - - A way to expose system-generated information, about the general process or - particular information, which in general would be too overwhelming for - debugging purposes but sometimes needed for particular analysis. - - It only produces information to the screen and into the logs if in TRACE mode. - """ - # as we're not even logging anything if not in TRACE mode, instead of calling the - # Printer with no stream and the 'avoid_logging' flag (which would be more consistent - # with the rest of the Emitter methods, in this case we just avoid moving any - # machinery as much as possible, because potentially there will be huge number - # of trace calls. - if self._mode == EmitterMode.TRACE: - self._printer.show(sys.stderr, text, use_timestamp=True) - - def _get_progress_params( - self, - permanent: bool, # noqa: FBT001 (boolean positional arg) - ) -> tuple[TextIO | None, bool, bool]: - """Calculate the different parameters for progress information.""" - if self._mode == EmitterMode.QUIET: - # will not be shown in the screen (always logged to the file) - stream = None - use_timestamp = False - ephemeral = True - elif self._mode == EmitterMode.BRIEF: - # show the indicated message to stderr (ephemeral, unless flag is used) and log it - stream = sys.stderr - use_timestamp = False - ephemeral = not permanent - elif self._mode == EmitterMode.VERBOSE: - # show the indicated message to stderr (permanent) and log it - stream = sys.stderr - use_timestamp = False - ephemeral = False - else: - # show to stderr with timestamp (permanent), and log it - stream = sys.stderr - use_timestamp = True - ephemeral = False - return stream, use_timestamp, ephemeral - - @_active_guard() - def progress(self, text: str, permanent: bool = False) -> None: # noqa: FBT001, FBT002 - """Progress information for a multi-step command. - - This is normally used to present several separated text messages. - - If a progress message is important enough that it should not be overwritten by the - next ones, use 'permanent=True'. - - These messages will be truncated to the terminal's width, and overwritten by the next - line (unless verbose/trace mode). - """ - stream, use_timestamp, ephemeral = self._get_progress_params(permanent) - - if self._streaming_brief: - # Clear the "new thing" prefix, as this is a new progress message. - self._printer.set_terminal_prefix("") - - self._printer.show( - stream, text, ephemeral=ephemeral, use_timestamp=use_timestamp - ) - - if self._mode == EmitterMode.BRIEF and ephemeral and self._streaming_brief: - # Set the "progress prefix" for upcoming non-permanent messages. - self._printer.set_terminal_prefix(text) - - @_active_guard() - def progress_bar( - self, - text: str, - total: float, - delta: bool = True, # noqa: FBT001, FBT002 - ) -> _Progresser: - """Progress information for a potentially long-running single step of a command. - - E.g. a download or provisioning step. - - Returns a context manager with a `.advance` method to call on each progress (passing the - delta progress, unless delta=False here, which implies that the calls to `.advance` should - pass the total so far). - """ - stream, use_timestamp, ephemeral = self._get_progress_params(permanent=False) - return _Progresser( - self._printer, total, text, stream, delta, use_timestamp, ephemeral - ) - - @_active_guard() - def open_stream(self, text: str | None = None) -> _StreamContextManager: - """Open a stream context manager to get messages from subprocesses.""" - if self._mode == EmitterMode.QUIET: - # no third party stream - stream = None - ephemeral = True - use_timestamp = False - elif self._mode == EmitterMode.BRIEF: - stream = sys.stderr - ephemeral = True - use_timestamp = False - elif self._mode == EmitterMode.VERBOSE: - # third party stream to stderr - stream = sys.stderr - ephemeral = False - use_timestamp = False - else: - # third party stream to stderr with timestamp - stream = sys.stderr - ephemeral = False - use_timestamp = True - return _StreamContextManager( - self._printer, - text, - stream=stream, - use_timestamp=use_timestamp, - ephemeral_mode=ephemeral, - ) - - @_active_guard() - @contextmanager - def pause(self) -> Generator[None, None, None]: - """Context manager that pauses and resumes the control of the terminal. - - Note that no messages will be collected while paused, not even for logging. - """ - self.debug("Emitter: Pausing control of the terminal") - self._printer.stop() - self._stopped = True - try: - yield - finally: - self._stopped = False - self._printer = self._log_handler.printer = Printer(self._log_filepath) - self.debug("Emitter: Resuming control of the terminal") - - def _stop(self) -> None: - """Do all the stopping.""" - self._printer.stop() - self._stopped = True - - @_active_guard(ignore_when_stopped=True) - def ended_ok(self) -> None: - """Finish the messaging system gracefully.""" - self._stop() - - @_active_guard(ignore_when_stopped=True) - def report_error(self, error: errors.CraftError) -> None: - """Report the different message lines from a CraftError.""" - use_timestamp = True - exception_stream = sys.stderr - if self._mode in (EmitterMode.QUIET, EmitterMode.BRIEF, EmitterMode.VERBOSE): - use_timestamp = False - exception_stream = None - - # The initial message. Print every line individually to correctly clear - # previous lines, if necessary. - for line in str(error).splitlines(): - self._printer.show( - sys.stderr, line, use_timestamp=use_timestamp, end_line=True - ) - - if isinstance(error, errors.CraftCommandError): - stderr = error.stderr - if stderr: - text = f"Captured error:\n{stderr}" - self._printer.show( - sys.stderr, text, use_timestamp=use_timestamp, end_line=True - ) - - # detailed information and/or original exception - if error.details: - details = _format_details(error.details) - text = f"Detailed information: {details}" - details_stream = None if self._mode == EmitterMode.QUIET else sys.stderr - self._printer.show( - details_stream, text, use_timestamp=use_timestamp, end_line=True - ) - if error.__cause__: - for line in _get_traceback_lines(error.__cause__): - self._printer.show( - exception_stream, line, use_timestamp=use_timestamp, end_line=True - ) - - # hints for the user to know more - if error.resolution: - text = f"Recommended resolution: {error.resolution}" - self._printer.show( - sys.stderr, text, use_timestamp=use_timestamp, end_line=True - ) - - doc_url = None - if self._docs_base_url and error.doc_slug: - doc_url = self._docs_base_url + error.doc_slug - if error.docs_url: - doc_url = error.docs_url - - if doc_url: - text = f"For more information, check out: {doc_url}" - self._printer.show( - sys.stderr, text, use_timestamp=use_timestamp, end_line=True - ) - - # expose the logfile path only if indicated - if error.logpath_report: - text = f"Full execution log: {str(self._log_filepath)!r}" - self._printer.show( - sys.stderr, text, use_timestamp=use_timestamp, end_line=True - ) - - @_active_guard(ignore_when_stopped=True) - def error(self, error: errors.CraftError) -> None: - """Handle the system's indicated error and stop machinery.""" - if self._streaming_brief: - # Clear the message prefix, as this error stands alone - self._printer.set_terminal_prefix("") - self.report_error(error) - self._stop() - - @_active_guard() - def append_to_log(self, file: TextIO, prefix: str = ":: ") -> None: - """Dump the contents of an external log file into the emitter log. - - :param file: A file I/O object to read from - :param prefix: A prefix for every line printed. Defaults to ":: ". - """ - for line in file: - text = f"{prefix}{line}" - self._printer.log.write(text) - self._printer.log.flush() - - @_active_guard() - def set_secrets(self, secrets: list[str]) -> None: - """Set the list of strings that should be masked out in all output.""" - self._printer.set_secrets(secrets) - - @_active_guard() - def confirm(self, prompt: str, *, default: bool = False) -> bool: - """Query user for yes/no answer. - - If stdin is not a tty, the default value is returned. - If user returns an empty answer, the default value is returned. - :returns: True if answer starts with [yY], False if answer starts with [nN], - otherwise the default. - """ - if not sys.stdin.isatty(): - return default - - choices = " [Y/n]: " if default else " [y/N]: " - - with self.pause(): - reply = input(prompt + choices).lower().strip() - - if reply and reply[0] == "y": - return True - if reply and reply[0] == "n": - return False - return default - - @_active_guard() - def prompt(self, prompt_text: str, *, hide: bool = False) -> str: - """Prompt user for input. - - If stdin is not a tty a CraftError is raised. - - :param prompt_text: text displayed to user while asking for an input. - :param hide: hide user input if True. - :returns: value that was provided by user. - :raises: CraftError if shell is not interactive or input is empty. - """ - if not sys.stdin.isatty(): - raise errors.CraftError("prompting not possible without tty") - - method: Callable[[str], str] = getpass.getpass if hide else input # type: ignore[assignment] - - with self.pause(): - val = method(prompt_text).strip() - if not val: - raise errors.CraftError("input cannot be empty") - return val - - @property - def log_filepath(self) -> pathlib.Path: - """The path to the log file.""" - return self._log_filepath - - -# module-level instantiated Emitter; this is the instance all code shall use and Emitter -# shall not be instantiated again for the process' run -emit = Emitter() - - -def _format_details(details: str) -> str: - """Prepend a newline to multi-line details, if necessary.""" - if "\n" in details: - return details if details.startswith("\n") else f"\n{details}" - return details diff --git a/craft_cli/printer.py b/craft_cli/printer.py deleted file mode 100644 index de4aa18b..00000000 --- a/craft_cli/printer.py +++ /dev/null @@ -1,528 +0,0 @@ -# Copyright 2023 Canonical Ltd. -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU Lesser General Public -# License version 3 as published by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -# Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with this program; if not, write to the Free Software Foundation, -# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. - -"""The output (for different destinations) handler and helper functions.""" - -from __future__ import annotations - -import itertools -import math -import os -import pathlib -import platform -import queue -import shutil -import sys -import threading -import time -import weakref -from collections.abc import Callable -from dataclasses import dataclass, field -from datetime import datetime -from functools import lru_cache -from typing import Any, TextIO - -# the char used to draw the progress bar ('FULL BLOCK') -_PROGRESS_BAR_SYMBOL = "█" - -# seconds before putting the spinner to work -_SPINNER_THRESHOLD = 2 - -# seconds between each spinner char -_SPINNER_DELAY = 0.1 - -# set to true when running *application* tests so some behaviours change (see -# craft_cli/pytest_plugin.py ) -TESTMODE = False - -ANSI_CLEAR_LINE_TO_END = "\x1b[K" # ANSI escape code to clear the rest of the line. -ANSI_HIDE_CURSOR = "\x1b[?25l" -ANSI_SHOW_CURSOR = "\x1b[?25h" - - -@dataclass -class _MessageInfo: - """Comprehensive information for a message that may go to screen and log.""" - - stream: TextIO | None - text: str - ephemeral: bool = False - bar_progress: int | float | None = None - bar_total: int | float | None = None - use_timestamp: bool = False - end_line: bool = False - created_at: datetime = field(default_factory=datetime.now, compare=False) - terminal_prefix: str = "" - - -@lru_cache -def _stream_is_terminal(stream: TextIO | None) -> bool: - is_a_terminal = getattr(stream, "isatty", lambda: False)() - return is_a_terminal and _get_terminal_width() > 0 - - -def _get_terminal_width() -> int: - """Return the number of columns of the terminal.""" - return shutil.get_terminal_size().columns - - -@lru_cache -def _supports_ansi_escape_sequences() -> bool: - """Whether the current environment supports ANSI escape sequences.""" - if platform.system() != "Windows": - return True - return ( - "WT_SESSION" in os.environ - ) # Windows Terminal supports ANSI escape sequences. - - -def _fill_line(text: str) -> str: - """Turn the input text into a line that will fill the terminal.""" - if _supports_ansi_escape_sequences(): - return text + ANSI_CLEAR_LINE_TO_END - width = _get_terminal_width() - # Fill the line but leave one character for the cursor. - n_spaces = width - len(text) % width - 1 - return text + " " * n_spaces - - -def _format_term_line( - previous_line_end: str, text: str, spintext: str, *, ephemeral: bool -) -> str: - """Format a line to print to the terminal.""" - # fill with spaces until the very end, on one hand to clear a possible previous message, - # but also to always have the cursor at the very end - width = _get_terminal_width() - usable = width - len(spintext) - 1 # the 1 is the cursor itself - if len(text) > usable: - if ephemeral: - text = text[: usable - 1] + "…" - elif spintext: - # we need to rewrite the message with the spintext, use only the last line for - # multiline messages, and ensure (again) that the last real line fits - remaining_for_last_line = len(text) % width - text = text[-remaining_for_last_line:] - if len(text) > usable: - text = text[: usable - 1] + "…" - - return previous_line_end + _fill_line(text + spintext) - - -class _Spinner(threading.Thread): - """A supervisor thread that will repeat long-standing messages with a spinner besides it. - - This will be a long-lived single thread that will supervise each message received - through the `supervise` method, and when it stays too long, the printer's `spin` - will be called with that message and a text to "draw" a spinner, including the elapsed - time. - - The timing related part of the code uses two constants: _SPINNER_THRESHOLD is how - many seconds before activating the spinner for the message, and _SPINNER_DELAY is - the time between `spin` calls. - - When a new message arrives (or None, to indicate that there is nothing to supervise) and - the previous message was "being spinned", a last `spin` call will be done to clean - the spinner. - """ - - def __init__(self, printer: Printer) -> None: - super().__init__() - # special flag used to stop the spinner thread - self.stop_flag = object() - - # daemon mode, so if the app crashes this thread does not holds everything - self.daemon = True - - # communication from the printer - self.queue: queue.Queue[Any] = queue.Queue() - - # hold the printer, to make it spin - self.printer = printer - - # a lock to wait the spinner to stop spinning - self.lock = threading.Lock() - - # Keep the message under supervision available for examination. - self._under_supervision: _MessageInfo | None = None - - def run(self) -> None: - prv_msg = None - t_init = time.time() - while prv_msg is not self.stop_flag: - try: - new_msg = self.queue.get(timeout=_SPINNER_THRESHOLD) - except queue.Empty: - # waited too much, start to show a spinner (if have a previous message) until - # we have further info - if prv_msg is None or prv_msg.end_line: - continue - spinchars = itertools.cycle("-\\|/") - with self.lock: - while True: - t_delta = time.time() - t_init - spintext = f" {next(spinchars)} ({t_delta:.1f}s)" - self.printer.spin(prv_msg, spintext) - try: - new_msg = self.queue.get(timeout=_SPINNER_DELAY) - except queue.Empty: - # still nothing! keep going - continue - # got a new message: clean the spinner and exit from the spinning state - self.printer.spin(prv_msg, " ") - break - - prv_msg = new_msg - t_init = time.time() - - def supervise(self, message: _MessageInfo | None) -> None: - """Supervise a message to spin it if it remains too long.""" - # Don't bother the spinner if we're repeating the same message - if message == self._under_supervision: - return - - self._under_supervision = message - self.queue.put(message) - # (maybe) wait for the spinner to exit spinning state (which does some cleaning) - self.lock.acquire() - self.lock.release() - - def stop(self) -> None: - """Stop self.""" - self.queue.put(self.stop_flag) - self.join() - - -class Printer: - """Handle writing the different messages to the different outputs (out, err and log). - - If TESTMODE is True, this class changes its behaviour: the spinner is never started, - so there is no thread polluting messages when running tests if they take too long to run. - """ - - def __init__(self, log_filepath: pathlib.Path) -> None: - self.stopped = False - - # holder of the previous message - self.prv_msg: _MessageInfo | None = None - - # open the log file (will be closed explicitly later) - self.log = log_filepath.open("at", encoding="utf8") - - # keep account of output terminal streams with unfinished lines - self.unfinished_stream: TextIO | None = None - - self.terminal_prefix = "" - - self.secrets: list[str] = [] - - # run the spinner supervisor - self.spinner = _Spinner(self) - if not TESTMODE: - self.spinner.start() - if _supports_ansi_escape_sequences() and _stream_is_terminal(sys.stderr): - print(ANSI_HIDE_CURSOR, end="", file=sys.stderr, flush=True) - weakref.finalize(self, self.stop) - - def set_terminal_prefix(self, prefix: str) -> None: - """Set the string to be prepended to every message shown to the terminal.""" - self.terminal_prefix = prefix - - def _get_prefixed_message_text(self, message: _MessageInfo) -> str: - """Get the message's text with the proper terminal prefix, if any.""" - text = message.text - prefix = message.terminal_prefix - - # Don't repeat text: can happen due to the spinner. - if prefix and text != prefix: - separator = ":: " - - # Don't duplicate the separator, which can come from multiple different - # sources. - if text.startswith(separator): - separator = "" - - text = f"{prefix} {separator}{text}" - - return text - - def _get_line_end(self, spintext: str) -> str: - """Get the end of line to use when writing a line to the terminal.""" - if spintext: - # forced to overwrite the previous message to present the spinner - return "\r" - if self.prv_msg is None or self.prv_msg.end_line: - # first message, or previous message completed the line: start clean - return "" - if self.prv_msg.ephemeral: - # the last one was ephemeral, overwrite it - return "\r" - # Previous line was ended; complete it. - return "\n" - - def _write_line_terminal( - self, message: _MessageInfo, *, spintext: str = "" - ) -> None: - """Write a simple line message to the screen.""" - # prepare the text with (maybe) the timestamp and remove trailing spaces - text = self._get_prefixed_message_text(message).rstrip() - - if message.use_timestamp: - timestamp_str = message.created_at.isoformat( - sep=" ", timespec="milliseconds" - ) - text = f"{timestamp_str} {text}" - - previous_line_end = self._get_line_end(spintext) - if ( - self.prv_msg - and self.prv_msg.ephemeral - and self.prv_msg.stream != message.stream - ): - # If the last message's stream is different from this new one, - # send a carriage return to the original stream only. - print("\r", flush=True, file=self.prv_msg.stream, end="") - previous_line_end = "" - if self.prv_msg and previous_line_end == "\n": - previous_line_end = "" - print(flush=True, file=self.prv_msg.stream) - - # fill with spaces until the very end, on one hand to clear a possible previous message, - # but also to always have the cursor at the very end - width = _get_terminal_width() - usable = width - len(spintext) - 1 # the 1 is the cursor itself - if len(text) > usable: - if message.ephemeral: - text = text[: usable - 1] + "…" - elif spintext: - # we need to rewrite the message with the spintext, use only the last line for - # multiline messages, and ensure (again) that the last real line fits - remaining_for_last_line = len(text) % width - text = text[-remaining_for_last_line:] - if len(text) > usable: - text = text[: usable - 1] + "…" - - # We don't need to rewrite the same ephemeral message repeatedly. - should_overwrite = spintext or message.end_line or not message.ephemeral - if should_overwrite or message != self.prv_msg: - line = _format_term_line( - previous_line_end, text, spintext, ephemeral=message.ephemeral - ) - print(line, end="", flush=True, file=message.stream) - - if message.end_line: - # finish the just shown line, as we need a clean terminal for some external thing - print(flush=True, file=message.stream) - self.unfinished_stream = None - else: - self.unfinished_stream = message.stream - - def _write_line_captured(self, message: _MessageInfo) -> None: - """Write a simple line message to a captured output.""" - # prepare the text with (maybe) the timestamp - if message.use_timestamp: - timestamp_str = message.created_at.isoformat( - sep=" ", timespec="milliseconds" - ) - text = timestamp_str + " " + message.text - else: - text = message.text - - print(text, file=message.stream) - - def _write_bar_terminal(self, message: _MessageInfo) -> None: - """Write a progress bar to the screen.""" - # prepare the text with (maybe) the timestamp - if message.use_timestamp: - timestamp_str = message.created_at.isoformat( - sep=" ", timespec="milliseconds" - ) - text = timestamp_str + " " + message.text - else: - text = message.text - - if self.prv_msg is None or self.prv_msg.end_line: - # first message, or previous message completed the line: start clean - maybe_cr = "" - elif self.prv_msg.ephemeral: - # the last one was ephemeral, overwrite it - maybe_cr = "\r" - else: - # complete the previous line, leaving that message ok - maybe_cr = "" - print(flush=True, file=self.prv_msg.stream) - - if ( - message.bar_progress is None or message.bar_total is None - ): # pragma: no cover - # Should not happen as the caller checks the message - raise ValueError("Tried to write a bar message with invalid attributes") - - numerical_progress = f"{message.bar_progress}/{message.bar_total}" - bar_percentage = min(message.bar_progress / message.bar_total, 1) - - # terminal size minus the text and numerical progress, and 5 (the cursor at the end, - # two spaces before and after the bar, and two surrounding brackets) - terminal_width = _get_terminal_width() - bar_width = terminal_width - len(text) - len(numerical_progress) - 5 - - # only show the bar with progress if there is enough space, otherwise just the - # message (truncated, if needed) - if bar_width > 0: - completed_width = math.floor(bar_width * min(bar_percentage, 100)) - completed_bar = _PROGRESS_BAR_SYMBOL * completed_width - empty_bar = " " * (bar_width - completed_width) - line = f"{maybe_cr}{text} [{completed_bar}{empty_bar}] {numerical_progress}" - else: - text = text[: terminal_width - 1] # space for cursor - line = f"{maybe_cr}{text}" - - print(line, end="", flush=True, file=message.stream) - self.unfinished_stream = message.stream - - def _write_bar_captured(self, message: _MessageInfo) -> None: - """Do not write any progress bar to the captured output.""" - - def _show(self, msg: _MessageInfo) -> None: - """Show the composed message.""" - # show the message in one way or the other only if there is a stream - if msg.stream is None: - return - - # the writing functions depend on the final output: if the stream is captured or it's - # a real terminal - write_line: Callable[[_MessageInfo], None] - if _stream_is_terminal(msg.stream): - write_line = self._write_line_terminal - write_bar = self._write_bar_terminal - else: - write_line = self._write_line_captured - write_bar = self._write_bar_captured - - if msg.bar_progress is None: - # regular message, send it to the spinner and write it - self.spinner.supervise(msg) - write_line(msg) - else: - # progress bar, send None to the spinner (as it's not a "spinnable" message) - # and write it - self.spinner.supervise(None) - write_bar(msg) - self.prv_msg = msg - - def _log(self, message: _MessageInfo) -> None: - """Write the line message to the log file.""" - # prepare the text with (maybe) the timestamp - timestamp_str = message.created_at.isoformat(sep=" ", timespec="milliseconds") - self.log.write(f"{timestamp_str} {message.text}\n") - # Flush the file: protect a bit in case of crashes, and multiprocess-based - # parallelism. - self.log.flush() - - def spin(self, message: _MessageInfo, spintext: str) -> None: - """Write a line message including a spin text, only to a terminal.""" - if _stream_is_terminal(message.stream): - self._write_line_terminal(message, spintext=spintext) - - def show( - self, - stream: TextIO | None, - text: str, - *, - ephemeral: bool = False, - use_timestamp: bool = False, - end_line: bool = False, - avoid_logging: bool = False, - ) -> None: - """Show a text to the given stream if not stopped.""" - if self.stopped: - return - - text = self._apply_secrets(text) - - msg = _MessageInfo( - stream=stream, - text=text.rstrip(), - ephemeral=ephemeral, - use_timestamp=use_timestamp, - end_line=end_line, - terminal_prefix=self._apply_secrets(self.terminal_prefix), - ) - self._show(msg) - if not avoid_logging: - self._log(msg) - - def progress_bar( - self, - stream: TextIO | None, - text: str, - *, - progress: float, - total: float, - use_timestamp: bool, - ) -> None: - """Show a progress bar to the given stream.""" - text = self._apply_secrets(text) - - msg = _MessageInfo( - stream=stream, - text=text.rstrip(), - bar_progress=progress, - bar_total=total, - ephemeral=True, # so it gets eventually overwritten by other message - use_timestamp=use_timestamp, - ) - self._show(msg) - - def stop(self) -> None: - """Stop the printing infrastructure. - - In detail: - - stop the spinner - - show the cursor - - add a new line to the screen (if needed) - - close the log file - """ - if self.stopped: - # Safe no-op - return - if not TESTMODE: - if self.spinner.is_alive(): - self.spinner.stop() - if _supports_ansi_escape_sequences() and _stream_is_terminal(sys.stderr): - print(ANSI_SHOW_CURSOR, end="", file=sys.stderr, flush=True) - if self.unfinished_stream is not None and not self.unfinished_stream.closed: - # With unfinished_stream set, the prv_msg object is valid. - if self.prv_msg is not None and self.prv_msg.ephemeral: - # If the last printed message is of 'ephemeral' type, the stop - # request must clean and reset the line. - cleaner = " " * (_get_terminal_width() - 1) - line = "\r" + cleaner + "\r" - print(line, end="", flush=True, file=self.prv_msg.stream) - else: - # The last printed message is permanent. Leave the cursor on - # the next clean line. - print(flush=True, file=self.unfinished_stream) - self.log.close() - self.stopped = True - - def set_secrets(self, secrets: list[str]) -> None: - """Set the list of strings that should be masked out in all outputs.""" - # Keep a copy, to protect against clients modifying the list on accident. - self.secrets = secrets.copy() - - def _apply_secrets(self, text: str) -> str: - for secret in self.secrets: - text = text.replace(secret, "*****") - return text diff --git a/craft_cli/utils.py b/craft_cli/utils.py index 8ce961bf..714f51f1 100644 --- a/craft_cli/utils.py +++ b/craft_cli/utils.py @@ -15,8 +15,6 @@ """Utility functions for craft_cli.""" +from craft_cli._rs.utils import humanize_list -def humanize_list(values: list[str], conjunction: str = "and") -> str: - """Convert a collection of values into a string that lists the values.""" - start = ", ".join(values[:-1]) - return f"{start}, {conjunction} {values[-1]}" +__all__ = ["humanize_list"] diff --git a/pyproject.toml b/pyproject.toml index 134ba277..6f164352 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,7 @@ [project] name = "craft-cli" -dynamic = ["version", "readme"] +version = "0.0.0+dev" +dynamic = ["readme"] description = "Command Line Interface" authors = [{ name = "Canonical Ltd", email = "snapcraft@lists.snapcraft.io" }] dependencies = [ @@ -20,6 +21,9 @@ classifiers = [ "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.14", + "Programming Language :: Rust", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: Implementation :: PyPy", ] requires-python = ">=3.10" license = { file = "LICENSE" } @@ -36,6 +40,7 @@ emitter = "craft_cli.pytest_plugin" [dependency-groups] dev = [ "coverage[toml]==7.13.0", + "maturin>=1.11.5", "pytest==8.4.2", "pytest-cov==7.0.0", "pytest-mock==3.15.1", @@ -62,41 +67,11 @@ docs = [ tics = ["flake8", "pylint"] [build-system] -requires = ["setuptools==80.9.0", "setuptools_scm[toml]>=7.1"] -build-backend = "setuptools.build_meta" +requires = ["maturin>=1.11,<2.0"] +build-backend = "maturin" -[tool.setuptools.dynamic] -readme = { file = "README.md" } - -[tool.setuptools_scm] -write_to = "craft_cli/_version.py" -# the version comes from the latest annotated git tag formatted as 'X.Y.Z' -# version scheme: -# - X.Y.Z.post+g.d<%Y%m%d> -# parts of scheme: -# - X.Y.Z - most recent git tag -# - post+g - present when current commit is not tagged -# - .d<%Y%m%d> - present when working dir is dirty -# version scheme when no tags exist: -# - 0.0.post+g -version_scheme = "post-release" -# deviations from the default 'git describe' command: -# - only match annotated tags -# - only match tags formatted as 'X.Y.Z' -git_describe_command = [ - "git", - "describe", - "--dirty", - "--long", - "--match", - "[0-9]*.[0-9]*.[0-9]*", - "--exclude", - "*[^0-9.]*", -] - -[tool.setuptools.packages.find] -include = ["*craft*"] -namespaces = false +[tool.maturin] +module-name = "craft_cli._rs" [tool.uv] constraint-dependencies = [ @@ -123,6 +98,13 @@ constraint-dependencies = [ "webencodings>=0.4.0", "wheel>=0.38", ] +# Override the cache keys to include Rust content +cache-keys = [ + { file = "src/**/*.rs" }, + { file = "Cargo.toml" }, + { file = "craft_cli/**/*.pyi?" }, + { file = "pyproject.toml" }, +] [tool.codespell] ignore-words-list = "buildd,crate,keyserver,comandos,ro,dedent,dedented" @@ -149,7 +131,6 @@ markers = ["slow: slow tests"] branch = true omit = ["tests/**"] - [tool.coverage.report] skip_empty = true exclude_also = ["if (typing\\.)?TYPE_CHECKING:"] @@ -164,6 +145,10 @@ exclude = [ # pyright might not like the annotations generated by setuptools_scm "**/_version.py", ] +# Pyright does not check .so files of compiled cpy, which means this generic warning is +# emitted every time a module written in Rust is imported. It basically just means that +# the type stubs were found, but not the corresponding source +reportMissingModuleSource = "none" [tool.mypy] python_version = "3.10" diff --git a/src/craft_cli_utils.rs b/src/craft_cli_utils.rs new file mode 100644 index 00000000..766a689f --- /dev/null +++ b/src/craft_cli_utils.rs @@ -0,0 +1,56 @@ +//! Utility functions for Craft CLI + +use pyo3::pymodule; + +/// Utility functions for Craft CLI +#[pymodule(submodule)] +pub mod utils { + use pyo3::{ + Bound, PyResult, + exceptions::{PyTypeError, PyValueError}, + pyfunction, + types::{PyAny, PyAnyMethods as _, PyModule, PyTypeMethods as _}, + }; + + use crate::utils::fix_imports; + + /// Convert a collection of values into a string that lists the values. + #[pyfunction] + #[pyo3(signature = (values, *, conjunction = "and"))] + fn humanize_list(values: Bound<'_, PyAny>, conjunction: &str) -> PyResult { + // Check if it's actually iterable at runtime and collect values + // This could be dramatically simplified if [PyO3#5757](https://github.com/PyO3/pyo3/issues/5757) + // is resolved. + let items: Vec<_> = match values.try_iter() { + Ok(py_iter) => py_iter + .into_iter() + .map(|maybe_item| maybe_item.map(|item| item.to_string())) + .collect::>()?, + Err(_) => { + let type_name = values.get_type().name()?; + return Err(PyTypeError::new_err(format!( + "'{type_name}' object is not iterable" + ))); + } + }; + + match items.as_slice() { + [] => Err(PyValueError::new_err("Cannot humanize empty list")), + [_] => Ok(items + .into_iter() + .next() + .expect("Internal error: Cannot get sole item from iterator")), + [start, end] => Ok(format!("{start} {conjunction} {end}")), + [start @ .., end] => { + let start = start.join(", "); + Ok(format!("{start}, {conjunction} {end}",)) + } + } + } + + /// Fix syspath for easier importing in Python. + #[pymodule_init] + fn init(m: &Bound<'_, PyModule>) -> PyResult<()> { + fix_imports(m, "craft_cli._rs.utils") + } +} diff --git a/src/emitter.rs b/src/emitter.rs new file mode 100644 index 00000000..60a8cfaa --- /dev/null +++ b/src/emitter.rs @@ -0,0 +1,371 @@ +//! The Emitter class and its associated helpers. + +use std::{borrow::Cow, path::PathBuf, thread}; + +use pyo3::{Bound, PyResult, pyclass, pymethods, pymodule, types::PyType}; + +use crate::{ + printer::{Message, MessageType, Target}, + streams::StreamHandle, + utils, +}; + +/// Verbosity modes. +#[derive(Clone, Copy)] +#[pyclass] +pub enum Verbosity { + /// Quiet output. Most messages should not be output at all. + #[pyo3(name = "QUIET")] + Quiet, + + /// Brief output. Most messages should be ephemeral and all debugging-style message + /// models should be skipped. + #[pyo3(name = "BRIEF")] + Brief, + + /// Verbose mode. All messages should be persistent and all debugging-style messages + /// kept. + #[pyo3(name = "VERBOSE")] + Verbose, + + /// Debug mode. Similar to trace mode, but slightly less information from external + /// loggers is kept. + #[pyo3(name = "DEBUG")] + Debug, + + /// Trace mode. The absolute maximum amount of information should be printed. + #[pyo3(name = "TRACE")] + Trace, +} + +/// Emitter +#[pyclass] +struct Emitter { + /// The original filepath of the log file. + log_filepath: String, + + // Used by `report_error` on the Python side, which was left in Python due to + // the retrieved errors all still being in Python. + #[expect(unused)] + /// The base URL for error messages. + docs_base_url: String, + + /// The verbosity mode. + verbosity: Verbosity, + + /// The greeting the emitter was started with. + greeting: String, +} + +#[pymethods] +impl Emitter { + /// Construct a new `Emitter` from Python. + #[new] + fn new( + log_filepath: String, + verbosity: Verbosity, + docs_base_url: &str, + greeting: String, + ) -> Self { + Self { + log_filepath, + docs_base_url: docs_base_url.trim_end_matches('/').to_string(), + verbosity, + greeting, + } + } + + /// Create a log filepath from the app name as an easy default. + #[classmethod] + fn log_filepath_from_name(_cls: &Bound<'_, PyType>, app_name: &str) -> String { + let base_dir = dirs::state_dir() + .unwrap_or( + std::env::current_dir() + .expect("Could not find a suitable log location. As a fallback, make sure the current directory exists.") + ); + + let now = jiff::Timestamp::now(); + let mut log_filepath = PathBuf::new(); + + log_filepath.extend([ + app_name, + "log", + &now.strftime("%Y%m%d-%H%M%S.%f").to_string(), + ]); + + let final_path = base_dir.join(log_filepath).with_added_extension("log"); + final_path.display().to_string() + } + + /// Get the current verbosity mode of the emitter. + fn get_verbosity(&self) -> Verbosity { + self.verbosity + } + + /// Set the verbosity of the emitter. + fn set_verbosity(&mut self, new: Verbosity) -> PyResult<()> { + self.verbosity = new; + + if let Verbosity::Verbose | Verbosity::Debug | Verbosity::Trace = new { + let messages = [ + self.greeting.clone(), + format!("Logging execution to {}", self.log_filepath), + ]; + for message in messages { + crate::printer::printer().send(Message { + text: message, + model: MessageType::Info, + target: Some(Target::Stderr), + })?; + } + } + + Ok(()) + } + + /// Verbose information. + /// + /// Useful for providing more information to the user that isn't particularly + /// helpful for "regular use" + fn verbose(&mut self, text: &str) -> PyResult<()> { + let timestamped = utils::apply_timestamp(text); + + let (maybe_timestamped, target) = match self.verbosity { + Verbosity::Brief | Verbosity::Quiet => (text, None), + Verbosity::Verbose => (text, Some(Target::Stderr)), + Verbosity::Debug | Verbosity::Trace => (timestamped.as_ref(), Some(Target::Stderr)), + }; + let text = maybe_timestamped.to_string(); + let model = MessageType::Debug; + + let message = Message { + text, + model, + target, + }; + + crate::printer::printer().send(message)?; + Ok(()) + } + + /// Debug information. + /// + /// Use to record anything that the user may not want to normally see, but + /// would be useful for the app developers to understand why things may be + /// failing. + fn debug(&mut self, text: &str) -> PyResult<()> { + let timestamped = utils::apply_timestamp(text); + + let target = match self.verbosity { + Verbosity::Brief | Verbosity::Quiet | Verbosity::Verbose => None, + _ => Some(Target::Stderr), + }; + let text = timestamped.to_string(); + let model = MessageType::Debug; + + let message = Message { + text, + model, + target, + }; + + crate::printer::printer().send(message)?; + Ok(()) + } + + /// Trace information. + /// + /// Use to expose system-generated information which in general would be + /// overwhelming for debugging purposes but sometimes needed for more + /// in-depth analysis. + fn trace(&mut self, text: &str) -> PyResult<()> { + let timestamped = utils::apply_timestamp(text); + + let target = match self.verbosity { + Verbosity::Trace => Some(Target::Stderr), + _ => None, + }; + let text = timestamped.to_string(); + let model = MessageType::Trace; + + let message = Message { + text, + model, + target, + }; + + crate::printer::printer().send(message)?; + Ok(()) + } + + /// Progress information. + /// + /// This is normally used to present several related messages relaying how + /// a task is going. If a progress message is important enough that it + /// shouldn't be overwritten by the next ones, use "permanent=True". + /// + /// These messages will be truncated to the terminal's width and overwritten + /// by the next line (unless in verbose or trace mode, or set to permanent). + #[pyo3(signature = (text, *, permanent = false))] + fn progress(&mut self, text: &str, mut permanent: bool) -> PyResult<()> { + let timestamped = Self::apply_timestamp(text); + + let (maybe_timestamped, target) = match self.verbosity { + Verbosity::Quiet => { + permanent = false; + (text, None) + } + Verbosity::Brief => (text, Some(Target::Stderr)), + Verbosity::Verbose => { + permanent = true; + (text, Some(Target::Stderr)) + } + _ => { + permanent = true; + (timestamped.as_ref(), Some(Target::Stderr)) + } + }; + let model = if permanent { + MessageType::ProgPersistent() + } else { + MessageType::ProgEphemeral() + }; + let text = maybe_timestamped.to_owned(); + let message = Message { + text, + model, + target, + }; + + crate::printer::printer().send(message)?; + Ok(()) + } + + /// Show a simple message to the user. + /// + /// Ideally used as the final message in a sequence to show a result, as it + /// goes to stdout unlike other message types. + fn message(&mut self, text: String) -> PyResult<()> { + let target = match self.verbosity { + Verbosity::Quiet => None, + _ => Some(Target::Stdout), + }; + let model = MessageType::Info; + + let message = Message { + text, + model, + target, + }; + + crate::printer::printer().send(message)?; + Ok(()) + } + + /// Show an important warning to the user. + #[pyo3(signature = (text, prefix = "Warning: "))] + fn warning(&mut self, text: &str, prefix: &str) -> PyResult<()> { + let prefixed = format!("{}{}", prefix, text); + let timestamped = Self::apply_timestamp(&prefixed); + + let (maybe_timestamped, target) = match self.verbosity { + Verbosity::Quiet => (prefixed.as_str(), None), + Verbosity::Debug | Verbosity::Trace => (timestamped.as_ref(), Some(Target::Stderr)), + _ => (prefixed.as_str(), Some(Target::Stderr)), + }; + let text = maybe_timestamped.to_string(); + let model = MessageType::Warning; + + let message = Message { + text, + model, + target, + }; + + crate::printer::printer().send(message)?; + Ok(()) + } + + #[expect(unused)] + /// Render an incremental progress bar. + fn progress_bar(&mut self, text: &str, total: u64) -> PyResult<()> { + unimplemented!() + } + + /// Stop gracefully. + fn ended_ok(&mut self) -> PyResult<()> { + self.finish() + } + + /// Initialize the logger, if wanted. + /// + /// All messages sent by the emitter will also be sent to this log file moving forward. + fn init_logger(&self) -> PyResult<()> { + crate::printer::printer().init_logger(&self.log_filepath, &self.greeting) + } + + /// Open a stream context manager to redirect output to a different stream. + #[cfg(unix)] + fn open_stream(&self) -> StreamHandle { + StreamHandle::new(self.verbosity) + } + + /// Open a stream context manager to redirect output to a different stream. + #[cfg(windows)] + fn open_stream(&self) -> PyResult<()> { + use pyo3::exceptions::PyNotImplementedError; + + // The Python implementation of this hinged upon the fact that Python accepts + // C-style integer file descriptors on Windows using `msvcrt` to convert + // named pipes into file descriptors for `os.open()`. Rust does not have the + // same abstraction, instead forcing us into unsafe code with the `libc` crate + // for similar behavior. + Err(PyNotImplementedError::new_err( + "Stream context manager not yet supported on Windows.", + )) + } +} + +impl Emitter { + /// Apply the timestamp to a message if necessary. + fn apply_timestamp(text: &str) -> Cow<'_, str> { + format!( + "{} {}", + jiff::Timestamp::now().strftime("%Y-%m-%d %H:%M:%S%.3f"), + text + ) + .into() + } + + /// Stop the printing infrastructure and print a final message to see the logs. + fn finish(&mut self) -> PyResult<()> { + crate::printer::printer().stop()?; + Ok(()) + } +} + +impl Drop for Emitter { + fn drop(&mut self) { + if let Err(e) = crate::printer::printer().stop() + && !thread::panicking() + { + eprintln!("Cannot stop printer: {e:?}"); + } + } +} + +#[pymodule(submodule)] +pub mod emitter { + use crate::utils::fix_imports; + use pyo3::types::PyModule; + use pyo3::{Bound, PyResult}; + + #[pymodule_export] + use crate::emitter::{Emitter, Verbosity}; + + /// Fix syspath for easier importing in Python. + #[pymodule_init] + fn init(m: &Bound<'_, PyModule>) -> PyResult<()> { + fix_imports(m, "craft_cli._rs.emitter") + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 00000000..1c84e97f --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,22 @@ +//! Craft CLI +//! +//! The perfect foundation for your CLI situation. + +use pyo3::pymodule; + +mod craft_cli_utils; +mod emitter; +mod printer; +mod streams; +mod test_utils; +mod utils; + +/// A Python module implemented in Rust. +#[pymodule(name = "_rs")] +mod craft_cli_extensions { + #[pymodule_export] + use crate::craft_cli_utils::utils; + + #[pymodule_export] + use crate::emitter::emitter; +} diff --git a/src/printer.rs b/src/printer.rs new file mode 100644 index 00000000..501710c2 --- /dev/null +++ b/src/printer.rs @@ -0,0 +1,417 @@ +//! Handling for sending messages to a terminal. + +use std::{ + fs, + io::Write as _, + sync::{ + LazyLock, Mutex, MutexGuard, OnceLock, + mpsc::{self, RecvTimeoutError}, + }, + thread::{self, JoinHandle}, + time::Duration, +}; + +use pyo3::{PyErr, PyResult}; + +use crate::utils::{self, log}; + +/// Duration to wait before beginning to spin. +const SPIN_TIMEOUT: Duration = Duration::from_secs(3); + +/// The only printer to ever exist! +/// +/// The printer is declared this way in order to allow it being +/// accessed by potentially multiple threads. +/// +/// Since PyO3 plays poorly with lifetimes, it isn't possible to +/// pass around a reference to a singular `Printer`. Furthermore, +/// having a `JoinHandle<_>` attached to the `Printer` makes it +/// infeasible to clone or copy the printer between structs, even +/// in an `Arc`. +static PRINTER: LazyLock> = LazyLock::new(|| Mutex::new(Printer::new())); + +/// Get the printer singleton. +/// +/// If this is the first get, it will initialize a printer. +pub fn printer<'a>() -> MutexGuard<'a, Printer> { + PRINTER.lock().unwrap() +} + +/// Representation of which stream should be targeted by a message. +#[derive(Debug, Clone, Copy)] +pub enum Target { + /// Target the stdout stream. + Stdout, + + /// Target the stderr stream. + Stderr, +} + +impl From for indicatif::ProgressDrawTarget { + fn from(val: Target) -> Self { + match val { + Target::Stdout => indicatif::ProgressDrawTarget::stdout(), + Target::Stderr => indicatif::ProgressDrawTarget::stderr(), + } + } +} + +/// Types of message for printing. +#[derive(Clone, Copy, Debug)] +pub enum MessageType { + /// A persistent progress message that will remain on the console. + /// + /// For a non-permanent message, see `ProgEphemeral`. + ProgPersistent(), + + /// An ephemeral progress message that will be overwritten by the next message. + /// + /// For a permanent message, see `ProgPersistent`. + ProgEphemeral(), + + /// A warning message. + Warning, + + // Pending implementation of CraftError parsing in Rust + #[expect(unused)] + /// An error message. + Error, + + /// A debugging info message. + Debug, + + /// A trace info message. + Trace, + + /// An informational message. + Info, + + // Pending implementation of incremental progress bars using indicatif + #[expect(unused)] + /// Signals to create a progress bar. + ProgBar { + /// The target stream. + target: Option, + + /// The number of elements to render a progress bar for. + size: u64, + }, +} + +/// A single message to be sent, and what type of message it is. +#[derive(Clone, Debug)] +pub struct Message { + /// The message to be printed. + pub(crate) text: String, + + /// The type of message to send. + pub(crate) model: MessageType, + + /// Where the message should be sent. + pub(crate) target: Option, +} + +/// An internal printer object meant to print from a separate thread. +struct InnerPrinter { + /// A channel upon which messages can be read. + /// + /// If this channel is found to be closed, the program is over and this struct + /// should begin to destruct itself. + channel: mpsc::Receiver, + + /// A handle on stdout. + stdout: console::Term, + + /// A handle on stderr. + stderr: console::Term, + + /// A flag indicating if the previous line should be overwritten when printing + /// the next. + needs_overwrite: bool, +} + +impl InnerPrinter { + /// Instantiate a new `InnerPrinter`. + pub fn new(channel: mpsc::Receiver) -> Self { + let result = Self { + stdout: console::Term::stdout(), + stderr: console::Term::stderr(), + channel, + needs_overwrite: false, + }; + + // Hide the terminal cursor while taking control + result.stdout.hide_cursor().unwrap(); + + result + } + + /// Begin listening for messages on `self.channel`. + /// + /// This method will block execution until the the corresponding `Sender` for + /// `self.channel` is closed. As such, it is strongly recommended to only invoke + /// this from a dedicated thread. + pub fn listen(&mut self) -> PyResult<()> { + let main_style = + indicatif::ProgressStyle::with_template("{spinner} {msg} ({elapsed})").unwrap(); + let mut spinner: Option = None; + let mut maybe_prv_msg: Option = None; + + loop { + // Wait the standard 3 seconds for a message + match self.await_message(SPIN_TIMEOUT) { + Ok(msg) => { + // If we were spinning, stop + if let Some(s) = spinner.take() + && let Some(mut prv_msg) = maybe_prv_msg.take() + { + s.finish_and_clear(); + self.needs_overwrite = false; + let dur = indicatif::HumanDuration(s.elapsed()); + prv_msg.text = format!("{} (took {:#})", prv_msg.text, dur); + self.handle_message(&prv_msg)?; + } + // Store the most recently received message in case we need to + // begin displaying a spin loader + maybe_prv_msg = Some(msg.clone()); + self.handle_message(&msg)?; + } + // Break out of this loop if the channel is closed + Err(RecvTimeoutError::Disconnected) => break, + // If the three seconds elapsed, spin + Err(RecvTimeoutError::Timeout) => { + // If we're already spinning on a message, keep waiting + if spinner.is_some() { + continue; + } + + let msg = match &maybe_prv_msg { + Some(msg) => msg, + None => continue, + }; + + let target = match msg.target { + Some(target) => target.into(), + None => continue, + }; + + let new_spinner = indicatif::ProgressBar::with_draw_target(None, target) + .with_style(main_style.clone()) + .with_message(msg.text.clone()) + .with_elapsed(SPIN_TIMEOUT); + self.stdout.clear_last_lines(1).unwrap(); + new_spinner.enable_steady_tick(Duration::from_millis(100)); + spinner = Some(new_spinner); + } + } + } + + Ok(()) + } + + /// Helper method for receiving a message from `self.channel` + fn await_message(&self, timeout: Duration) -> ::std::result::Result { + self.channel.recv_timeout(timeout) + } + + /// Routing method for sending a message to the proper printing logic for a given + /// message type. + fn handle_message(&mut self, msg: &Message) -> PyResult<()> { + use MessageType as Mt; + if msg.target.is_none() { + return Ok(()); + } + match msg.model { + Mt::Info => self.print(msg), + Mt::Error | Mt::Warning | Mt::Debug => self.error(msg), + Mt::ProgEphemeral(..) => self.progress(msg, false), + Mt::ProgPersistent(..) => self.progress(msg, true), + Mt::Trace | Mt::ProgBar { .. } => unimplemented!(), + } + } + + /// Handle the need (or lackthereof) to overwrite the previous line. + fn handle_overwrite(&mut self) -> PyResult<()> { + if self.needs_overwrite { + self.stdout.clear_last_lines(1)?; + } + Ok(()) + } + + /// Print a simple message to stdout. + fn print(&mut self, message: &Message) -> PyResult<()> { + self.handle_overwrite()?; + self.stdout.write_line(&message.text)?; + Ok(()) + } + + /// Print a simple message to stderr. + fn error(&mut self, message: &Message) -> PyResult<()> { + self.handle_overwrite()?; + self.stderr.write_line(&message.text)?; + Ok(()) + } + + /// Print progress on a task. + fn progress(&mut self, message: &Message, permanent: bool) -> PyResult<()> { + self.needs_overwrite = !permanent; + match message + .target + .expect("Internal error: null message made it to printer") + { + Target::Stdout => self.print(message)?, + Target::Stderr => self.error(message)?, + }; + Ok(()) + } + + #[expect(unused)] + /// Handle an incremental progress bar. + fn progress_bar(&mut self, message: &Message) -> PyResult<()> { + unimplemented!() + } +} + +impl Drop for InnerPrinter { + /// Restore the cursor when releasing control of the terminal. + fn drop(&mut self) { + // Attempt to restore sanity, but don't break more if already panicking + let res = self + .handle_overwrite() + .map_or_else(|_| self.stdout.show_cursor(), Ok); + if let Err(e) = res + && !thread::panicking() + { + eprintln!("Unable to destruct inner printer: {e}"); + } + } +} + +/// Outer handler for printing. Stores a handle to the thread that `InnerPrinter` +/// is printing from, and a channel to send messages. +/// +/// Since an mpsc channel is being used, this struct can be arbitrarily cloned, +/// but should always use an existing `InnerPrinter` rather than constructing its +/// own new one. +#[derive(Default)] +pub struct Printer { + /// A handle on the thread running the `InnerPrinter` instance. + handle: OnceLock>>, + + /// A channel to send messages to the `InnerPrinter` instance. + channel: OnceLock>, + + /// A file handle to write to for logging operations. + log_handle: Option, +} + +impl Printer { + /// Create a new Printer. + fn new() -> Self { + let mut printer = Self::default(); + printer.start(); + printer + } + + /// Spawn a thread to begin listening for messages to print. + fn start(&mut self) { + let (send, recv) = mpsc::channel(); + + if self.channel.set(send).is_err() { + // The printer has already been started. + return; + } + + let handle = thread::spawn(move || -> PyResult<()> { + let mut printer = InnerPrinter::new(recv); + printer.listen()?; + Ok(()) + }); + + // If this fails, some sort of strange partial initialization state + // has happened where the send channel was set, but the corresponding + // thread handle that held the recv end of the channel was not saved. + // + // Since `OnceLocks` are only writable once, this is an irrecoverable + // state. We cannot create a new recv channel to match the already + // written thread handle. + self.handle.set(handle).unwrap(); + } + + /// Stop printing. + /// + /// This ends the `InnerPrinter` instance's thread. + pub fn stop(&mut self) -> PyResult<()> { + // Dropping the channel closes it, which will be seen by the other thread as a + // stopping condition + _ = self.channel.take(); + if let Some(handle) = self.handle.take() + && let Err(e) = handle.join() + { + // PyErr should be the only type returned by members of this + // crate, so we should be safe to blindly downcast. Failures + // here should be considered bugs rather than unhandled errors + return Err(*e.downcast::().unwrap()); + } + + Ok(()) + } + + /// Send a message to the `InnerPrinter` for displaying + pub fn send(&mut self, msg: Message) -> PyResult<()> { + self.log(&msg.text)?; + match self.channel.get() { + Some(chan) => chan.send(msg).unwrap(), + None => panic!("Receiver closed early?"), + } + Ok(()) + } +} + +impl Printer { + /// Initialize the logger, if wanted. + /// + /// All messages received by the printer will be sent to this log file. + pub fn init_logger(&mut self, filepath: &str, greeting: &str) -> PyResult<()> { + let log_handle = fs::OpenOptions::new() + .write(true) + .truncate(true) + .create(true) + .open(filepath)?; + + self.log_handle = Some(log_handle); + self.log(greeting)?; + + Ok(()) + } + + /// Print a string to the log with a timestamp. + fn log(&mut self, text: &str) -> PyResult<()> { + if let Some(log) = self.log_handle.as_mut() { + let timestamped = utils::apply_timestamp(text); + writeln!(log, "{timestamped}")?; + } + Ok(()) + } +} + +impl Drop for Printer { + fn drop(&mut self) { + if let Err(e) = self.stop() + && !thread::panicking() + { + if !thread::panicking() { + eprintln!("Error encountered in printing thread: {e:?}"); + } + + // Make a last-ditch attempt to restore the text cursor before bailing. + // + // This should be a no-op if it already succeeded during + // the tear down of another object, so this really is just insurance + // to try to leave the shell in a usable state. + if let Err(e) = console::Term::stdout().show_cursor() { + eprintln!("Unable to restore text cursor: {e}") + } + } + } +} diff --git a/src/streams.rs b/src/streams.rs new file mode 100644 index 00000000..badce17f --- /dev/null +++ b/src/streams.rs @@ -0,0 +1,257 @@ +//! Stream redirection tools. + +use std::{ + io::{self, Read}, + os::fd::{AsRawFd as _, RawFd}, + sync::{ + Arc, OnceLock, + atomic::{AtomicBool, Ordering}, + }, + thread::{self, JoinHandle}, + time::Duration, +}; + +use pyo3::{ + Bound, PyRefMut, PyResult, exceptions::PyRuntimeError, pyclass, pymethods, types::PyTuple, +}; + +use crate::{ + emitter::Verbosity, + printer::{Message, MessageType, Target}, + utils, +}; + +/// Number of bytes to read at a time from the pipe. +const PIPE_READER_CHUNK_SIZE: usize = 4096; + +/// A handle on a writable stream. +/// +/// All messages written to this stream will be sent to the log and emitter. +#[pyclass] +pub struct StreamHandle { + /// A join handle for the thread monitoring the pipe. + handle: OnceLock>>, + + /// An atomic bool to signal to the pipe-monitoring thread that it is time to + /// stop when set to true. + stop_flag: Arc, + + /// The verbosity level to log stream events. + verbosity: Verbosity, + + /// A handle on the write end of the pipe. This is kept for resource management + /// reasons so that this struct can decide when/where to drop the handle on this + /// pipe. + write: Option, +} + +#[pymethods] +impl StreamHandle { + /// Enter the context manager. + #[pyo3(name = "__enter__")] + fn enter(mut slf: PyRefMut<'_, Self>) -> PyResult> { + let (read, write) = io::pipe()?; + + slf.write = Some(write); + + let verbosity = slf.verbosity; + let stop_flag = Arc::clone(&slf.stop_flag); + let handle = thread::spawn(move || PipeListener::begin(read, verbosity, stop_flag)); + + if slf.handle.set(handle).is_err() { + return Err(PyRuntimeError::new_err( + "Internal error: thread handle was already allocated!", + )); + } + + Ok(slf) + } + + /// End the context manager. + #[pyo3(name = "__exit__", signature = (*_args))] + fn exit(&mut self, _args: Bound<'_, PyTuple>) -> PyResult<()> { + let handle = match self.handle.take() { + None => { + return Err(PyRuntimeError::new_err( + "Cannot exit, stream handle was never entered.", + )); + } + Some(handle) => handle, + }; + + self.stop_flag.store(true, Ordering::Relaxed); + + if let Err(e) = handle.join() { + return Err(PyRuntimeError::new_err(format!( + "Stream handler thread panicked: {e:?}" + ))); + } + + _ = self.write.take(); + + Ok(()) + } + + /// Get a writable file descriptor for this object. + fn fileno(&self) -> PyResult { + match &self.write { + Some(write) => Ok(write.as_raw_fd()), + None => Err(PyRuntimeError::new_err( + "Cannot get a fileno of an uninitialized StreamHandle.", + )), + } + } +} + +impl StreamHandle { + /// Construct a new StreamHandle using the verbosity of the rest of the program. + pub fn new(verbosity: Verbosity) -> Self { + Self { + handle: OnceLock::new(), + stop_flag: Arc::new(AtomicBool::new(false)), + verbosity, + write: None, + } + } +} + +/// An internal structure for monitoring a pipe for reads. +struct PipeListener { + /// The verbosity level to send messages to the printer with. This + /// is additionally used to determine whether messages should be ephemeral + /// or not. + verbosity: Verbosity, + + /// When set to true, this listener should exit its event loop and clean up so + /// its thread can be joined. + stop_flag: Arc, + + /// The leftover content from the last message read from the pipe. + /// + /// Since there's no guarantee that the pipe is newline-terminated, any content + /// beyond the last newline is stored here. Then, on the next read, + remaining_content: Vec, +} + +impl PipeListener { + fn begin( + pipe: io::PipeReader, + verbosity: Verbosity, + stop_flag: Arc, + ) -> PyResult<()> { + Self { + verbosity, + stop_flag, + remaining_content: Vec::new(), + } + .listen(pipe) + } + + /// Listening loop for messages on the read end of the pipe. + fn listen(&mut self, mut pipe: io::PipeReader) -> PyResult<()> { + let mut buf = [0u8; PIPE_READER_CHUNK_SIZE]; + + let mut poll = mio::Poll::new()?; + let mut events = mio::Events::with_capacity(128); + let mut listener = mio::unix::SourceFd(&pipe.as_raw_fd()); + poll.registry() + .register(&mut listener, mio::Token(0), mio::Interest::READABLE)?; + + while !self.stop_flag.load(Ordering::Relaxed) { + self.handle_pipe(&mut buf, &mut poll, &mut events, &mut pipe)?; + } + + // Mio requires explicit deregistration and dropping of resources. + // + // For more information: https://docs.rs/mio/1.1.1/mio/event/trait.Source.html#dropping-eventsources + poll.registry().deregister(&mut listener)?; + drop(pipe); + + // Once the event loop ends, assume the remaining content is complete + // and append a newline at the end for it. + if !self.remaining_content.is_empty() { + self.send_streamed_message(b"\n")?; + } + + Ok(()) + } + + /// Helper function to handle pipe events. + fn handle_pipe( + &mut self, + buf: &mut [u8; PIPE_READER_CHUNK_SIZE], + poll: &mut mio::Poll, + events: &mut mio::Events, + pipe: &mut io::PipeReader, + ) -> PyResult<()> { + poll.poll(events, Some(Duration::from_millis(100)))?; + + if events.is_empty() { + return Ok(()); + } + + let num_read = match pipe.read(buf) { + // No need to exit, we can just try again. + Err(e) => { + eprintln!("Failed to read from pipe: {e}"); + return Ok(()); + } + Ok(num_read) => { + // Don't send a message if we didn't receive anything. + if num_read == 0 { + return Ok(()); + } + num_read + } + }; + + self.send_streamed_message(&buf[0..num_read]) + } + + /// Helper function for handling the content read from the pipe. + fn send_streamed_message(&mut self, message: &[u8]) -> PyResult<()> { + // Append the new content to the content left over from the previous print + self.remaining_content.extend_from_slice(message); + + let all_parts = self + .remaining_content + .split(|c| *c == b'\n') + .collect::>(); + + let (last, parts) = all_parts + .split_last() + .expect("Internal error: Attempted to send empty content through stream handle"); + + for part in parts { + let parsed = String::from_utf8_lossy(part); + + let text = match self.verbosity { + Verbosity::Debug | Verbosity::Trace => utils::apply_timestamp(&parsed), + _ => parsed, + } + .to_string(); + let model = match self.verbosity { + Verbosity::Quiet | Verbosity::Brief => MessageType::ProgEphemeral(), + Verbosity::Verbose | Verbosity::Debug | Verbosity::Trace => { + MessageType::ProgPersistent() + } + }; + let target = match self.verbosity { + Verbosity::Quiet => None, + _ => Some(Target::Stderr), + }; + + let message = Message { + text, + model, + target, + }; + + crate::printer::printer().send(message)?; + } + + self.remaining_content = last.to_vec(); + + Ok(()) + } +} diff --git a/src/test_utils.rs b/src/test_utils.rs new file mode 100644 index 00000000..8f157495 --- /dev/null +++ b/src/test_utils.rs @@ -0,0 +1,46 @@ +//! Utilities for testing +#![cfg(test)] + +use pyo3::{PyErr, PyTypeInfo, Python}; +use regex::Regex; + +pub fn assert_error_type(err: &PyErr) { + Python::attach(|py| assert!(err.is_instance_of::(py))); +} + +pub fn assert_error_contents(err: &PyErr, re: &str) { + let re: Regex = re.try_into().expect("Could not be parsed as regex!"); + + Python::attach(|py| { + let value = err.value(py).to_string(); + assert!(re.is_match(&value)); + }); +} + +#[cfg(test)] +mod tests { + use super::*; + use pyo3::exceptions::PyValueError; + + mod assert_error_type { + use super::*; + + #[test] + fn basic() { + let err = PyValueError::new_err("Oh no!"); + + assert_error_type::(&err); + } + } + + mod assert_error_contents { + use super::*; + + #[test] + fn basic() { + let err = PyValueError::new_err("Oh no!"); + + assert_error_contents(&err, "Oh no!"); + } + } +} diff --git a/src/utils.rs b/src/utils.rs new file mode 100644 index 00000000..5eee3006 --- /dev/null +++ b/src/utils.rs @@ -0,0 +1,60 @@ +//! Internal utils for Craft CLI. + +use std::borrow::Cow; + +use pyo3::{ + Bound, PyResult, Python, + types::{PyAnyMethods, PyModule}, +}; + +/// Hack: workaround for [an upstream issue in PyO3](https://github.com/PyO3/pyo3/issues/759) +pub fn fix_imports(m: &Bound<'_, PyModule>, name: &str) -> PyResult<()> { + Python::attach(|py| py.import("sys")?.getattr("modules")?.set_item(name, m)) +} + +/// Apply the timestamp to a message if necessary. +pub fn apply_timestamp(text: &str) -> Cow<'_, str> { + format!( + "{} {}", + jiff::Timestamp::now().strftime("%Y-%m-%d %H:%M:%S%.3f"), + text + ) + .into() +} + +// This log function is very convenient for development, but may not necessarily always exist +// in live code. +#[allow(unused, clippy::allow_attributes)] +/// Log a message for debugging purposes only. +/// +/// All messages will go to `./craft-cli-debug.log`. This file will be created the first time +/// a message attempts to be logged, and will be cleared between runs. +pub fn log(message: impl Into) { + #[cfg(debug_assertions)] + { + use std::{ + fs, + io::Write as _, + sync::{LazyLock, Mutex}, + }; + + static FILE: LazyLock> = LazyLock::new(|| { + let mut handle = fs::OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .open("craft-cli-debug.log") + .expect("Couldn't open debugging log!"); + + writeln!( + handle, + "I hope you find what you are looking for, traveller." + ) + .expect("Cannot write to debugging log"); + Mutex::new(handle) + }); + + writeln!(FILE.lock().unwrap(), "{}", message.into()) + .expect("Cannot write to debugging log"); + } +} diff --git a/uv.lock b/uv.lock index b894821e..ceee8169 100644 --- a/uv.lock +++ b/uv.lock @@ -431,6 +431,7 @@ toml = [ [[package]] name = "craft-cli" +version = "0.0.0+dev" source = { editable = "." } dependencies = [ { name = "jinja2" }, @@ -442,6 +443,7 @@ dependencies = [ [package.dev-dependencies] dev = [ { name = "coverage", extra = ["toml"] }, + { name = "maturin" }, { name = "pytest" }, { name = "pytest-cov" }, { name = "pytest-mock" }, @@ -480,6 +482,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ { name = "coverage", extras = ["toml"], specifier = "==7.13.0" }, + { name = "maturin", specifier = ">=1.11.5" }, { name = "pytest", specifier = "==8.4.2" }, { name = "pytest-cov", specifier = "==7.0.0" }, { name = "pytest-mock", specifier = "==3.15.1" }, @@ -873,6 +876,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739, upload-time = "2024-10-18T15:21:42.784Z" }, ] +[[package]] +name = "maturin" +version = "1.11.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a4/84/bfed8cc10e2d8b6656cf0f0ca6609218e6fcb45a62929f5094e1063570f7/maturin-1.11.5.tar.gz", hash = "sha256:7579cf47640fb9595a19fe83a742cbf63203f0343055c349c1cab39045a30c29", size = 226885, upload-time = "2026-01-09T11:06:13.801Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/6c/3443d2f8c6d4eae5fc7479cd4053542aff4c1a8566d0019d0612d241b15a/maturin-1.11.5-py3-none-linux_armv6l.whl", hash = "sha256:edd1d4d35050ea2b9ef42aa01e87fe019a1e822940346b35ccb973e0aa8f6d82", size = 8845897, upload-time = "2026-01-09T11:06:17.327Z" }, + { url = "https://files.pythonhosted.org/packages/c5/03/abf1826d8aebc0d47ef6d21bdd752d98d63ac4372ad2b115db9cd5176229/maturin-1.11.5-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:2a596eab137cb3e169b97e89a739515abfa7a8755e2e5f0fc91432ef446f74f4", size = 17233855, upload-time = "2026-01-09T11:06:04.272Z" }, + { url = "https://files.pythonhosted.org/packages/90/a1/5ad62913271724035a7e4bcf796d7c95b4119317ae5f8cb034844aa99bc4/maturin-1.11.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:1c27a2eb47821edf26c75d100b3150b52dca2c1a5f074d7514af06f7a7acb9d5", size = 8881776, upload-time = "2026-01-09T11:06:10.24Z" }, + { url = "https://files.pythonhosted.org/packages/c6/66/997974b44f8d3de641281ec04fbf5b6ca821bdc8291a2fa73305978db74d/maturin-1.11.5-py3-none-manylinux_2_12_i686.manylinux2010_i686.musllinux_1_1_i686.whl", hash = "sha256:f1320dacddcd3aa84a4bdfc77ee6fdb60e4c3835c853d7eb79c09473628b0498", size = 8870347, upload-time = "2026-01-09T11:06:12.178Z" }, + { url = "https://files.pythonhosted.org/packages/58/e0/c8fa042daf0608cc2e9a59b6df3a9e287bfc7f229136f17727f4118bac2d/maturin-1.11.5-py3-none-manylinux_2_12_x86_64.manylinux2010_x86_64.musllinux_1_1_x86_64.whl", hash = "sha256:ffe7418834ff3b4a6c987187b7abb85ba033f4733e089d77d84e2de87057b4e7", size = 9291396, upload-time = "2026-01-09T11:06:02.05Z" }, + { url = "https://files.pythonhosted.org/packages/99/af/9d3edc8375efc8d435d5f24794bc4de234d4e743447592da970d53b31361/maturin-1.11.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:c739b243d012386902f112ea63a54a94848932b70ae3565fa5e121fd1c0200e0", size = 8827831, upload-time = "2026-01-09T11:06:19.523Z" }, + { url = "https://files.pythonhosted.org/packages/8a/12/cc341f6abbf9005f90935a4ee5dc7b30e2df7d1bb90b96d48b756b2c0ee7/maturin-1.11.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:8127d2cd25950bacbcdc8a2ec6daab1d4d27200f7d73964392680ad64d27f7f0", size = 8718895, upload-time = "2026-01-09T11:06:21.617Z" }, + { url = "https://files.pythonhosted.org/packages/76/17/654a59c66287e287373f2a0086e4fc8a23f0545a81c2bd6e324db26a5801/maturin-1.11.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.musllinux_1_1_ppc64le.whl", hash = "sha256:2a4e872fb78e77748217084ffeb59de565d08a86ccefdace054520aaa7b66db4", size = 11384741, upload-time = "2026-01-09T11:06:15.261Z" }, + { url = "https://files.pythonhosted.org/packages/2e/da/7118de648182971d723ea99d79c55007f96cdafc95f5322cc1ad15f6683e/maturin-1.11.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2079447967819b5cf615e5b5b99a406d662effdc8d6afd493dcd253c6afc3707", size = 9423814, upload-time = "2026-01-09T11:05:57.242Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8f/be14395c6e23b19ddaa0c171e68915bdcd1ef61ad1f411739c6721196903/maturin-1.11.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:50f6c668c1d5d4d4dc1c3ffec7b4270dab493e5b2368f8e4213f4bcde6a50eea", size = 9104378, upload-time = "2026-01-09T11:05:59.835Z" }, + { url = "https://files.pythonhosted.org/packages/77/83/53ea82a2f42a03930ea5545673d11a4ef49bb886827353a701f41a5f11c4/maturin-1.11.5-py3-none-win32.whl", hash = "sha256:49f85ce6cbe478e9743ecddd6da2964afc0ded57013aa4d054256be702d23d40", size = 7738696, upload-time = "2026-01-09T11:06:06.651Z" }, + { url = "https://files.pythonhosted.org/packages/3c/41/353a26d49aa80081c514a6354d429efbecedb90d0153ec598cece3baa607/maturin-1.11.5-py3-none-win_amd64.whl", hash = "sha256:70d3e5beffb9ef9dfae5f3c1a7eeb572091505eb8cb076e9434518df1c42a73b", size = 9029838, upload-time = "2026-01-09T11:05:54.543Z" }, + { url = "https://files.pythonhosted.org/packages/15/67/c94f8f5440bc42d54113a2d99de0d6107f06b5a33f31823e52b2715d856f/maturin-1.11.5-py3-none-win_arm64.whl", hash = "sha256:9348f7f0a346108e0c96e6719be91da4470bd43c15802435e9f4157f5cca43d4", size = 7624029, upload-time = "2026-01-09T11:06:08.728Z" }, +] + [[package]] name = "mccabe" version = "0.7.0"