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.
@@ -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,10 @@
1
+ # Python-generated files
2
+ __pycache__/
3
+ *.py[oc]
4
+ build/
5
+ dist/
6
+ wheels/
7
+ *.egg-info
8
+
9
+ # Virtual environments
10
+ .venv
@@ -0,0 +1 @@
1
+ 3.14
@@ -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.
@@ -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 |
@@ -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