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.
Files changed (45) hide show
  1. ikichunk/__init__.py +8 -0
  2. ikichunk/automation/__init__.py +3 -0
  3. ikichunk/automation/tasks.py +5 -0
  4. ikichunk/cli.py +41 -0
  5. ikichunk/concurrency/__init__.py +3 -0
  6. ikichunk/concurrency/parallel.py +43 -0
  7. ikichunk/configuration/__init__.py +3 -0
  8. ikichunk/configuration/loader.py +43 -0
  9. ikichunk/exceptions.py +25 -0
  10. ikichunk/facade.py +167 -0
  11. ikichunk/inspection/__init__.py +3 -0
  12. ikichunk/inspection/inspectors.py +59 -0
  13. ikichunk/integrity/__init__.py +3 -0
  14. ikichunk/integrity/hashing.py +21 -0
  15. ikichunk/io/__init__.py +6 -0
  16. ikichunk/io/atomic.py +20 -0
  17. ikichunk/io/core.py +56 -0
  18. ikichunk/io/formats.py +252 -0
  19. ikichunk/io/streaming.py +27 -0
  20. ikichunk/net/__init__.py +3 -0
  21. ikichunk/net/http.py +8 -0
  22. ikichunk/observability/__init__.py +4 -0
  23. ikichunk/observability/logging_.py +18 -0
  24. ikichunk/observability/timing.py +32 -0
  25. ikichunk/partitioning/__init__.py +3 -0
  26. ikichunk/partitioning/strategies.py +177 -0
  27. ikichunk/plugins/__init__.py +3 -0
  28. ikichunk/plugins/registry.py +5 -0
  29. ikichunk/resilience/__init__.py +3 -0
  30. ikichunk/resilience/retry.py +21 -0
  31. ikichunk/storage/__init__.py +5 -0
  32. ikichunk/storage/codecs.py +19 -0
  33. ikichunk/storage/compression.py +152 -0
  34. ikichunk/system/__init__.py +4 -0
  35. ikichunk/system/platform.py +98 -0
  36. ikichunk/templates/__init__.py +3 -0
  37. ikichunk/templates/rendering.py +31 -0
  38. ikichunk/validation/__init__.py +3 -0
  39. ikichunk/validation/validate.py +20 -0
  40. ikichunk-0.2.0.dist-info/METADATA +615 -0
  41. ikichunk-0.2.0.dist-info/RECORD +45 -0
  42. ikichunk-0.2.0.dist-info/WHEEL +5 -0
  43. ikichunk-0.2.0.dist-info/entry_points.txt +2 -0
  44. ikichunk-0.2.0.dist-info/licenses/LICENSE +21 -0
  45. ikichunk-0.2.0.dist-info/top_level.txt +1 -0
ikichunk/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ from __future__ import annotations
2
+
3
+ from .facade import Partition, __version__, load_plugins
4
+
5
+ partition = Partition()
6
+ load_plugins(partition)
7
+
8
+ __all__ = ["partition", "Partition", "__version__"]
@@ -0,0 +1,3 @@
1
+ from .tasks import run_task
2
+
3
+ __all__ = ["run_task"]
@@ -0,0 +1,5 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ def run_task(name: str, **kwargs):
5
+ return {"name": name, **kwargs}
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,3 @@
1
+ from .parallel import pmap
2
+
3
+ __all__ = ["pmap"]
@@ -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,3 @@
1
+ from .loader import env, load_config
2
+
3
+ __all__ = ["load_config", "env"]
@@ -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,3 @@
1
+ from .inspectors import head, inspect, redact, register_inspector
2
+
3
+ __all__ = ["inspect", "head", "redact", "register_inspector"]
@@ -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,3 @@
1
+ from .hashing import hash, verify
2
+
3
+ __all__ = ["hash", "verify"]
@@ -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
@@ -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")