From cedbd068a37941855eaf7408cfab83c8628b65ff Mon Sep 17 00:00:00 2001 From: Imani Pelton Date: Thu, 22 Jan 2026 07:40:51 -0500 Subject: [PATCH 01/17] build: add rust build infrastructure --- .gitignore | 5 + Cargo.lock | 446 +++++++++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 28 ++++ pyproject.toml | 57 +++---- uv.lock | 27 +++ 5 files changed, 527 insertions(+), 36 deletions(-) create mode 100644 Cargo.lock create mode 100644 Cargo.toml diff --git a/.gitignore b/.gitignore index 28596654..157cedf8 100644 --- a/.gitignore +++ b/.gitignore @@ -149,3 +149,8 @@ dmypy.json # JetBrains IDEs /.idea/ + + +# Added by cargo + +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 00000000..b16a9640 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,446 @@ +# 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 = "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", + "indicatif", + "jiff", + "pyo3", + "regex", + "xdg", +] + +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + +[[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 = "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 = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[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 = "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 = "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 = "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", +] + +[[package]] +name = "xdg" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fb433233f2df9344722454bc7e96465c9d03bff9d77c248f9e7523fe79585b5" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 00000000..7a77a4d0 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "craft-cli" +edition = "2024" + +[lib] +name = "_rs" +crate-type = ["cdylib"] +path = "rust/lib.rs" + +[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" +indicatif = { version = "0.18.0", features = ["improved_unicode"] } +jiff = "0.2.15" +pyo3 = { features = ["extension-module"], workspace = true } +xdg = "3.0.0" + +[dev-dependencies] +pyo3 = { features = ["auto-initialize"], workspace = true } +regex = "1.11.1" diff --git a/pyproject.toml b/pyproject.toml index 134ba277..f6a5542b 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 = "python/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/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" From 1ca1d92ceb0c3af1139467026518e4ad334bce78 Mon Sep 17 00:00:00 2001 From: Imani Pelton Date: Thu, 22 Jan 2026 09:12:16 -0500 Subject: [PATCH 02/17] feat: add rust-based emitter class --- rust/craft_cli_utils.rs | 36 ++++ rust/emitter.rs | 362 ++++++++++++++++++++++++++++++++++++++++ rust/lib.rs | 33 ++++ rust/printer.rs | 347 ++++++++++++++++++++++++++++++++++++++ rust/test_utils.rs | 54 ++++++ rust/utils.rs | 45 +++++ 6 files changed, 877 insertions(+) create mode 100644 rust/craft_cli_utils.rs create mode 100644 rust/emitter.rs create mode 100644 rust/lib.rs create mode 100644 rust/printer.rs create mode 100644 rust/test_utils.rs create mode 100644 rust/utils.rs diff --git a/rust/craft_cli_utils.rs b/rust/craft_cli_utils.rs new file mode 100644 index 00000000..3c653bf7 --- /dev/null +++ b/rust/craft_cli_utils.rs @@ -0,0 +1,36 @@ +//! Utility functions for Craft CLI + +use pyo3::pymodule; + +/// Utility functions for Craft CLI +#[pymodule(submodule)] +pub mod utils { + use pyo3::{Bound, PyResult, pyfunction, types::PyModule}; + + 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(mut values: Vec, conjunction: Option<&str>) -> String { + let start = values + .drain(..values.len() - 1) + .collect::>() + .join(", "); + + let conjunction = conjunction.unwrap_or("and"); + + format!( + "{}, {} {}", + start, + conjunction, + values.first().expect("Guaranteed by drain call above") + ) + } + + /// 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/rust/emitter.rs b/rust/emitter.rs new file mode 100644 index 00000000..34cf7689 --- /dev/null +++ b/rust/emitter.rs @@ -0,0 +1,362 @@ +//! The Emitter class and its associated helpers. + +use std::{ + borrow::Cow, + fs::{self, File}, + io::Write as _, +}; + +use pyo3::{Bound, PyResult, Python, pyclass, pymethods, pymodule, types::PyType}; + +use crate::printer::{Message, MessageType, Printer, Target}; + +/// Verbosity modes. +#[non_exhaustive] +#[derive(Clone, Copy)] +#[pyclass] +pub enum Verbosity { + /// Quiet output. Most messages should not be output at all. + Quiet, + + /// Brief output. Most messages should be ephemeral and all debugging-style message + /// models should be skipped. + Brief, + + /// Verbose mode. All messages should be persistent and all debugging-style messages + /// kept. + Verbose, + + /// Debug mode. Similar to trace mode, but slightly less information from external + /// loggers is kept. + Debug, + + /// Trace mode. The absolute maximum amount of information should be printed. + Trace, +} + +/// Emitter +#[pyclass] +struct Emitter { + /// Internal printer instance for sending messages. + /// + /// Executes I/O operations in a separate thread to make all logging non-blocking. + printer: Printer, + + /// A handle to the desired log file. + log_handle: File, + + /// 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( + py: Python<'_>, + log_filepath: String, + verbosity: Verbosity, + docs_base_url: &str, + greeting: String, + ) -> PyResult { + let mut printer = Printer::default(); + + // Spawn the printer thread without using the GIL at all + // This is necessary to avoid deadlocks when using OnceCell, see the link below + // for more information. + // https://pyo3.rs/v0.25.1/faq.html#im-experiencing-deadlocks-using-pyo3-with-stdsynconcelock-stdsynclazylock-lazy_static-and-once_cell + py.detach(|| printer.start(verbosity)); + + let log_handle = fs::OpenOptions::new() + .write(true) + .truncate(true) + .create(true) + .open(&log_filepath)?; + + Ok(Self { + printer, + log_handle, + 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: String) -> String { + let dirs = xdg::BaseDirectories::with_prefix(app_name); + let mut p = dirs + .get_data_home() + .unwrap_or(std::env::current_dir().expect("Could not find suitable log location. As a fallback, make sure the current directory exists.")); + + let now = jiff::Timestamp::now(); + let filename = format!("{}.log", now.strftime("%Y%m%d-%H%M%S.%f")); + p.extend(["log", &filename]); + p.to_string_lossy().into() + } + + /// 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) { + 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 { + self.printer.send(Message { + text: message, + model: MessageType::Info(), + target: Target::Stderr, + }); + } + } + } + + /// 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 = Self::apply_timestamp(text); + self.log(×tamped)?; + + let (maybe_timestamped, target) = match self.verbosity { + Verbosity::Brief | Verbosity::Quiet => (text, Target::Null), + Verbosity::Verbose => (text, Target::Stderr), + _ => (timestamped.as_ref(), Target::Stderr), + }; + + let message = Message { + text: maybe_timestamped.to_string(), + target, + model: MessageType::Debug(), + }; + + self.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 = Self::apply_timestamp(text); + self.log(×tamped)?; + + let target = match self.verbosity { + Verbosity::Brief | Verbosity::Quiet | Verbosity::Verbose => Target::Null, + _ => Target::Stderr, + }; + + let message = Message { + text: timestamped.to_string(), + target, + model: MessageType::Debug(), + }; + + self.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 = Self::apply_timestamp(text); + self.log(×tamped)?; + + let target = match self.verbosity { + Verbosity::Trace => Target::Stderr, + _ => Target::Null, + }; + + let message = Message { + text: timestamped.to_string(), + target, + model: MessageType::Trace(), + }; + + self.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). + fn progress(&mut self, text: &str, mut permanent: Option) -> PyResult<()> { + let timestamped = Self::apply_timestamp(text); + self.log(×tamped)?; + + let (maybe_timestamped, target) = match self.verbosity { + Verbosity::Quiet => { + permanent = Some(false); + (text, Target::Null) + } + Verbosity::Brief => (text, Target::Stderr), + Verbosity::Verbose => { + permanent = Some(true); + (text, Target::Stderr) + } + _ => { + permanent = Some(true); + (timestamped.as_ref(), Target::Stderr) + } + }; + + let message = Message { + text: maybe_timestamped.to_string(), + model: if permanent.unwrap_or(false) { + MessageType::ProgPersistent(target) + } else { + MessageType::ProgEphemeral(target) + }, + target, + }; + + self.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 timestamped = Self::apply_timestamp(&text); + self.log(×tamped)?; + + let target = match self.verbosity { + Verbosity::Quiet => Target::Null, + _ => Target::Stdout, + }; + + let message = Message { + text, + model: MessageType::Info(), + target, + }; + + self.printer.send(message); + Ok(()) + } + + /// Show an important warning to the user. + #[pyo3(signature = (text, prefix = "Warning: "))] + fn warning(&mut self, text: &str, prefix: Option<&str>) -> PyResult<()> { + let prefixed = format!("{}{}", prefix.unwrap_or("Warning: "), text); + let timestamped = Self::apply_timestamp(&prefixed); + self.log(×tamped)?; + + let (maybe_timestamped, target) = match self.verbosity { + Verbosity::Quiet => (prefixed.as_str(), Target::Null), + Verbosity::Debug | Verbosity::Trace => (timestamped.as_ref(), Target::Stderr), + _ => (prefixed.as_str(), Target::Stderr), + }; + + let message = Message { + text: maybe_timestamped.to_string(), + model: MessageType::Warning(), + target, + }; + + self.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() + } +} + +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() + } + + /// Print a string to the log. + fn log(&mut self, text: &str) -> PyResult<()> { + self.log_handle.write_all(text.as_ref())?; + Ok(()) + } + + /// Stop the printing infrastructure and print a final message to see the logs. + fn finish(&mut self) -> PyResult<()> { + let message = Message { + text: format!("Full execution log at '{}'", self.log_filepath), + model: MessageType::Info(), + target: Target::Stderr, + }; + self.printer.send(message); + self.printer.stop()?; + Ok(()) + } +} + +impl Drop for Emitter { + fn drop(&mut self) { + self.printer.stop().expect( + "An unknown error has occurred! The Emitter was not stopped correctly,\ + so context about the error has been lost. Please report this error.", + ); + } +} + +#[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/rust/lib.rs b/rust/lib.rs new file mode 100644 index 00000000..638060fd --- /dev/null +++ b/rust/lib.rs @@ -0,0 +1,33 @@ +#![warn( + clippy::pedantic, + clippy::mem_forget, + clippy::allow_attributes, + clippy::dbg_macro, + clippy::clone_on_ref_ptr, + clippy::missing_docs_in_private_items +)] +// Specifically allow wildcard imports as they are a very common pattern for enum +// matching and module setup +#![allow(clippy::wildcard_imports, clippy::enum_glob_use)] + +//! Craft CLI +//! +//! The perfect foundation for your CLI situation. + +use pyo3::pymodule; + +mod craft_cli_utils; +mod emitter; +mod printer; +mod test_utils; +mod utils; + +/// A Python module implemented in Rust. +#[pymodule] +mod _rs { + #[pymodule_export] + use crate::craft_cli_utils::utils; + + #[pymodule_export] + use crate::emitter::emitter; +} diff --git a/rust/printer.rs b/rust/printer.rs new file mode 100644 index 00000000..1094bbe0 --- /dev/null +++ b/rust/printer.rs @@ -0,0 +1,347 @@ +//! Handling for sending messages to a terminal. + +use std::{ + sync::{ + LazyLock, OnceLock, + mpsc::{self, RecvTimeoutError}, + }, + thread::{self, JoinHandle}, + time::Duration, +}; + +use pyo3::{PyErr, PyResult}; + +use crate::emitter::Verbosity; + +/// 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, + + /// Target no stream at all. + Null, +} + +impl From for indicatif::ProgressDrawTarget { + fn from(val: Target) -> Self { + match val { + Target::Stdout => indicatif::ProgressDrawTarget::stdout(), + Target::Stderr => indicatif::ProgressDrawTarget::stderr(), + Target::Null => indicatif::ProgressDrawTarget::hidden(), + } + } +} + +/// 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(Target), + + /// An ephemeral progress message that will be overwritten by the next message. + /// + /// For a permanent message, see `ProgPersistent`. + ProgEphemeral(Target), + + /// 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(Target, 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: Target, +} + +impl Message { + /// Calculate which stream a message should go to based on its model. + pub fn determine_stream(&self, mode: Verbosity) -> Option { + use self::Target::*; + match self.model { + MessageType::ProgPersistent(target) + | MessageType::ProgEphemeral(target) + | MessageType::ProgBar(target, ..) => target.into(), + MessageType::Warning() | MessageType::Error() => Stderr.into(), + MessageType::Debug() | MessageType::Trace() | MessageType::Info() => match mode { + Verbosity::Verbose => Stdout.into(), + _ => None, + }, + } + } +} + +/// 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, + + /// Printing verbosity mode. + mode: Verbosity, + + /// 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(mode: Verbosity, channel: mpsc::Receiver) -> Self { + let result = Self { + stdout: console::Term::stdout(), + stderr: console::Term::stderr(), + channel, + mode, + 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<()> { + static MAIN_STYLE: LazyLock = LazyLock::new(|| { + 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(Duration::from_secs(3)) { + 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; + } + // If there's a previous message to spin on, then, + spinner = maybe_prv_msg.as_ref().and_then(|prv_msg| { + // If there is a stream to print to, + prv_msg.determine_stream(self.mode).map(|target| { + // Construct a spinner + let s = indicatif::ProgressBar::with_draw_target(None, target.into()) + .with_message(prv_msg.text.clone()) + .with_style(MAIN_STYLE.clone()) + .with_elapsed(Duration::from_secs(3)); + + // It doesn't matter which stream we clear, the line we're about to + // spin is wiped either way + self.stdout.clear_last_lines(1).unwrap(); + // Start spinning + s.enable_steady_tick(Duration::from_millis(100)); + s + }) + }); + } + } + } + + 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 self::MessageType::*; + if let Target::Null = msg.target { + return Ok(()); + } + match msg.model { + Info() => self.print(msg), + Error() => self.error(msg), + ProgEphemeral(..) => self.progress(msg, false), + ProgPersistent(..) => self.progress(msg, true), + _ => 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.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.handle_overwrite()?; + self.needs_overwrite = !permanent; + self.print(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) { + self.handle_overwrite().unwrap(); + self.stdout.show_cursor().unwrap(); + } +} + +/// 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>, +} + +impl Printer { + /// Spawn a thread to begin listening for messages to print. + pub fn start(&mut self, mode: Verbosity) { + 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(mode, 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(&self, msg: Message) { + match self.channel.get() { + Some(chan) => chan.send(msg).unwrap(), + None => panic!("Receiver closed early?"), + } + } +} + +impl Drop for Printer { + fn drop(&mut self) { + self.stop().expect("An error was encountered while logging. Tear down the printer properly to view the error."); + } +} diff --git a/rust/test_utils.rs b/rust/test_utils.rs new file mode 100644 index 00000000..288d045f --- /dev/null +++ b/rust/test_utils.rs @@ -0,0 +1,54 @@ +//! Utilities for testing +#![cfg(test)] + +use std::fmt::Debug; + +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, r#match: R) +where + // Accept anything that can be converted into Regex + R: TryInto, + // Should always be true, this is just to please the type checker + >::Error: Debug, +{ + let re: Regex = r#match.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/rust/utils.rs b/rust/utils.rs new file mode 100644 index 00000000..2e57da55 --- /dev/null +++ b/rust/utils.rs @@ -0,0 +1,45 @@ +//! Internal utils for Craft CLI. + +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)) +} + +// 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. +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!"); + + handle + .write_all("I hope you find what you are looking for, traveller.\n".as_ref()) + .expect("Couldn't write to debugging log!"); + + Mutex::new(handle) + }); + FILE.lock() + .unwrap() + .write_all(format!("{}\n", message.into()).as_ref()) + .expect("Couldn't write to debugging log!"); + } +} From b1ffca28aaf2658572439165ac315f3ee7b56469 Mon Sep 17 00:00:00 2001 From: Imani Pelton Date: Thu, 22 Jan 2026 09:27:10 -0500 Subject: [PATCH 03/17] feat: incorporate oxidized emitter --- craft_cli/__init__.py | 3 +- craft_cli/dispatcher.py | 25 +- craft_cli/messages.py | 907 ---------------------------------------- craft_cli/printer.py | 528 ----------------------- rust/emitter.rs | 5 + 5 files changed, 17 insertions(+), 1451 deletions(-) delete mode 100644 craft_cli/messages.py delete mode 100644 craft_cli/printer.py 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..96437ab6 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.Logger(__file__) + class CommandGroup(NamedTuple): """Definition of a command group. @@ -90,6 +92,8 @@ def __post_init__(self) -> None: self.choices = [choice.lower() for choice in self.choices] +_VERBOSITIES = frozenset({"quiet", "brief", "verbose", "debug", "trace"}) + _DEFAULT_GLOBAL_ARGS = [ GlobalArgument( "help", @@ -118,8 +122,8 @@ 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), + validator=lambda mode: mode in _VERBOSITIES, case_sensitive=False, ), ] @@ -286,7 +290,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 +511,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 +525,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 +548,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/rust/emitter.rs b/rust/emitter.rs index 34cf7689..e7b91c70 100644 --- a/rust/emitter.rs +++ b/rust/emitter.rs @@ -16,21 +16,26 @@ use crate::printer::{Message, MessageType, Printer, Target}; #[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, } From 12b4343ad617b19ce1cb28679916c0e70b4df99e Mon Sep 17 00:00:00 2001 From: Imani Pelton Date: Thu, 22 Jan 2026 16:42:41 -0500 Subject: [PATCH 04/17] style: enable linting with clippy --- Makefile | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index c341dec8..20ccfe57 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,19 @@ 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 +endif + + .PHONY: lint-ty lint-ty: install-ty ty check From 4ad2cd7deb2fdc3b787dda89c640c70d21a9bdec Mon Sep 17 00:00:00 2001 From: Imani Pelton Date: Fri, 23 Jan 2026 16:09:55 -0500 Subject: [PATCH 05/17] refactor: apply code suggestions from rust surgery --- rust/craft_cli_utils.rs | 28 ++++++++++++------------- rust/printer.rs | 46 ++++++++++++++++++++--------------------- 2 files changed, 36 insertions(+), 38 deletions(-) diff --git a/rust/craft_cli_utils.rs b/rust/craft_cli_utils.rs index 3c653bf7..c366973c 100644 --- a/rust/craft_cli_utils.rs +++ b/rust/craft_cli_utils.rs @@ -5,27 +5,27 @@ use pyo3::pymodule; /// Utility functions for Craft CLI #[pymodule(submodule)] pub mod utils { - use pyo3::{Bound, PyResult, pyfunction, types::PyModule}; + use pyo3::{Bound, PyResult, exceptions::PyValueError, pyfunction, types::PyModule}; 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(mut values: Vec, conjunction: Option<&str>) -> String { - let start = values - .drain(..values.len() - 1) - .collect::>() - .join(", "); - + fn humanize_list(values: Vec, conjunction: Option<&str>) -> PyResult { let conjunction = conjunction.unwrap_or("and"); - - format!( - "{}, {} {}", - start, - conjunction, - values.first().expect("Guaranteed by drain call above") - ) + match values.as_slice() { + [] => Err(PyValueError::new_err("Cannot humanize empty list")), + [_] => Ok(values + .into_iter() + .next() + .expect("Size checked by match arm")), + [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. diff --git a/rust/printer.rs b/rust/printer.rs index 1094bbe0..9c88b3b2 100644 --- a/rust/printer.rs +++ b/rust/printer.rs @@ -2,7 +2,7 @@ use std::{ sync::{ - LazyLock, OnceLock, + OnceLock, mpsc::{self, RecvTimeoutError}, }, thread::{self, JoinHandle}, @@ -13,6 +13,9 @@ use pyo3::{PyErr, PyResult}; use crate::emitter::Verbosity; +/// Duration to wait before beginning to spin. +const SPIN_TIMEOUT: Duration = Duration::from_secs(3); + /// Representation of which stream should be targeted by a message. #[derive(Debug, Clone, Copy)] pub enum Target { @@ -147,16 +150,14 @@ impl InnerPrinter { /// `self.channel` is closed. As such, it is strongly recommended to only invoke /// this from a dedicated thread. pub fn listen(&mut self) -> PyResult<()> { - static MAIN_STYLE: LazyLock = LazyLock::new(|| { - indicatif::ProgressStyle::with_template("{spinner} {msg} ({elapsed})").unwrap() - }); + 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(Duration::from_secs(3)) { + match self.await_message(SPIN_TIMEOUT) { Ok(msg) => { // If we were spinning, stop if let Some(s) = spinner.take() @@ -181,24 +182,21 @@ impl InnerPrinter { if spinner.is_some() { continue; } - // If there's a previous message to spin on, then, - spinner = maybe_prv_msg.as_ref().and_then(|prv_msg| { - // If there is a stream to print to, - prv_msg.determine_stream(self.mode).map(|target| { - // Construct a spinner - let s = indicatif::ProgressBar::with_draw_target(None, target.into()) - .with_message(prv_msg.text.clone()) - .with_style(MAIN_STYLE.clone()) - .with_elapsed(Duration::from_secs(3)); - - // It doesn't matter which stream we clear, the line we're about to - // spin is wiped either way - self.stdout.clear_last_lines(1).unwrap(); - // Start spinning - s.enable_steady_tick(Duration::from_millis(100)); - s - }) - }); + + let Some(msg) = maybe_prv_msg.as_ref() else { + continue; + }; + let Some(target) = msg.determine_stream(self.mode) else { + continue; + }; + + let new_spinner = indicatif::ProgressBar::with_draw_target(None, target.into()) + .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); } } } From 34c10aa81dfef8e440b51b9bad24be4f1f955e00 Mon Sep 17 00:00:00 2001 From: Imani Pelton Date: Tue, 27 Jan 2026 09:19:24 -0500 Subject: [PATCH 06/17] chore: rename source directory from rust to src --- Cargo.toml | 1 - {rust => src}/craft_cli_utils.rs | 0 {rust => src}/emitter.rs | 0 {rust => src}/lib.rs | 0 {rust => src}/printer.rs | 0 {rust => src}/test_utils.rs | 0 {rust => src}/utils.rs | 0 7 files changed, 1 deletion(-) rename {rust => src}/craft_cli_utils.rs (100%) rename {rust => src}/emitter.rs (100%) rename {rust => src}/lib.rs (100%) rename {rust => src}/printer.rs (100%) rename {rust => src}/test_utils.rs (100%) rename {rust => src}/utils.rs (100%) diff --git a/Cargo.toml b/Cargo.toml index 7a77a4d0..f5a2d10b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,6 @@ edition = "2024" [lib] name = "_rs" crate-type = ["cdylib"] -path = "rust/lib.rs" [workspace] members = ["."] diff --git a/rust/craft_cli_utils.rs b/src/craft_cli_utils.rs similarity index 100% rename from rust/craft_cli_utils.rs rename to src/craft_cli_utils.rs diff --git a/rust/emitter.rs b/src/emitter.rs similarity index 100% rename from rust/emitter.rs rename to src/emitter.rs diff --git a/rust/lib.rs b/src/lib.rs similarity index 100% rename from rust/lib.rs rename to src/lib.rs diff --git a/rust/printer.rs b/src/printer.rs similarity index 100% rename from rust/printer.rs rename to src/printer.rs diff --git a/rust/test_utils.rs b/src/test_utils.rs similarity index 100% rename from rust/test_utils.rs rename to src/test_utils.rs diff --git a/rust/utils.rs b/src/utils.rs similarity index 100% rename from rust/utils.rs rename to src/utils.rs From ed56f8659ade13b5eeb136f11e1cdd4a91eced51 Mon Sep 17 00:00:00 2001 From: Imani Pelton Date: Tue, 27 Jan 2026 09:19:50 -0500 Subject: [PATCH 07/17] fix: use a tuple of verbosities instead of frozenset --- craft_cli/dispatcher.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/craft_cli/dispatcher.py b/craft_cli/dispatcher.py index 96437ab6..41614d0d 100644 --- a/craft_cli/dispatcher.py +++ b/craft_cli/dispatcher.py @@ -92,7 +92,7 @@ def __post_init__(self) -> None: self.choices = [choice.lower() for choice in self.choices] -_VERBOSITIES = frozenset({"quiet", "brief", "verbose", "debug", "trace"}) +_VERBOSITIES = ("quiet", "brief", "verbose", "debug", "trace") _DEFAULT_GLOBAL_ARGS = [ GlobalArgument( From f7d1e3c1dc1274a6fc97d8e5b4fb9716b1968705 Mon Sep 17 00:00:00 2001 From: Imani Pelton Date: Tue, 27 Jan 2026 09:35:19 -0500 Subject: [PATCH 08/17] feat: use dirs instead of xdg for log filepath calculation --- Cargo.lock | 99 ++++++++++++++++++++++++++++++++++++++++++++++---- Cargo.toml | 2 +- src/emitter.rs | 25 +++++++++---- 3 files changed, 110 insertions(+), 16 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b16a9640..a0e5301b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,12 @@ 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" @@ -47,11 +53,32 @@ name = "craft-cli" version = "0.0.0" dependencies = [ "console", + "dirs", "indicatif", "jiff", "pyo3", "regex", - "xdg", +] + +[[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]] @@ -60,6 +87,17 @@ 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" @@ -146,6 +184,16 @@ 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" @@ -173,6 +221,12 @@ 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" @@ -267,6 +321,17 @@ 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" @@ -339,6 +404,26 @@ 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" @@ -369,6 +454,12 @@ 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" @@ -438,9 +529,3 @@ checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ "windows-link", ] - -[[package]] -name = "xdg" -version = "3.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fb433233f2df9344722454bc7e96465c9d03bff9d77c248f9e7523fe79585b5" diff --git a/Cargo.toml b/Cargo.toml index f5a2d10b..8f0e69b2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,10 +17,10 @@ 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" pyo3 = { features = ["extension-module"], workspace = true } -xdg = "3.0.0" [dev-dependencies] pyo3 = { features = ["auto-initialize"], workspace = true } diff --git a/src/emitter.rs b/src/emitter.rs index e7b91c70..0cc99fd0 100644 --- a/src/emitter.rs +++ b/src/emitter.rs @@ -4,6 +4,7 @@ use std::{ borrow::Cow, fs::{self, File}, io::Write as _, + path::PathBuf, }; use pyo3::{Bound, PyResult, Python, pyclass, pymethods, pymodule, types::PyType}; @@ -103,16 +104,24 @@ impl Emitter { /// Create a log filepath from the app name as an easy default. #[classmethod] - fn log_filepath_from_name(_cls: &Bound<'_, PyType>, app_name: String) -> String { - let dirs = xdg::BaseDirectories::with_prefix(app_name); - let mut p = dirs - .get_data_home() - .unwrap_or(std::env::current_dir().expect("Could not find suitable log location. As a fallback, make sure the current directory exists.")); + 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 filename = format!("{}.log", now.strftime("%Y%m%d-%H%M%S.%f")); - p.extend(["log", &filename]); - p.to_string_lossy().into() + let mut log_filepath = PathBuf::new(); + + log_filepath.extend([ + "log", + app_name, + &now.strftime("%Y%m%d-%H%M%S.%f").to_string(), + ]); + log_filepath.add_extension("log"); + + base_dir.join(log_filepath).display().to_string() } /// Get the current verbosity mode of the emitter. From a187e35e832735d1447582b612e6ad65f43a93b3 Mon Sep 17 00:00:00 2001 From: Imani Pelton Date: Tue, 27 Jan 2026 09:38:22 -0500 Subject: [PATCH 09/17] chore: remove extraneous whitespace in gitignore --- .gitignore | 2 -- 1 file changed, 2 deletions(-) diff --git a/.gitignore b/.gitignore index 157cedf8..e3d5dd87 100644 --- a/.gitignore +++ b/.gitignore @@ -150,7 +150,5 @@ dmypy.json # JetBrains IDEs /.idea/ - # Added by cargo - /target From 3b148074190e471abe34b6f4893424bdb481c2cb Mon Sep 17 00:00:00 2001 From: Imani Pelton Date: Tue, 27 Jan 2026 14:57:06 -0500 Subject: [PATCH 10/17] style: incorporate @TheSignPainter98's feedback --- Cargo.toml | 2 +- src/craft_cli_utils.rs | 5 +-- src/emitter.rs | 92 +++++++++++++++++++++++------------------- src/lib.rs | 16 +------- src/printer.rs | 66 +++++++++++++++++------------- src/test_utils.rs | 12 +----- src/utils.rs | 18 +++++---- 7 files changed, 107 insertions(+), 104 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 8f0e69b2..89d0ead2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ name = "craft-cli" edition = "2024" [lib] -name = "_rs" +name = "craft_cli_extensions" crate-type = ["cdylib"] [workspace] diff --git a/src/craft_cli_utils.rs b/src/craft_cli_utils.rs index c366973c..aa03d687 100644 --- a/src/craft_cli_utils.rs +++ b/src/craft_cli_utils.rs @@ -11,9 +11,8 @@ pub mod utils { /// Convert a collection of values into a string that lists the values. #[pyfunction] - #[pyo3(signature = (values, conjunction = "and"))] - fn humanize_list(values: Vec, conjunction: Option<&str>) -> PyResult { - let conjunction = conjunction.unwrap_or("and"); + #[pyo3(signature = (values, *, conjunction = "and"))] + fn humanize_list(values: Vec, conjunction: &str) -> PyResult { match values.as_slice() { [] => Err(PyValueError::new_err("Cannot humanize empty list")), [_] => Ok(values diff --git a/src/emitter.rs b/src/emitter.rs index 0cc99fd0..d63ad8f3 100644 --- a/src/emitter.rs +++ b/src/emitter.rs @@ -12,7 +12,6 @@ use pyo3::{Bound, PyResult, Python, pyclass, pymethods, pymodule, types::PyType} use crate::printer::{Message, MessageType, Printer, Target}; /// Verbosity modes. -#[non_exhaustive] #[derive(Clone, Copy)] #[pyclass] pub enum Verbosity { @@ -141,8 +140,8 @@ impl Emitter { for message in messages { self.printer.send(Message { text: message, - model: MessageType::Info(), - target: Target::Stderr, + model: MessageType::Info, + target: Some(Target::Stderr), }); } } @@ -157,15 +156,17 @@ impl Emitter { self.log(×tamped)?; let (maybe_timestamped, target) = match self.verbosity { - Verbosity::Brief | Verbosity::Quiet => (text, Target::Null), - Verbosity::Verbose => (text, Target::Stderr), - _ => (timestamped.as_ref(), Target::Stderr), + 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: maybe_timestamped.to_string(), + text, + model, target, - model: MessageType::Debug(), }; self.printer.send(message); @@ -182,14 +183,16 @@ impl Emitter { self.log(×tamped)?; let target = match self.verbosity { - Verbosity::Brief | Verbosity::Quiet | Verbosity::Verbose => Target::Null, - _ => Target::Stderr, + Verbosity::Brief | Verbosity::Quiet | Verbosity::Verbose => None, + _ => Some(Target::Stderr), }; + let text = timestamped.to_string(); + let model = MessageType::Debug; let message = Message { - text: timestamped.to_string(), + text, + model, target, - model: MessageType::Debug(), }; self.printer.send(message); @@ -206,14 +209,16 @@ impl Emitter { self.log(×tamped)?; let target = match self.verbosity { - Verbosity::Trace => Target::Stderr, - _ => Target::Null, + Verbosity::Trace => Some(Target::Stderr), + _ => None, }; + let text = timestamped.to_string(); + let model = MessageType::Trace; let message = Message { - text: timestamped.to_string(), + text, + model, target, - model: MessageType::Trace(), }; self.printer.send(message); @@ -228,33 +233,35 @@ impl Emitter { /// /// 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). - fn progress(&mut self, text: &str, mut permanent: Option) -> PyResult<()> { + #[pyo3(signature = (text, *, permanent = false))] + fn progress(&mut self, text: &str, mut permanent: bool) -> PyResult<()> { let timestamped = Self::apply_timestamp(text); self.log(×tamped)?; let (maybe_timestamped, target) = match self.verbosity { Verbosity::Quiet => { - permanent = Some(false); - (text, Target::Null) + permanent = false; + (text, None) } - Verbosity::Brief => (text, Target::Stderr), + Verbosity::Brief => (text, Some(Target::Stderr)), Verbosity::Verbose => { - permanent = Some(true); - (text, Target::Stderr) + permanent = true; + (text, Some(Target::Stderr)) } _ => { - permanent = Some(true); - (timestamped.as_ref(), Target::Stderr) + permanent = true; + (timestamped.as_ref(), Some(Target::Stderr)) } }; - + let model = if permanent { + MessageType::ProgPersistent(target) + } else { + MessageType::ProgEphemeral(target) + }; + let text = maybe_timestamped.to_owned(); let message = Message { - text: maybe_timestamped.to_string(), - model: if permanent.unwrap_or(false) { - MessageType::ProgPersistent(target) - } else { - MessageType::ProgEphemeral(target) - }, + text, + model, target, }; @@ -271,13 +278,14 @@ impl Emitter { self.log(×tamped)?; let target = match self.verbosity { - Verbosity::Quiet => Target::Null, - _ => Target::Stdout, + Verbosity::Quiet => None, + _ => Some(Target::Stdout), }; + let model = MessageType::Info; let message = Message { text, - model: MessageType::Info(), + model, target, }; @@ -293,14 +301,16 @@ impl Emitter { self.log(×tamped)?; let (maybe_timestamped, target) = match self.verbosity { - Verbosity::Quiet => (prefixed.as_str(), Target::Null), - Verbosity::Debug | Verbosity::Trace => (timestamped.as_ref(), Target::Stderr), - _ => (prefixed.as_str(), Target::Stderr), + 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: maybe_timestamped.to_string(), - model: MessageType::Warning(), + text, + model, target, }; @@ -341,8 +351,8 @@ impl Emitter { fn finish(&mut self) -> PyResult<()> { let message = Message { text: format!("Full execution log at '{}'", self.log_filepath), - model: MessageType::Info(), - target: Target::Stderr, + model: MessageType::Info, + target: Some(Target::Stderr), }; self.printer.send(message); self.printer.stop()?; diff --git a/src/lib.rs b/src/lib.rs index 638060fd..90f66be4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,15 +1,3 @@ -#![warn( - clippy::pedantic, - clippy::mem_forget, - clippy::allow_attributes, - clippy::dbg_macro, - clippy::clone_on_ref_ptr, - clippy::missing_docs_in_private_items -)] -// Specifically allow wildcard imports as they are a very common pattern for enum -// matching and module setup -#![allow(clippy::wildcard_imports, clippy::enum_glob_use)] - //! Craft CLI //! //! The perfect foundation for your CLI situation. @@ -23,8 +11,8 @@ mod test_utils; mod utils; /// A Python module implemented in Rust. -#[pymodule] -mod _rs { +#[pymodule(name = "_rs")] +mod craft_cli_extensions { #[pymodule_export] use crate::craft_cli_utils::utils; diff --git a/src/printer.rs b/src/printer.rs index 9c88b3b2..6fcad8a8 100644 --- a/src/printer.rs +++ b/src/printer.rs @@ -24,9 +24,6 @@ pub enum Target { /// Target the stderr stream. Stderr, - - /// Target no stream at all. - Null, } impl From for indicatif::ProgressDrawTarget { @@ -34,7 +31,6 @@ impl From for indicatif::ProgressDrawTarget { match val { Target::Stdout => indicatif::ProgressDrawTarget::stdout(), Target::Stderr => indicatif::ProgressDrawTarget::stderr(), - Target::Null => indicatif::ProgressDrawTarget::hidden(), } } } @@ -45,34 +41,40 @@ pub enum MessageType { /// A persistent progress message that will remain on the console. /// /// For a non-permanent message, see `ProgEphemeral`. - ProgPersistent(Target), + ProgPersistent(Option), /// An ephemeral progress message that will be overwritten by the next message. /// /// For a permanent message, see `ProgPersistent`. - ProgEphemeral(Target), + ProgEphemeral(Option), /// A warning message. - Warning(), + Warning, // Pending implementation of CraftError parsing in Rust #[expect(unused)] /// An error message. - Error(), + Error, /// A debugging info message. - Debug(), + Debug, /// A trace info message. - Trace(), + Trace, /// An informational message. - Info(), + Info, // Pending implementation of incremental progress bars using indicatif #[expect(unused)] /// Signals to create a progress bar. - ProgBar(Target, u64), + 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. @@ -85,20 +87,20 @@ pub struct Message { pub(crate) model: MessageType, /// Where the message should be sent. - pub(crate) target: Target, + pub(crate) target: Option, } impl Message { /// Calculate which stream a message should go to based on its model. pub fn determine_stream(&self, mode: Verbosity) -> Option { - use self::Target::*; + use Target as Tar; match self.model { MessageType::ProgPersistent(target) | MessageType::ProgEphemeral(target) - | MessageType::ProgBar(target, ..) => target.into(), - MessageType::Warning() | MessageType::Error() => Stderr.into(), - MessageType::Debug() | MessageType::Trace() | MessageType::Info() => match mode { - Verbosity::Verbose => Stdout.into(), + | MessageType::ProgBar { target, .. } => target, + MessageType::Warning | MessageType::Error => Some(Tar::Stderr), + MessageType::Debug | MessageType::Trace | MessageType::Info => match mode { + Verbosity::Verbose => Some(Tar::Stdout), _ => None, }, } @@ -183,11 +185,13 @@ impl InnerPrinter { continue; } - let Some(msg) = maybe_prv_msg.as_ref() else { - continue; + let msg = match &maybe_prv_msg { + Some(msg) => msg, + None => continue, }; - let Some(target) = msg.determine_stream(self.mode) else { - continue; + let target = match msg.determine_stream(self.mode) { + Some(target) => target, + None => continue, }; let new_spinner = indicatif::ProgressBar::with_draw_target(None, target.into()) @@ -212,15 +216,15 @@ impl InnerPrinter { /// 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 self::MessageType::*; - if let Target::Null = msg.target { + use MessageType as Mt; + if msg.target.is_none() { return Ok(()); } match msg.model { - Info() => self.print(msg), - Error() => self.error(msg), - ProgEphemeral(..) => self.progress(msg, false), - ProgPersistent(..) => self.progress(msg, true), + Mt::Info => self.print(msg), + Mt::Error => self.error(msg), + Mt::ProgEphemeral(..) => self.progress(msg, false), + Mt::ProgPersistent(..) => self.progress(msg, true), _ => unimplemented!(), } } @@ -340,6 +344,12 @@ impl Printer { impl Drop for Printer { fn drop(&mut self) { + if thread::panicking() { + eprintln!("Unwinding due to panic! Printer was not stopped properly."); + if let Err(e) = console::Term::stdout().show_cursor() { + eprintln!("Unable to restore text cursor: {e}") + } + } self.stop().expect("An error was encountered while logging. Tear down the printer properly to view the error."); } } diff --git a/src/test_utils.rs b/src/test_utils.rs index 288d045f..8f157495 100644 --- a/src/test_utils.rs +++ b/src/test_utils.rs @@ -1,8 +1,6 @@ //! Utilities for testing #![cfg(test)] -use std::fmt::Debug; - use pyo3::{PyErr, PyTypeInfo, Python}; use regex::Regex; @@ -10,14 +8,8 @@ pub fn assert_error_type(err: &PyErr) { Python::attach(|py| assert!(err.is_instance_of::(py))); } -pub fn assert_error_contents(err: &PyErr, r#match: R) -where - // Accept anything that can be converted into Regex - R: TryInto, - // Should always be true, this is just to please the type checker - >::Error: Debug, -{ - let re: Regex = r#match.try_into().expect("Could not be parsed as regex!"); +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(); diff --git a/src/utils.rs b/src/utils.rs index 2e57da55..88e5956f 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -14,6 +14,9 @@ pub fn fix_imports(m: &Bound<'_, PyModule>, name: &str) -> PyResult<()> { // 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)] { @@ -31,15 +34,16 @@ pub fn log(message: impl Into) { .open("craft-cli-debug.log") .expect("Couldn't open debugging log!"); - handle - .write_all("I hope you find what you are looking for, traveller.\n".as_ref()) - .expect("Couldn't write to debugging log!"); + writeln!( + FILE.lock().unwrap(), + "I hope you find what you are looking for, traveller." + ) + .expect("Cannot write to debugging log"); Mutex::new(handle) }); - FILE.lock() - .unwrap() - .write_all(format!("{}\n", message.into()).as_ref()) - .expect("Couldn't write to debugging log!"); + + writeln!(FILE.lock().unwrap(), "{}", message.into()) + .expect("Cannot write to debugging log"); } } From 2f5343e6b2b29995edcf82b5de71a33010245514 Mon Sep 17 00:00:00 2001 From: Imani Pelton Date: Tue, 27 Jan 2026 15:42:16 -0500 Subject: [PATCH 11/17] chore: incorporate copilot feedback --- Makefile | 3 +++ craft_cli/dispatcher.py | 12 ++++++++++-- pyproject.toml | 4 ++-- src/emitter.rs | 26 +++++++++++++++----------- src/printer.rs | 14 +++++++++++--- 5 files changed, 41 insertions(+), 18 deletions(-) diff --git a/Makefile b/Makefile index 20ccfe57..a4513257 100644 --- a/Makefile +++ b/Makefile @@ -55,6 +55,9 @@ 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 diff --git a/craft_cli/dispatcher.py b/craft_cli/dispatcher.py index 41614d0d..4ba83d9c 100644 --- a/craft_cli/dispatcher.py +++ b/craft_cli/dispatcher.py @@ -28,7 +28,7 @@ from craft_cli.helptexts import HelpBuilder, OutputFormat from craft_cli.utils import humanize_list -logger = logging.Logger(__file__) +logger = logging.getLogger(__file__) class CommandGroup(NamedTuple): @@ -94,6 +94,13 @@ def __post_init__(self) -> None: _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", @@ -123,7 +130,8 @@ def __post_init__(self) -> None: "--verbosity", "Set the verbosity level to 'quiet', 'brief', 'verbose', 'debug' or 'trace'", choices=list(_VERBOSITIES), - validator=lambda mode: mode in _VERBOSITIES, + # The "else None" is dead, but required code. + validator=_validate_verbosity, case_sensitive=False, ), ] diff --git a/pyproject.toml b/pyproject.toml index f6a5542b..6f164352 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,7 +22,7 @@ classifiers = [ "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.14", "Programming Language :: Rust", - "Programming Language :: Python :: Implementation: CPython", + "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", ] requires-python = ">=3.10" @@ -102,7 +102,7 @@ constraint-dependencies = [ cache-keys = [ { file = "src/**/*.rs" }, { file = "Cargo.toml" }, - { file = "python/craft_cli/**/*.pyi?" }, + { file = "craft_cli/**/*.pyi?" }, { file = "pyproject.toml" }, ] diff --git a/src/emitter.rs b/src/emitter.rs index d63ad8f3..de949f73 100644 --- a/src/emitter.rs +++ b/src/emitter.rs @@ -5,6 +5,7 @@ use std::{ fs::{self, File}, io::Write as _, path::PathBuf, + thread, }; use pyo3::{Bound, PyResult, Python, pyclass, pymethods, pymodule, types::PyType}; @@ -114,13 +115,13 @@ impl Emitter { let mut log_filepath = PathBuf::new(); log_filepath.extend([ - "log", app_name, + "log", &now.strftime("%Y%m%d-%H%M%S.%f").to_string(), ]); - log_filepath.add_extension("log"); - base_dir.join(log_filepath).display().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. @@ -295,8 +296,8 @@ impl Emitter { /// Show an important warning to the user. #[pyo3(signature = (text, prefix = "Warning: "))] - fn warning(&mut self, text: &str, prefix: Option<&str>) -> PyResult<()> { - let prefixed = format!("{}{}", prefix.unwrap_or("Warning: "), text); + fn warning(&mut self, text: &str, prefix: &str) -> PyResult<()> { + let prefixed = format!("{}{}", prefix, text); let timestamped = Self::apply_timestamp(&prefixed); self.log(×tamped)?; @@ -335,7 +336,7 @@ impl Emitter { fn apply_timestamp(text: &str) -> Cow<'_, str> { format!( "{} {}", - jiff::Timestamp::now().strftime("%Y-%m-%D %H:%M:%s%.3f"), + jiff::Timestamp::now().strftime("%Y-%m-%d %H:%M:%s%.3f"), text ) .into() @@ -343,7 +344,7 @@ impl Emitter { /// Print a string to the log. fn log(&mut self, text: &str) -> PyResult<()> { - self.log_handle.write_all(text.as_ref())?; + writeln!(self.log_handle, "{text}")?; Ok(()) } @@ -362,10 +363,13 @@ impl Emitter { impl Drop for Emitter { fn drop(&mut self) { - self.printer.stop().expect( - "An unknown error has occurred! The Emitter was not stopped correctly,\ - so context about the error has been lost. Please report this error.", - ); + if let Err(e) = self.printer.stop() + && thread::panicking() + { + eprintln!( + "Unwinding due to panic! The Printer was not stopped correctly. Please report this as a bug: {e}" + ); + } } } diff --git a/src/printer.rs b/src/printer.rs index 6fcad8a8..f8289712 100644 --- a/src/printer.rs +++ b/src/printer.rs @@ -344,12 +344,20 @@ impl Printer { impl Drop for Printer { fn drop(&mut self) { - if thread::panicking() { - eprintln!("Unwinding due to panic! Printer was not stopped properly."); + if let Err(e) = self.stop() { + if thread::panicking() { + eprintln!( + "Unwinding due to panic! Printer was not stopped properly. Please report this as a bug: {e}" + ); + } else { + eprintln!( + "Failed to tear down printer. Destruct the printer correctly to view this error. Please report this as a bug: {e}" + ); + } + // Make a last-ditch attempt to restore the text cursor before bailing. if let Err(e) = console::Term::stdout().show_cursor() { eprintln!("Unable to restore text cursor: {e}") } } - self.stop().expect("An error was encountered while logging. Tear down the printer properly to view the error."); } } From b9f0d3567860ac7753678c83d6d18b5429758504 Mon Sep 17 00:00:00 2001 From: Imani Pelton Date: Tue, 27 Jan 2026 16:57:43 -0500 Subject: [PATCH 12/17] feat: finalize the humanize_list implementation --- craft_cli/utils.py | 6 ++---- src/craft_cli_utils.rs | 30 +++++++++++++++++++++++------- 2 files changed, 25 insertions(+), 11 deletions(-) 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/src/craft_cli_utils.rs b/src/craft_cli_utils.rs index aa03d687..587af7f4 100644 --- a/src/craft_cli_utils.rs +++ b/src/craft_cli_utils.rs @@ -5,20 +5,36 @@ use pyo3::pymodule; /// Utility functions for Craft CLI #[pymodule(submodule)] pub mod utils { - use pyo3::{Bound, PyResult, exceptions::PyValueError, pyfunction, types::PyModule}; + 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: Vec, conjunction: &str) -> PyResult { - match values.as_slice() { - [] => Err(PyValueError::new_err("Cannot humanize empty list")), - [_] => Ok(values + fn humanize_list(values: Bound<'_, PyAny>, conjunction: &str) -> PyResult { + // Check if it's actually iterable at runtime and collect values + let items: Vec<_> = match values.try_iter() { + Ok(py_iter) => py_iter .into_iter() - .next() - .expect("Size checked by match arm")), + .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("Size checked by match arm")), [start, end] => Ok(format!("{start} {conjunction} {end}")), [start @ .., end] => { let start = start.join(", "); From 2f0fc17ede628e9e3ebdaf7fa7cfa9c0c8b04f95 Mon Sep 17 00:00:00 2001 From: Imani Pelton Date: Tue, 27 Jan 2026 17:20:14 -0500 Subject: [PATCH 13/17] docs: add comment indicating point of improvement --- src/craft_cli_utils.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/craft_cli_utils.rs b/src/craft_cli_utils.rs index 587af7f4..5006962f 100644 --- a/src/craft_cli_utils.rs +++ b/src/craft_cli_utils.rs @@ -19,6 +19,8 @@ pub mod utils { #[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() From bf1fd9a0082e6b0f2523b4385ead3a1db61f890c Mon Sep 17 00:00:00 2001 From: Imani Pelton Date: Fri, 30 Jan 2026 13:18:49 -0500 Subject: [PATCH 14/17] chore: clean up error cases --- src/craft_cli_utils.rs | 5 ++++- src/emitter.rs | 6 ++---- src/printer.rs | 30 +++++++++++++++++++----------- 3 files changed, 25 insertions(+), 16 deletions(-) diff --git a/src/craft_cli_utils.rs b/src/craft_cli_utils.rs index 5006962f..766a689f 100644 --- a/src/craft_cli_utils.rs +++ b/src/craft_cli_utils.rs @@ -36,7 +36,10 @@ pub mod utils { match items.as_slice() { [] => Err(PyValueError::new_err("Cannot humanize empty list")), - [_] => Ok(items.into_iter().next().expect("Size checked by match arm")), + [_] => 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(", "); diff --git a/src/emitter.rs b/src/emitter.rs index de949f73..1e5052c1 100644 --- a/src/emitter.rs +++ b/src/emitter.rs @@ -364,11 +364,9 @@ impl Emitter { impl Drop for Emitter { fn drop(&mut self) { if let Err(e) = self.printer.stop() - && thread::panicking() + && !thread::panicking() { - eprintln!( - "Unwinding due to panic! The Printer was not stopped correctly. Please report this as a bug: {e}" - ); + eprintln!("Cannot stop printer: {e:?}"); } } } diff --git a/src/printer.rs b/src/printer.rs index f8289712..5e537e65 100644 --- a/src/printer.rs +++ b/src/printer.rs @@ -268,8 +268,15 @@ impl InnerPrinter { impl Drop for InnerPrinter { /// Restore the cursor when releasing control of the terminal. fn drop(&mut self) { - self.handle_overwrite().unwrap(); - self.stdout.show_cursor().unwrap(); + // 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}"); + } } } @@ -344,17 +351,18 @@ impl Printer { impl Drop for Printer { fn drop(&mut self) { - if let Err(e) = self.stop() { - if thread::panicking() { - eprintln!( - "Unwinding due to panic! Printer was not stopped properly. Please report this as a bug: {e}" - ); - } else { - eprintln!( - "Failed to tear down printer. Destruct the printer correctly to view this error. Please report this as a bug: {e}" - ); + 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}") } From c0ebaa204ba6481974e93f5c2f0a24b00a108341 Mon Sep 17 00:00:00 2001 From: Imani Pelton Date: Fri, 30 Jan 2026 17:17:34 -0500 Subject: [PATCH 15/17] feat: add handling for warning and debug, allow dynamic destinations for progress --- src/printer.rs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/printer.rs b/src/printer.rs index 5e537e65..123d8643 100644 --- a/src/printer.rs +++ b/src/printer.rs @@ -222,10 +222,10 @@ impl InnerPrinter { } match msg.model { Mt::Info => self.print(msg), - Mt::Error => self.error(msg), + Mt::Error | Mt::Warning | Mt::Debug => self.error(msg), Mt::ProgEphemeral(..) => self.progress(msg, false), Mt::ProgPersistent(..) => self.progress(msg, true), - _ => unimplemented!(), + Mt::Trace | Mt::ProgBar { .. } => unimplemented!(), } } @@ -239,6 +239,7 @@ impl InnerPrinter { /// Print a simple message to stdout. fn print(&mut self, message: &Message) -> PyResult<()> { + self.handle_overwrite()?; self.stdout.write_line(&message.text)?; Ok(()) } @@ -252,9 +253,14 @@ impl InnerPrinter { /// Print progress on a task. fn progress(&mut self, message: &Message, permanent: bool) -> PyResult<()> { - self.handle_overwrite()?; self.needs_overwrite = !permanent; - self.print(message)?; + match message + .target + .expect("Internal error: null message made it to printer") + { + Target::Stdout => self.print(message)?, + Target::Stderr => self.error(message)?, + }; Ok(()) } From d072721c55f4bbde1f053b1e51686134ade447da Mon Sep 17 00:00:00 2001 From: Imani Pelton Date: Fri, 30 Jan 2026 17:17:49 -0500 Subject: [PATCH 16/17] perf: use buffered writing --- src/emitter.rs | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/emitter.rs b/src/emitter.rs index 1e5052c1..46022e29 100644 --- a/src/emitter.rs +++ b/src/emitter.rs @@ -3,7 +3,7 @@ use std::{ borrow::Cow, fs::{self, File}, - io::Write as _, + io::{BufWriter, Write as _}, path::PathBuf, thread, }; @@ -49,7 +49,7 @@ struct Emitter { printer: Printer, /// A handle to the desired log file. - log_handle: File, + log_handle: BufWriter, /// The original filepath of the log file. log_filepath: String, @@ -86,11 +86,13 @@ impl Emitter { // https://pyo3.rs/v0.25.1/faq.html#im-experiencing-deadlocks-using-pyo3-with-stdsynconcelock-stdsynclazylock-lazy_static-and-once_cell py.detach(|| printer.start(verbosity)); - let log_handle = fs::OpenOptions::new() - .write(true) - .truncate(true) - .create(true) - .open(&log_filepath)?; + let log_handle = BufWriter::new( + fs::OpenOptions::new() + .write(true) + .truncate(true) + .create(true) + .open(&log_filepath)?, + ); Ok(Self { printer, @@ -336,7 +338,7 @@ impl Emitter { fn apply_timestamp(text: &str) -> Cow<'_, str> { format!( "{} {}", - jiff::Timestamp::now().strftime("%Y-%m-%d %H:%M:%s%.3f"), + jiff::Timestamp::now().strftime("%Y-%m-%d %H:%M:%S%.3f"), text ) .into() From e885309cc48c9535b2a4b62552bb5dd5ab74bc3c Mon Sep 17 00:00:00 2001 From: Imani Pelton Date: Fri, 30 Jan 2026 17:19:31 -0500 Subject: [PATCH 17/17] fix: don't print execution log location every time --- src/emitter.rs | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/emitter.rs b/src/emitter.rs index 46022e29..77ac3577 100644 --- a/src/emitter.rs +++ b/src/emitter.rs @@ -352,12 +352,6 @@ impl Emitter { /// Stop the printing infrastructure and print a final message to see the logs. fn finish(&mut self) -> PyResult<()> { - let message = Message { - text: format!("Full execution log at '{}'", self.log_filepath), - model: MessageType::Info, - target: Some(Target::Stderr), - }; - self.printer.send(message); self.printer.stop()?; Ok(()) }