vflow-cli 0.1.2__tar.gz → 0.1.4__tar.gz
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.
- {vflow_cli-0.1.2 → vflow_cli-0.1.4}/PKG-INFO +1 -1
- {vflow_cli-0.1.2 → vflow_cli-0.1.4}/pyproject.toml +1 -1
- {vflow_cli-0.1.2 → vflow_cli-0.1.4}/src/vflow/config.py +4 -0
- {vflow_cli-0.1.2 → vflow_cli-0.1.4}/src/vflow/main.py +51 -147
- vflow_cli-0.1.4/src/vflow/utils_date.py +104 -0
- {vflow_cli-0.1.2 → vflow_cli-0.1.4}/src/vflow_cli.egg-info/PKG-INFO +1 -1
- {vflow_cli-0.1.2 → vflow_cli-0.1.4}/src/vflow_cli.egg-info/SOURCES.txt +1 -0
- {vflow_cli-0.1.2 → vflow_cli-0.1.4}/README.md +0 -0
- {vflow_cli-0.1.2 → vflow_cli-0.1.4}/setup.cfg +0 -0
- {vflow_cli-0.1.2 → vflow_cli-0.1.4}/src/vflow/__init__.py +0 -0
- {vflow_cli-0.1.2 → vflow_cli-0.1.4}/src/vflow/actions.py +0 -0
- {vflow_cli-0.1.2 → vflow_cli-0.1.4}/src/vflow/backup_service.py +0 -0
- {vflow_cli-0.1.2 → vflow_cli-0.1.4}/src/vflow/core/__init__.py +0 -0
- {vflow_cli-0.1.2 → vflow_cli-0.1.4}/src/vflow/core/date_utils.py +0 -0
- {vflow_cli-0.1.2 → vflow_cli-0.1.4}/src/vflow/core/fs_ops.py +0 -0
- {vflow_cli-0.1.2 → vflow_cli-0.1.4}/src/vflow/core/media_ops.py +0 -0
- {vflow_cli-0.1.2 → vflow_cli-0.1.4}/src/vflow/core/patterns.py +0 -0
- {vflow_cli-0.1.2 → vflow_cli-0.1.4}/src/vflow/delivery_service.py +0 -0
- {vflow_cli-0.1.2 → vflow_cli-0.1.4}/src/vflow/ingest_service.py +0 -0
- {vflow_cli-0.1.2 → vflow_cli-0.1.4}/src/vflow_cli.egg-info/dependency_links.txt +0 -0
- {vflow_cli-0.1.2 → vflow_cli-0.1.4}/src/vflow_cli.egg-info/entry_points.txt +0 -0
- {vflow_cli-0.1.2 → vflow_cli-0.1.4}/src/vflow_cli.egg-info/requires.txt +0 -0
- {vflow_cli-0.1.2 → vflow_cli-0.1.4}/src/vflow_cli.egg-info/top_level.txt +0 -0
- {vflow_cli-0.1.2 → vflow_cli-0.1.4}/tests/test_braw_and_prores_extensions.py +0 -0
- {vflow_cli-0.1.2 → vflow_cli-0.1.4}/tests/test_cli_backup_and_verify.py +0 -0
- {vflow_cli-0.1.2 → vflow_cli-0.1.4}/tests/test_cli_ingest_and_pull_filters.py +0 -0
- {vflow_cli-0.1.2 → vflow_cli-0.1.4}/tests/test_cli_ingest_report.py +0 -0
- {vflow_cli-0.1.2 → vflow_cli-0.1.4}/tests/test_cli_list_backups.py +0 -0
- {vflow_cli-0.1.2 → vflow_cli-0.1.4}/tests/test_cli_restore_folder.py +0 -0
- {vflow_cli-0.1.2 → vflow_cli-0.1.4}/tests/test_cli_verify_backup_mirror.py +0 -0
- {vflow_cli-0.1.2 → vflow_cli-0.1.4}/tests/test_patterns_and_duplicates.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "vflow-cli"
|
|
3
|
-
version = "0.1.
|
|
3
|
+
version = "0.1.4"
|
|
4
4
|
description = "The v-flow CLI and Claude skills bundle for automating media backup and processing workflows for videographers."
|
|
5
5
|
authors = [{ name = "Kaung", email = "kaungzinye@gmail.com" }]
|
|
6
6
|
readme = "README.md"
|
|
@@ -42,3 +42,7 @@ def get_location(config: dict, name: str) -> Path:
|
|
|
42
42
|
def get_setting(config: dict, key: str, default: any = None) -> any:
|
|
43
43
|
"""Gets a setting from the config, returning default if not found."""
|
|
44
44
|
return config.get("settings", {}).get(key, default)
|
|
45
|
+
|
|
46
|
+
def save_config(config: dict) -> None:
|
|
47
|
+
with open(CONFIG_PATH, "w") as f:
|
|
48
|
+
yaml.dump(config, f, default_flow_style=False, sort_keys=False, allow_unicode=True)
|
|
@@ -394,6 +394,56 @@ def copy_meta(
|
|
|
394
394
|
|
|
395
395
|
|
|
396
396
|
|
|
397
|
+
LOCATION_KEYS = {"laptop", "work_ssd", "archive_hdd"}
|
|
398
|
+
|
|
399
|
+
@app.command()
|
|
400
|
+
def locations():
|
|
401
|
+
"""Show configured v-flow paths and whether they are currently mounted."""
|
|
402
|
+
app_config = config.load_config()
|
|
403
|
+
locs = app_config.get("locations", {})
|
|
404
|
+
settings = app_config.get("settings", {})
|
|
405
|
+
typer.echo("Locations:")
|
|
406
|
+
for key, path in locs.items():
|
|
407
|
+
mounted = "✓" if Path(path).exists() else "✗ not mounted"
|
|
408
|
+
typer.echo(f" {key}: {path} [{mounted}]")
|
|
409
|
+
if settings:
|
|
410
|
+
typer.echo("Settings:")
|
|
411
|
+
for key, val in settings.items():
|
|
412
|
+
typer.echo(f" {key}: {val}")
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
@app.command("set")
|
|
416
|
+
def set_config(
|
|
417
|
+
key: str = typer.Argument(help="Config key to update. Location keys: laptop, work_ssd, archive_hdd. Settings: settings.<key> (e.g. settings.default_split_gap)."),
|
|
418
|
+
value: str = typer.Argument(help="New value for the key."),
|
|
419
|
+
):
|
|
420
|
+
"""Update a single config value without re-running setup.
|
|
421
|
+
|
|
422
|
+
Examples:
|
|
423
|
+
v-flow set archive_hdd "/Volumes/Kaung HDD/MediaArchive"
|
|
424
|
+
v-flow set laptop "/Users/me/Desktop/Ingest"
|
|
425
|
+
v-flow set settings.default_split_gap 24
|
|
426
|
+
"""
|
|
427
|
+
app_config = config.load_config()
|
|
428
|
+
|
|
429
|
+
if key in LOCATION_KEYS:
|
|
430
|
+
app_config.setdefault("locations", {})[key] = value
|
|
431
|
+
typer.echo(f"Set locations.{key} = {value}")
|
|
432
|
+
elif key.startswith("settings."):
|
|
433
|
+
setting_key = key[len("settings."):]
|
|
434
|
+
app_config.setdefault("settings", {})[setting_key] = value
|
|
435
|
+
typer.echo(f"Set settings.{setting_key} = {value}")
|
|
436
|
+
else:
|
|
437
|
+
typer.echo(
|
|
438
|
+
f"Unknown key '{key}'. Use one of: {', '.join(sorted(LOCATION_KEYS))}, or settings.<key>.",
|
|
439
|
+
err=True,
|
|
440
|
+
)
|
|
441
|
+
raise typer.Exit(code=1)
|
|
442
|
+
|
|
443
|
+
config.save_config(app_config)
|
|
444
|
+
typer.echo(f"Config saved to {config.CONFIG_PATH}")
|
|
445
|
+
|
|
446
|
+
|
|
397
447
|
@app.command()
|
|
398
448
|
def make_config():
|
|
399
449
|
"""
|
|
@@ -418,153 +468,7 @@ def make_config():
|
|
|
418
468
|
yaml.dump(sample_config, f, default_flow_style=False, sort_keys=False)
|
|
419
469
|
|
|
420
470
|
typer.echo(f"Sample configuration file created at: {config.CONFIG_PATH}")
|
|
421
|
-
typer.echo("Please edit this file with your actual folder paths
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
@app.command("setup")
|
|
425
|
-
def setup_config() -> None:
|
|
426
|
-
"""
|
|
427
|
-
Interactive wizard to create or update your v-flow configuration.
|
|
428
|
-
"""
|
|
429
|
-
typer.echo(f"\nThis wizard will create or update your v-flow config at:\n {config.CONFIG_PATH}\n")
|
|
430
|
-
existing: dict = {}
|
|
431
|
-
if config.CONFIG_PATH.exists():
|
|
432
|
-
typer.echo("Existing configuration found. Loading it so you can review/edit values...")
|
|
433
|
-
try:
|
|
434
|
-
with config.CONFIG_PATH.open("r") as f:
|
|
435
|
-
existing = yaml.safe_load(f) or {}
|
|
436
|
-
except Exception as e:
|
|
437
|
-
typer.echo(f"[WARNING] Could not read existing config: {e}", err=True)
|
|
438
|
-
existing = {}
|
|
439
|
-
|
|
440
|
-
locations = dict(existing.get("locations", {}))
|
|
441
|
-
settings = dict(existing.get("settings", {}))
|
|
442
|
-
|
|
443
|
-
# Optionally show mounted volumes (macOS style) to help users pick paths.
|
|
444
|
-
volumes_root = Path("/Volumes")
|
|
445
|
-
if volumes_root.exists() and volumes_root.is_dir():
|
|
446
|
-
volumes = [p for p in volumes_root.iterdir() if p.is_dir()]
|
|
447
|
-
if volumes:
|
|
448
|
-
typer.echo("\nDetected external volumes (for reference):")
|
|
449
|
-
for v in volumes:
|
|
450
|
-
typer.echo(f" - {v}")
|
|
451
|
-
|
|
452
|
-
def ask_location(key: str, label: str, description: str, default_suggestion: Path) -> str:
|
|
453
|
-
current = locations.get(key)
|
|
454
|
-
default_value = current or str(default_suggestion)
|
|
455
|
-
prompt = f"{label} ({description})"
|
|
456
|
-
value = typer.prompt(prompt, default=default_value)
|
|
457
|
-
return value
|
|
458
|
-
|
|
459
|
-
home = Path.home()
|
|
460
|
-
laptop_path = ask_location(
|
|
461
|
-
"laptop",
|
|
462
|
-
"Laptop ingest folder",
|
|
463
|
-
"where you first copy footage onto your laptop",
|
|
464
|
-
home / "Desktop" / "Ingest",
|
|
465
|
-
)
|
|
466
|
-
work_ssd_path = ask_location(
|
|
467
|
-
"work_ssd",
|
|
468
|
-
"Workspace SSD projects folder",
|
|
469
|
-
"fast drive where your editing projects live",
|
|
470
|
-
home / "Movies" / "Projects",
|
|
471
|
-
)
|
|
472
|
-
archive_hdd_path = ask_location(
|
|
473
|
-
"archive_hdd",
|
|
474
|
-
"Archive drive root",
|
|
475
|
-
"long-term storage drive for finished and backed-up media",
|
|
476
|
-
Path("/Volumes/Archive"),
|
|
477
|
-
)
|
|
478
|
-
|
|
479
|
-
new_locations = {
|
|
480
|
-
"laptop": str(Path(laptop_path).expanduser()),
|
|
481
|
-
"work_ssd": str(Path(work_ssd_path).expanduser()),
|
|
482
|
-
"archive_hdd": str(Path(archive_hdd_path).expanduser()),
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
# Optional settings
|
|
486
|
-
def ask_int_setting(key: str, label: str, default_val: int) -> int:
|
|
487
|
-
current_val = settings.get(key, default_val)
|
|
488
|
-
while True:
|
|
489
|
-
raw = typer.prompt(label, default=str(current_val))
|
|
490
|
-
try:
|
|
491
|
-
return int(raw)
|
|
492
|
-
except ValueError:
|
|
493
|
-
typer.echo("Please enter a number (e.g. 0, 4, 12).", err=True)
|
|
494
|
-
|
|
495
|
-
typer.echo("\nOptional settings (press Enter to accept the suggested default).")
|
|
496
|
-
default_split_gap = ask_int_setting(
|
|
497
|
-
"default_split_gap",
|
|
498
|
-
"Default split gap (in hours) for ingest when --split-by-gap is not provided (0 = disable auto-splitting)",
|
|
499
|
-
settings.get("default_split_gap", 0),
|
|
500
|
-
)
|
|
501
|
-
|
|
502
|
-
default_backup_source = typer.prompt(
|
|
503
|
-
"Default folder to back up when you say 'back up my stuff' (optional, can be empty)",
|
|
504
|
-
default=settings.get("default_backup_source", ""),
|
|
505
|
-
).strip()
|
|
506
|
-
|
|
507
|
-
new_settings: dict = {}
|
|
508
|
-
new_settings["default_split_gap"] = default_split_gap
|
|
509
|
-
if default_backup_source:
|
|
510
|
-
new_settings["default_backup_source"] = default_backup_source
|
|
511
|
-
|
|
512
|
-
new_config: dict = {"locations": new_locations}
|
|
513
|
-
if new_settings:
|
|
514
|
-
new_config["settings"] = new_settings
|
|
515
|
-
|
|
516
|
-
config.CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
517
|
-
with config.CONFIG_PATH.open("w") as f:
|
|
518
|
-
yaml.dump(new_config, f, default_flow_style=False, sort_keys=False)
|
|
519
|
-
|
|
520
|
-
typer.echo(f"\nConfiguration written to: {config.CONFIG_PATH}\n")
|
|
521
|
-
|
|
522
|
-
# Basic validation of paths
|
|
523
|
-
typer.echo("Validating configured locations:")
|
|
524
|
-
missing = 0
|
|
525
|
-
for name, path_str in new_locations.items():
|
|
526
|
-
p = Path(path_str).expanduser()
|
|
527
|
-
if not p.exists() or not p.is_dir():
|
|
528
|
-
typer.echo(f" [WARNING] '{name}' points to a directory that does not exist yet: {p}", err=True)
|
|
529
|
-
missing += 1
|
|
530
|
-
else:
|
|
531
|
-
typer.echo(f" [OK] {name}: {p}")
|
|
532
|
-
|
|
533
|
-
if missing:
|
|
534
|
-
typer.echo(
|
|
535
|
-
"\nSome locations do not exist yet. You can create those folders and re-run 'v-flow setup', "
|
|
536
|
-
"or edit the config file manually if needed.",
|
|
537
|
-
err=True,
|
|
538
|
-
)
|
|
539
|
-
else:
|
|
540
|
-
typer.echo("\nAll configured locations exist and look good.")
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
@app.command("config-validate")
|
|
544
|
-
def config_validate() -> None:
|
|
545
|
-
"""
|
|
546
|
-
Validate your v-flow configuration file and report any issues.
|
|
547
|
-
"""
|
|
548
|
-
typer.echo(f"Validating configuration at: {config.CONFIG_PATH}")
|
|
549
|
-
cfg = config.load_config()
|
|
550
|
-
locations = cfg.get("locations", {})
|
|
551
|
-
if not locations:
|
|
552
|
-
typer.echo("No locations defined in configuration.", err=True)
|
|
553
|
-
raise typer.Exit(code=1)
|
|
554
|
-
|
|
555
|
-
ok = True
|
|
556
|
-
for name, path_str in locations.items():
|
|
557
|
-
p = Path(path_str).expanduser()
|
|
558
|
-
if not p.exists() or not p.is_dir():
|
|
559
|
-
typer.echo(f"[ERROR] Location '{name}' points to missing or invalid directory: {p}", err=True)
|
|
560
|
-
ok = False
|
|
561
|
-
else:
|
|
562
|
-
typer.echo(f"[OK] {name}: {p}")
|
|
563
|
-
|
|
564
|
-
if not ok:
|
|
565
|
-
raise typer.Exit(code=1)
|
|
566
|
-
|
|
567
|
-
typer.echo("Configuration looks good.")
|
|
471
|
+
typer.echo("Please edit this file with your actual folder paths.")
|
|
568
472
|
|
|
569
473
|
|
|
570
474
|
if __name__ == "__main__":
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Date parsing and shoot range helper functions.
|
|
3
|
+
"""
|
|
4
|
+
from datetime import datetime, date
|
|
5
|
+
import re
|
|
6
|
+
from typing import Optional, Tuple
|
|
7
|
+
|
|
8
|
+
def parse_shoot_date_range(shoot_name: str) -> Optional[Tuple[date, date]]:
|
|
9
|
+
"""
|
|
10
|
+
Parse a shoot name to extract date range.
|
|
11
|
+
Returns (start_date, end_date) or None if no date found.
|
|
12
|
+
|
|
13
|
+
Supports formats:
|
|
14
|
+
- YYYY-MM-DD_ShootName (single date)
|
|
15
|
+
- YYYY-MM-DD_to_YYYY-MM-DD_ShootName (date range)
|
|
16
|
+
"""
|
|
17
|
+
# Pattern for date range: YYYY-MM-DD_to_YYYY-MM-DD_ShootName
|
|
18
|
+
range_pattern = r'^(\d{4}-\d{2}-\d{2})_to_(\d{4}-\d{2}-\d{2})_(.+)$'
|
|
19
|
+
match = re.match(range_pattern, shoot_name)
|
|
20
|
+
if match:
|
|
21
|
+
try:
|
|
22
|
+
start = datetime.strptime(match.group(1), '%Y-%m-%d').date()
|
|
23
|
+
end = datetime.strptime(match.group(2), '%Y-%m-%d').date()
|
|
24
|
+
return (start, end)
|
|
25
|
+
except ValueError:
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
# Pattern for single date: YYYY-MM-DD_ShootName
|
|
29
|
+
single_pattern = r'^(\d{4}-\d{2}-\d{2})_(.+)$'
|
|
30
|
+
match = re.match(single_pattern, shoot_name)
|
|
31
|
+
if match:
|
|
32
|
+
try:
|
|
33
|
+
shoot_date = datetime.strptime(match.group(1), '%Y-%m-%d').date()
|
|
34
|
+
return (shoot_date, shoot_date)
|
|
35
|
+
except ValueError:
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
def format_shoot_name(start_date: date, end_date: date, name_suffix: str = "Ingest") -> str:
|
|
41
|
+
"""
|
|
42
|
+
Format a shoot name from a date range.
|
|
43
|
+
If start and end are the same, use single date format.
|
|
44
|
+
"""
|
|
45
|
+
if start_date == end_date:
|
|
46
|
+
return f"{start_date.strftime('%Y-%m-%d')}_{name_suffix}"
|
|
47
|
+
else:
|
|
48
|
+
return f"{start_date.strftime('%Y-%m-%d')}_to_{end_date.strftime('%Y-%m-%d')}_{name_suffix}"
|
|
49
|
+
|
|
50
|
+
def date_in_range(check_date: date, start_date: date, end_date: date) -> bool:
|
|
51
|
+
"""Check if a date falls within a date range (inclusive)."""
|
|
52
|
+
return start_date <= check_date <= end_date
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def cluster_files_by_date(files_with_dates: list[Tuple[any, datetime]], gap_hours: int) -> list[list[any]]:
|
|
56
|
+
"""
|
|
57
|
+
Group files into clusters based on a time gap threshold.
|
|
58
|
+
files_with_dates: List of tuples (file_path, file_date)
|
|
59
|
+
gap_hours: Minimum gap in hours to trigger a split
|
|
60
|
+
|
|
61
|
+
Returns a list of lists, where each inner list contains file paths for one cluster.
|
|
62
|
+
"""
|
|
63
|
+
if not files_with_dates:
|
|
64
|
+
return []
|
|
65
|
+
|
|
66
|
+
# Sort by date
|
|
67
|
+
sorted_files = sorted(files_with_dates, key=lambda x: x[1])
|
|
68
|
+
|
|
69
|
+
clusters = []
|
|
70
|
+
current_cluster = []
|
|
71
|
+
|
|
72
|
+
if not sorted_files:
|
|
73
|
+
return []
|
|
74
|
+
|
|
75
|
+
# Start first cluster
|
|
76
|
+
current_cluster.append(sorted_files[0][0])
|
|
77
|
+
last_date = sorted_files[0][1]
|
|
78
|
+
|
|
79
|
+
for i in range(1, len(sorted_files)):
|
|
80
|
+
file_path, file_date = sorted_files[i]
|
|
81
|
+
|
|
82
|
+
# Calculate difference in hours
|
|
83
|
+
# Ensure we are comparing datetimes
|
|
84
|
+
if isinstance(file_date, date) and not isinstance(file_date, datetime):
|
|
85
|
+
# Fallback if we only have dates (assume midnight)
|
|
86
|
+
file_date = datetime.combine(file_date, datetime.min.time())
|
|
87
|
+
if isinstance(last_date, date) and not isinstance(last_date, datetime):
|
|
88
|
+
last_date = datetime.combine(last_date, datetime.min.time())
|
|
89
|
+
|
|
90
|
+
diff = file_date - last_date
|
|
91
|
+
diff_hours = diff.total_seconds() / 3600
|
|
92
|
+
|
|
93
|
+
if diff_hours >= gap_hours:
|
|
94
|
+
# Gap exceeded, start new cluster
|
|
95
|
+
clusters.append(current_cluster)
|
|
96
|
+
current_cluster = []
|
|
97
|
+
|
|
98
|
+
current_cluster.append(file_path)
|
|
99
|
+
last_date = file_date
|
|
100
|
+
|
|
101
|
+
if current_cluster:
|
|
102
|
+
clusters.append(current_cluster)
|
|
103
|
+
|
|
104
|
+
return clusters
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|