putplace 0.4.1__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.
Potentially problematic release.
This version of putplace might be problematic. Click here for more details.
- putplace/__init__.py +5 -0
- putplace/auth.py +279 -0
- putplace/config.py +167 -0
- putplace/database.py +387 -0
- putplace/main.py +3048 -0
- putplace/models.py +179 -0
- putplace/ppclient.py +586 -0
- putplace/ppserver.py +453 -0
- putplace/scripts/__init__.py +1 -0
- putplace/scripts/create_api_key.py +119 -0
- putplace/storage.py +456 -0
- putplace/user_auth.py +52 -0
- putplace/version.py +6 -0
- putplace-0.4.1.dist-info/METADATA +346 -0
- putplace-0.4.1.dist-info/RECORD +17 -0
- putplace-0.4.1.dist-info/WHEEL +4 -0
- putplace-0.4.1.dist-info/entry_points.txt +3 -0
putplace/ppclient.py
ADDED
|
@@ -0,0 +1,586 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""PutPlace Client - Process files and directories, send file metadata to the server."""
|
|
3
|
+
|
|
4
|
+
import configargparse
|
|
5
|
+
import configparser
|
|
6
|
+
import hashlib
|
|
7
|
+
import os
|
|
8
|
+
import signal
|
|
9
|
+
import socket
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
from rich.console import Console
|
|
16
|
+
from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, TaskProgressColumn
|
|
17
|
+
|
|
18
|
+
console = Console()
|
|
19
|
+
|
|
20
|
+
# Global flag for interrupt handling
|
|
21
|
+
interrupted = False
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def signal_handler(signum, frame):
|
|
25
|
+
"""Handle Ctrl-C signal gracefully."""
|
|
26
|
+
global interrupted
|
|
27
|
+
interrupted = True
|
|
28
|
+
console.print("\n[yellow]⚠ Interrupt received, finishing current file and exiting...[/yellow]")
|
|
29
|
+
console.print("[dim](Press Ctrl-C again to force quit)[/dim]")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def get_exclude_patterns_from_config(config_files: list[str]) -> list[str]:
|
|
33
|
+
"""Manually extract exclude patterns from config files.
|
|
34
|
+
|
|
35
|
+
This is needed because configargparse doesn't properly handle
|
|
36
|
+
multiple values with action="append" in config files.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
config_files: List of config file paths to check
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
List of exclude patterns found in config files
|
|
43
|
+
"""
|
|
44
|
+
exclude_patterns = []
|
|
45
|
+
|
|
46
|
+
for config_file in config_files:
|
|
47
|
+
# Expand ~ in path
|
|
48
|
+
config_path = Path(config_file).expanduser()
|
|
49
|
+
|
|
50
|
+
if not config_path.exists():
|
|
51
|
+
continue
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
# Read the file manually to get all exclude patterns
|
|
55
|
+
with open(config_path, 'r') as f:
|
|
56
|
+
for line in f:
|
|
57
|
+
line = line.strip()
|
|
58
|
+
if line.startswith('exclude'):
|
|
59
|
+
# Parse "exclude = value"
|
|
60
|
+
parts = line.split('=', 1)
|
|
61
|
+
if len(parts) == 2:
|
|
62
|
+
pattern = parts[1].strip()
|
|
63
|
+
if pattern:
|
|
64
|
+
exclude_patterns.append(pattern)
|
|
65
|
+
except Exception:
|
|
66
|
+
# Ignore errors reading config files
|
|
67
|
+
pass
|
|
68
|
+
|
|
69
|
+
return exclude_patterns
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def get_hostname() -> str:
|
|
73
|
+
"""Get the current hostname."""
|
|
74
|
+
return socket.gethostname()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def get_ip_address() -> str:
|
|
78
|
+
"""Get the primary IP address of this machine."""
|
|
79
|
+
try:
|
|
80
|
+
# Connect to a public DNS server to determine the local IP
|
|
81
|
+
# This doesn't actually send data, just determines routing
|
|
82
|
+
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
|
|
83
|
+
s.connect(("8.8.8.8", 80))
|
|
84
|
+
return s.getsockname()[0]
|
|
85
|
+
except Exception:
|
|
86
|
+
return "127.0.0.1"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def calculate_sha256(filepath: Path, chunk_size: int = 8192) -> Optional[str]:
|
|
90
|
+
"""Calculate SHA256 hash of a file.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
filepath: Path to the file
|
|
94
|
+
chunk_size: Size of chunks to read (default: 8KB)
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
Hexadecimal SHA256 hash or None if file cannot be read
|
|
98
|
+
"""
|
|
99
|
+
sha256_hash = hashlib.sha256()
|
|
100
|
+
try:
|
|
101
|
+
with open(filepath, "rb") as f:
|
|
102
|
+
while chunk := f.read(chunk_size):
|
|
103
|
+
sha256_hash.update(chunk)
|
|
104
|
+
return sha256_hash.hexdigest()
|
|
105
|
+
except (IOError, OSError) as e:
|
|
106
|
+
console.print(f"[yellow]Warning: Cannot read {filepath}: {e}[/yellow]")
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def get_file_stats(filepath: Path) -> Optional[dict]:
|
|
111
|
+
"""Get file stat information.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
filepath: Path to the file
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
Dictionary with stat information or None if stat fails
|
|
118
|
+
"""
|
|
119
|
+
try:
|
|
120
|
+
stat_info = os.stat(filepath)
|
|
121
|
+
return {
|
|
122
|
+
"file_size": stat_info.st_size,
|
|
123
|
+
"file_mode": stat_info.st_mode,
|
|
124
|
+
"file_uid": stat_info.st_uid,
|
|
125
|
+
"file_gid": stat_info.st_gid,
|
|
126
|
+
"file_mtime": stat_info.st_mtime,
|
|
127
|
+
"file_atime": stat_info.st_atime,
|
|
128
|
+
"file_ctime": stat_info.st_ctime,
|
|
129
|
+
}
|
|
130
|
+
except (IOError, OSError) as e:
|
|
131
|
+
console.print(f"[yellow]Warning: Cannot stat {filepath}: {e}[/yellow]")
|
|
132
|
+
return None
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def matches_exclude_pattern(path: Path, base_path: Path, patterns: list[str]) -> bool:
|
|
136
|
+
"""Check if a path matches any exclude pattern.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
path: Path to check
|
|
140
|
+
base_path: Base path for relative matching
|
|
141
|
+
patterns: List of exclude patterns
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
True if path matches any pattern
|
|
145
|
+
"""
|
|
146
|
+
if not patterns:
|
|
147
|
+
return False
|
|
148
|
+
|
|
149
|
+
try:
|
|
150
|
+
relative_path = path.relative_to(base_path)
|
|
151
|
+
except ValueError:
|
|
152
|
+
# Path is not relative to base_path
|
|
153
|
+
return False
|
|
154
|
+
|
|
155
|
+
relative_str = str(relative_path)
|
|
156
|
+
path_parts = relative_path.parts
|
|
157
|
+
|
|
158
|
+
for pattern in patterns:
|
|
159
|
+
# Check if pattern matches the full relative path
|
|
160
|
+
if relative_str == pattern:
|
|
161
|
+
return True
|
|
162
|
+
|
|
163
|
+
# Check if pattern matches any part of the path
|
|
164
|
+
if pattern in path_parts:
|
|
165
|
+
return True
|
|
166
|
+
|
|
167
|
+
# Check for wildcard patterns
|
|
168
|
+
if "*" in pattern:
|
|
169
|
+
import fnmatch
|
|
170
|
+
|
|
171
|
+
if fnmatch.fnmatch(relative_str, pattern):
|
|
172
|
+
return True
|
|
173
|
+
|
|
174
|
+
# Check each part for pattern match
|
|
175
|
+
for part in path_parts:
|
|
176
|
+
if fnmatch.fnmatch(part, pattern):
|
|
177
|
+
return True
|
|
178
|
+
|
|
179
|
+
return False
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def upload_file_content(
|
|
183
|
+
filepath: Path,
|
|
184
|
+
sha256: str,
|
|
185
|
+
hostname: str,
|
|
186
|
+
upload_url: str,
|
|
187
|
+
base_url: str,
|
|
188
|
+
api_key: Optional[str] = None,
|
|
189
|
+
) -> bool:
|
|
190
|
+
"""Upload file content to server.
|
|
191
|
+
|
|
192
|
+
Args:
|
|
193
|
+
filepath: Path to the file to upload
|
|
194
|
+
sha256: SHA256 hash of the file
|
|
195
|
+
hostname: Hostname where file is located
|
|
196
|
+
upload_url: Upload URL path (e.g., /upload_file/{sha256})
|
|
197
|
+
base_url: Base API URL
|
|
198
|
+
api_key: Optional API key for authentication
|
|
199
|
+
|
|
200
|
+
Returns:
|
|
201
|
+
True if upload successful, False otherwise
|
|
202
|
+
"""
|
|
203
|
+
try:
|
|
204
|
+
# Construct full URL
|
|
205
|
+
full_url = f"{base_url.rstrip('/')}{upload_url}"
|
|
206
|
+
|
|
207
|
+
# Add query parameters for hostname and filepath
|
|
208
|
+
params = {
|
|
209
|
+
"hostname": hostname,
|
|
210
|
+
"filepath": str(filepath.absolute()),
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
# Prepare headers
|
|
214
|
+
headers = {}
|
|
215
|
+
if api_key:
|
|
216
|
+
headers["X-API-Key"] = api_key
|
|
217
|
+
|
|
218
|
+
# Open file and upload
|
|
219
|
+
with open(filepath, "rb") as f:
|
|
220
|
+
files = {"file": (filepath.name, f, "application/octet-stream")}
|
|
221
|
+
response = httpx.post(full_url, files=files, params=params, headers=headers, timeout=30.0)
|
|
222
|
+
response.raise_for_status()
|
|
223
|
+
|
|
224
|
+
console.print(f"[green]✓ Uploaded: {filepath.name}[/green]")
|
|
225
|
+
return True
|
|
226
|
+
|
|
227
|
+
except httpx.HTTPError as e:
|
|
228
|
+
console.print(f"[red]Failed to upload {filepath.name}: {e}[/red]")
|
|
229
|
+
return False
|
|
230
|
+
except (IOError, OSError) as e:
|
|
231
|
+
console.print(f"[red]Cannot read file {filepath}: {e}[/red]")
|
|
232
|
+
return False
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def process_path(
|
|
236
|
+
start_path: Path,
|
|
237
|
+
exclude_patterns: list[str],
|
|
238
|
+
hostname: str,
|
|
239
|
+
ip_address: str,
|
|
240
|
+
api_url: str,
|
|
241
|
+
dry_run: bool = False,
|
|
242
|
+
api_key: Optional[str] = None,
|
|
243
|
+
) -> tuple[int, int, int, int]:
|
|
244
|
+
"""Process a file or directory and send file metadata to server.
|
|
245
|
+
|
|
246
|
+
Args:
|
|
247
|
+
start_path: File or directory path to process
|
|
248
|
+
exclude_patterns: List of patterns to exclude (only applies to directories)
|
|
249
|
+
hostname: Hostname to send
|
|
250
|
+
ip_address: IP address to send
|
|
251
|
+
api_url: API endpoint URL (e.g., http://localhost:8000/put_file)
|
|
252
|
+
dry_run: If True, don't actually send data to server
|
|
253
|
+
api_key: Optional API key for authentication
|
|
254
|
+
|
|
255
|
+
Returns:
|
|
256
|
+
Tuple of (total_files, successful, failed, uploaded)
|
|
257
|
+
"""
|
|
258
|
+
global interrupted
|
|
259
|
+
|
|
260
|
+
if not start_path.exists():
|
|
261
|
+
console.print(f"[red]Error: Path does not exist: {start_path}[/red]")
|
|
262
|
+
return 0, 0, 0, 0
|
|
263
|
+
|
|
264
|
+
# Collect all files first to show progress
|
|
265
|
+
files_to_process = []
|
|
266
|
+
|
|
267
|
+
if start_path.is_file():
|
|
268
|
+
# Single file mode
|
|
269
|
+
console.print(f"[cyan]Processing file: {start_path}[/cyan]")
|
|
270
|
+
files_to_process.append(start_path)
|
|
271
|
+
elif start_path.is_dir():
|
|
272
|
+
# Directory mode - scan recursively
|
|
273
|
+
console.print(f"[cyan]Scanning directory: {start_path}[/cyan]")
|
|
274
|
+
|
|
275
|
+
for filepath in start_path.rglob("*"):
|
|
276
|
+
if not filepath.is_file():
|
|
277
|
+
continue
|
|
278
|
+
|
|
279
|
+
# Check exclude patterns
|
|
280
|
+
if matches_exclude_pattern(filepath, start_path, exclude_patterns):
|
|
281
|
+
console.print(f"[dim]Excluded: {filepath.relative_to(start_path)}[/dim]")
|
|
282
|
+
continue
|
|
283
|
+
|
|
284
|
+
files_to_process.append(filepath)
|
|
285
|
+
else:
|
|
286
|
+
console.print(f"[red]Error: Path is neither a file nor a directory: {start_path}[/red]")
|
|
287
|
+
return 0, 0, 0, 0
|
|
288
|
+
|
|
289
|
+
if not files_to_process:
|
|
290
|
+
console.print("[yellow]No files to process[/yellow]")
|
|
291
|
+
return 0, 0, 0, 0
|
|
292
|
+
|
|
293
|
+
console.print(f"[green]Found {len(files_to_process)} files to process[/green]")
|
|
294
|
+
|
|
295
|
+
total_files = len(files_to_process)
|
|
296
|
+
successful = 0
|
|
297
|
+
failed = 0
|
|
298
|
+
uploaded = 0
|
|
299
|
+
|
|
300
|
+
# Process files with progress bar
|
|
301
|
+
with Progress(
|
|
302
|
+
SpinnerColumn(),
|
|
303
|
+
TextColumn("[progress.description]{task.description}"),
|
|
304
|
+
BarColumn(),
|
|
305
|
+
TaskProgressColumn(),
|
|
306
|
+
console=console,
|
|
307
|
+
) as progress:
|
|
308
|
+
task = progress.add_task("[cyan]Processing files...", total=total_files)
|
|
309
|
+
|
|
310
|
+
for filepath in files_to_process:
|
|
311
|
+
# Check for interrupt
|
|
312
|
+
if interrupted:
|
|
313
|
+
console.print("\n[yellow]Processing interrupted by user[/yellow]")
|
|
314
|
+
break
|
|
315
|
+
|
|
316
|
+
progress.update(
|
|
317
|
+
task, description=f"[cyan]Processing: {filepath.name[:30]}..."
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
# Calculate SHA256
|
|
321
|
+
sha256 = calculate_sha256(filepath)
|
|
322
|
+
if sha256 is None:
|
|
323
|
+
failed += 1
|
|
324
|
+
progress.advance(task)
|
|
325
|
+
continue
|
|
326
|
+
|
|
327
|
+
# Get file stats
|
|
328
|
+
file_stats = get_file_stats(filepath)
|
|
329
|
+
if file_stats is None:
|
|
330
|
+
failed += 1
|
|
331
|
+
progress.advance(task)
|
|
332
|
+
continue
|
|
333
|
+
|
|
334
|
+
# Prepare metadata
|
|
335
|
+
metadata = {
|
|
336
|
+
"filepath": str(filepath.absolute()),
|
|
337
|
+
"hostname": hostname,
|
|
338
|
+
"ip_address": ip_address,
|
|
339
|
+
"sha256": sha256,
|
|
340
|
+
**file_stats, # Unpack stat information
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
# Send to server
|
|
344
|
+
if dry_run:
|
|
345
|
+
console.print(f"[dim]Dry run: Would send {filepath.name}[/dim]")
|
|
346
|
+
successful += 1
|
|
347
|
+
else:
|
|
348
|
+
try:
|
|
349
|
+
# Prepare headers with API key if provided
|
|
350
|
+
headers = {}
|
|
351
|
+
if api_key:
|
|
352
|
+
headers["X-API-Key"] = api_key
|
|
353
|
+
|
|
354
|
+
response = httpx.post(api_url, json=metadata, headers=headers, timeout=10.0)
|
|
355
|
+
response.raise_for_status()
|
|
356
|
+
data = response.json()
|
|
357
|
+
successful += 1
|
|
358
|
+
|
|
359
|
+
# Check if file upload is required
|
|
360
|
+
if data.get("upload_required", False):
|
|
361
|
+
upload_url = data.get("upload_url")
|
|
362
|
+
if upload_url:
|
|
363
|
+
# Extract base URL from api_url (remove /put_file suffix)
|
|
364
|
+
base_url = api_url.rsplit("/", 1)[0]
|
|
365
|
+
if upload_file_content(filepath, sha256, hostname, upload_url, base_url, api_key):
|
|
366
|
+
uploaded += 1
|
|
367
|
+
else:
|
|
368
|
+
console.print(f"[dim]Skipped upload (deduplicated): {filepath.name}[/dim]")
|
|
369
|
+
|
|
370
|
+
except httpx.HTTPError as e:
|
|
371
|
+
console.print(
|
|
372
|
+
f"[red]Failed to send {filepath.name}: {e}[/red]"
|
|
373
|
+
)
|
|
374
|
+
failed += 1
|
|
375
|
+
|
|
376
|
+
progress.advance(task)
|
|
377
|
+
|
|
378
|
+
return total_files, successful, failed, uploaded
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def main() -> int:
|
|
382
|
+
"""Main entry point."""
|
|
383
|
+
global interrupted
|
|
384
|
+
|
|
385
|
+
parser = configargparse.ArgumentParser(
|
|
386
|
+
default_config_files=["~/ppclient.conf", "ppclient.conf"],
|
|
387
|
+
ignore_unknown_config_file_keys=True,
|
|
388
|
+
description="Process files or directories and send file metadata to PutPlace server",
|
|
389
|
+
formatter_class=configargparse.RawDescriptionHelpFormatter,
|
|
390
|
+
epilog="""
|
|
391
|
+
Examples:
|
|
392
|
+
# Process a single file
|
|
393
|
+
%(prog)s --path /var/log/app.log
|
|
394
|
+
|
|
395
|
+
# Scan current directory
|
|
396
|
+
%(prog)s --path .
|
|
397
|
+
|
|
398
|
+
# Scan specific directory
|
|
399
|
+
%(prog)s --path /var/log
|
|
400
|
+
|
|
401
|
+
# Exclude .git directories and *.log files (when scanning directories)
|
|
402
|
+
%(prog)s --path /var/log --exclude .git --exclude "*.log"
|
|
403
|
+
|
|
404
|
+
# Dry run (don't send to server)
|
|
405
|
+
%(prog)s --path /var/log --dry-run
|
|
406
|
+
|
|
407
|
+
# Use custom server URL
|
|
408
|
+
%(prog)s --path /var/log --url http://localhost:8080/put_file
|
|
409
|
+
|
|
410
|
+
# Use config file
|
|
411
|
+
%(prog)s --path /var/log --config myconfig.conf
|
|
412
|
+
|
|
413
|
+
Config file format (INI style):
|
|
414
|
+
[DEFAULT]
|
|
415
|
+
url = http://remote-server:8000/put_file
|
|
416
|
+
api-key = your-api-key-here
|
|
417
|
+
exclude = .git
|
|
418
|
+
exclude = *.log
|
|
419
|
+
hostname = myserver
|
|
420
|
+
|
|
421
|
+
Authentication:
|
|
422
|
+
# Option 1: Command line
|
|
423
|
+
%(prog)s --path /var/log --api-key YOUR_API_KEY
|
|
424
|
+
|
|
425
|
+
# Option 2: Environment variable
|
|
426
|
+
export PUTPLACE_API_KEY=YOUR_API_KEY
|
|
427
|
+
%(prog)s --path /var/log
|
|
428
|
+
|
|
429
|
+
# Option 3: Config file (~/ppclient.conf)
|
|
430
|
+
echo "api-key = YOUR_API_KEY" >> ~/ppclient.conf
|
|
431
|
+
%(prog)s --path /var/log
|
|
432
|
+
""",
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
parser.add_argument(
|
|
436
|
+
"-c",
|
|
437
|
+
"--config",
|
|
438
|
+
is_config_file=True,
|
|
439
|
+
help="Config file path (default: ~/ppclient.conf or ppclient.conf)",
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
parser.add_argument(
|
|
443
|
+
"--path",
|
|
444
|
+
"-p",
|
|
445
|
+
type=Path,
|
|
446
|
+
required=True,
|
|
447
|
+
help="File or directory path to process (directories are scanned recursively)",
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
parser.add_argument(
|
|
451
|
+
"--exclude",
|
|
452
|
+
"-e",
|
|
453
|
+
action="append",
|
|
454
|
+
dest="exclude_list",
|
|
455
|
+
default=None,
|
|
456
|
+
help="Exclude pattern (can be specified multiple times). "
|
|
457
|
+
"Supports wildcards like *.log or directory names like .git",
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
parser.add_argument(
|
|
461
|
+
"--url",
|
|
462
|
+
default="http://localhost:8000/put_file",
|
|
463
|
+
help="API endpoint URL (default: http://localhost:8000/put_file)",
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
parser.add_argument(
|
|
467
|
+
"--hostname",
|
|
468
|
+
default=None,
|
|
469
|
+
help="Override hostname (default: auto-detect)",
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
parser.add_argument(
|
|
473
|
+
"--ip",
|
|
474
|
+
default=None,
|
|
475
|
+
help="Override IP address (default: auto-detect)",
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
parser.add_argument(
|
|
479
|
+
"--dry-run",
|
|
480
|
+
action="store_true",
|
|
481
|
+
help="Scan files but don't send to server",
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
parser.add_argument(
|
|
485
|
+
"--verbose",
|
|
486
|
+
"-v",
|
|
487
|
+
action="store_true",
|
|
488
|
+
help="Verbose output",
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
parser.add_argument(
|
|
492
|
+
"--api-key",
|
|
493
|
+
"-k",
|
|
494
|
+
env_var="PUTPLACE_API_KEY",
|
|
495
|
+
default=None,
|
|
496
|
+
help="API key for authentication (required for API v1.0+). "
|
|
497
|
+
"Can be specified via: 1) --api-key flag, 2) PUTPLACE_API_KEY environment variable, "
|
|
498
|
+
"or 3) 'api-key' in config file.",
|
|
499
|
+
)
|
|
500
|
+
|
|
501
|
+
args = parser.parse_args()
|
|
502
|
+
|
|
503
|
+
# Get API key (configargparse handles CLI, env var, and config file automatically)
|
|
504
|
+
api_key = args.api_key
|
|
505
|
+
|
|
506
|
+
# Get hostname and IP
|
|
507
|
+
hostname = args.hostname or get_hostname()
|
|
508
|
+
ip_address = args.ip or get_ip_address()
|
|
509
|
+
|
|
510
|
+
# Handle exclude patterns: merge config file and CLI patterns
|
|
511
|
+
# configargparse doesn't properly handle action="append" with config files,
|
|
512
|
+
# so we manually read exclude patterns from config files
|
|
513
|
+
config_files_to_check = []
|
|
514
|
+
|
|
515
|
+
# Add explicit config file if specified
|
|
516
|
+
if hasattr(args, 'config') and args.config:
|
|
517
|
+
config_files_to_check.append(args.config)
|
|
518
|
+
# Add default config files
|
|
519
|
+
config_files_to_check.extend(["~/ppclient.conf", "ppclient.conf"])
|
|
520
|
+
|
|
521
|
+
# Get exclude patterns from config files
|
|
522
|
+
config_exclude_patterns = get_exclude_patterns_from_config(config_files_to_check)
|
|
523
|
+
|
|
524
|
+
# Get exclude patterns from CLI (may include duplicates from config)
|
|
525
|
+
cli_exclude_patterns = args.exclude_list if args.exclude_list is not None else []
|
|
526
|
+
|
|
527
|
+
# Merge both lists, removing duplicates while preserving order
|
|
528
|
+
seen = set()
|
|
529
|
+
exclude_patterns = []
|
|
530
|
+
for pattern in config_exclude_patterns + cli_exclude_patterns:
|
|
531
|
+
if pattern not in seen:
|
|
532
|
+
seen.add(pattern)
|
|
533
|
+
exclude_patterns.append(pattern)
|
|
534
|
+
|
|
535
|
+
# Display configuration
|
|
536
|
+
console.print("\n[bold cyan]PutPlace Client[/bold cyan]")
|
|
537
|
+
console.print(f" Path: {args.path.absolute()}")
|
|
538
|
+
console.print(f" Hostname: {hostname}")
|
|
539
|
+
console.print(f" IP Address: {ip_address}")
|
|
540
|
+
console.print(f" API URL: {args.url}")
|
|
541
|
+
|
|
542
|
+
if api_key:
|
|
543
|
+
console.print(f" [green]API Key: {'*' * 8}{api_key[-8:]}[/green]")
|
|
544
|
+
else:
|
|
545
|
+
console.print(" [yellow]Warning: No API key provided (authentication may fail)[/yellow]")
|
|
546
|
+
|
|
547
|
+
if exclude_patterns:
|
|
548
|
+
console.print(f" Exclude patterns: {', '.join(exclude_patterns)}")
|
|
549
|
+
|
|
550
|
+
if args.dry_run:
|
|
551
|
+
console.print(" [yellow]DRY RUN MODE[/yellow]")
|
|
552
|
+
|
|
553
|
+
console.print()
|
|
554
|
+
|
|
555
|
+
# Register signal handler for graceful shutdown
|
|
556
|
+
signal.signal(signal.SIGINT, signal_handler)
|
|
557
|
+
|
|
558
|
+
# Scan and process
|
|
559
|
+
total, successful, failed, uploaded = process_path(
|
|
560
|
+
args.path,
|
|
561
|
+
exclude_patterns,
|
|
562
|
+
hostname,
|
|
563
|
+
ip_address,
|
|
564
|
+
args.url,
|
|
565
|
+
args.dry_run,
|
|
566
|
+
api_key,
|
|
567
|
+
)
|
|
568
|
+
|
|
569
|
+
# Display results
|
|
570
|
+
console.print("\n[bold]Results:[/bold]")
|
|
571
|
+
if interrupted:
|
|
572
|
+
console.print(" [yellow]Status: Interrupted (partial completion)[/yellow]")
|
|
573
|
+
console.print(f" Total files: {total}")
|
|
574
|
+
console.print(f" [green]Successful: {successful}[/green]")
|
|
575
|
+
if uploaded > 0:
|
|
576
|
+
console.print(f" [cyan]Uploaded: {uploaded}[/cyan]")
|
|
577
|
+
if failed > 0:
|
|
578
|
+
console.print(f" [red]Failed: {failed}[/red]")
|
|
579
|
+
if interrupted:
|
|
580
|
+
console.print(f" [dim]Remaining: {total - successful - failed}[/dim]")
|
|
581
|
+
|
|
582
|
+
return 0 if (failed == 0 and not interrupted) else 1
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
if __name__ == "__main__":
|
|
586
|
+
sys.exit(main())
|