usecli 0.1.79__tar.gz → 0.1.81__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. {usecli-0.1.79 → usecli-0.1.81}/PKG-INFO +30 -1
  2. {usecli-0.1.79 → usecli-0.1.81}/README.md +29 -0
  3. {usecli-0.1.79 → usecli-0.1.81}/pyproject.toml +1 -1
  4. {usecli-0.1.79 → usecli-0.1.81}/pyproject.toml.orig +1 -1
  5. {usecli-0.1.79 → usecli-0.1.81}/src/usecli/cli/core/ui/list.py +6 -2
  6. {usecli-0.1.79 → usecli-0.1.81}/src/usecli/cli/templates/usecli.config.toml.j2 +7 -0
  7. {usecli-0.1.79 → usecli-0.1.81}/src/usecli/shared/config/manager.py +10 -2
  8. {usecli-0.1.79 → usecli-0.1.81}/src/usecli/shared/upgrades/discovery.py +107 -24
  9. usecli-0.1.81/src/usecli/shared/upgrades/settings.py +129 -0
  10. {usecli-0.1.79 → usecli-0.1.81}/LICENSE +0 -0
  11. {usecli-0.1.79 → usecli-0.1.81}/src/usecli/__init__.py +0 -0
  12. {usecli-0.1.79 → usecli-0.1.81}/src/usecli/bundler/__init__.py +0 -0
  13. {usecli-0.1.79 → usecli-0.1.81}/src/usecli/bundler/__main__.py +0 -0
  14. {usecli-0.1.79 → usecli-0.1.81}/src/usecli/cli/__init__.py +0 -0
  15. {usecli-0.1.79 → usecli-0.1.81}/src/usecli/cli/commands/README.md +0 -0
  16. {usecli-0.1.79 → usecli-0.1.81}/src/usecli/cli/commands/__init__.py +0 -0
  17. {usecli-0.1.79 → usecli-0.1.81}/src/usecli/cli/commands/custom/README.md +0 -0
  18. {usecli-0.1.79 → usecli-0.1.81}/src/usecli/cli/commands/custom/__init__.py +0 -0
  19. {usecli-0.1.79 → usecli-0.1.81}/src/usecli/cli/commands/defaults/__init__.py +0 -0
  20. {usecli-0.1.79 → usecli-0.1.81}/src/usecli/cli/commands/defaults/base/__init__.py +0 -0
  21. {usecli-0.1.79 → usecli-0.1.81}/src/usecli/cli/commands/defaults/base/about_command.py +0 -0
  22. {usecli-0.1.79 → usecli-0.1.81}/src/usecli/cli/commands/defaults/base/help_command.py +0 -0
  23. {usecli-0.1.79 → usecli-0.1.81}/src/usecli/cli/commands/defaults/base/inspire_command.py +0 -0
  24. {usecli-0.1.79 → usecli-0.1.81}/src/usecli/cli/commands/defaults/base/internal/__init__.py +0 -0
  25. {usecli-0.1.79 → usecli-0.1.81}/src/usecli/cli/commands/defaults/base/internal/fzf_command.py +0 -0
  26. {usecli-0.1.79 → usecli-0.1.81}/src/usecli/cli/commands/defaults/base/upgrade_command.py +0 -0
  27. {usecli-0.1.79 → usecli-0.1.81}/src/usecli/cli/commands/defaults/core/__init__.py +0 -0
  28. {usecli-0.1.79 → usecli-0.1.81}/src/usecli/cli/commands/defaults/core/utils.py +0 -0
  29. {usecli-0.1.79 → usecli-0.1.81}/src/usecli/cli/commands/defaults/make/__init__.py +0 -0
  30. {usecli-0.1.79 → usecli-0.1.81}/src/usecli/cli/commands/defaults/make/make_bundle_command.py +0 -0
  31. {usecli-0.1.79 → usecli-0.1.81}/src/usecli/cli/commands/defaults/make/make_command.py +0 -0
  32. {usecli-0.1.79 → usecli-0.1.81}/src/usecli/cli/commands/defaults/make/make_theme_command.py +0 -0
  33. {usecli-0.1.79 → usecli-0.1.81}/src/usecli/cli/commands/init_command.py +0 -0
  34. {usecli-0.1.79 → usecli-0.1.81}/src/usecli/cli/config/__init__.py +0 -0
  35. {usecli-0.1.79 → usecli-0.1.81}/src/usecli/cli/config/colors.py +0 -0
  36. {usecli-0.1.79 → usecli-0.1.81}/src/usecli/cli/core/__init__.py +0 -0
  37. {usecli-0.1.79 → usecli-0.1.81}/src/usecli/cli/core/base_command.py +0 -0
  38. {usecli-0.1.79 → usecli-0.1.81}/src/usecli/cli/core/error/__init__.py +0 -0
  39. {usecli-0.1.79 → usecli-0.1.81}/src/usecli/cli/core/error/handler.py +0 -0
  40. {usecli-0.1.79 → usecli-0.1.81}/src/usecli/cli/core/error/utils.py +0 -0
  41. {usecli-0.1.79 → usecli-0.1.81}/src/usecli/cli/core/exceptions/__init__.py +0 -0
  42. {usecli-0.1.79 → usecli-0.1.81}/src/usecli/cli/core/exceptions/base.py +0 -0
  43. {usecli-0.1.79 → usecli-0.1.81}/src/usecli/cli/core/exceptions/config.py +0 -0
  44. {usecli-0.1.79 → usecli-0.1.81}/src/usecli/cli/core/exceptions/usage.py +0 -0
  45. {usecli-0.1.79 → usecli-0.1.81}/src/usecli/cli/core/exceptions/validation.py +0 -0
  46. {usecli-0.1.79 → usecli-0.1.81}/src/usecli/cli/core/runtime.py +0 -0
  47. {usecli-0.1.79 → usecli-0.1.81}/src/usecli/cli/core/ui/__init__.py +0 -0
  48. {usecli-0.1.79 → usecli-0.1.81}/src/usecli/cli/core/ui/title.py +0 -0
  49. {usecli-0.1.79 → usecli-0.1.81}/src/usecli/cli/core/ui/title.txt +0 -0
  50. {usecli-0.1.79 → usecli-0.1.81}/src/usecli/cli/core/validators/__init__.py +0 -0
  51. {usecli-0.1.79 → usecli-0.1.81}/src/usecli/cli/core/validators/network.py +0 -0
  52. {usecli-0.1.79 → usecli-0.1.81}/src/usecli/cli/core/validators/numeric.py +0 -0
  53. {usecli-0.1.79 → usecli-0.1.81}/src/usecli/cli/core/validators/path.py +0 -0
  54. {usecli-0.1.79 → usecli-0.1.81}/src/usecli/cli/core/validators/string.py +0 -0
  55. {usecli-0.1.79 → usecli-0.1.81}/src/usecli/cli/services/__init__.py +0 -0
  56. {usecli-0.1.79 → usecli-0.1.81}/src/usecli/cli/services/command_service.py +0 -0
  57. {usecli-0.1.79 → usecli-0.1.81}/src/usecli/cli/templates/command.py.j2 +0 -0
  58. {usecli-0.1.79 → usecli-0.1.81}/src/usecli/cli/templates/theme.toml.j2 +0 -0
  59. {usecli-0.1.79 → usecli-0.1.81}/src/usecli/cli/themes/ayu_dark.toml +0 -0
  60. {usecli-0.1.79 → usecli-0.1.81}/src/usecli/cli/themes/catppuccin_frappe.toml +0 -0
  61. {usecli-0.1.79 → usecli-0.1.81}/src/usecli/cli/themes/catppuccin_latte.toml +0 -0
  62. {usecli-0.1.79 → usecli-0.1.81}/src/usecli/cli/themes/catppuccin_macchiato.toml +0 -0
  63. {usecli-0.1.79 → usecli-0.1.81}/src/usecli/cli/themes/catppuccin_mocha.toml +0 -0
  64. {usecli-0.1.79 → usecli-0.1.81}/src/usecli/cli/themes/default.toml +0 -0
  65. {usecli-0.1.79 → usecli-0.1.81}/src/usecli/cli/themes/dracula.toml +0 -0
  66. {usecli-0.1.79 → usecli-0.1.81}/src/usecli/cli/themes/gruvbox_dark.toml +0 -0
  67. {usecli-0.1.79 → usecli-0.1.81}/src/usecli/cli/themes/nord.toml +0 -0
  68. {usecli-0.1.79 → usecli-0.1.81}/src/usecli/cli/themes/tokyo_night.toml +0 -0
  69. {usecli-0.1.79 → usecli-0.1.81}/src/usecli/cli/utils/__init__.py +0 -0
  70. {usecli-0.1.79 → usecli-0.1.81}/src/usecli/cli/utils/interactive/__init__.py +0 -0
  71. {usecli-0.1.79 → usecli-0.1.81}/src/usecli/cli/utils/interactive/terminal_menu.py +0 -0
  72. {usecli-0.1.79 → usecli-0.1.81}/src/usecli/entry.py +0 -0
  73. {usecli-0.1.79 → usecli-0.1.81}/src/usecli/menu.py +0 -0
  74. {usecli-0.1.79 → usecli-0.1.81}/src/usecli/params.py +0 -0
  75. {usecli-0.1.79 → usecli-0.1.81}/src/usecli/progress.py +0 -0
  76. {usecli-0.1.79 → usecli-0.1.81}/src/usecli/shared/__init__.py +0 -0
  77. {usecli-0.1.79 → usecli-0.1.81}/src/usecli/shared/config/__init__.py +0 -0
  78. {usecli-0.1.79 → usecli-0.1.81}/src/usecli/shared/config/globals.py +0 -0
  79. {usecli-0.1.79 → usecli-0.1.81}/src/usecli/shared/upgrades/__init__.py +0 -0
  80. {usecli-0.1.79 → usecli-0.1.81}/src/usecli/shared/upgrades/checker.py +0 -0
  81. {usecli-0.1.79 → usecli-0.1.81}/src/usecli/shared/upgrades/installer.py +0 -0
  82. {usecli-0.1.79 → usecli-0.1.81}/src/usecli/shared/upgrades/pyproject.py +0 -0
  83. {usecli-0.1.79 → usecli-0.1.81}/src/usecli/shared/upgrades/service.py +0 -0
  84. {usecli-0.1.79 → usecli-0.1.81}/src/usecli/ui.py +0 -0
  85. {usecli-0.1.79 → usecli-0.1.81}/src/usecli/usecli.config.toml +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: usecli
3
- Version: 0.1.79
3
+ Version: 0.1.81
4
4
  Summary: A powerful Python CLI framework for building beautiful, developer-friendly command-line tools.
5
5
  Author: Edward Boswell
6
6
  Author-email: Edward Boswell <thememium@gmail.com>
@@ -251,6 +251,35 @@ make:theme Create new theme (usecli only)
251
251
  make:bundle Build a standalone executable with PyInstaller (usecli only)
252
252
  ```
253
253
 
254
+ ### Upgrades
255
+
256
+ Every CLI built on usecli ships `upgrade`, which detects how the running
257
+ package was installed and delegates to the same package manager — `uv tool`,
258
+ `pipx`, or `pip` — instead of creating a second installation:
259
+
260
+ ```bash
261
+ mycli upgrade --check # show installed/latest versions and the source
262
+ mycli upgrade # confirm and install the latest release
263
+ mycli upgrade --force # skip the prompt and the up-to-date check
264
+ ```
265
+
266
+ The source comes from packaging metadata: PyPI for index installs, and the
267
+ Git remote for PEP 610 `direct_url.json` installs. Override it in
268
+ `usecli.config.toml` when metadata is not enough (private mirrors, missing
269
+ `direct_url.json`, a package name that differs from the command name):
270
+
271
+ ```toml
272
+ [usecli.upgrade]
273
+ enabled = true
274
+ source = "git" # "auto" (default) | "pypi" | "git"
275
+ package = "mycli" # optional
276
+ url = "https://github.com/owner/mycli.git" # required for "git" unless detected
277
+ ref = "main" # branch, tag, or commit
278
+ ```
279
+
280
+ Frozen bundles, editable installs, and commit-pinned installs are reported
281
+ as non-upgradeable with an explanation and manual next step.
282
+
254
283
  <p align="right">(<a href="#readme-top">back to top</a>)</p>
255
284
 
256
285
  <!-- ENTRY POINTS & PACKAGING -->
@@ -221,6 +221,35 @@ make:theme Create new theme (usecli only)
221
221
  make:bundle Build a standalone executable with PyInstaller (usecli only)
222
222
  ```
223
223
 
224
+ ### Upgrades
225
+
226
+ Every CLI built on usecli ships `upgrade`, which detects how the running
227
+ package was installed and delegates to the same package manager — `uv tool`,
228
+ `pipx`, or `pip` — instead of creating a second installation:
229
+
230
+ ```bash
231
+ mycli upgrade --check # show installed/latest versions and the source
232
+ mycli upgrade # confirm and install the latest release
233
+ mycli upgrade --force # skip the prompt and the up-to-date check
234
+ ```
235
+
236
+ The source comes from packaging metadata: PyPI for index installs, and the
237
+ Git remote for PEP 610 `direct_url.json` installs. Override it in
238
+ `usecli.config.toml` when metadata is not enough (private mirrors, missing
239
+ `direct_url.json`, a package name that differs from the command name):
240
+
241
+ ```toml
242
+ [usecli.upgrade]
243
+ enabled = true
244
+ source = "git" # "auto" (default) | "pypi" | "git"
245
+ package = "mycli" # optional
246
+ url = "https://github.com/owner/mycli.git" # required for "git" unless detected
247
+ ref = "main" # branch, tag, or commit
248
+ ```
249
+
250
+ Frozen bundles, editable installs, and commit-pinned installs are reported
251
+ as non-upgradeable with an explanation and manual next step.
252
+
224
253
  <p align="right">(<a href="#readme-top">back to top</a>)</p>
225
254
 
226
255
  <!-- ENTRY POINTS & PACKAGING -->
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "usecli"
3
- version = "0.1.79"
3
+ version = "0.1.81"
4
4
  description = "A powerful Python CLI framework for building beautiful, developer-friendly command-line tools."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "usecli"
3
- version = "0.1.79"
3
+ version = "0.1.81"
4
4
  description = "A powerful Python CLI framework for building beautiful, developer-friendly command-line tools."
5
5
  readme = "README.md"
6
6
  authors = [{ name = "Edward Boswell", email = "thememium@gmail.com" }]
@@ -150,8 +150,12 @@ def collect_commands_data(
150
150
  or any(alias.startswith(prefix_filter) for alias in g["aliases"])
151
151
  ]
152
152
 
153
- app_name = getattr(app, "info", None)
154
- app_name = getattr(app_name, "name", "usecli") or "usecli"
153
+ info = getattr(app, "info", None)
154
+ app_name = (
155
+ getattr(info, "name", None)
156
+ or get_script_command_name(default="usecli")
157
+ or "usecli"
158
+ )
155
159
 
156
160
  return {
157
161
  "usage": f"{app_name} [COMMAND] [OPTIONS] [ARGUMENTS]",
@@ -12,3 +12,10 @@ hide_inspire = {{ hide_inspire | default(false) | lower }}
12
12
 
13
13
  [usecli.upgrade]
14
14
  enabled = {{ upgrade_enabled | default(true) | lower }}
15
+ # Where upgrades come from: "auto" (detect from install metadata), "pypi", or "git".
16
+ source = "{{ upgrade_source | default('auto') }}"
17
+ # Git only: repository URL. Falls back to the URL in the install metadata.
18
+ # url = "https://github.com/owner/repo.git"
19
+ # Optional: distribution name and Git ref (branch, tag, or commit) overrides.
20
+ # package = "my-cli"
21
+ # ref = "main"
@@ -284,8 +284,16 @@ class ConfigManager:
284
284
  "environment": "prod",
285
285
  "command_name": "usecli",
286
286
  "hide_inspire": False,
287
- # Users disable the built-in `upgrade` command via [usecli.upgrade].
288
- "upgrade": {"enabled": True},
287
+ # The built-in `upgrade` command reads [usecli.upgrade]. `source`
288
+ # overrides install-source detection: "auto" (packaging metadata),
289
+ # "pypi", or "git" (with `package`/`url`/`ref` overrides).
290
+ "upgrade": {
291
+ "enabled": True,
292
+ "source": "auto",
293
+ "package": None,
294
+ "url": None,
295
+ "ref": None,
296
+ },
289
297
  }
290
298
 
291
299
  def __init__(
@@ -13,9 +13,13 @@ import logging
13
13
  import os
14
14
  import sys
15
15
  from dataclasses import dataclass
16
- from typing import Any, Literal
16
+ from typing import TYPE_CHECKING, Any, Literal
17
17
 
18
18
  from usecli.shared.config.manager import _find_distribution_for_console_script
19
+ from usecli.shared.upgrades.settings import config_error, from_config
20
+
21
+ if TYPE_CHECKING:
22
+ from usecli.shared.upgrades.settings import UpgradeSettings
19
23
 
20
24
  logger = logging.getLogger(__name__)
21
25
 
@@ -170,29 +174,8 @@ def _revision_kind(revision: str | None) -> Literal["commit", "tag", "branch"] |
170
174
  return "branch"
171
175
 
172
176
 
173
- def discover(config: Any | None = None) -> InstallInfo:
174
- """Build an :class:`InstallInfo` for the running application.
175
-
176
- Args:
177
- config: Optional ConfigManager used for the version fallback when no
178
- distribution can be identified. Defaults to the global config.
179
-
180
- Returns:
181
- The discovered installation details. ``source`` is ``"unknown"`` and
182
- ``pinned`` is true when the installation cannot be classified (frozen
183
- bundles, unidentified distributions).
184
- """
185
- if getattr(sys, "frozen", False):
186
- return InstallInfo(
187
- package=_running_command_name() or "application",
188
- version=_fallback_version(config),
189
- source="unknown",
190
- pinned=True,
191
- pinned_reason="frozen bundle",
192
- frozen=True,
193
- )
194
-
195
- dist = _find_running_distribution()
177
+ def _from_distribution(dist: Any | None, config: Any | None) -> InstallInfo:
178
+ """Classify an installation from its distribution metadata."""
196
179
  if dist is None:
197
180
  logger.debug("No distribution found for the running console script")
198
181
  return InstallInfo(
@@ -285,3 +268,103 @@ def discover(config: Any | None = None) -> InstallInfo:
285
268
  pinned=True,
286
269
  pinned_reason="direct URL install",
287
270
  )
271
+
272
+
273
+ def _override_package(
274
+ install: InstallInfo,
275
+ settings: UpgradeSettings,
276
+ config: Any | None,
277
+ ) -> str:
278
+ """Resolve the distribution name for a configured source."""
279
+ if settings.package:
280
+ return settings.package
281
+ if install.source != "unknown" and install.package:
282
+ return install.package
283
+ raise config_error(
284
+ "upgrade.source is set but the package name could not be discovered.",
285
+ 'Set \\[usecli.upgrade] package = "<distribution name>".',
286
+ config,
287
+ )
288
+
289
+
290
+ def _apply_settings(
291
+ install: InstallInfo,
292
+ settings: UpgradeSettings,
293
+ config: Any | None,
294
+ ) -> InstallInfo:
295
+ """Replace the discovered source with the configured one."""
296
+ package = _override_package(install, settings, config)
297
+
298
+ if settings.source == "pypi":
299
+ return InstallInfo(
300
+ package=package,
301
+ version=install.version,
302
+ source="index",
303
+ installer=install.installer,
304
+ )
305
+
306
+ # settings.source == "git": prefer the configured remote, then the
307
+ # discovered Git URL. Direct-URL and editable records are not remotes.
308
+ url = settings.url or (install.url if install.source == "git" else None)
309
+ if not url:
310
+ raise config_error(
311
+ 'upgrade.source = "git" requires a repository URL.',
312
+ 'Set \\[usecli.upgrade] url = "https://github.com/owner/repo.git".',
313
+ config,
314
+ )
315
+
316
+ # The installed revision and commit only describe ``url`` when the
317
+ # configured remote is the one the package was installed from.
318
+ same_remote = install.source == "git" and (
319
+ settings.url is None or settings.url == install.url
320
+ )
321
+ revision = settings.ref or (install.revision if same_remote else None)
322
+ pinned = _revision_kind(revision) == "commit"
323
+ return InstallInfo(
324
+ package=package,
325
+ version=install.version,
326
+ source="git",
327
+ installer=install.installer,
328
+ url=url,
329
+ revision=revision,
330
+ commit=install.commit if same_remote else None,
331
+ pinned=pinned,
332
+ pinned_reason=f"Git commit ({revision})" if pinned else None,
333
+ )
334
+
335
+
336
+ def discover(config: Any | None = None) -> InstallInfo:
337
+ """Build an :class:`InstallInfo` for the running application.
338
+
339
+ Args:
340
+ config: Optional ConfigManager used for ``[usecli.upgrade]`` source
341
+ overrides and for the version fallback when no distribution can
342
+ be identified. Defaults to the global config.
343
+
344
+ Returns:
345
+ The discovered installation details. ``source`` is ``"unknown"`` and
346
+ ``pinned`` is true when the installation cannot be classified (frozen
347
+ bundles, unidentified distributions).
348
+
349
+ Raises:
350
+ UsecliConfigError: When ``[usecli.upgrade]`` configures an invalid
351
+ source, or omits a value that source requires.
352
+ """
353
+ if getattr(sys, "frozen", False):
354
+ return InstallInfo(
355
+ package=_running_command_name() or "application",
356
+ version=_fallback_version(config),
357
+ source="unknown",
358
+ pinned=True,
359
+ pinned_reason="frozen bundle",
360
+ frozen=True,
361
+ )
362
+
363
+ install = _from_distribution(_find_running_distribution(), config)
364
+ settings = from_config(config)
365
+ if not settings.overrides_source:
366
+ return install
367
+ return _apply_settings(install, settings, config)
368
+
369
+
370
+ __all__ = ["InstallInfo", "InstallSource", "discover"]
@@ -0,0 +1,129 @@
1
+ """Upgrade source configuration from ``[usecli.upgrade]``.
2
+
3
+ The ``upgrade`` command discovers the install source from packaging
4
+ metadata by default. CLI authors distribute builds that metadata cannot
5
+ describe (private indexes, mirrors, Git remotes installed with a tool that
6
+ strips ``direct_url.json``), so the source can be pinned in
7
+ ``usecli.config.toml``:
8
+
9
+ .. code-block:: toml
10
+
11
+ [usecli.upgrade]
12
+ enabled = true
13
+ source = "auto" # auto | pypi | git
14
+ package = "my-cli"
15
+ url = "https://github.com/owner/my-cli.git" # source = "git"
16
+ ref = "main" # branch, tag, or commit
17
+
18
+ ``auto`` keeps the metadata-based behaviour. Any other value is
19
+ authoritative: discovery, checks, and installation all use it.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from dataclasses import dataclass
25
+ from typing import Any, Literal
26
+
27
+ UpgradeSource = Literal["auto", "pypi", "git"]
28
+
29
+ _SOURCES: dict[str, UpgradeSource] = {
30
+ "auto": "auto",
31
+ "pypi": "pypi",
32
+ "git": "git",
33
+ }
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class UpgradeSettings:
38
+ """Upgrade source overrides configured by the CLI author.
39
+
40
+ Attributes:
41
+ source: Where releases come from. ``auto`` derives it from the
42
+ running installation's packaging metadata.
43
+ package: Distribution name to upgrade. Defaults to the discovered
44
+ distribution name.
45
+ url: Git remote to upgrade from. Defaults to the discovered Git URL.
46
+ ref: Git branch, tag, or commit to install. Defaults to the
47
+ discovered revision.
48
+ """
49
+
50
+ source: UpgradeSource = "auto"
51
+ package: str | None = None
52
+ url: str | None = None
53
+ ref: str | None = None
54
+
55
+ @property
56
+ def overrides_source(self) -> bool:
57
+ """Whether the configured source replaces metadata discovery."""
58
+ return self.source != "auto"
59
+
60
+
61
+ def _config_file(config: Any) -> str | None:
62
+ path = getattr(config, "usecli_config_path", None)
63
+ return str(path) if path else None
64
+
65
+
66
+ def config_error(message: str, suggestion: str, config: Any) -> Exception:
67
+ # Imported lazily: exceptions live in the CLI layer and import Rich.
68
+ from usecli.cli.core.exceptions.config import UsecliConfigError
69
+
70
+ return UsecliConfigError(
71
+ message,
72
+ config_file=_config_file(config),
73
+ suggestion=suggestion,
74
+ )
75
+
76
+
77
+ def _text(value: Any) -> str | None:
78
+ """Normalize an optional string setting; blank values mean unset."""
79
+ if not isinstance(value, str):
80
+ return None
81
+ stripped = value.strip()
82
+ return stripped or None
83
+
84
+
85
+ def from_config(config: Any | None) -> UpgradeSettings:
86
+ """Read ``[usecli.upgrade]`` settings from a ``ConfigManager``-like object.
87
+
88
+ Args:
89
+ config: Config manager exposing ``get(key, default)``. Objects
90
+ without a ``get`` method (test doubles, ``None``) yield the
91
+ defaults, preserving metadata-based discovery.
92
+
93
+ Returns:
94
+ The resolved settings.
95
+
96
+ Raises:
97
+ UsecliConfigError: When ``upgrade.source`` is not one of
98
+ ``auto``, ``pypi``, or ``git``.
99
+ """
100
+ get = getattr(config, "get", None)
101
+ if get is None:
102
+ return UpgradeSettings()
103
+
104
+ raw_source = get("upgrade.source", "auto")
105
+ if raw_source is None:
106
+ raw_source = "auto"
107
+ if not isinstance(raw_source, str):
108
+ raise config_error(
109
+ f"upgrade.source must be a string, got {type(raw_source).__name__}.",
110
+ 'Set \\[usecli.upgrade] source = "auto", "pypi", or "git".',
111
+ config,
112
+ )
113
+ source = _SOURCES.get(raw_source.strip().lower())
114
+ if source is None:
115
+ raise config_error(
116
+ f"Invalid upgrade.source '{raw_source}'.",
117
+ 'Set \\[usecli.upgrade] source to "auto", "pypi", or "git".',
118
+ config,
119
+ )
120
+
121
+ return UpgradeSettings(
122
+ source=source,
123
+ package=_text(get("upgrade.package")),
124
+ url=_text(get("upgrade.url")),
125
+ ref=_text(get("upgrade.ref")),
126
+ )
127
+
128
+
129
+ __all__ = ["UpgradeSettings", "UpgradeSource", "config_error", "from_config"]
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes