usecli 0.1.77__tar.gz → 0.1.79__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 (84) hide show
  1. {usecli-0.1.77 → usecli-0.1.79}/PKG-INFO +123 -1
  2. {usecli-0.1.77 → usecli-0.1.79}/README.md +120 -0
  3. {usecli-0.1.77 → usecli-0.1.79}/pyproject.toml +6 -2
  4. {usecli-0.1.77 → usecli-0.1.79}/pyproject.toml.orig +6 -3
  5. {usecli-0.1.77 → usecli-0.1.79}/src/usecli/__init__.py +8 -2
  6. usecli-0.1.79/src/usecli/bundler/__init__.py +275 -0
  7. usecli-0.1.79/src/usecli/bundler/__main__.py +6 -0
  8. usecli-0.1.79/src/usecli/cli/commands/defaults/base/upgrade_command.py +316 -0
  9. usecli-0.1.79/src/usecli/cli/commands/defaults/make/make_bundle_command.py +147 -0
  10. {usecli-0.1.77 → usecli-0.1.79}/src/usecli/cli/templates/usecli.config.toml.j2 +3 -0
  11. usecli-0.1.79/src/usecli/entry.py +204 -0
  12. {usecli-0.1.77 → usecli-0.1.79}/src/usecli/shared/config/manager.py +65 -2
  13. usecli-0.1.79/src/usecli/shared/upgrades/__init__.py +22 -0
  14. usecli-0.1.79/src/usecli/shared/upgrades/checker.py +276 -0
  15. usecli-0.1.79/src/usecli/shared/upgrades/discovery.py +287 -0
  16. usecli-0.1.79/src/usecli/shared/upgrades/installer.py +262 -0
  17. usecli-0.1.79/src/usecli/shared/upgrades/pyproject.py +619 -0
  18. usecli-0.1.79/src/usecli/shared/upgrades/service.py +47 -0
  19. {usecli-0.1.77 → usecli-0.1.79}/LICENSE +0 -0
  20. {usecli-0.1.77 → usecli-0.1.79}/src/usecli/cli/__init__.py +0 -0
  21. {usecli-0.1.77 → usecli-0.1.79}/src/usecli/cli/commands/README.md +0 -0
  22. {usecli-0.1.77 → usecli-0.1.79}/src/usecli/cli/commands/__init__.py +0 -0
  23. {usecli-0.1.77 → usecli-0.1.79}/src/usecli/cli/commands/custom/README.md +0 -0
  24. {usecli-0.1.77 → usecli-0.1.79}/src/usecli/cli/commands/custom/__init__.py +0 -0
  25. {usecli-0.1.77 → usecli-0.1.79}/src/usecli/cli/commands/defaults/__init__.py +0 -0
  26. {usecli-0.1.77 → usecli-0.1.79}/src/usecli/cli/commands/defaults/base/__init__.py +0 -0
  27. {usecli-0.1.77 → usecli-0.1.79}/src/usecli/cli/commands/defaults/base/about_command.py +0 -0
  28. {usecli-0.1.77 → usecli-0.1.79}/src/usecli/cli/commands/defaults/base/help_command.py +0 -0
  29. {usecli-0.1.77 → usecli-0.1.79}/src/usecli/cli/commands/defaults/base/inspire_command.py +0 -0
  30. {usecli-0.1.77 → usecli-0.1.79}/src/usecli/cli/commands/defaults/base/internal/__init__.py +0 -0
  31. {usecli-0.1.77 → usecli-0.1.79}/src/usecli/cli/commands/defaults/base/internal/fzf_command.py +0 -0
  32. {usecli-0.1.77 → usecli-0.1.79}/src/usecli/cli/commands/defaults/core/__init__.py +0 -0
  33. {usecli-0.1.77 → usecli-0.1.79}/src/usecli/cli/commands/defaults/core/utils.py +0 -0
  34. {usecli-0.1.77 → usecli-0.1.79}/src/usecli/cli/commands/defaults/make/__init__.py +0 -0
  35. {usecli-0.1.77 → usecli-0.1.79}/src/usecli/cli/commands/defaults/make/make_command.py +0 -0
  36. {usecli-0.1.77 → usecli-0.1.79}/src/usecli/cli/commands/defaults/make/make_theme_command.py +0 -0
  37. {usecli-0.1.77 → usecli-0.1.79}/src/usecli/cli/commands/init_command.py +0 -0
  38. {usecli-0.1.77 → usecli-0.1.79}/src/usecli/cli/config/__init__.py +0 -0
  39. {usecli-0.1.77 → usecli-0.1.79}/src/usecli/cli/config/colors.py +0 -0
  40. {usecli-0.1.77 → usecli-0.1.79}/src/usecli/cli/core/__init__.py +0 -0
  41. {usecli-0.1.77 → usecli-0.1.79}/src/usecli/cli/core/base_command.py +0 -0
  42. {usecli-0.1.77 → usecli-0.1.79}/src/usecli/cli/core/error/__init__.py +0 -0
  43. {usecli-0.1.77 → usecli-0.1.79}/src/usecli/cli/core/error/handler.py +0 -0
  44. {usecli-0.1.77 → usecli-0.1.79}/src/usecli/cli/core/error/utils.py +0 -0
  45. {usecli-0.1.77 → usecli-0.1.79}/src/usecli/cli/core/exceptions/__init__.py +0 -0
  46. {usecli-0.1.77 → usecli-0.1.79}/src/usecli/cli/core/exceptions/base.py +0 -0
  47. {usecli-0.1.77 → usecli-0.1.79}/src/usecli/cli/core/exceptions/config.py +0 -0
  48. {usecli-0.1.77 → usecli-0.1.79}/src/usecli/cli/core/exceptions/usage.py +0 -0
  49. {usecli-0.1.77 → usecli-0.1.79}/src/usecli/cli/core/exceptions/validation.py +0 -0
  50. {usecli-0.1.77 → usecli-0.1.79}/src/usecli/cli/core/runtime.py +0 -0
  51. {usecli-0.1.77 → usecli-0.1.79}/src/usecli/cli/core/ui/__init__.py +0 -0
  52. {usecli-0.1.77 → usecli-0.1.79}/src/usecli/cli/core/ui/list.py +0 -0
  53. {usecli-0.1.77 → usecli-0.1.79}/src/usecli/cli/core/ui/title.py +0 -0
  54. {usecli-0.1.77 → usecli-0.1.79}/src/usecli/cli/core/ui/title.txt +0 -0
  55. {usecli-0.1.77 → usecli-0.1.79}/src/usecli/cli/core/validators/__init__.py +0 -0
  56. {usecli-0.1.77 → usecli-0.1.79}/src/usecli/cli/core/validators/network.py +0 -0
  57. {usecli-0.1.77 → usecli-0.1.79}/src/usecli/cli/core/validators/numeric.py +0 -0
  58. {usecli-0.1.77 → usecli-0.1.79}/src/usecli/cli/core/validators/path.py +0 -0
  59. {usecli-0.1.77 → usecli-0.1.79}/src/usecli/cli/core/validators/string.py +0 -0
  60. {usecli-0.1.77 → usecli-0.1.79}/src/usecli/cli/services/__init__.py +0 -0
  61. {usecli-0.1.77 → usecli-0.1.79}/src/usecli/cli/services/command_service.py +0 -0
  62. {usecli-0.1.77 → usecli-0.1.79}/src/usecli/cli/templates/command.py.j2 +0 -0
  63. {usecli-0.1.77 → usecli-0.1.79}/src/usecli/cli/templates/theme.toml.j2 +0 -0
  64. {usecli-0.1.77 → usecli-0.1.79}/src/usecli/cli/themes/ayu_dark.toml +0 -0
  65. {usecli-0.1.77 → usecli-0.1.79}/src/usecli/cli/themes/catppuccin_frappe.toml +0 -0
  66. {usecli-0.1.77 → usecli-0.1.79}/src/usecli/cli/themes/catppuccin_latte.toml +0 -0
  67. {usecli-0.1.77 → usecli-0.1.79}/src/usecli/cli/themes/catppuccin_macchiato.toml +0 -0
  68. {usecli-0.1.77 → usecli-0.1.79}/src/usecli/cli/themes/catppuccin_mocha.toml +0 -0
  69. {usecli-0.1.77 → usecli-0.1.79}/src/usecli/cli/themes/default.toml +0 -0
  70. {usecli-0.1.77 → usecli-0.1.79}/src/usecli/cli/themes/dracula.toml +0 -0
  71. {usecli-0.1.77 → usecli-0.1.79}/src/usecli/cli/themes/gruvbox_dark.toml +0 -0
  72. {usecli-0.1.77 → usecli-0.1.79}/src/usecli/cli/themes/nord.toml +0 -0
  73. {usecli-0.1.77 → usecli-0.1.79}/src/usecli/cli/themes/tokyo_night.toml +0 -0
  74. {usecli-0.1.77 → usecli-0.1.79}/src/usecli/cli/utils/__init__.py +0 -0
  75. {usecli-0.1.77 → usecli-0.1.79}/src/usecli/cli/utils/interactive/__init__.py +0 -0
  76. {usecli-0.1.77 → usecli-0.1.79}/src/usecli/cli/utils/interactive/terminal_menu.py +0 -0
  77. {usecli-0.1.77 → usecli-0.1.79}/src/usecli/menu.py +0 -0
  78. {usecli-0.1.77 → usecli-0.1.79}/src/usecli/params.py +0 -0
  79. {usecli-0.1.77 → usecli-0.1.79}/src/usecli/progress.py +0 -0
  80. {usecli-0.1.77 → usecli-0.1.79}/src/usecli/shared/__init__.py +0 -0
  81. {usecli-0.1.77 → usecli-0.1.79}/src/usecli/shared/config/__init__.py +0 -0
  82. {usecli-0.1.77 → usecli-0.1.79}/src/usecli/shared/config/globals.py +0 -0
  83. {usecli-0.1.77 → usecli-0.1.79}/src/usecli/ui.py +0 -0
  84. {usecli-0.1.77 → usecli-0.1.79}/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.77
3
+ Version: 0.1.79
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>
@@ -18,12 +18,14 @@ Requires-Dist: rich>=15.0.0
18
18
  Requires-Dist: simple-term-menu>=1.6.6
19
19
  Requires-Dist: tomli>=2.4.1
20
20
  Requires-Dist: typer>=0.27.0
21
+ Requires-Dist: pyinstaller>=6.21.0 ; extra == 'pyinstaller'
21
22
  Requires-Python: >=3.10
22
23
  Project-URL: Homepage, https://github.com/thememium/usecli
23
24
  Project-URL: Documentation, https://github.com/thememium/usecli
24
25
  Project-URL: Repository, https://github.com/thememium/usecli.git
25
26
  Project-URL: Issues, https://github.com/thememium/usecli/issues
26
27
  Project-URL: Changelog, https://github.com/thememium/usecli/blob/master/CHANGELOG.md
28
+ Provides-Extra: pyinstaller
27
29
  Description-Content-Type: text/markdown
28
30
 
29
31
  <a name="readme-top"></a>
@@ -52,6 +54,7 @@ Description-Content-Type: text/markdown
52
54
  <li><a href="#about">About</a></li>
53
55
  <li><a href="#quick-start">Quick Start</a></li>
54
56
  <li><a href="#usage">Usage</a></li>
57
+ <li><a href="#entry-points--packaging">Entry Points &amp; Packaging</a></li>
55
58
  <li><a href="#development">Development</a></li>
56
59
  <li><a href="#contributing">Contributing</a></li>
57
60
  <li><a href="#license">License</a></li>
@@ -242,12 +245,131 @@ about Show app info
242
245
  help Show help
243
246
  init Initialize usecli (usecli only)
244
247
  inspire Random quote
248
+ upgrade Upgrade the application to the latest version
245
249
  make:command Create new command (usecli only)
246
250
  make:theme Create new theme (usecli only)
251
+ make:bundle Build a standalone executable with PyInstaller (usecli only)
247
252
  ```
248
253
 
249
254
  <p align="right">(<a href="#readme-top">back to top</a>)</p>
250
255
 
256
+ <!-- ENTRY POINTS & PACKAGING -->
257
+
258
+ ## Entry Points &amp; Packaging
259
+
260
+ A usecli CLI can be exposed in several ways, depending on how you distribute and
261
+ run it. There are three common setups — none requires anything but the base
262
+ `usecli` install, and the optional bundler adds PyInstaller packaging on top.
263
+
264
+ ### 1. Default — console script (no entry point)
265
+
266
+ The simplest setup. Declare a console script in your project's `pyproject.toml`:
267
+
268
+ ```toml
269
+ [project.scripts]
270
+ mycli = "usecli:main"
271
+ ```
272
+
273
+ usecli resolves your project's config automatically from the current directory
274
+ (config discovery), so no `main.py` is required. Install and run it:
275
+
276
+ ```sh
277
+ uv add usecli
278
+ uv run mycli --help
279
+ ```
280
+
281
+ This is the **default** — no entry point file of your own is needed.
282
+
283
+ ### 2. Custom entry point — `main.py`
284
+
285
+ If you want your own entry point, use the public runtime API. It works in both
286
+ development and frozen bundles, and accepts an optional config path (the config
287
+ file is always named `usecli.config.toml`):
288
+
289
+ ```python
290
+ # main.py
291
+ from usecli import run
292
+
293
+
294
+ def main():
295
+ run() # auto-detect project config
296
+ # run("path/to/usecli.config.toml") # or point at a specific config
297
+
298
+
299
+ if __name__ == "__main__":
300
+ main()
301
+ ```
302
+
303
+ Run it in development (no PyInstaller required):
304
+
305
+ ```sh
306
+ uv run python main.py --help
307
+ ```
308
+
309
+ To expose the same entry point as a console script, point `[project.scripts]`
310
+ at the `main` function in your `myapp` package (the usual `src/myapp` layout):
311
+
312
+ ```toml
313
+ [project.scripts]
314
+ myapp = "myapp:main"
315
+ ```
316
+
317
+ Because `run()` injects the located config explicitly, a custom `main.py` works
318
+ regardless of whether your CLI also has a `[project.scripts]` entry.
319
+
320
+ ### 3. Bundler — standalone executable (optional, requires PyInstaller)
321
+
322
+ The bundler is an **optional** feature gated on PyInstaller being installed.
323
+ Install it with:
324
+
325
+ ```sh
326
+ uv add "usecli[pyinstaller]"
327
+ ```
328
+
329
+ Once installed, a `make:bundle` command appears (usecli only) that builds your
330
+ project into a standalone executable. It asks which mode to use via an
331
+ interactive menu, and confirms before building (defaults to no unless `-y`):
332
+
333
+ ```sh
334
+ uv run usecli make:bundle # menu: one file vs one folder + confirm
335
+ uv run usecli make:bundle --mode onefile # single-file executable
336
+ uv run usecli make:bundle --mode onedir # one-folder bundle
337
+ uv run usecli make:bundle -y # skip the confirmation
338
+ uv run usecli make:bundle --mode onedir -y # fully programmatic
339
+ uv run usecli make:bundle --mode onedir --zip -y # one-folder bundle + zip
340
+ ```
341
+
342
+ When building a **one-folder** bundle, `make:bundle` asks whether to also zip
343
+ the folder into `dist/<name>.zip` (PyInstaller's recommended distribution
344
+ format for one-folder mode). Pass `--zip` / `--no-zip` to skip the prompt and
345
+ force a choice; with `-y` and no flag, zipping is skipped.
346
+
347
+ A `usecli-bundle` console script wraps the same build, and you can drive it from
348
+ Python too:
349
+
350
+ ```sh
351
+ uv run usecli-bundle
352
+ uv run usecli-bundle --mode onedir
353
+ uv run usecli-bundle --mode onedir --zip
354
+ ```
355
+
356
+ ```python
357
+ from usecli import pyinstaller
358
+
359
+ pyinstaller() # auto-detect config, single-file
360
+ pyinstaller(mode="onedir") # one-folder bundle
361
+ pyinstaller(mode="onedir", zip=True) # one-folder bundle + zip
362
+ pyinstaller("path/to/usecli.config.toml") # or point at a specific config
363
+ ```
364
+
365
+ Output lands in `dist/` — `dist/mycli` for `onefile`, or a `dist/mycli/` folder
366
+ containing the executable (plus `_internal/` with the bundled assets) for
367
+ `onedir`. With `zip=True`, a `dist/mycli.zip` archive is created alongside the
368
+ folder. `make:bundle` and `usecli-bundle` are only available when PyInstaller
369
+ is installed.
370
+
371
+ <p align="right">(<a href="#readme-top">back to top</a>)</p>
372
+
251
373
  <!-- DEVELOPMENT -->
252
374
 
253
375
  ## Development
@@ -24,6 +24,7 @@
24
24
  <li><a href="#about">About</a></li>
25
25
  <li><a href="#quick-start">Quick Start</a></li>
26
26
  <li><a href="#usage">Usage</a></li>
27
+ <li><a href="#entry-points--packaging">Entry Points &amp; Packaging</a></li>
27
28
  <li><a href="#development">Development</a></li>
28
29
  <li><a href="#contributing">Contributing</a></li>
29
30
  <li><a href="#license">License</a></li>
@@ -214,12 +215,131 @@ about Show app info
214
215
  help Show help
215
216
  init Initialize usecli (usecli only)
216
217
  inspire Random quote
218
+ upgrade Upgrade the application to the latest version
217
219
  make:command Create new command (usecli only)
218
220
  make:theme Create new theme (usecli only)
221
+ make:bundle Build a standalone executable with PyInstaller (usecli only)
219
222
  ```
220
223
 
221
224
  <p align="right">(<a href="#readme-top">back to top</a>)</p>
222
225
 
226
+ <!-- ENTRY POINTS & PACKAGING -->
227
+
228
+ ## Entry Points &amp; Packaging
229
+
230
+ A usecli CLI can be exposed in several ways, depending on how you distribute and
231
+ run it. There are three common setups — none requires anything but the base
232
+ `usecli` install, and the optional bundler adds PyInstaller packaging on top.
233
+
234
+ ### 1. Default — console script (no entry point)
235
+
236
+ The simplest setup. Declare a console script in your project's `pyproject.toml`:
237
+
238
+ ```toml
239
+ [project.scripts]
240
+ mycli = "usecli:main"
241
+ ```
242
+
243
+ usecli resolves your project's config automatically from the current directory
244
+ (config discovery), so no `main.py` is required. Install and run it:
245
+
246
+ ```sh
247
+ uv add usecli
248
+ uv run mycli --help
249
+ ```
250
+
251
+ This is the **default** — no entry point file of your own is needed.
252
+
253
+ ### 2. Custom entry point — `main.py`
254
+
255
+ If you want your own entry point, use the public runtime API. It works in both
256
+ development and frozen bundles, and accepts an optional config path (the config
257
+ file is always named `usecli.config.toml`):
258
+
259
+ ```python
260
+ # main.py
261
+ from usecli import run
262
+
263
+
264
+ def main():
265
+ run() # auto-detect project config
266
+ # run("path/to/usecli.config.toml") # or point at a specific config
267
+
268
+
269
+ if __name__ == "__main__":
270
+ main()
271
+ ```
272
+
273
+ Run it in development (no PyInstaller required):
274
+
275
+ ```sh
276
+ uv run python main.py --help
277
+ ```
278
+
279
+ To expose the same entry point as a console script, point `[project.scripts]`
280
+ at the `main` function in your `myapp` package (the usual `src/myapp` layout):
281
+
282
+ ```toml
283
+ [project.scripts]
284
+ myapp = "myapp:main"
285
+ ```
286
+
287
+ Because `run()` injects the located config explicitly, a custom `main.py` works
288
+ regardless of whether your CLI also has a `[project.scripts]` entry.
289
+
290
+ ### 3. Bundler — standalone executable (optional, requires PyInstaller)
291
+
292
+ The bundler is an **optional** feature gated on PyInstaller being installed.
293
+ Install it with:
294
+
295
+ ```sh
296
+ uv add "usecli[pyinstaller]"
297
+ ```
298
+
299
+ Once installed, a `make:bundle` command appears (usecli only) that builds your
300
+ project into a standalone executable. It asks which mode to use via an
301
+ interactive menu, and confirms before building (defaults to no unless `-y`):
302
+
303
+ ```sh
304
+ uv run usecli make:bundle # menu: one file vs one folder + confirm
305
+ uv run usecli make:bundle --mode onefile # single-file executable
306
+ uv run usecli make:bundle --mode onedir # one-folder bundle
307
+ uv run usecli make:bundle -y # skip the confirmation
308
+ uv run usecli make:bundle --mode onedir -y # fully programmatic
309
+ uv run usecli make:bundle --mode onedir --zip -y # one-folder bundle + zip
310
+ ```
311
+
312
+ When building a **one-folder** bundle, `make:bundle` asks whether to also zip
313
+ the folder into `dist/<name>.zip` (PyInstaller's recommended distribution
314
+ format for one-folder mode). Pass `--zip` / `--no-zip` to skip the prompt and
315
+ force a choice; with `-y` and no flag, zipping is skipped.
316
+
317
+ A `usecli-bundle` console script wraps the same build, and you can drive it from
318
+ Python too:
319
+
320
+ ```sh
321
+ uv run usecli-bundle
322
+ uv run usecli-bundle --mode onedir
323
+ uv run usecli-bundle --mode onedir --zip
324
+ ```
325
+
326
+ ```python
327
+ from usecli import pyinstaller
328
+
329
+ pyinstaller() # auto-detect config, single-file
330
+ pyinstaller(mode="onedir") # one-folder bundle
331
+ pyinstaller(mode="onedir", zip=True) # one-folder bundle + zip
332
+ pyinstaller("path/to/usecli.config.toml") # or point at a specific config
333
+ ```
334
+
335
+ Output lands in `dist/` — `dist/mycli` for `onefile`, or a `dist/mycli/` folder
336
+ containing the executable (plus `_internal/` with the bundled assets) for
337
+ `onedir`. With `zip=True`, a `dist/mycli.zip` archive is created alongside the
338
+ folder. `make:bundle` and `usecli-bundle` are only available when PyInstaller
339
+ is installed.
340
+
341
+ <p align="right">(<a href="#readme-top">back to top</a>)</p>
342
+
223
343
  <!-- DEVELOPMENT -->
224
344
 
225
345
  ## Development
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "usecli"
3
- version = "0.1.77"
3
+ version = "0.1.79"
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"
@@ -27,6 +27,9 @@ license-files = ["LICEN[CS]E*"]
27
27
  name = "Edward Boswell"
28
28
  email = "thememium@gmail.com"
29
29
 
30
+ [project.optional-dependencies]
31
+ pyinstaller = ["pyinstaller>=6.21.0"]
32
+
30
33
  [project.urls]
31
34
  Homepage = "https://github.com/thememium/usecli"
32
35
  Documentation = "https://github.com/thememium/usecli"
@@ -36,7 +39,7 @@ Changelog = "https://github.com/thememium/usecli/blob/master/CHANGELOG.md"
36
39
 
37
40
  [project.scripts]
38
41
  usecli = "usecli:main"
39
- INVALID = "usecli:main"
42
+ usecli-bundle = "usecli.bundler:cli"
40
43
 
41
44
  [build-system]
42
45
  requires = ["uv_build>=0.10.2,<0.11.0"]
@@ -47,6 +50,7 @@ dev = [
47
50
  "deptry>=0.25.1",
48
51
  "isort>=8.0.1",
49
52
  "poethepoet>=0.48.0",
53
+ "pyinstaller>=6.21.0",
50
54
  "pytest>=9.1.1",
51
55
  "pytest-cov>=7.1.0",
52
56
  "ruff>=0.16.1",
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "usecli"
3
- version = "0.1.77"
3
+ version = "0.1.79"
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" }]
@@ -24,6 +24,9 @@ dependencies = [
24
24
  license = "MIT"
25
25
  license-files = ["LICEN[CS]E*"]
26
26
 
27
+ [project.optional-dependencies]
28
+ pyinstaller = ["pyinstaller>=6.21.0"]
29
+
27
30
  [project.urls]
28
31
  Homepage = "https://github.com/thememium/usecli"
29
32
  Documentation = "https://github.com/thememium/usecli"
@@ -33,7 +36,7 @@ Changelog = "https://github.com/thememium/usecli/blob/master/CHANGELOG.md"
33
36
 
34
37
  [project.scripts]
35
38
  usecli = "usecli:main"
36
- INVALID = "usecli:main"
39
+ usecli-bundle = "usecli.bundler:cli"
37
40
 
38
41
  [build-system]
39
42
  requires = ["uv_build>=0.10.2,<0.11.0"]
@@ -44,6 +47,7 @@ dev = [
44
47
  "deptry>=0.25.1",
45
48
  "isort>=8.0.1",
46
49
  "poethepoet>=0.48.0",
50
+ "pyinstaller>=6.21.0",
47
51
  "pytest>=9.1.1",
48
52
  "pytest-cov>=7.1.0",
49
53
  "ruff>=0.16.1",
@@ -92,4 +96,3 @@ include = ["cli*"]
92
96
 
93
97
  [tool.setuptools.package-data]
94
98
  cli = ["**/*.toml", "**/*.j2"]
95
-
@@ -42,6 +42,8 @@ _LAZY_EXPORTS = {
42
42
  "console": ("usecli.ui", "console"),
43
43
  "Spinner": ("usecli.progress", "Spinner"),
44
44
  "ProgressBar": ("usecli.progress", "ProgressBar"),
45
+ "pyinstaller": ("usecli.bundler", "pyinstaller"),
46
+ "run": ("usecli.entry", "run"),
45
47
  }
46
48
 
47
49
 
@@ -272,7 +274,7 @@ def _ensure_cli_initialized() -> None:
272
274
  fail(error.__class__.__name__, str(error), 1)
273
275
 
274
276
  def invoke(self, ctx):
275
- from click.exceptions import Exit
277
+ from click.exceptions import ClickException, Exit
276
278
 
277
279
  from usecli.cli.core.runtime import is_json_mode
278
280
 
@@ -304,7 +306,9 @@ def _ensure_cli_initialized() -> None:
304
306
  styled_error = UsecliUsageError(e.message, ctx=e.ctx)
305
307
  styled_error.show()
306
308
  sys.exit(styled_error.exit_code)
307
- except _TyperClickException as e:
309
+ # UsecliError extends real-click ClickException, not the vendored
310
+ # typer._click one — catch both, mirroring the JSON path.
311
+ except (_TyperClickException, ClickException) as e:
308
312
  if hasattr(e, "show"):
309
313
  e.show()
310
314
  sys.exit(e.exit_code if hasattr(e, "exit_code") else 1)
@@ -365,6 +369,8 @@ __all__ = [
365
369
  "colors",
366
370
  "console",
367
371
  "main",
372
+ "pyinstaller",
373
+ "run",
368
374
  "theme",
369
375
  ]
370
376
 
@@ -0,0 +1,275 @@
1
+ """PyInstaller integration for usecli projects.
2
+
3
+ Every usecli project is a thin layer over usecli: it provides a
4
+ ``usecli.config.toml`` plus ``commands/``, ``templates/`` and ``themes/`` that
5
+ usecli loads at runtime. Packaging that with PyInstaller by hand requires a
6
+ hand-written spec, ``--collect-all`` flags and a custom entry point that
7
+ re-injects the bundle paths — easy to get wrong.
8
+
9
+ :func:`pyinstaller` turns all of that into a one-liner::
10
+
11
+ from usecli import pyinstaller
12
+
13
+ pyinstaller() # auto-detect config from the current directory
14
+ pyinstaller("config.toml") # or point at a specific config
15
+ pyinstaller(mode="onedir") # or a one-folder (directory) bundle
16
+
17
+ PyInstaller supports two operating modes (see
18
+ https://pyinstaller.org/en/stable/operating-mode.html):
19
+
20
+ * ``mode="onefile"`` (default): a single self-contained executable.
21
+ * ``mode="onedir"``: a folder containing the executable plus its dependencies
22
+ (faster to start, easier to debug — you can inspect what was collected).
23
+
24
+ It locates the project's ``usecli.config.toml`` (and the sibling
25
+ ``commands/``, ``templates/``, ``themes/`` and ``pyproject.toml``), then runs
26
+ PyInstaller to produce a bundle named after the config's ``command_name``.
27
+ PyInstaller is an optional dependency — install it with
28
+ ``pip install usecli[pyinstaller]`` (or ``uv add usecli[pyinstaller]``).
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import os
34
+ import sys
35
+ import zipfile
36
+ from collections.abc import Sequence
37
+ from pathlib import Path
38
+
39
+ from usecli.entry import BUNDLE_DATA_DIR
40
+
41
+ _MODE_FLAG = {"onefile": "--onefile", "onedir": "--onedir"}
42
+ _MODE_LABEL = {
43
+ "onefile": "a single-file executable",
44
+ "onedir": "a one-folder bundle",
45
+ }
46
+
47
+
48
+ def _resolve_config_path(
49
+ config_path: str | os.PathLike[str] | None,
50
+ ) -> Path:
51
+ """Return an absolute, existing path to the project's usecli.config.toml.
52
+
53
+ When ``config_path`` is ``None``, it is auto-detected: first by walking up
54
+ from the current directory, then (e.g. configs nested under ``cli/``) via a
55
+ bounded recursive search rooted at the detected project root.
56
+ """
57
+ if config_path is not None:
58
+ resolved = Path(config_path).resolve()
59
+ if not resolved.is_file():
60
+ raise FileNotFoundError(f"usecli.config.toml not found at: {resolved}")
61
+ return resolved
62
+
63
+ from usecli.shared.config.manager import resolve_config_path
64
+
65
+ return resolve_config_path(Path.cwd())
66
+
67
+
68
+ def _find_pyproject(data_root: Path) -> Path | None:
69
+ """Find the nearest pyproject.toml walking up from the data dir."""
70
+ from usecli.shared.config.manager import find_project_pyproject
71
+
72
+ return find_project_pyproject(data_root)
73
+
74
+
75
+ def _build_args(
76
+ config_path: Path,
77
+ entry_path: Path,
78
+ *,
79
+ name: str | None,
80
+ distpath: Path | None,
81
+ workpath: Path | None,
82
+ extra_args: Sequence[str] | None,
83
+ mode: str = "onefile",
84
+ ) -> list[str]:
85
+ from usecli.shared.config.manager import ConfigManager
86
+
87
+ if mode not in _MODE_FLAG:
88
+ raise ValueError(
89
+ f"Invalid mode {mode!r}; expected one of {sorted(_MODE_FLAG)}."
90
+ )
91
+
92
+ data_root = config_path.parent
93
+ command_name = name or (
94
+ ConfigManager._load_usecli_toml(config_path).get("command_name") or "usecli"
95
+ )
96
+ pyproject = _find_pyproject(data_root)
97
+
98
+ args: list[str] = [_MODE_FLAG[mode], "--name", command_name]
99
+ if distpath is not None:
100
+ args += ["--distpath", str(distpath)]
101
+ if workpath is not None:
102
+ args += ["--workpath", str(workpath)]
103
+ args += [
104
+ "--collect-all",
105
+ "usecli",
106
+ "--collect-all",
107
+ "pyfiglet",
108
+ "--add-data",
109
+ f"{data_root}{os.pathsep}{BUNDLE_DATA_DIR}",
110
+ "--paths",
111
+ str(data_root.parent),
112
+ "-y",
113
+ ]
114
+ if pyproject is not None:
115
+ args += ["--add-data", f"{pyproject}{os.pathsep}{BUNDLE_DATA_DIR}"]
116
+ if extra_args:
117
+ args += list(extra_args)
118
+ args.append(str(entry_path))
119
+ return args
120
+
121
+
122
+ def _zip_bundle(dist_dir: Path, name: str) -> Path:
123
+ """Compress a one-folder bundle into ``<name>.zip``.
124
+
125
+ The archive is rooted at the bundle folder (``<name>/``) so users can
126
+ install the program simply by unzipping it — the distribution flow PyInstaller
127
+ describes for one-folder mode
128
+ (https://pyinstaller.org/en/stable/operating-mode.html#bundling-to-one-folder).
129
+ """
130
+ bundle_dir = dist_dir / name
131
+ if not bundle_dir.is_dir():
132
+ raise FileNotFoundError(
133
+ f"Cannot zip: expected one-folder bundle at {bundle_dir}."
134
+ )
135
+ target = dist_dir / f"{name}.zip"
136
+ with zipfile.ZipFile(target, "w", zipfile.ZIP_DEFLATED) as zf:
137
+ for path in sorted(bundle_dir.rglob("*")):
138
+ if path.is_file():
139
+ zf.write(path, arcname=f"{name}/{path.relative_to(bundle_dir)}")
140
+ return target
141
+
142
+
143
+ def pyinstaller(
144
+ config_path: str | os.PathLike[str] | None = None,
145
+ *,
146
+ name: str | None = None,
147
+ mode: str = "onefile",
148
+ distpath: str | os.PathLike[str] | None = None,
149
+ workpath: str | os.PathLike[str] | None = None,
150
+ extra_args: Sequence[str] | None = None,
151
+ zip: bool = False,
152
+ ) -> int:
153
+ """Build an executable bundle for the given usecli project.
154
+
155
+ Args:
156
+ config_path: Optional path to a ``usecli.config.toml``. When omitted it
157
+ is auto-detected by walking up from the current directory.
158
+ name: Optional override for the executable name. Defaults to the
159
+ config's ``command_name``.
160
+ mode: ``"onefile"`` (single executable, default) or ``"onedir"``
161
+ (folder containing the executable plus its dependencies).
162
+ distpath: Output directory for the bundle (default: ``./dist``).
163
+ workpath: PyInstaller work directory (default: ``./build``).
164
+ extra_args: Additional PyInstaller CLI flags, e.g.
165
+ ``["--icon", "icon.icns"]``.
166
+ zip: When ``mode="onedir"`` and ``zip=True``, compress the resulting
167
+ folder into ``<name>.zip`` (alongside the folder in ``distpath``)
168
+ after a successful build — the distribution format PyInstaller
169
+ recommends for one-folder bundles. Ignored for ``mode="onefile"``.
170
+
171
+ Returns:
172
+ The PyInstaller exit code (``0`` on success).
173
+
174
+ Raises:
175
+ FileNotFoundError: if no config can be located / the given path is not
176
+ a file.
177
+ ValueError: if ``mode`` is not ``"onefile"`` or ``"onedir"``.
178
+ ImportError: if PyInstaller is not installed. Install with
179
+ ``pip install usecli[pyinstaller]``.
180
+ """
181
+ resolved = _resolve_config_path(config_path)
182
+ import usecli.entry as entry_module
183
+
184
+ entry_path = Path(entry_module.__file__)
185
+
186
+ try:
187
+ from PyInstaller.__main__ import run as _pyinstaller_run
188
+ except ImportError as exc: # pragma: no cover - env dependent
189
+ raise ImportError(
190
+ "PyInstaller is required to build an executable. Install it with "
191
+ "`pip install usecli[pyinstaller]` (or `uv add usecli[pyinstaller]`)."
192
+ ) from exc
193
+
194
+ args = _build_args(
195
+ resolved,
196
+ entry_path,
197
+ name=name,
198
+ mode=mode,
199
+ distpath=Path(distpath) if distpath is not None else None,
200
+ workpath=Path(workpath) if workpath is not None else None,
201
+ extra_args=extra_args,
202
+ )
203
+
204
+ print(
205
+ f"[usecli] Packaging '{args[args.index('--name') + 1]}' "
206
+ f"from {resolved} as {_MODE_LABEL[mode]}..."
207
+ )
208
+ code = _pyinstaller_run(args)
209
+ if isinstance(code, int) and code != 0:
210
+ raise SystemExit(code)
211
+ result = int(code or 0)
212
+ if zip and mode == "onedir" and result == 0:
213
+ from usecli.shared.config.manager import ConfigManager
214
+
215
+ dist_dir = (
216
+ Path(distpath).resolve() if distpath is not None else Path.cwd() / "dist"
217
+ )
218
+ bundle_name = name or (
219
+ ConfigManager._load_usecli_toml(resolved).get("command_name") or "usecli"
220
+ )
221
+ zip_file = _zip_bundle(dist_dir, bundle_name)
222
+ print(f"[usecli] Created {zip_file}")
223
+ return result
224
+
225
+
226
+ def cli(argv: Sequence[str] | None = None) -> int:
227
+ """Command-line interface backing the ``usecli-bundle`` console script.
228
+
229
+ So a project's pyproject.toml never needs to carry PyInstaller flags —
230
+ ``uv run usecli-bundle`` auto-detects the config and builds the bundle::
231
+
232
+ [tool.poe.tasks]
233
+ bundle = "uv run usecli-bundle" # single-file executable
234
+ bundle-dir = "uv run usecli-bundle --mode onedir"
235
+ """
236
+ import argparse
237
+
238
+ parser = argparse.ArgumentParser(
239
+ prog="usecli-bundle",
240
+ description="Build an executable bundle for the current usecli project.",
241
+ )
242
+ parser.add_argument(
243
+ "config_path",
244
+ nargs="?",
245
+ default=None,
246
+ help="Path to usecli.config.toml (auto-detected when omitted).",
247
+ )
248
+ parser.add_argument("--name", default=None, help="Executable name override.")
249
+ parser.add_argument(
250
+ "--mode",
251
+ choices=sorted(_MODE_FLAG),
252
+ default="onefile",
253
+ help="onefile (single executable, default) or onedir (folder bundle).",
254
+ )
255
+ parser.add_argument("--distpath", default=None, help="Output directory.")
256
+ parser.add_argument("--workpath", default=None, help="PyInstaller work dir.")
257
+ parser.add_argument(
258
+ "--zip",
259
+ action="store_true",
260
+ help="Zip the one-folder bundle into <name>.zip (onedir mode).",
261
+ )
262
+ known, extra = parser.parse_known_args(argv)
263
+ return pyinstaller(
264
+ config_path=known.config_path,
265
+ name=known.name,
266
+ mode=known.mode,
267
+ distpath=known.distpath,
268
+ workpath=known.workpath,
269
+ extra_args=extra or None,
270
+ zip=known.zip,
271
+ )
272
+
273
+
274
+ if __name__ == "__main__": # pragma: no cover - convenience CLI
275
+ sys.exit(cli())
@@ -0,0 +1,6 @@
1
+ """Allow ``python -m usecli.bundler`` to build a single-file executable."""
2
+
3
+ from usecli.bundler import cli
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(cli())