shrip 0.1.0__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.
- shrip-0.1.0/LICENSE +21 -0
- shrip-0.1.0/PKG-INFO +91 -0
- shrip-0.1.0/README.md +74 -0
- shrip-0.1.0/pyproject.toml +26 -0
- shrip-0.1.0/setup.cfg +4 -0
- shrip-0.1.0/shrip/__init__.py +6 -0
- shrip-0.1.0/shrip/__main__.py +3 -0
- shrip-0.1.0/shrip/archive.py +161 -0
- shrip-0.1.0/shrip/cli.py +144 -0
- shrip-0.1.0/shrip/upload.py +114 -0
- shrip-0.1.0/shrip.egg-info/PKG-INFO +91 -0
- shrip-0.1.0/shrip.egg-info/SOURCES.txt +17 -0
- shrip-0.1.0/shrip.egg-info/dependency_links.txt +1 -0
- shrip-0.1.0/shrip.egg-info/entry_points.txt +2 -0
- shrip-0.1.0/shrip.egg-info/requires.txt +8 -0
- shrip-0.1.0/shrip.egg-info/top_level.txt +1 -0
- shrip-0.1.0/tests/test_archive.py +347 -0
- shrip-0.1.0/tests/test_cli.py +251 -0
- shrip-0.1.0/tests/test_upload.py +252 -0
shrip-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 nbfrodri
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
shrip-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: shrip
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Zip and share files from the terminal — no browser needed.
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Requires-Python: >=3.9
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Dist: typer>=0.9.0
|
|
10
|
+
Requires-Dist: requests>=2.31.0
|
|
11
|
+
Requires-Dist: rich>=13.0.0
|
|
12
|
+
Provides-Extra: dev
|
|
13
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
14
|
+
Requires-Dist: pytest-mock; extra == "dev"
|
|
15
|
+
Requires-Dist: ruff; extra == "dev"
|
|
16
|
+
Dynamic: license-file
|
|
17
|
+
|
|
18
|
+
# shrip
|
|
19
|
+
|
|
20
|
+
**Zip and share files from the terminal — no browser needed.**
|
|
21
|
+
|
|
22
|
+
`shrip` bundles files and folders into a compressed archive and uploads it to [gofile.io](https://gofile.io), giving you a temporary public download link instantly. No accounts, no configuration, no context-switching.
|
|
23
|
+
|
|
24
|
+
## Installation
|
|
25
|
+
|
|
26
|
+
**From PyPI (recommended):**
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pip install shrip
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
**With [pipx](https://pipx.pypa.io/) (isolated install):**
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pipx install shrip
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
**From GitHub:**
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install git+https://github.com/nbfrodri/shrip.git
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
> Requires Python 3.9 or higher. Works on Windows, macOS, and Linux.
|
|
45
|
+
|
|
46
|
+
## Usage
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
# Share a single file
|
|
50
|
+
shrip report.pdf
|
|
51
|
+
|
|
52
|
+
# Share multiple files and folders
|
|
53
|
+
shrip ./src/ README.md logo.png --name project-handover
|
|
54
|
+
|
|
55
|
+
# Custom archive name
|
|
56
|
+
shrip ./build/ -n release-v2
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
**Example output:**
|
|
60
|
+
|
|
61
|
+
```
|
|
62
|
+
Compressing 3 items into project-handover.zip...
|
|
63
|
+
⠋ Compressing ████████████████████████████████████ 3/3 files
|
|
64
|
+
Uploading to gofile.io...
|
|
65
|
+
⠋ Uploading ████████████████████████████████████ 1.2/1.2 MB 850.3 kB/s
|
|
66
|
+
|
|
67
|
+
Success! Your file is live:
|
|
68
|
+
https://gofile.io/d/AbCd123
|
|
69
|
+
|
|
70
|
+
(Files are automatically deleted after a period of inactivity.)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Options
|
|
74
|
+
|
|
75
|
+
| Flag | Short | Description | Default |
|
|
76
|
+
|------|-------|-------------|---------|
|
|
77
|
+
| `--name` | `-n` | Custom archive name (without `.zip`) | `shrip_archive` |
|
|
78
|
+
| `--version` | `-v` | Show version and exit | |
|
|
79
|
+
| `--help` | | Show usage help | |
|
|
80
|
+
|
|
81
|
+
## How It Works
|
|
82
|
+
|
|
83
|
+
1. Validates that all provided paths exist.
|
|
84
|
+
2. Compresses everything into a temporary `.zip` archive — directories are walked recursively, preserving folder structure.
|
|
85
|
+
3. Uploads the archive to [gofile.io](https://gofile.io) (anonymous, no account needed, no file size limit).
|
|
86
|
+
4. Prints the download URL.
|
|
87
|
+
5. Deletes the temporary zip file automatically — even if the upload fails or you hit Ctrl+C.
|
|
88
|
+
|
|
89
|
+
## License
|
|
90
|
+
|
|
91
|
+
MIT
|
shrip-0.1.0/README.md
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# shrip
|
|
2
|
+
|
|
3
|
+
**Zip and share files from the terminal — no browser needed.**
|
|
4
|
+
|
|
5
|
+
`shrip` bundles files and folders into a compressed archive and uploads it to [gofile.io](https://gofile.io), giving you a temporary public download link instantly. No accounts, no configuration, no context-switching.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
**From PyPI (recommended):**
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install shrip
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
**With [pipx](https://pipx.pypa.io/) (isolated install):**
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pipx install shrip
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
**From GitHub:**
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pip install git+https://github.com/nbfrodri/shrip.git
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
> Requires Python 3.9 or higher. Works on Windows, macOS, and Linux.
|
|
28
|
+
|
|
29
|
+
## Usage
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
# Share a single file
|
|
33
|
+
shrip report.pdf
|
|
34
|
+
|
|
35
|
+
# Share multiple files and folders
|
|
36
|
+
shrip ./src/ README.md logo.png --name project-handover
|
|
37
|
+
|
|
38
|
+
# Custom archive name
|
|
39
|
+
shrip ./build/ -n release-v2
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
**Example output:**
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
Compressing 3 items into project-handover.zip...
|
|
46
|
+
⠋ Compressing ████████████████████████████████████ 3/3 files
|
|
47
|
+
Uploading to gofile.io...
|
|
48
|
+
⠋ Uploading ████████████████████████████████████ 1.2/1.2 MB 850.3 kB/s
|
|
49
|
+
|
|
50
|
+
Success! Your file is live:
|
|
51
|
+
https://gofile.io/d/AbCd123
|
|
52
|
+
|
|
53
|
+
(Files are automatically deleted after a period of inactivity.)
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Options
|
|
57
|
+
|
|
58
|
+
| Flag | Short | Description | Default |
|
|
59
|
+
|------|-------|-------------|---------|
|
|
60
|
+
| `--name` | `-n` | Custom archive name (without `.zip`) | `shrip_archive` |
|
|
61
|
+
| `--version` | `-v` | Show version and exit | |
|
|
62
|
+
| `--help` | | Show usage help | |
|
|
63
|
+
|
|
64
|
+
## How It Works
|
|
65
|
+
|
|
66
|
+
1. Validates that all provided paths exist.
|
|
67
|
+
2. Compresses everything into a temporary `.zip` archive — directories are walked recursively, preserving folder structure.
|
|
68
|
+
3. Uploads the archive to [gofile.io](https://gofile.io) (anonymous, no account needed, no file size limit).
|
|
69
|
+
4. Prints the download URL.
|
|
70
|
+
5. Deletes the temporary zip file automatically — even if the upload fails or you hit Ctrl+C.
|
|
71
|
+
|
|
72
|
+
## License
|
|
73
|
+
|
|
74
|
+
MIT
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "shrip"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Zip and share files from the terminal — no browser needed."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
dependencies = [
|
|
13
|
+
"typer>=0.9.0",
|
|
14
|
+
"requests>=2.31.0",
|
|
15
|
+
"rich>=13.0.0",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[project.scripts]
|
|
19
|
+
shrip = "shrip.cli:app"
|
|
20
|
+
|
|
21
|
+
[project.optional-dependencies]
|
|
22
|
+
dev = ["pytest>=7.0", "pytest-mock", "ruff"]
|
|
23
|
+
|
|
24
|
+
[tool.ruff]
|
|
25
|
+
target-version = "py39"
|
|
26
|
+
line-length = 100
|
shrip-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Archive creation module — zips files and directories into a temp file."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import tempfile
|
|
7
|
+
import zipfile
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Callable, Optional
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def sanitize_name(name: str) -> str:
|
|
13
|
+
"""Strip dangerous characters and normalize an archive name."""
|
|
14
|
+
name = name.removesuffix(".zip")
|
|
15
|
+
name = re.sub(r'[/\\:*?"<>|]', "", name)
|
|
16
|
+
name = name.replace(" ", "_")
|
|
17
|
+
name = name.strip("._")
|
|
18
|
+
return name or "shrip_archive"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _resolve_safe(path: Path, allowed_roots: list[Path]) -> Path | None:
|
|
22
|
+
"""Resolve a path and return it only if it lives under one of the allowed roots."""
|
|
23
|
+
try:
|
|
24
|
+
resolved = path.resolve()
|
|
25
|
+
except OSError:
|
|
26
|
+
return None
|
|
27
|
+
for root in allowed_roots:
|
|
28
|
+
try:
|
|
29
|
+
resolved.relative_to(root)
|
|
30
|
+
return resolved
|
|
31
|
+
except ValueError:
|
|
32
|
+
continue
|
|
33
|
+
return None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _collect_files(paths: list[Path]) -> list[tuple[Path, str]]:
|
|
37
|
+
"""
|
|
38
|
+
Walk all input paths and return a list of (absolute_path, arcname) pairs.
|
|
39
|
+
|
|
40
|
+
- Files are stored with their filename only.
|
|
41
|
+
- Directories are walked recursively; files inside are stored relative to the
|
|
42
|
+
directory itself (e.g., mydir/sub/file.txt → mydir/sub/file.txt).
|
|
43
|
+
- Duplicate arcnames are made unique by prefixing with a counter.
|
|
44
|
+
"""
|
|
45
|
+
entries: list[tuple[Path, str]] = []
|
|
46
|
+
seen_arcnames: dict[str, int] = {}
|
|
47
|
+
allowed_roots = [p.resolve().parent if p.is_file() else p.resolve() for p in paths]
|
|
48
|
+
|
|
49
|
+
for input_path in paths:
|
|
50
|
+
input_path = input_path.resolve()
|
|
51
|
+
|
|
52
|
+
if input_path.is_file():
|
|
53
|
+
arcname = input_path.name
|
|
54
|
+
arcname = _deduplicate_arcname(arcname, seen_arcnames)
|
|
55
|
+
entries.append((input_path, arcname))
|
|
56
|
+
|
|
57
|
+
elif input_path.is_dir():
|
|
58
|
+
dir_name = input_path.name
|
|
59
|
+
has_children = False
|
|
60
|
+
for child in sorted(input_path.rglob("*")):
|
|
61
|
+
if not child.is_file():
|
|
62
|
+
continue
|
|
63
|
+
# Symlink safety: resolve and verify target is inside allowed roots
|
|
64
|
+
if child.is_symlink():
|
|
65
|
+
safe = _resolve_safe(child, allowed_roots)
|
|
66
|
+
if safe is None:
|
|
67
|
+
continue
|
|
68
|
+
relative = child.relative_to(input_path)
|
|
69
|
+
arcname = str(Path(dir_name) / relative)
|
|
70
|
+
arcname = _deduplicate_arcname(arcname, seen_arcnames)
|
|
71
|
+
entries.append((child, arcname))
|
|
72
|
+
has_children = True
|
|
73
|
+
|
|
74
|
+
# Empty directory: add a directory entry
|
|
75
|
+
if not has_children:
|
|
76
|
+
entries.append((input_path, dir_name + "/"))
|
|
77
|
+
|
|
78
|
+
return entries
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _deduplicate_arcname(arcname: str, seen: dict[str, int]) -> str:
|
|
82
|
+
"""If arcname was already used, prefix with a counter to make it unique."""
|
|
83
|
+
if arcname not in seen:
|
|
84
|
+
seen[arcname] = 1
|
|
85
|
+
return arcname
|
|
86
|
+
seen[arcname] += 1
|
|
87
|
+
stem = Path(arcname)
|
|
88
|
+
new_name = (
|
|
89
|
+
f"{stem.parent}/{stem.stem}_{seen[arcname]}{stem.suffix}"
|
|
90
|
+
if str(stem.parent) != "."
|
|
91
|
+
else f"{stem.stem}_{seen[arcname]}{stem.suffix}"
|
|
92
|
+
)
|
|
93
|
+
# Recurse in case the new name also collides
|
|
94
|
+
return _deduplicate_arcname(new_name, seen)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def create_archive(
|
|
98
|
+
paths: list[Path],
|
|
99
|
+
name: str = "shrip_archive",
|
|
100
|
+
progress_callback: Optional[Callable[[Path], None]] = None,
|
|
101
|
+
) -> Path:
|
|
102
|
+
"""
|
|
103
|
+
Create a temporary .zip archive containing all provided files and directories.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
paths: Files and/or directories to include.
|
|
107
|
+
name: Archive base name (without .zip). Sanitized automatically.
|
|
108
|
+
progress_callback: Called with each file path after it is added to the archive.
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
Path to the created temporary zip file.
|
|
112
|
+
|
|
113
|
+
Raises:
|
|
114
|
+
FileNotFoundError: If any input path does not exist.
|
|
115
|
+
PermissionError: If any input file cannot be read.
|
|
116
|
+
ValueError: If no files are found to archive.
|
|
117
|
+
"""
|
|
118
|
+
safe_name = sanitize_name(name)
|
|
119
|
+
|
|
120
|
+
# Validate all paths exist and are readable upfront
|
|
121
|
+
for p in paths:
|
|
122
|
+
resolved = p.resolve()
|
|
123
|
+
if not resolved.exists():
|
|
124
|
+
raise FileNotFoundError(f"Path does not exist: {p}")
|
|
125
|
+
if resolved.is_file() and not _is_readable(resolved):
|
|
126
|
+
raise PermissionError(f"Cannot read file: {p}")
|
|
127
|
+
|
|
128
|
+
entries = _collect_files(paths)
|
|
129
|
+
|
|
130
|
+
# Check we have something to zip (allow empty dirs, but not zero entries)
|
|
131
|
+
if not entries:
|
|
132
|
+
raise ValueError("No files found to archive.")
|
|
133
|
+
|
|
134
|
+
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".zip", prefix=f".shrip_{safe_name}_")
|
|
135
|
+
tmp_path = Path(tmp.name)
|
|
136
|
+
tmp.close()
|
|
137
|
+
|
|
138
|
+
try:
|
|
139
|
+
with zipfile.ZipFile(tmp_path, "w", zipfile.ZIP_DEFLATED) as zf:
|
|
140
|
+
for file_path, arcname in entries:
|
|
141
|
+
if arcname.endswith("/"):
|
|
142
|
+
# Empty directory entry
|
|
143
|
+
zf.mkdir(arcname)
|
|
144
|
+
else:
|
|
145
|
+
zf.write(file_path, arcname)
|
|
146
|
+
if progress_callback is not None:
|
|
147
|
+
progress_callback(file_path)
|
|
148
|
+
return tmp_path
|
|
149
|
+
except Exception:
|
|
150
|
+
# Cleanup on failure
|
|
151
|
+
tmp_path.unlink(missing_ok=True)
|
|
152
|
+
raise
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _is_readable(path: Path) -> bool:
|
|
156
|
+
"""Check if a file can be opened for reading."""
|
|
157
|
+
try:
|
|
158
|
+
with open(path, "rb"):
|
|
159
|
+
return True
|
|
160
|
+
except (PermissionError, OSError):
|
|
161
|
+
return False
|
shrip-0.1.0/shrip/cli.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""CLI entry point — Typer app with Rich progress bars."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Annotated, Optional
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.progress import (
|
|
11
|
+
BarColumn,
|
|
12
|
+
DownloadColumn,
|
|
13
|
+
Progress,
|
|
14
|
+
SpinnerColumn,
|
|
15
|
+
TextColumn,
|
|
16
|
+
TransferSpeedColumn,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
from shrip import __version__
|
|
20
|
+
from shrip.archive import create_archive, sanitize_name
|
|
21
|
+
from shrip.upload import UploadError, upload_to_gofile
|
|
22
|
+
|
|
23
|
+
app = typer.Typer(
|
|
24
|
+
name="shrip",
|
|
25
|
+
help="Zip and share files from the terminal.",
|
|
26
|
+
add_completion=False,
|
|
27
|
+
)
|
|
28
|
+
console = Console()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _version_callback(value: bool) -> None:
|
|
32
|
+
if value:
|
|
33
|
+
console.print(f"shrip {__version__}")
|
|
34
|
+
raise typer.Exit()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@app.command()
|
|
38
|
+
def main(
|
|
39
|
+
paths: Annotated[
|
|
40
|
+
list[Path],
|
|
41
|
+
typer.Argument(help="Files and/or folders to share."),
|
|
42
|
+
],
|
|
43
|
+
name: Annotated[
|
|
44
|
+
str,
|
|
45
|
+
typer.Option("--name", "-n", help="Archive name (without .zip)."),
|
|
46
|
+
] = "shrip_archive",
|
|
47
|
+
version: Annotated[
|
|
48
|
+
Optional[bool],
|
|
49
|
+
typer.Option(
|
|
50
|
+
"--version",
|
|
51
|
+
"-v",
|
|
52
|
+
help="Show version and exit.",
|
|
53
|
+
callback=_version_callback,
|
|
54
|
+
is_eager=True,
|
|
55
|
+
),
|
|
56
|
+
] = None,
|
|
57
|
+
) -> None:
|
|
58
|
+
"""Zip files and folders, upload to gofile.io, and get a download link."""
|
|
59
|
+
# Normalize name
|
|
60
|
+
if not name or not name.strip():
|
|
61
|
+
name = "shrip_archive"
|
|
62
|
+
safe_name = sanitize_name(name)
|
|
63
|
+
|
|
64
|
+
# Validate all paths upfront, report ALL invalid ones at once
|
|
65
|
+
invalid = [p for p in paths if not p.resolve().exists()]
|
|
66
|
+
if invalid:
|
|
67
|
+
for p in invalid:
|
|
68
|
+
console.print(f"[red]Error:[/red] Path does not exist: {p}")
|
|
69
|
+
raise typer.Exit(code=1)
|
|
70
|
+
|
|
71
|
+
# Count items for display
|
|
72
|
+
item_count = len(paths)
|
|
73
|
+
item_label = "item" if item_count == 1 else "items"
|
|
74
|
+
|
|
75
|
+
zip_path: Path | None = None
|
|
76
|
+
try:
|
|
77
|
+
# ── Compress ─────────────────────────────────────────────────
|
|
78
|
+
# Count total files for the progress bar
|
|
79
|
+
total_files = 0
|
|
80
|
+
for p in paths:
|
|
81
|
+
rp = p.resolve()
|
|
82
|
+
if rp.is_file():
|
|
83
|
+
total_files += 1
|
|
84
|
+
elif rp.is_dir():
|
|
85
|
+
total_files += sum(1 for f in rp.rglob("*") if f.is_file())
|
|
86
|
+
if total_files == 0:
|
|
87
|
+
total_files = 1 # empty dir counts as 1 entry
|
|
88
|
+
|
|
89
|
+
console.print(
|
|
90
|
+
f"\n[cyan]Compressing {item_count} {item_label} into {safe_name}.zip...[/cyan]"
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
with Progress(
|
|
94
|
+
SpinnerColumn(),
|
|
95
|
+
TextColumn("[progress.description]{task.description}"),
|
|
96
|
+
BarColumn(),
|
|
97
|
+
TextColumn("{task.completed}/{task.total} files"),
|
|
98
|
+
console=console,
|
|
99
|
+
) as progress:
|
|
100
|
+
task = progress.add_task("Compressing", total=total_files)
|
|
101
|
+
|
|
102
|
+
def on_file_compressed(file_path: Path) -> None:
|
|
103
|
+
progress.advance(task)
|
|
104
|
+
|
|
105
|
+
zip_path = create_archive(paths, name, progress_callback=on_file_compressed)
|
|
106
|
+
|
|
107
|
+
# ── Upload ───────────────────────────────────────────────────
|
|
108
|
+
zip_size = zip_path.stat().st_size
|
|
109
|
+
console.print("[cyan]Uploading to gofile.io...[/cyan]")
|
|
110
|
+
|
|
111
|
+
with Progress(
|
|
112
|
+
SpinnerColumn(),
|
|
113
|
+
TextColumn("[progress.description]{task.description}"),
|
|
114
|
+
BarColumn(),
|
|
115
|
+
DownloadColumn(),
|
|
116
|
+
TransferSpeedColumn(),
|
|
117
|
+
console=console,
|
|
118
|
+
) as progress:
|
|
119
|
+
task = progress.add_task("Uploading", total=zip_size)
|
|
120
|
+
|
|
121
|
+
def on_bytes_sent(cumulative: int) -> None:
|
|
122
|
+
progress.update(task, completed=cumulative)
|
|
123
|
+
|
|
124
|
+
download_url = upload_to_gofile(zip_path, progress_callback=on_bytes_sent)
|
|
125
|
+
|
|
126
|
+
# ── Success ──────────────────────────────────────────────────
|
|
127
|
+
console.print("\n[bold green]Success! Your file is live:[/bold green]")
|
|
128
|
+
console.print(f"[bold]{download_url}[/bold]")
|
|
129
|
+
console.print(
|
|
130
|
+
"\n[dim](Files are automatically deleted after a period of inactivity.)[/dim]\n"
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
except KeyboardInterrupt:
|
|
134
|
+
console.print("\n[yellow]Interrupted.[/yellow]")
|
|
135
|
+
raise typer.Exit(code=130)
|
|
136
|
+
except UploadError as e:
|
|
137
|
+
console.print(f"\n[red]Error:[/red] {e}")
|
|
138
|
+
raise typer.Exit(code=1)
|
|
139
|
+
except (FileNotFoundError, PermissionError, ValueError) as e:
|
|
140
|
+
console.print(f"\n[red]Error:[/red] {e}")
|
|
141
|
+
raise typer.Exit(code=1)
|
|
142
|
+
finally:
|
|
143
|
+
if zip_path is not None:
|
|
144
|
+
zip_path.unlink(missing_ok=True)
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Upload module — sends a file to gofile.io and returns the download URL."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Callable, Optional
|
|
7
|
+
|
|
8
|
+
import requests
|
|
9
|
+
|
|
10
|
+
GOFILE_UPLOAD_URL = "https://upload.gofile.io/uploadfile"
|
|
11
|
+
CONNECT_TIMEOUT = 10 # seconds
|
|
12
|
+
READ_TIMEOUT = 300 # seconds (5 min, generous for large files)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class UploadError(Exception):
|
|
16
|
+
"""Raised when the upload fails for any reason."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class _ProgressReader:
|
|
20
|
+
"""Wraps a file object to track bytes read and call a progress callback."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, fileobj, total_size: int, callback: Callable[[int], None]):
|
|
23
|
+
self._fileobj = fileobj
|
|
24
|
+
self._total_size = total_size
|
|
25
|
+
self._bytes_read = 0
|
|
26
|
+
self._callback = callback
|
|
27
|
+
|
|
28
|
+
def read(self, size: int = -1) -> bytes:
|
|
29
|
+
chunk = self._fileobj.read(size)
|
|
30
|
+
self._bytes_read += len(chunk)
|
|
31
|
+
self._callback(self._bytes_read)
|
|
32
|
+
return chunk
|
|
33
|
+
|
|
34
|
+
def __len__(self) -> int:
|
|
35
|
+
return self._total_size
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def upload_to_gofile(
|
|
39
|
+
file_path: Path,
|
|
40
|
+
progress_callback: Optional[Callable[[int], None]] = None,
|
|
41
|
+
) -> str:
|
|
42
|
+
"""
|
|
43
|
+
Upload a file to gofile.io and return the download page URL.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
file_path: Path to the file to upload.
|
|
47
|
+
progress_callback: Called with cumulative bytes sent after each chunk.
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
The download page URL (e.g., "https://gofile.io/d/AbCd123").
|
|
51
|
+
|
|
52
|
+
Raises:
|
|
53
|
+
UploadError: On any upload or API failure.
|
|
54
|
+
ValueError: If the file is empty (0 bytes).
|
|
55
|
+
"""
|
|
56
|
+
file_path = file_path.resolve()
|
|
57
|
+
file_size = file_path.stat().st_size
|
|
58
|
+
|
|
59
|
+
if file_size == 0:
|
|
60
|
+
raise ValueError("Archive is empty, nothing to upload.")
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
with open(file_path, "rb") as f:
|
|
64
|
+
if progress_callback is not None:
|
|
65
|
+
reader = _ProgressReader(f, file_size, progress_callback)
|
|
66
|
+
files = {"file": (file_path.name, reader)}
|
|
67
|
+
else:
|
|
68
|
+
files = {"file": (file_path.name, f)}
|
|
69
|
+
|
|
70
|
+
response = requests.post(
|
|
71
|
+
GOFILE_UPLOAD_URL,
|
|
72
|
+
files=files,
|
|
73
|
+
timeout=(CONNECT_TIMEOUT, READ_TIMEOUT),
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
except requests.exceptions.SSLError:
|
|
77
|
+
raise UploadError("SSL verification failed when connecting to gofile.io.")
|
|
78
|
+
except requests.exceptions.Timeout:
|
|
79
|
+
raise UploadError("Upload timed out — check your connection and try again.")
|
|
80
|
+
except requests.exceptions.ConnectionError:
|
|
81
|
+
raise UploadError("Could not reach gofile.io — check your internet connection.")
|
|
82
|
+
except requests.exceptions.RequestException as e:
|
|
83
|
+
raise UploadError(f"Upload failed: {e}")
|
|
84
|
+
|
|
85
|
+
if response.status_code == 429:
|
|
86
|
+
raise UploadError("Rate limited by gofile.io — wait a moment and try again.")
|
|
87
|
+
if response.status_code >= 500:
|
|
88
|
+
raise UploadError("gofile.io is temporarily unavailable — try again later.")
|
|
89
|
+
if response.status_code != 200:
|
|
90
|
+
raise UploadError(f"Upload failed with HTTP {response.status_code}.")
|
|
91
|
+
|
|
92
|
+
try:
|
|
93
|
+
body = response.json()
|
|
94
|
+
except (ValueError, requests.exceptions.JSONDecodeError):
|
|
95
|
+
raise UploadError("Invalid response from gofile.io.")
|
|
96
|
+
|
|
97
|
+
status = body.get("status")
|
|
98
|
+
if status != "ok":
|
|
99
|
+
msg = (
|
|
100
|
+
body.get("data", {}).get("message", "unknown error")
|
|
101
|
+
if isinstance(body.get("data"), dict)
|
|
102
|
+
else status
|
|
103
|
+
)
|
|
104
|
+
raise UploadError(f"gofile.io returned an error: {msg}")
|
|
105
|
+
|
|
106
|
+
data = body.get("data")
|
|
107
|
+
if not isinstance(data, dict):
|
|
108
|
+
raise UploadError("Unexpected response from gofile.io — the API may have changed.")
|
|
109
|
+
|
|
110
|
+
download_url = data.get("downloadPage")
|
|
111
|
+
if not download_url:
|
|
112
|
+
raise UploadError("Unexpected response from gofile.io — the API may have changed.")
|
|
113
|
+
|
|
114
|
+
return download_url
|