diff --git a/craft_parts/packages/errors.py b/craft_parts/packages/errors.py
index 283f7f814..17c4e1b39 100644
--- a/craft_parts/packages/errors.py
+++ b/craft_parts/packages/errors.py
@@ -18,6 +18,8 @@
from collections.abc import Sequence
+import humanize
+
from craft_parts.errors import PartsError
from craft_parts.utils import formatting_utils
@@ -57,7 +59,7 @@ class PackagesNotFound(PackagesError):
def __init__(self, packages: Sequence[str]) -> None:
self.packages = packages
- missing_pkgs = formatting_utils.humanize_list(packages, "and")
+ missing_pkgs = humanize.natural_list([repr(pkg) for pkg in sorted(packages)])
brief = f"Failed to find installation candidate for packages: {missing_pkgs}."
resolution = (
"Make sure the repository configuration and package names are correct."
diff --git a/craft_parts/sources/errors.py b/craft_parts/sources/errors.py
index 5c759fb0b..52d143466 100644
--- a/craft_parts/sources/errors.py
+++ b/craft_parts/sources/errors.py
@@ -18,6 +18,8 @@
from collections.abc import Sequence
+import humanize
+
from craft_parts import errors
from craft_parts.utils import formatting_utils
@@ -74,7 +76,7 @@ class InvalidSourceOptions(SourceError):
def __init__(self, *, source_type: str, options: list[str]) -> None:
self.source_type = source_type
self.options = options
- humanized_options = formatting_utils.humanize_list(options, "and")
+ humanized_options = humanize.natural_list([repr(opt) for opt in sorted(options)])
brief = (
f"Failed to pull source: {humanized_options} cannot be used "
f"with a {source_type} source."
@@ -94,7 +96,7 @@ class IncompatibleSourceOptions(SourceError):
def __init__(self, source_type: str, options: list[str]) -> None:
self.source_type = source_type
self.options = options
- humanized_options = formatting_utils.humanize_list(options, "and")
+ humanized_options = humanize.natural_list([repr(opt) for opt in sorted(options)])
brief = (
f"Failed to pull source: cannot specify both {humanized_options} "
f"for a {source_type} source."
diff --git a/craft_parts/utils/formatting_utils.py b/craft_parts/utils/formatting_utils.py
deleted file mode 100644
index 7df800875..000000000
--- a/craft_parts/utils/formatting_utils.py
+++ /dev/null
@@ -1,44 +0,0 @@
-# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*-
-#
-# Copyright 2016-2021 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, see .
-
-"""Text formatting utilities."""
-
-from collections.abc import Iterable
-
-
-def humanize_list(
- items: Iterable[str], conjunction: str, item_format: str = "{!r}"
-) -> str:
- """Format a list into a human-readable string.
-
- :param items: List to humanize.
- :param conjunction: The conjunction used to join the final element to
- the rest of the list (e.g. 'and').
- :param item_format: Format string to use per item.
- """
- if not items:
- return ""
-
- quoted_items = [item_format.format(item) for item in sorted(items)]
- if len(quoted_items) == 1:
- return quoted_items[0]
-
- humanized = ", ".join(quoted_items[:-1])
-
- if len(quoted_items) > 2: # noqa: PLR2004
- humanized += ","
-
- return f"{humanized} {conjunction} {quoted_items[-1]}"
diff --git a/pyproject.toml b/pyproject.toml
index 26f75cbfa..1a7b36868 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -17,6 +17,7 @@ classifiers = [
"Programming Language :: Python :: 3.12",
]
dependencies = [
+ "humanize>=4.11.0",
"overrides!=7.6.0",
"pydantic>=2.0.0",
"pyxdg",
diff --git a/tests/unit/sources/test_rpm_source.py b/tests/unit/sources/test_rpm_source.py
index 531024db7..4274087f6 100644
--- a/tests/unit/sources/test_rpm_source.py
+++ b/tests/unit/sources/test_rpm_source.py
@@ -97,7 +97,7 @@ def test_valid_options(partitions):
"branch",
"submodule",
"depth",
- "'source-branch', 'source-commit', 'source-depth', 'source-submodules', and 'source-tag'",
+ "'source-branch', 'source-commit', 'source-depth', 'source-submodules' and 'source-tag'",
id="all-values-bad",
),
],
diff --git a/tests/unit/utils/test_formatting_utils.py b/tests/unit/utils/test_formatting_utils.py
deleted file mode 100644
index 00400ee54..000000000
--- a/tests/unit/utils/test_formatting_utils.py
+++ /dev/null
@@ -1,47 +0,0 @@
-# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*-
-#
-# Copyright 2021 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, see .
-
-import pytest
-from craft_parts.utils import formatting_utils
-
-
-@pytest.mark.parametrize(
- ("items", "result"),
- [
- (None, ""),
- ([], ""),
- (["foo"], "'foo'"),
- (["foo", "bar"], "'bar' & 'foo'"),
- ([3, 2, 1], "1, 2, & 3"),
- ],
-)
-def test_humanize_list(items, result):
- hl = formatting_utils.humanize_list(items, "&")
- assert hl == result
-
-
-@pytest.mark.parametrize(
- ("items", "item_format", "result"),
- [
- (["foo"], "", ""),
- (["foo"], "{!r}", "'foo'"),
- ([1], "{!r}", "1"),
- ([42], "{:2x}", "2a"),
- ],
-)
-def test_humanize_list_item_format(items, item_format, result):
- hl = formatting_utils.humanize_list(iter(items), "&", item_format)
- assert hl == result
diff --git a/uv.lock b/uv.lock
index f89295f95..d93b15fba 100644
--- a/uv.lock
+++ b/uv.lock
@@ -334,9 +334,9 @@ toml = [
[[package]]
name = "craft-parts"
-version = "2.2.1.post21+gf142785.d20250114"
source = { editable = "." }
dependencies = [
+ { name = "humanize" },
{ name = "overrides" },
{ name = "pydantic" },
{ name = "pyxdg" },
@@ -435,6 +435,7 @@ requires-dist = [
{ name = "canonical-sphinx", marker = "extra == 'docs'" },
{ name = "codespell", marker = "extra == 'dev'" },
{ name = "coverage", extras = ["toml"], marker = "extra == 'dev'", specifier = "~=7.4" },
+ { name = "humanize", specifier = ">=4.11.0" },
{ name = "hypothesis", marker = "extra == 'dev'" },
{ name = "jsonschema", marker = "extra == 'dev'" },
{ name = "mypy", extras = ["reports"], marker = "extra == 'dev'", specifier = "~=1.14.1" },
@@ -556,6 +557,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/27/48/e791a7ed487dbb9729ef32bb5d1af16693d8925f4366befef54119b2e576/furo-2024.8.6-py3-none-any.whl", hash = "sha256:6cd97c58b47813d3619e63e9081169880fbe331f0ca883c871ff1f3f11814f5c", size = 341333 },
]
+[[package]]
+name = "humanize"
+version = "4.11.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/6a/40/64a912b9330786df25e58127194d4a5a7441f818b400b155e748a270f924/humanize-4.11.0.tar.gz", hash = "sha256:e66f36020a2d5a974c504bd2555cf770621dbdbb6d82f94a6857c0b1ea2608be", size = 80374 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/92/75/4bc3e242ad13f2e6c12e0b0401ab2c5e5c6f0d7da37ec69bc808e24e0ccb/humanize-4.11.0-py3-none-any.whl", hash = "sha256:b53caaec8532bcb2fff70c8826f904c35943f8cecaca29d272d9df38092736c0", size = 128055 },
+]
+
[[package]]
name = "hypothesis"
version = "6.123.7"