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.
- {c2pa_python-0.34.0/src/c2pa_python.egg-info → c2pa_python-0.35.0}/PKG-INFO +1 -1
- {c2pa_python-0.34.0 → c2pa_python-0.35.0}/pyproject.toml +1 -1
- {c2pa_python-0.34.0 → c2pa_python-0.35.0}/src/c2pa/c2pa.py +11 -1
- {c2pa_python-0.34.0 → c2pa_python-0.35.0}/src/c2pa/lib.py +25 -0
- {c2pa_python-0.34.0 → c2pa_python-0.35.0/src/c2pa_python.egg-info}/PKG-INFO +1 -1
- {c2pa_python-0.34.0 → c2pa_python-0.35.0}/tests/test_unit_tests_threaded.py +161 -1
- {c2pa_python-0.34.0 → c2pa_python-0.35.0}/LICENSE-APACHE +0 -0
- {c2pa_python-0.34.0 → c2pa_python-0.35.0}/LICENSE-MIT +0 -0
- {c2pa_python-0.34.0 → c2pa_python-0.35.0}/MANIFEST.in +0 -0
- {c2pa_python-0.34.0 → c2pa_python-0.35.0}/README.md +0 -0
- {c2pa_python-0.34.0 → c2pa_python-0.35.0}/requirements.txt +0 -0
- {c2pa_python-0.34.0 → c2pa_python-0.35.0}/scripts/download_artifacts.py +0 -0
- {c2pa_python-0.34.0 → c2pa_python-0.35.0}/setup.cfg +0 -0
- {c2pa_python-0.34.0 → c2pa_python-0.35.0}/setup.py +0 -0
- {c2pa_python-0.34.0 → c2pa_python-0.35.0}/src/c2pa/__init__.py +0 -0
- {c2pa_python-0.34.0 → c2pa_python-0.35.0}/src/c2pa/build.py +0 -0
- {c2pa_python-0.34.0 → c2pa_python-0.35.0}/src/c2pa_python.egg-info/SOURCES.txt +0 -0
- {c2pa_python-0.34.0 → c2pa_python-0.35.0}/src/c2pa_python.egg-info/dependency_links.txt +0 -0
- {c2pa_python-0.34.0 → c2pa_python-0.35.0}/src/c2pa_python.egg-info/entry_points.txt +0 -0
- {c2pa_python-0.34.0 → c2pa_python-0.35.0}/src/c2pa_python.egg-info/requires.txt +0 -0
- {c2pa_python-0.34.0 → c2pa_python-0.35.0}/src/c2pa_python.egg-info/top_level.txt +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|