multitool 0.9.0__tar.gz → 0.10.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.
Files changed (27) hide show
  1. {multitool-0.9.0 → multitool-0.10.0}/PKG-INFO +1 -1
  2. {multitool-0.9.0 → multitool-0.10.0}/multitool/__init__.py +1 -1
  3. multitool-0.10.0/multitool/bootstrap/commands.py +654 -0
  4. {multitool-0.9.0 → multitool-0.10.0}/multitool.egg-info/PKG-INFO +1 -1
  5. {multitool-0.9.0 → multitool-0.10.0}/pyproject.toml +1 -1
  6. multitool-0.9.0/multitool/bootstrap/commands.py +0 -57
  7. {multitool-0.9.0 → multitool-0.10.0}/LICENSE +0 -0
  8. {multitool-0.9.0 → multitool-0.10.0}/README.rst +0 -0
  9. {multitool-0.9.0 → multitool-0.10.0}/multitool/__main__.py +0 -0
  10. {multitool-0.9.0 → multitool-0.10.0}/multitool/cls.py +0 -0
  11. {multitool-0.9.0 → multitool-0.10.0}/multitool/console.py +0 -0
  12. {multitool-0.9.0 → multitool-0.10.0}/multitool/exceptions.py +0 -0
  13. {multitool-0.9.0 → multitool-0.10.0}/multitool/plugins/__init__.py +0 -0
  14. {multitool-0.9.0 → multitool-0.10.0}/multitool/plugins/commands.py +0 -0
  15. {multitool-0.9.0 → multitool-0.10.0}/multitool/run/commands.py +0 -0
  16. {multitool-0.9.0 → multitool-0.10.0}/multitool/silent.py +0 -0
  17. {multitool-0.9.0 → multitool-0.10.0}/multitool/utils.py +0 -0
  18. {multitool-0.9.0 → multitool-0.10.0}/multitool/utils_init.py +0 -0
  19. {multitool-0.9.0 → multitool-0.10.0}/multitool/verbose.py +0 -0
  20. {multitool-0.9.0 → multitool-0.10.0}/multitool.egg-info/SOURCES.txt +0 -0
  21. {multitool-0.9.0 → multitool-0.10.0}/multitool.egg-info/dependency_links.txt +0 -0
  22. {multitool-0.9.0 → multitool-0.10.0}/multitool.egg-info/entry_points.txt +0 -0
  23. {multitool-0.9.0 → multitool-0.10.0}/multitool.egg-info/requires.txt +0 -0
  24. {multitool-0.9.0 → multitool-0.10.0}/multitool.egg-info/top_level.txt +0 -0
  25. {multitool-0.9.0 → multitool-0.10.0}/setup.cfg +0 -0
  26. {multitool-0.9.0 → multitool-0.10.0}/tests/__init__.py +0 -0
  27. {multitool-0.9.0 → multitool-0.10.0}/tests/test_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: multitool
3
- Version: 0.9.0
3
+ Version: 0.10.0
4
4
  Summary: Create and run plugin-based command-line tools.
5
5
  Author-email: Matthew Delotavo <matthew.t.delotavo@gmail.com>
6
6
  License-Expression: MIT
@@ -1,5 +1,5 @@
1
1
  APP = "multitool"
2
- __version__ = "0.9.0"
2
+ __version__ = "0.10.0"
3
3
  description = "Create and run plugin-based command-line tools."
4
4
  long_description = ("Create and run plugin-based command-line tools by dynamically loading Click commands from local or Git-managed plugin repositories.")
5
5
 
@@ -0,0 +1,654 @@
1
+ from pathlib import Path
2
+
3
+ import click
4
+
5
+ from multitool import console
6
+ from multitool.silent import common_silent_options
7
+ from multitool.utils import mkdir
8
+ from multitool.verbose import common_verbose_options
9
+
10
+ PROJECT_STRUCTURE = {
11
+ "pyproject.toml": None,
12
+ "{app}": {
13
+ "__init__.py": None,
14
+ "__main__.py": None,
15
+ "console.py": None,
16
+ "exceptions.py": None,
17
+ "request.py": None,
18
+ "utils.py": None,
19
+ "submodule": {
20
+ "__init__.py": None,
21
+ "commands.py": None,
22
+ },
23
+ },
24
+ }
25
+
26
+ FILE_TEMPLATES = {
27
+ "pyproject.toml": """\
28
+ [build-system]
29
+ requires = ["setuptools>=68"]
30
+ build-backend = "setuptools.build_meta"
31
+
32
+ [project]
33
+ name = "{app}"
34
+ version = "0.1.0"
35
+ description = "A Python package."
36
+ readme = "README.md"
37
+ requires-python = ">=3.10"
38
+ dependencies = [
39
+ "click>=8.1",
40
+ "requests>=2.31",
41
+ ]
42
+
43
+ [project.scripts]
44
+ {app} = "{app}.__main__:main"
45
+
46
+ [tool.setuptools.packages.find]
47
+ where = ["."]
48
+ """,
49
+ "__init__.py": """\
50
+ \"\"\"{app} package.\"\"\"
51
+
52
+ __version__ = "0.1.0"
53
+ """,
54
+ "__main__.py": """\
55
+ \"\"\"Command-line entry point for {app}.\"\"\"
56
+
57
+ from .submodule.commands import cli
58
+
59
+
60
+ def main() -> None:
61
+ \"\"\"Run the command-line interface.\"\"\"
62
+ cli()
63
+
64
+
65
+ if __name__ == "__main__":
66
+ main()
67
+ """,
68
+ "console.py": """\
69
+ \"\"\"Console output helpers.\"\"\"
70
+
71
+ import click
72
+
73
+
74
+ def echo(message: str = "") -> None:
75
+ \"\"\"Print a normal message.\"\"\"
76
+ click.echo(message)
77
+
78
+
79
+ def info(message: str) -> None:
80
+ \"\"\"Print an informational message.\"\"\"
81
+ click.echo(f"INFO: {message}")
82
+
83
+
84
+ def success(message: str) -> None:
85
+ \"\"\"Print a success message.\"\"\"
86
+ click.echo(f"OK: {message}")
87
+
88
+
89
+ def warning(message: str) -> None:
90
+ \"\"\"Print a warning message.\"\"\"
91
+ click.echo(f"WARNING: {message}")
92
+
93
+
94
+ def error(message: str) -> None:
95
+ \"\"\"Print an error message to stderr.\"\"\"
96
+ click.echo(f"ERROR: {message}", err=True)
97
+ """,
98
+ "exceptions.py": """\
99
+ \"\"\"Application-specific exceptions.\"\"\"
100
+
101
+
102
+ class AppError(Exception):
103
+ \"\"\"Base exception for application errors.\"\"\"
104
+
105
+
106
+ class ConfigurationError(AppError):
107
+ \"\"\"Raised when configuration is invalid.\"\"\"
108
+
109
+
110
+ class ValidationError(AppError):
111
+ \"\"\"Raised when input validation fails.\"\"\"
112
+
113
+
114
+ class RequestError(AppError):
115
+ \"\"\"Raised when an HTTP request fails.\"\"\"
116
+
117
+ def __init__(
118
+ self,
119
+ message: str,
120
+ status_code: int | None = None,
121
+ response=None,
122
+ ) -> None:
123
+ super().__init__(message)
124
+ self.status_code = status_code
125
+ self.response = response
126
+ """,
127
+ "request.py": """\
128
+ \"\"\"Generic helpers for making REST API requests.\"\"\"
129
+
130
+ from typing import Any
131
+
132
+ import requests
133
+
134
+ from .exceptions import RequestError
135
+
136
+
137
+ DEFAULT_TIMEOUT = 30
138
+ DEFAULT_VERIFY_SSL = True
139
+
140
+
141
+ def request(
142
+ method: str,
143
+ url: str,
144
+ *,
145
+ params: dict[str, Any] | None = None,
146
+ data: Any = None,
147
+ json: Any = None,
148
+ headers: dict[str, str] | None = None,
149
+ timeout: int | float = DEFAULT_TIMEOUT,
150
+ verify_ssl: bool = DEFAULT_VERIFY_SSL,
151
+ **kwargs: Any,
152
+ ) -> requests.Response:
153
+ \"\"\"Make an HTTP request.\"\"\"
154
+ try:
155
+ response = requests.request(
156
+ method=method,
157
+ url=url,
158
+ params=params,
159
+ data=data,
160
+ json=json,
161
+ headers=headers,
162
+ timeout=timeout,
163
+ verify=verify_ssl,
164
+ **kwargs,
165
+ )
166
+
167
+ response.raise_for_status()
168
+
169
+ return response
170
+
171
+ except requests.RequestException as exc:
172
+ status_code = None
173
+
174
+ if exc.response is not None:
175
+ status_code = exc.response.status_code
176
+
177
+ raise RequestError(
178
+ str(exc),
179
+ status_code=status_code,
180
+ response=exc.response,
181
+ ) from exc
182
+
183
+
184
+ def get(
185
+ url: str,
186
+ *,
187
+ params: dict[str, Any] | None = None,
188
+ headers: dict[str, str] | None = None,
189
+ timeout: int | float = DEFAULT_TIMEOUT,
190
+ verify_ssl: bool = DEFAULT_VERIFY_SSL,
191
+ **kwargs: Any,
192
+ ) -> requests.Response:
193
+ \"\"\"Make a GET request.\"\"\"
194
+ return request(
195
+ "GET",
196
+ url,
197
+ params=params,
198
+ headers=headers,
199
+ timeout=timeout,
200
+ verify_ssl=verify_ssl,
201
+ **kwargs,
202
+ )
203
+
204
+
205
+ def post(
206
+ url: str,
207
+ *,
208
+ data: Any = None,
209
+ json: Any = None,
210
+ headers: dict[str, str] | None = None,
211
+ timeout: int | float = DEFAULT_TIMEOUT,
212
+ verify_ssl: bool = DEFAULT_VERIFY_SSL,
213
+ **kwargs: Any,
214
+ ) -> requests.Response:
215
+ \"\"\"Make a POST request.\"\"\"
216
+ return request(
217
+ "POST",
218
+ url,
219
+ data=data,
220
+ json=json,
221
+ headers=headers,
222
+ timeout=timeout,
223
+ verify_ssl=verify_ssl,
224
+ **kwargs,
225
+ )
226
+
227
+
228
+ def put(
229
+ url: str,
230
+ *,
231
+ data: Any = None,
232
+ json: Any = None,
233
+ headers: dict[str, str] | None = None,
234
+ timeout: int | float = DEFAULT_TIMEOUT,
235
+ verify_ssl: bool = DEFAULT_VERIFY_SSL,
236
+ **kwargs: Any,
237
+ ) -> requests.Response:
238
+ \"\"\"Make a PUT request.\"\"\"
239
+ return request(
240
+ "PUT",
241
+ url,
242
+ data=data,
243
+ json=json,
244
+ headers=headers,
245
+ timeout=timeout,
246
+ verify_ssl=verify_ssl,
247
+ **kwargs,
248
+ )
249
+
250
+
251
+ def patch(
252
+ url: str,
253
+ *,
254
+ data: Any = None,
255
+ json: Any = None,
256
+ headers: dict[str, str] | None = None,
257
+ timeout: int | float = DEFAULT_TIMEOUT,
258
+ verify_ssl: bool = DEFAULT_VERIFY_SSL,
259
+ **kwargs: Any,
260
+ ) -> requests.Response:
261
+ \"\"\"Make a PATCH request.\"\"\"
262
+ return request(
263
+ "PATCH",
264
+ url,
265
+ data=data,
266
+ json=json,
267
+ headers=headers,
268
+ timeout=timeout,
269
+ verify_ssl=verify_ssl,
270
+ **kwargs,
271
+ )
272
+
273
+
274
+ def delete(
275
+ url: str,
276
+ *,
277
+ headers: dict[str, str] | None = None,
278
+ timeout: int | float = DEFAULT_TIMEOUT,
279
+ verify_ssl: bool = DEFAULT_VERIFY_SSL,
280
+ **kwargs: Any,
281
+ ) -> requests.Response:
282
+ \"\"\"Make a DELETE request.\"\"\"
283
+ return request(
284
+ "DELETE",
285
+ url,
286
+ headers=headers,
287
+ timeout=timeout,
288
+ verify_ssl=verify_ssl,
289
+ **kwargs,
290
+ )
291
+
292
+
293
+ def get_json(
294
+ url: str,
295
+ *,
296
+ params: dict[str, Any] | None = None,
297
+ headers: dict[str, str] | None = None,
298
+ timeout: int | float = DEFAULT_TIMEOUT,
299
+ verify_ssl: bool = DEFAULT_VERIFY_SSL,
300
+ **kwargs: Any,
301
+ ) -> Any:
302
+ \"\"\"Make a GET request and return decoded JSON.\"\"\"
303
+ response = get(
304
+ url,
305
+ params=params,
306
+ headers=headers,
307
+ timeout=timeout,
308
+ verify_ssl=verify_ssl,
309
+ **kwargs,
310
+ )
311
+
312
+ try:
313
+ return response.json()
314
+ except ValueError as exc:
315
+ raise RequestError(
316
+ "Response did not contain valid JSON.",
317
+ status_code=response.status_code,
318
+ response=response,
319
+ ) from exc
320
+ """,
321
+ "utils.py": """\
322
+ \"\"\"General-purpose utility functions.\"\"\"
323
+
324
+ from pathlib import Path
325
+ from typing import Iterable
326
+
327
+
328
+ def ensure_directory(path: Path) -> Path:
329
+ \"\"\"Create a directory if it does not already exist.\"\"\"
330
+ path.mkdir(parents=True, exist_ok=True)
331
+ return path
332
+
333
+
334
+ def read_text(path: Path, encoding: str = "utf-8") -> str:
335
+ \"\"\"Read text from a file.\"\"\"
336
+ return path.read_text(encoding=encoding)
337
+
338
+
339
+ def write_text(
340
+ path: Path,
341
+ content: str,
342
+ encoding: str = "utf-8",
343
+ ) -> Path:
344
+ \"\"\"Write text to a file, creating parent directories when needed.\"\"\"
345
+ path.parent.mkdir(parents=True, exist_ok=True)
346
+ path.write_text(content, encoding=encoding)
347
+ return path
348
+
349
+
350
+ def chunks(items: Iterable, size: int):
351
+ \"\"\"Yield an iterable in chunks of the requested size.\"\"\"
352
+ if size <= 0:
353
+ raise ValueError("size must be greater than zero")
354
+
355
+ chunk = []
356
+
357
+ for item in items:
358
+ chunk.append(item)
359
+
360
+ if len(chunk) == size:
361
+ yield chunk
362
+ chunk = []
363
+
364
+ if chunk:
365
+ yield chunk
366
+ """,
367
+ "submodule/__init__.py": """\
368
+ \"\"\"Submodule package for {app}.\"\"\"
369
+ """,
370
+ "commands.py": """\
371
+ \"\"\"CLI commands for {app}.\"\"\"
372
+
373
+ import click
374
+
375
+ from .. import __version__
376
+ from ..exceptions import RequestError
377
+ from ..request import get_json, post
378
+
379
+
380
+ @click.group()
381
+ @click.version_option(__version__, prog_name="{app}")
382
+ def cli() -> None:
383
+ \"\"\"Command-line interface for {app}.\"\"\"
384
+
385
+
386
+ @cli.command()
387
+ def hello() -> None:
388
+ \"\"\"Run a simple example command.\"\"\"
389
+ click.echo("Hello from {app}!")
390
+
391
+
392
+ @cli.command()
393
+ @click.argument("name")
394
+ def greet(name: str) -> None:
395
+ \"\"\"Greet NAME.\"\"\"
396
+ click.echo(f"Hello, {name}!")
397
+
398
+
399
+ @cli.command()
400
+ @click.option(
401
+ "--insecure",
402
+ is_flag=True,
403
+ help="Disable SSL certificate verification.",
404
+ )
405
+ def api_get(insecure: bool) -> None:
406
+ \"\"\"Example GET request against a REST API.\"\"\"
407
+ try:
408
+ data = get_json(
409
+ "https://jsonplaceholder.typicode.com/todos/1",
410
+ verify_ssl=not insecure,
411
+ )
412
+
413
+ click.echo(data)
414
+
415
+ except RequestError as exc:
416
+ raise click.ClickException(str(exc)) from exc
417
+
418
+
419
+ @cli.command()
420
+ @click.option(
421
+ "--insecure",
422
+ is_flag=True,
423
+ help="Disable SSL certificate verification.",
424
+ )
425
+ def api_post(insecure: bool) -> None:
426
+ \"\"\"Example POST request against a REST API.\"\"\"
427
+
428
+ payload = {
429
+ "title": "Example",
430
+ "body": "Created by {app}.",
431
+ "userId": 1,
432
+ }
433
+
434
+ try:
435
+ response = post(
436
+ "https://jsonplaceholder.typicode.com/posts",
437
+ json=payload,
438
+ verify_ssl=not insecure,
439
+ )
440
+
441
+ click.echo(response.json())
442
+
443
+ except RequestError as exc:
444
+ raise click.ClickException(str(exc)) from exc
445
+
446
+
447
+ @cli.command()
448
+ def rest_examples() -> None:
449
+ \"\"\"Show REST API usage examples.\"\"\"
450
+
451
+ click.echo(
452
+ \"\"\"
453
+ REST API examples
454
+ =================
455
+
456
+ GET
457
+ ---
458
+
459
+ from {app}.request import get
460
+
461
+ response = get(
462
+ "https://jsonplaceholder.typicode.com/todos/1"
463
+ )
464
+
465
+ print(response.json())
466
+
467
+
468
+ GET JSON
469
+ --------
470
+
471
+ from {app}.request import get_json
472
+
473
+ todo = get_json(
474
+ "https://jsonplaceholder.typicode.com/todos/1"
475
+ )
476
+
477
+ print(todo)
478
+
479
+
480
+ GET with query parameters
481
+ -------------------------
482
+
483
+ from {app}.request import get_json
484
+
485
+ users = get_json(
486
+ "https://jsonplaceholder.typicode.com/users",
487
+ params={{"id": 1}},
488
+ )
489
+
490
+ print(users)
491
+
492
+
493
+ POST
494
+ ----
495
+
496
+ from {app}.request import post
497
+
498
+ response = post(
499
+ "https://jsonplaceholder.typicode.com/posts",
500
+ json={{
501
+ "title": "Example",
502
+ "body": "Hello from {app}",
503
+ "userId": 1,
504
+ }},
505
+ )
506
+
507
+ print(response.json())
508
+
509
+
510
+ PUT
511
+ ---
512
+
513
+ from {app}.request import put
514
+
515
+ response = put(
516
+ "https://jsonplaceholder.typicode.com/posts/1",
517
+ json={{
518
+ "id": 1,
519
+ "title": "Updated title",
520
+ "body": "Updated body",
521
+ "userId": 1,
522
+ }},
523
+ )
524
+
525
+ print(response.json())
526
+
527
+
528
+ PATCH
529
+ ------
530
+
531
+ from {app}.request import patch
532
+
533
+ response = patch(
534
+ "https://jsonplaceholder.typicode.com/posts/1",
535
+ json={{
536
+ "title": "Updated title",
537
+ }},
538
+ )
539
+
540
+ print(response.json())
541
+
542
+
543
+ DELETE
544
+ ------
545
+
546
+ from {app}.request import delete
547
+
548
+ response = delete(
549
+ "https://jsonplaceholder.typicode.com/posts/1"
550
+ )
551
+
552
+ print(response.status_code)
553
+
554
+
555
+ SSL verification
556
+ ----------------
557
+
558
+ SSL verification is enabled by default:
559
+
560
+ response = get(
561
+ "https://api.example.com",
562
+ verify_ssl=True,
563
+ )
564
+
565
+ To disable SSL verification:
566
+
567
+ response = get(
568
+ "https://localhost:8443",
569
+ verify_ssl=False,
570
+ )
571
+
572
+
573
+ CLI commands
574
+ ------------
575
+
576
+ {app} --help
577
+ {app} hello
578
+ {app} greet Matthew
579
+ {app} api-get
580
+ {app} api-get --insecure
581
+ {app} api-post
582
+ {app} api-post --insecure
583
+ {app} rest-examples
584
+ \"\"\"
585
+ )
586
+ """,
587
+ }
588
+
589
+
590
+ def create_structure(
591
+ root: Path,
592
+ structure: dict,
593
+ app: str,
594
+ ) -> None:
595
+ """Create the project structure and populate files from templates."""
596
+
597
+ for name, children in structure.items():
598
+ path = root / name.replace("{app}", app)
599
+
600
+ if children is None:
601
+ content = FILE_TEMPLATES.get(name)
602
+
603
+ if content is None:
604
+ path.touch()
605
+ else:
606
+ path.parent.mkdir(parents=True, exist_ok=True)
607
+
608
+ # Only replace our application-name placeholder.
609
+ # Do not use str.format(), because generated Python
610
+ # contains its own braces.
611
+ content = content.replace("{app}", app)
612
+
613
+ path.write_text(
614
+ content,
615
+ encoding="utf-8",
616
+ )
617
+
618
+ else:
619
+ mkdir(path)
620
+
621
+ create_structure(
622
+ path,
623
+ children,
624
+ app,
625
+ )
626
+
627
+
628
+ @click.command(help="Create the directory structure for a new Python package project.")
629
+ @click.argument("name")
630
+ @click.argument(
631
+ "directory",
632
+ type=click.Path(path_type=Path),
633
+ default=".",
634
+ required=False,
635
+ )
636
+ @common_silent_options
637
+ @common_verbose_options
638
+ def bootstrap(silent, verbose, name, directory):
639
+ root = directory / name
640
+
641
+ mkdir(root)
642
+
643
+ create_structure(
644
+ root,
645
+ PROJECT_STRUCTURE,
646
+ name,
647
+ )
648
+
649
+ console.echo(f"""Created project: {root}
650
+
651
+ Next steps:
652
+ cd {name}
653
+ python -m {name}
654
+ python3 -m {name}""")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: multitool
3
- Version: 0.9.0
3
+ Version: 0.10.0
4
4
  Summary: Create and run plugin-based command-line tools.
5
5
  Author-email: Matthew Delotavo <matthew.t.delotavo@gmail.com>
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "multitool"
7
- version = "0.9.0"
7
+ version = "0.10.0"
8
8
  description = "Create and run plugin-based command-line tools."
9
9
  readme = { file = "README.rst", content-type = "text/x-rst" }
10
10
  requires-python = ">=3.7"
@@ -1,57 +0,0 @@
1
- from pathlib import Path
2
-
3
- import click
4
-
5
- from multitool import console
6
- from multitool.silent import common_silent_options
7
- from multitool.utils import mkdir, touch
8
- from multitool.verbose import common_verbose_options
9
-
10
- PROJECT_STRUCTURE = {
11
- "pyproject.toml": None,
12
- "{app}": {
13
- "__init__.py": None,
14
- "__main__.py": None,
15
- # "cls.py": None,
16
- "console.py": None,
17
- "exceptions.py": None,
18
- "silent.py": None,
19
- "utils.py": None,
20
- "utils_init.py": None,
21
- "verbose.py": None,
22
- "submodule": {
23
- "__init__.py": None,
24
- "commands.py": None,
25
- },
26
- },
27
- }
28
-
29
-
30
- def create_structure(root: Path, structure: dict, app: str):
31
- for name, children in structure.items():
32
- path = root / name.format(app=app)
33
-
34
- if children is None:
35
- touch(path)
36
- else:
37
- mkdir(path)
38
- create_structure(path, children, app)
39
-
40
-
41
- @click.command(help="Create the directory structure for a new Python package project.")
42
- @click.argument("name")
43
- @click.argument(
44
- "directory",
45
- type=click.Path(path_type=Path),
46
- default=".",
47
- required=False,
48
- )
49
- @common_silent_options
50
- @common_verbose_options
51
- def bootstrap(silent, verbose, name, directory):
52
- root = directory / name
53
- mkdir(root)
54
-
55
- create_structure(root, PROJECT_STRUCTURE, name)
56
-
57
- console.echo(f"Created project: {root}")
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes