ikichunk 0.2.0__py3-none-any.whl
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.
- ikichunk/__init__.py +8 -0
- ikichunk/automation/__init__.py +3 -0
- ikichunk/automation/tasks.py +5 -0
- ikichunk/cli.py +41 -0
- ikichunk/concurrency/__init__.py +3 -0
- ikichunk/concurrency/parallel.py +43 -0
- ikichunk/configuration/__init__.py +3 -0
- ikichunk/configuration/loader.py +43 -0
- ikichunk/exceptions.py +25 -0
- ikichunk/facade.py +167 -0
- ikichunk/inspection/__init__.py +3 -0
- ikichunk/inspection/inspectors.py +59 -0
- ikichunk/integrity/__init__.py +3 -0
- ikichunk/integrity/hashing.py +21 -0
- ikichunk/io/__init__.py +6 -0
- ikichunk/io/atomic.py +20 -0
- ikichunk/io/core.py +56 -0
- ikichunk/io/formats.py +252 -0
- ikichunk/io/streaming.py +27 -0
- ikichunk/net/__init__.py +3 -0
- ikichunk/net/http.py +8 -0
- ikichunk/observability/__init__.py +4 -0
- ikichunk/observability/logging_.py +18 -0
- ikichunk/observability/timing.py +32 -0
- ikichunk/partitioning/__init__.py +3 -0
- ikichunk/partitioning/strategies.py +177 -0
- ikichunk/plugins/__init__.py +3 -0
- ikichunk/plugins/registry.py +5 -0
- ikichunk/resilience/__init__.py +3 -0
- ikichunk/resilience/retry.py +21 -0
- ikichunk/storage/__init__.py +5 -0
- ikichunk/storage/codecs.py +19 -0
- ikichunk/storage/compression.py +152 -0
- ikichunk/system/__init__.py +4 -0
- ikichunk/system/platform.py +98 -0
- ikichunk/templates/__init__.py +3 -0
- ikichunk/templates/rendering.py +31 -0
- ikichunk/validation/__init__.py +3 -0
- ikichunk/validation/validate.py +20 -0
- ikichunk-0.2.0.dist-info/METADATA +615 -0
- ikichunk-0.2.0.dist-info/RECORD +45 -0
- ikichunk-0.2.0.dist-info/WHEEL +5 -0
- ikichunk-0.2.0.dist-info/entry_points.txt +2 -0
- ikichunk-0.2.0.dist-info/licenses/LICENSE +21 -0
- ikichunk-0.2.0.dist-info/top_level.txt +1 -0
ikichunk/__init__.py
ADDED
ikichunk/cli.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from . import partition
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
11
|
+
parser = argparse.ArgumentParser(prog="ikichunk")
|
|
12
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
13
|
+
|
|
14
|
+
inspect_parser = subparsers.add_parser("inspect")
|
|
15
|
+
inspect_parser.add_argument("path")
|
|
16
|
+
inspect_parser.set_defaults(func=_cmd_inspect)
|
|
17
|
+
|
|
18
|
+
split_parser = subparsers.add_parser("split")
|
|
19
|
+
split_parser.add_argument("path")
|
|
20
|
+
split_parser.add_argument("--rows", type=int, default=100)
|
|
21
|
+
split_parser.set_defaults(func=_cmd_split)
|
|
22
|
+
|
|
23
|
+
return parser
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _cmd_inspect(args: argparse.Namespace) -> int:
|
|
27
|
+
print(json.dumps(partition.inspect(args.path), indent=2))
|
|
28
|
+
return 0
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _cmd_split(args: argparse.Namespace) -> int:
|
|
32
|
+
parts = partition.split_file(args.path, by="rows", rows=args.rows, out_dir=str(
|
|
33
|
+
Path(args.path).parent / "parts"))
|
|
34
|
+
print(json.dumps(parts, indent=2))
|
|
35
|
+
return 0
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def main() -> int:
|
|
39
|
+
parser = build_parser()
|
|
40
|
+
args = parser.parse_args()
|
|
41
|
+
return args.func(args)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import pickle
|
|
4
|
+
from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from ..exceptions import PartitionParallelError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _call_with_retries(func, item, retries):
|
|
11
|
+
for attempt in range(retries + 1):
|
|
12
|
+
try:
|
|
13
|
+
return func(item)
|
|
14
|
+
except Exception:
|
|
15
|
+
if attempt >= retries:
|
|
16
|
+
raise
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def pmap(func, items, *, workers: Optional[int] = None, backend: str = "thread", retries: int = 0, progress: bool = False, ordered: bool = True):
|
|
20
|
+
items = list(items)
|
|
21
|
+
if workers is None:
|
|
22
|
+
workers = max(
|
|
23
|
+
1, min(32, len(items) if hasattr(items, '__len__') else 1))
|
|
24
|
+
if backend not in {"thread", "process"}:
|
|
25
|
+
raise ValueError("backend must be 'thread' or 'process'")
|
|
26
|
+
if backend == "process":
|
|
27
|
+
try:
|
|
28
|
+
pickle.dumps(func)
|
|
29
|
+
for item in items:
|
|
30
|
+
pickle.dumps(item)
|
|
31
|
+
except Exception as exc:
|
|
32
|
+
raise PartitionParallelError(
|
|
33
|
+
"func and items must be picklable for backend='process'") from exc
|
|
34
|
+
|
|
35
|
+
if backend == "process":
|
|
36
|
+
executor_cls = ProcessPoolExecutor
|
|
37
|
+
else:
|
|
38
|
+
executor_cls = ThreadPoolExecutor
|
|
39
|
+
|
|
40
|
+
with executor_cls(max_workers=workers) as executor:
|
|
41
|
+
futures = [executor.submit(
|
|
42
|
+
_call_with_retries, func, item, retries) for item in items]
|
|
43
|
+
return [future.result() for future in futures]
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from typing import Any, Optional
|
|
5
|
+
|
|
6
|
+
from ..io import cat as _io_cat
|
|
7
|
+
from ..io import read as _io_read
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def load_config(*sources: str, secrets: Optional[str] = None, env_prefix: str = "", **kwargs) -> dict:
|
|
11
|
+
merged: dict = {}
|
|
12
|
+
for source in sources:
|
|
13
|
+
if os.path.exists(source):
|
|
14
|
+
data = _io_read(source)
|
|
15
|
+
if isinstance(data, dict):
|
|
16
|
+
merged.update(data)
|
|
17
|
+
if secrets and os.path.exists(secrets):
|
|
18
|
+
merged.update(_parse_dotenv(secrets))
|
|
19
|
+
if env_prefix:
|
|
20
|
+
env_items = {k[len(env_prefix):]: v for k,
|
|
21
|
+
v in os.environ.items() if k.startswith(env_prefix)}
|
|
22
|
+
merged.update(env_items)
|
|
23
|
+
return merged
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _parse_dotenv(path: str) -> dict:
|
|
27
|
+
out: dict = {}
|
|
28
|
+
for line in _io_cat(path).splitlines():
|
|
29
|
+
line = line.strip()
|
|
30
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
31
|
+
continue
|
|
32
|
+
key, value = line.split("=", 1)
|
|
33
|
+
out[key.strip()] = value.strip()
|
|
34
|
+
return out
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def env(key: str, default: Any = None, cast=str) -> Any:
|
|
38
|
+
if key not in os.environ:
|
|
39
|
+
return default
|
|
40
|
+
value = os.environ[key]
|
|
41
|
+
if cast is str:
|
|
42
|
+
return value
|
|
43
|
+
return cast(value)
|
ikichunk/exceptions.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class IkichunkError(Exception):
|
|
5
|
+
"""Base exception for intentionally raised ikichunk errors."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class UnknownFormatError(IkichunkError):
|
|
9
|
+
"""Raised when a file format cannot be determined safely."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class MissingDependencyError(IkichunkError, ImportError):
|
|
13
|
+
"""Raised when an optional dependency is missing."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class UnsafeCommandError(IkichunkError):
|
|
17
|
+
"""Raised when a shell command string is considered unsafe."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class PartitionParallelError(IkichunkError):
|
|
21
|
+
"""Raised when process-based parallel execution cannot be pickled."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class UnsafeArchiveError(IkichunkError):
|
|
25
|
+
"""Raised when an archive would extract outside the target directory."""
|
ikichunk/facade.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Callable, Iterator, Optional
|
|
4
|
+
|
|
5
|
+
from . import automation as _automation
|
|
6
|
+
from . import concurrency as _concurrency
|
|
7
|
+
from . import configuration as _configuration
|
|
8
|
+
from . import inspection as _inspection
|
|
9
|
+
from . import integrity as _integrity
|
|
10
|
+
from . import io as _io
|
|
11
|
+
from . import net as _net
|
|
12
|
+
from . import observability as _observability
|
|
13
|
+
from . import partitioning as _partitioning
|
|
14
|
+
from . import plugins as _plugins
|
|
15
|
+
from . import resilience as _resilience
|
|
16
|
+
from . import storage as _storage
|
|
17
|
+
from . import system as _system
|
|
18
|
+
from . import templates as _templates
|
|
19
|
+
from . import validation as _validation
|
|
20
|
+
|
|
21
|
+
__version__ = "0.2.0"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Partition:
|
|
25
|
+
def __init__(self, log_level: str = "INFO", env_prefix: str = "") -> None:
|
|
26
|
+
self.log_level = log_level
|
|
27
|
+
self.env_prefix = env_prefix
|
|
28
|
+
|
|
29
|
+
# I/O
|
|
30
|
+
def read(self, path: str, fmt: Optional[str] = None, **kwargs) -> Any:
|
|
31
|
+
return _io.read(path, fmt=fmt, **kwargs)
|
|
32
|
+
|
|
33
|
+
def write(self, path: str, data: Any, *, atomic: bool = True, backup: bool = False, fmt: Optional[str] = None, **kwargs) -> str:
|
|
34
|
+
return _io.write(path, data, atomic=atomic, backup=backup, fmt=fmt, **kwargs)
|
|
35
|
+
|
|
36
|
+
def exists(self, path: str) -> bool:
|
|
37
|
+
return _io.exists(path)
|
|
38
|
+
|
|
39
|
+
def ensure_dir(self, path: str) -> str:
|
|
40
|
+
return _io.ensure_dir(path)
|
|
41
|
+
|
|
42
|
+
def list_files(self, path: str = ".", pattern: str = "*", recursive: bool = False) -> list[str]:
|
|
43
|
+
return _io.list_files(path, pattern=pattern, recursive=recursive)
|
|
44
|
+
|
|
45
|
+
ls = list_files
|
|
46
|
+
|
|
47
|
+
def cat(self, path: str) -> str:
|
|
48
|
+
return _io.cat(path)
|
|
49
|
+
|
|
50
|
+
def stream(self, path: str, fmt: Optional[str] = None, chunk_size: Optional[int] = None) -> Iterator[Any]:
|
|
51
|
+
return _io.stream(path, fmt=fmt, chunk_size=chunk_size)
|
|
52
|
+
|
|
53
|
+
# Inspect
|
|
54
|
+
def inspect(self, obj_or_path: Any, sample: int = 3, **kwargs) -> dict:
|
|
55
|
+
return _inspection.inspect(obj_or_path, sample=sample, **kwargs)
|
|
56
|
+
|
|
57
|
+
def head(self, obj_or_path: Any, n: int = 5, **kwargs) -> Any:
|
|
58
|
+
return _inspection.head(obj_or_path, n=n, **kwargs)
|
|
59
|
+
|
|
60
|
+
def redact(self, data: dict) -> dict:
|
|
61
|
+
return _inspection.redact(data)
|
|
62
|
+
|
|
63
|
+
# Config
|
|
64
|
+
def config(self, *sources: str, secrets: Optional[str] = None, env_prefix: Optional[str] = None, **kwargs) -> dict:
|
|
65
|
+
return _configuration.load_config(*sources, secrets=secrets, env_prefix=env_prefix or self.env_prefix, **kwargs)
|
|
66
|
+
|
|
67
|
+
def env(self, key: str, default: Any = None, cast=str) -> Any:
|
|
68
|
+
return _configuration.env(key, default=default, cast=cast)
|
|
69
|
+
|
|
70
|
+
# Logging / time
|
|
71
|
+
def log(self, name: Optional[str] = None, level: Optional[str] = None, **kwargs):
|
|
72
|
+
return _observability.get_logger(name or "partition", level or self.log_level, **kwargs)
|
|
73
|
+
|
|
74
|
+
def now(self, fmt: str = "%Y-%m-%d %H:%M:%S") -> str:
|
|
75
|
+
return _observability.now(fmt)
|
|
76
|
+
|
|
77
|
+
def timer(self, name: str = "block"):
|
|
78
|
+
return _observability.timer(name)
|
|
79
|
+
|
|
80
|
+
def duration(self, seconds: float) -> str:
|
|
81
|
+
return _observability.duration(seconds)
|
|
82
|
+
|
|
83
|
+
# Retry
|
|
84
|
+
def retry(self, tries: int = 3, delay: float = 1.0, backoff: float = 2.0, exceptions=(Exception,)) -> Callable:
|
|
85
|
+
return _resilience.retry(tries=tries, delay=delay, backoff=backoff, exceptions=exceptions)
|
|
86
|
+
|
|
87
|
+
# Parallel
|
|
88
|
+
def pmap(self, func: Callable, items, *, workers: Optional[int] = None, backend: str = "thread", retries: int = 0, progress: bool = False, ordered: bool = True):
|
|
89
|
+
return _concurrency.pmap(func, items, workers=workers, backend=backend, retries=retries, progress=progress, ordered=ordered)
|
|
90
|
+
|
|
91
|
+
# Partitioning
|
|
92
|
+
def split_file(self, path: str, *, by: str = "size", size: Optional[str] = None, rows: Optional[int] = None, count: Optional[int] = None, parts: Optional[int] = None, out_dir: Optional[str] = None, fmt: Optional[str] = None, prefix: Optional[str] = None) -> list[str]:
|
|
93
|
+
return _partitioning.split_file(path, by=by, size=size, rows=rows, count=count, parts=parts, out_dir=out_dir, fmt=fmt, prefix=prefix)
|
|
94
|
+
|
|
95
|
+
def smart_split(self, path: str, *, goal: str = "parallel", workers: Optional[int] = None, chunk_size: Optional[int] = None, out_dir: Optional[str] = None, fmt: Optional[str] = None, explain: bool = False):
|
|
96
|
+
return _partitioning.smart_split(path, goal=goal, workers=workers, chunk_size=chunk_size, out_dir=out_dir, fmt=fmt, explain=explain)
|
|
97
|
+
|
|
98
|
+
def chunks(self, iterable, size: int):
|
|
99
|
+
return _partitioning.chunks(iterable, size)
|
|
100
|
+
|
|
101
|
+
def manifest(self, paths, *, hash_algo: str = "sha256") -> dict:
|
|
102
|
+
return _partitioning.manifest(paths, hash_algo=hash_algo)
|
|
103
|
+
|
|
104
|
+
# Integrity
|
|
105
|
+
def hash(self, path_or_bytes, algo: str = "sha256") -> str:
|
|
106
|
+
return _integrity.hash(path_or_bytes, algo=algo)
|
|
107
|
+
|
|
108
|
+
def hash_file(self, path: str, algo: str = "sha256") -> str:
|
|
109
|
+
return self.hash(path, algo=algo)
|
|
110
|
+
|
|
111
|
+
def verify(self, path: str, expected_hash: str, algo: str = "sha256") -> bool:
|
|
112
|
+
return _integrity.verify(path, expected_hash, algo=algo)
|
|
113
|
+
|
|
114
|
+
# Storage
|
|
115
|
+
def compress(self, path: str, *, algo: str = "gzip", out: Optional[str] = None, keep_original: bool = True) -> str:
|
|
116
|
+
return _storage.compress(path, algo=algo, out=out, keep_original=keep_original)
|
|
117
|
+
|
|
118
|
+
def decompress(self, path: str, *, out: Optional[str] = None) -> str:
|
|
119
|
+
return _storage.decompress(path, out=out)
|
|
120
|
+
|
|
121
|
+
def archive(self, source: str, out_path: str, *, fmt: str = "tar.gz") -> str:
|
|
122
|
+
return _storage.archive(source, out_path, fmt=fmt)
|
|
123
|
+
|
|
124
|
+
def extract(self, archive_path: str, *, out_dir: Optional[str] = None) -> str:
|
|
125
|
+
return _storage.extract(archive_path, out_dir=out_dir)
|
|
126
|
+
|
|
127
|
+
def register_codec(self, name: str, codec) -> None:
|
|
128
|
+
_storage.register_codec(name, codec)
|
|
129
|
+
|
|
130
|
+
# System/platform/process/net
|
|
131
|
+
def platform_info(self) -> dict:
|
|
132
|
+
return _system.platform_info()
|
|
133
|
+
|
|
134
|
+
def which(self, cmd: str) -> Optional[str]:
|
|
135
|
+
return _system.which(cmd)
|
|
136
|
+
|
|
137
|
+
def normalize_path(self, path: str) -> str:
|
|
138
|
+
return _system.normalize_path(path)
|
|
139
|
+
|
|
140
|
+
def is_running(self, pid: int) -> bool:
|
|
141
|
+
return _system.is_running(pid)
|
|
142
|
+
|
|
143
|
+
def is_port_open(self, host: str, port: int, timeout: float = 0.2) -> bool:
|
|
144
|
+
return _system.is_port_open(host, port, timeout=timeout)
|
|
145
|
+
|
|
146
|
+
def run(self, command, *, check: bool = False, shell: bool = False, timeout: Optional[float] = None):
|
|
147
|
+
return _system.run(command, check=check, shell=shell, timeout=timeout)
|
|
148
|
+
|
|
149
|
+
def fetch(self, url: str, *, timeout: float = 5.0) -> str:
|
|
150
|
+
return _net.fetch(url, timeout=timeout)
|
|
151
|
+
|
|
152
|
+
def render(self, template: str, variables: dict, *, out: Optional[str] = None, strict: bool = True) -> str:
|
|
153
|
+
return _templates.render(template, variables, out=out, strict=strict)
|
|
154
|
+
|
|
155
|
+
# Validation and plugins
|
|
156
|
+
def register_format(self, name: str, handler, extensions: Optional[list[str]] = None) -> None:
|
|
157
|
+
_io.register_format(name, handler, extensions=extensions)
|
|
158
|
+
|
|
159
|
+
def register_split_goal(self, name: str, fn: Callable[[int, dict, int], dict]) -> None:
|
|
160
|
+
_partitioning.register_goal(name, fn)
|
|
161
|
+
|
|
162
|
+
def validate(self, value: Any, schema: Optional[dict] = None) -> bool:
|
|
163
|
+
return _validation.validate(value, schema=schema)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def load_plugins(instance: Partition) -> None:
|
|
167
|
+
_plugins.load_plugins(instance)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import functools
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from ..io import read as _io_read
|
|
8
|
+
|
|
9
|
+
_SECRET_KEY_PATTERNS = ("_key", "_token", "_secret",
|
|
10
|
+
"_password", "password", "secret", "token")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def redact(d: dict) -> dict:
|
|
14
|
+
out = {}
|
|
15
|
+
for key, value in d.items():
|
|
16
|
+
if any(pattern in str(key).lower() for pattern in _SECRET_KEY_PATTERNS):
|
|
17
|
+
out[key] = "<redacted>"
|
|
18
|
+
else:
|
|
19
|
+
out[key] = value
|
|
20
|
+
return out
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@functools.singledispatch
|
|
24
|
+
def _inspect_obj(obj: Any, sample: int = 3) -> dict:
|
|
25
|
+
return {"type": type(obj).__name__, "repr": repr(obj)[:200]}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@_inspect_obj.register
|
|
29
|
+
def _inspect_list(obj: list, sample: int = 3) -> dict:
|
|
30
|
+
info = {"type": "list", "length": len(obj)}
|
|
31
|
+
if obj:
|
|
32
|
+
info["sample"] = obj[:sample]
|
|
33
|
+
return info
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@_inspect_obj.register
|
|
37
|
+
def _inspect_dict(obj: dict, sample: int = 3) -> dict:
|
|
38
|
+
return {"type": "dict", "keys": list(obj.keys()), "sample": redact(dict(list(obj.items())[:sample]))}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@_inspect_obj.register
|
|
42
|
+
def _inspect_str(obj: str, sample: int = 3) -> dict:
|
|
43
|
+
lines = obj.splitlines()
|
|
44
|
+
return {"type": "text", "length": len(obj), "lines": len(lines), "sample": lines[:sample]}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def register_inspector(python_type, func) -> None:
|
|
48
|
+
_inspect_obj.register(python_type, func)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def inspect(obj_or_path: Any, sample: int = 3, **kwargs) -> dict:
|
|
52
|
+
if isinstance(obj_or_path, str) and Path(obj_or_path).exists():
|
|
53
|
+
return _inspect_obj(_io_read(obj_or_path), sample=sample)
|
|
54
|
+
return _inspect_obj(obj_or_path, sample=sample)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def head(obj_or_path: Any, n: int = 5, **kwargs) -> Any:
|
|
58
|
+
info = inspect(obj_or_path, sample=n, **kwargs)
|
|
59
|
+
return info.get("sample", info)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def hash(path_or_bytes, algo: str = "sha256") -> str:
|
|
9
|
+
hasher = hashlib.new(algo)
|
|
10
|
+
if isinstance(path_or_bytes, (bytes, bytearray)):
|
|
11
|
+
hasher.update(bytes(path_or_bytes))
|
|
12
|
+
return hasher.hexdigest()
|
|
13
|
+
path = Path(path_or_bytes)
|
|
14
|
+
with path.open("rb") as fh:
|
|
15
|
+
for chunk in iter(lambda: fh.read(1024 * 1024), b""):
|
|
16
|
+
hasher.update(chunk)
|
|
17
|
+
return hasher.hexdigest()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def verify(path: str, expected_hash: str, algo: str = "sha256") -> bool:
|
|
21
|
+
return hash(path, algo=algo) == expected_hash
|
ikichunk/io/__init__.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
from .core import cat, ensure_dir, exists, list_files, read, write
|
|
2
|
+
from .formats import FormatHandler, register_format
|
|
3
|
+
from .streaming import stream
|
|
4
|
+
|
|
5
|
+
__all__ = ["read", "write", "exists", "ensure_dir", "list_files",
|
|
6
|
+
"cat", "stream", "FormatHandler", "register_format"]
|
ikichunk/io/atomic.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import tempfile
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Callable
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def atomic_write(target: Path, serialize: Callable[[Path], None]) -> None:
|
|
10
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
11
|
+
fd, tmp_path = tempfile.mkstemp(
|
|
12
|
+
prefix=target.name + ".", dir=str(target.parent))
|
|
13
|
+
os.close(fd)
|
|
14
|
+
tmp = Path(tmp_path)
|
|
15
|
+
try:
|
|
16
|
+
serialize(tmp)
|
|
17
|
+
os.replace(tmp, target)
|
|
18
|
+
finally:
|
|
19
|
+
if tmp.exists():
|
|
20
|
+
tmp.unlink(missing_ok=True)
|
ikichunk/io/core.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, List, Optional
|
|
6
|
+
|
|
7
|
+
from .atomic import atomic_write
|
|
8
|
+
from .formats import detect_fmt, get_handler
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def read(path: str, fmt: Optional[str] = None, **kwargs) -> Any:
|
|
12
|
+
p = Path(path)
|
|
13
|
+
handler = get_handler(detect_fmt(p, fmt))
|
|
14
|
+
return handler.read(p, kwargs)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def write(path: str, data: Any, *, atomic: bool = True, backup: bool = False, fmt: Optional[str] = None, **kwargs) -> str:
|
|
18
|
+
p = Path(path)
|
|
19
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
20
|
+
resolved_fmt = fmt or detect_fmt(p, None)
|
|
21
|
+
if atomic:
|
|
22
|
+
atomic_write(p, lambda tmp: _serialize(
|
|
23
|
+
tmp, data, fmt=resolved_fmt, **kwargs))
|
|
24
|
+
else:
|
|
25
|
+
_serialize(p, data, fmt=resolved_fmt, **kwargs)
|
|
26
|
+
if backup and p.exists():
|
|
27
|
+
backup_path = p.with_suffix(p.suffix + ".bak")
|
|
28
|
+
shutil.copy2(p, backup_path)
|
|
29
|
+
return str(p)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _serialize(path: Path, data: Any, *, fmt: Optional[str] = None, **kwargs) -> None:
|
|
33
|
+
handler = get_handler(detect_fmt(path, fmt))
|
|
34
|
+
handler.write(path, data, kwargs)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def exists(path: str) -> bool:
|
|
38
|
+
return Path(path).exists()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def ensure_dir(path: str) -> str:
|
|
42
|
+
Path(path).mkdir(parents=True, exist_ok=True)
|
|
43
|
+
return str(path)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def list_files(path: str = ".", pattern: str = "*", recursive: bool = False) -> List[str]:
|
|
47
|
+
base = Path(path)
|
|
48
|
+
if recursive:
|
|
49
|
+
matches = [p for p in base.rglob(pattern) if p.is_file()]
|
|
50
|
+
else:
|
|
51
|
+
matches = [p for p in base.glob(pattern) if p.is_file()]
|
|
52
|
+
return sorted(str(p) for p in matches)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def cat(path: str) -> str:
|
|
56
|
+
return read(path, fmt="text")
|