pdfdancer-client-python 0.3.12__tar.gz → 0.3.13__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 (92) hide show
  1. {pdfdancer_client_python-0.3.12 → pdfdancer_client_python-0.3.13}/.github/workflows/ci.yml +67 -28
  2. pdfdancer_client_python-0.3.13/.github/workflows/sdk-backward-compat.yml +88 -0
  3. {pdfdancer_client_python-0.3.12 → pdfdancer_client_python-0.3.13}/PKG-INFO +1 -1
  4. {pdfdancer_client_python-0.3.12 → pdfdancer_client_python-0.3.13}/pyproject.toml +1 -1
  5. {pdfdancer_client_python-0.3.12 → pdfdancer_client_python-0.3.13}/src/pdfdancer/__init__.py +5 -1
  6. {pdfdancer_client_python-0.3.12 → pdfdancer_client_python-0.3.13}/src/pdfdancer/models.py +82 -0
  7. {pdfdancer_client_python-0.3.12 → pdfdancer_client_python-0.3.13}/src/pdfdancer/pdfdancer_v1.py +75 -3
  8. {pdfdancer_client_python-0.3.12 → pdfdancer_client_python-0.3.13}/src/pdfdancer/types.py +92 -1
  9. {pdfdancer_client_python-0.3.12 → pdfdancer_client_python-0.3.13}/src/pdfdancer_client_python.egg-info/PKG-INFO +1 -1
  10. {pdfdancer_client_python-0.3.12 → pdfdancer_client_python-0.3.13}/src/pdfdancer_client_python.egg-info/SOURCES.txt +1 -1
  11. {pdfdancer_client_python-0.3.12 → pdfdancer_client_python-0.3.13}/tests/e2e/test_context_manager.py +6 -0
  12. {pdfdancer_client_python-0.3.12 → pdfdancer_client_python-0.3.13}/tests/e2e/test_path.py +48 -1
  13. {pdfdancer_client_python-0.3.12 → pdfdancer_client_python-0.3.13}/tests/e2e/test_redact.py +1 -0
  14. {pdfdancer_client_python-0.3.12 → pdfdancer_client_python-0.3.13}/tests/test_pdf_object_equality.py +19 -10
  15. pdfdancer_client_python-0.3.12/.api-url +0 -1
  16. {pdfdancer_client_python-0.3.12 → pdfdancer_client_python-0.3.13}/.claude/commands/discuss.md +0 -0
  17. {pdfdancer_client_python-0.3.12 → pdfdancer_client_python-0.3.13}/.claude/commands/implement-new-api-features.md +0 -0
  18. {pdfdancer_client_python-0.3.12 → pdfdancer_client_python-0.3.13}/.flake8 +0 -0
  19. {pdfdancer_client_python-0.3.12 → pdfdancer_client_python-0.3.13}/.github/workflows/daily-tests.yml +0 -0
  20. {pdfdancer_client_python-0.3.12 → pdfdancer_client_python-0.3.13}/.github/workflows/release.yml +0 -0
  21. {pdfdancer_client_python-0.3.12 → pdfdancer_client_python-0.3.13}/.gitignore +0 -0
  22. {pdfdancer_client_python-0.3.12 → pdfdancer_client_python-0.3.13}/.gitmodules +0 -0
  23. {pdfdancer_client_python-0.3.12 → pdfdancer_client_python-0.3.13}/CLAUDE.md +0 -0
  24. {pdfdancer_client_python-0.3.12 → pdfdancer_client_python-0.3.13}/LICENSE +0 -0
  25. {pdfdancer_client_python-0.3.12 → pdfdancer_client_python-0.3.13}/NOTICE +0 -0
  26. {pdfdancer_client_python-0.3.12 → pdfdancer_client_python-0.3.13}/README.md +0 -0
  27. {pdfdancer_client_python-0.3.12 → pdfdancer_client_python-0.3.13}/TODO.md +0 -0
  28. {pdfdancer_client_python-0.3.12 → pdfdancer_client_python-0.3.13}/check.py +0 -0
  29. {pdfdancer_client_python-0.3.12 → pdfdancer_client_python-0.3.13}/docs/capabilities/.gitkeep +0 -0
  30. {pdfdancer_client_python-0.3.12 → pdfdancer_client_python-0.3.13}/docs/capabilities/CLEAR_CLIPPING.md +0 -0
  31. {pdfdancer_client_python-0.3.12 → pdfdancer_client_python-0.3.13}/media/logo-orange-512h.webp +0 -0
  32. {pdfdancer_client_python-0.3.12 → pdfdancer_client_python-0.3.13}/media/logo-orange-60h.webp +0 -0
  33. {pdfdancer_client_python-0.3.12 → pdfdancer_client_python-0.3.13}/media/logo-silver-512h.webp +0 -0
  34. {pdfdancer_client_python-0.3.12 → pdfdancer_client_python-0.3.13}/media/logo-silver-60h.webp +0 -0
  35. {pdfdancer_client_python-0.3.12 → pdfdancer_client_python-0.3.13}/release.py +0 -0
  36. {pdfdancer_client_python-0.3.12 → pdfdancer_client_python-0.3.13}/setup.cfg +0 -0
  37. {pdfdancer_client_python-0.3.12 → pdfdancer_client_python-0.3.13}/src/pdfdancer/exceptions.py +0 -0
  38. {pdfdancer_client_python-0.3.12 → pdfdancer_client_python-0.3.13}/src/pdfdancer/fingerprint.py +0 -0
  39. {pdfdancer_client_python-0.3.12 → pdfdancer_client_python-0.3.13}/src/pdfdancer/image_builder.py +0 -0
  40. {pdfdancer_client_python-0.3.12 → pdfdancer_client_python-0.3.13}/src/pdfdancer/page_builder.py +0 -0
  41. {pdfdancer_client_python-0.3.12 → pdfdancer_client_python-0.3.13}/src/pdfdancer/paragraph_builder.py +0 -0
  42. {pdfdancer_client_python-0.3.12 → pdfdancer_client_python-0.3.13}/src/pdfdancer/path_builder.py +0 -0
  43. {pdfdancer_client_python-0.3.12 → pdfdancer_client_python-0.3.13}/src/pdfdancer/text_line_builder.py +0 -0
  44. {pdfdancer_client_python-0.3.12 → pdfdancer_client_python-0.3.13}/src/pdfdancer_client_python.egg-info/dependency_links.txt +0 -0
  45. {pdfdancer_client_python-0.3.12 → pdfdancer_client_python-0.3.13}/src/pdfdancer_client_python.egg-info/requires.txt +0 -0
  46. {pdfdancer_client_python-0.3.12 → pdfdancer_client_python-0.3.13}/src/pdfdancer_client_python.egg-info/top_level.txt +0 -0
  47. {pdfdancer_client_python-0.3.12 → pdfdancer_client_python-0.3.13}/test.sh +0 -0
  48. {pdfdancer_client_python-0.3.12 → pdfdancer_client_python-0.3.13}/tests/__init__.py +0 -0
  49. {pdfdancer_client_python-0.3.12 → pdfdancer_client_python-0.3.13}/tests/conftest.py +0 -0
  50. {pdfdancer_client_python-0.3.12 → pdfdancer_client_python-0.3.13}/tests/e2e/__init__.py +0 -0
  51. {pdfdancer_client_python-0.3.12 → pdfdancer_client_python-0.3.13}/tests/e2e/pdf_assertions.py +0 -0
  52. {pdfdancer_client_python-0.3.12 → pdfdancer_client_python-0.3.13}/tests/e2e/test_acroform.py +0 -0
  53. {pdfdancer_client_python-0.3.12 → pdfdancer_client_python-0.3.13}/tests/e2e/test_bezier_builder.py +0 -0
  54. {pdfdancer_client_python-0.3.12 → pdfdancer_client_python-0.3.13}/tests/e2e/test_clipping.py +0 -0
  55. {pdfdancer_client_python-0.3.12 → pdfdancer_client_python-0.3.13}/tests/e2e/test_form_x_objects.py +0 -0
  56. {pdfdancer_client_python-0.3.12 → pdfdancer_client_python-0.3.13}/tests/e2e/test_image.py +0 -0
  57. {pdfdancer_client_python-0.3.12 → pdfdancer_client_python-0.3.13}/tests/e2e/test_image_transform.py +0 -0
  58. {pdfdancer_client_python-0.3.12 → pdfdancer_client_python-0.3.13}/tests/e2e/test_line.py +0 -0
  59. {pdfdancer_client_python-0.3.12 → pdfdancer_client_python-0.3.13}/tests/e2e/test_line_builder.py +0 -0
  60. {pdfdancer_client_python-0.3.12 → pdfdancer_client_python-0.3.13}/tests/e2e/test_new_pdf.py +0 -0
  61. {pdfdancer_client_python-0.3.12 → pdfdancer_client_python-0.3.13}/tests/e2e/test_page.py +0 -0
  62. {pdfdancer_client_python-0.3.12 → pdfdancer_client_python-0.3.13}/tests/e2e/test_paragraph.py +0 -0
  63. {pdfdancer_client_python-0.3.12 → pdfdancer_client_python-0.3.13}/tests/e2e/test_path_builder.py +0 -0
  64. {pdfdancer_client_python-0.3.12 → pdfdancer_client_python-0.3.13}/tests/e2e/test_path_builder_rectangle.py +0 -0
  65. {pdfdancer_client_python-0.3.12 → pdfdancer_client_python-0.3.13}/tests/e2e/test_path_comprehensive.py +0 -0
  66. {pdfdancer_client_python-0.3.12 → pdfdancer_client_python-0.3.13}/tests/e2e/test_path_group.py +0 -0
  67. {pdfdancer_client_python-0.3.12 → pdfdancer_client_python-0.3.13}/tests/e2e/test_pdfdancer.py +0 -0
  68. {pdfdancer_client_python-0.3.12 → pdfdancer_client_python-0.3.13}/tests/e2e/test_positioning.py +0 -0
  69. {pdfdancer_client_python-0.3.12 → pdfdancer_client_python-0.3.13}/tests/e2e/test_rectangle_builder.py +0 -0
  70. {pdfdancer_client_python-0.3.12 → pdfdancer_client_python-0.3.13}/tests/e2e/test_singular_selection.py +0 -0
  71. {pdfdancer_client_python-0.3.12 → pdfdancer_client_python-0.3.13}/tests/e2e/test_snapshot.py +0 -0
  72. {pdfdancer_client_python-0.3.12 → pdfdancer_client_python-0.3.13}/tests/e2e/test_template_replace.py +0 -0
  73. {pdfdancer_client_python-0.3.12 → pdfdancer_client_python-0.3.13}/tests/e2e/test_template_replace_linebreak.py +0 -0
  74. {pdfdancer_client_python-0.3.12 → pdfdancer_client_python-0.3.13}/tests/e2e/test_text_line_edit.py +0 -0
  75. {pdfdancer_client_python-0.3.12 → pdfdancer_client_python-0.3.13}/tests/fixtures/Asimovian-Regular.ttf +0 -0
  76. {pdfdancer_client_python-0.3.12 → pdfdancer_client_python-0.3.13}/tests/fixtures/DancingScript-Regular.ttf +0 -0
  77. {pdfdancer_client_python-0.3.12 → pdfdancer_client_python-0.3.13}/tests/fixtures/Empty.pdf +0 -0
  78. {pdfdancer_client_python-0.3.12 → pdfdancer_client_python-0.3.13}/tests/fixtures/JetBrainsMono-Regular.ttf +0 -0
  79. {pdfdancer_client_python-0.3.12 → pdfdancer_client_python-0.3.13}/tests/fixtures/Roboto-Regular.ttf +0 -0
  80. {pdfdancer_client_python-0.3.12 → pdfdancer_client_python-0.3.13}/tests/fixtures/Showcase.pdf +0 -0
  81. {pdfdancer_client_python-0.3.12 → pdfdancer_client_python-0.3.13}/tests/fixtures/basic-paths.pdf +0 -0
  82. {pdfdancer_client_python-0.3.12 → pdfdancer_client_python-0.3.13}/tests/fixtures/form-xobject-example.pdf +0 -0
  83. {pdfdancer_client_python-0.3.12 → pdfdancer_client_python-0.3.13}/tests/fixtures/invisible-content-clipping-test.pdf +0 -0
  84. {pdfdancer_client_python-0.3.12 → pdfdancer_client_python-0.3.13}/tests/fixtures/logo-80.png +0 -0
  85. {pdfdancer_client_python-0.3.12 → pdfdancer_client_python-0.3.13}/tests/fixtures/mixed-form-types.pdf +0 -0
  86. {pdfdancer_client_python-0.3.12 → pdfdancer_client_python-0.3.13}/tests/test_anonymous_token.py +0 -0
  87. {pdfdancer_client_python-0.3.12 → pdfdancer_client_python-0.3.13}/tests/test_fingerprint.py +0 -0
  88. {pdfdancer_client_python-0.3.12 → pdfdancer_client_python-0.3.13}/tests/test_models.py +0 -0
  89. {pdfdancer_client_python-0.3.12 → pdfdancer_client_python-0.3.13}/tests/test_openapi_compliance.py +0 -0
  90. {pdfdancer_client_python-0.3.12 → pdfdancer_client_python-0.3.13}/tests/test_path_models.py +0 -0
  91. {pdfdancer_client_python-0.3.12 → pdfdancer_client_python-0.3.13}/tests/test_rate_limit.py +0 -0
  92. {pdfdancer_client_python-0.3.12 → pdfdancer_client_python-0.3.13}/tests/test_standard_fonts.py +0 -0
@@ -12,6 +12,10 @@ jobs:
12
12
  # Quick minimal test on non-main branches
13
13
  if: github.ref != 'refs/heads/main'
14
14
  runs-on: ubuntu-latest
15
+ permissions:
16
+ contents: read
17
+ issues: read
18
+ pull-requests: read
15
19
  strategy:
16
20
  fail-fast: false
17
21
  max-parallel: 2
@@ -23,16 +27,51 @@ jobs:
23
27
  with:
24
28
  submodules: true
25
29
 
26
- - name: Detect API URL override
27
- if: github.event_name == 'pull_request'
28
- id: api-url
29
- run: |
30
- if [ -f .api-url ]; then
31
- url=$(cat .api-url | tr -d '[:space:]')
32
- if [ -n "$url" ]; then
33
- echo "base_url=$url" >> "$GITHUB_OUTPUT"
34
- fi
35
- fi
30
+ - name: Resolve API base URL
31
+ id: resolve-api-base-url
32
+ uses: actions/github-script@v7
33
+ with:
34
+ github-token: ${{ secrets.PROPAGATE_TOKEN }}
35
+ script: |
36
+ const fallbackUrl = 'https://api-staging.pdfdancer.com';
37
+ const pr = context.payload.pull_request;
38
+ if (!pr) {
39
+ core.setOutput('base_url', fallbackUrl);
40
+ return;
41
+ }
42
+
43
+ const body = pr.body || '';
44
+ const apiPrMatch = body.match(/## Source API PR[\s\S]*?^- Repository:\s*MenschMachine\/pdfdancer-api\s*$[\s\S]*?^- PR:\s*#?(\d+)\s*$/im);
45
+ if (!apiPrMatch) {
46
+ core.info('No Source API PR block found in the PR body; using staging API.');
47
+ core.setOutput('base_url', fallbackUrl);
48
+ return;
49
+ }
50
+
51
+ const apiPrNumber = Number(apiPrMatch[1]);
52
+ const comments = await github.paginate(github.rest.issues.listComments, {
53
+ owner: 'MenschMachine',
54
+ repo: 'pdfdancer-api',
55
+ issue_number: apiPrNumber,
56
+ per_page: 100,
57
+ });
58
+ const previewComments = comments.filter((comment) =>
59
+ typeof comment.body === 'string' && comment.body.includes('### Preview Environment')
60
+ );
61
+ if (previewComments.length === 0) {
62
+ core.setFailed(`Source API PR #${apiPrNumber} is referenced by this PR, but no preview comment was found.`);
63
+ return;
64
+ }
65
+
66
+ const previewComment = previewComments[previewComments.length - 1];
67
+ const urlMatch = previewComment.body.match(/\*\*API URL:\*\*\s*(https?:\/\/\S+)/);
68
+ if (!urlMatch) {
69
+ core.setFailed(`Preview comment on source API PR #${apiPrNumber} does not contain a parseable API URL.`);
70
+ return;
71
+ }
72
+
73
+ core.info(`Using preview API URL from source API PR #${apiPrNumber}: ${urlMatch[1]}`);
74
+ core.setOutput('base_url', urlMatch[1]);
36
75
 
37
76
  - name: Set up Python ${{ matrix.python-version }}
38
77
  uses: actions/setup-python@v5
@@ -53,8 +92,8 @@ jobs:
53
92
 
54
93
  - name: Run tests
55
94
  run: |
56
- PDFDANCER_BASE_URL=${{ steps.api-url.outputs.base_url || 'https://api-staging.pdfdancer.com' }} \
57
- PDFDANCER_TOKEN=42 \
95
+ PDFDANCER_BASE_URL=${{ steps.resolve-api-base-url.outputs.base_url }} \
96
+ PDFDANCER_API_TOKEN=42 \
58
97
  venv/bin/python -m pytest tests/ -v --maxfail=3
59
98
 
60
99
  - name: Build distribution packages
@@ -68,6 +107,10 @@ jobs:
68
107
  # Full matrix only on main
69
108
  if: github.ref == 'refs/heads/main'
70
109
  runs-on: ${{ matrix.os }}
110
+ permissions:
111
+ contents: read
112
+ issues: read
113
+ pull-requests: read
71
114
  strategy:
72
115
  fail-fast: false
73
116
  max-parallel: 2
@@ -80,17 +123,13 @@ jobs:
80
123
  with:
81
124
  submodules: true
82
125
 
83
- - name: Detect API URL override
84
- if: github.event_name == 'pull_request'
85
- id: api-url
86
- shell: bash
87
- run: |
88
- if [ -f .api-url ]; then
89
- url=$(cat .api-url | tr -d '[:space:]')
90
- if [ -n "$url" ]; then
91
- echo "base_url=$url" >> "$GITHUB_OUTPUT"
92
- fi
93
- fi
126
+ - name: Resolve API base URL
127
+ id: resolve-api-base-url
128
+ uses: actions/github-script@v7
129
+ with:
130
+ github-token: ${{ secrets.PROPAGATE_TOKEN }}
131
+ script: |
132
+ core.setOutput('base_url', 'https://api-staging.pdfdancer.com');
94
133
 
95
134
  - name: Set up Python ${{ matrix.python-version }}
96
135
  uses: actions/setup-python@v5
@@ -115,8 +154,8 @@ jobs:
115
154
  - name: Run tests (Unix)
116
155
  if: runner.os != 'Windows'
117
156
  run: |
118
- PDFDANCER_BASE_URL=${{ steps.api-url.outputs.base_url || 'https://api-staging.pdfdancer.com' }} \
119
- PDFDANCER_TOKEN=42 \
157
+ PDFDANCER_BASE_URL=${{ steps.resolve-api-base-url.outputs.base_url }} \
158
+ PDFDANCER_API_TOKEN=42 \
120
159
  venv/bin/python -m pytest tests/ -v --maxfail=3
121
160
 
122
161
  - name: Build & Validate (Unix)
@@ -146,8 +185,8 @@ jobs:
146
185
  if: runner.os == 'Windows'
147
186
  shell: cmd
148
187
  run: |
149
- set PDFDANCER_BASE_URL=${{ steps.api-url.outputs.base_url || 'https://api-staging.pdfdancer.com' }}
150
- set PDFDANCER_TOKEN=42
188
+ set PDFDANCER_BASE_URL=${{ steps.resolve-api-base-url.outputs.base_url }}
189
+ set PDFDANCER_API_TOKEN=42
151
190
  venv\Scripts\python -m pytest tests/ -v --maxfail=3
152
191
 
153
192
  - name: Build & Validate (Windows)
@@ -155,4 +194,4 @@ jobs:
155
194
  shell: cmd
156
195
  run: |
157
196
  venv\Scripts\python -m build
158
- venv\Scripts\python -m twine check dist/*
197
+ venv\Scripts\python -m twine check dist/*
@@ -0,0 +1,88 @@
1
+ name: SDK Backward Compatibility Tests
2
+
3
+ on:
4
+ repository_dispatch:
5
+ types: [api-preview-deployed]
6
+
7
+ workflow_dispatch:
8
+ inputs:
9
+ api_url:
10
+ description: 'API base URL to test against'
11
+ required: true
12
+ type: string
13
+ sdk_token:
14
+ description: 'API token for testing'
15
+ required: true
16
+ type: string
17
+ default: '42'
18
+
19
+ jobs:
20
+ get-tags:
21
+ runs-on: ubuntu-latest
22
+ outputs:
23
+ tags: ${{ steps.tags.outputs.tags }}
24
+ steps:
25
+ - name: Fetch tags
26
+ id: tags
27
+ run: |
28
+ TAGS=$(git ls-remote --tags https://github.com/MenschMachine/pdfdancer-client-python.git \
29
+ | grep -v '\^{}' \
30
+ | awk -F'/' '{print $NF}' \
31
+ | grep '^v' \
32
+ | sort -V \
33
+ | tail -3 \
34
+ | jq -R -s -c 'split("\n") | map(select(length > 0))')
35
+ echo "tags=$TAGS" >> "$GITHUB_OUTPUT"
36
+ echo "Last 3 SDK tags: $TAGS"
37
+
38
+ test-compat:
39
+ needs: get-tags
40
+ runs-on: ubuntu-latest
41
+ timeout-minutes: 60
42
+ strategy:
43
+ fail-fast: false
44
+ max-parallel: 1
45
+ matrix:
46
+ tag: ${{ fromJSON(needs.get-tags.outputs.tags) }}
47
+
48
+ steps:
49
+ - name: Checkout SDK at ${{ matrix.tag }}
50
+ uses: actions/checkout@v4
51
+ with:
52
+ repository: MenschMachine/pdfdancer-client-python
53
+ ref: ${{ matrix.tag }}
54
+ path: sdk
55
+
56
+ - name: Set up Python
57
+ uses: actions/setup-python@v5
58
+ with:
59
+ python-version: '3.12'
60
+
61
+ - name: Create virtual environment
62
+ run: python -m venv venv
63
+
64
+ - name: Install SDK at ${{ matrix.tag }}
65
+ run: |
66
+ venv/bin/pip install --upgrade pip
67
+ venv/bin/pip install -e "./sdk[dev]"
68
+
69
+ - name: Run e2e tests
70
+ working-directory: sdk
71
+ run: |
72
+ PDFDANCER_BASE_URL=${{ github.event.client_payload.api_url || inputs.api_url }} \
73
+ PDFDANCER_API_TOKEN=${{ github.event.client_payload.sdk_token || inputs.sdk_token }} \
74
+ ../venv/bin/python -m pytest -v -x \
75
+ -k "not test_redact_multiple_paths and not test_context_manager_vs_manual_apply" \
76
+ --junitxml=test-results.xml
77
+ env:
78
+ PYTHONWARNINGS: "ignore:Unverified HTTPS request"
79
+
80
+ - name: Upload test results
81
+ if: failure()
82
+ uses: actions/upload-artifact@v4
83
+ with:
84
+ name: test-results-${{ matrix.tag }}
85
+ path: |
86
+ sdk/test-results.xml
87
+ sdk/.pytest_cache/
88
+ retention-days: 7
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pdfdancer-client-python
3
- Version: 0.3.12
3
+ Version: 0.3.13
4
4
  Summary: Python client for PDFDancer API
5
5
  Author-email: "The Famous Cat Ltd." <hi@thefamouscat.com>
6
6
  License:
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pdfdancer-client-python"
7
- version = "0.3.12"
7
+ version = "0.3.13"
8
8
  description = "Python client for PDFDancer API"
9
9
  readme = "README.md"
10
10
  authors = [
@@ -28,6 +28,7 @@ from .models import (
28
28
  ImageTransformRequest,
29
29
  ImageTransformType,
30
30
  Line,
31
+ ModifyPathRequest,
31
32
  ObjectRef,
32
33
  ObjectType,
33
34
  Orientation,
@@ -36,6 +37,7 @@ from .models import (
36
37
  Paragraph,
37
38
  Path,
38
39
  PathGroupInfo,
40
+ PathObjectRef,
39
41
  PathSegment,
40
42
  Point,
41
43
  Position,
@@ -55,7 +57,7 @@ from .paragraph_builder import ParagraphBuilder
55
57
  from .path_builder import BezierBuilder, LineBuilder, PathBuilder
56
58
  from .text_line_builder import TextLineBuilder
57
59
 
58
- __version__ = "0.3.12"
60
+ __version__ = "0.3.13"
59
61
  __all__ = [
60
62
  "PDFDancer",
61
63
  "ParagraphBuilder",
@@ -65,6 +67,8 @@ __all__ = [
65
67
  "LineBuilder",
66
68
  "BezierBuilder",
67
69
  "ObjectRef",
70
+ "PathObjectRef",
71
+ "ModifyPathRequest",
68
72
  "Position",
69
73
  "ObjectType",
70
74
  "Font",
@@ -1895,3 +1895,85 @@ class ImageTransformRequest:
1895
1895
  result["fillColor"] = self.fill_color
1896
1896
 
1897
1897
  return result
1898
+
1899
+
1900
+ @dataclass
1901
+ class ModifyPathRequest:
1902
+ """Request to modify path stroke and fill colors.
1903
+
1904
+ Parameters:
1905
+ - object_ref: Reference to the path to modify.
1906
+ - stroke_color: New stroke color (optional - null means don't change).
1907
+ - fill_color: New fill color (optional - null means don't change).
1908
+
1909
+ Example:
1910
+ ```python
1911
+ req = ModifyPathRequest(object_ref=path_ref, stroke_color=Color(255, 0, 0), fill_color=None)
1912
+ payload = req.to_dict()
1913
+ ```
1914
+ """
1915
+
1916
+ object_ref: ObjectRef
1917
+ stroke_color: Optional[Color] = None
1918
+ fill_color: Optional[Color] = None
1919
+
1920
+ def to_dict(self) -> dict:
1921
+ """Convert to dictionary for JSON serialization."""
1922
+ result: Dict[str, Any] = {
1923
+ "ref": self.object_ref.to_dict(),
1924
+ }
1925
+
1926
+ if self.stroke_color is not None:
1927
+ result["strokeColor"] = {
1928
+ "red": self.stroke_color.r,
1929
+ "green": self.stroke_color.g,
1930
+ "blue": self.stroke_color.b,
1931
+ "alpha": self.stroke_color.a,
1932
+ }
1933
+
1934
+ if self.fill_color is not None:
1935
+ result["fillColor"] = {
1936
+ "red": self.fill_color.r,
1937
+ "green": self.fill_color.g,
1938
+ "blue": self.fill_color.b,
1939
+ "alpha": self.fill_color.a,
1940
+ }
1941
+
1942
+ return result
1943
+
1944
+
1945
+ class PathObjectRef(ObjectRef):
1946
+ """
1947
+ Reference to a path object with stroke and fill color information.
1948
+
1949
+ Parameters (typically provided by the server):
1950
+ - internal_id: Identifier of the path object.
1951
+ - position: Position of the path.
1952
+ - object_type: Should be ObjectType.PATH.
1953
+ - stroke_color: Stroke/outline color of the path (optional).
1954
+ - fill_color: Fill color of the path (optional).
1955
+
1956
+ Usage:
1957
+ - Returned by find/snapshot APIs when querying paths.
1958
+ - Pass to ModifyPathRequest to update path colors.
1959
+ """
1960
+
1961
+ def __init__(
1962
+ self,
1963
+ internal_id: str,
1964
+ position: Position,
1965
+ object_type: ObjectType,
1966
+ stroke_color: Optional[Color] = None,
1967
+ fill_color: Optional[Color] = None,
1968
+ ):
1969
+ super().__init__(internal_id, position, object_type)
1970
+ self.stroke_color = stroke_color
1971
+ self.fill_color = fill_color
1972
+
1973
+ def get_stroke_color(self) -> Optional[Color]:
1974
+ """Get the stroke/outline color of the path."""
1975
+ return self.stroke_color
1976
+
1977
+ def get_fill_color(self) -> Optional[Color]:
1978
+ """Get the fill color of the path."""
1979
+ return self.fill_color
@@ -47,6 +47,7 @@ from .models import (
47
47
  FontType,
48
48
  FormFieldRef,
49
49
  Image,
50
+ ModifyPathRequest,
50
51
  ModifyRequest,
51
52
  ModifyTextRequest,
52
53
  MoveRequest,
@@ -58,6 +59,7 @@ from .models import (
58
59
  PageSize,
59
60
  PageSnapshot,
60
61
  Paragraph,
62
+ PathObjectRef,
61
63
  Position,
62
64
  PositionMode,
63
65
  RedactRequest,
@@ -2755,6 +2757,38 @@ class PDFDancer:
2755
2757
  self._invalidate_snapshots()
2756
2758
  return result
2757
2759
 
2760
+ def _modify_path(
2761
+ self,
2762
+ object_ref: ObjectRef,
2763
+ stroke_color: Optional[Color],
2764
+ fill_color: Optional[Color],
2765
+ ) -> CommandResult:
2766
+ """
2767
+ Modifies the stroke and fill colors of a path object.
2768
+
2769
+ Args:
2770
+ object_ref: Reference to the path to modify.
2771
+ stroke_color: New stroke color (None means don't change).
2772
+ fill_color: New fill color (None means don't change).
2773
+
2774
+ Returns:
2775
+ CommandResult indicating success or failure.
2776
+ """
2777
+ if object_ref is None:
2778
+ raise ValidationException("Object reference cannot be null")
2779
+
2780
+ request_data = ModifyPathRequest(
2781
+ object_ref=object_ref,
2782
+ stroke_color=stroke_color,
2783
+ fill_color=fill_color,
2784
+ ).to_dict()
2785
+ response = self._make_request("PUT", "/pdf/modify/path", data=request_data)
2786
+ result = CommandResult.from_dict(response.json())
2787
+
2788
+ # Invalidate snapshot caches after mutation
2789
+ self._invalidate_snapshots()
2790
+ return result
2791
+
2758
2792
  # Font Operations
2759
2793
 
2760
2794
  def find_fonts(self, font_name: str, font_size: int) -> List[Font]:
@@ -3159,6 +3193,43 @@ class PDFDancer:
3159
3193
  value=obj_data["value"] if "value" in obj_data else None,
3160
3194
  )
3161
3195
 
3196
+ def _parse_path_object_ref(self, obj_data: dict) -> PathObjectRef:
3197
+ """Parse JSON object data into PathObjectRef instance with color information."""
3198
+ position_data = obj_data.get("position", {})
3199
+ position = self._parse_position(position_data) if position_data else None
3200
+
3201
+ object_type = ObjectType(obj_data["type"])
3202
+
3203
+ # Parse stroke color if present
3204
+ stroke_color = None
3205
+ stroke_color_data = obj_data.get("strokeColor")
3206
+ if isinstance(stroke_color_data, dict):
3207
+ red = stroke_color_data.get("red")
3208
+ green = stroke_color_data.get("green")
3209
+ blue = stroke_color_data.get("blue")
3210
+ alpha = stroke_color_data.get("alpha", 255)
3211
+ if all(isinstance(v, int) for v in [red, green, blue]):
3212
+ stroke_color = Color(red, green, blue, alpha)
3213
+
3214
+ # Parse fill color if present
3215
+ fill_color = None
3216
+ fill_color_data = obj_data.get("fillColor")
3217
+ if isinstance(fill_color_data, dict):
3218
+ red = fill_color_data.get("red")
3219
+ green = fill_color_data.get("green")
3220
+ blue = fill_color_data.get("blue")
3221
+ alpha = fill_color_data.get("alpha", 255)
3222
+ if all(isinstance(v, int) for v in [red, green, blue]):
3223
+ fill_color = Color(red, green, blue, alpha)
3224
+
3225
+ return PathObjectRef(
3226
+ internal_id=obj_data["internalId"] if "internalId" in obj_data else None,
3227
+ position=position,
3228
+ object_type=object_type,
3229
+ stroke_color=stroke_color,
3230
+ fill_color=fill_color,
3231
+ )
3232
+
3162
3233
  @staticmethod
3163
3234
  def _parse_position(pos_data: dict) -> Position:
3164
3235
  """Parse JSON position data into Position instance."""
@@ -3473,6 +3544,9 @@ class PDFDancer:
3473
3544
  ):
3474
3545
  # Parse as FormFieldRef to capture name and value
3475
3546
  elements.append(self._parse_form_field_ref(elem_data))
3547
+ elif elem_type == ObjectType.PATH:
3548
+ # Parse as PathObjectRef to capture stroke/fill colors
3549
+ elements.append(self._parse_path_object_ref(elem_data))
3476
3550
  else:
3477
3551
  # Parse as basic ObjectRef
3478
3552
  elements.append(self._parse_object_ref(elem_data))
@@ -3526,9 +3600,7 @@ class PDFDancer:
3526
3600
  self._client.close()
3527
3601
 
3528
3602
  def _to_path_objects(self, refs: List[ObjectRef]) -> List[PathObject]:
3529
- return [
3530
- PathObject(self, ref.internal_id, ref.type, ref.position) for ref in refs
3531
- ]
3603
+ return [PathObject(self, ref) for ref in refs]
3532
3604
 
3533
3605
  def _to_paragraph_objects(self, refs: List[TextObjectRef]) -> List[ParagraphObject]:
3534
3606
  return [ParagraphObject(self, ref) for ref in refs]
@@ -4,7 +4,7 @@ import sys
4
4
  from dataclasses import dataclass
5
5
  from typing import TYPE_CHECKING, Optional
6
6
 
7
- from . import FormFieldRef, ObjectRef, ObjectType, Point, Position, TextObjectRef
7
+ from . import FormFieldRef, ObjectRef, ObjectType, PathObjectRef, Point, Position, TextObjectRef
8
8
  from .exceptions import ValidationException
9
9
 
10
10
  if TYPE_CHECKING:
@@ -86,11 +86,44 @@ class PDFObjectBase:
86
86
  class PathObject(PDFObjectBase):
87
87
  """Represents a vector path object inside a PDF page."""
88
88
 
89
+ def __init__(self, client: "PDFDancer", object_ref):
90
+ """
91
+ Initialize a PathObject.
92
+
93
+ Args:
94
+ client: PDFDancer client instance
95
+ object_ref: ObjectRef or PathObjectRef with path data
96
+ """
97
+ super().__init__(
98
+ client, object_ref.internal_id, object_ref.type, object_ref.position
99
+ )
100
+ self._object_ref = object_ref
101
+
89
102
  @property
90
103
  def bounding_box(self) -> Optional[BoundingRect]:
91
104
  """Optional bounding rectangle (if available)."""
92
105
  return self.position.bounding_rect
93
106
 
107
+ def edit(self) -> PathEditSession:
108
+ """Start a fluent editing session to modify path colors."""
109
+ return PathEditSession(self._client, self.object_ref())
110
+
111
+ def object_ref(self):
112
+ """Return an ObjectRef for this path."""
113
+ return self._object_ref
114
+
115
+ def get_stroke_color(self) -> Optional["Color"]:
116
+ """Get the stroke/outline color of the path, or None if not set."""
117
+ if isinstance(self._object_ref, PathObjectRef):
118
+ return self._object_ref.get_stroke_color()
119
+ return None
120
+
121
+ def get_fill_color(self) -> Optional["Color"]:
122
+ """Get the fill color of the path, or None if not set."""
123
+ if isinstance(self._object_ref, PathObjectRef):
124
+ return self._object_ref.get_fill_color()
125
+ return None
126
+
94
127
  def __eq__(self, other):
95
128
  if not isinstance(other, PathObject):
96
129
  return False
@@ -748,3 +781,61 @@ class FormFieldObject(PDFObjectBase):
748
781
  and self.name == other.name
749
782
  and self.value == other.value
750
783
  )
784
+
785
+
786
+ class PathEditSession:
787
+ """
788
+ Fluent editing helper for modifying path stroke and fill colors.
789
+ """
790
+
791
+ def __init__(self, client: "PDFDancer", object_ref):
792
+ self._client = client
793
+ self._object_ref = object_ref
794
+ self._stroke_color = None
795
+ self._fill_color = None
796
+
797
+ def __enter__(self):
798
+ return self
799
+
800
+ def __exit__(self, exc_type, exc_val, exc_tb):
801
+ if exc_type:
802
+ return False
803
+ self.apply()
804
+ return False
805
+
806
+ def stroke_color(self, color) -> "PathEditSession":
807
+ """
808
+ Set the stroke/outline color.
809
+
810
+ Args:
811
+ color: The stroke color (Color object)
812
+
813
+ Returns:
814
+ Self for method chaining
815
+ """
816
+ self._stroke_color = color
817
+ return self
818
+
819
+ def fill_color(self, color) -> "PathEditSession":
820
+ """
821
+ Set the fill color.
822
+
823
+ Args:
824
+ color: The fill color (Color object)
825
+
826
+ Returns:
827
+ Self for method chaining
828
+ """
829
+ self._fill_color = color
830
+ return self
831
+
832
+ def apply(self):
833
+ """
834
+ Apply the color modifications to the path.
835
+
836
+ Returns:
837
+ CommandResult indicating success or failure
838
+ """
839
+ return self._client._modify_path(
840
+ self._object_ref, self._stroke_color, self._fill_color
841
+ )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pdfdancer-client-python
3
- Version: 0.3.12
3
+ Version: 0.3.13
4
4
  Summary: Python client for PDFDancer API
5
5
  Author-email: "The Famous Cat Ltd." <hi@thefamouscat.com>
6
6
  License:
@@ -1,4 +1,3 @@
1
- .api-url
2
1
  .flake8
3
2
  .gitignore
4
3
  .gitmodules
@@ -16,6 +15,7 @@ test.sh
16
15
  .github/workflows/ci.yml
17
16
  .github/workflows/daily-tests.yml
18
17
  .github/workflows/release.yml
18
+ .github/workflows/sdk-backward-compat.yml
19
19
  docs/capabilities/.gitkeep
20
20
  docs/capabilities/CLEAR_CLIPPING.md
21
21
  media/logo-orange-512h.webp
@@ -398,6 +398,12 @@ def test_context_manager_example_from_docs():
398
398
  )
399
399
 
400
400
 
401
+ @pytest.mark.skip(reason="""
402
+ The following test is disabled because it fails intermittently:
403
+ Minor variations in PDF byte output result in failures, even though output is visually and functionally identical.
404
+ Such spurious differences can be caused by library version changes, metadata, timestamps, and non-semantic structure within PDFs.
405
+ Therefore, strict byte count comparison is too brittle for a reliable automated test.
406
+ """)
401
407
  def test_context_manager_vs_manual_apply():
402
408
  """Test that context manager produces same result as manual apply()"""
403
409
  base_url, token, pdf_path = _require_env_and_fixture("Showcase.pdf")
@@ -1,6 +1,6 @@
1
1
  import pytest
2
2
 
3
- from pdfdancer import ObjectType
3
+ from pdfdancer import Color, ObjectType
4
4
  from pdfdancer.pdfdancer_v1 import PDFDancer
5
5
  from tests.e2e import _require_env_and_fixture
6
6
  from tests.e2e.pdf_assertions import PDFAssertions
@@ -79,3 +79,50 @@ def test_move_path():
79
79
  .assert_no_path_at(80, 720)
80
80
  .assert_path_is_at("PATH_0_000001", 50.1, 100)
81
81
  )
82
+
83
+
84
+ def test_modify_path_colors():
85
+ """Test modifying stroke and fill colors of a path."""
86
+ base_url, token, pdf_path = _require_env_and_fixture("basic-paths.pdf")
87
+
88
+ with PDFDancer.open(pdf_path, token=token, base_url=base_url, timeout=30.0) as pdf:
89
+ # PATH_0_000001 is a line at (80, 720)
90
+ path = pdf.page(1).select_paths_at(80, 720)[0]
91
+ assert path.internal_id == "PATH_0_000001"
92
+
93
+ # Modify the stroke color to red
94
+ red = Color(255, 0, 0)
95
+ result = path.edit().stroke_color(red).apply()
96
+ assert result.success, f"Expected success but got: {result.message}"
97
+
98
+ # Re-fetch path via select_paths() to verify the stroke color was actually changed
99
+ # Note: select_paths_at() goes to API which doesn't return colors,
100
+ # select_paths() uses snapshot which includes color data
101
+ paths_after_stroke = [p for p in pdf.select_paths() if p.internal_id == "PATH_0_000001"]
102
+ assert len(paths_after_stroke) == 1
103
+ path_after_stroke = paths_after_stroke[0]
104
+ stroke_color = path_after_stroke.get_stroke_color()
105
+ assert stroke_color is not None, "Expected stroke color to be set after modification"
106
+ assert stroke_color.r == 255, f"Expected red=255 but got {stroke_color.r}"
107
+ assert stroke_color.g == 0, f"Expected green=0 but got {stroke_color.g}"
108
+ assert stroke_color.b == 0, f"Expected blue=0 but got {stroke_color.b}"
109
+
110
+ # Modify both stroke and fill colors
111
+ blue = Color(0, 0, 255)
112
+ result = path.edit().stroke_color(red).fill_color(blue).apply()
113
+ assert result.success, f"Expected success but got: {result.message}"
114
+
115
+ # Re-fetch via select_paths() to verify both colors were actually changed
116
+ paths_after_both = [p for p in pdf.select_paths() if p.internal_id == "PATH_0_000001"]
117
+ assert len(paths_after_both) == 1
118
+ path_after_both = paths_after_both[0]
119
+ stroke_color = path_after_both.get_stroke_color()
120
+ fill_color = path_after_both.get_fill_color()
121
+ assert stroke_color is not None, "Expected stroke color to be set"
122
+ assert fill_color is not None, "Expected fill color to be set after modification"
123
+ assert stroke_color.r == 255, f"Expected stroke red=255 but got {stroke_color.r}"
124
+ assert stroke_color.g == 0, f"Expected stroke green=0 but got {stroke_color.g}"
125
+ assert stroke_color.b == 0, f"Expected stroke blue=0 but got {stroke_color.b}"
126
+ assert fill_color.r == 0, f"Expected fill red=0 but got {fill_color.r}"
127
+ assert fill_color.g == 0, f"Expected fill green=0 but got {fill_color.g}"
128
+ assert fill_color.b == 255, f"Expected fill blue=255 but got {fill_color.b}"
@@ -202,6 +202,7 @@ def test_redact_path():
202
202
  assertions.assert_number_of_paths(8)
203
203
 
204
204
 
205
+ @pytest.mark.skip(reason="TODO cannot make a meaningful assertion currently")
205
206
  def test_redact_multiple_paths():
206
207
  """Test batch redacting multiple paths"""
207
208
  base_url, token, pdf_path = _require_env_and_fixture("basic-paths.pdf")
@@ -6,7 +6,7 @@ from unittest.mock import Mock
6
6
 
7
7
  import pytest
8
8
 
9
- from pdfdancer import Color, ObjectType, Position, TextObjectRef
9
+ from pdfdancer import Color, ObjectRef, ObjectType, Position, TextObjectRef
10
10
  from pdfdancer.types import (
11
11
  FormFieldObject,
12
12
  FormObject,
@@ -24,9 +24,10 @@ class TestPDFObjectEquality:
24
24
  """PathObject instances with same internal_id and type should be equal."""
25
25
  mock_client = Mock()
26
26
  position = Position.at_page(1)
27
+ ref = ObjectRef("id123", position, ObjectType.PATH)
27
28
 
28
- obj1 = PathObject(mock_client, "id123", ObjectType.PATH, position)
29
- obj2 = PathObject(mock_client, "id123", ObjectType.PATH, position)
29
+ obj1 = PathObject(mock_client, ref)
30
+ obj2 = PathObject(mock_client, ref)
30
31
 
31
32
  assert obj1 == obj2
32
33
 
@@ -34,9 +35,11 @@ class TestPDFObjectEquality:
34
35
  """PathObject instances with different internal_id should not be equal."""
35
36
  mock_client = Mock()
36
37
  position = Position.at_page(1)
38
+ ref1 = ObjectRef("id123", position, ObjectType.PATH)
39
+ ref2 = ObjectRef("id456", position, ObjectType.PATH)
37
40
 
38
- obj1 = PathObject(mock_client, "id123", ObjectType.PATH, position)
39
- obj2 = PathObject(mock_client, "id456", ObjectType.PATH, position)
41
+ obj1 = PathObject(mock_client, ref1)
42
+ obj2 = PathObject(mock_client, ref2)
40
43
 
41
44
  assert obj1 != obj2
42
45
 
@@ -46,8 +49,11 @@ class TestPDFObjectEquality:
46
49
  position1 = Position.at_page(1)
47
50
  position2 = Position.at_page(2)
48
51
 
49
- obj1 = PathObject(mock_client, "id123", ObjectType.PATH, position1)
50
- obj2 = PathObject(mock_client, "id123", ObjectType.PATH, position2)
52
+ ref1 = ObjectRef("id123", position1, ObjectType.PATH)
53
+ ref2 = ObjectRef("id123", position2, ObjectType.PATH)
54
+
55
+ obj1 = PathObject(mock_client, ref1)
56
+ obj2 = PathObject(mock_client, ref2)
51
57
 
52
58
  assert obj1 != obj2
53
59
 
@@ -55,8 +61,9 @@ class TestPDFObjectEquality:
55
61
  """PathObject should not equal non-PathObject."""
56
62
  mock_client = Mock()
57
63
  position = Position.at_page(1)
64
+ path_ref = ObjectRef("id123", position, ObjectType.PATH)
58
65
 
59
- obj1 = PathObject(mock_client, "id123", ObjectType.PATH, position)
66
+ obj1 = PathObject(mock_client, path_ref)
60
67
  obj2 = ImageObject(mock_client, "id123", ObjectType.IMAGE, position)
61
68
 
62
69
  assert obj1 != obj2
@@ -296,8 +303,9 @@ class TestPDFObjectEquality:
296
303
  """PDFObjectBase subclasses should not equal None."""
297
304
  mock_client = Mock()
298
305
  position = Position.at_page(1)
306
+ ref = ObjectRef("id123", position, ObjectType.PATH)
299
307
 
300
- obj = PathObject(mock_client, "id123", ObjectType.PATH, position)
308
+ obj = PathObject(mock_client, ref)
301
309
 
302
310
  assert obj != None
303
311
 
@@ -305,7 +313,8 @@ class TestPDFObjectEquality:
305
313
  """PDFObjectBase subclasses should not equal strings."""
306
314
  mock_client = Mock()
307
315
  position = Position.at_page(1)
316
+ ref = ObjectRef("id123", position, ObjectType.PATH)
308
317
 
309
- obj = PathObject(mock_client, "id123", ObjectType.PATH, position)
318
+ obj = PathObject(mock_client, ref)
310
319
 
311
320
  assert obj != "id123"
@@ -1 +0,0 @@
1
- http://46.225.120.69:8080