tacklebox-cli 0.2.6__tar.gz → 0.4.0__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.
@@ -1,5 +1,4 @@
1
- The MIT License (MIT)
2
- =====================
1
+ # The MIT License (MIT)
3
2
 
4
3
  Copyright © 2025 cswimr
5
4
 
@@ -0,0 +1,104 @@
1
+ Metadata-Version: 2.4
2
+ Name: tacklebox-cli
3
+ Version: 0.4.0
4
+ Summary: A small collection of CLI utilities.
5
+ Project-URL: Homepage, https://c.csw.im/cswimr/tacklebox
6
+ Project-URL: Issues, https://c.csw.im/cswimr/tacklebox/issues
7
+ Project-URL: Source Archive, https://c.csw.im/cswimr/tacklebox/archive/9286755479cd8bf8d5ad51736ce7b519c01e1479.tar.gz
8
+ Author-email: cswimr <seaswimmerthefsh@gmail.com>
9
+ License-Expression: MIT
10
+ License-File: LICENSE.md
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python
13
+ Classifier: Programming Language :: Python :: 3.14
14
+ Requires-Python: >=3.13
15
+ Requires-Dist: typer>=0.20.0
16
+ Description-Content-Type: text/markdown
17
+
18
+ # tacklebox-cli
19
+
20
+ [<img alt="Actions Status" src="https://c.csw.im/cswimr/tacklebox/badges/workflows/actions.yml/badge.svg?style=plastic">](https://c.csw.im/cswimr/tacklebox/actions?workflow=actions.yml)
21
+ [<img alt="PyPI - Version" src="https://img.shields.io/pypi/v/tacklebox-cli?style=plastic">](https://pypi.org/project/tacklebox-cli/)
22
+ [<img alt="PyPI - Python Version" src="https://img.shields.io/pypi/pyversions/tacklebox-cli?style=plastic">](https://pypi.org/project/tacklebox-cli/)
23
+ [<img alt="PyPI - License" src="https://img.shields.io/pypi/l/tacklebox-cli?style=plastic">](https://c.csw.im/cswimr/tacklebox/src/tag/0.4.0/LICENSE.md)
24
+ tacklebox-cli offers a suite of useful CLI tools.
25
+
26
+ ## Usage
27
+
28
+ ### tacklebox copy / paste
29
+
30
+ Cross-platform clipboard management tool. Uses system tools such as `wl-copy` on Linux Wayland or `clip.exe` on Windows, and [OSC 52](https://www.reddit.com/r/vim/comments/k1ydpn/a_guide_on_how_to_copy_text_from_anywhere/) escape codes when **copying** over SSH or when no other tools are available. See [`copy_with_tooling()`](https://c.csw.im/cswimr/tacklebox/src/tag/0.4.0/tacklebox/commands/clipboard.py) for all supported tools.
31
+
32
+ ```bash
33
+ $ echo "a" | tacklebox copy --trim && tacklebox paste
34
+ a
35
+ ```
36
+
37
+ ### tacklebox prepend-to-file
38
+
39
+ Prepend the contents of one file to another, with support for adding a newline automatically and checking if the operation has already been performed on the destination file.
40
+
41
+ ```bash
42
+ $ echo "template-example" > template
43
+ $ echo "hello world" > hello-world.txt
44
+ $ tacklebox prepend-to-file template hello-world.txt -n
45
+ $ cat hello-world.txt
46
+ template-example
47
+ hello world
48
+ ```
49
+
50
+ ### tacklebox find-desktop-entry (Linux only)
51
+
52
+ Checks all of the paths under `$XDG_DATA_DIRS` for an application desktop file with the given name.
53
+ Prints the full path of the desktop file to stdout if found.
54
+
55
+ ```bash
56
+ $ tacklebox find-desktop-entry kitty
57
+ kitty.desktop found in /etc/profiles/per-user/cswimr/share/applications
58
+ /etc/profiles/per-user/cswimr/share/applications/kitty.desktop
59
+
60
+ $ tacklebox find-desktop-entry --read kitty
61
+ kitty.desktop found in /etc/profiles/per-user/cswimr/share/applications
62
+ # /etc/profiles/per-user/cswimr/share/applications/kitty.desktop
63
+ [Desktop Entry]
64
+ Version=1.0
65
+ Type=Application
66
+ Name=kitty
67
+ GenericName=Terminal emulator
68
+ Comment=Fast, feature-rich, GPU based terminal
69
+ TryExec=kitty
70
+ StartupNotify=true
71
+ Exec=kitty
72
+ Icon=kitty
73
+ Categories=System;TerminalEmulator;
74
+ X-TerminalArgExec=--
75
+ X-TerminalArgTitle=--title
76
+ X-TerminalArgAppId=--class
77
+ X-TerminalArgDir=--working-directory
78
+ X-TerminalArgHold=--hold
79
+ ```
80
+
81
+ ## License - The MIT License
82
+
83
+ Copyright © 2025 cswimr
84
+
85
+ Permission is hereby granted, free of charge, to any person
86
+ obtaining a copy of this software and associated documentation
87
+ files (the “Software”), to deal in the Software without
88
+ restriction, including without limitation the rights to use,
89
+ copy, modify, merge, publish, distribute, sublicense, and/or sell
90
+ copies of the Software, and to permit persons to whom the
91
+ Software is furnished to do so, subject to the following
92
+ conditions:
93
+
94
+ The above copyright notice and this permission notice shall be
95
+ included in all copies or substantial portions of the Software.
96
+
97
+ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND,
98
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
99
+ OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
100
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
101
+ HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
102
+ WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
103
+ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
104
+ OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,72 @@
1
+ [project]
2
+ name = "tacklebox-cli"
3
+ description = "A small collection of CLI utilities."
4
+ requires-python = ">=3.13"
5
+ license = "MIT"
6
+ license-files = ["LICEN[CS]E*"]
7
+ authors = [{ name = "cswimr", email = "seaswimmerthefsh@gmail.com" }]
8
+ classifiers = [
9
+ "License :: OSI Approved :: MIT License",
10
+ "Programming Language :: Python",
11
+ "Programming Language :: Python :: 3.14"
12
+ ]
13
+ dependencies = [
14
+ "typer>=0.20.0",
15
+ ]
16
+ dynamic = ["readme", "urls", "version"]
17
+
18
+ [project.scripts]
19
+ tacklebox = "tacklebox.entrypoint:app"
20
+ tacklebox-cli = "tacklebox.entrypoint:app" # for uvx / pipx, prefer tacklebox when possible
21
+
22
+ [dependency-groups]
23
+ dev = [
24
+ "basedpyright~=1.38.2",
25
+ "pyinstrument~=5.1.2",
26
+ "ruff~=0.15.4",
27
+ ]
28
+
29
+ [build-system]
30
+ requires = ["hatch-fancy-pypi-readme", "hatch-vcs", "hatchling"]
31
+ build-backend = "hatchling.build"
32
+
33
+ [tool.hatch.version]
34
+ source = "vcs"
35
+
36
+ [tool.hatch.build]
37
+ include = ["/tacklebox", "/LICENSE.md", "/pyproject.toml"]
38
+
39
+ [tool.hatch.build.hooks.vcs]
40
+ version-file = "tacklebox/version.py"
41
+
42
+ [tool.hatch.metadata.hooks.vcs.urls]
43
+ Homepage = "https://c.csw.im/cswimr/tacklebox"
44
+ Issues = "https://c.csw.im/cswimr/tacklebox/issues"
45
+ "Source Archive" = "https://c.csw.im/cswimr/tacklebox/archive/{commit_hash}.tar.gz"
46
+
47
+ [tool.hatch.metadata.hooks.fancy-pypi-readme]
48
+ content-type = "text/markdown"
49
+
50
+ [[tool.hatch.metadata.hooks.fancy-pypi-readme.fragments]]
51
+ path = "README.md"
52
+
53
+ [[tool.hatch.metadata.hooks.fancy-pypi-readme.fragments]]
54
+ text = "\n## License - The MIT License"
55
+
56
+ [[tool.hatch.metadata.hooks.fancy-pypi-readme.fragments]]
57
+ path = "LICENSE.md"
58
+ start-after = "# The MIT License (MIT)"
59
+
60
+ [[tool.hatch.metadata.hooks.fancy-pypi-readme.substitutions]]
61
+ pattern = '\[(.+?)\]\(((?!https?://)\S+?)\)'
62
+ replacement = '[\1](https://c.csw.im/cswimr/tacklebox/src/tag/$HFPR_VERSION\g<2>)'
63
+
64
+ [tool.pyright]
65
+ typeCheckingMode = "strict"
66
+ reportMissingTypeStubs = false
67
+
68
+ [tool.ruff]
69
+ line-length = 145
70
+
71
+ [tool.typos]
72
+ files.extend-exclude = [".direnv/**", ".venv/**"]
@@ -0,0 +1,13 @@
1
+ import platform
2
+
3
+ from typer import Typer
4
+
5
+ from .clipboard import app as clipboard
6
+ from .find_desktop_entry import app as find_desktop_entry
7
+ from .prepend_to_file import app as prepend_to_file
8
+ from .version import app as version
9
+
10
+ commands: list[Typer] = [clipboard, prepend_to_file, version]
11
+
12
+ if platform.system() == "Linux":
13
+ commands.extend([find_desktop_entry])
@@ -9,7 +9,7 @@ from typing import Annotated, Literal
9
9
 
10
10
  from typer import Argument, Exit, Option, Typer, echo
11
11
 
12
- from tacklebox.utils import get_environment
12
+ from tacklebox.utils import get_environment, stderr, stdout
13
13
 
14
14
  app = Typer()
15
15
 
@@ -140,14 +140,14 @@ def use_tooling(
140
140
 
141
141
  if in_wsl:
142
142
  if verbose:
143
- echo("Detected Windows Subsystem for Linux.", err=True)
143
+ stderr.print("Detected Windows Subsystem for Linux.")
144
144
  commands.extend(tools[mode]["windows"])
145
145
 
146
146
  protocol: Literal["wayland", "x11"] | None = (
147
147
  "wayland" if "WAYLAND_DISPLAY" in get_environment() else ("x11" if "DISPLAY" in get_environment() else None)
148
148
  )
149
149
  if verbose:
150
- echo(f"Detected display protocol: {protocol}", err=True)
150
+ stderr.print(f"Detected display protocol: {protocol}")
151
151
 
152
152
  match protocol:
153
153
  case "wayland":
@@ -157,10 +157,7 @@ def use_tooling(
157
157
  case _:
158
158
  if not in_wsl:
159
159
  if verbose:
160
- echo(
161
- "Unknown display protocol: neither WAYLAND_DISPLAY nor DISPLAY set.",
162
- err=True,
163
- )
160
+ stderr.print("Unknown display protocol: neither WAYLAND_DISPLAY nor DISPLAY set.")
164
161
  return False, None
165
162
 
166
163
  case "darwin":
@@ -169,11 +166,11 @@ def use_tooling(
169
166
  commands.extend(tools[mode]["windows"])
170
167
  case _:
171
168
  if verbose:
172
- echo(f"No suitable clipboard tool known for platform '{system}'", err=True)
169
+ stderr.print(f"No suitable clipboard tool known for platform '{system}'")
173
170
  return False, None
174
171
 
175
172
  if verbose:
176
- echo("\nAttempting commands:\n" + "\n".join(" ".join(cmd) for cmd in commands) + "\n", err=True)
173
+ stderr.print("\nAttempting commands:\n" + "\n".join(" ".join(cmd) for cmd in commands) + "\n")
177
174
  for cmd in commands:
178
175
  if (success := _try_command(mode, cmd, verbose, data))[0]:
179
176
  return success
@@ -195,28 +192,27 @@ def encode_osc52(data: str, verbose: bool = False) -> str:
195
192
 
196
193
  if "TMUX" in os.environ:
197
194
  if verbose:
198
- echo("Wrapping OSC 52 for tmux.")
195
+ stderr.print("Wrapping OSC 52 for tmux.")
199
196
  return f"\x1bPtmux;\x1b{osc_seq}\x1b\\"
200
197
  elif os.environ.get("TERM", "").startswith("screen"):
201
198
  if verbose:
202
- echo("Wrapping OSC 52 for screen.")
199
+ stderr.print("Wrapping OSC 52 for screen.")
203
200
  return f"\x1bP{osc_seq}\x1b\\"
204
201
  else:
205
202
  if verbose:
206
- echo("Using plain OSC 52.")
203
+ stderr.print("Using plain OSC 52.")
207
204
  return osc_seq
208
205
 
209
206
 
210
207
  def _maybe_print_environment_information(verbose: bool) -> None:
211
208
  if verbose:
212
- echo(
209
+ stderr.print(
213
210
  (
214
211
  f"Platform: {platform.system()}\n"
215
212
  f"TERM: {os.environ.get('TERM')}\n"
216
213
  f"TMUX: {'present' if 'TMUX' in os.environ else 'absent'}\n"
217
214
  f"SCREEN: {'present' if os.environ.get('TERM', '').startswith('screen') else 'absent'}"
218
- ),
219
- err=True,
215
+ )
220
216
  )
221
217
 
222
218
 
@@ -250,8 +246,8 @@ def copy(
250
246
  data = sys.stdin.read()
251
247
 
252
248
  if not data:
253
- echo("No input received from stdin.", err=True)
254
- raise Exit(code=1)
249
+ stderr.print("No input received from stdin.")
250
+ raise Exit(1)
255
251
 
256
252
  if trim:
257
253
  data = data.rstrip("\r\n")
@@ -266,16 +262,16 @@ def copy(
266
262
  return
267
263
 
268
264
  if verbose:
269
- echo("Clipboard tools failed; trying OSC 52...", err=True)
265
+ stderr.print("Clipboard tools failed; trying OSC 52...")
270
266
 
271
267
  osc = encode_osc52(data, verbose)
272
268
  try:
273
269
  with open("/dev/tty", "w") as tty:
274
270
  tty.write(osc)
275
271
  if verbose:
276
- echo("Copied using OSC 52.")
272
+ stderr.print("Copied using OSC 52.")
277
273
  except Exception as e:
278
- echo(f"OSC 52 failed: {e}", err=True)
274
+ stderr.print(f"OSC 52 failed: {e}")
279
275
  raise Exit(code=1)
280
276
 
281
277
 
@@ -307,11 +303,11 @@ def paste(
307
303
  if trim and string is not None:
308
304
  string = string.rstrip("\r\n")
309
305
 
310
- echo(string)
306
+ stdout.out(string)
311
307
  return
312
308
 
313
- echo(f"Paste command failed!\n{output}", err=True)
314
- exit(code=1)
309
+ stderr.print(f"Paste command failed!\n{output}")
310
+ raise Exit(1)
315
311
 
316
312
 
317
313
  if __name__ == "__main__":
@@ -0,0 +1,55 @@
1
+ import os
2
+ from pathlib import Path
3
+ from typing import Annotated
4
+
5
+ from typer import Argument, Exit, Option, Typer
6
+
7
+ from tacklebox.utils import stderr, stdout
8
+
9
+ app = Typer()
10
+
11
+
12
+ @app.command()
13
+ def find_desktop_entry(
14
+ desktop_entry: Annotated[str, Argument(help="The name (application ID) of the desktop file to search for.")],
15
+ *,
16
+ read: Annotated[
17
+ bool,
18
+ Option(..., "--read", "-r", help="Print the contents of the desktop file, if found."),
19
+ ] = False,
20
+ print_paths: Annotated[bool, Option(..., "--print-paths", help="Print the paths that the command will check for desktop files.")] = False,
21
+ include_user_data_dir: Annotated[
22
+ bool,
23
+ Option(..., "--user-data-dir", "-u", help="Include the user data directory in the search, if not already included in `XDG_DATA_DIRS`."),
24
+ ] = False,
25
+ ) -> None:
26
+ """Locate a desktop entry according to the value of your `XDG_DATA_DIRS` environment variable."""
27
+ if not desktop_entry.endswith(".desktop"):
28
+ desktop_entry += ".desktop"
29
+
30
+ entry_paths = []
31
+
32
+ xdg_data_dirs = [Path(path) for path in os.getenv("XDG_DATA_DIRS", "/usr/share:/usr/local/share").split(":")]
33
+ entry_paths = [path / "applications" for path in xdg_data_dirs]
34
+ if include_user_data_dir:
35
+ data_home = Path(os.getenv("XDG_DATA_HOME", "~/.local/share")).expanduser()
36
+ application_path = data_home / "applications"
37
+ if application_path not in entry_paths:
38
+ entry_paths.append(application_path)
39
+
40
+ if print_paths:
41
+ stderr.print(f"Checking the following paths for {desktop_entry}:\n{[str(path) for path in entry_paths]}\n{'-' * 20}")
42
+
43
+ for entry_path in entry_paths:
44
+ entry_file = entry_path / f"{desktop_entry}"
45
+ if entry_file.is_file():
46
+ stderr.print(f"{desktop_entry} found in {entry_path}")
47
+ if read:
48
+ content = entry_file.read_text("utf-8")
49
+ stdout.out(f"# {entry_file}\n{content}")
50
+ else:
51
+ stdout.out(entry_file)
52
+ return
53
+
54
+ stderr.print(f"Desktop entry {desktop_entry} could not be found.")
55
+ raise Exit(1)
@@ -0,0 +1,84 @@
1
+ #! /usr/bin/env python3
2
+
3
+ import os
4
+ import shutil
5
+ import tempfile
6
+ from pathlib import Path
7
+ from typing import Annotated
8
+
9
+ import typer
10
+
11
+ from tacklebox.utils import stderr
12
+
13
+ app = typer.Typer()
14
+
15
+
16
+ def check_file(file: Path, /, *, write: bool = True) -> None:
17
+ if not file.exists():
18
+ raise FileNotFoundError(f"{file} does not exist!")
19
+ if not file.is_file():
20
+ raise FileNotFoundError(f"{file} is not a file!")
21
+ if not os.access(file, os.R_OK):
22
+ raise PermissionError(f"Cannot read file {file}!")
23
+
24
+ if write:
25
+ if not os.access(file, os.W_OK):
26
+ raise PermissionError(f"Cannot write to file {file}!")
27
+ if not os.access(file.parent, os.W_OK | os.X_OK):
28
+ raise PermissionError(f"Cannot write to directory {file.parent}!")
29
+
30
+
31
+ @app.command()
32
+ def prepend_to_file(
33
+ source_file: Annotated[Path, typer.Argument(help="The file to read content from.")],
34
+ dest_file: Annotated[Path, typer.Argument(help="The target file to modify.")],
35
+ add_newline: Annotated[
36
+ bool,
37
+ typer.Option(
38
+ "--add-newline/--no-add-newline",
39
+ "-a/-n",
40
+ help="Automatically strip newlines and append a newline to the provided content.",
41
+ ),
42
+ ] = True,
43
+ check_for_existence: Annotated[
44
+ bool,
45
+ typer.Option(
46
+ "--check-existence/--no-check-existence",
47
+ "-c/-C",
48
+ help="Check if the file already starts with the provided text.",
49
+ ),
50
+ ] = True,
51
+ ) -> None:
52
+ """Prepend text to the top of a file."""
53
+
54
+ try:
55
+ check_file(source_file, write=False)
56
+ check_file(dest_file, write=True)
57
+ except (PermissionError, FileNotFoundError) as err:
58
+ stderr.print(f"[red]{err}[reset]")
59
+
60
+ source_content = source_file.read_text()
61
+ dest_content = dest_file.read_text()
62
+
63
+ tmp_path: Path | None = None
64
+
65
+ stripped = source_content.rstrip("\r\n")
66
+ if add_newline:
67
+ source_content = source_content + "\n"
68
+
69
+ if check_for_existence and dest_content.startswith(stripped):
70
+ stderr.print(
71
+ f"[red]File [purple]{dest_file}[red] already begins with the given content; skipping[reset]",
72
+ )
73
+ return
74
+
75
+ try:
76
+ with tempfile.NamedTemporaryFile("w", delete=False, dir=dest_file.parent) as tmp:
77
+ tmp.write(source_content)
78
+ tmp.write(dest_content)
79
+ tmp_path = Path(tmp.name)
80
+
81
+ shutil.move(tmp.name, dest_file)
82
+ finally:
83
+ if tmp_path and tmp_path.exists():
84
+ os.remove(tmp_path)
@@ -1,9 +1,9 @@
1
1
  from importlib.metadata import version
2
2
 
3
- from rich import print
4
3
  from typer import Typer
5
4
 
6
5
  from tacklebox import __version__
6
+ from tacklebox.utils import stdout
7
7
 
8
8
  app = Typer()
9
9
 
@@ -18,13 +18,9 @@ def _get_package_version(package_name: str) -> str | None:
18
18
 
19
19
  @app.command(name="version")
20
20
  def show_versions() -> None:
21
- """Shows versions of the tacklebox, zipline-py, desktop-notifier, platformdirs, aiohttp, typer, click, and rich packages."""
21
+ """Shows versions of the tacklebox, typer, click, and rich packages."""
22
22
  versions: dict[str, str | None] = {
23
23
  "tacklebox": __version__,
24
- "zipline-py": _get_package_version("zipline-py"),
25
- "desktop-notifier": _get_package_version("desktop-notifier"),
26
- "platformdirs": _get_package_version("platformdirs"),
27
- "aiohttp": _get_package_version("aiohttp"),
28
24
  "typer": _get_package_version("typer"),
29
25
  "click": _get_package_version("click"),
30
26
  "rich": _get_package_version("rich"),
@@ -35,7 +31,7 @@ def show_versions() -> None:
35
31
  for name, version in versions.items()
36
32
  )
37
33
 
38
- print(output)
34
+ stdout.print(output)
39
35
 
40
36
 
41
37
  if __name__ == "__main__":
@@ -0,0 +1,21 @@
1
+ import asyncio
2
+ import platform
3
+
4
+ from typer import Typer
5
+
6
+ from tacklebox.commands import commands
7
+
8
+ # fmt: off
9
+ if platform.system() == "Darwin":
10
+ from rubicon.objc.eventloop import EventLoopPolicy # pyright: ignore[reportMissingImports, reportUnknownVariableType]
11
+
12
+ asyncio.set_event_loop_policy(EventLoopPolicy()) # pyright: ignore[reportUnknownArgumentType, reportDeprecated]
13
+
14
+ app = Typer(name="tacklebox", no_args_is_help=True, pretty_exceptions_show_locals=False)
15
+
16
+
17
+ for typer_instance in commands:
18
+ app.add_typer(typer_instance)
19
+
20
+ if __name__ == "__main__":
21
+ app()
@@ -1,5 +1,10 @@
1
1
  import os
2
2
 
3
+ from rich.console import Console
4
+
5
+ stdout = Console()
6
+ stderr = Console(stderr=True)
7
+
3
8
 
4
9
  def get_environment() -> dict[str, str]:
5
10
  env = dict(os.environ)
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.4.0'
22
+ __version_tuple__ = version_tuple = (0, 4, 0)
23
+
24
+ __commit_id__ = commit_id = None
@@ -1,60 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: tacklebox-cli
3
- Version: 0.2.6
4
- Summary: A small collection of CLI utilities.
5
- Project-URL: Homepage, https://c.csw.im/cswimr/tacklebox
6
- Project-URL: Issues, https://c.csw.im/cswimr/tacklebox/issues
7
- Project-URL: source_archive, https://c.csw.im/cswimr/tacklebox/archive/7d8c5beaa8fce3cf47c233f13720ed44548464bd.tar.gz
8
- Author-email: cswimr <seaswimmerthefsh@gmail.com>
9
- License-Expression: MIT
10
- License-File: LICENSE.md
11
- Classifier: License :: OSI Approved :: MIT License
12
- Classifier: Programming Language :: Python
13
- Classifier: Programming Language :: Python :: 3.11
14
- Classifier: Programming Language :: Python :: 3.12
15
- Classifier: Programming Language :: Python :: 3.13
16
- Requires-Python: >=3.11
17
- Requires-Dist: desktop-notifier>=6.1.0
18
- Requires-Dist: platformdirs>=4.3.0
19
- Requires-Dist: zipline-py[cli]>=0.27.0
20
- Description-Content-Type: text/markdown
21
-
22
- # tacklebox-cli
23
-
24
- [<img alt="Actions Status" src="https://c.csw.im/cswimr/tacklebox/badges/workflows/actions.yml/badge.svg?style=plastic">](https://c.csw.im/cswimr/tacklebox/actions?workflow=actions.yml)
25
- [<img alt="PyPI - Version" src="https://img.shields.io/pypi/v/tacklebox-cli?style=plastic">](https://pypi.org/project/tacklebox-cli/)
26
- [<img alt="PyPI - Python Version" src="https://img.shields.io/pypi/pyversions/tacklebox-cli?style=plastic">](https://pypi.org/project/tacklebox-cli/)
27
- [<img alt="PyPI - License" src="https://img.shields.io/pypi/l/tacklebox-cli?style=plastic">](https://c.csw.im/cswimr/tacklebox/src/branch/main/LICENSE/)
28
- tacklebox-cli offers a suite of useful CLI tools.
29
-
30
- ## Usage
31
-
32
- ### tacklebox copy / paste
33
-
34
- Cross-platform clipboard management tool. Uses system tools such as `wl-copy` on Linux Wayland or `clip.exe` on Windows, and [OSC 52](https://www.reddit.com/r/vim/comments/k1ydpn/a_guide_on_how_to_copy_text_from_anywhere/) escape codes when **copying** over SSH or when no other tools are available. See [`copy_with_tooling()`](https://c.csw.im/cswimr/tacklebox/src/branch/main/tacklebox/commands/clipboard.py) for all supported tools.
35
-
36
- ```bash
37
- $ echo "a" | tacklebox copy --trim && tacklebox paste
38
- a
39
- ```
40
-
41
- ### tacklebox spectacle (Linux only)
42
-
43
- Uses the [zipline.py](https://pypi.org/project/zipline-py/) library alongside KDE's [Spectacle](https://invent.kde.org/plasma/spectacle) application to take a screenshot or screen recording and automatically upload it to a [Zipline](https://github.com/diced/zipline) instance. This automatically reads Spectacle's configuration files to determine file formats.
44
-
45
- ```bash
46
- $ tacklebox spectacle --mode region \
47
- --server "https://zipline.example.com" \
48
- --token "$(cat /file/containing/zipline/token)" \
49
- | tacklebox copy --trim
50
-
51
- # or to record a video
52
- $ tacklebox spectacle --mode region --record \
53
- --server "https://zipline.example.com" \
54
- --token "$(cat /file/containing/zipline/token)" \
55
- | tacklebox copy --trim
56
- ```
57
-
58
- ### tacklebox zipline
59
-
60
- Wraps the [zipline.py](https://pypi.org/project/zipline-py/) CLI. See the [zipline.py CLI documentation](https://ziplinepy.readthedocs.io/en/latest/cli.html) for more information.
@@ -1,39 +0,0 @@
1
- # tacklebox-cli
2
-
3
- [<img alt="Actions Status" src="https://c.csw.im/cswimr/tacklebox/badges/workflows/actions.yml/badge.svg?style=plastic">](https://c.csw.im/cswimr/tacklebox/actions?workflow=actions.yml)
4
- [<img alt="PyPI - Version" src="https://img.shields.io/pypi/v/tacklebox-cli?style=plastic">](https://pypi.org/project/tacklebox-cli/)
5
- [<img alt="PyPI - Python Version" src="https://img.shields.io/pypi/pyversions/tacklebox-cli?style=plastic">](https://pypi.org/project/tacklebox-cli/)
6
- [<img alt="PyPI - License" src="https://img.shields.io/pypi/l/tacklebox-cli?style=plastic">](https://c.csw.im/cswimr/tacklebox/src/branch/main/LICENSE/)
7
- tacklebox-cli offers a suite of useful CLI tools.
8
-
9
- ## Usage
10
-
11
- ### tacklebox copy / paste
12
-
13
- Cross-platform clipboard management tool. Uses system tools such as `wl-copy` on Linux Wayland or `clip.exe` on Windows, and [OSC 52](https://www.reddit.com/r/vim/comments/k1ydpn/a_guide_on_how_to_copy_text_from_anywhere/) escape codes when **copying** over SSH or when no other tools are available. See [`copy_with_tooling()`](https://c.csw.im/cswimr/tacklebox/src/branch/main/tacklebox/commands/clipboard.py) for all supported tools.
14
-
15
- ```bash
16
- $ echo "a" | tacklebox copy --trim && tacklebox paste
17
- a
18
- ```
19
-
20
- ### tacklebox spectacle (Linux only)
21
-
22
- Uses the [zipline.py](https://pypi.org/project/zipline-py/) library alongside KDE's [Spectacle](https://invent.kde.org/plasma/spectacle) application to take a screenshot or screen recording and automatically upload it to a [Zipline](https://github.com/diced/zipline) instance. This automatically reads Spectacle's configuration files to determine file formats.
23
-
24
- ```bash
25
- $ tacklebox spectacle --mode region \
26
- --server "https://zipline.example.com" \
27
- --token "$(cat /file/containing/zipline/token)" \
28
- | tacklebox copy --trim
29
-
30
- # or to record a video
31
- $ tacklebox spectacle --mode region --record \
32
- --server "https://zipline.example.com" \
33
- --token "$(cat /file/containing/zipline/token)" \
34
- | tacklebox copy --trim
35
- ```
36
-
37
- ### tacklebox zipline
38
-
39
- Wraps the [zipline.py](https://pypi.org/project/zipline-py/) CLI. See the [zipline.py CLI documentation](https://ziplinepy.readthedocs.io/en/latest/cli.html) for more information.
@@ -1,60 +0,0 @@
1
- [project]
2
- name = "tacklebox-cli"
3
- description = "A small collection of CLI utilities."
4
- authors = [{ name = "cswimr", email = "seaswimmerthefsh@gmail.com" }]
5
- readme = "README.md"
6
- requires-python = ">=3.11"
7
- license = "MIT"
8
- license-files = ["LICEN[CS]E*"]
9
- classifiers = [
10
- "License :: OSI Approved :: MIT License",
11
- "Programming Language :: Python",
12
- "Programming Language :: Python :: 3.11",
13
- "Programming Language :: Python :: 3.12",
14
- "Programming Language :: Python :: 3.13",
15
- ]
16
- dependencies = [
17
- "desktop-notifier>=6.1.0",
18
- "platformdirs>=4.3.0",
19
- "zipline.py[cli]>=0.27.0",
20
- ]
21
- dynamic = ["version", "urls"]
22
-
23
- [dependency-groups]
24
- dev = [
25
- "basedpyright==1.29.2",
26
- "pyinstrument==5.0.2",
27
- "ruff==0.11.12",
28
- ]
29
-
30
- [project.scripts]
31
- tacklebox = "tacklebox.entrypoint:app"
32
- tacklebox-cli = "tacklebox.entrypoint:app" # for uvx / pipx, prefer tacklebox when possible
33
-
34
- [tool.ruff]
35
- line-length = 145
36
-
37
- [tool.pyright]
38
- typeCheckingMode = "strict"
39
- reportMissingTypeStubs = false
40
-
41
- [tool.typos]
42
- files.extend-exclude = [".direnv/**", ".venv/**"]
43
-
44
- [tool.hatch.version]
45
- source = "vcs"
46
-
47
- [tool.hatch.build]
48
- include = ["/tacklebox", "/README.md", "/LICENSE.md", "/pyproject.toml"]
49
-
50
- [tool.hatch.build.hooks.vcs]
51
- version-file = "tacklebox/version.py"
52
-
53
- [tool.hatch.metadata.hooks.vcs.urls]
54
- Homepage = "https://c.csw.im/cswimr/tacklebox"
55
- Issues = "https://c.csw.im/cswimr/tacklebox/issues"
56
- source_archive = "https://c.csw.im/cswimr/tacklebox/archive/{commit_hash}.tar.gz"
57
-
58
- [build-system]
59
- requires = ["hatchling", "hatch-vcs"]
60
- build-backend = "hatchling.build"
@@ -1,14 +0,0 @@
1
- import platform
2
-
3
- from typer import Typer
4
- from zipline.cli.entrypoint import app as zipline
5
-
6
- from .clipboard import app as clipboard
7
- from .version import app as version
8
-
9
- commands: list[Typer] = [clipboard, version, zipline]
10
-
11
- if platform.system() == "Linux":
12
- from .spectacle import app as spectacle
13
-
14
- commands.append(spectacle)
@@ -1,333 +0,0 @@
1
- import asyncio
2
- import configparser
3
- import platform
4
- import subprocess
5
- import sys
6
- import tempfile
7
- from datetime import datetime, timezone
8
- from enum import IntEnum, StrEnum
9
- from pathlib import Path
10
- from shutil import which
11
- from typing import Annotated
12
-
13
- from desktop_notifier import DEFAULT_SOUND, Attachment, DesktopNotifier, Urgency
14
- from platformdirs import user_config_dir
15
- from rich import print
16
- from rich.progress import Progress, SpinnerColumn, TextColumn
17
- from typer import Exit, Option, Typer
18
- from zipline.cli.commands._handling import handle_api_errors
19
- from zipline.cli.commands.upload import _complete_format # pyright: ignore[reportPrivateUsage]
20
- from zipline.client import Client, FileData, NameFormat
21
-
22
- from tacklebox import sync
23
-
24
- if not platform.system() == "Linux":
25
- print("Spectacle is only supported on Linux.")
26
- raise Exit(code=1)
27
-
28
- app = Typer()
29
-
30
-
31
- class VideoFormat(IntEnum):
32
- """Because Spectacle is weird and doesn't just use file extensions for storing video formats 😭"""
33
-
34
- WEBM = 0
35
- MP4 = 2
36
- WEBP = 4
37
- GIF = 8
38
-
39
- @property
40
- def ext(self) -> str:
41
- return "." + self.__str__()
42
-
43
- def __str__(self) -> str:
44
- return self.name.lower()
45
-
46
-
47
- def _read_spectacle_config() -> tuple[str, VideoFormat]:
48
- """Read the Spectacle config file and return the configured file formats."""
49
- path = Path(user_config_dir("spectaclerc"))
50
- if not path.exists():
51
- return "png", VideoFormat.WEBM
52
-
53
- config = configparser.ConfigParser(strict=False)
54
- config.read(path)
55
-
56
- preferred_image_format = config.get("ImageSave", "preferredImageFormat", fallback="png").lower()
57
-
58
- preferred_video_format = VideoFormat(config.getint("VideoSave", "preferredVideoFormat", fallback=0))
59
-
60
- return preferred_image_format, preferred_video_format
61
-
62
-
63
- class SpectacleMode(StrEnum):
64
- REGION = "region"
65
- DESKTOP = "desktop"
66
- MONITOR = "monitor"
67
- WINDOW = "window"
68
- ACTIVEWINDOW = "activewindow"
69
- TRANSIENT = "transient"
70
-
71
- @classmethod
72
- def complete_format(cls, incomplete: str) -> list[tuple[str, str] | str]:
73
- """Generate autocompletion strings for Typer."""
74
- completions: list[tuple[str, str] | str] = []
75
- for mode in SpectacleMode:
76
- if mode.startswith(incomplete):
77
- completions.append((mode, mode.completion_string) if mode.completion_string else mode)
78
- return completions
79
-
80
- @property
81
- def completion_string(self) -> str | None:
82
- argument = self.get_argument()
83
- record_argument = self.get_argument(True)
84
-
85
- completion_strings: dict[SpectacleMode, str] = {
86
- SpectacleMode.REGION: f"Capture a retangular region of the desktop. ({argument} | {record_argument})",
87
- SpectacleMode.DESKTOP: f"Capture the entire desktop (default). When recording, this will capture only the current monitor. ({argument} | {record_argument})",
88
- SpectacleMode.MONITOR: f"Capture the current monitor. ({argument} | {record_argument})",
89
- SpectacleMode.WINDOW: f"Capture the window currently under the cursor, including parents of pop-up menus. ({argument} | {record_argument})",
90
- SpectacleMode.ACTIVEWINDOW: f"Capture the currently selected window. ({argument} | {record_argument})",
91
- SpectacleMode.TRANSIENT: f"Capture the window currently under the cursor, excluding parents of pop-up menus. ({argument} | {record_argument})",
92
- }
93
-
94
- return completion_strings.get(self, None)
95
-
96
- def get_argument(self, record: bool = False) -> str:
97
- match self:
98
- case SpectacleMode.REGION:
99
- return "--record=region" if record else "--region"
100
- case SpectacleMode.DESKTOP:
101
- return "--record=screen" if record else "--fullscreen"
102
- case SpectacleMode.MONITOR:
103
- return "--record=screen" if record else "--current"
104
- case SpectacleMode.WINDOW:
105
- return "--record=window" if record else "--windowundercursor"
106
- case SpectacleMode.ACTIVEWINDOW:
107
- return "--record=window" if record else "--activewindow"
108
- case SpectacleMode.TRANSIENT:
109
- return "--record=window" if record else "--transientonly"
110
-
111
-
112
- @app.command(name="spectacle")
113
- @sync
114
- async def spectacle(
115
- server_url: Annotated[
116
- str,
117
- Option(
118
- ...,
119
- "--server",
120
- "-s",
121
- help="Specify the URL to your Zipline instance.",
122
- envvar="ZIPLINE_SERVER",
123
- prompt=True,
124
- ),
125
- ],
126
- token: Annotated[
127
- str,
128
- Option(
129
- ...,
130
- "--token",
131
- "-t",
132
- help="Specify a token used for authentication against your chosen Zipline instance.",
133
- envvar="ZIPLINE_TOKEN",
134
- prompt=True,
135
- hide_input=True,
136
- ),
137
- ],
138
- print_object: Annotated[
139
- bool,
140
- Option(
141
- ...,
142
- "--object/--text",
143
- "-o/-O",
144
- default_factory=lambda: bool(sys.stdout.isatty()),
145
- help=(
146
- "Specify how to format the output. If --text (or piped), "
147
- "you'll get a link to the uploaded file; if --object (or on a TTY), "
148
- "you'll get the raw Python object."
149
- ),
150
- envvar="ZIPLINE_PRINT_OBJECT",
151
- ),
152
- ],
153
- mode: Annotated[
154
- SpectacleMode,
155
- Option(..., "--mode", "-M", help="Specify what mode Spectacle should be launched in.", autocompletion=SpectacleMode.complete_format),
156
- ] = SpectacleMode.DESKTOP,
157
- record: Annotated[
158
- bool, Option(..., "--record", "-r", help="Specify whether or not to record a video instead of taking a screenshot.")
159
- ] = False,
160
- format: Annotated[
161
- NameFormat | None,
162
- Option(
163
- ...,
164
- "--format",
165
- "-f",
166
- help="Specify what format Zipline should use to generate a link for this file.",
167
- autocompletion=_complete_format,
168
- ),
169
- ] = None,
170
- compression_percent: Annotated[
171
- int,
172
- Option(
173
- ...,
174
- "--compression-percent",
175
- "-c",
176
- help="Specify how much this file should be compressed.",
177
- ),
178
- ] = 0,
179
- expiry: Annotated[
180
- datetime | None,
181
- Option(
182
- ...,
183
- "--expiry",
184
- "-e",
185
- help=(
186
- "Specify when this file should expire.\n"
187
- "When this time expires, the file will be deleted from the Zipline instance.\n"
188
- "This argument uses your system's local timezone, not UTC dates."
189
- ),
190
- ),
191
- ] = None,
192
- password: Annotated[
193
- str | None,
194
- Option(
195
- ...,
196
- "--password",
197
- "-p",
198
- help="Specify a password for this file. Viewing this file from Zipline will then require having this password.",
199
- ),
200
- ] = None,
201
- max_views: Annotated[
202
- int | None,
203
- Option(
204
- ...,
205
- "--max-views",
206
- "-m",
207
- help="Specify how many times this file can be viewed before it will be automatically deleted.",
208
- ),
209
- ] = None,
210
- override_name: Annotated[
211
- str | None,
212
- Option(
213
- ...,
214
- "--name",
215
- "-n",
216
- help="Specify the name to give this file in Zipline. This overrides the --format option, if provided.",
217
- ),
218
- ] = None,
219
- folder: Annotated[
220
- str | None,
221
- Option(
222
- ...,
223
- "--folder",
224
- "-F",
225
- help="Specify what folder the file should be added to after it is uploaded.",
226
- ),
227
- ] = None,
228
- override_domain: Annotated[
229
- str | None,
230
- Option(
231
- ...,
232
- "--domain",
233
- "-d",
234
- help="Specify what domain should be used instead of the Zipline instance's core domain.",
235
- ),
236
- ] = None,
237
- verbose: Annotated[
238
- bool,
239
- Option(
240
- ...,
241
- "--verbose",
242
- "-v",
243
- help="Specify whether or not the application should print tracebacks from exceptions to the console. If the application encounters an exception it doesn't expect, it will always be printed to the console regardless of this option.",
244
- envvar="ZIPLINE_VERBOSE",
245
- ),
246
- ] = False,
247
- ) -> None:
248
- """Take a screenshot or record a video using Spectacle, then upload it to a remote Zipline instance using zipline.py."""
249
- notifier = DesktopNotifier(app_name="Tacklebox - Spectacle")
250
-
251
- with Progress(
252
- SpinnerColumn(),
253
- TextColumn("[progress.description]{task.description}"),
254
- transient=True,
255
- ) as progress:
256
- task = progress.add_task(description="Setting up...", total=None)
257
-
258
- if not which("spectacle"):
259
- print("spectacle is not installed!")
260
- raise Exit(code=1)
261
-
262
- image_format, video_format = _read_spectacle_config()
263
- if record:
264
- ext = video_format.ext
265
- else:
266
- ext = "." + image_format
267
-
268
- temp_file = tempfile.NamedTemporaryFile(delete=False, suffix=ext)
269
- file_path = Path(temp_file.name)
270
- temp_file.close()
271
-
272
- command: list[str] = [
273
- "spectacle",
274
- "--nonotify",
275
- "--background",
276
- "--pointer",
277
- mode.get_argument(record),
278
- "--copy-image",
279
- "--output",
280
- str(file_path),
281
- ]
282
-
283
- proc = await asyncio.create_subprocess_exec(*command, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
284
- stdout, stderr = await proc.communicate()
285
-
286
- if proc.returncode != 0:
287
- if file_path.exists():
288
- file_path.unlink(missing_ok=True)
289
- raise subprocess.CalledProcessError(proc.returncode or 1, command, output=stdout, stderr=stderr)
290
-
291
- if not file_path.stat().st_size:
292
- file_path.unlink(missing_ok=True)
293
- raise FileNotFoundError("The file was not created properly.")
294
-
295
- progress.update(task, description="Reading file...", total=None)
296
- file_data = FileData(data=file_path)
297
-
298
- progress.update(task, description="Uploading file...", total=None)
299
- async with Client(server_url, token) as client:
300
- try:
301
- uploaded_file = await client.upload_file(
302
- payload=file_data,
303
- compression_percent=compression_percent,
304
- expiry=expiry.astimezone(tz=timezone.utc) if expiry else None,
305
- format=format,
306
- password=password,
307
- max_views=max_views,
308
- override_name=override_name,
309
- override_domain=override_domain,
310
- folder=folder,
311
- )
312
- except Exception as exception:
313
- handle_api_errors(exception, server_url, traceback=verbose)
314
-
315
- if print_object:
316
- print(uploaded_file)
317
- else:
318
- print(uploaded_file.files[0].url)
319
-
320
- await notifier.send(
321
- title="File Uploaded!",
322
- message=f"File successfully uploaded to {uploaded_file.files[0].url}",
323
- attachment=Attachment(path=file_path),
324
- urgency=Urgency.Low,
325
- timeout=5,
326
- sound=DEFAULT_SOUND,
327
- )
328
-
329
- file_path.unlink(missing_ok=True)
330
-
331
-
332
- if __name__ == "__main__":
333
- app()
@@ -1,29 +0,0 @@
1
- import asyncio
2
- import platform
3
-
4
- from typer import Typer
5
-
6
- from tacklebox.commands import commands
7
-
8
- # fmt: off
9
- if platform.system() == "Darwin":
10
- from rubicon.objc.eventloop import EventLoopPolicy # pyright: ignore[reportMissingImports, reportUnknownVariableType]
11
-
12
- asyncio.set_event_loop_policy(EventLoopPolicy()) # pyright: ignore[reportUnknownArgumentType]
13
-
14
- app = Typer(name="Tacklebox", pretty_exceptions_show_locals=False)
15
-
16
-
17
- for typer_instance in commands:
18
- match typer_instance.info.name:
19
- case "zipline.py":
20
- app.add_typer(
21
- typer_instance,
22
- name="zipline",
23
- help="Interact with a remote Zipline instance using the zipline.py library's CLI.",
24
- )
25
- case _:
26
- app.add_typer(typer_instance)
27
-
28
- if __name__ == "__main__":
29
- app()
@@ -1,21 +0,0 @@
1
- # file generated by setuptools-scm
2
- # don't change, don't track in version control
3
-
4
- __all__ = ["__version__", "__version_tuple__", "version", "version_tuple"]
5
-
6
- TYPE_CHECKING = False
7
- if TYPE_CHECKING:
8
- from typing import Tuple
9
- from typing import Union
10
-
11
- VERSION_TUPLE = Tuple[Union[int, str], ...]
12
- else:
13
- VERSION_TUPLE = object
14
-
15
- version: str
16
- __version__: str
17
- __version_tuple__: VERSION_TUPLE
18
- version_tuple: VERSION_TUPLE
19
-
20
- __version__ = version = '0.2.6'
21
- __version_tuple__ = version_tuple = (0, 2, 6)
File without changes