shadow-tools 0.1.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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 William Laurento II
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,79 @@
1
+ Metadata-Version: 2.5
2
+ Name: shadow-tools
3
+ Version: 0.1.0
4
+ Summary: A practical all-in-one Python toolkit for HTTP, WebSockets, files, data, validation, async work, and CLIs.
5
+ Project-URL: Homepage, https://github.com/WilliamLaurento/shadow-tools
6
+ Project-URL: Issues, https://github.com/WilliamLaurento/shadow-tools/issues
7
+ Author: William Laurento II
8
+ License: MIT
9
+ License-File: LICENSE
10
+ Keywords: async,cli,http,tools,utilities,websocket
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3 :: Only
16
+ Requires-Python: >=3.10
17
+ Provides-Extra: dev
18
+ Requires-Dist: build>=1.2; extra == 'dev'
19
+ Requires-Dist: pytest>=8.0; extra == 'dev'
20
+ Requires-Dist: twine>=6.0; extra == 'dev'
21
+ Provides-Extra: websocket
22
+ Requires-Dist: websocket-client>=1.8; extra == 'websocket'
23
+ Description-Content-Type: text/markdown
24
+
25
+ # Shadow Tools
26
+
27
+ `shadow-tools` is a lightweight Python toolbox for everyday apps and scripts. It keeps the basics in one clean package without trying to replace specialized libraries.
28
+
29
+ ## Install
30
+
31
+ ```bash
32
+ pip install shadow-tools
33
+ pip install "shadow-tools[websocket]" # optional WebSocket client
34
+ ```
35
+
36
+ ## What is included
37
+
38
+ - HTTP requests with no required third-party dependency
39
+ - WebSocket connections through an optional extra
40
+ - Async execution for blocking jobs
41
+ - File and directory helpers
42
+ - JSON and CSV reading/writing
43
+ - Email, URL, and required-key checks
44
+ - `shadow` command-line tool
45
+
46
+ ## Quick start
47
+
48
+ ```python
49
+ from shadowtools import get, write_json, is_email
50
+
51
+ response = get("https://api.github.com")
52
+ print(response.status, response.json())
53
+ write_json("output.json", {"ready": True})
54
+ assert is_email("person@example.com")
55
+ ```
56
+
57
+ ```bash
58
+ shadow get https://api.github.com --json
59
+ shadow json-format settings.json
60
+ ```
61
+
62
+ ## WebSockets
63
+
64
+ ```python
65
+ from shadowtools.websocket import connect
66
+
67
+ socket = connect("wss://example.com/socket")
68
+ socket.send("hello")
69
+ print(socket.recv())
70
+ socket.close()
71
+ ```
72
+
73
+ ## Release
74
+
75
+ Build locally with `python -m build`, then publish only after reviewing the generated distributions and confirming the PyPI project name is available.
76
+
77
+ ## License
78
+
79
+ MIT
@@ -0,0 +1,55 @@
1
+ # Shadow Tools
2
+
3
+ `shadow-tools` is a lightweight Python toolbox for everyday apps and scripts. It keeps the basics in one clean package without trying to replace specialized libraries.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install shadow-tools
9
+ pip install "shadow-tools[websocket]" # optional WebSocket client
10
+ ```
11
+
12
+ ## What is included
13
+
14
+ - HTTP requests with no required third-party dependency
15
+ - WebSocket connections through an optional extra
16
+ - Async execution for blocking jobs
17
+ - File and directory helpers
18
+ - JSON and CSV reading/writing
19
+ - Email, URL, and required-key checks
20
+ - `shadow` command-line tool
21
+
22
+ ## Quick start
23
+
24
+ ```python
25
+ from shadowtools import get, write_json, is_email
26
+
27
+ response = get("https://api.github.com")
28
+ print(response.status, response.json())
29
+ write_json("output.json", {"ready": True})
30
+ assert is_email("person@example.com")
31
+ ```
32
+
33
+ ```bash
34
+ shadow get https://api.github.com --json
35
+ shadow json-format settings.json
36
+ ```
37
+
38
+ ## WebSockets
39
+
40
+ ```python
41
+ from shadowtools.websocket import connect
42
+
43
+ socket = connect("wss://example.com/socket")
44
+ socket.send("hello")
45
+ print(socket.recv())
46
+ socket.close()
47
+ ```
48
+
49
+ ## Release
50
+
51
+ Build locally with `python -m build`, then publish only after reviewing the generated distributions and confirming the PyPI project name is available.
52
+
53
+ ## License
54
+
55
+ MIT
@@ -0,0 +1,38 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.25"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "shadow-tools"
7
+ version = "0.1.0"
8
+ description = "A practical all-in-one Python toolkit for HTTP, WebSockets, files, data, validation, async work, and CLIs."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "William Laurento II" }]
13
+ keywords = ["tools", "websocket", "http", "async", "cli", "utilities"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3 :: Only",
20
+ ]
21
+ dependencies = []
22
+
23
+ [project.optional-dependencies]
24
+ websocket = ["websocket-client>=1.8"]
25
+ dev = ["pytest>=8.0", "build>=1.2", "twine>=6.0"]
26
+
27
+ [project.scripts]
28
+ shadow = "shadowtools.cli:main"
29
+
30
+ [project.urls]
31
+ Homepage = "https://github.com/WilliamLaurento/shadow-tools"
32
+ Issues = "https://github.com/WilliamLaurento/shadow-tools/issues"
33
+
34
+ [tool.hatch.build.targets.wheel]
35
+ packages = ["src/shadowtools"]
36
+
37
+ [tool.pytest.ini_options]
38
+ testpaths = ["tests"]
@@ -0,0 +1,14 @@
1
+ """Shadow Tools: focused helpers for common Python automation tasks."""
2
+
3
+ from .data import read_csv, read_json, write_csv, write_json
4
+ from .files import append_text, ensure_dir, read_text, write_text
5
+ from .http import get, request
6
+ from .validation import is_email, is_url, require_keys
7
+
8
+ __version__ = "0.1.0"
9
+
10
+ __all__ = [
11
+ "append_text", "ensure_dir", "get", "is_email", "is_url", "read_csv",
12
+ "read_json", "read_text", "request", "require_keys", "write_csv",
13
+ "write_json", "write_text",
14
+ ]
@@ -0,0 +1,11 @@
1
+ """Async helpers that keep blocking work off the event loop."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ from collections.abc import Callable, Iterable
6
+ from typing import Any
7
+
8
+
9
+ async def run_all(functions: Iterable[Callable[[], Any]]) -> list[Any]:
10
+ """Run zero-argument blocking functions concurrently in worker threads."""
11
+ return list(await asyncio.gather(*(asyncio.to_thread(fn) for fn in functions)))
@@ -0,0 +1,33 @@
1
+ """The `shadow` command-line interface."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ from pathlib import Path
7
+
8
+ from . import __version__
9
+ from .data import read_json, write_json
10
+ from .http import get
11
+
12
+
13
+ def main() -> None:
14
+ parser = argparse.ArgumentParser(prog="shadow", description="Useful developer tools without the clutter.")
15
+ parser.add_argument("--version", action="version", version=f"shadow-tools {__version__}")
16
+ commands = parser.add_subparsers(dest="command", required=True)
17
+ http_parser = commands.add_parser("get", help="Fetch a URL")
18
+ http_parser.add_argument("url")
19
+ http_parser.add_argument("--json", action="store_true", help="Pretty-print a JSON response")
20
+ json_parser = commands.add_parser("json-format", help="Format a JSON file")
21
+ json_parser.add_argument("path", type=Path)
22
+ args = parser.parse_args()
23
+ if args.command == "get":
24
+ response = get(args.url)
25
+ print(f"HTTP {response.status}")
26
+ print(json.dumps(response.json(), indent=2) if args.json else response.text)
27
+ elif args.command == "json-format":
28
+ write_json(args.path, read_json(args.path))
29
+ print(f"Formatted {args.path}")
30
+
31
+
32
+ if __name__ == "__main__":
33
+ main()
@@ -0,0 +1,39 @@
1
+ """JSON and CSV convenience functions."""
2
+ from __future__ import annotations
3
+
4
+ import csv
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any, Iterable, Mapping
8
+
9
+
10
+ def read_json(path: str | Path) -> Any:
11
+ with Path(path).open(encoding="utf-8") as handle:
12
+ return json.load(handle)
13
+
14
+
15
+ def write_json(path: str | Path, value: Any, *, indent: int = 2) -> Path:
16
+ target = Path(path)
17
+ target.parent.mkdir(parents=True, exist_ok=True)
18
+ with target.open("w", encoding="utf-8") as handle:
19
+ json.dump(value, handle, indent=indent, ensure_ascii=False)
20
+ handle.write("\n")
21
+ return target
22
+
23
+
24
+ def read_csv(path: str | Path) -> list[dict[str, str]]:
25
+ with Path(path).open(newline="", encoding="utf-8") as handle:
26
+ return list(csv.DictReader(handle))
27
+
28
+
29
+ def write_csv(path: str | Path, rows: Iterable[Mapping[str, Any]]) -> Path:
30
+ materialized = list(rows)
31
+ if not materialized:
32
+ raise ValueError("rows cannot be empty because CSV headers are unknown")
33
+ target = Path(path)
34
+ target.parent.mkdir(parents=True, exist_ok=True)
35
+ with target.open("w", newline="", encoding="utf-8") as handle:
36
+ writer = csv.DictWriter(handle, fieldnames=list(materialized[0]))
37
+ writer.writeheader()
38
+ writer.writerows(materialized)
39
+ return target
@@ -0,0 +1,29 @@
1
+ """Safe, small file helpers."""
2
+ from __future__ import annotations
3
+
4
+ from pathlib import Path
5
+
6
+
7
+ def ensure_dir(path: str | Path) -> Path:
8
+ target = Path(path)
9
+ target.mkdir(parents=True, exist_ok=True)
10
+ return target
11
+
12
+
13
+ def read_text(path: str | Path, *, encoding: str = "utf-8") -> str:
14
+ return Path(path).read_text(encoding=encoding)
15
+
16
+
17
+ def write_text(path: str | Path, content: str, *, encoding: str = "utf-8") -> Path:
18
+ target = Path(path)
19
+ target.parent.mkdir(parents=True, exist_ok=True)
20
+ target.write_text(content, encoding=encoding)
21
+ return target
22
+
23
+
24
+ def append_text(path: str | Path, content: str, *, encoding: str = "utf-8") -> Path:
25
+ target = Path(path)
26
+ target.parent.mkdir(parents=True, exist_ok=True)
27
+ with target.open("a", encoding=encoding) as handle:
28
+ handle.write(content)
29
+ return target
@@ -0,0 +1,52 @@
1
+ """Small, dependency-free HTTP client built on urllib."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from dataclasses import dataclass
6
+ from typing import Any, Mapping
7
+ from urllib.error import HTTPError, URLError
8
+ from urllib.request import Request, urlopen
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class Response:
13
+ status: int
14
+ headers: Mapping[str, str]
15
+ body: bytes
16
+
17
+ @property
18
+ def text(self) -> str:
19
+ charset = "utf-8"
20
+ content_type = self.headers.get("Content-Type", "")
21
+ if "charset=" in content_type.lower():
22
+ charset = content_type.split("charset=", 1)[1].split(";", 1)[0].strip()
23
+ return self.body.decode(charset, errors="replace")
24
+
25
+ def json(self) -> Any:
26
+ return json.loads(self.text)
27
+
28
+
29
+ def request(method: str, url: str, *, headers: Mapping[str, str] | None = None,
30
+ json_body: Any | None = None, data: bytes | str | None = None,
31
+ timeout: float = 20) -> Response:
32
+ """Make an HTTP request and return a response, including HTTP error bodies."""
33
+ if json_body is not None and data is not None:
34
+ raise ValueError("Pass json_body or data, not both.")
35
+ final_headers = dict(headers or {})
36
+ if json_body is not None:
37
+ data = json.dumps(json_body).encode("utf-8")
38
+ final_headers.setdefault("Content-Type", "application/json")
39
+ if isinstance(data, str):
40
+ data = data.encode("utf-8")
41
+ req = Request(url, data=data, headers=final_headers, method=method.upper())
42
+ try:
43
+ with urlopen(req, timeout=timeout) as raw:
44
+ return Response(raw.status, dict(raw.headers.items()), raw.read())
45
+ except HTTPError as exc:
46
+ return Response(exc.code, dict(exc.headers.items()) if exc.headers else {}, exc.read())
47
+ except URLError as exc:
48
+ raise ConnectionError(f"Could not reach {url}: {exc.reason}") from exc
49
+
50
+
51
+ def get(url: str, **kwargs: Any) -> Response:
52
+ return request("GET", url, **kwargs)
@@ -0,0 +1,23 @@
1
+ """Simple input checks for common automation code."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+ from collections.abc import Mapping
6
+ from urllib.parse import urlparse
7
+
8
+ _EMAIL = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
9
+
10
+
11
+ def is_email(value: str) -> bool:
12
+ return bool(_EMAIL.fullmatch(value))
13
+
14
+
15
+ def is_url(value: str, *, schemes: tuple[str, ...] = ("http", "https")) -> bool:
16
+ parsed = urlparse(value)
17
+ return parsed.scheme in schemes and bool(parsed.netloc)
18
+
19
+
20
+ def require_keys(value: Mapping[str, object], *keys: str) -> None:
21
+ missing = [key for key in keys if key not in value]
22
+ if missing:
23
+ raise ValueError(f"Missing required keys: {', '.join(missing)}")
@@ -0,0 +1,12 @@
1
+ """Optional WebSocket support. Install with: pip install shadow-tools[websocket]"""
2
+ from __future__ import annotations
3
+
4
+ from typing import Any
5
+
6
+
7
+ def connect(url: str, **kwargs: Any) -> Any:
8
+ try:
9
+ import websocket
10
+ except ImportError as exc:
11
+ raise ImportError("WebSocket support needs `pip install shadow-tools[websocket]`.") from exc
12
+ return websocket.create_connection(url, **kwargs)
@@ -0,0 +1,35 @@
1
+ import asyncio
2
+
3
+ import pytest
4
+
5
+ from shadowtools.async_tools import run_all
6
+ from shadowtools.data import read_csv, read_json, write_csv, write_json
7
+ from shadowtools.files import append_text, read_text, write_text
8
+ from shadowtools.validation import is_email, is_url, require_keys
9
+
10
+
11
+ def test_file_and_data_helpers(tmp_path):
12
+ note = tmp_path / "nested" / "note.txt"
13
+ write_text(note, "hi")
14
+ append_text(note, " there")
15
+ assert read_text(note) == "hi there"
16
+ payload = tmp_path / "payload.json"
17
+ write_json(payload, {"ok": True})
18
+ assert read_json(payload) == {"ok": True}
19
+ table = tmp_path / "table.csv"
20
+ write_csv(table, [{"name": "Will", "count": 1}])
21
+ assert read_csv(table) == [{"name": "Will", "count": "1"}]
22
+
23
+
24
+ def test_validation():
25
+ assert is_email("will@example.com")
26
+ assert not is_email("bad email")
27
+ assert is_url("https://example.com/a")
28
+ assert not is_url("not-a-url")
29
+ require_keys({"a": 1}, "a")
30
+ with pytest.raises(ValueError):
31
+ require_keys({}, "missing")
32
+
33
+
34
+ def test_run_all():
35
+ assert asyncio.run(run_all([lambda: 2, lambda: 3])) == [2, 3]