pdfdancer-client-python 0.3.10__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.10 → pdfdancer_client_python-0.3.11}/PKG-INFO +10 -9
  3. {pdfdancer_client_python-0.3.10 → pdfdancer_client_python-0.3.11}/README.md +8 -7
  4. {pdfdancer_client_python-0.3.10 → pdfdancer_client_python-0.3.11}/pyproject.toml +2 -2
  5. {pdfdancer_client_python-0.3.10 → pdfdancer_client_python-0.3.11}/release.py +51 -5
  6. {pdfdancer_client_python-0.3.10 → pdfdancer_client_python-0.3.11}/src/pdfdancer/__init__.py +7 -1
  7. {pdfdancer_client_python-0.3.10 → pdfdancer_client_python-0.3.11}/src/pdfdancer/exceptions.py +9 -0
  8. {pdfdancer_client_python-0.3.10 → pdfdancer_client_python-0.3.11}/src/pdfdancer/models.py +19 -0
  9. {pdfdancer_client_python-0.3.10 → pdfdancer_client_python-0.3.11}/src/pdfdancer/pdfdancer_v1.py +122 -3
  10. {pdfdancer_client_python-0.3.10 → pdfdancer_client_python-0.3.11}/src/pdfdancer/types.py +56 -0
  11. {pdfdancer_client_python-0.3.10 → pdfdancer_client_python-0.3.11}/src/pdfdancer_client_python.egg-info/PKG-INFO +10 -9
  12. {pdfdancer_client_python-0.3.10 → pdfdancer_client_python-0.3.11}/src/pdfdancer_client_python.egg-info/SOURCES.txt +3 -0
  13. {pdfdancer_client_python-0.3.10 → pdfdancer_client_python-0.3.11}/tests/e2e/pdf_assertions.py +25 -0
  14. {pdfdancer_client_python-0.3.10 → pdfdancer_client_python-0.3.11}/tests/e2e/test_acroform.py +2 -2
  15. {pdfdancer_client_python-0.3.10 → pdfdancer_client_python-0.3.11}/tests/e2e/test_form_x_objects.py +1 -1
  16. {pdfdancer_client_python-0.3.10 → pdfdancer_client_python-0.3.11}/tests/e2e/test_image.py +3 -3
  17. {pdfdancer_client_python-0.3.10 → pdfdancer_client_python-0.3.11}/tests/e2e/test_paragraph.py +9 -9
  18. {pdfdancer_client_python-0.3.10 → pdfdancer_client_python-0.3.11}/tests/e2e/test_path.py +5 -5
  19. {pdfdancer_client_python-0.3.10 → pdfdancer_client_python-0.3.11}/tests/e2e/test_path_comprehensive.py +1 -1
  20. pdfdancer_client_python-0.3.11/tests/e2e/test_path_group.py +244 -0
  21. {pdfdancer_client_python-0.3.10 → pdfdancer_client_python-0.3.11}/tests/e2e/test_redact.py +2 -2
  22. {pdfdancer_client_python-0.3.10 → pdfdancer_client_python-0.3.11}/tests/e2e/test_template_replace.py +1 -1
  23. pdfdancer_client_python-0.3.11/tests/e2e/test_template_replace_linebreak.py +141 -0
  24. {pdfdancer_client_python-0.3.10 → pdfdancer_client_python-0.3.11}/.claude/commands/discuss.md +0 -0
  25. {pdfdancer_client_python-0.3.10 → pdfdancer_client_python-0.3.11}/.claude/commands/implement-new-api-features.md +0 -0
  26. {pdfdancer_client_python-0.3.10 → pdfdancer_client_python-0.3.11}/.flake8 +0 -0
  27. {pdfdancer_client_python-0.3.10 → pdfdancer_client_python-0.3.11}/.github/workflows/ci.yml +0 -0
  28. {pdfdancer_client_python-0.3.10 → pdfdancer_client_python-0.3.11}/.github/workflows/daily-tests.yml +0 -0
  29. {pdfdancer_client_python-0.3.10 → pdfdancer_client_python-0.3.11}/.gitignore +0 -0
  30. {pdfdancer_client_python-0.3.10 → pdfdancer_client_python-0.3.11}/CLAUDE.md +0 -0
  31. {pdfdancer_client_python-0.3.10 → pdfdancer_client_python-0.3.11}/LICENSE +0 -0
  32. {pdfdancer_client_python-0.3.10 → pdfdancer_client_python-0.3.11}/NOTICE +0 -0
  33. {pdfdancer_client_python-0.3.10 → pdfdancer_client_python-0.3.11}/TODO.md +0 -0
  34. {pdfdancer_client_python-0.3.10 → pdfdancer_client_python-0.3.11}/check.py +0 -0
  35. {pdfdancer_client_python-0.3.10 → pdfdancer_client_python-0.3.11}/docs/api-schemas/v0.yml +0 -0
  36. {pdfdancer_client_python-0.3.10 → pdfdancer_client_python-0.3.11}/docs/api-schemas/v1.yml +0 -0
  37. {pdfdancer_client_python-0.3.10 → pdfdancer_client_python-0.3.11}/media/logo-orange-512h.webp +0 -0
  38. {pdfdancer_client_python-0.3.10 → pdfdancer_client_python-0.3.11}/media/logo-orange-60h.webp +0 -0
  39. {pdfdancer_client_python-0.3.10 → pdfdancer_client_python-0.3.11}/media/logo-silver-512h.webp +0 -0
  40. {pdfdancer_client_python-0.3.10 → pdfdancer_client_python-0.3.11}/media/logo-silver-60h.webp +0 -0
  41. {pdfdancer_client_python-0.3.10 → pdfdancer_client_python-0.3.11}/setup.cfg +0 -0
  42. {pdfdancer_client_python-0.3.10 → pdfdancer_client_python-0.3.11}/src/pdfdancer/fingerprint.py +0 -0
  43. {pdfdancer_client_python-0.3.10 → pdfdancer_client_python-0.3.11}/src/pdfdancer/image_builder.py +0 -0
  44. {pdfdancer_client_python-0.3.10 → pdfdancer_client_python-0.3.11}/src/pdfdancer/page_builder.py +0 -0
  45. {pdfdancer_client_python-0.3.10 → pdfdancer_client_python-0.3.11}/src/pdfdancer/paragraph_builder.py +0 -0
  46. {pdfdancer_client_python-0.3.10 → pdfdancer_client_python-0.3.11}/src/pdfdancer/path_builder.py +0 -0
  47. {pdfdancer_client_python-0.3.10 → pdfdancer_client_python-0.3.11}/src/pdfdancer/text_line_builder.py +0 -0
  48. {pdfdancer_client_python-0.3.10 → pdfdancer_client_python-0.3.11}/src/pdfdancer_client_python.egg-info/dependency_links.txt +0 -0
  49. {pdfdancer_client_python-0.3.10 → pdfdancer_client_python-0.3.11}/src/pdfdancer_client_python.egg-info/requires.txt +0 -0
  50. {pdfdancer_client_python-0.3.10 → pdfdancer_client_python-0.3.11}/src/pdfdancer_client_python.egg-info/top_level.txt +0 -0
  51. {pdfdancer_client_python-0.3.10 → pdfdancer_client_python-0.3.11}/test.sh +0 -0
  52. {pdfdancer_client_python-0.3.10 → pdfdancer_client_python-0.3.11}/tests/__init__.py +0 -0
  53. {pdfdancer_client_python-0.3.10 → pdfdancer_client_python-0.3.11}/tests/conftest.py +0 -0
  54. {pdfdancer_client_python-0.3.10 → pdfdancer_client_python-0.3.11}/tests/e2e/__init__.py +0 -0
  55. {pdfdancer_client_python-0.3.10 → pdfdancer_client_python-0.3.11}/tests/e2e/test_bezier_builder.py +0 -0
  56. {pdfdancer_client_python-0.3.10 → pdfdancer_client_python-0.3.11}/tests/e2e/test_context_manager.py +0 -0
  57. {pdfdancer_client_python-0.3.10 → pdfdancer_client_python-0.3.11}/tests/e2e/test_image_transform.py +0 -0
  58. {pdfdancer_client_python-0.3.10 → pdfdancer_client_python-0.3.11}/tests/e2e/test_line.py +0 -0
  59. {pdfdancer_client_python-0.3.10 → pdfdancer_client_python-0.3.11}/tests/e2e/test_line_builder.py +0 -0
  60. {pdfdancer_client_python-0.3.10 → pdfdancer_client_python-0.3.11}/tests/e2e/test_new_pdf.py +0 -0
  61. {pdfdancer_client_python-0.3.10 → pdfdancer_client_python-0.3.11}/tests/e2e/test_page.py +0 -0
  62. {pdfdancer_client_python-0.3.10 → pdfdancer_client_python-0.3.11}/tests/e2e/test_path_builder.py +0 -0
  63. {pdfdancer_client_python-0.3.10 → pdfdancer_client_python-0.3.11}/tests/e2e/test_path_builder_rectangle.py +0 -0
  64. {pdfdancer_client_python-0.3.10 → pdfdancer_client_python-0.3.11}/tests/e2e/test_pdfdancer.py +0 -0
  65. {pdfdancer_client_python-0.3.10 → pdfdancer_client_python-0.3.11}/tests/e2e/test_positioning.py +0 -0
  66. {pdfdancer_client_python-0.3.10 → pdfdancer_client_python-0.3.11}/tests/e2e/test_rectangle_builder.py +0 -0
  67. {pdfdancer_client_python-0.3.10 → pdfdancer_client_python-0.3.11}/tests/e2e/test_singular_selection.py +0 -0
  68. {pdfdancer_client_python-0.3.10 → pdfdancer_client_python-0.3.11}/tests/e2e/test_snapshot.py +0 -0
  69. {pdfdancer_client_python-0.3.10 → pdfdancer_client_python-0.3.11}/tests/e2e/test_text_line_edit.py +0 -0
  70. {pdfdancer_client_python-0.3.10 → pdfdancer_client_python-0.3.11}/tests/fixtures/DancingScript-Regular.ttf +0 -0
  71. {pdfdancer_client_python-0.3.10 → pdfdancer_client_python-0.3.11}/tests/fixtures/Empty.pdf +0 -0
  72. {pdfdancer_client_python-0.3.10 → pdfdancer_client_python-0.3.11}/tests/fixtures/JetBrainsMono-Regular.ttf +0 -0
  73. {pdfdancer_client_python-0.3.10 → pdfdancer_client_python-0.3.11}/tests/fixtures/Showcase.pdf +0 -0
  74. {pdfdancer_client_python-0.3.10 → pdfdancer_client_python-0.3.11}/tests/fixtures/basic-paths.pdf +0 -0
  75. {pdfdancer_client_python-0.3.10 → pdfdancer_client_python-0.3.11}/tests/fixtures/form-xobject-example.pdf +0 -0
  76. {pdfdancer_client_python-0.3.10 → pdfdancer_client_python-0.3.11}/tests/fixtures/logo-80.png +0 -0
  77. {pdfdancer_client_python-0.3.10 → pdfdancer_client_python-0.3.11}/tests/fixtures/mixed-form-types.pdf +0 -0
  78. {pdfdancer_client_python-0.3.10 → pdfdancer_client_python-0.3.11}/tests/test_anonymous_token.py +0 -0
  79. {pdfdancer_client_python-0.3.10 → pdfdancer_client_python-0.3.11}/tests/test_fingerprint.py +0 -0
  80. {pdfdancer_client_python-0.3.10 → pdfdancer_client_python-0.3.11}/tests/test_models.py +0 -0
  81. {pdfdancer_client_python-0.3.10 → pdfdancer_client_python-0.3.11}/tests/test_openapi_compliance.py +0 -0
  82. {pdfdancer_client_python-0.3.10 → pdfdancer_client_python-0.3.11}/tests/test_path_models.py +0 -0
  83. {pdfdancer_client_python-0.3.10 → pdfdancer_client_python-0.3.11}/tests/test_pdf_object_equality.py +0 -0
  84. {pdfdancer_client_python-0.3.10 → pdfdancer_client_python-0.3.11}/tests/test_rate_limit.py +0 -0
  85. {pdfdancer_client_python-0.3.10 → pdfdancer_client_python-0.3.11}/tests/test_standard_fonts.py +0 -0
  86. {pdfdancer_client_python-0.3.10 → 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 }}
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pdfdancer-client-python
3
- Version: 0.3.10
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
@@ -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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pdfdancer-client-python"
7
- version = "0.3.10"
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
 
@@ -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)
@@ -728,6 +738,28 @@ class PageClient:
728
738
  self.root._find_paths(Position.at_page(self.page_number))
729
739
  )
730
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
+
731
763
  def select_elements(self):
732
764
  """
733
765
  Select all elements (paragraphs, images, paths, forms) on this page.
@@ -819,6 +851,7 @@ class PDFDancer:
819
851
 
820
852
  @classmethod
821
853
  def _resolve_base_url(cls, base_url: Optional[str]) -> Optional[str]:
854
+ _load_env()
822
855
  env_base_url = os.getenv("PDFDANCER_BASE_URL")
823
856
  resolved_base_url = base_url or (
824
857
  env_base_url.strip() if env_base_url and env_base_url.strip() else None
@@ -953,6 +986,7 @@ class PDFDancer:
953
986
  1. PDFDANCER_API_TOKEN (preferred)
954
987
  2. PDFDANCER_TOKEN (legacy)
955
988
  """
989
+ _load_env()
956
990
  resolved_token = token.strip() if token and token.strip() else None
957
991
  if resolved_token is None:
958
992
  # Check PDFDANCER_API_TOKEN first (preferred), then PDFDANCER_TOKEN (legacy)
@@ -1616,7 +1650,7 @@ class PDFDancer:
1616
1650
 
1617
1651
  _log_generated_at_header(response, method, path)
1618
1652
 
1619
- # Handle FontNotFoundException
1653
+ # Handle 404 errors
1620
1654
  if response.status_code == 404:
1621
1655
  try:
1622
1656
  error_data = response.json()
@@ -1624,6 +1658,10 @@ class PDFDancer:
1624
1658
  raise FontNotFoundException(
1625
1659
  error_data.get("message", "Font not found")
1626
1660
  )
1661
+ if error_data.get("error") == "SessionNotFoundException":
1662
+ raise SessionNotFoundException(
1663
+ error_data.get("message", "Session not found")
1664
+ )
1627
1665
  except (json.JSONDecodeError, KeyError):
1628
1666
  pass
1629
1667
 
@@ -2455,6 +2493,87 @@ class PDFDancer:
2455
2493
 
2456
2494
  return result
2457
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
+
2458
2577
  def new_paragraph(self) -> ParagraphBuilder:
2459
2578
  return ParagraphBuilder(self)
2460
2579
 
@@ -292,6 +292,62 @@ class ImageObject(PDFObjectBase):
292
292
  )
293
293
 
294
294
 
295
+ class PathGroupObject:
296
+ """Represents a group of vector paths that can be manipulated as a unit."""
297
+
298
+ def __init__(self, client: "PDFDancer", page_index: int, info):
299
+ self._client = client
300
+ self._page_index = page_index
301
+ self._info = info
302
+
303
+ @property
304
+ def group_id(self) -> str:
305
+ return self._info.group_id
306
+
307
+ @property
308
+ def path_count(self) -> int:
309
+ return self._info.path_count
310
+
311
+ @property
312
+ def bounding_box(self):
313
+ return self._info.bounding_box
314
+
315
+ @property
316
+ def x(self) -> float:
317
+ return self._info.x
318
+
319
+ @property
320
+ def y(self) -> float:
321
+ return self._info.y
322
+
323
+ def move_to(self, x: float, y: float) -> bool:
324
+ self._client._move_path_group(self._page_index, self.group_id, x, y)
325
+ return True
326
+
327
+ def scale(self, factor: float) -> bool:
328
+ self._client._scale_path_group(self._page_index, self.group_id, factor)
329
+ return True
330
+
331
+ def rotate(self, degrees: float) -> bool:
332
+ self._client._rotate_path_group(
333
+ self._page_index, self.group_id, degrees
334
+ )
335
+ return True
336
+
337
+ def resize(self, width: float, height: float) -> bool:
338
+ self._client._resize_path_group(
339
+ self._page_index, self.group_id, width, height
340
+ )
341
+ return True
342
+
343
+ def remove(self) -> bool:
344
+ self._client._remove_path_group(self._page_index, self.group_id)
345
+ return True
346
+
347
+ def __repr__(self):
348
+ return f"PathGroupObject(group_id={self.group_id!r}, path_count={self.path_count}, page_index={self._page_index})"
349
+
350
+
295
351
  class FormObject(PDFObjectBase):
296
352
  def __eq__(self, other):
297
353
  if not isinstance(other, FormObject):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pdfdancer-client-python
3
- Version: 0.3.10
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
@@ -14,6 +14,7 @@ update-api-spec.sh
14
14
  .claude/commands/implement-new-api-features.md
15
15
  .github/workflows/ci.yml
16
16
  .github/workflows/daily-tests.yml
17
+ .github/workflows/release.yml
17
18
  docs/api-schemas/v0.yml
18
19
  docs/api-schemas/v1.yml
19
20
  media/logo-orange-512h.webp
@@ -63,6 +64,7 @@ tests/e2e/test_path.py
63
64
  tests/e2e/test_path_builder.py
64
65
  tests/e2e/test_path_builder_rectangle.py
65
66
  tests/e2e/test_path_comprehensive.py
67
+ tests/e2e/test_path_group.py
66
68
  tests/e2e/test_pdfdancer.py
67
69
  tests/e2e/test_positioning.py
68
70
  tests/e2e/test_rectangle_builder.py
@@ -70,6 +72,7 @@ tests/e2e/test_redact.py
70
72
  tests/e2e/test_singular_selection.py
71
73
  tests/e2e/test_snapshot.py
72
74
  tests/e2e/test_template_replace.py
75
+ tests/e2e/test_template_replace_linebreak.py
73
76
  tests/e2e/test_text_line_edit.py
74
77
  tests/fixtures/DancingScript-Regular.ttf
75
78
  tests/fixtures/Empty.pdf