pdfstrip 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.
- pdfstrip-0.1.0/.github/workflows/publish.yml +17 -0
- pdfstrip-0.1.0/.gitignore +10 -0
- pdfstrip-0.1.0/.python-version +1 -0
- pdfstrip-0.1.0/CLAUDE.md +35 -0
- pdfstrip-0.1.0/LICENSE +21 -0
- pdfstrip-0.1.0/PKG-INFO +60 -0
- pdfstrip-0.1.0/README.md +46 -0
- pdfstrip-0.1.0/pdfstrip/__init__.py +1 -0
- pdfstrip-0.1.0/pdfstrip/cli.py +104 -0
- pdfstrip-0.1.0/pdfstrip/core.py +112 -0
- pdfstrip-0.1.0/pdfstrip/output.py +58 -0
- pdfstrip-0.1.0/pyproject.toml +28 -0
- pdfstrip-0.1.0/tests/__init__.py +0 -0
- pdfstrip-0.1.0/tests/conftest.py +37 -0
- pdfstrip-0.1.0/tests/test_cli.py +60 -0
- pdfstrip-0.1.0/tests/test_core.py +119 -0
- pdfstrip-0.1.0/uv.lock +474 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- "v*"
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
publish:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
permissions:
|
|
12
|
+
id-token: write
|
|
13
|
+
steps:
|
|
14
|
+
- uses: actions/checkout@v4
|
|
15
|
+
- uses: astral-sh/setup-uv@v4
|
|
16
|
+
- run: uv build
|
|
17
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.14
|
pdfstrip-0.1.0/CLAUDE.md
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# CLAUDE.md
|
|
2
|
+
|
|
3
|
+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
4
|
+
|
|
5
|
+
## Project Overview
|
|
6
|
+
|
|
7
|
+
pdfstrip is a CLI tool that removes passwords from PDF files. Built with pikepdf, Typer, and Rich. Managed with `uv`.
|
|
8
|
+
|
|
9
|
+
## Commands
|
|
10
|
+
|
|
11
|
+
- **Run**: `uv run pdfstrip <files> -p <password>`
|
|
12
|
+
- **Help**: `uv run pdfstrip --help`
|
|
13
|
+
- **Tests**: `uv run pytest`
|
|
14
|
+
- **Single test**: `uv run pytest tests/test_core.py::TestUnlockPdf::test_success`
|
|
15
|
+
- **Sync deps**: `uv sync`
|
|
16
|
+
- **Add dep**: `uv add <package>`
|
|
17
|
+
- **Install globally**: `uv tool install -e .`
|
|
18
|
+
|
|
19
|
+
## Architecture
|
|
20
|
+
|
|
21
|
+
Three-module split with strict import boundaries:
|
|
22
|
+
|
|
23
|
+
- **`core.py`** — Pure logic, no CLI or Rich imports. Every `unlock_pdf()` call returns a frozen `UnlockResult` dataclass (never raises). This makes batch processing trivial — collect results, then render.
|
|
24
|
+
- **`output.py`** — Rich rendering (progress bar, colored results, summary table, password prompt). Imports from core only.
|
|
25
|
+
- **`cli.py`** — Typer app that wires core + output together. Handles argument parsing, validation, single vs batch dispatch.
|
|
26
|
+
|
|
27
|
+
Key patterns:
|
|
28
|
+
- In-place unlock uses tempfile + `shutil.move()` because pikepdf can't overwrite its input while open.
|
|
29
|
+
- Directory expansion is non-recursive (`*.pdf` glob, not `**/*.pdf`).
|
|
30
|
+
- `--output`, `--output-dir`, and `--in-place` are mutually exclusive; validated before any work starts.
|
|
31
|
+
- Password prompt is deferred until after file validation.
|
|
32
|
+
|
|
33
|
+
## Tests
|
|
34
|
+
|
|
35
|
+
Test fixtures in `conftest.py` generate real encrypted/unencrypted PDFs with pikepdf. CLI tests use `typer.testing.CliRunner`.
|
pdfstrip-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Maximilian Walterskirchen
|
|
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.
|
pdfstrip-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pdfstrip
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Remove passwords from PDF files
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Classifier: Environment :: Console
|
|
8
|
+
Classifier: Topic :: Utilities
|
|
9
|
+
Requires-Python: >=3.11
|
|
10
|
+
Requires-Dist: pikepdf>=9
|
|
11
|
+
Requires-Dist: rich>=13
|
|
12
|
+
Requires-Dist: typer>=0.12
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# pdfstrip
|
|
16
|
+
|
|
17
|
+
Remove passwords from PDF files.
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pipx install pdfstrip
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Or with `uv`:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
uv tool install pdfstrip
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Usage
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
# Unlock a single file
|
|
35
|
+
pdfstrip secret.pdf -p mypassword
|
|
36
|
+
|
|
37
|
+
# Unlock to a specific output
|
|
38
|
+
pdfstrip secret.pdf -p mypassword -o unlocked.pdf
|
|
39
|
+
|
|
40
|
+
# Unlock in place
|
|
41
|
+
pdfstrip secret.pdf -p mypassword --in-place
|
|
42
|
+
|
|
43
|
+
# Batch unlock a directory
|
|
44
|
+
pdfstrip ./pdfs/ -p mypassword --skip-unprotected
|
|
45
|
+
|
|
46
|
+
# Dry run
|
|
47
|
+
pdfstrip secret.pdf -p mypassword --dry-run
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Options
|
|
51
|
+
|
|
52
|
+
| Flag | Short | Description |
|
|
53
|
+
|------|-------|-------------|
|
|
54
|
+
| `--password` | `-p` | PDF password |
|
|
55
|
+
| `--output` | `-o` | Output file path (single file only) |
|
|
56
|
+
| `--output-dir` | `-d` | Output directory for unlocked files |
|
|
57
|
+
| `--in-place` | `-i` | Overwrite original files |
|
|
58
|
+
| `--force` | `-f` | Overwrite existing output files |
|
|
59
|
+
| `--skip-unprotected` | `-s` | Skip files that are not encrypted |
|
|
60
|
+
| `--dry-run` | `-n` | Show what would be done without writing |
|
pdfstrip-0.1.0/README.md
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# pdfstrip
|
|
2
|
+
|
|
3
|
+
Remove passwords from PDF files.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pipx install pdfstrip
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Or with `uv`:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
uv tool install pdfstrip
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Usage
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
# Unlock a single file
|
|
21
|
+
pdfstrip secret.pdf -p mypassword
|
|
22
|
+
|
|
23
|
+
# Unlock to a specific output
|
|
24
|
+
pdfstrip secret.pdf -p mypassword -o unlocked.pdf
|
|
25
|
+
|
|
26
|
+
# Unlock in place
|
|
27
|
+
pdfstrip secret.pdf -p mypassword --in-place
|
|
28
|
+
|
|
29
|
+
# Batch unlock a directory
|
|
30
|
+
pdfstrip ./pdfs/ -p mypassword --skip-unprotected
|
|
31
|
+
|
|
32
|
+
# Dry run
|
|
33
|
+
pdfstrip secret.pdf -p mypassword --dry-run
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Options
|
|
37
|
+
|
|
38
|
+
| Flag | Short | Description |
|
|
39
|
+
|------|-------|-------------|
|
|
40
|
+
| `--password` | `-p` | PDF password |
|
|
41
|
+
| `--output` | `-o` | Output file path (single file only) |
|
|
42
|
+
| `--output-dir` | `-d` | Output directory for unlocked files |
|
|
43
|
+
| `--in-place` | `-i` | Overwrite original files |
|
|
44
|
+
| `--force` | `-f` | Overwrite existing output files |
|
|
45
|
+
| `--skip-unprotected` | `-s` | Skip files that are not encrypted |
|
|
46
|
+
| `--dry-run` | `-n` | Show what would be done without writing |
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Annotated, Optional
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from pdfstrip.core import UnlockResult, UnlockStatus, collect_pdf_files, unlock_pdf
|
|
9
|
+
from pdfstrip.output import (
|
|
10
|
+
confirm,
|
|
11
|
+
console,
|
|
12
|
+
create_progress,
|
|
13
|
+
print_result,
|
|
14
|
+
print_summary,
|
|
15
|
+
prompt_password,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
app = typer.Typer(add_completion=False)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _validate_options(
|
|
22
|
+
output: Path | None,
|
|
23
|
+
output_dir: Path | None,
|
|
24
|
+
in_place: bool,
|
|
25
|
+
) -> None:
|
|
26
|
+
set_count = sum([output is not None, output_dir is not None, in_place])
|
|
27
|
+
if set_count > 1:
|
|
28
|
+
console.print("[red]Error:[/] --output, --output-dir, and --in-place are mutually exclusive")
|
|
29
|
+
raise typer.Exit(1)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@app.command()
|
|
33
|
+
def main(
|
|
34
|
+
files: Annotated[list[Path], typer.Argument(help="PDF files or directories to unlock")],
|
|
35
|
+
password: Annotated[Optional[str], typer.Option("--password", "-p", help="PDF password")] = None,
|
|
36
|
+
output: Annotated[Optional[Path], typer.Option("--output", "-o", help="Output file path (single file only)")] = None,
|
|
37
|
+
output_dir: Annotated[Optional[Path], typer.Option("--output-dir", "-d", help="Output directory for unlocked files")] = None,
|
|
38
|
+
in_place: Annotated[bool, typer.Option("--in-place", "-i", help="Overwrite original files")] = False,
|
|
39
|
+
force: Annotated[bool, typer.Option("--force", "-f", help="Overwrite existing output files")] = False,
|
|
40
|
+
skip_unprotected: Annotated[bool, typer.Option("--skip-unprotected", "-s", help="Skip files that are not encrypted")] = False,
|
|
41
|
+
dry_run: Annotated[bool, typer.Option("--dry-run", "-n", help="Show what would be done without writing")] = False,
|
|
42
|
+
) -> None:
|
|
43
|
+
"""Remove passwords from PDF files."""
|
|
44
|
+
_validate_options(output, output_dir, in_place)
|
|
45
|
+
|
|
46
|
+
pdf_files = collect_pdf_files(files)
|
|
47
|
+
if not pdf_files:
|
|
48
|
+
console.print("[yellow]No PDF files found.[/]")
|
|
49
|
+
raise typer.Exit(0)
|
|
50
|
+
|
|
51
|
+
if output is not None and len(pdf_files) > 1:
|
|
52
|
+
console.print("[red]Error:[/] --output can only be used with a single file")
|
|
53
|
+
raise typer.Exit(1)
|
|
54
|
+
|
|
55
|
+
if in_place and not dry_run:
|
|
56
|
+
if not confirm("Overwrite original files?"):
|
|
57
|
+
raise typer.Exit(0)
|
|
58
|
+
|
|
59
|
+
if password is None:
|
|
60
|
+
password = prompt_password()
|
|
61
|
+
|
|
62
|
+
batch = len(pdf_files) > 1
|
|
63
|
+
results: list[UnlockResult] = []
|
|
64
|
+
|
|
65
|
+
if batch:
|
|
66
|
+
progress = create_progress()
|
|
67
|
+
with progress:
|
|
68
|
+
task = progress.add_task("Unlocking", total=len(pdf_files))
|
|
69
|
+
for f in pdf_files:
|
|
70
|
+
result = unlock_pdf(
|
|
71
|
+
f,
|
|
72
|
+
password,
|
|
73
|
+
output=None,
|
|
74
|
+
output_dir=output_dir,
|
|
75
|
+
in_place=in_place,
|
|
76
|
+
dry_run=dry_run,
|
|
77
|
+
force=force,
|
|
78
|
+
skip_unprotected=skip_unprotected,
|
|
79
|
+
)
|
|
80
|
+
results.append(result)
|
|
81
|
+
progress.advance(task)
|
|
82
|
+
for r in results:
|
|
83
|
+
print_result(r)
|
|
84
|
+
print_summary(results)
|
|
85
|
+
else:
|
|
86
|
+
result = unlock_pdf(
|
|
87
|
+
pdf_files[0],
|
|
88
|
+
password,
|
|
89
|
+
output=output,
|
|
90
|
+
output_dir=output_dir,
|
|
91
|
+
in_place=in_place,
|
|
92
|
+
dry_run=dry_run,
|
|
93
|
+
force=force,
|
|
94
|
+
skip_unprotected=skip_unprotected,
|
|
95
|
+
)
|
|
96
|
+
results.append(result)
|
|
97
|
+
print_result(result)
|
|
98
|
+
|
|
99
|
+
if any(r.status in {UnlockStatus.WRONG_PASSWORD, UnlockStatus.FILE_NOT_FOUND, UnlockStatus.ERROR, UnlockStatus.OUTPUT_EXISTS} for r in results):
|
|
100
|
+
raise typer.Exit(1)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
if __name__ == "__main__":
|
|
104
|
+
app()
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
import tempfile
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import pikepdf
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class UnlockStatus(Enum):
|
|
13
|
+
SUCCESS = "success"
|
|
14
|
+
ALREADY_UNLOCKED = "already_unlocked"
|
|
15
|
+
WRONG_PASSWORD = "wrong_password"
|
|
16
|
+
FILE_NOT_FOUND = "file_not_found"
|
|
17
|
+
OUTPUT_EXISTS = "output_exists"
|
|
18
|
+
ERROR = "error"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class UnlockResult:
|
|
23
|
+
source: Path
|
|
24
|
+
destination: Path | None
|
|
25
|
+
status: UnlockStatus
|
|
26
|
+
message: str
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def is_encrypted(path: Path) -> bool:
|
|
30
|
+
"""Check if a PDF file is password-protected."""
|
|
31
|
+
try:
|
|
32
|
+
with pikepdf.open(path):
|
|
33
|
+
return False
|
|
34
|
+
except pikepdf.PasswordError:
|
|
35
|
+
return True
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def collect_pdf_files(paths: list[Path]) -> list[Path]:
|
|
39
|
+
"""Expand directories to *.pdf (non-recursive), pass files through."""
|
|
40
|
+
result: list[Path] = []
|
|
41
|
+
for p in paths:
|
|
42
|
+
if p.is_dir():
|
|
43
|
+
result.extend(sorted(p.glob("*.pdf")))
|
|
44
|
+
else:
|
|
45
|
+
result.append(p)
|
|
46
|
+
return result
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def resolve_output_path(
|
|
50
|
+
source: Path,
|
|
51
|
+
*,
|
|
52
|
+
output: Path | None = None,
|
|
53
|
+
output_dir: Path | None = None,
|
|
54
|
+
in_place: bool = False,
|
|
55
|
+
) -> Path:
|
|
56
|
+
"""Determine the destination path for an unlocked PDF."""
|
|
57
|
+
if output is not None:
|
|
58
|
+
return output
|
|
59
|
+
if output_dir is not None:
|
|
60
|
+
return output_dir / source.name
|
|
61
|
+
if in_place:
|
|
62
|
+
return source
|
|
63
|
+
# Default: <name>_unlocked.pdf in the same directory
|
|
64
|
+
return source.with_stem(f"{source.stem}_unlocked")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def unlock_pdf(
|
|
68
|
+
source: Path,
|
|
69
|
+
password: str,
|
|
70
|
+
*,
|
|
71
|
+
output: Path | None = None,
|
|
72
|
+
output_dir: Path | None = None,
|
|
73
|
+
in_place: bool = False,
|
|
74
|
+
dry_run: bool = False,
|
|
75
|
+
force: bool = False,
|
|
76
|
+
skip_unprotected: bool = False,
|
|
77
|
+
) -> UnlockResult:
|
|
78
|
+
"""Unlock a single PDF file. Returns a structured result."""
|
|
79
|
+
if not source.exists():
|
|
80
|
+
return UnlockResult(source, None, UnlockStatus.FILE_NOT_FOUND, "File not found")
|
|
81
|
+
|
|
82
|
+
if not is_encrypted(source):
|
|
83
|
+
if skip_unprotected:
|
|
84
|
+
return UnlockResult(source, None, UnlockStatus.ALREADY_UNLOCKED, "Skipped (not encrypted)")
|
|
85
|
+
return UnlockResult(source, None, UnlockStatus.ALREADY_UNLOCKED, "File is not encrypted")
|
|
86
|
+
|
|
87
|
+
dest = resolve_output_path(source, output=output, output_dir=output_dir, in_place=in_place)
|
|
88
|
+
|
|
89
|
+
if not in_place and dest.exists() and not force:
|
|
90
|
+
return UnlockResult(source, dest, UnlockStatus.OUTPUT_EXISTS, f"Output already exists: {dest}")
|
|
91
|
+
|
|
92
|
+
if dry_run:
|
|
93
|
+
return UnlockResult(source, dest, UnlockStatus.SUCCESS, "Would unlock (dry run)")
|
|
94
|
+
|
|
95
|
+
try:
|
|
96
|
+
with pikepdf.open(source, password=password) as pdf:
|
|
97
|
+
# Write to a temp file in the same directory, then move into place.
|
|
98
|
+
# pikepdf cannot overwrite its own input while the handle is open.
|
|
99
|
+
fd, tmp = tempfile.mkstemp(suffix=".pdf", dir=dest.parent)
|
|
100
|
+
try:
|
|
101
|
+
pdf.save(tmp)
|
|
102
|
+
except Exception:
|
|
103
|
+
Path(tmp).unlink(missing_ok=True)
|
|
104
|
+
raise
|
|
105
|
+
# Handle is closed — safe to move now.
|
|
106
|
+
shutil.move(tmp, dest)
|
|
107
|
+
except pikepdf.PasswordError:
|
|
108
|
+
return UnlockResult(source, dest, UnlockStatus.WRONG_PASSWORD, "Wrong password")
|
|
109
|
+
except Exception as exc:
|
|
110
|
+
return UnlockResult(source, dest, UnlockStatus.ERROR, str(exc))
|
|
111
|
+
|
|
112
|
+
return UnlockResult(source, dest, UnlockStatus.SUCCESS, f"Unlocked → {dest}")
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from rich.console import Console
|
|
4
|
+
from rich.progress import BarColumn, MofNCompleteColumn, Progress, TextColumn
|
|
5
|
+
from rich.table import Table
|
|
6
|
+
|
|
7
|
+
from pdfstrip.core import UnlockResult, UnlockStatus
|
|
8
|
+
|
|
9
|
+
console = Console()
|
|
10
|
+
|
|
11
|
+
STATUS_STYLES: dict[UnlockStatus, tuple[str, str]] = {
|
|
12
|
+
UnlockStatus.SUCCESS: ("green", "Unlocked"),
|
|
13
|
+
UnlockStatus.ALREADY_UNLOCKED: ("yellow", "Already unlocked"),
|
|
14
|
+
UnlockStatus.WRONG_PASSWORD: ("red", "Wrong password"),
|
|
15
|
+
UnlockStatus.FILE_NOT_FOUND: ("red", "Not found"),
|
|
16
|
+
UnlockStatus.OUTPUT_EXISTS: ("red", "Output exists"),
|
|
17
|
+
UnlockStatus.ERROR: ("red", "Error"),
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def create_progress() -> Progress:
|
|
22
|
+
return Progress(
|
|
23
|
+
TextColumn("[bold blue]{task.description}"),
|
|
24
|
+
BarColumn(),
|
|
25
|
+
MofNCompleteColumn(),
|
|
26
|
+
console=console,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def print_result(result: UnlockResult) -> None:
|
|
31
|
+
color, label = STATUS_STYLES[result.status]
|
|
32
|
+
console.print(f"[{color}]{label}[/] {result.source} — {result.message}")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def print_summary(results: list[UnlockResult]) -> None:
|
|
36
|
+
counts: dict[UnlockStatus, int] = {}
|
|
37
|
+
for r in results:
|
|
38
|
+
counts[r.status] = counts.get(r.status, 0) + 1
|
|
39
|
+
|
|
40
|
+
table = Table(title="Summary")
|
|
41
|
+
table.add_column("Status", style="bold")
|
|
42
|
+
table.add_column("Count", justify="right")
|
|
43
|
+
|
|
44
|
+
for status, (color, label) in STATUS_STYLES.items():
|
|
45
|
+
count = counts.get(status, 0)
|
|
46
|
+
if count:
|
|
47
|
+
table.add_row(f"[{color}]{label}[/]", str(count))
|
|
48
|
+
|
|
49
|
+
console.print(table)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def confirm(message: str) -> bool:
|
|
53
|
+
answer = console.input(f"{message} [y/N] ")
|
|
54
|
+
return answer.strip().lower() == "y"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def prompt_password() -> str:
|
|
58
|
+
return console.input("[bold]Password: [/]", password=True)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "pdfstrip"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Remove passwords from PDF files"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
requires-python = ">=3.11"
|
|
8
|
+
classifiers = [
|
|
9
|
+
"Environment :: Console",
|
|
10
|
+
"Topic :: Utilities",
|
|
11
|
+
]
|
|
12
|
+
dependencies = [
|
|
13
|
+
"pikepdf>=9",
|
|
14
|
+
"typer>=0.12",
|
|
15
|
+
"rich>=13",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[project.scripts]
|
|
19
|
+
pdfstrip = "pdfstrip.cli:app"
|
|
20
|
+
|
|
21
|
+
[build-system]
|
|
22
|
+
requires = ["hatchling"]
|
|
23
|
+
build-backend = "hatchling.build"
|
|
24
|
+
|
|
25
|
+
[dependency-groups]
|
|
26
|
+
dev = [
|
|
27
|
+
"pytest>=8",
|
|
28
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import pikepdf
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _make_pdf(path: Path, *, password: str | None = None) -> Path:
|
|
10
|
+
"""Create a minimal PDF, optionally password-protected."""
|
|
11
|
+
pdf = pikepdf.new()
|
|
12
|
+
pdf.add_blank_page(page_size=(200, 200))
|
|
13
|
+
encryption = pikepdf.Encryption(owner=password, user=password) if password else None
|
|
14
|
+
pdf.save(path, encryption=encryption)
|
|
15
|
+
pdf.close()
|
|
16
|
+
return path
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@pytest.fixture()
|
|
20
|
+
def protected_pdf(tmp_path: Path) -> Path:
|
|
21
|
+
return _make_pdf(tmp_path / "secret.pdf", password="pass123")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@pytest.fixture()
|
|
25
|
+
def unprotected_pdf(tmp_path: Path) -> Path:
|
|
26
|
+
return _make_pdf(tmp_path / "open.pdf")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@pytest.fixture()
|
|
30
|
+
def batch_dir(tmp_path: Path) -> Path:
|
|
31
|
+
d = tmp_path / "batch"
|
|
32
|
+
d.mkdir()
|
|
33
|
+
_make_pdf(d / "a.pdf", password="pass123")
|
|
34
|
+
_make_pdf(d / "b.pdf", password="pass123")
|
|
35
|
+
_make_pdf(d / "c.pdf", password="pass123")
|
|
36
|
+
_make_pdf(d / "open.pdf")
|
|
37
|
+
return d
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from typer.testing import CliRunner
|
|
6
|
+
|
|
7
|
+
from pdfstrip.cli import app
|
|
8
|
+
|
|
9
|
+
runner = CliRunner()
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TestCliSingleFile:
|
|
13
|
+
def test_unlock(self, protected_pdf: Path) -> None:
|
|
14
|
+
result = runner.invoke(app, [str(protected_pdf), "-p", "pass123"])
|
|
15
|
+
assert result.exit_code == 0
|
|
16
|
+
assert "Unlocked" in result.output
|
|
17
|
+
|
|
18
|
+
def test_wrong_password(self, protected_pdf: Path) -> None:
|
|
19
|
+
result = runner.invoke(app, [str(protected_pdf), "-p", "wrong"])
|
|
20
|
+
assert result.exit_code == 1
|
|
21
|
+
assert "Wrong password" in result.output
|
|
22
|
+
|
|
23
|
+
def test_unprotected(self, unprotected_pdf: Path) -> None:
|
|
24
|
+
result = runner.invoke(app, [str(unprotected_pdf), "-p", "pass123"])
|
|
25
|
+
assert result.exit_code == 0
|
|
26
|
+
assert "not encrypted" in result.output
|
|
27
|
+
|
|
28
|
+
def test_dry_run(self, protected_pdf: Path) -> None:
|
|
29
|
+
result = runner.invoke(app, [str(protected_pdf), "-p", "pass123", "--dry-run"])
|
|
30
|
+
assert result.exit_code == 0
|
|
31
|
+
assert "dry run" in result.output
|
|
32
|
+
|
|
33
|
+
def test_in_place(self, protected_pdf: Path) -> None:
|
|
34
|
+
result = runner.invoke(app, [str(protected_pdf), "-p", "pass123", "--in-place"], input="y\n")
|
|
35
|
+
assert result.exit_code == 0
|
|
36
|
+
assert "Unlocked" in result.output
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class TestCliBatch:
|
|
40
|
+
def test_directory(self, batch_dir: Path) -> None:
|
|
41
|
+
result = runner.invoke(app, [str(batch_dir), "-p", "pass123", "--skip-unprotected"])
|
|
42
|
+
assert result.exit_code == 0
|
|
43
|
+
assert "Summary" in result.output
|
|
44
|
+
|
|
45
|
+
def test_dry_run(self, batch_dir: Path) -> None:
|
|
46
|
+
result = runner.invoke(app, [str(batch_dir), "-p", "pass123", "--dry-run"])
|
|
47
|
+
assert result.exit_code == 0
|
|
48
|
+
assert "dry run" in result.output
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class TestCliValidation:
|
|
52
|
+
def test_mutually_exclusive(self, protected_pdf: Path) -> None:
|
|
53
|
+
result = runner.invoke(app, [str(protected_pdf), "-p", "pass", "--in-place", "-o", "out.pdf"], input="y\n")
|
|
54
|
+
assert result.exit_code == 1
|
|
55
|
+
assert "mutually exclusive" in result.output
|
|
56
|
+
|
|
57
|
+
def test_output_with_multiple_files(self, batch_dir: Path) -> None:
|
|
58
|
+
result = runner.invoke(app, [str(batch_dir), "-p", "pass", "-o", "out.pdf"])
|
|
59
|
+
assert result.exit_code == 1
|
|
60
|
+
assert "single file" in result.output
|