Mercurial > p > roundup > code
comparison roundup/hyperdb.py @ 8416:370689471a08 issue2550923_computed_property
merge from default branch accumulated changes since Nov 2023
| author | John Rouillard <rouilj@ieee.org> |
|---|---|
| date | Sun, 17 Aug 2025 16:12:25 -0400 |
| parents | 14a8e11f3a87 a81a3cd067fa |
| children |
comparison
equal
deleted
inserted
replaced
| 7693:78585199552a | 8416:370689471a08 |
|---|---|
| 19 """Hyperdatabase implementation, especially field types. | 19 """Hyperdatabase implementation, especially field types. |
| 20 """ | 20 """ |
| 21 __docformat__ = 'restructuredtext' | 21 __docformat__ = 'restructuredtext' |
| 22 | 22 |
| 23 # standard python modules | 23 # standard python modules |
| 24 import copy | |
| 24 import logging | 25 import logging |
| 25 import os | 26 import os |
| 26 import re | 27 import re |
| 27 import shutil | 28 import shutil |
| 28 import sys | 29 import sys |
| 29 import traceback | 30 import traceback |
| 30 import weakref | 31 import weakref |
| 31 | 32 |
| 33 from hashlib import md5 | |
| 34 | |
| 32 # roundup modules | 35 # roundup modules |
| 33 from . import date, password | 36 from . import date, password |
| 34 from .support import ensureParentsExist, PrioList | 37 from .support import ensureParentsExist, PrioList |
| 35 from roundup.mlink_expr import Expression | 38 from roundup.mlink_expr import Expression |
| 36 from roundup.i18n import _ | 39 from roundup.i18n import _ |
| 37 from roundup.cgi.exceptions import DetectorError | 40 from roundup.cgi.exceptions import DetectorError |
| 38 from roundup.anypy.cmp_ import NoneAndDictComparable | 41 from roundup.anypy.cmp_ import NoneAndDictComparable |
| 39 from roundup.anypy.strings import eval_import | 42 from roundup.anypy.strings import b2s, bs2b, eval_import |
| 40 | 43 |
| 41 logger = logging.getLogger('roundup.hyperdb') | 44 logger = logging.getLogger('roundup.hyperdb') |
| 45 | |
| 46 # marker used for an unspecified keyword argument | |
| 47 _marker = [] | |
| 42 | 48 |
| 43 | 49 |
| 44 # | 50 # |
| 45 # Types | 51 # Types |
| 46 # | 52 # |
| 191 return val | 197 return val |
| 192 return str(val) | 198 return str(val) |
| 193 | 199 |
| 194 | 200 |
| 195 class Date(_Type): | 201 class Date(_Type): |
| 196 """An object designating a Date property.""" | 202 """An object designating a Date property. |
| 203 The display_time parameter specifies if we want date and time or | |
| 204 date only. Both display_time and format are defaults for the | |
| 205 field method of the DateHTMLProperty (for rendering html). | |
| 206 """ | |
| 197 def __init__(self, offset=None, required=False, default_value=None, | 207 def __init__(self, offset=None, required=False, default_value=None, |
| 198 quiet=False): | 208 quiet=False, display_time='yes', format=None): |
| 199 super(Date, self).__init__(required=required, | 209 super(Date, self).__init__(required=required, |
| 200 default_value=default_value, | 210 default_value=default_value, |
| 201 quiet=quiet) | 211 quiet=quiet) |
| 202 self._offset = offset | 212 self._offset = offset |
| 213 self.display_time = display_time == 'yes' | |
| 214 self.format = format | |
| 203 | 215 |
| 204 def offset(self, db): | 216 def offset(self, db): |
| 205 if self._offset is not None: | 217 if self._offset is not None: |
| 206 return self._offset | 218 return self._offset |
| 207 return db.getUserTimezone() | 219 return db.getUserTimezone() |
| 1116 def unserialise(self, classname, node): | 1128 def unserialise(self, classname, node): |
| 1117 """Decode the marshalled node data | 1129 """Decode the marshalled node data |
| 1118 """ | 1130 """ |
| 1119 return node | 1131 return node |
| 1120 | 1132 |
| 1121 def getnode(self, classname, nodeid): | 1133 def getnode(self, classname, nodeid, allow_abort=True): |
| 1122 """Get a node from the database. | 1134 """Get a node from the database. |
| 1123 | 1135 |
| 1124 'cache' exists for backwards compatibility, and is not used. | 1136 'cache' exists for backwards compatibility, and is not used. |
| 1137 'allow_abort' determines if we allow that the current | |
| 1138 transaction is aborted due to a data error (e.g. invalid nodeid). | |
| 1125 """ | 1139 """ |
| 1126 raise NotImplementedError | 1140 raise NotImplementedError |
| 1127 | 1141 |
| 1128 def hasnode(self, classname, nodeid): | 1142 def hasnode(self, classname, nodeid): |
| 1129 """Determine if the database has a given node. | 1143 """Determine if the database has a given node. |
| 1286 If an id in a link or multilink property does not refer to a valid | 1300 If an id in a link or multilink property does not refer to a valid |
| 1287 node, an IndexError is raised. | 1301 node, an IndexError is raised. |
| 1288 """ | 1302 """ |
| 1289 raise NotImplementedError | 1303 raise NotImplementedError |
| 1290 | 1304 |
| 1291 _marker = [] | 1305 def get(self, nodeid, propname, default=_marker, cache=1, allow_abort=True): |
| 1292 | |
| 1293 def get(self, nodeid, propname, default=_marker, cache=1): | |
| 1294 """Get the value of a property on an existing node of this class. | 1306 """Get the value of a property on an existing node of this class. |
| 1295 | 1307 |
| 1296 'nodeid' must be the id of an existing node of this class or an | 1308 'nodeid' must be the id of an existing node of this class or an |
| 1297 IndexError is raised. 'propname' must be the name of a property | 1309 IndexError is raised. 'propname' must be the name of a property |
| 1298 of this class or a KeyError is raised. | 1310 of this class or a KeyError is raised. |
| 1299 | 1311 |
| 1300 'cache' exists for backwards compatibility, and is not used. | 1312 'cache' exists for backwards compatibility, and is not used. |
| 1313 'allow_abort' determines if we allow that the current | |
| 1314 transaction is aborted due to a data error (e.g. invalid nodeid). | |
| 1301 """ | 1315 """ |
| 1302 raise NotImplementedError | 1316 raise NotImplementedError |
| 1303 | 1317 |
| 1304 # not in spec | 1318 # not in spec |
| 1305 def getnode(self, nodeid): | 1319 def getnode(self, nodeid): |
| 1353 | 1367 |
| 1354 Make node available for all operations like it was before retirement. | 1368 Make node available for all operations like it was before retirement. |
| 1355 """ | 1369 """ |
| 1356 raise NotImplementedError | 1370 raise NotImplementedError |
| 1357 | 1371 |
| 1358 def is_retired(self, nodeid): | 1372 def is_retired(self, nodeid, allow_abort=True): |
| 1359 """Return true if the node is rerired | 1373 """Return true if the node is rerired |
| 1374 'allow_abort' specifies if we allow the transaction to be | |
| 1375 aborted if a syntactically invalid nodeid is passed. | |
| 1360 """ | 1376 """ |
| 1361 raise NotImplementedError | 1377 raise NotImplementedError |
| 1362 | 1378 |
| 1363 def destroy(self, nodeid): | 1379 def destroy(self, nodeid): |
| 1364 """Destroy a node. | 1380 """Destroy a node. |
| 1859 # cache for each id returned. Note that the filter_iter doesn't | 1875 # cache for each id returned. Note that the filter_iter doesn't |
| 1860 # promise to correctly sort by multilink (which isn't sane to do | 1876 # promise to correctly sort by multilink (which isn't sane to do |
| 1861 # anyway). | 1877 # anyway). |
| 1862 filter_iter = filter | 1878 filter_iter = filter |
| 1863 | 1879 |
| 1880 def filter_with_permissions(self, search_matches, filterspec, sort=[], | |
| 1881 group=[], retired=False, exact_match_spec={}, | |
| 1882 limit=None, offset=None, | |
| 1883 permission='View', userid=None): | |
| 1884 """ Do the same as filter but return only the items the user is | |
| 1885 entitled to see, running the results through security checks. | |
| 1886 The userid defaults to the current database user. | |
| 1887 """ | |
| 1888 if userid is None: | |
| 1889 userid = self.db.getuid() | |
| 1890 cn = self.classname | |
| 1891 sec = self.db.security | |
| 1892 filterspec = sec.filterFilterspec(userid, cn, filterspec) | |
| 1893 if exact_match_spec: | |
| 1894 exact_match_spec = sec.filterFilterspec(userid, cn, | |
| 1895 exact_match_spec) | |
| 1896 sort = sec.filterSortspec(userid, cn, sort) | |
| 1897 group = sec.filterSortspec(userid, cn, group) | |
| 1898 item_ids = self.filter(search_matches, filterspec, sort, group, | |
| 1899 retired, exact_match_spec, limit, offset) | |
| 1900 check = sec.hasPermission | |
| 1901 if check(permission, userid, cn, skip_permissions_with_check = True): | |
| 1902 allowed = item_ids | |
| 1903 else: | |
| 1904 debug = self.db.config.RDBMS_DEBUG_FILTER | |
| 1905 # Note that is_filterable returns True if no permissions are | |
| 1906 # found. This makes it fail early (with an empty allowed list) | |
| 1907 # instead of running through all ids with an empty | |
| 1908 # permission list. | |
| 1909 if not debug and sec.is_filterable(permission, userid, cn): | |
| 1910 new_ids = set(item_ids) | |
| 1911 confirmed = set() | |
| 1912 for perm in sec.filter_iter(permission, userid, cn): | |
| 1913 fargs = perm.filter(self.db, userid, self) | |
| 1914 for farg in fargs: | |
| 1915 farg.update(sort=[], group=[], retired=None) | |
| 1916 result = self.filter(list(new_ids), **farg) | |
| 1917 new_ids.difference_update(result) | |
| 1918 confirmed.update(result) | |
| 1919 # all allowed? | |
| 1920 if not new_ids: | |
| 1921 break | |
| 1922 # all allowed? | |
| 1923 if not new_ids: | |
| 1924 break | |
| 1925 # Need to sort again in database | |
| 1926 allowed = self.filter(confirmed, {}, sort=sort, group=group, | |
| 1927 retired=None) | |
| 1928 else: # Last resort: filter in python | |
| 1929 allowed = [id for id in item_ids | |
| 1930 if check(permission, userid, cn, itemid=id)] | |
| 1931 return allowed | |
| 1932 | |
| 1933 | |
| 1864 def count(self): | 1934 def count(self): |
| 1865 """Get the number of nodes in this class. | 1935 """Get the number of nodes in this class. |
| 1866 | 1936 |
| 1867 If the returned integer is 'numnodes', the ids of all the nodes | 1937 If the returned integer is 'numnodes', the ids of all the nodes |
| 1868 in this class run from 1 to numnodes, and numnodes+1 will be the | 1938 in this class run from 1 to numnodes, and numnodes+1 will be the |
| 2101 | 2171 |
| 2102 return value | 2172 return value |
| 2103 | 2173 |
| 2104 | 2174 |
| 2105 class FileClass: | 2175 class FileClass: |
| 2106 """ A class that requires the "content" property and stores it on | 2176 """ This class defines a large chunk of data. To support this, it |
| 2107 disk. | 2177 has a mandatory String property "content" which is saved off |
| 2178 externally to the hyperdb. | |
| 2179 | |
| 2180 The default MIME type of this data is defined by the | |
| 2181 "default_mime_type" class attribute, which may be overridden by | |
| 2182 each node if the class defines a "type" String property. | |
| 2108 """ | 2183 """ |
| 2109 default_mime_type = 'text/plain' | 2184 default_mime_type = 'text/plain' |
| 2110 | 2185 |
| 2111 def __init__(self, db, classname, **properties): | 2186 def _update_properties(self, properties): |
| 2112 """The newly-created class automatically includes the "content" | 2187 """The newly-created class automatically includes the "content" |
| 2113 property. | 2188 and "type" properties. This method must be called by __init__. |
| 2114 """ | 2189 """ |
| 2115 if 'content' not in properties: | 2190 if 'content' not in properties: |
| 2116 properties['content'] = String(indexme='yes') | 2191 properties['content'] = String(indexme='yes') |
| 2192 if 'type' not in properties: | |
| 2193 properties['type'] = String() | |
| 2194 | |
| 2195 def create(self, **propvalues): | |
| 2196 """ snaffle the file propvalue and store in a file | |
| 2197 """ | |
| 2198 # we need to fire the auditors now, or the content property won't | |
| 2199 # be in propvalues for the auditors to play with | |
| 2200 self.fireAuditors('create', None, propvalues) | |
| 2201 | |
| 2202 # now remove the content property so it's not stored in the db | |
| 2203 content = propvalues['content'] | |
| 2204 del propvalues['content'] | |
| 2205 | |
| 2206 # do the database create | |
| 2207 newid = self.create_inner(**propvalues) | |
| 2208 | |
| 2209 # figure the mime type | |
| 2210 mime_type = propvalues.get('type', self.default_mime_type) | |
| 2211 | |
| 2212 # optionally index | |
| 2213 # This wasn't done for the anydbm backend (but the 'set' method | |
| 2214 # *did* update the index) so this is probably a bug-fix for anydbm | |
| 2215 if self.properties['content'].indexme: | |
| 2216 index_content = content | |
| 2217 if bytes != str and isinstance(content, bytes): | |
| 2218 index_content = content.decode('utf-8', errors='ignore') | |
| 2219 self.db.indexer.add_text((self.classname, newid, 'content'), | |
| 2220 index_content, mime_type) | |
| 2221 | |
| 2222 # store off the content as a file | |
| 2223 self.db.storefile(self.classname, newid, None, bs2b(content)) | |
| 2224 | |
| 2225 # fire reactors | |
| 2226 self.fireReactors('create', newid, None) | |
| 2227 | |
| 2228 return newid | |
| 2117 | 2229 |
| 2118 def export_propnames(self): | 2230 def export_propnames(self): |
| 2119 """ Don't export the "content" property | 2231 """ Don't export the "content" property |
| 2120 """ | 2232 """ |
| 2121 propnames = list(self.getprops().keys()) | 2233 propnames = list(self.getprops().keys()) |
| 2138 source = self.db.filename(self.classname, nodeid) | 2250 source = self.db.filename(self.classname, nodeid) |
| 2139 | 2251 |
| 2140 dest = self.exportFilename(dirname, nodeid) | 2252 dest = self.exportFilename(dirname, nodeid) |
| 2141 ensureParentsExist(dest) | 2253 ensureParentsExist(dest) |
| 2142 shutil.copyfile(source, dest) | 2254 shutil.copyfile(source, dest) |
| 2255 | |
| 2256 def get(self, nodeid, propname, default=_marker, cache=1, allow_abort=True): | |
| 2257 """ Trap the content propname and get it from the file | |
| 2258 | |
| 2259 'cache' exists for backwards compatibility, and is not used. | |
| 2260 | |
| 2261 'allow_abort' determines if we allow that the current | |
| 2262 transaction is aborted due to a data error (e.g. invalid nodeid). | |
| 2263 """ | |
| 2264 poss_msg = 'Possibly an access right configuration problem.' | |
| 2265 if propname == 'content': | |
| 2266 try: | |
| 2267 return b2s(self.db.getfile(self.classname, nodeid, None)) | |
| 2268 except IOError as strerror: | |
| 2269 # BUG: by catching this we don't see an error in the log. | |
| 2270 return 'ERROR reading file: %s%s\n%s\n%s' % ( | |
| 2271 self.classname, nodeid, poss_msg, strerror) | |
| 2272 except UnicodeDecodeError: | |
| 2273 # if content is not text (e.g. jpeg file) we get | |
| 2274 # unicode error trying to convert to string in python 3. | |
| 2275 # trap it and supply an error message. Include md5sum | |
| 2276 # of content as this string is included in the etag | |
| 2277 # calculation of the object. | |
| 2278 return ('%s%s is not text, retrieve using ' | |
| 2279 'binary_content property. mdsum: %s') % ( | |
| 2280 self.classname, nodeid, | |
| 2281 md5(self.db.getfile( | |
| 2282 self.classname, | |
| 2283 nodeid, | |
| 2284 None)).hexdigest()) # nosec - bandit md5 use ok | |
| 2285 elif propname == 'binary_content': | |
| 2286 return self.db.getfile(self.classname, nodeid, None) | |
| 2287 | |
| 2288 if default is not _marker: | |
| 2289 return self.subclass.get(self, nodeid, propname, default, | |
| 2290 allow_abort=allow_abort) | |
| 2291 else: | |
| 2292 return self.subclass.get(self, nodeid, propname, | |
| 2293 allow_abort=allow_abort) | |
| 2143 | 2294 |
| 2144 def import_files(self, dirname, nodeid): | 2295 def import_files(self, dirname, nodeid): |
| 2145 """ Import the "content" property as a file | 2296 """ Import the "content" property as a file |
| 2146 """ | 2297 """ |
| 2147 source = self.exportFilename(dirname, nodeid) | 2298 source = self.exportFilename(dirname, nodeid) |
| 2164 # other types. So if mime type of file is correct, we | 2315 # other types. So if mime type of file is correct, we |
| 2165 # call add_text on content. | 2316 # call add_text on content. |
| 2166 self.db.indexer.add_text((self.classname, nodeid, 'content'), | 2317 self.db.indexer.add_text((self.classname, nodeid, 'content'), |
| 2167 index_content, mime_type) | 2318 index_content, mime_type) |
| 2168 | 2319 |
| 2320 def index(self, nodeid): | |
| 2321 """ Add (or refresh) the node to search indexes. | |
| 2322 | |
| 2323 Use the content-type property for the content property. | |
| 2324 """ | |
| 2325 # find all the String properties that have indexme | |
| 2326 for prop, propclass in self.getprops().items(): | |
| 2327 if prop == 'content' and propclass.indexme: | |
| 2328 mime_type = self.get(nodeid, 'type', self.default_mime_type) | |
| 2329 index_content = self.get(nodeid, 'binary_content') | |
| 2330 if bytes != str and isinstance(index_content, bytes): | |
| 2331 index_content = index_content.decode('utf-8', | |
| 2332 errors='ignore') | |
| 2333 self.db.indexer.add_text((self.classname, nodeid, 'content'), | |
| 2334 index_content, mime_type) | |
| 2335 elif isinstance(propclass, String) and propclass.indexme: | |
| 2336 # index them under (classname, nodeid, property) | |
| 2337 try: | |
| 2338 value = str(self.get(nodeid, prop)) | |
| 2339 except IndexError: | |
| 2340 # node has been destroyed | |
| 2341 continue | |
| 2342 self.db.indexer.add_text((self.classname, nodeid, prop), value) | |
| 2343 | |
| 2344 def set(self, itemid, **propvalues): | |
| 2345 """ Snarf the "content" propvalue and update it in a file | |
| 2346 """ | |
| 2347 self.fireAuditors('set', itemid, propvalues) | |
| 2348 | |
| 2349 # create the oldvalues dict - fill in any missing values | |
| 2350 oldvalues = copy.deepcopy(self.db.getnode(self.classname, itemid)) | |
| 2351 # The following is redundant for rdbms backends but needed for anydbm | |
| 2352 # the performance impact is so low we that we don't factor this. | |
| 2353 for name, prop in self.getprops(protected=0).items(): | |
| 2354 if name in oldvalues: | |
| 2355 continue | |
| 2356 if isinstance(prop, Multilink): | |
| 2357 oldvalues[name] = [] | |
| 2358 else: | |
| 2359 oldvalues[name] = None | |
| 2360 | |
| 2361 # now remove the content property so it's not stored in the db | |
| 2362 content = None | |
| 2363 if 'content' in propvalues: | |
| 2364 content = propvalues['content'] | |
| 2365 del propvalues['content'] | |
| 2366 | |
| 2367 # do the database update | |
| 2368 propvalues = self.set_inner(itemid, **propvalues) | |
| 2369 | |
| 2370 # do content? | |
| 2371 if content: | |
| 2372 # store and possibly index | |
| 2373 self.db.storefile(self.classname, itemid, None, bs2b(content)) | |
| 2374 if self.properties['content'].indexme: | |
| 2375 index_content = content | |
| 2376 if bytes != str and isinstance(content, bytes): | |
| 2377 index_content = content.decode('utf-8', errors='ignore') | |
| 2378 mime_type = self.get(itemid, 'type', self.default_mime_type) | |
| 2379 self.db.indexer.add_text((self.classname, itemid, 'content'), | |
| 2380 index_content, mime_type) | |
| 2381 propvalues['content'] = content | |
| 2382 | |
| 2383 # fire reactors | |
| 2384 self.fireReactors('set', itemid, oldvalues) | |
| 2385 return propvalues | |
| 2169 | 2386 |
| 2170 class Node: | 2387 class Node: |
| 2171 """ A convenience wrapper for the given node | 2388 """ A convenience wrapper for the given node |
| 2172 """ | 2389 """ |
| 2173 def __init__(self, cl, nodeid, cache=1): | 2390 def __init__(self, cl, nodeid, cache=1): |
