pydt4 0.1.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.
pydt4-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,5 @@
1
+ Metadata-Version: 2.4
2
+ Name: pydt4
3
+ Version: 0.1.0
4
+ Summary: Direct DEVONthink Apple Events via ctypes
5
+ Requires-Python: >=3.10
pydt4-0.1.0/README.md ADDED
@@ -0,0 +1,18 @@
1
+ # pydt4
2
+
3
+ A macOS Python interface to DEVONthink using Foundation's native Apple Event descriptors through `ctypes`. No PyObjC, JXA, or external runtime dependencies are required.
4
+
5
+ ```python
6
+ from pydt4 import DEVONthink, Record
7
+ app = DEVONthink() # or bundle_id="com.devon-technologies.think3"
8
+ db = app.databases[0]
9
+ print(db.records.batch_get("name"))
10
+ record: Record = db.records[0]
11
+ print(record.name)
12
+ unread = db.records.whose((Record.unread == True) & Record.name.contains("project"))
13
+ print(unread.pluck("name", "uuid"))
14
+ ```
15
+
16
+ Object and collection references are lazy. `batch_get` fetches a property for all collection elements in one Apple Event. `pluck` makes one request per property. macOS may ask for Automation permission when sending to DEVONthink. For deterministic tests, pass `sender=` to `DEVONthink` or `AEBridge`; it receives the actual event descriptor and returns a reply descriptor.
17
+
18
+ Run deterministic tests with `python3 -m unittest discover -s tests -v`. Run `scripts/test-live.sh` to build an on-demand, entitled helper and execute the read-only DEVONthink integration tests without copying commands into another terminal. macOS may request Automation permission for `Pydt4LiveTests`; after testing is finished, `scripts/test-live.sh --cleanup` revokes that permission and removes the helper.
@@ -0,0 +1,8 @@
1
+ """Direct, dependency-free DEVONthink Apple Event interface."""
2
+ from .aevent import (
3
+ AEBridge, AEDescriptor, AERecord, examined_spec, examined_prop_spec,
4
+ cmp_spec, logic_spec, whose_spec, AppleEventError, AppleEventPermissionError,
5
+ AppleEventTimeoutError, AppleEventObjectNotFoundError, EnumValue,
6
+ )
7
+ from .sdef import load_script_suite, resolve_enum
8
+ from .models import AEObject, AEReadOnly, AEProp, AEElements, AEFilter, FilteredElements, ViewerWindow, DEVONthink, DEVONthink3, Database, Record, SmartGroup, TagGroup, Reminder, ThinkWindow, DocumentWindow, ThinkTab, TextStorage, ElementArray
@@ -0,0 +1,207 @@
1
+ """Direct NSAppleEventDescriptor construction and Apple Event transport."""
2
+ import ctypes as C
3
+ import struct
4
+ import sys
5
+ from datetime import datetime
6
+ from pathlib import Path
7
+ from dataclasses import dataclass
8
+ from .objc import (cls, send, nsstr, pystr, nsdata_to_bytes, osstatus_info,
9
+ datetime_to_nsdate, nsdate_to_datetime, path_to_nsurl,
10
+ nsurl_to_path, autorelease_pool)
11
+
12
+ D = cls('NSAppleEventDescriptor')
13
+ def fourcc(code):
14
+ b = code.encode('mac_roman') if isinstance(code, str) else bytes(code)
15
+ if len(b) != 4: raise ValueError('FourCC must be exactly four bytes')
16
+ return int.from_bytes(b, 'big')
17
+ def uncode(value): return value.to_bytes(4, 'big').decode('mac_roman')
18
+
19
+ class AEDescriptor:
20
+ def __init__(self, ptr):
21
+ if not ptr: raise ValueError('nil NSAppleEventDescriptor')
22
+ self.ptr = send(ptr, 'retain')
23
+ def __del__(self):
24
+ if getattr(self, 'ptr', None): send(self.ptr, 'release', restype=None)
25
+ @property
26
+ def type(self): return send(self.ptr, 'descriptorType', restype=C.c_uint32)
27
+ @property
28
+ def data(self): return nsdata_to_bytes(send(self.ptr, 'data'))
29
+ def coerce(self, code):
30
+ p = send(self.ptr, 'coerceToDescriptorType:', fourcc(code), argtypes=(C.c_uint32,))
31
+ return AEDescriptor(p) if p else None
32
+ def keyword(self, code):
33
+ p = send(self.ptr, 'descriptorForKeyword:', fourcc(code), argtypes=(C.c_uint32,))
34
+ return AEDescriptor(p) if p else None
35
+ def param(self, code):
36
+ p = send(self.ptr, 'paramDescriptorForKeyword:', fourcc(code), argtypes=(C.c_uint32,))
37
+ return AEDescriptor(p) if p else None
38
+ def attribute(self, code):
39
+ p = send(self.ptr, 'attributeDescriptorForKeyword:', fourcc(code), argtypes=(C.c_uint32,))
40
+ return AEDescriptor(p) if p else None
41
+ def item(self, i):
42
+ p = send(self.ptr, 'descriptorAtIndex:', i, argtypes=(C.c_long,))
43
+ return AEDescriptor(p) if p else None
44
+ @property
45
+ def count(self): return send(self.ptr, 'numberOfItems', restype=C.c_long)
46
+ def put(self, code, value):
47
+ send(self.ptr, 'setDescriptor:forKeyword:', value.ptr, fourcc(code), restype=None, argtypes=(C.c_void_p,C.c_uint32))
48
+ def put_param(self, code, value):
49
+ send(self.ptr, 'setParamDescriptor:forKeyword:', value.ptr, fourcc(code), restype=None, argtypes=(C.c_void_p,C.c_uint32))
50
+ def append(self, value):
51
+ send(self.ptr, 'insertDescriptor:atIndex:', value.ptr, 0, restype=None, argtypes=(C.c_void_p,C.c_long))
52
+ @classmethod
53
+ def raw(cls_, typ, data):
54
+ return cls_(send(D,'descriptorWithDescriptorType:bytes:length:',fourcc(typ),data,len(data),argtypes=(C.c_uint32,C.c_char_p,C.c_ulong)))
55
+ @classmethod
56
+ def ordinal(cls_, code): return cls_.raw('abso', fourcc(code).to_bytes(4,sys.byteorder))
57
+
58
+ def factory(selector, *args, argtypes=()): return AEDescriptor(send(D,selector,*args,argtypes=argtypes))
59
+ def typecode(code): return factory('descriptorWithTypeCode:',fourcc(code),argtypes=(C.c_uint32,))
60
+ def enumcode(code): return factory('descriptorWithEnumCode:',fourcc(code),argtypes=(C.c_uint32,))
61
+ @dataclass(frozen=True)
62
+ class EnumValue:
63
+ code: str
64
+
65
+ class AERecord(dict):
66
+ """An Apple Event record with explicit FourCC keywords."""
67
+
68
+ def pack(value):
69
+ if isinstance(value, AEDescriptor): return value
70
+ if hasattr(value, '_spec'): return value._spec
71
+ if value is None: return factory('nullDescriptor')
72
+ if isinstance(value, EnumValue): return enumcode(value.code)
73
+ if isinstance(value, bool): return factory('descriptorWithBoolean:',value,argtypes=(C.c_bool,))
74
+ if isinstance(value, int):
75
+ if -(2**31) <= value < 2**31: return factory('descriptorWithInt32:',value,argtypes=(C.c_int32,))
76
+ return AEDescriptor.raw('comp',struct.pack('=q',value))
77
+ if isinstance(value,float): return AEDescriptor.raw('doub',struct.pack('=d',value))
78
+ if isinstance(value,str): return factory('descriptorWithString:',nsstr(value),argtypes=(C.c_void_p,))
79
+ if isinstance(value,bytes): return AEDescriptor.raw('tdta',value)
80
+ if isinstance(value,datetime):
81
+ return factory('descriptorWithDate:',datetime_to_nsdate(value),argtypes=(C.c_void_p,))
82
+ if isinstance(value,Path):
83
+ return factory('descriptorWithFileURL:',path_to_nsurl(value),argtypes=(C.c_void_p,))
84
+ if isinstance(value,(list,tuple)):
85
+ d=factory('listDescriptor')
86
+ for v in value: d.append(pack(v))
87
+ return d
88
+ if isinstance(value,AERecord):
89
+ d=factory('recordDescriptor')
90
+ for k,v in value.items(): d.put(k,pack(v))
91
+ return d
92
+ if isinstance(value,dict):
93
+ d=factory('recordDescriptor'); user=[]
94
+ for k,v in value.items(): user.extend((k,v))
95
+ if user: d.put('usrf',pack(user))
96
+ return d
97
+ raise TypeError(f'Cannot pack {type(value).__name__}')
98
+
99
+ def spec(want, form, selection, container=None):
100
+ r=factory('recordDescriptor')
101
+ for k,v in [('want',typecode(want)),('from',pack(container)),('form',enumcode(form)),('seld',selection)]: r.put(k,v)
102
+ result=r.coerce('obj ')
103
+ if not result: raise ValueError('Could not coerce object specifier')
104
+ return result
105
+ def prop_spec(code, container=None): return spec('prop','prop',typecode(code),container)
106
+ def index_spec(want,index,container=None): return spec(want,'indx',pack(index),container)
107
+ def all_spec(want,container=None): return spec(want,'indx',AEDescriptor.ordinal('all '),container)
108
+ def id_spec(want,unique_id,container=None): return spec(want,'ID ',pack(unique_id),container)
109
+ def name_spec(want,name,container=None): return spec(want,'name',pack(name),container)
110
+
111
+ def examined_spec(): return AEDescriptor.raw('exmn',b'')
112
+ def examined_prop_spec(code): return prop_spec(code,examined_spec())
113
+ def cmp_spec(op_code,obj1,obj2):
114
+ r=factory('recordDescriptor')
115
+ for k,v in [('relo',enumcode(op_code)),('obj1',pack(obj1)),('obj2',pack(obj2))]: r.put(k,v)
116
+ return r.coerce('cmpd')
117
+ def logic_spec(op_code,terms):
118
+ r=factory('recordDescriptor')
119
+ for k,v in [('logc',enumcode(op_code)),('term',pack(list(terms)))]: r.put(k,v)
120
+ return r.coerce('logi')
121
+ def whose_spec(want,test_desc,container=None): return spec(want,'test',test_desc,container)
122
+
123
+ def unpack(d, bridge=None):
124
+ if d is None: return None
125
+ t=uncode(d.type); b=d.data
126
+ if t in ('null','msng'): return None
127
+ if t in ('bool','true','fals'): return t=='true' or (t=='bool' and bool(b[0]))
128
+ if t in ('long','shor','comp','magn'): return int.from_bytes(b,sys.byteorder,signed=t!='magn')
129
+ if t in ('doub','sing'): return struct.unpack('=d' if t=='doub' else '=f',b)[0]
130
+ if t in ('utxt','TEXT','utf8','ctxt'): return pystr(send(d.ptr,'stringValue'))
131
+ if t in ('tdta','rdat','pgPD'): return b
132
+ if t=='ldt ': return nsdate_to_datetime(send(d.ptr,'dateValue'))
133
+ if t=='furl': return nsurl_to_path(send(d.ptr,'fileURLValue'))
134
+ if t in ('enum','type'): return EnumValue(uncode(int.from_bytes(b,sys.byteorder)))
135
+ if t=='list': return [unpack(d.item(i),bridge) for i in range(1,d.count+1)]
136
+ if t=='reco':
137
+ result={}
138
+ for i in range(1,d.count+1):
139
+ key=uncode(send(d.ptr,'keywordForDescriptorAtIndex:',i,restype=C.c_uint32,argtypes=(C.c_long,)))
140
+ val=unpack(d.item(i),bridge)
141
+ if key=='usrf' and isinstance(val,list): result.update(zip(val[::2],val[1::2]))
142
+ else: result[key]=val
143
+ return result
144
+ if t=='obj ':
145
+ from .models import object_for_spec
146
+ return object_for_spec(bridge,d)
147
+ return d
148
+
149
+ class AppleEventError(Exception):
150
+ def __init__(self, code, message='', event_class=None, event_id=None):
151
+ if message is not None: message = str(message)
152
+ symbol, comment = (None, None) if code == -1 and message else osstatus_info(code)
153
+ if not message:
154
+ message = ': '.join(p for p in (symbol, comment) if p) or 'Apple Event failed'
155
+ elif symbol and symbol not in message:
156
+ message = f'{symbol}: {message}'
157
+ self.code, self.symbol, self.message = code, symbol, message
158
+ self.event_class, self.event_id = event_class, event_id
159
+ loc = f' {event_class}/{event_id}' if event_class and event_id else ''
160
+ super().__init__(f'Apple Event{loc} ({code}): {message}')
161
+
162
+ class AppleEventPermissionError(AppleEventError, PermissionError): pass
163
+ class AppleEventTimeoutError(AppleEventError, TimeoutError): pass
164
+ class AppleEventObjectNotFoundError(AppleEventError, LookupError): pass
165
+
166
+ _ERROR_CLASSES = {
167
+ -1743: AppleEventPermissionError,
168
+ -1744: AppleEventPermissionError,
169
+ -1712: AppleEventTimeoutError,
170
+ -1728: AppleEventObjectNotFoundError,
171
+ -1719: AppleEventObjectNotFoundError,
172
+ }
173
+
174
+ def make_apple_event_error(code, message='', event_class=None, event_id=None):
175
+ err_cls = _ERROR_CLASSES.get(code, AppleEventError)
176
+ return err_cls(code, message, event_class, event_id)
177
+
178
+ class AEBridge:
179
+ def __init__(self,bundle_id=None,*,sender=None,timeout=60.0):
180
+ if bundle_id is None:
181
+ import os
182
+ bundle_id='com.devon-technologies.think' if os.path.exists('/Applications/DEVONthink.app') else 'com.devon-technologies.think3'
183
+ self.bundle_id,self.sender,self.timeout=bundle_id,sender,timeout
184
+ def event(self,event_class,event_id,direct=None,**kwargs):
185
+ with autorelease_pool():
186
+ target=factory('descriptorWithBundleIdentifier:',nsstr(self.bundle_id),argtypes=(C.c_void_p,))
187
+ event=factory('appleEventWithEventClass:eventID:targetDescriptor:returnID:transactionID:',fourcc(event_class),fourcc(event_id),target.ptr,-1,0,argtypes=(C.c_uint32,C.c_uint32,C.c_void_p,C.c_int16,C.c_int32))
188
+ if direct is not None: event.put_param('----',pack(direct))
189
+ for k,v in kwargs.items():
190
+ if v is not None: event.put_param(k,pack(v))
191
+ reply=self.send_event(event)
192
+ err=reply.param('errn')
193
+ if err and unpack(err):
194
+ msg=reply.param('errs'); raise make_apple_event_error(unpack(err),unpack(msg) if msg else '',event_class,event_id)
195
+ return unpack(reply.param('----'),self)
196
+ def send_event(self,event):
197
+ if self.sender is not None: return self.sender(event,self.timeout)
198
+ error=C.c_void_p()
199
+ p=send(event.ptr,'sendEventWithOptions:timeout:error:',0x23,self.timeout,C.byref(error),argtypes=(C.c_ulong,C.c_double,C.c_void_p))
200
+ if not p:
201
+ code=send(error.value,'code',restype=C.c_long) if error.value else -1
202
+ message=pystr(send(error.value,'localizedDescription')) if error.value else 'Apple Event send failed without NSError'
203
+ raise make_apple_event_error(code,message)
204
+ return AEDescriptor(p)
205
+ def get(self,spec): return self.event('core','getd',spec)
206
+ def set(self,spec,value): return self.event('core','setd',spec, data=value)
207
+ def count(self,container,want): return self.event('core','cnte',container, kocl=typecode(want))