tetra-rp 0.13.0__py3-none-any.whl → 0.14.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.
- tetra_rp/cli/commands/build.py +501 -0
- tetra_rp/cli/commands/init.py +106 -57
- tetra_rp/cli/commands/run.py +65 -87
- tetra_rp/cli/main.py +6 -4
- tetra_rp/cli/utils/conda.py +127 -0
- tetra_rp/cli/utils/ignore.py +139 -0
- tetra_rp/cli/utils/skeleton.py +317 -71
- tetra_rp/client.py +35 -8
- tetra_rp/core/resources/cpu.py +9 -0
- tetra_rp/core/resources/serverless_cpu.py +10 -2
- {tetra_rp-0.13.0.dist-info → tetra_rp-0.14.0.dist-info}/METADATA +2 -1
- {tetra_rp-0.13.0.dist-info → tetra_rp-0.14.0.dist-info}/RECORD +15 -18
- tetra_rp/cli/templates/advanced/main.py +0 -58
- tetra_rp/cli/templates/advanced/utils.py +0 -24
- tetra_rp/cli/templates/basic/main.py +0 -32
- tetra_rp/cli/templates/gpu-compute/main.py +0 -64
- tetra_rp/cli/templates/web-api/api.py +0 -67
- tetra_rp/cli/templates/web-api/main.py +0 -42
- {tetra_rp-0.13.0.dist-info → tetra_rp-0.14.0.dist-info}/WHEEL +0 -0
- {tetra_rp-0.13.0.dist-info → tetra_rp-0.14.0.dist-info}/entry_points.txt +0 -0
- {tetra_rp-0.13.0.dist-info → tetra_rp-0.14.0.dist-info}/top_level.txt +0 -0
tetra_rp/cli/commands/run.py
CHANGED
|
@@ -1,122 +1,100 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""Run Flash development server."""
|
|
2
2
|
|
|
3
|
-
import
|
|
3
|
+
import subprocess
|
|
4
4
|
import sys
|
|
5
5
|
from pathlib import Path
|
|
6
6
|
from typing import Optional
|
|
7
|
+
|
|
7
8
|
import typer
|
|
8
9
|
from rich.console import Console
|
|
9
|
-
from rich.progress import Progress, SpinnerColumn, TextColumn
|
|
10
|
-
from rich.panel import Panel
|
|
11
|
-
|
|
12
|
-
from tetra_rp.config import get_paths
|
|
13
10
|
|
|
14
11
|
console = Console()
|
|
15
12
|
|
|
16
13
|
|
|
17
14
|
def run_command(
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
False, "--no-deploy", help="Skip resource deployment"
|
|
15
|
+
host: str = typer.Option("localhost", "--host", help="Host to bind to"),
|
|
16
|
+
port: int = typer.Option(8888, "--port", "-p", help="Port to bind to"),
|
|
17
|
+
reload: bool = typer.Option(
|
|
18
|
+
True, "--reload/--no-reload", help="Enable auto-reload"
|
|
23
19
|
),
|
|
24
20
|
):
|
|
25
|
-
"""
|
|
21
|
+
"""Run Flash development server with uvicorn."""
|
|
26
22
|
|
|
27
|
-
# Discover entry point
|
|
23
|
+
# Discover entry point
|
|
24
|
+
entry_point = discover_entry_point()
|
|
28
25
|
if not entry_point:
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
console.print("No entry point found")
|
|
32
|
-
console.print("Specify entry point with --entry or create main.py")
|
|
33
|
-
raise typer.Exit(1)
|
|
34
|
-
|
|
35
|
-
# Validate entry point exists
|
|
36
|
-
entry_path = Path(entry_point)
|
|
37
|
-
if not entry_path.exists():
|
|
38
|
-
console.print(f"Entry point not found: {entry_point}")
|
|
26
|
+
console.print("[red]Error:[/red] No entry point found")
|
|
27
|
+
console.print("Create main.py with a FastAPI app")
|
|
39
28
|
raise typer.Exit(1)
|
|
40
29
|
|
|
41
|
-
|
|
30
|
+
# Check if entry point has FastAPI app
|
|
31
|
+
app_location = check_fastapi_app(entry_point)
|
|
32
|
+
if not app_location:
|
|
33
|
+
console.print(f"[red]Error:[/red] No FastAPI app found in {entry_point}")
|
|
34
|
+
console.print("Make sure your main.py contains: app = FastAPI()")
|
|
35
|
+
raise typer.Exit(1)
|
|
42
36
|
|
|
43
|
-
|
|
37
|
+
console.print("[green]Starting Flash Server[/green]")
|
|
38
|
+
console.print(f"Entry point: [bold]{app_location}[/bold]")
|
|
39
|
+
console.print(f"Server: [bold]http://{host}:{port}[/bold]")
|
|
40
|
+
console.print(f"Auto-reload: [bold]{'enabled' if reload else 'disabled'}[/bold]")
|
|
41
|
+
console.print("\nPress CTRL+C to stop\n")
|
|
42
|
+
|
|
43
|
+
# Build uvicorn command
|
|
44
|
+
cmd = [
|
|
45
|
+
sys.executable,
|
|
46
|
+
"-m",
|
|
47
|
+
"uvicorn",
|
|
48
|
+
app_location,
|
|
49
|
+
"--host",
|
|
50
|
+
host,
|
|
51
|
+
"--port",
|
|
52
|
+
str(port),
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
if reload:
|
|
56
|
+
cmd.append("--reload")
|
|
57
|
+
|
|
58
|
+
# Run uvicorn
|
|
44
59
|
try:
|
|
45
|
-
|
|
60
|
+
subprocess.run(cmd)
|
|
46
61
|
except KeyboardInterrupt:
|
|
47
|
-
console.print("\
|
|
48
|
-
raise typer.Exit(
|
|
62
|
+
console.print("\n[yellow]Server stopped[/yellow]")
|
|
63
|
+
raise typer.Exit(0)
|
|
49
64
|
except Exception as e:
|
|
50
|
-
console.print(f"
|
|
65
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
51
66
|
raise typer.Exit(1)
|
|
52
67
|
|
|
53
68
|
|
|
54
69
|
def discover_entry_point() -> Optional[str]:
|
|
55
70
|
"""Discover the main entry point file."""
|
|
56
|
-
|
|
57
|
-
candidates = ["main.py", "app.py", "run.py", "__main__.py"]
|
|
71
|
+
candidates = ["main.py", "app.py", "server.py"]
|
|
58
72
|
|
|
59
73
|
for candidate in candidates:
|
|
60
74
|
if Path(candidate).exists():
|
|
61
75
|
return candidate
|
|
62
76
|
|
|
63
|
-
|
|
64
|
-
paths = get_paths()
|
|
65
|
-
config_path = paths.config_file
|
|
66
|
-
if config_path.exists():
|
|
67
|
-
try:
|
|
68
|
-
import json
|
|
77
|
+
return None
|
|
69
78
|
|
|
70
|
-
with open(config_path) as f:
|
|
71
|
-
config = json.load(f)
|
|
72
|
-
return config.get("entry_point")
|
|
73
|
-
except (json.JSONDecodeError, KeyError):
|
|
74
|
-
pass
|
|
75
79
|
|
|
76
|
-
|
|
80
|
+
def check_fastapi_app(entry_point: str) -> Optional[str]:
|
|
81
|
+
"""
|
|
82
|
+
Check if entry point has a FastAPI app and return the app location.
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
App location in format "module:app" or None
|
|
86
|
+
"""
|
|
87
|
+
try:
|
|
88
|
+
# Read the file
|
|
89
|
+
content = Path(entry_point).read_text()
|
|
90
|
+
|
|
91
|
+
# Check for FastAPI app
|
|
92
|
+
if "app = FastAPI(" in content or "app=FastAPI(" in content:
|
|
93
|
+
# Extract module name from file path
|
|
94
|
+
module = entry_point.replace(".py", "").replace("/", ".")
|
|
95
|
+
return f"{module}:app"
|
|
77
96
|
|
|
97
|
+
return None
|
|
78
98
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
with Progress(
|
|
83
|
-
SpinnerColumn(),
|
|
84
|
-
TextColumn("[progress.description]{task.description}"),
|
|
85
|
-
console=console,
|
|
86
|
-
) as progress:
|
|
87
|
-
if not no_deploy:
|
|
88
|
-
# Deployment phase
|
|
89
|
-
deploy_task = progress.add_task("Preparing resources...", total=None)
|
|
90
|
-
await asyncio.sleep(1) # Mock deployment time
|
|
91
|
-
progress.update(deploy_task, description="Resources ready")
|
|
92
|
-
progress.stop_task(deploy_task)
|
|
93
|
-
|
|
94
|
-
# Execution phase
|
|
95
|
-
exec_task = progress.add_task("Executing...", total=None)
|
|
96
|
-
|
|
97
|
-
# Execute the Python file
|
|
98
|
-
try:
|
|
99
|
-
# Import and run the module
|
|
100
|
-
spec = __import__("importlib.util").util.spec_from_file_location(
|
|
101
|
-
entry_path.stem, entry_path
|
|
102
|
-
)
|
|
103
|
-
module = __import__("importlib.util").util.module_from_spec(spec)
|
|
104
|
-
|
|
105
|
-
# Add the directory to sys.path so imports work
|
|
106
|
-
sys.path.insert(0, str(entry_path.parent))
|
|
107
|
-
|
|
108
|
-
spec.loader.exec_module(module)
|
|
109
|
-
|
|
110
|
-
progress.update(exec_task, description="Complete!")
|
|
111
|
-
await asyncio.sleep(0.5) # Brief pause to show completion
|
|
112
|
-
|
|
113
|
-
except Exception as e:
|
|
114
|
-
progress.update(exec_task, description=f"Failed: {e}")
|
|
115
|
-
raise
|
|
116
|
-
finally:
|
|
117
|
-
progress.stop_task(exec_task)
|
|
118
|
-
|
|
119
|
-
# Success message
|
|
120
|
-
console.print(
|
|
121
|
-
Panel("Execution completed successfully", title="Success", expand=False)
|
|
122
|
-
)
|
|
99
|
+
except Exception:
|
|
100
|
+
return None
|
tetra_rp/cli/main.py
CHANGED
|
@@ -8,6 +8,7 @@ from rich.panel import Panel
|
|
|
8
8
|
from .commands import (
|
|
9
9
|
init,
|
|
10
10
|
run,
|
|
11
|
+
build,
|
|
11
12
|
resource,
|
|
12
13
|
deploy,
|
|
13
14
|
)
|
|
@@ -26,7 +27,7 @@ console = Console()
|
|
|
26
27
|
# command: flash
|
|
27
28
|
app = typer.Typer(
|
|
28
29
|
name="flash",
|
|
29
|
-
help="Flash CLI - Distributed inference and serving framework",
|
|
30
|
+
help="Runpod Flash CLI - Distributed inference and serving framework",
|
|
30
31
|
no_args_is_help=True,
|
|
31
32
|
rich_markup_mode="rich",
|
|
32
33
|
)
|
|
@@ -34,6 +35,7 @@ app = typer.Typer(
|
|
|
34
35
|
# command: flash <command>
|
|
35
36
|
app.command("init")(init.init_command)
|
|
36
37
|
app.command("run")(run.run_command)
|
|
38
|
+
app.command("build")(build.build_command)
|
|
37
39
|
app.command("report")(resource.report_command)
|
|
38
40
|
app.command("clean")(resource.clean_command)
|
|
39
41
|
|
|
@@ -60,15 +62,15 @@ def main(
|
|
|
60
62
|
ctx: typer.Context,
|
|
61
63
|
version: bool = typer.Option(False, "--version", "-v", help="Show version"),
|
|
62
64
|
):
|
|
63
|
-
"""Flash CLI - Distributed inference and serving framework."""
|
|
65
|
+
"""Runpod Flash CLI - Distributed inference and serving framework."""
|
|
64
66
|
if version:
|
|
65
|
-
console.print(f"Flash CLI v{get_version()}")
|
|
67
|
+
console.print(f"Runpod Flash CLI v{get_version()}")
|
|
66
68
|
raise typer.Exit()
|
|
67
69
|
|
|
68
70
|
if ctx.invoked_subcommand is None:
|
|
69
71
|
console.print(
|
|
70
72
|
Panel(
|
|
71
|
-
"[bold blue]Flash CLI[/bold blue]\n\n"
|
|
73
|
+
"[bold blue]Runpod Flash CLI[/bold blue]\n\n"
|
|
72
74
|
"A framework for distributed inference and serving of ML models.\n\n"
|
|
73
75
|
"Use [bold]flash --help[/bold] to see available commands.",
|
|
74
76
|
title="Welcome",
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Conda environment management utilities."""
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
from typing import List, Tuple
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
|
|
7
|
+
console = Console()
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def check_conda_available() -> bool:
|
|
11
|
+
"""Check if conda is available on the system."""
|
|
12
|
+
try:
|
|
13
|
+
result = subprocess.run(
|
|
14
|
+
["conda", "--version"],
|
|
15
|
+
capture_output=True,
|
|
16
|
+
text=True,
|
|
17
|
+
timeout=5,
|
|
18
|
+
)
|
|
19
|
+
return result.returncode == 0
|
|
20
|
+
except (subprocess.SubprocessError, FileNotFoundError):
|
|
21
|
+
return False
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def create_conda_environment(
|
|
25
|
+
env_name: str, python_version: str = "3.11"
|
|
26
|
+
) -> Tuple[bool, str]:
|
|
27
|
+
"""
|
|
28
|
+
Create a new conda environment.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
env_name: Name of the conda environment
|
|
32
|
+
python_version: Python version to use
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
Tuple of (success, message)
|
|
36
|
+
"""
|
|
37
|
+
try:
|
|
38
|
+
console.print(f"Creating conda environment: {env_name}")
|
|
39
|
+
|
|
40
|
+
result = subprocess.run(
|
|
41
|
+
["conda", "create", "-n", env_name, f"python={python_version}", "-y"],
|
|
42
|
+
capture_output=True,
|
|
43
|
+
text=True,
|
|
44
|
+
timeout=300, # 5 minutes timeout
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
if result.returncode == 0:
|
|
48
|
+
return True, f"Conda environment '{env_name}' created successfully"
|
|
49
|
+
else:
|
|
50
|
+
return False, f"Failed to create environment: {result.stderr}"
|
|
51
|
+
|
|
52
|
+
except subprocess.TimeoutExpired:
|
|
53
|
+
return False, "Environment creation timed out"
|
|
54
|
+
except Exception as e:
|
|
55
|
+
return False, f"Error creating environment: {e}"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def install_packages_in_env(
|
|
59
|
+
env_name: str, packages: List[str], use_pip: bool = True
|
|
60
|
+
) -> Tuple[bool, str]:
|
|
61
|
+
"""
|
|
62
|
+
Install packages in a conda environment.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
env_name: Name of the conda environment
|
|
66
|
+
packages: List of packages to install
|
|
67
|
+
use_pip: If True, use pip install; otherwise use conda install
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
Tuple of (success, message)
|
|
71
|
+
"""
|
|
72
|
+
try:
|
|
73
|
+
console.print(f"Installing packages: {', '.join(packages)}")
|
|
74
|
+
|
|
75
|
+
if use_pip:
|
|
76
|
+
# Use conda run to execute pip in the environment
|
|
77
|
+
cmd = [
|
|
78
|
+
"conda",
|
|
79
|
+
"run",
|
|
80
|
+
"-n",
|
|
81
|
+
env_name,
|
|
82
|
+
"pip",
|
|
83
|
+
"install",
|
|
84
|
+
] + packages
|
|
85
|
+
else:
|
|
86
|
+
cmd = ["conda", "install", "-n", env_name, "-y"] + packages
|
|
87
|
+
|
|
88
|
+
result = subprocess.run(
|
|
89
|
+
cmd,
|
|
90
|
+
capture_output=True,
|
|
91
|
+
text=True,
|
|
92
|
+
timeout=600, # 10 minutes timeout
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
if result.returncode == 0:
|
|
96
|
+
return True, "Packages installed successfully"
|
|
97
|
+
else:
|
|
98
|
+
return False, f"Failed to install packages: {result.stderr}"
|
|
99
|
+
|
|
100
|
+
except subprocess.TimeoutExpired:
|
|
101
|
+
return False, "Package installation timed out"
|
|
102
|
+
except Exception as e:
|
|
103
|
+
return False, f"Error installing packages: {e}"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def environment_exists(env_name: str) -> bool:
|
|
107
|
+
"""Check if a conda environment exists."""
|
|
108
|
+
try:
|
|
109
|
+
result = subprocess.run(
|
|
110
|
+
["conda", "env", "list"],
|
|
111
|
+
capture_output=True,
|
|
112
|
+
text=True,
|
|
113
|
+
timeout=10,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
if result.returncode == 0:
|
|
117
|
+
# Check if environment name appears in the output
|
|
118
|
+
return env_name in result.stdout
|
|
119
|
+
return False
|
|
120
|
+
|
|
121
|
+
except Exception:
|
|
122
|
+
return False
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def get_activation_command(env_name: str) -> str:
|
|
126
|
+
"""Get the command to activate the conda environment."""
|
|
127
|
+
return f"conda activate {env_name}"
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Ignore pattern matching utilities for Flash build."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import pathspec
|
|
7
|
+
|
|
8
|
+
log = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def parse_ignore_file(file_path: Path) -> list[str]:
|
|
12
|
+
"""
|
|
13
|
+
Parse an ignore file and return list of patterns.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
file_path: Path to ignore file (.flashignore or .gitignore)
|
|
17
|
+
|
|
18
|
+
Returns:
|
|
19
|
+
List of pattern strings
|
|
20
|
+
"""
|
|
21
|
+
if not file_path.exists():
|
|
22
|
+
return []
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
content = file_path.read_text(encoding="utf-8")
|
|
26
|
+
patterns = []
|
|
27
|
+
|
|
28
|
+
for line in content.splitlines():
|
|
29
|
+
line = line.strip()
|
|
30
|
+
# Skip empty lines and comments
|
|
31
|
+
if line and not line.startswith("#"):
|
|
32
|
+
patterns.append(line)
|
|
33
|
+
|
|
34
|
+
return patterns
|
|
35
|
+
|
|
36
|
+
except Exception as e:
|
|
37
|
+
log.warning(f"Failed to read {file_path.name}: {e}")
|
|
38
|
+
return []
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def load_ignore_patterns(project_dir: Path) -> pathspec.PathSpec:
|
|
42
|
+
"""
|
|
43
|
+
Load ignore patterns from .flashignore and .gitignore files.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
project_dir: Flash project directory
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
PathSpec object for pattern matching
|
|
50
|
+
"""
|
|
51
|
+
patterns = []
|
|
52
|
+
|
|
53
|
+
# Load .flashignore
|
|
54
|
+
flashignore = project_dir / ".flashignore"
|
|
55
|
+
if flashignore.exists():
|
|
56
|
+
flash_patterns = parse_ignore_file(flashignore)
|
|
57
|
+
patterns.extend(flash_patterns)
|
|
58
|
+
log.debug(f"Loaded {len(flash_patterns)} patterns from .flashignore")
|
|
59
|
+
|
|
60
|
+
# Load .gitignore
|
|
61
|
+
gitignore = project_dir / ".gitignore"
|
|
62
|
+
if gitignore.exists():
|
|
63
|
+
git_patterns = parse_ignore_file(gitignore)
|
|
64
|
+
patterns.extend(git_patterns)
|
|
65
|
+
log.debug(f"Loaded {len(git_patterns)} patterns from .gitignore")
|
|
66
|
+
|
|
67
|
+
# Always exclude build artifacts
|
|
68
|
+
always_ignore = [
|
|
69
|
+
".build/",
|
|
70
|
+
".tetra/",
|
|
71
|
+
"*.tar.gz",
|
|
72
|
+
".git/",
|
|
73
|
+
]
|
|
74
|
+
patterns.extend(always_ignore)
|
|
75
|
+
|
|
76
|
+
# Create PathSpec with gitwildmatch pattern (gitignore-style)
|
|
77
|
+
return pathspec.PathSpec.from_lines("gitwildmatch", patterns)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def should_ignore(file_path: Path, spec: pathspec.PathSpec, base_dir: Path) -> bool:
|
|
81
|
+
"""
|
|
82
|
+
Check if a file should be ignored based on patterns.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
file_path: File path to check
|
|
86
|
+
spec: PathSpec object with ignore patterns
|
|
87
|
+
base_dir: Base directory for relative path calculation
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
True if file should be ignored
|
|
91
|
+
"""
|
|
92
|
+
try:
|
|
93
|
+
# Get relative path for pattern matching
|
|
94
|
+
rel_path = file_path.relative_to(base_dir)
|
|
95
|
+
|
|
96
|
+
# Check if file matches any ignore pattern
|
|
97
|
+
return spec.match_file(str(rel_path))
|
|
98
|
+
|
|
99
|
+
except ValueError:
|
|
100
|
+
# file_path is not relative to base_dir
|
|
101
|
+
return False
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def get_file_tree(
|
|
105
|
+
directory: Path, spec: pathspec.PathSpec, base_dir: Path | None = None
|
|
106
|
+
) -> list[Path]:
|
|
107
|
+
"""
|
|
108
|
+
Recursively collect all files in directory excluding ignored patterns.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
directory: Directory to scan
|
|
112
|
+
spec: PathSpec object with ignore patterns
|
|
113
|
+
base_dir: Base directory for relative paths (defaults to directory)
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
List of file paths that should be included
|
|
117
|
+
"""
|
|
118
|
+
if base_dir is None:
|
|
119
|
+
base_dir = directory
|
|
120
|
+
|
|
121
|
+
files = []
|
|
122
|
+
|
|
123
|
+
try:
|
|
124
|
+
for item in directory.iterdir():
|
|
125
|
+
# Check if should ignore
|
|
126
|
+
if should_ignore(item, spec, base_dir):
|
|
127
|
+
log.debug(f"Ignoring: {item.relative_to(base_dir)}")
|
|
128
|
+
continue
|
|
129
|
+
|
|
130
|
+
if item.is_file():
|
|
131
|
+
files.append(item)
|
|
132
|
+
elif item.is_dir():
|
|
133
|
+
# Recursively collect files from subdirectory
|
|
134
|
+
files.extend(get_file_tree(item, spec, base_dir))
|
|
135
|
+
|
|
136
|
+
except PermissionError as e:
|
|
137
|
+
log.warning(f"Permission denied: {directory} - {e}")
|
|
138
|
+
|
|
139
|
+
return files
|