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/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())