nerdit 0.2.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.
@@ -0,0 +1,65 @@
1
+ """nerdit connect — Connect CLI to a remote nerditd daemon."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from pathlib import Path
7
+
8
+ import typer
9
+
10
+ from nerdit.cli.display import console
11
+ from nerdit.config.defaults import DEFAULT_PORT
12
+
13
+
14
+ def connect(
15
+ host: str = typer.Argument(..., help="Server IP address or hostname"),
16
+ port: int = typer.Option(DEFAULT_PORT, "--port", "-p", help="Daemon port"),
17
+ token: str = typer.Option(..., "--token", "-t", help="Authentication token"),
18
+ ) -> None:
19
+ """Connect the CLI to a remote daemon."""
20
+ asyncio.run(_connect_async(host, port, token))
21
+
22
+
23
+ async def _connect_async(host: str, port: int, token: str) -> None:
24
+ config_path = Path("~/.nerdit/config.toml").expanduser()
25
+
26
+ # Read existing config or start fresh
27
+ if config_path.exists():
28
+ import tomllib
29
+
30
+ with open(config_path, "rb") as f:
31
+ data = tomllib.load(f)
32
+ else:
33
+ data = {}
34
+
35
+ # Update [client] section
36
+ data["client"] = {
37
+ "remote_host": host,
38
+ "remote_port": port,
39
+ "auth_token": token,
40
+ }
41
+
42
+ # Write back
43
+ import tomli_w
44
+
45
+ config_path.parent.mkdir(parents=True, exist_ok=True)
46
+ with open(config_path, "wb") as f:
47
+ tomli_w.dump(data, f)
48
+
49
+ console.print("[green]Client configuration saved[/green]")
50
+ console.print(f" Server: {host}:{port}")
51
+
52
+ # Test connection
53
+ from nerdit.cli.client import NerditClient
54
+
55
+ client = NerditClient(host=host, port=port, token=token)
56
+ try:
57
+ health = await client.health()
58
+ console.print(
59
+ f"[green]Connection successful[/green] — version {health['version']}, "
60
+ f"{health['gpu_count']} GPU(s)"
61
+ )
62
+ except Exception as exc:
63
+ console.print(f"[red]Connection failed:[/red] {exc}")
64
+ console.print("[dim]Check that the daemon is running and the token is correct.[/dim]")
65
+ raise typer.Exit(1)
@@ -0,0 +1,57 @@
1
+ """nerdit init — Generate project configuration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import typer
8
+
9
+ from nerdit.cli.display import console
10
+
11
+ NERDIT_TOML_TEMPLATE = """\
12
+ # nerdit.toml — Project configuration for Nerdit
13
+ # Place this file at the root of your project.
14
+ # `nerdit run` with no arguments will use these defaults.
15
+
16
+ [run]
17
+ # script = "train.py"
18
+ # gpus = 1
19
+ # priority = 5
20
+ # image = "nerdit-runtime:0.1"
21
+ # tags = ["render", "nightly"]
22
+
23
+ [scheduling]
24
+ # time_window = "22:00-06:00" # job starts only within this window
25
+ # max_runtime = "12h" # auto-kill after this duration
26
+ # preemptible = false # mark job as interruptible (future use)
27
+
28
+ [data]
29
+ # Data mounts: name = "host_path:container_path"
30
+ # dataset = "/data/imagenet:/data"
31
+ # output = "/results/{{job_id}}:/output"
32
+ """
33
+
34
+
35
+ def init(
36
+ project: bool = typer.Option(
37
+ False, "--project", help="Generate a nerdit.toml in the current directory"
38
+ ),
39
+ ) -> None:
40
+ """Initialize a nerdit.toml project configuration."""
41
+ if project:
42
+ _generate_project_config()
43
+ else:
44
+ console.print(
45
+ "Use [cyan]nerdit init --project[/cyan] to generate a nerdit.toml template.\n"
46
+ "To start a daemon, install the full nerdit engine on your GPU server."
47
+ )
48
+
49
+
50
+ def _generate_project_config() -> None:
51
+ """Write a nerdit.toml template to cwd."""
52
+ target = Path.cwd() / "nerdit.toml"
53
+ if target.exists():
54
+ console.print(f"[yellow]nerdit.toml already exists:[/yellow] {target}")
55
+ return
56
+ target.write_text(NERDIT_TOML_TEMPLATE)
57
+ console.print(f"[green]nerdit.toml created:[/green] {target}")
@@ -0,0 +1,69 @@
1
+ """nerdit logs — Stream job logs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+
7
+ import typer
8
+
9
+ from nerdit.cli.display import console, display_logs
10
+
11
+
12
+ def logs(
13
+ job_id: str = typer.Argument(..., help="Job ID"),
14
+ follow: bool = typer.Option(False, "--follow", "-f", help="Follow logs continuously"),
15
+ ) -> None:
16
+ """Display logs for a job."""
17
+ asyncio.run(_logs_async(job_id, follow))
18
+
19
+
20
+ async def _logs_async(job_id: str, follow: bool) -> None:
21
+ from nerdit.cli.client import get_configured_client
22
+
23
+ client = get_configured_client()
24
+
25
+ try:
26
+ if not follow:
27
+ entries = await client.get_logs(job_id, since_id=0)
28
+ if entries:
29
+ display_logs(entries)
30
+ else:
31
+ console.print("[dim]No logs available[/dim]")
32
+ return
33
+
34
+ # Follow mode: use SSE stream
35
+ try:
36
+ async for event in client.stream_logs(job_id):
37
+ if event["event"] == "log":
38
+ display_logs([event["data"]])
39
+ elif event["event"] == "done":
40
+ status = event["data"]["status"]
41
+ console.print(f"\n[bold]Job finished: {status}[/bold]")
42
+ break
43
+ except ImportError:
44
+ # Fallback to polling if httpx-sse not installed
45
+ await _follow_polling(client, job_id)
46
+
47
+ except Exception as exc:
48
+ console.print(f"[red]Error:[/red] {exc}")
49
+ raise typer.Exit(1)
50
+
51
+
52
+ async def _follow_polling(client, job_id: str) -> None:
53
+ """Fallback follow mode using polling (v0.1 behavior)."""
54
+ since_id = 0
55
+ while True:
56
+ entries = await client.get_logs(job_id, since_id=since_id)
57
+ if entries:
58
+ display_logs(entries)
59
+ since_id = entries[-1]["id"]
60
+
61
+ job = await client.get_job(job_id)
62
+ if job["status"] in ("completed", "failed", "cancelled"):
63
+ entries = await client.get_logs(job_id, since_id=since_id)
64
+ if entries:
65
+ display_logs(entries)
66
+ console.print(f"\n[bold]Job finished: {job['status']}[/bold]")
67
+ break
68
+
69
+ await asyncio.sleep(1.0)
@@ -0,0 +1,157 @@
1
+ """nerdit run — Submit a job to the scheduler."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from pathlib import Path
7
+ from typing import Optional
8
+
9
+ import typer
10
+
11
+ from nerdit.cli.display import console, display_job_submitted
12
+
13
+
14
+ def run(
15
+ script: Optional[str] = typer.Argument(None, help="Python script to run"),
16
+ gpus: Optional[int] = typer.Option(None, "--gpus", "-g", help="Number of GPUs required"),
17
+ priority: Optional[int] = typer.Option(None, "--priority", "-p", help="Priority (1-10)"),
18
+ image: Optional[str] = typer.Option(None, "--image", "-i", help="Docker image to use"),
19
+ name: Optional[str] = typer.Option(None, "--name", "-n", help="Job name"),
20
+ data: Optional[list[str]] = typer.Option(
21
+ None, "--data", "-d", help="Data mount host:container"
22
+ ),
23
+ tag: Optional[list[str]] = typer.Option(None, "--tag", "-t", help="Job tag (repeatable)"),
24
+ ) -> None:
25
+ """Submit a job to the scheduler."""
26
+ asyncio.run(_run_async(script, gpus, priority, image, name, data, tag))
27
+
28
+
29
+ async def _run_async(
30
+ script: str | None,
31
+ gpus: int | None,
32
+ priority: int | None,
33
+ image: str | None,
34
+ name: str | None,
35
+ data: list[str] | None,
36
+ tag: list[str] | None,
37
+ ) -> None:
38
+ """Build effective job parameters by merging CLI flags over nerdit.toml defaults."""
39
+ from nerdit.cli.client import get_configured_client
40
+ from nerdit.config.project import (
41
+ load_project_config,
42
+ parse_data_flags,
43
+ parse_data_mounts,
44
+ )
45
+ from nerdit.config.settings import load_settings
46
+
47
+ # Load nerdit.toml defaults
48
+ project = load_project_config()
49
+ if project:
50
+ console.print("[dim]nerdit.toml found[/dim]")
51
+
52
+ # Merge: CLI flags override nerdit.toml [run] defaults
53
+ run_cfg = project.run if project and project.run else None
54
+ sched_cfg = project.scheduling if project and project.scheduling else None
55
+
56
+ effective_script = script or (run_cfg.script if run_cfg else None)
57
+ if not effective_script:
58
+ console.print("[red]No script specified (argument or nerdit.toml [run].script)[/red]")
59
+ raise typer.Exit(1)
60
+
61
+ effective_gpus = gpus if gpus is not None else (run_cfg.gpus if run_cfg else None) or 1
62
+ effective_priority = (
63
+ priority if priority is not None else (run_cfg.priority if run_cfg else None) or 5
64
+ )
65
+ effective_image = image or (run_cfg.image if run_cfg else None)
66
+
67
+ # Build tags: toml [run].tags + --tag CLI flags
68
+ effective_tags: list[str] = []
69
+ if run_cfg and run_cfg.tags:
70
+ effective_tags.extend(run_cfg.tags)
71
+ if tag:
72
+ for t in tag:
73
+ if t not in effective_tags:
74
+ effective_tags.append(t)
75
+
76
+ # Scheduling fields from toml [scheduling]
77
+ effective_time_window = sched_cfg.time_window if sched_cfg else None
78
+ effective_max_runtime = sched_cfg.max_runtime if sched_cfg else None
79
+ effective_preemptible = sched_cfg.preemptible if sched_cfg else False
80
+
81
+ # Build volumes: start with toml [data] mounts, overlay --data flags
82
+ volumes: dict[str, str] = {}
83
+ if project and project.data:
84
+ volumes.update(parse_data_mounts(project.data))
85
+ if data:
86
+ volumes.update(parse_data_flags(data))
87
+
88
+ # Resolve script to absolute path
89
+ script_path = str(Path(effective_script).resolve())
90
+
91
+ if not Path(script_path).exists():
92
+ console.print(f"[red]File not found:[/red] {script_path}")
93
+ raise typer.Exit(1)
94
+
95
+ settings = load_settings()
96
+ client = get_configured_client()
97
+
98
+ # Warn about remote mount paths
99
+ if settings.client.remote_host and volumes:
100
+ console.print("[yellow]Remote mode: mount paths reference the remote server.[/yellow]")
101
+
102
+ try:
103
+ if settings.client.remote_host:
104
+ # Remote mode: create ZIP archive and upload
105
+ from nerdit.cli.upload import (
106
+ check_upload_size,
107
+ create_script_zip,
108
+ warn_large_upload,
109
+ )
110
+
111
+ console.print("[dim]Remote mode — creating archive...[/dim]")
112
+ zip_bytes, script_name = create_script_zip(script_path)
113
+
114
+ check_upload_size(zip_bytes)
115
+
116
+ warning = warn_large_upload(zip_bytes)
117
+ if warning:
118
+ console.print(f"[yellow]{warning}[/yellow]")
119
+
120
+ size_mb = len(zip_bytes) / (1024 * 1024)
121
+ console.print(f"[dim]Uploading archive ({size_mb:.1f} MB)...[/dim]")
122
+
123
+ job = await client.upload_job(
124
+ zip_bytes,
125
+ script_name,
126
+ name=name,
127
+ gpu_count=effective_gpus,
128
+ priority=effective_priority,
129
+ image=effective_image,
130
+ volumes=volumes or None,
131
+ tags=effective_tags or None,
132
+ preemptible=effective_preemptible,
133
+ max_runtime=effective_max_runtime,
134
+ time_window=effective_time_window,
135
+ )
136
+ else:
137
+ # Local mode: existing path-based job creation
138
+ job = await client.create_job(
139
+ script_path=script_path,
140
+ name=name,
141
+ gpu_count=effective_gpus,
142
+ priority=effective_priority,
143
+ image=effective_image,
144
+ volumes=volumes or None,
145
+ tags=effective_tags or None,
146
+ preemptible=effective_preemptible,
147
+ max_runtime=effective_max_runtime,
148
+ time_window=effective_time_window,
149
+ )
150
+ display_job_submitted(job)
151
+ except ValueError as exc:
152
+ console.print(f"[red]Error:[/red] {exc}")
153
+ raise typer.Exit(1)
154
+ except Exception as exc:
155
+ console.print(f"[red]Error:[/red] {exc}")
156
+ console.print("[dim]Is the daemon running? (nerdit init)[/dim]")
157
+ raise typer.Exit(1)
@@ -0,0 +1,39 @@
1
+ """nerdit status — Display GPU and job status."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+
7
+ import typer
8
+
9
+ from nerdit.cli.display import console, display_gpu_table, display_job_table
10
+
11
+
12
+ def status() -> None:
13
+ """Display GPU and job status."""
14
+ asyncio.run(_status_async())
15
+
16
+
17
+ async def _status_async() -> None:
18
+ from nerdit.cli.client import get_configured_client
19
+
20
+ client = get_configured_client()
21
+ try:
22
+ gpus = await client.list_gpus()
23
+ jobs = await client.list_jobs()
24
+
25
+ if gpus:
26
+ display_gpu_table(gpus)
27
+ else:
28
+ console.print(
29
+ "[yellow]No GPU detected. Jobs requiring GPUs will stay pending.[/yellow]"
30
+ )
31
+ console.print()
32
+ if jobs:
33
+ display_job_table(jobs)
34
+ else:
35
+ console.print("[dim]No jobs[/dim]")
36
+ except Exception as exc:
37
+ console.print(f"[red]Error:[/red] {exc}")
38
+ console.print("[dim]Is the daemon running? (nerdit init)[/dim]")
39
+ raise typer.Exit(1)
@@ -0,0 +1,31 @@
1
+ """nerdit stop — Stop a running job."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+
7
+ import typer
8
+
9
+ from nerdit.cli.display import console
10
+
11
+
12
+ def stop(
13
+ job_id: str = typer.Argument(..., help="Job ID to stop"),
14
+ ) -> None:
15
+ """Stop a running job."""
16
+ asyncio.run(_stop_async(job_id))
17
+
18
+
19
+ async def _stop_async(job_id: str) -> None:
20
+ from nerdit.cli.client import get_configured_client
21
+
22
+ client = get_configured_client()
23
+ try:
24
+ result = await client.stop_job(job_id)
25
+ if result.get("stopped"):
26
+ console.print(f"[green]Job {job_id} stopped[/green]")
27
+ else:
28
+ console.print(f"[yellow]Job {job_id} could not be stopped[/yellow]")
29
+ except Exception as exc:
30
+ console.print(f"[red]Error:[/red] {exc}")
31
+ raise typer.Exit(1)
@@ -0,0 +1,20 @@
1
+ """nerdit token — Display the current authentication token."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from nerdit.cli.display import console
6
+ from nerdit.config.settings import load_settings
7
+
8
+
9
+ def token() -> None:
10
+ """Display the current authentication token."""
11
+ settings = load_settings()
12
+
13
+ auth_token = settings.daemon.auth_token
14
+ if settings.client.remote_host:
15
+ auth_token = settings.client.auth_token
16
+
17
+ if auth_token:
18
+ console.print(auth_token)
19
+ else:
20
+ console.print("[yellow]No authentication token configured.[/yellow]")
nerdit/cli/display.py ADDED
@@ -0,0 +1,101 @@
1
+ """Rich display formatters for CLI output."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from rich.console import Console
6
+ from rich.table import Table
7
+
8
+ console = Console()
9
+
10
+
11
+ def display_gpu_table(gpus: list[dict]) -> None:
12
+ """Display a Rich table of GPU information."""
13
+ table = Table(title="GPUs")
14
+ table.add_column("ID", style="dim", max_width=16)
15
+ table.add_column("Name", style="cyan")
16
+ table.add_column("Memory", justify="right")
17
+ table.add_column("Status", style="bold")
18
+ table.add_column("Util %", justify="right")
19
+ table.add_column("Temp °C", justify="right")
20
+
21
+ for gpu in gpus:
22
+ status_style = {
23
+ "idle": "green",
24
+ "busy": "yellow",
25
+ "error": "red",
26
+ "offline": "dim",
27
+ }.get(gpu["status"], "")
28
+
29
+ raw_util = gpu.get("utilization_percent")
30
+ util = str(raw_util) if raw_util is not None else "-"
31
+ raw_temp = gpu.get("temperature_c")
32
+ temp = str(raw_temp) if raw_temp is not None else "-"
33
+
34
+ table.add_row(
35
+ gpu["id"][:12] + "...",
36
+ gpu["name"],
37
+ f"{gpu['memory_mb']} MB",
38
+ f"[{status_style}]{gpu['status']}[/{status_style}]",
39
+ util,
40
+ temp,
41
+ )
42
+
43
+ console.print(table)
44
+
45
+
46
+ def display_job_table(jobs: list[dict]) -> None:
47
+ """Display a Rich table of jobs."""
48
+ table = Table(title="Jobs")
49
+ table.add_column("ID", style="dim")
50
+ table.add_column("Name")
51
+ table.add_column("Status", style="bold")
52
+ table.add_column("GPUs", justify="right")
53
+ table.add_column("Priority", justify="right")
54
+ table.add_column("Created")
55
+
56
+ for job in jobs:
57
+ status_style = {
58
+ "pending": "yellow",
59
+ "scheduled": "blue",
60
+ "running": "green",
61
+ "completed": "green bold",
62
+ "failed": "red",
63
+ "cancelled": "dim",
64
+ "retrying": "magenta",
65
+ }.get(job["status"], "")
66
+
67
+ name = job.get("name") or job.get("script_path", "").split("/")[-1]
68
+
69
+ table.add_row(
70
+ job["id"][:12],
71
+ name,
72
+ f"[{status_style}]{job['status']}[/{status_style}]",
73
+ str(job["gpu_count"]),
74
+ str(job["priority"]),
75
+ job.get("created_at", "")[:19],
76
+ )
77
+
78
+ console.print(table)
79
+
80
+
81
+ def display_job_submitted(job: dict) -> None:
82
+ """Display a submitted job confirmation."""
83
+ console.print(f"[green]Job submitted:[/green] {job['id']}")
84
+ console.print(f" Script: {job['script_path']}")
85
+ console.print(f" GPUs: {job['gpu_count']}")
86
+ console.print(f" Status: {job['status']}")
87
+
88
+
89
+ def display_logs(logs: list[dict]) -> None:
90
+ """Display log entries."""
91
+ for log in logs:
92
+ stream = log.get("stream", "stdout")
93
+ message = log.get("message", "")
94
+ ts = log.get("timestamp", "")[:19]
95
+
96
+ if stream == "stderr":
97
+ console.print(f"[dim]{ts}[/dim] [red]{message}[/red]")
98
+ elif stream == "system":
99
+ console.print(f"[dim]{ts}[/dim] [blue]{message}[/blue]")
100
+ else:
101
+ console.print(f"[dim]{ts}[/dim] {message}")
nerdit/cli/upload.py ADDED
@@ -0,0 +1,70 @@
1
+ """ZIP creation and upload helpers for remote job submission."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import fnmatch
6
+ import io
7
+ import zipfile
8
+ from pathlib import Path
9
+
10
+ from nerdit.config.defaults import (
11
+ DEFAULT_MAX_UPLOAD_BYTES,
12
+ UPLOAD_WARNING_BYTES,
13
+ ZIP_EXCLUDE_PATTERNS,
14
+ )
15
+
16
+
17
+ def should_exclude(rel_path: str) -> bool:
18
+ """Check if a relative path matches any exclusion pattern."""
19
+ parts = Path(rel_path).parts
20
+ for pattern in ZIP_EXCLUDE_PATTERNS:
21
+ # Directory pattern (ends with /)
22
+ if pattern.endswith("/"):
23
+ dir_name = pattern.rstrip("/")
24
+ if dir_name in parts:
25
+ return True
26
+ # Glob pattern
27
+ elif any(fnmatch.fnmatch(part, pattern) for part in parts):
28
+ return True
29
+ return False
30
+
31
+
32
+ def create_script_zip(script_path: str) -> tuple[bytes, str]:
33
+ """Create a ZIP archive of the script's parent directory.
34
+
35
+ Returns (zip_bytes, script_name) where script_name is the relative
36
+ path of the script inside the archive.
37
+ """
38
+ script = Path(script_path).resolve()
39
+ base_dir = script.parent
40
+
41
+ buf = io.BytesIO()
42
+ with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
43
+ for file in sorted(base_dir.rglob("*")):
44
+ if not file.is_file():
45
+ continue
46
+ rel = str(file.relative_to(base_dir))
47
+ if should_exclude(rel):
48
+ continue
49
+ zf.write(file, rel)
50
+
51
+ return buf.getvalue(), script.name
52
+
53
+
54
+ def check_upload_size(
55
+ zip_bytes: bytes,
56
+ max_bytes: int = DEFAULT_MAX_UPLOAD_BYTES,
57
+ ) -> None:
58
+ """Raise ValueError if zip_bytes exceed the maximum upload size."""
59
+ if len(zip_bytes) > max_bytes:
60
+ size_mb = len(zip_bytes) / (1024 * 1024)
61
+ limit_mb = max_bytes / (1024 * 1024)
62
+ raise ValueError(f"Archive is {size_mb:.0f} MB, limit is {limit_mb:.0f} MB.")
63
+
64
+
65
+ def warn_large_upload(zip_bytes: bytes) -> str | None:
66
+ """Return a warning message if zip_bytes exceed the warning threshold."""
67
+ if len(zip_bytes) > UPLOAD_WARNING_BYTES:
68
+ size_mb = len(zip_bytes) / (1024 * 1024)
69
+ return f"Archive is {size_mb:.0f} MB — upload may take a while."
70
+ return None
File without changes
@@ -0,0 +1,31 @@
1
+ """Default constants for Nerdit configuration."""
2
+
3
+ from pathlib import Path
4
+
5
+ DEFAULT_HOST = "127.0.0.1"
6
+ DEFAULT_PORT = 9321
7
+ DEFAULT_DATA_DIR = Path("~/.nerdit")
8
+ DEFAULT_DB_NAME = "nerdit.db"
9
+ DEFAULT_PID_FILE = "nerditd.pid"
10
+ DEFAULT_LOG_LEVEL = "info"
11
+
12
+ DEFAULT_IMAGE = "nerdit-runtime:0.1"
13
+ DEFAULT_RUNTIME = "docker"
14
+ DEFAULT_CACHE_DIR = "~/.nerdit/cache"
15
+
16
+ DEFAULT_PRIORITY = 5
17
+ DEFAULT_MAX_CONCURRENT_JOBS = 0 # 0 = auto (number of GPUs)
18
+ DEFAULT_TIMEOUT = 0 # 0 = no timeout
19
+ DEFAULT_MAX_RETRIES = 3
20
+
21
+ DEFAULT_MONITOR_INTERVAL = 5 # seconds
22
+ DEFAULT_GPU_TEMP_WARNING = 80 # °C
23
+ DEFAULT_GPU_TEMP_CRITICAL = 90 # °C
24
+
25
+ DAEMON_URL = f"http://{DEFAULT_HOST}:{DEFAULT_PORT}"
26
+
27
+ # Upload / ZIP constants (v0.2)
28
+ DEFAULT_UPLOAD_DIR = Path("~/.nerdit/uploads")
29
+ DEFAULT_MAX_UPLOAD_BYTES = 500 * 1024 * 1024 # 500 MB
30
+ UPLOAD_WARNING_BYTES = 100 * 1024 * 1024 # 100 MB
31
+ ZIP_EXCLUDE_PATTERNS = {".git", "data/", "*.pyc", "__pycache__", ".venv", "node_modules"}