pdfdancer-client-python 0.3.9__tar.gz → 0.3.11__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.
Files changed (86) hide show
  1. pdfdancer_client_python-0.3.11/.github/workflows/release.yml +78 -0
  2. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.11}/.gitignore +1 -0
  3. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.11}/PKG-INFO +11 -9
  4. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.11}/README.md +9 -7
  5. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.11}/pyproject.toml +2 -2
  6. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.11}/release.py +51 -5
  7. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.11}/src/pdfdancer/__init__.py +7 -1
  8. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.11}/src/pdfdancer/exceptions.py +9 -0
  9. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.11}/src/pdfdancer/models.py +39 -2
  10. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.11}/src/pdfdancer/pdfdancer_v1.py +154 -3
  11. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.11}/src/pdfdancer/types.py +56 -0
  12. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.11}/src/pdfdancer_client_python.egg-info/PKG-INFO +11 -9
  13. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.11}/src/pdfdancer_client_python.egg-info/SOURCES.txt +3 -0
  14. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.11}/tests/e2e/__init__.py +2 -0
  15. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.11}/tests/e2e/pdf_assertions.py +25 -0
  16. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.11}/tests/e2e/test_acroform.py +2 -2
  17. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.11}/tests/e2e/test_form_x_objects.py +1 -1
  18. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.11}/tests/e2e/test_image.py +3 -3
  19. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.11}/tests/e2e/test_line.py +1 -1
  20. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.11}/tests/e2e/test_paragraph.py +9 -9
  21. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.11}/tests/e2e/test_path.py +5 -5
  22. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.11}/tests/e2e/test_path_comprehensive.py +1 -1
  23. pdfdancer_client_python-0.3.11/tests/e2e/test_path_group.py +244 -0
  24. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.11}/tests/e2e/test_redact.py +2 -2
  25. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.11}/tests/e2e/test_template_replace.py +53 -1
  26. pdfdancer_client_python-0.3.11/tests/e2e/test_template_replace_linebreak.py +141 -0
  27. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.11}/tests/test_models.py +95 -0
  28. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.11}/.claude/commands/discuss.md +0 -0
  29. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.11}/.claude/commands/implement-new-api-features.md +0 -0
  30. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.11}/.flake8 +0 -0
  31. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.11}/.github/workflows/ci.yml +0 -0
  32. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.11}/.github/workflows/daily-tests.yml +0 -0
  33. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.11}/CLAUDE.md +0 -0
  34. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.11}/LICENSE +0 -0
  35. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.11}/NOTICE +0 -0
  36. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.11}/TODO.md +0 -0
  37. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.11}/check.py +0 -0
  38. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.11}/docs/api-schemas/v0.yml +0 -0
  39. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.11}/docs/api-schemas/v1.yml +0 -0
  40. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.11}/media/logo-orange-512h.webp +0 -0
  41. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.11}/media/logo-orange-60h.webp +0 -0
  42. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.11}/media/logo-silver-512h.webp +0 -0
  43. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.11}/media/logo-silver-60h.webp +0 -0
  44. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.11}/setup.cfg +0 -0
  45. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.11}/src/pdfdancer/fingerprint.py +0 -0
  46. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.11}/src/pdfdancer/image_builder.py +0 -0
  47. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.11}/src/pdfdancer/page_builder.py +0 -0
  48. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.11}/src/pdfdancer/paragraph_builder.py +0 -0
  49. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.11}/src/pdfdancer/path_builder.py +0 -0
  50. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.11}/src/pdfdancer/text_line_builder.py +0 -0
  51. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.11}/src/pdfdancer_client_python.egg-info/dependency_links.txt +0 -0
  52. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.11}/src/pdfdancer_client_python.egg-info/requires.txt +0 -0
  53. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.11}/src/pdfdancer_client_python.egg-info/top_level.txt +0 -0
  54. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.11}/test.sh +0 -0
  55. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.11}/tests/__init__.py +0 -0
  56. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.11}/tests/conftest.py +0 -0
  57. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.11}/tests/e2e/test_bezier_builder.py +0 -0
  58. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.11}/tests/e2e/test_context_manager.py +0 -0
  59. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.11}/tests/e2e/test_image_transform.py +0 -0
  60. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.11}/tests/e2e/test_line_builder.py +0 -0
  61. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.11}/tests/e2e/test_new_pdf.py +0 -0
  62. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.11}/tests/e2e/test_page.py +0 -0
  63. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.11}/tests/e2e/test_path_builder.py +0 -0
  64. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.11}/tests/e2e/test_path_builder_rectangle.py +0 -0
  65. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.11}/tests/e2e/test_pdfdancer.py +0 -0
  66. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.11}/tests/e2e/test_positioning.py +0 -0
  67. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.11}/tests/e2e/test_rectangle_builder.py +0 -0
  68. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.11}/tests/e2e/test_singular_selection.py +0 -0
  69. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.11}/tests/e2e/test_snapshot.py +0 -0
  70. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.11}/tests/e2e/test_text_line_edit.py +0 -0
  71. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.11}/tests/fixtures/DancingScript-Regular.ttf +0 -0
  72. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.11}/tests/fixtures/Empty.pdf +0 -0
  73. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.11}/tests/fixtures/JetBrainsMono-Regular.ttf +0 -0
  74. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.11}/tests/fixtures/Showcase.pdf +0 -0
  75. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.11}/tests/fixtures/basic-paths.pdf +0 -0
  76. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.11}/tests/fixtures/form-xobject-example.pdf +0 -0
  77. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.11}/tests/fixtures/logo-80.png +0 -0
  78. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.11}/tests/fixtures/mixed-form-types.pdf +0 -0
  79. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.11}/tests/test_anonymous_token.py +0 -0
  80. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.11}/tests/test_fingerprint.py +0 -0
  81. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.11}/tests/test_openapi_compliance.py +0 -0
  82. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.11}/tests/test_path_models.py +0 -0
  83. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.11}/tests/test_pdf_object_equality.py +0 -0
  84. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.11}/tests/test_rate_limit.py +0 -0
  85. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.11}/tests/test_standard_fonts.py +0 -0
  86. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.11}/update-api-spec.sh +0 -0
@@ -0,0 +1,78 @@
1
+ name: Release to PyPI
2
+
3
+ on:
4
+ push:
5
+ tags: ['v*']
6
+
7
+ jobs:
8
+ test:
9
+ runs-on: ubuntu-latest
10
+ steps:
11
+ - uses: actions/checkout@v4
12
+
13
+ - name: Set up Python 3.12
14
+ uses: actions/setup-python@v5
15
+ with:
16
+ python-version: '3.12'
17
+
18
+ - name: Create virtual environment
19
+ run: python -m venv venv
20
+
21
+ - name: Install dependencies
22
+ run: |
23
+ venv/bin/pip install --upgrade pip
24
+ venv/bin/pip install -e ".[dev]"
25
+
26
+ - name: Run linter
27
+ run: venv/bin/python -m flake8 src/
28
+
29
+ - name: Run tests
30
+ run: venv/bin/python -m pytest tests/ -v --maxfail=1 --ignore=tests/e2e/test_pdfdancer.py
31
+ env:
32
+ PDFDANCER_BASE_URL: https://api.pdfdancer.com
33
+ PDFDANCER_API_TOKEN: ${{ secrets.PDFDANCER_API_TOKEN_PRODUCTION }}
34
+
35
+ publish:
36
+ needs: test
37
+ runs-on: ubuntu-latest
38
+ permissions:
39
+ id-token: write
40
+ steps:
41
+ - uses: actions/checkout@v4
42
+
43
+ - name: Set up Python 3.12
44
+ uses: actions/setup-python@v5
45
+ with:
46
+ python-version: '3.12'
47
+
48
+ - name: Verify tag matches package version
49
+ run: |
50
+ TAG_VERSION="${GITHUB_REF_NAME#v}"
51
+ PKG_VERSION=$(python -c "import re; print(re.search(r'version\s*=\s*\"([^\"]+)\"', open('pyproject.toml').read()).group(1))")
52
+ if [ "$TAG_VERSION" != "$PKG_VERSION" ]; then
53
+ echo "Tag version ($TAG_VERSION) does not match pyproject.toml version ($PKG_VERSION)"
54
+ exit 1
55
+ fi
56
+ echo "Version verified: $TAG_VERSION"
57
+
58
+ - name: Install build dependencies
59
+ run: pip install build
60
+
61
+ - name: Build package
62
+ run: python -m build
63
+
64
+ - name: Publish to PyPI
65
+ uses: pypa/gh-action-pypi-publish@release/v1
66
+
67
+ github-release:
68
+ needs: publish
69
+ runs-on: ubuntu-latest
70
+ permissions:
71
+ contents: write
72
+ steps:
73
+ - uses: actions/checkout@v4
74
+
75
+ - name: Create GitHub Release
76
+ run: gh release create "$GITHUB_REF_NAME" --generate-notes
77
+ env:
78
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -18,3 +18,4 @@ __pycache__/
18
18
  .Python
19
19
  /.run/
20
20
  /build
21
+ .env
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pdfdancer-client-python
3
- Version: 0.3.9
3
+ Version: 0.3.11
4
4
  Summary: Python client for PDFDancer API
5
5
  Author-email: "The Famous Cat Ltd." <hi@thefamouscat.com>
6
6
  License:
@@ -210,7 +210,7 @@ Project-URL: Homepage, https://www.pdfdancer.com/
210
210
  Project-URL: Documentation, https://www.pdfdancer.com/
211
211
  Project-URL: Source, https://github.com/MenschMachine/pdfdancer-client-python
212
212
  Project-URL: Issues, https://github.com/MenschMachine/pdfdancer-client-python/issues
213
- Classifier: Development Status :: 4 - Beta
213
+ Classifier: Development Status :: 5 - Production/Stable
214
214
  Classifier: Intended Audience :: Developers
215
215
  Classifier: License :: OSI Approved :: Apache Software License
216
216
  Classifier: Programming Language :: Python :: 3.10
@@ -571,15 +571,16 @@ Artifacts will be created in the `dist/` directory.
571
571
 
572
572
  #### Publishing to PyPI
573
573
 
574
- ```bash
575
- # Test upload to TestPyPI (recommended first)
576
- python -m twine upload --repository testpypi dist/*
574
+ Releases are published automatically to PyPI when a `v*` tag is pushed to GitHub (via GitHub Actions with Trusted Publishers).
577
575
 
578
- # Upload to PyPI
579
- python -m twine upload dist/*
576
+ ```bash
577
+ # Set version, commit, and push tag — GitHub Actions handles the rest
578
+ python release.py tag --version 1.1.0
580
579
 
581
- # Or use the release script
582
- python release.py
580
+ # Or manually:
581
+ # 1. Update version in pyproject.toml and src/pdfdancer/__init__.py
582
+ # 2. Commit
583
+ # 3. git tag v1.1.0 && git push origin HEAD && git push origin v1.1.0
583
584
  ```
584
585
 
585
586
  #### Code Quality
@@ -672,6 +673,7 @@ Contributions are welcome via pull request. Please:
672
673
  - [PyPI](https://pypi.org/project/pdfdancer-client-python/)
673
674
  - [Changelog](https://www.pdfdancer.com/changelog/?utm_source=github&utm_medium=readme&utm_campaign=pdfdancer-python)
674
675
  - [Status](https://status.pdfdancer.com?utm_source=github&utm_medium=readme&utm_campaign=pdfdancer-python)
676
+ - [Issue tracker](https://github.com/MenschMachine/pdfdancer)
675
677
 
676
678
  ## Related SDKs
677
679
 
@@ -332,15 +332,16 @@ Artifacts will be created in the `dist/` directory.
332
332
 
333
333
  #### Publishing to PyPI
334
334
 
335
- ```bash
336
- # Test upload to TestPyPI (recommended first)
337
- python -m twine upload --repository testpypi dist/*
335
+ Releases are published automatically to PyPI when a `v*` tag is pushed to GitHub (via GitHub Actions with Trusted Publishers).
338
336
 
339
- # Upload to PyPI
340
- python -m twine upload dist/*
337
+ ```bash
338
+ # Set version, commit, and push tag — GitHub Actions handles the rest
339
+ python release.py tag --version 1.1.0
341
340
 
342
- # Or use the release script
343
- python release.py
341
+ # Or manually:
342
+ # 1. Update version in pyproject.toml and src/pdfdancer/__init__.py
343
+ # 2. Commit
344
+ # 3. git tag v1.1.0 && git push origin HEAD && git push origin v1.1.0
344
345
  ```
345
346
 
346
347
  #### Code Quality
@@ -433,6 +434,7 @@ Contributions are welcome via pull request. Please:
433
434
  - [PyPI](https://pypi.org/project/pdfdancer-client-python/)
434
435
  - [Changelog](https://www.pdfdancer.com/changelog/?utm_source=github&utm_medium=readme&utm_campaign=pdfdancer-python)
435
436
  - [Status](https://status.pdfdancer.com?utm_source=github&utm_medium=readme&utm_campaign=pdfdancer-python)
437
+ - [Issue tracker](https://github.com/MenschMachine/pdfdancer)
436
438
 
437
439
  ## Related SDKs
438
440
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pdfdancer-client-python"
7
- version = "0.3.9"
7
+ version = "0.3.11"
8
8
  description = "Python client for PDFDancer API"
9
9
  readme = "README.md"
10
10
  authors = [
@@ -12,7 +12,7 @@ authors = [
12
12
  ]
13
13
  license = { file = "LICENSE" }
14
14
  classifiers = [
15
- "Development Status :: 4 - Beta",
15
+ "Development Status :: 5 - Production/Stable",
16
16
  "Intended Audience :: Developers",
17
17
  "License :: OSI Approved :: Apache Software License",
18
18
  "Programming Language :: Python :: 3.10",
@@ -20,10 +20,12 @@ class ReleaseError(Exception):
20
20
 
21
21
 
22
22
  class VersionBumper:
23
- """Handles version bumping in pyproject.toml."""
23
+ """Handles version bumping in pyproject.toml and __init__.py."""
24
24
 
25
- def __init__(self, pyproject_path: Path = Path("pyproject.toml")):
25
+ def __init__(self, pyproject_path: Path = Path("pyproject.toml"),
26
+ init_path: Path = Path("src/pdfdancer/__init__.py")):
26
27
  self.pyproject_path = pyproject_path
28
+ self.init_path = init_path
27
29
  if not self.pyproject_path.exists():
28
30
  raise ReleaseError(f"pyproject.toml not found at {pyproject_path}")
29
31
 
@@ -36,7 +38,8 @@ class VersionBumper:
36
38
  return match.group(1)
37
39
 
38
40
  def set_version(self, new_version: str) -> None:
39
- """Set a new version in pyproject.toml."""
41
+ """Set a new version in pyproject.toml and __init__.py."""
42
+ # Update pyproject.toml
40
43
  content = self.pyproject_path.read_text()
41
44
  new_content = re.sub(
42
45
  r'^version\s*=\s*"[^"]+"',
@@ -48,6 +51,18 @@ class VersionBumper:
48
51
  raise ReleaseError("Failed to update version in pyproject.toml")
49
52
  self.pyproject_path.write_text(new_content)
50
53
 
54
+ # Update __init__.py
55
+ if self.init_path.exists():
56
+ init_content = self.init_path.read_text()
57
+ new_init = re.sub(
58
+ r'^__version__\s*=\s*"[^"]+"',
59
+ f'__version__ = "{new_version}"',
60
+ init_content,
61
+ flags=re.MULTILINE
62
+ )
63
+ self.init_path.write_text(new_init)
64
+ print(f"Updated {self.init_path} to {new_version}")
65
+
51
66
  def bump_version(self, bump_type: str) -> str:
52
67
  """Bump version by type (major, minor, patch)."""
53
68
  current = self.get_current_version()
@@ -159,8 +174,8 @@ def main():
159
174
  parser = argparse.ArgumentParser(description="PDFDancer Python Client Release Tool")
160
175
  parser.add_argument(
161
176
  "action",
162
- choices=["bump", "upload", "release"],
163
- help="Action to perform: bump (version only), upload (build+upload), release (bump+test+build+upload)"
177
+ choices=["bump", "upload", "release", "tag"],
178
+ help="Action to perform: bump (version only), upload (build+upload), release (bump+test+build+upload), tag (set version + commit + push tag)"
164
179
  )
165
180
  parser.add_argument(
166
181
  "--bump-type",
@@ -230,6 +245,37 @@ def main():
230
245
 
231
246
  print(f"New version: {new_version}")
232
247
 
248
+ if args.action == "tag":
249
+ if not args.version:
250
+ raise ReleaseError("--version is required for the tag action")
251
+
252
+ new_version = args.version
253
+ current_version = version_bumper.get_current_version()
254
+ print(f"Current version: {current_version}")
255
+
256
+ if not args.dry_run:
257
+ version_bumper.set_version(new_version)
258
+ print(f"Version set to {new_version}")
259
+
260
+ # Commit the version bump
261
+ uploader.run_command(
262
+ ["git", "add", "pyproject.toml", "src/pdfdancer/__init__.py"]
263
+ )
264
+ uploader.run_command(
265
+ ["git", "commit", "-m", f"release: v{new_version}"]
266
+ )
267
+ print(f"Committed version bump")
268
+
269
+ # Create and push tag
270
+ tag_name = f"v{new_version}"
271
+ uploader.run_command(["git", "tag", tag_name])
272
+ uploader.run_command(["git", "push", "origin", "HEAD"])
273
+ uploader.run_command(["git", "push", "origin", tag_name])
274
+ print(f"Pushed tag {tag_name}")
275
+ else:
276
+ print(f"Would set version to {new_version}")
277
+ print(f"Would commit and push tag v{new_version}")
278
+
233
279
  if args.action in ["upload", "release"]:
234
280
  if args.action == "release" and not args.skip_tests:
235
281
  if not args.dry_run:
@@ -12,6 +12,7 @@ from .exceptions import (
12
12
  PdfDancerException,
13
13
  RateLimitException,
14
14
  SessionException,
15
+ SessionNotFoundException,
15
16
  ValidationException,
16
17
  )
17
18
  from .models import (
@@ -34,6 +35,7 @@ from .models import (
34
35
  PageSize,
35
36
  Paragraph,
36
37
  Path,
38
+ PathGroupInfo,
37
39
  PathSegment,
38
40
  Point,
39
41
  Position,
@@ -47,12 +49,13 @@ from .models import (
47
49
  TextObjectRef,
48
50
  TextStatus,
49
51
  )
52
+ from .types import PathGroupObject
50
53
  from .page_builder import PageBuilder
51
54
  from .paragraph_builder import ParagraphBuilder
52
55
  from .path_builder import BezierBuilder, LineBuilder, PathBuilder
53
56
  from .text_line_builder import TextLineBuilder
54
57
 
55
- __version__ = "1.0.0"
58
+ __version__ = "0.3.11"
56
59
  __all__ = [
57
60
  "PDFDancer",
58
61
  "ParagraphBuilder",
@@ -89,6 +92,8 @@ __all__ = [
89
92
  "Line",
90
93
  "Bezier",
91
94
  "Path",
95
+ "PathGroupInfo",
96
+ "PathGroupObject",
92
97
  "RedactTarget",
93
98
  "RedactResponse",
94
99
  "ReflowPreset",
@@ -97,6 +102,7 @@ __all__ = [
97
102
  "ValidationException",
98
103
  "HttpClientException",
99
104
  "SessionException",
105
+ "SessionNotFoundException",
100
106
  "RateLimitException",
101
107
  "set_ssl_verify",
102
108
  ]
@@ -55,6 +55,15 @@ class SessionException(PdfDancerException):
55
55
  pass
56
56
 
57
57
 
58
+ class SessionNotFoundException(SessionException):
59
+ """
60
+ Exception raised when a session is not found (expired or invalid).
61
+ """
62
+
63
+ def __init__(self, message: str):
64
+ super().__init__(message)
65
+
66
+
58
67
  class ValidationException(PdfDancerException):
59
68
  """
60
69
  Exception raised for input validation errors.
@@ -1643,6 +1643,25 @@ class Size:
1643
1643
  return {"width": self.width, "height": self.height}
1644
1644
 
1645
1645
 
1646
+ @dataclass
1647
+ class PathGroupInfo:
1648
+ group_id: str
1649
+ path_count: int
1650
+ bounding_box: Optional[Dict[str, Any]]
1651
+ x: float
1652
+ y: float
1653
+
1654
+ @staticmethod
1655
+ def from_dict(d: Dict[str, Any]) -> "PathGroupInfo":
1656
+ return PathGroupInfo(
1657
+ group_id=d.get("groupId", ""),
1658
+ path_count=d.get("pathCount", 0),
1659
+ bounding_box=d.get("boundingBox"),
1660
+ x=d.get("x", 0.0),
1661
+ y=d.get("y", 0.0),
1662
+ )
1663
+
1664
+
1646
1665
  class ImageTransformType(Enum):
1647
1666
  """Type of image transformation operation."""
1648
1667
 
@@ -1686,18 +1705,22 @@ class TemplateReplacement:
1686
1705
 
1687
1706
  Parameters:
1688
1707
  - placeholder: The exact text to find and replace in the PDF.
1689
- - text: The text to replace the placeholder with.
1708
+ - text: The text to replace the placeholder with. None for image replacements.
1690
1709
  - font: Optional font for the replacement text.
1691
1710
  - color: Optional color for the replacement text.
1711
+ - image: Optional Image to replace the placeholder with. When set, text should be None.
1692
1712
  """
1693
1713
 
1694
1714
  placeholder: str
1695
- text: str
1715
+ text: Optional[str] = None
1696
1716
  font: Optional[Font] = None
1697
1717
  color: Optional[Color] = None
1718
+ image: Optional[Image] = None
1698
1719
 
1699
1720
  def to_dict(self) -> dict:
1700
1721
  """Convert to dictionary for JSON serialization."""
1722
+ import base64
1723
+
1701
1724
  result: Dict[str, Any] = {
1702
1725
  "placeholder": self.placeholder,
1703
1726
  "text": self.text,
@@ -1711,6 +1734,20 @@ class TemplateReplacement:
1711
1734
  "blue": self.color.b,
1712
1735
  "alpha": self.color.a,
1713
1736
  }
1737
+ if self.image:
1738
+ image_dict: Dict[str, Any] = {}
1739
+ if self.image.data:
1740
+ image_dict["data"] = base64.b64encode(self.image.data).decode("utf-8")
1741
+ if self.image.format:
1742
+ image_dict["format"] = self.image.format
1743
+ if self.image.width is not None or self.image.height is not None:
1744
+ size: Dict[str, float] = {}
1745
+ if self.image.width is not None:
1746
+ size["width"] = self.image.width
1747
+ if self.image.height is not None:
1748
+ size["height"] = self.image.height
1749
+ image_dict["size"] = size
1750
+ result["image"] = image_dict
1714
1751
  return result
1715
1752
 
1716
1753
 
@@ -19,7 +19,7 @@ from pathlib import Path
19
19
  from typing import TYPE_CHECKING, Any, BinaryIO, Dict, List, Mapping, Optional, Union
20
20
 
21
21
  import httpx
22
- from dotenv import load_dotenv
22
+ from dotenv import find_dotenv, load_dotenv
23
23
 
24
24
  from . import BezierBuilder, LineBuilder, ParagraphBuilder, PathBuilder
25
25
  from .exceptions import (
@@ -28,6 +28,7 @@ from .exceptions import (
28
28
  PdfDancerException,
29
29
  RateLimitException,
30
30
  SessionException,
31
+ SessionNotFoundException,
31
32
  ValidationException,
32
33
  )
33
34
  from .fingerprint import Fingerprint
@@ -85,7 +86,16 @@ if TYPE_CHECKING:
85
86
  from .models import ImageTransformRequest, PathSegment
86
87
  from .path_builder import RectangleBuilder
87
88
 
88
- load_dotenv()
89
+ _env_loaded = False
90
+
91
+
92
+ def _load_env():
93
+ global _env_loaded
94
+ if _env_loaded:
95
+ return
96
+ load_dotenv(find_dotenv(usecwd=True))
97
+ _env_loaded = True
98
+
89
99
 
90
100
  # Client identifier header for all HTTP requests
91
101
  # Always reads from installed package metadata (stays in sync with pyproject.toml)
@@ -131,6 +141,34 @@ def _dict_to_replacements(
131
141
  for placeholder, value in replacements.items():
132
142
  if isinstance(value, str):
133
143
  result.append(TemplateReplacement(placeholder=placeholder, text=value))
144
+ elif "image" in value:
145
+ image_source = value["image"]
146
+ if isinstance(image_source, Path):
147
+ image_data = image_source.read_bytes()
148
+ image_format = image_source.suffix.lstrip(".").upper()
149
+ if image_format == "JPG":
150
+ image_format = "JPEG"
151
+ elif isinstance(image_source, bytes):
152
+ image_data = image_source
153
+ image_format = None
154
+ else:
155
+ raise ValueError(
156
+ f"Unsupported image source type: {type(image_source)}. "
157
+ "Use a Path or bytes."
158
+ )
159
+ img = Image(
160
+ data=image_data,
161
+ format=value.get("format", image_format),
162
+ width=value.get("width"),
163
+ height=value.get("height"),
164
+ )
165
+ result.append(
166
+ TemplateReplacement(
167
+ placeholder=placeholder,
168
+ text=None,
169
+ image=img,
170
+ )
171
+ )
134
172
  else:
135
173
  result.append(
136
174
  TemplateReplacement(
@@ -644,6 +682,8 @@ class PageClient:
644
682
  replacements: Dict mapping placeholder strings to replacement values.
645
683
  - Simple: {"{{NAME}}": "John Doe"}
646
684
  - With options: {"{{NAME}}": {"text": "John", "font": Font(...), "color": Color(...)}}
685
+ - With image: {"{{LOGO}}": {"image": Path("logo.png")}}
686
+ - With image and size: {"{{LOGO}}": {"image": Path("logo.png"), "width": 50, "height": 50}}
647
687
  reflow_preset: Optional ReflowPreset to control text reflow behavior.
648
688
  - BEST_EFFORT: Attempt to reflow, proceed even if imperfect
649
689
  - FIT_OR_FAIL: Reflow must succeed or operation fails
@@ -698,6 +738,28 @@ class PageClient:
698
738
  self.root._find_paths(Position.at_page(self.page_number))
699
739
  )
700
740
 
741
+ def group_paths(self, path_ids):
742
+ """Group paths by their IDs. Returns a PathGroupObject."""
743
+ from .types import PathGroupObject
744
+
745
+ page_index = self.page_number - 1
746
+ info = self.root._create_path_group(page_index, path_ids=path_ids)
747
+ return PathGroupObject(self.root, page_index, info)
748
+
749
+ def group_paths_in_region(self, region):
750
+ """Group paths within a bounding region. Returns a PathGroupObject."""
751
+ from .types import PathGroupObject
752
+
753
+ page_index = self.page_number - 1
754
+ info = self.root._create_path_group(page_index, region=region)
755
+ return PathGroupObject(self.root, page_index, info)
756
+
757
+ def get_path_groups(self):
758
+ """List all path groups on this page."""
759
+
760
+ page_index = self.page_number - 1
761
+ return self.root._list_path_groups(page_index)
762
+
701
763
  def select_elements(self):
702
764
  """
703
765
  Select all elements (paragraphs, images, paths, forms) on this page.
@@ -789,6 +851,7 @@ class PDFDancer:
789
851
 
790
852
  @classmethod
791
853
  def _resolve_base_url(cls, base_url: Optional[str]) -> Optional[str]:
854
+ _load_env()
792
855
  env_base_url = os.getenv("PDFDANCER_BASE_URL")
793
856
  resolved_base_url = base_url or (
794
857
  env_base_url.strip() if env_base_url and env_base_url.strip() else None
@@ -923,6 +986,7 @@ class PDFDancer:
923
986
  1. PDFDANCER_API_TOKEN (preferred)
924
987
  2. PDFDANCER_TOKEN (legacy)
925
988
  """
989
+ _load_env()
926
990
  resolved_token = token.strip() if token and token.strip() else None
927
991
  if resolved_token is None:
928
992
  # Check PDFDANCER_API_TOKEN first (preferred), then PDFDANCER_TOKEN (legacy)
@@ -1586,7 +1650,7 @@ class PDFDancer:
1586
1650
 
1587
1651
  _log_generated_at_header(response, method, path)
1588
1652
 
1589
- # Handle FontNotFoundException
1653
+ # Handle 404 errors
1590
1654
  if response.status_code == 404:
1591
1655
  try:
1592
1656
  error_data = response.json()
@@ -1594,6 +1658,10 @@ class PDFDancer:
1594
1658
  raise FontNotFoundException(
1595
1659
  error_data.get("message", "Font not found")
1596
1660
  )
1661
+ if error_data.get("error") == "SessionNotFoundException":
1662
+ raise SessionNotFoundException(
1663
+ error_data.get("message", "Session not found")
1664
+ )
1597
1665
  except (json.JSONDecodeError, KeyError):
1598
1666
  pass
1599
1667
 
@@ -2281,6 +2349,8 @@ class PDFDancer:
2281
2349
  replacements: Dict mapping placeholder strings to replacement values.
2282
2350
  - Simple: {"{{NAME}}": "John Doe"}
2283
2351
  - With options: {"{{NAME}}": {"text": "John", "font": Font(...), "color": Color(...)}}
2352
+ - With image: {"{{LOGO}}": {"image": Path("logo.png")}}
2353
+ - With image and size: {"{{LOGO}}": {"image": Path("logo.png"), "width": 50, "height": 50}}
2284
2354
  reflow_preset: Optional ReflowPreset to control text reflow behavior.
2285
2355
  - BEST_EFFORT: Attempt to reflow, proceed even if imperfect
2286
2356
  - FIT_OR_FAIL: Reflow must succeed or operation fails
@@ -2423,6 +2493,87 @@ class PDFDancer:
2423
2493
 
2424
2494
  return result
2425
2495
 
2496
+ # Path Group Operations (internal, 0-based page_index)
2497
+ def _create_path_group(self, page_index, path_ids=None, region=None):
2498
+ from .models import PathGroupInfo
2499
+
2500
+ if path_ids is not None:
2501
+ if not isinstance(path_ids, list) or len(path_ids) == 0:
2502
+ raise ValidationException("path_ids must be a non-empty list")
2503
+
2504
+ data = {"pageIndex": page_index}
2505
+ if path_ids is not None:
2506
+ data["pathIds"] = path_ids
2507
+ if region is not None:
2508
+ data["region"] = {
2509
+ "x": region.x, "y": region.y,
2510
+ "width": region.width, "height": region.height,
2511
+ }
2512
+ response = self._make_request(
2513
+ "POST", "/pdf/path-group/create", data=data
2514
+ )
2515
+ self._invalidate_snapshots()
2516
+ return PathGroupInfo.from_dict(response.json())
2517
+
2518
+ def _move_path_group(self, page_index, group_id, x, y):
2519
+ data = {
2520
+ "pageIndex": page_index, "groupId": group_id,
2521
+ "x": x, "y": y,
2522
+ }
2523
+ response = self._make_request("PUT", "/pdf/path-group/move", data=data)
2524
+ self._invalidate_snapshots()
2525
+ return response.json()
2526
+
2527
+ def _transform_path_group(self, page_index, group_id, transform_type, **kwargs):
2528
+ data = {
2529
+ "pageIndex": page_index, "groupId": group_id,
2530
+ "transformType": transform_type,
2531
+ }
2532
+ data.update({k: v for k, v in kwargs.items() if v is not None})
2533
+ response = self._make_request(
2534
+ "PUT", "/pdf/path-group/transform", data=data
2535
+ )
2536
+ self._invalidate_snapshots()
2537
+ return response.json()
2538
+
2539
+ def _scale_path_group(self, page_index, group_id, factor):
2540
+ if factor <= 0:
2541
+ raise ValidationException("Scale factor must be positive")
2542
+ return self._transform_path_group(
2543
+ page_index, group_id, "SCALE", scaleFactor=factor
2544
+ )
2545
+
2546
+ def _rotate_path_group(self, page_index, group_id, degrees):
2547
+ return self._transform_path_group(
2548
+ page_index, group_id, "ROTATE", rotationAngle=degrees
2549
+ )
2550
+
2551
+ def _resize_path_group(self, page_index, group_id, width, height):
2552
+ if width <= 0 or height <= 0:
2553
+ raise ValidationException("Width and height must be positive")
2554
+ return self._transform_path_group(
2555
+ page_index, group_id, "RESIZE",
2556
+ targetWidth=width, targetHeight=height,
2557
+ )
2558
+
2559
+ def _remove_path_group(self, page_index, group_id):
2560
+ data = {"pageIndex": page_index, "groupId": group_id}
2561
+ response = self._make_request(
2562
+ "DELETE", "/pdf/path-group/remove", data=data
2563
+ )
2564
+ self._invalidate_snapshots()
2565
+ return response.json()
2566
+
2567
+ def _list_path_groups(self, page_index):
2568
+ from .models import PathGroupInfo
2569
+ from .types import PathGroupObject
2570
+
2571
+ response = self._make_request(
2572
+ "GET", f"/pdf/page/{page_index}/path-groups"
2573
+ )
2574
+ infos = [PathGroupInfo.from_dict(d) for d in response.json()]
2575
+ return [PathGroupObject(self, page_index, info) for info in infos]
2576
+
2426
2577
  def new_paragraph(self) -> ParagraphBuilder:
2427
2578
  return ParagraphBuilder(self)
2428
2579