c2pa-python 0.34.0__tar.gz → 0.35.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (22) hide show
  1. {c2pa_python-0.34.0/src/c2pa_python.egg-info → c2pa_python-0.35.0}/PKG-INFO +1 -1
  2. {c2pa_python-0.34.0 → c2pa_python-0.35.0}/pyproject.toml +1 -1
  3. {c2pa_python-0.34.0 → c2pa_python-0.35.0}/src/c2pa/c2pa.py +11 -1
  4. {c2pa_python-0.34.0 → c2pa_python-0.35.0}/src/c2pa/lib.py +25 -0
  5. {c2pa_python-0.34.0 → c2pa_python-0.35.0/src/c2pa_python.egg-info}/PKG-INFO +1 -1
  6. {c2pa_python-0.34.0 → c2pa_python-0.35.0}/tests/test_unit_tests_threaded.py +161 -1
  7. {c2pa_python-0.34.0 → c2pa_python-0.35.0}/LICENSE-APACHE +0 -0
  8. {c2pa_python-0.34.0 → c2pa_python-0.35.0}/LICENSE-MIT +0 -0
  9. {c2pa_python-0.34.0 → c2pa_python-0.35.0}/MANIFEST.in +0 -0
  10. {c2pa_python-0.34.0 → c2pa_python-0.35.0}/README.md +0 -0
  11. {c2pa_python-0.34.0 → c2pa_python-0.35.0}/requirements.txt +0 -0
  12. {c2pa_python-0.34.0 → c2pa_python-0.35.0}/scripts/download_artifacts.py +0 -0
  13. {c2pa_python-0.34.0 → c2pa_python-0.35.0}/setup.cfg +0 -0
  14. {c2pa_python-0.34.0 → c2pa_python-0.35.0}/setup.py +0 -0
  15. {c2pa_python-0.34.0 → c2pa_python-0.35.0}/src/c2pa/__init__.py +0 -0
  16. {c2pa_python-0.34.0 → c2pa_python-0.35.0}/src/c2pa/build.py +0 -0
  17. {c2pa_python-0.34.0 → c2pa_python-0.35.0}/src/c2pa_python.egg-info/SOURCES.txt +0 -0
  18. {c2pa_python-0.34.0 → c2pa_python-0.35.0}/src/c2pa_python.egg-info/dependency_links.txt +0 -0
  19. {c2pa_python-0.34.0 → c2pa_python-0.35.0}/src/c2pa_python.egg-info/entry_points.txt +0 -0
  20. {c2pa_python-0.34.0 → c2pa_python-0.35.0}/src/c2pa_python.egg-info/requires.txt +0 -0
  21. {c2pa_python-0.34.0 → c2pa_python-0.35.0}/src/c2pa_python.egg-info/top_level.txt +0 -0
  22. {c2pa_python-0.34.0 → c2pa_python-0.35.0}/tests/test_unit_tests.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: c2pa-python
3
- Version: 0.34.0
3
+ Version: 0.35.0
4
4
  Summary: Python bindings for the C2PA Content Authenticity Initiative (CAI) library
5
5
  Author-email: Gavin Peacock <gvnpeacock@adobe.com>, Tania Mathern <mathern@adobe.com>
6
6
  Maintainer-email: Gavin Peacock <gpeacock@adobe.com>
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "c2pa-python"
7
- version = "0.34.0"
7
+ version = "0.35.0"
8
8
  requires-python = ">=3.10"
9
9
  description = "Python bindings for the C2PA Content Authenticity Initiative (CAI) library"
10
10
  readme = { file = "README.md", content-type = "text/markdown" }
@@ -22,7 +22,7 @@ from abc import ABC, abstractmethod
22
22
  from pathlib import Path
23
23
  from typing import Optional, Union, Callable, Any, overload
24
24
  import io
25
- from .lib import dynamically_load_library
25
+ from .lib import dynamically_load_library, record_owner_pid, is_foreign_process
26
26
  import mimetypes
27
27
  from itertools import count
28
28
 
@@ -238,6 +238,7 @@ class ManagedResource:
238
238
  self._lifecycle_state = LifecycleState.UNINITIALIZED
239
239
  self._handle = None
240
240
  _clear_error_state()
241
+ record_owner_pid(self)
241
242
 
242
243
  @staticmethod
243
244
  def _free_native_ptr(ptr):
@@ -280,6 +281,8 @@ class ManagedResource:
280
281
  def _cleanup_resources(self):
281
282
  """Release native resources idempotently."""
282
283
  try:
284
+ if is_foreign_process(self):
285
+ return
283
286
  if (
284
287
  hasattr(self, '_lifecycle_state')
285
288
  and self._lifecycle_state != LifecycleState.CLOSED
@@ -1768,6 +1771,7 @@ class Stream:
1768
1771
  raise Exception("Failed to create stream: {}".format(error))
1769
1772
 
1770
1773
  self._initialized = True
1774
+ record_owner_pid(self)
1771
1775
 
1772
1776
  def __enter__(self):
1773
1777
  """Context manager entry."""
@@ -1786,6 +1790,8 @@ class Stream:
1786
1790
  hasn't been explicitly closed.
1787
1791
  """
1788
1792
  try:
1793
+ if is_foreign_process(self):
1794
+ return
1789
1795
  # Only cleanup if not already closed and we have a valid stream
1790
1796
  if hasattr(self, '_closed') and not self._closed:
1791
1797
  stream = self._stream
@@ -1816,6 +1822,10 @@ class Stream:
1816
1822
  """
1817
1823
  if self._closed:
1818
1824
  return
1825
+ if is_foreign_process(self):
1826
+ self._closed = True
1827
+ self._initialized = False
1828
+ return
1819
1829
 
1820
1830
  try:
1821
1831
  # Clean up stream first as it depends on callbacks
@@ -299,3 +299,28 @@ def dynamically_load_library(
299
299
  raise RuntimeError(f"Could not find {c2pa_lib_name} in any of the search paths")
300
300
 
301
301
  return c2pa_lib
302
+
303
+
304
+ def record_owner_pid(obj):
305
+ """Keep the PID that created this native-handle wrapper
306
+ (call from __init__ as needed).
307
+ """
308
+ obj._owner_pid = os.getpid()
309
+
310
+
311
+ def is_foreign_process(obj):
312
+ """Return True when this object is being finalized in a forked child that did not
313
+ create it. After a multithreaded fork(), native mutexes may be held by threads
314
+ absent in the child -> any lock() call deadlocks. Callers must skip native frees
315
+ when this returns True.
316
+
317
+ Skipping the free does not cause a cumulative leak. If the child calls exec() the
318
+ address space is replaced entirely; if it exits, the OS reclaims all process memory.
319
+ Even a long-lived fork child (e.g. a multiprocessing fork-start worker) leaks at most
320
+ the objects inherited at fork time — a one-time, bounded amount reclaimed when the
321
+ child terminates. Objects the child creates itself carry the child's PID and are
322
+ freed normally.
323
+
324
+ Defensive default: if _owner_pid was never set, returns False (no regression)."""
325
+ owner = getattr(obj, '_owner_pid', None)
326
+ return owner is not None and owner != os.getpid()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: c2pa-python
3
- Version: 0.34.0
3
+ Version: 0.35.0
4
4
  Summary: Python bindings for the C2PA Content Authenticity Initiative (CAI) library
5
5
  Author-email: Gavin Peacock <gvnpeacock@adobe.com>, Tania Mathern <mathern@adobe.com>
6
6
  Maintainer-email: Gavin Peacock <gpeacock@adobe.com>
@@ -11,6 +11,7 @@
11
11
  # specific language governing permissions and limitations under
12
12
  # each license.
13
13
 
14
+ import ctypes
14
15
  import os
15
16
  import io
16
17
  import json
@@ -20,13 +21,172 @@ import concurrent.futures
20
21
  import time
21
22
  import asyncio
22
23
  import random
24
+ from unittest.mock import MagicMock, patch
23
25
 
24
26
  from c2pa import Builder, C2paError as Error, Reader, C2paSigningAlg as SigningAlg, C2paSignerInfo, Signer, sdk_version # noqa: E501
25
27
  from c2pa import Context, Settings
26
- from c2pa.c2pa import Stream
28
+ from c2pa.c2pa import ManagedResource, Stream, LifecycleState
29
+ from c2pa.lib import is_foreign_process, record_owner_pid
27
30
 
28
31
  PROJECT_PATH = os.getcwd()
29
32
  FIXTURES_FOLDER = os.path.join(os.path.dirname(__file__), "fixtures")
33
+
34
+
35
+ class _ConcreteResource(ManagedResource):
36
+ """Minimal concrete subclass for testing ManagedResource cleanup."""
37
+
38
+
39
+ def _make_resource(pid_offset):
40
+ """Construct a ManagedResource-like object without triggering native init.
41
+
42
+ pid_offset=1 → simulates a forked child (foreign PID)
43
+ pid_offset=0 → same process (normal cleanup)
44
+ pid_offset=None → no _owner_pid stamp (backward-compat: no protection)
45
+ """
46
+ obj = object.__new__(_ConcreteResource)
47
+ obj._lifecycle_state = LifecycleState.ACTIVE
48
+ obj._handle = ctypes.c_void_p(1) # non-None, non-zero sentinel
49
+ if pid_offset is not None:
50
+ obj._owner_pid = os.getpid() + pid_offset
51
+ return obj
52
+
53
+
54
+ def _make_stream(pid_offset):
55
+ """Construct a Stream-like object without triggering native init."""
56
+ obj = object.__new__(Stream)
57
+ obj._closed = False
58
+ obj._initialized = True
59
+ obj._stream = MagicMock() # non-None stream handle
60
+ if pid_offset is not None:
61
+ obj._owner_pid = os.getpid() + pid_offset
62
+ return obj
63
+
64
+
65
+ class TestManagedResourceForkGuard(unittest.TestCase):
66
+ """Fork-safety unit tests for ManagedResource and Stream.
67
+
68
+ Verifies that the is_foreign_process() PID guard prevents native frees
69
+ from running in a forked child process (where native mutexes may be held
70
+ by threads that no longer exist, causing deadlock before exec()).
71
+
72
+ No real fork or auth credentials are required; PID mismatch is simulated
73
+ by setting _owner_pid = os.getpid() + 1.
74
+ """
75
+
76
+ def test_foreign_pid_skips_free(self):
77
+ """In a forked child (pid_offset=1), no native free should run."""
78
+ obj = _make_resource(pid_offset=1)
79
+ with patch('c2pa.c2pa._lib') as mock_lib:
80
+ obj._cleanup_resources()
81
+ mock_lib.c2pa_free.assert_not_called()
82
+
83
+ def test_own_pid_calls_free(self):
84
+ """In the owning process, cleanup must call c2pa_free normally."""
85
+ obj = _make_resource(pid_offset=0)
86
+ expected_handle = obj._handle
87
+ with patch('c2pa.c2pa._lib'):
88
+ with patch.object(ManagedResource, '_free_native_ptr') as mock_free:
89
+ obj._cleanup_resources()
90
+ mock_free.assert_called_once_with(expected_handle)
91
+
92
+ def test_no_stamp_calls_free(self):
93
+ """No _owner_pid (backward-compat) must NOT suppress cleanup."""
94
+ obj = _make_resource(pid_offset=None)
95
+ with patch.object(ManagedResource, '_free_native_ptr') as mock_free:
96
+ obj._cleanup_resources()
97
+ mock_free.assert_called_once()
98
+
99
+ def test_foreign_pid_leaves_state_unchanged(self):
100
+ """Guard returns early; lifecycle state stays ACTIVE (not CLOSED)."""
101
+ obj = _make_resource(pid_offset=1)
102
+ with patch('c2pa.c2pa._lib'):
103
+ obj._cleanup_resources()
104
+ self.assertEqual(obj._lifecycle_state, LifecycleState.ACTIVE)
105
+
106
+ def test_double_cleanup_is_idempotent(self):
107
+ """Second call is a no-op after successful first cleanup."""
108
+ obj = _make_resource(pid_offset=0)
109
+ with patch.object(ManagedResource, '_free_native_ptr') as mock_free:
110
+ obj._cleanup_resources()
111
+ obj._cleanup_resources()
112
+ mock_free.assert_called_once()
113
+
114
+ def test_foreign_pid_skips_release_via_del(self):
115
+ obj = _make_stream(pid_offset=1)
116
+ with patch('c2pa.c2pa._lib') as mock_lib:
117
+ obj.__del__()
118
+ mock_lib.c2pa_release_stream.assert_not_called()
119
+
120
+ def test_own_pid_releases_stream_via_del(self):
121
+ obj = _make_stream(pid_offset=0)
122
+ with patch('c2pa.c2pa._lib') as mock_lib:
123
+ obj.__del__()
124
+ mock_lib.c2pa_release_stream.assert_called_once()
125
+
126
+ def test_no_stamp_releases_stream_via_del(self):
127
+ obj = _make_stream(pid_offset=None)
128
+ with patch('c2pa.c2pa._lib') as mock_lib:
129
+ obj.__del__()
130
+ mock_lib.c2pa_release_stream.assert_called_once()
131
+
132
+ def test_already_closed_is_noop_via_del(self):
133
+ obj = _make_stream(pid_offset=0)
134
+ obj._closed = True
135
+ with patch('c2pa.c2pa._lib') as mock_lib:
136
+ obj.__del__()
137
+ mock_lib.c2pa_release_stream.assert_not_called()
138
+
139
+ def test_foreign_pid_skips_release_via_close(self):
140
+ obj = _make_stream(pid_offset=1)
141
+ with patch('c2pa.c2pa._lib') as mock_lib:
142
+ obj.close()
143
+ mock_lib.c2pa_release_stream.assert_not_called()
144
+ self.assertTrue(obj._closed)
145
+
146
+ def test_own_pid_releases_stream_via_close(self):
147
+ obj = _make_stream(pid_offset=0)
148
+ with patch('c2pa.c2pa._lib') as mock_lib:
149
+ obj.close()
150
+ mock_lib.c2pa_release_stream.assert_called_once()
151
+
152
+ def test_no_stamp_releases_stream_via_close(self):
153
+ obj = _make_stream(pid_offset=None)
154
+ with patch('c2pa.c2pa._lib') as mock_lib:
155
+ obj.close()
156
+ mock_lib.c2pa_release_stream.assert_called_once()
157
+
158
+ def test_already_closed_is_noop_via_close(self):
159
+ obj = _make_stream(pid_offset=0)
160
+ obj._closed = True
161
+ with patch('c2pa.c2pa._lib') as mock_lib:
162
+ obj.close()
163
+ mock_lib.c2pa_release_stream.assert_not_called()
164
+
165
+ def test_foreign_pid_close_marks_closed(self):
166
+ """close() in forked child must set _closed=True to prevent re-entry,
167
+ and _initialized=False so the public properties report a closed stream."""
168
+ obj = _make_stream(pid_offset=1)
169
+ with patch('c2pa.c2pa._lib'):
170
+ obj.close()
171
+ self.assertTrue(obj._closed)
172
+ self.assertFalse(obj._initialized)
173
+
174
+
175
+ class TestHelpers(unittest.TestCase):
176
+
177
+ def test_record_and_detect_own_pid(self):
178
+ obj = MagicMock()
179
+ record_owner_pid(obj)
180
+ self.assertFalse(is_foreign_process(obj))
181
+
182
+ def test_detect_foreign_pid(self):
183
+ obj = MagicMock()
184
+ obj._owner_pid = os.getpid() + 1
185
+ self.assertTrue(is_foreign_process(obj))
186
+
187
+ def test_no_stamp_not_foreign(self):
188
+ obj = MagicMock(spec=[]) # no _owner_pid attribute
189
+ self.assertFalse(is_foreign_process(obj))
30
190
  DEFAULT_TEST_FILE = os.path.join(FIXTURES_FOLDER, "C.jpg")
31
191
  INGREDIENT_TEST_FILE = os.path.join(FIXTURES_FOLDER, "A.jpg")
32
192
  ALTERNATIVE_INGREDIENT_TEST_FILE = os.path.join(FIXTURES_FOLDER, "cloud.jpg")
File without changes
File without changes
File without changes
File without changes
File without changes