provide-foundation 0.0.0.dev0__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.
- provide/__init__.py +15 -0
- provide/foundation/__init__.py +155 -0
- provide/foundation/_version.py +58 -0
- provide/foundation/cli/__init__.py +67 -0
- provide/foundation/cli/commands/__init__.py +3 -0
- provide/foundation/cli/commands/deps.py +71 -0
- provide/foundation/cli/commands/logs/__init__.py +63 -0
- provide/foundation/cli/commands/logs/generate.py +357 -0
- provide/foundation/cli/commands/logs/generate_old.py +569 -0
- provide/foundation/cli/commands/logs/query.py +174 -0
- provide/foundation/cli/commands/logs/send.py +166 -0
- provide/foundation/cli/commands/logs/tail.py +112 -0
- provide/foundation/cli/decorators.py +262 -0
- provide/foundation/cli/main.py +65 -0
- provide/foundation/cli/testing.py +220 -0
- provide/foundation/cli/utils.py +210 -0
- provide/foundation/config/__init__.py +106 -0
- provide/foundation/config/base.py +295 -0
- provide/foundation/config/env.py +369 -0
- provide/foundation/config/loader.py +311 -0
- provide/foundation/config/manager.py +387 -0
- provide/foundation/config/schema.py +284 -0
- provide/foundation/config/sync.py +281 -0
- provide/foundation/config/types.py +78 -0
- provide/foundation/config/validators.py +80 -0
- provide/foundation/console/__init__.py +29 -0
- provide/foundation/console/input.py +364 -0
- provide/foundation/console/output.py +178 -0
- provide/foundation/context/__init__.py +12 -0
- provide/foundation/context/core.py +356 -0
- provide/foundation/core.py +20 -0
- provide/foundation/crypto/__init__.py +182 -0
- provide/foundation/crypto/algorithms.py +111 -0
- provide/foundation/crypto/certificates.py +896 -0
- provide/foundation/crypto/checksums.py +301 -0
- provide/foundation/crypto/constants.py +57 -0
- provide/foundation/crypto/hashing.py +265 -0
- provide/foundation/crypto/keys.py +188 -0
- provide/foundation/crypto/signatures.py +144 -0
- provide/foundation/crypto/utils.py +164 -0
- provide/foundation/errors/__init__.py +96 -0
- provide/foundation/errors/auth.py +73 -0
- provide/foundation/errors/base.py +81 -0
- provide/foundation/errors/config.py +103 -0
- provide/foundation/errors/context.py +299 -0
- provide/foundation/errors/decorators.py +484 -0
- provide/foundation/errors/handlers.py +360 -0
- provide/foundation/errors/integration.py +105 -0
- provide/foundation/errors/platform.py +37 -0
- provide/foundation/errors/process.py +140 -0
- provide/foundation/errors/resources.py +133 -0
- provide/foundation/errors/runtime.py +160 -0
- provide/foundation/errors/safe_decorators.py +133 -0
- provide/foundation/errors/types.py +276 -0
- provide/foundation/file/__init__.py +79 -0
- provide/foundation/file/atomic.py +157 -0
- provide/foundation/file/directory.py +134 -0
- provide/foundation/file/formats.py +236 -0
- provide/foundation/file/lock.py +175 -0
- provide/foundation/file/safe.py +179 -0
- provide/foundation/file/utils.py +170 -0
- provide/foundation/hub/__init__.py +88 -0
- provide/foundation/hub/click_builder.py +310 -0
- provide/foundation/hub/commands.py +42 -0
- provide/foundation/hub/components.py +640 -0
- provide/foundation/hub/decorators.py +244 -0
- provide/foundation/hub/info.py +32 -0
- provide/foundation/hub/manager.py +446 -0
- provide/foundation/hub/registry.py +279 -0
- provide/foundation/hub/type_mapping.py +54 -0
- provide/foundation/hub/types.py +28 -0
- provide/foundation/logger/__init__.py +41 -0
- provide/foundation/logger/base.py +22 -0
- provide/foundation/logger/config/__init__.py +16 -0
- provide/foundation/logger/config/base.py +40 -0
- provide/foundation/logger/config/logging.py +394 -0
- provide/foundation/logger/config/telemetry.py +188 -0
- provide/foundation/logger/core.py +239 -0
- provide/foundation/logger/custom_processors.py +172 -0
- provide/foundation/logger/emoji/__init__.py +44 -0
- provide/foundation/logger/emoji/matrix.py +209 -0
- provide/foundation/logger/emoji/sets.py +458 -0
- provide/foundation/logger/emoji/types.py +56 -0
- provide/foundation/logger/factories.py +56 -0
- provide/foundation/logger/processors/__init__.py +13 -0
- provide/foundation/logger/processors/main.py +254 -0
- provide/foundation/logger/processors/trace.py +113 -0
- provide/foundation/logger/ratelimit/__init__.py +31 -0
- provide/foundation/logger/ratelimit/limiters.py +294 -0
- provide/foundation/logger/ratelimit/processor.py +203 -0
- provide/foundation/logger/ratelimit/queue_limiter.py +305 -0
- provide/foundation/logger/setup/__init__.py +29 -0
- provide/foundation/logger/setup/coordinator.py +138 -0
- provide/foundation/logger/setup/emoji_resolver.py +64 -0
- provide/foundation/logger/setup/processors.py +85 -0
- provide/foundation/logger/setup/testing.py +39 -0
- provide/foundation/logger/trace.py +38 -0
- provide/foundation/metrics/__init__.py +119 -0
- provide/foundation/metrics/otel.py +122 -0
- provide/foundation/metrics/simple.py +165 -0
- provide/foundation/observability/__init__.py +53 -0
- provide/foundation/observability/openobserve/__init__.py +79 -0
- provide/foundation/observability/openobserve/auth.py +72 -0
- provide/foundation/observability/openobserve/client.py +307 -0
- provide/foundation/observability/openobserve/commands.py +357 -0
- provide/foundation/observability/openobserve/exceptions.py +41 -0
- provide/foundation/observability/openobserve/formatters.py +298 -0
- provide/foundation/observability/openobserve/models.py +134 -0
- provide/foundation/observability/openobserve/otlp.py +320 -0
- provide/foundation/observability/openobserve/search.py +222 -0
- provide/foundation/observability/openobserve/streaming.py +235 -0
- provide/foundation/platform/__init__.py +44 -0
- provide/foundation/platform/detection.py +193 -0
- provide/foundation/platform/info.py +157 -0
- provide/foundation/process/__init__.py +39 -0
- provide/foundation/process/async_runner.py +373 -0
- provide/foundation/process/lifecycle.py +406 -0
- provide/foundation/process/runner.py +390 -0
- provide/foundation/setup/__init__.py +101 -0
- provide/foundation/streams/__init__.py +44 -0
- provide/foundation/streams/console.py +57 -0
- provide/foundation/streams/core.py +65 -0
- provide/foundation/streams/file.py +104 -0
- provide/foundation/testing/__init__.py +166 -0
- provide/foundation/testing/cli.py +227 -0
- provide/foundation/testing/crypto.py +163 -0
- provide/foundation/testing/fixtures.py +49 -0
- provide/foundation/testing/hub.py +23 -0
- provide/foundation/testing/logger.py +106 -0
- provide/foundation/testing/streams.py +54 -0
- provide/foundation/tracer/__init__.py +49 -0
- provide/foundation/tracer/context.py +115 -0
- provide/foundation/tracer/otel.py +135 -0
- provide/foundation/tracer/spans.py +174 -0
- provide/foundation/types.py +32 -0
- provide/foundation/utils/__init__.py +97 -0
- provide/foundation/utils/deps.py +195 -0
- provide/foundation/utils/env.py +491 -0
- provide/foundation/utils/formatting.py +483 -0
- provide/foundation/utils/parsing.py +235 -0
- provide/foundation/utils/rate_limiting.py +112 -0
- provide/foundation/utils/streams.py +67 -0
- provide/foundation/utils/timing.py +93 -0
- provide_foundation-0.0.0.dev0.dist-info/METADATA +469 -0
- provide_foundation-0.0.0.dev0.dist-info/RECORD +149 -0
- provide_foundation-0.0.0.dev0.dist-info/WHEEL +5 -0
- provide_foundation-0.0.0.dev0.dist-info/entry_points.txt +2 -0
- provide_foundation-0.0.0.dev0.dist-info/licenses/LICENSE +201 -0
- provide_foundation-0.0.0.dev0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,175 @@
|
|
1
|
+
"""File-based locking for concurrent access control."""
|
2
|
+
|
3
|
+
import os
|
4
|
+
from pathlib import Path
|
5
|
+
import time
|
6
|
+
|
7
|
+
from provide.foundation.errors.resources import LockError
|
8
|
+
from provide.foundation.logger import get_logger
|
9
|
+
|
10
|
+
log = get_logger(__name__)
|
11
|
+
|
12
|
+
|
13
|
+
class FileLock:
|
14
|
+
"""File-based lock for concurrent access control.
|
15
|
+
|
16
|
+
Uses exclusive file creation as the locking mechanism.
|
17
|
+
The lock file contains the PID of the process holding the lock.
|
18
|
+
|
19
|
+
Example:
|
20
|
+
with FileLock("/tmp/myapp.lock"):
|
21
|
+
# Exclusive access to resource
|
22
|
+
do_something()
|
23
|
+
"""
|
24
|
+
|
25
|
+
def __init__(
|
26
|
+
self,
|
27
|
+
path: Path | str,
|
28
|
+
timeout: float = 10.0,
|
29
|
+
check_interval: float = 0.1,
|
30
|
+
) -> None:
|
31
|
+
"""Initialize file lock.
|
32
|
+
|
33
|
+
Args:
|
34
|
+
path: Lock file path
|
35
|
+
timeout: Max seconds to wait for lock
|
36
|
+
check_interval: Seconds between lock checks
|
37
|
+
"""
|
38
|
+
self.path = Path(path)
|
39
|
+
self.timeout = timeout
|
40
|
+
self.check_interval = check_interval
|
41
|
+
self.locked = False
|
42
|
+
self.pid = os.getpid()
|
43
|
+
|
44
|
+
def acquire(self, blocking: bool = True) -> bool:
|
45
|
+
"""Acquire the lock.
|
46
|
+
|
47
|
+
Args:
|
48
|
+
blocking: If True, wait for lock. If False, return immediately.
|
49
|
+
|
50
|
+
Returns:
|
51
|
+
True if lock acquired, False if not (non-blocking mode only)
|
52
|
+
|
53
|
+
Raises:
|
54
|
+
LockError: If timeout exceeded (blocking mode)
|
55
|
+
"""
|
56
|
+
start_time = time.time()
|
57
|
+
|
58
|
+
while True:
|
59
|
+
try:
|
60
|
+
# Try to create lock file exclusively
|
61
|
+
fd = os.open(
|
62
|
+
str(self.path), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644
|
63
|
+
)
|
64
|
+
try:
|
65
|
+
# Write our PID to the lock file
|
66
|
+
os.write(fd, str(self.pid).encode())
|
67
|
+
finally:
|
68
|
+
os.close(fd)
|
69
|
+
|
70
|
+
self.locked = True
|
71
|
+
log.debug("Acquired lock", path=str(self.path), pid=self.pid)
|
72
|
+
return True
|
73
|
+
|
74
|
+
except FileExistsError:
|
75
|
+
# Lock file exists, check if holder is still alive
|
76
|
+
if self._check_stale_lock():
|
77
|
+
continue # Retry after removing stale lock
|
78
|
+
|
79
|
+
if not blocking:
|
80
|
+
log.debug("Lock unavailable (non-blocking)", path=str(self.path))
|
81
|
+
return False
|
82
|
+
|
83
|
+
# Check timeout
|
84
|
+
if time.time() - start_time > self.timeout:
|
85
|
+
raise LockError(
|
86
|
+
f"Failed to acquire lock within {self.timeout}s",
|
87
|
+
code="LOCK_TIMEOUT",
|
88
|
+
path=str(self.path),
|
89
|
+
)
|
90
|
+
|
91
|
+
# Wait before retry
|
92
|
+
time.sleep(self.check_interval)
|
93
|
+
|
94
|
+
def release(self) -> None:
|
95
|
+
"""Release the lock.
|
96
|
+
|
97
|
+
Only removes the lock file if we own it.
|
98
|
+
"""
|
99
|
+
if not self.locked:
|
100
|
+
return
|
101
|
+
|
102
|
+
try:
|
103
|
+
# Verify we own the lock before removing
|
104
|
+
if self.path.exists():
|
105
|
+
try:
|
106
|
+
content = self.path.read_text().strip()
|
107
|
+
if content == str(self.pid):
|
108
|
+
self.path.unlink()
|
109
|
+
log.debug("Released lock", path=str(self.path), pid=self.pid)
|
110
|
+
else:
|
111
|
+
log.warning(
|
112
|
+
"Lock owned by different process",
|
113
|
+
path=str(self.path),
|
114
|
+
owner_pid=content,
|
115
|
+
our_pid=self.pid,
|
116
|
+
)
|
117
|
+
except Exception as e:
|
118
|
+
log.warning(
|
119
|
+
"Error checking lock ownership",
|
120
|
+
path=str(self.path),
|
121
|
+
error=str(e),
|
122
|
+
)
|
123
|
+
# Still try to remove if we think we own it
|
124
|
+
if self.locked:
|
125
|
+
self.path.unlink()
|
126
|
+
except FileNotFoundError:
|
127
|
+
pass # Lock already released
|
128
|
+
except Exception as e:
|
129
|
+
log.error("Failed to release lock", path=str(self.path), error=str(e))
|
130
|
+
finally:
|
131
|
+
self.locked = False
|
132
|
+
|
133
|
+
def _check_stale_lock(self) -> bool:
|
134
|
+
"""Check if lock file is stale and remove if so.
|
135
|
+
|
136
|
+
Returns:
|
137
|
+
True if stale lock was removed, False otherwise
|
138
|
+
"""
|
139
|
+
try:
|
140
|
+
content = self.path.read_text().strip()
|
141
|
+
if content.isdigit():
|
142
|
+
lock_pid = int(content)
|
143
|
+
|
144
|
+
# Check if process is still alive
|
145
|
+
try:
|
146
|
+
os.kill(lock_pid, 0)
|
147
|
+
# Process exists
|
148
|
+
return False
|
149
|
+
except ProcessLookupError:
|
150
|
+
# Process doesn't exist, lock is stale
|
151
|
+
log.warning(
|
152
|
+
"Removing stale lock", path=str(self.path), stale_pid=lock_pid
|
153
|
+
)
|
154
|
+
self.path.unlink()
|
155
|
+
return True
|
156
|
+
except Exception as e:
|
157
|
+
log.debug("Error checking stale lock", path=str(self.path), error=str(e))
|
158
|
+
|
159
|
+
return False
|
160
|
+
|
161
|
+
def __enter__(self):
|
162
|
+
"""Context manager entry."""
|
163
|
+
self.acquire()
|
164
|
+
return self
|
165
|
+
|
166
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
167
|
+
"""Context manager exit."""
|
168
|
+
self.release()
|
169
|
+
return False # Don't suppress exceptions
|
170
|
+
|
171
|
+
|
172
|
+
__all__ = [
|
173
|
+
"FileLock",
|
174
|
+
"LockError",
|
175
|
+
]
|
@@ -0,0 +1,179 @@
|
|
1
|
+
"""Safe file operations with error handling and defaults."""
|
2
|
+
|
3
|
+
from pathlib import Path
|
4
|
+
import shutil
|
5
|
+
|
6
|
+
from provide.foundation.logger import get_logger
|
7
|
+
|
8
|
+
log = get_logger(__name__)
|
9
|
+
|
10
|
+
|
11
|
+
def safe_read(
|
12
|
+
path: Path | str,
|
13
|
+
default: bytes | None = None,
|
14
|
+
encoding: str | None = None,
|
15
|
+
) -> bytes | str | None:
|
16
|
+
"""Read file safely, returning default if not found.
|
17
|
+
|
18
|
+
Args:
|
19
|
+
path: File to read
|
20
|
+
default: Value to return if file doesn't exist
|
21
|
+
encoding: If provided, decode bytes to str
|
22
|
+
|
23
|
+
Returns:
|
24
|
+
File contents or default value
|
25
|
+
"""
|
26
|
+
path = Path(path)
|
27
|
+
|
28
|
+
try:
|
29
|
+
data = path.read_bytes()
|
30
|
+
if encoding:
|
31
|
+
return data.decode(encoding)
|
32
|
+
return data
|
33
|
+
except FileNotFoundError:
|
34
|
+
log.debug("File not found, returning default", path=str(path))
|
35
|
+
if default is not None and encoding:
|
36
|
+
return default.decode(encoding) if isinstance(default, bytes) else default
|
37
|
+
return default
|
38
|
+
except Exception as e:
|
39
|
+
log.warning("Failed to read file", path=str(path), error=str(e))
|
40
|
+
return default
|
41
|
+
|
42
|
+
|
43
|
+
def safe_read_text(
|
44
|
+
path: Path | str,
|
45
|
+
default: str = "",
|
46
|
+
encoding: str = "utf-8",
|
47
|
+
) -> str:
|
48
|
+
"""Read text file safely with default.
|
49
|
+
|
50
|
+
Args:
|
51
|
+
path: File to read
|
52
|
+
default: Default text if file doesn't exist
|
53
|
+
encoding: Text encoding
|
54
|
+
|
55
|
+
Returns:
|
56
|
+
File contents or default text
|
57
|
+
"""
|
58
|
+
result = safe_read(path, default=default.encode(encoding), encoding=encoding)
|
59
|
+
return result if isinstance(result, str) else default
|
60
|
+
|
61
|
+
|
62
|
+
def safe_delete(
|
63
|
+
path: Path | str,
|
64
|
+
missing_ok: bool = True,
|
65
|
+
) -> bool:
|
66
|
+
"""Delete file safely.
|
67
|
+
|
68
|
+
Args:
|
69
|
+
path: File to delete
|
70
|
+
missing_ok: If True, don't raise error if file doesn't exist
|
71
|
+
|
72
|
+
Returns:
|
73
|
+
True if deleted, False if didn't exist
|
74
|
+
|
75
|
+
Raises:
|
76
|
+
OSError: If deletion fails and file exists
|
77
|
+
"""
|
78
|
+
path = Path(path)
|
79
|
+
|
80
|
+
try:
|
81
|
+
path.unlink()
|
82
|
+
log.debug("Deleted file", path=str(path))
|
83
|
+
return True
|
84
|
+
except FileNotFoundError:
|
85
|
+
if missing_ok:
|
86
|
+
log.debug("File already absent", path=str(path))
|
87
|
+
return False
|
88
|
+
raise
|
89
|
+
except Exception as e:
|
90
|
+
log.error("Failed to delete file", path=str(path), error=str(e))
|
91
|
+
raise
|
92
|
+
|
93
|
+
|
94
|
+
def safe_move(
|
95
|
+
src: Path | str,
|
96
|
+
dst: Path | str,
|
97
|
+
overwrite: bool = False,
|
98
|
+
) -> None:
|
99
|
+
"""Move file safely with optional overwrite.
|
100
|
+
|
101
|
+
Args:
|
102
|
+
src: Source file path
|
103
|
+
dst: Destination file path
|
104
|
+
overwrite: Whether to overwrite existing destination
|
105
|
+
|
106
|
+
Raises:
|
107
|
+
FileNotFoundError: If source doesn't exist
|
108
|
+
FileExistsError: If destination exists and overwrite=False
|
109
|
+
OSError: If move operation fails
|
110
|
+
"""
|
111
|
+
src = Path(src)
|
112
|
+
dst = Path(dst)
|
113
|
+
|
114
|
+
if not src.exists():
|
115
|
+
raise FileNotFoundError(f"Source file does not exist: {src}")
|
116
|
+
|
117
|
+
if dst.exists() and not overwrite:
|
118
|
+
raise FileExistsError(f"Destination already exists: {dst}")
|
119
|
+
|
120
|
+
# Ensure destination directory exists
|
121
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
122
|
+
|
123
|
+
try:
|
124
|
+
shutil.move(str(src), str(dst))
|
125
|
+
log.debug("Moved file", src=str(src), dst=str(dst))
|
126
|
+
except Exception as e:
|
127
|
+
log.error("Failed to move file", src=str(src), dst=str(dst), error=str(e))
|
128
|
+
raise
|
129
|
+
|
130
|
+
|
131
|
+
def safe_copy(
|
132
|
+
src: Path | str,
|
133
|
+
dst: Path | str,
|
134
|
+
overwrite: bool = False,
|
135
|
+
preserve_mode: bool = True,
|
136
|
+
) -> None:
|
137
|
+
"""Copy file safely with metadata preservation.
|
138
|
+
|
139
|
+
Args:
|
140
|
+
src: Source file path
|
141
|
+
dst: Destination file path
|
142
|
+
overwrite: Whether to overwrite existing destination
|
143
|
+
preserve_mode: Whether to preserve file permissions
|
144
|
+
|
145
|
+
Raises:
|
146
|
+
FileNotFoundError: If source doesn't exist
|
147
|
+
FileExistsError: If destination exists and overwrite=False
|
148
|
+
OSError: If copy operation fails
|
149
|
+
"""
|
150
|
+
src = Path(src)
|
151
|
+
dst = Path(dst)
|
152
|
+
|
153
|
+
if not src.exists():
|
154
|
+
raise FileNotFoundError(f"Source file does not exist: {src}")
|
155
|
+
|
156
|
+
if dst.exists() and not overwrite:
|
157
|
+
raise FileExistsError(f"Destination already exists: {dst}")
|
158
|
+
|
159
|
+
# Ensure destination directory exists
|
160
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
161
|
+
|
162
|
+
try:
|
163
|
+
if preserve_mode:
|
164
|
+
shutil.copy2(str(src), str(dst))
|
165
|
+
else:
|
166
|
+
shutil.copy(str(src), str(dst))
|
167
|
+
log.debug("Copied file", src=str(src), dst=str(dst))
|
168
|
+
except Exception as e:
|
169
|
+
log.error("Failed to copy file", src=str(src), dst=str(dst), error=str(e))
|
170
|
+
raise
|
171
|
+
|
172
|
+
|
173
|
+
__all__ = [
|
174
|
+
"safe_copy",
|
175
|
+
"safe_delete",
|
176
|
+
"safe_move",
|
177
|
+
"safe_read",
|
178
|
+
"safe_read_text",
|
179
|
+
]
|
@@ -0,0 +1,170 @@
|
|
1
|
+
"""File utility functions."""
|
2
|
+
|
3
|
+
from datetime import datetime
|
4
|
+
from pathlib import Path
|
5
|
+
import shutil
|
6
|
+
|
7
|
+
from provide.foundation.logger import get_logger
|
8
|
+
|
9
|
+
log = get_logger(__name__)
|
10
|
+
|
11
|
+
|
12
|
+
def get_size(path: Path | str) -> int:
|
13
|
+
"""Get file size in bytes, 0 if not exists.
|
14
|
+
|
15
|
+
Args:
|
16
|
+
path: File path
|
17
|
+
|
18
|
+
Returns:
|
19
|
+
Size in bytes, or 0 if file doesn't exist
|
20
|
+
"""
|
21
|
+
path = Path(path)
|
22
|
+
|
23
|
+
try:
|
24
|
+
return path.stat().st_size
|
25
|
+
except FileNotFoundError:
|
26
|
+
return 0
|
27
|
+
except Exception as e:
|
28
|
+
log.warning("Failed to get file size", path=str(path), error=str(e))
|
29
|
+
return 0
|
30
|
+
|
31
|
+
|
32
|
+
def get_mtime(path: Path | str) -> float | None:
|
33
|
+
"""Get modification time, None if not exists.
|
34
|
+
|
35
|
+
Args:
|
36
|
+
path: File path
|
37
|
+
|
38
|
+
Returns:
|
39
|
+
Modification time as timestamp, or None if doesn't exist
|
40
|
+
"""
|
41
|
+
path = Path(path)
|
42
|
+
|
43
|
+
try:
|
44
|
+
return path.stat().st_mtime
|
45
|
+
except FileNotFoundError:
|
46
|
+
return None
|
47
|
+
except Exception as e:
|
48
|
+
log.warning("Failed to get modification time", path=str(path), error=str(e))
|
49
|
+
return None
|
50
|
+
|
51
|
+
|
52
|
+
def touch(
|
53
|
+
path: Path | str,
|
54
|
+
mode: int = 0o644,
|
55
|
+
exist_ok: bool = True,
|
56
|
+
) -> None:
|
57
|
+
"""Create empty file or update timestamp.
|
58
|
+
|
59
|
+
Args:
|
60
|
+
path: File path
|
61
|
+
mode: File permissions for new files
|
62
|
+
exist_ok: If False, raise error if file exists
|
63
|
+
|
64
|
+
Raises:
|
65
|
+
FileExistsError: If exist_ok=False and file exists
|
66
|
+
"""
|
67
|
+
path = Path(path)
|
68
|
+
|
69
|
+
if path.exists() and not exist_ok:
|
70
|
+
raise FileExistsError(f"File already exists: {path}")
|
71
|
+
|
72
|
+
# Ensure parent directory exists
|
73
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
74
|
+
|
75
|
+
# Touch the file
|
76
|
+
path.touch(mode=mode, exist_ok=exist_ok)
|
77
|
+
log.debug("Touched file", path=str(path))
|
78
|
+
|
79
|
+
|
80
|
+
def find_files(
|
81
|
+
pattern: str,
|
82
|
+
root: Path | str = ".",
|
83
|
+
recursive: bool = True,
|
84
|
+
) -> list[Path]:
|
85
|
+
"""Find files matching pattern.
|
86
|
+
|
87
|
+
Args:
|
88
|
+
pattern: Glob pattern (e.g., "*.py", "**/*.json")
|
89
|
+
root: Root directory to search from
|
90
|
+
recursive: If True, search recursively
|
91
|
+
|
92
|
+
Returns:
|
93
|
+
List of matching file paths
|
94
|
+
"""
|
95
|
+
root = Path(root)
|
96
|
+
|
97
|
+
if not root.exists():
|
98
|
+
log.warning("Search root doesn't exist", root=str(root))
|
99
|
+
return []
|
100
|
+
|
101
|
+
# Use glob or rglob based on recursive flag
|
102
|
+
if recursive and "**" not in pattern:
|
103
|
+
pattern = f"**/{pattern}"
|
104
|
+
|
105
|
+
try:
|
106
|
+
if recursive:
|
107
|
+
matches = list(root.glob(pattern))
|
108
|
+
else:
|
109
|
+
matches = list(root.glob(pattern.lstrip("/")))
|
110
|
+
|
111
|
+
# Filter to files only
|
112
|
+
files = [p for p in matches if p.is_file()]
|
113
|
+
|
114
|
+
log.debug("Found files", pattern=pattern, root=str(root), count=len(files))
|
115
|
+
return files
|
116
|
+
except Exception as e:
|
117
|
+
log.error("Failed to find files", pattern=pattern, root=str(root), error=str(e))
|
118
|
+
return []
|
119
|
+
|
120
|
+
|
121
|
+
def backup_file(
|
122
|
+
path: Path | str,
|
123
|
+
suffix: str = ".bak",
|
124
|
+
timestamp: bool = False,
|
125
|
+
) -> Path | None:
|
126
|
+
"""Create backup copy of file.
|
127
|
+
|
128
|
+
Args:
|
129
|
+
path: File to backup
|
130
|
+
suffix: Backup suffix
|
131
|
+
timestamp: If True, add timestamp to backup name
|
132
|
+
|
133
|
+
Returns:
|
134
|
+
Path to backup file, or None if source doesn't exist
|
135
|
+
"""
|
136
|
+
path = Path(path)
|
137
|
+
|
138
|
+
if not path.exists():
|
139
|
+
log.debug("Source file doesn't exist, no backup created", path=str(path))
|
140
|
+
return None
|
141
|
+
|
142
|
+
# Build backup filename
|
143
|
+
if timestamp:
|
144
|
+
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
145
|
+
backup_path = path.with_suffix(f".{ts}{suffix}")
|
146
|
+
else:
|
147
|
+
backup_path = path.with_suffix(path.suffix + suffix)
|
148
|
+
|
149
|
+
# Find unique name if backup already exists
|
150
|
+
counter = 1
|
151
|
+
while backup_path.exists():
|
152
|
+
backup_path = path.with_suffix(f"{path.suffix}{suffix}.{counter}")
|
153
|
+
counter += 1
|
154
|
+
|
155
|
+
try:
|
156
|
+
shutil.copy2(str(path), str(backup_path))
|
157
|
+
log.debug("Created backup", source=str(path), backup=str(backup_path))
|
158
|
+
return backup_path
|
159
|
+
except Exception as e:
|
160
|
+
log.error("Failed to create backup", path=str(path), error=str(e))
|
161
|
+
return None
|
162
|
+
|
163
|
+
|
164
|
+
__all__ = [
|
165
|
+
"backup_file",
|
166
|
+
"find_files",
|
167
|
+
"get_mtime",
|
168
|
+
"get_size",
|
169
|
+
"touch",
|
170
|
+
]
|
@@ -0,0 +1,88 @@
|
|
1
|
+
"""
|
2
|
+
Provide Foundation Hub - Component and Command Coordination System
|
3
|
+
===================================================================
|
4
|
+
|
5
|
+
The hub module provides a unified system for registering, discovering, and
|
6
|
+
managing components and CLI commands across the provide-io ecosystem.
|
7
|
+
|
8
|
+
Key Features:
|
9
|
+
- Multi-dimensional component registry
|
10
|
+
- CLI command registration and discovery
|
11
|
+
- Entry point discovery
|
12
|
+
- Integration with Click framework
|
13
|
+
- Type-safe decorators using Python 3.11+ features
|
14
|
+
|
15
|
+
Example Usage:
|
16
|
+
>>> from provide.foundation.hub import Hub, register_command
|
17
|
+
>>>
|
18
|
+
>>> class MyResource:
|
19
|
+
>>> def __init__(self, name: str):
|
20
|
+
>>> self.name = name
|
21
|
+
>>>
|
22
|
+
>>> @register_command("init")
|
23
|
+
>>> def init_command():
|
24
|
+
>>> pass
|
25
|
+
>>>
|
26
|
+
>>> hub = Hub()
|
27
|
+
>>> hub.add_component(MyResource, name="my_resource", version="1.0.0")
|
28
|
+
>>> resource_class = hub.get_component("my_resource")
|
29
|
+
>>> command = hub.get_command("init")
|
30
|
+
"""
|
31
|
+
|
32
|
+
# Core hub components (always available)
|
33
|
+
from provide.foundation.hub.components import (
|
34
|
+
ComponentCategory,
|
35
|
+
get_component_registry,
|
36
|
+
)
|
37
|
+
from provide.foundation.hub.decorators import register_command
|
38
|
+
from provide.foundation.hub.manager import (
|
39
|
+
Hub,
|
40
|
+
clear_hub,
|
41
|
+
get_hub,
|
42
|
+
)
|
43
|
+
|
44
|
+
|
45
|
+
# CLI features (require click) - lazy loaded
|
46
|
+
def get_click_commands():
|
47
|
+
"""
|
48
|
+
Get CLI command building functions.
|
49
|
+
|
50
|
+
Returns:
|
51
|
+
Module with click command building functionality.
|
52
|
+
|
53
|
+
Raises:
|
54
|
+
ImportError: If click is not available.
|
55
|
+
"""
|
56
|
+
try:
|
57
|
+
from provide.foundation.hub.commands import build_click_command
|
58
|
+
|
59
|
+
return {"build_click_command": build_click_command}
|
60
|
+
except ImportError as e:
|
61
|
+
if "click" in str(e):
|
62
|
+
raise ImportError(
|
63
|
+
"CLI command building requires optional dependencies. Install with: "
|
64
|
+
"pip install 'provide-foundation[cli]'"
|
65
|
+
) from e
|
66
|
+
raise
|
67
|
+
|
68
|
+
|
69
|
+
def __getattr__(name: str):
|
70
|
+
"""Support lazy loading of CLI-dependent features."""
|
71
|
+
if name == "build_click_command":
|
72
|
+
return get_click_commands()["build_click_command"]
|
73
|
+
raise AttributeError(f"module '{__name__}' has no attribute '{name}'")
|
74
|
+
|
75
|
+
|
76
|
+
__all__ = [
|
77
|
+
# Components
|
78
|
+
"get_component_registry",
|
79
|
+
"ComponentCategory",
|
80
|
+
# Hub
|
81
|
+
"Hub",
|
82
|
+
"clear_hub",
|
83
|
+
"get_hub",
|
84
|
+
# Commands (core)
|
85
|
+
"register_command",
|
86
|
+
# CLI features (lazy loaded)
|
87
|
+
"get_click_commands",
|
88
|
+
]
|