5
5
import functools
6
6
import os .path
7
7
import urllib .parse
8
+ from io import BytesIO
9
+ from typing import Optional
8
10
9
11
import kazoo .exceptions
10
12
import kazoo .recipe .watchers
11
13
12
14
from .abc import LibraryProviderABC
13
15
from ..item import LibraryItem
14
16
from ...zookeeper import ZooKeeperContainer
15
- from ...contextvars import Tenant
17
+ from ...contextvars import Tenant , Authz
16
18
17
19
#
18
20
@@ -264,78 +266,91 @@ def _check_version_counter(self, version):
264
266
265
267
self .App .TaskService .schedule (self ._on_library_changed ())
266
268
267
- async def read (self , path : str ) -> typing .IO :
269
+ async def read (self , path : str ) -> Optional [BytesIO ]:
270
+ """
271
+ Read a node with precedence personal → tenant → global.
272
+
273
+ Args:
274
+ path: Logical library path starting with '/'.
275
+
276
+ Returns:
277
+ BytesIO or None if not found in any scope.
278
+
279
+ Raises:
280
+ RuntimeError: If ZooKeeper is not ready.
281
+ """
268
282
if self .Zookeeper is None :
269
283
L .warning ("Zookeeper Client has not been established (yet). Cannot read {}" .format (path ))
270
284
raise RuntimeError ("Zookeeper Client has not been established (yet). Not ready." )
271
285
272
286
try :
273
- # Try tenant-specific path first
274
- node_path = self .build_path (path , tenant_specific = True )
275
- node_data = await self .Zookeeper .get_data (node_path )
276
-
277
- # If not found, try the normal path
278
- if node_data is None :
279
- node_path = self .build_path (path , tenant_specific = False )
287
+ for target in ("personal" , "tenant" , "global" ):
288
+ node_path = self .build_path (path , target = target )
280
289
node_data = await self .Zookeeper .get_data (node_path )
281
-
282
- if node_data is not None :
283
- return io .BytesIO (initial_bytes = node_data )
284
- else :
285
- return None
290
+ if node_data is not None :
291
+ return io .BytesIO (initial_bytes = node_data )
292
+ return None
286
293
287
294
except kazoo .exceptions .ConnectionClosedError :
288
295
L .warning ("Zookeeper library provider is not ready" )
289
296
raise RuntimeError ("Zookeeper library provider is not ready" )
290
297
291
298
async def list (self , path : str ) -> list :
299
+ """
300
+ List nodes under `path` across all scopes in precedence order:
301
+ personal, then tenant, then global. Results are concatenated
302
+ (no dedup), matching current behavior while adding 'personal'.
303
+
304
+ Args:
305
+ path: Directory path starting with '/'.
306
+
307
+ Returns:
308
+ List[LibraryItem]
309
+ """
292
310
if self .Zookeeper is None :
293
311
L .warning ("Zookeeper Client has not been established (yet). Cannot list {}" .format (path ))
294
312
raise RuntimeError ("Zookeeper Client has not been established (yet). Not ready." )
295
313
296
314
items = []
297
315
298
- # Personal nodes
316
+ # Personal scope
299
317
personal_node_path = self .build_path (path , target = "personal" )
300
318
personal_nodes = await self .Zookeeper .get_children (personal_node_path ) or []
301
319
items += await self .process_nodes (personal_nodes , path , target = "personal" )
302
320
303
- # Tenant nodes
321
+ # Tenant scope
304
322
tenant_node_path = self .build_path (path , target = "tenant" )
305
323
tenant_nodes = await self .Zookeeper .get_children (tenant_node_path ) or []
306
324
items += await self .process_nodes (tenant_nodes , path , target = "tenant" )
307
325
308
- # Global nodes
326
+ # Global scope
309
327
global_node_path = self .build_path (path , target = "global" )
310
328
global_nodes = await self .Zookeeper .get_children (global_node_path ) or []
311
329
items += await self .process_nodes (global_nodes , path , target = "global" )
312
330
313
331
return items
314
332
315
- async def process_nodes (self , nodes , base_path , target = "global" ):
333
+ async def process_nodes (self , nodes : list , base_path : str , target : str = "global" ) -> list :
316
334
"""
317
- Processes a list of nodes and creates corresponding LibraryItem objects with their size .
335
+ Convert child node names under `base_path` to LibraryItem objects.
318
336
319
337
Args:
320
- nodes (list): List of node names to process .
321
- base_path (str): The base path for the nodes .
322
- target (str): Specifies the target context, e.g., " tenant", "global", or "personal" .
338
+ nodes: Children names returned by ZooKeeper .
339
+ base_path: Logical library base path (starts with '/') .
340
+ target: 'personal' | ' tenant' | 'global' .
323
341
324
342
Returns:
325
- list: A list of LibraryItem objects with size information .
343
+ List[ LibraryItem] with size set for files .
326
344
"""
327
345
items = []
328
346
for node in nodes :
329
- # Remove any component that starts with '.'
330
347
startswithdot = functools .reduce (lambda x , y : x or y .startswith ('.' ), node .split (os .path .sep ), False )
331
348
if startswithdot :
332
349
continue
333
350
334
- # Determine if this is a file or directory
335
- if '.' in node and not node .endswith (('.io' , '.d' )): # File check
351
+ if '.' in node and not node .endswith (('.io' , '.d' )):
336
352
fname = "{}/{}" .format (base_path .rstrip ("/" ), node )
337
353
ftype = "item"
338
-
339
354
try :
340
355
node_path = self .build_path (fname , target = target )
341
356
zstat = self .Zookeeper .Client .exists (node_path )
@@ -345,12 +360,11 @@ async def process_nodes(self, nodes, base_path, target="global"):
345
360
except Exception as e :
346
361
L .warning ("Failed to retrieve size for node {}: {}" .format (node_path , e ))
347
362
size = None
348
- else : # Directory check
363
+ else :
349
364
fname = "{}/{}/" .format (base_path .rstrip ("/" ), node )
350
365
ftype = "dir"
351
366
size = None
352
367
353
- # 👇 Add your block here
354
368
if self .Layer == 0 :
355
369
if target == "global" :
356
370
layer_label = "0:global"
@@ -361,28 +375,40 @@ async def process_nodes(self, nodes, base_path, target="global"):
361
375
else :
362
376
layer_label = self .Layer
363
377
364
- # Build LibraryItem
365
378
items .append (LibraryItem (
366
379
name = fname ,
367
380
type = ftype ,
368
381
layers = [layer_label ],
369
382
providers = [self ],
370
383
size = size
371
384
))
372
-
373
385
return items
374
386
375
387
376
- def build_path (self , path , target = "global" ):
388
+ def build_path (self , path : str , target : str = "global" ) -> str :
389
+ """
390
+ Build an absolute ZooKeeper node path for the given logical library `path`
391
+ within the specified target scope ('personal' | 'tenant' | 'global').
392
+
393
+ Args:
394
+ path: The logical library path starting with '/'.
395
+ target: Scope selector. Defaults to 'global'.
396
+
397
+ Returns:
398
+ The fully qualified ZooKeeper node path.
399
+
400
+ Raises:
401
+ AssertionError: If the resulting node path is malformed.
402
+ """
377
403
assert path [:1 ] == '/'
378
404
if path != '/' :
379
405
node_path = "{}{}" .format (self .BasePath , path )
380
406
else :
381
407
node_path = "{}" .format (self .BasePath )
382
408
383
409
if target == "personal" :
410
+ # /.personal/{CredentialsId}/...
384
411
try :
385
- from ...contextvars import Authz
386
412
authz = Authz .get ()
387
413
cred_id = getattr (authz , "CredentialsId" , None )
388
414
except LookupError :
@@ -391,40 +417,57 @@ def build_path(self, path, target="global"):
391
417
node_path = "{}/.personal/{}{}" .format (self .BasePath , cred_id , path )
392
418
393
419
elif target == "tenant" :
420
+ # /.tenants/{tenant}/...
394
421
try :
395
422
tenant = Tenant .get ()
396
423
except LookupError :
397
424
tenant = None
398
425
if tenant :
399
426
node_path = "{}/.tenants/{}{}" .format (self .BasePath , tenant , path )
400
427
428
+ # global target keeps node_path as is
429
+
401
430
node_path = node_path .rstrip ("/" )
402
431
403
432
assert '//' not in node_path , "Directory path cannot contain double slashes (//). Example format: /library/Templates/"
404
433
assert node_path [0 ] == '/' , "Directory path must start with a forward slash (/). For example: /library/Templates/"
405
434
406
435
return node_path
407
436
408
- async def subscribe (self , path , target : typing .Union [str , tuple , None ] = None ):
437
+ async def subscribe (self , path : str , target : typing .Union [str , tuple , None ] = None ):
438
+ """
439
+ Subscribe to changes under `path` for the given target:
440
+ - None / 'global' : watch global path
441
+ - 'tenant' : watch all tenants
442
+ - ('tenant', id) : watch a specific tenant
443
+ - 'personal' : watch all personal credentials
444
+ - ('personal', id): watch a specific credentials id
445
+ """
409
446
self .Subscriptions .add ((target , path ))
410
447
411
- if target is None :
412
- # Back-compat (pubsub callback must be called without `target` argument)
413
- # Watch path globally
414
- self .NodeDigests [path ] = await self ._get_directory_hash (path )
415
- elif target == "global" :
416
- # Watch path globally
448
+ if target is None or target == "global" :
417
449
self .NodeDigests [path ] = await self ._get_directory_hash (path )
450
+
418
451
elif target == "tenant" :
419
- # Watch path in all tenants
420
452
for tenant in await self ._get_tenants ():
421
453
actual_path = "/.tenants/{}{}" .format (tenant , path )
422
454
self .NodeDigests [actual_path ] = await self ._get_directory_hash (actual_path )
455
+
423
456
elif isinstance (target , tuple ) and len (target ) == 2 and target [0 ] == "tenant" :
424
- # Watch path in a specific tenant
425
457
_ , tenant = target
426
458
actual_path = "/.tenants/{}{}" .format (tenant , path )
427
459
self .NodeDigests [actual_path ] = await self ._get_directory_hash (actual_path )
460
+
461
+ elif target == "personal" :
462
+ for cred_id in await self ._get_personals ():
463
+ actual_path = "/.personal/{}{}" .format (cred_id , path )
464
+ self .NodeDigests [actual_path ] = await self ._get_directory_hash (actual_path )
465
+
466
+ elif isinstance (target , tuple ) and len (target ) == 2 and target [0 ] == "personal" :
467
+ _ , cred_id = target
468
+ actual_path = "/.personal/{}{}" .format (cred_id , path )
469
+ self .NodeDigests [actual_path ] = await self ._get_directory_hash (actual_path )
470
+
428
471
else :
429
472
raise ValueError ("Unexpected target: {!r}" .format (target ))
430
473
@@ -450,18 +493,17 @@ def recursive_traversal(path, digest):
450
493
await self .Zookeeper .ProactorService .execute (recursive_traversal , path , digest )
451
494
return digest .digest ()
452
495
453
-
454
496
async def _on_library_changed (self , event_name = None ):
455
497
"""
456
- Check watched paths and publish a pubsub message for every one that has changed.
498
+ Recompute hashes for subscribed paths and publish "Library.change!" when changed.
499
+ Supports global, tenant, and personal scopes.
457
500
"""
458
501
for (target , path ) in list (self .Subscriptions ):
459
502
460
- async def do_check_path (actual_path ):
503
+ async def do_check_path (actual_path : str ):
461
504
try :
462
505
newdigest = await self ._get_directory_hash (actual_path )
463
506
except kazoo .exceptions .NoNodeError :
464
- # This node is either deleted or has never existed.
465
507
newdigest = None
466
508
467
509
if newdigest != self .NodeDigests .get (actual_path ):
@@ -472,10 +514,7 @@ async def do_check_path(actual_path):
472
514
try :
473
515
await do_check_path (actual_path = path )
474
516
except Exception as e :
475
- L .exception (
476
- "Failed to process library changes: '{}'" .format (e ),
477
- struct_data = {"path" : path },
478
- )
517
+ L .exception ("Failed to process library changes: '{}'" .format (e ), struct_data = {"path" : path })
479
518
480
519
elif target == "tenant" :
481
520
for tenant in await self ._get_tenants ():
@@ -496,9 +535,43 @@ async def do_check_path(actual_path):
496
535
"Failed to process library changes: '{}'" .format (e ),
497
536
struct_data = {"path" : path , "tenant" : tenant },
498
537
)
538
+
539
+ elif target == "personal" :
540
+ for cred_id in await self ._get_personals ():
541
+ try :
542
+ await do_check_path (actual_path = "/.personal/{}{}" .format (cred_id , path ))
543
+ except Exception as e :
544
+ L .exception (
545
+ "Failed to process library changes: '{}'" .format (e ),
546
+ struct_data = {"path" : path , "cred_id" : cred_id },
547
+ )
548
+
549
+ elif isinstance (target , tuple ) and len (target ) == 2 and target [0 ] == "personal" :
550
+ cred_id = target [1 ]
551
+ try :
552
+ await do_check_path (actual_path = "/.personal/{}{}" .format (cred_id , path ))
553
+ except Exception as e :
554
+ L .exception (
555
+ "Failed to process library changes: '{}'" .format (e ),
556
+ struct_data = {"path" : path , "cred_id" : cred_id },
557
+ )
558
+
499
559
else :
500
560
raise ValueError ("Unexpected target: {!r}" .format ((target , path )))
501
561
562
+ async def _get_personals (self ) -> typing .List [str ]:
563
+ """
564
+ List CredentialsIds that have custom content (i.e., directories) under /.personal.
565
+ """
566
+ try :
567
+ cred_ids = [
568
+ c for c in await self .Zookeeper .get_children ("{}/.personal" .format (self .BasePath )) or []
569
+ if not c .startswith ("." )
570
+ ]
571
+ except kazoo .exceptions .NoNodeError :
572
+ cred_ids = []
573
+ return cred_ids
574
+
502
575
503
576
async def _get_tenants (self ) -> typing .List [str ]:
504
577
"""
@@ -514,7 +587,6 @@ async def _get_tenants(self) -> typing.List[str]:
514
587
return tenants
515
588
516
589
517
-
518
590
async def find (self , filename : str ) -> list :
519
591
"""
520
592
Recursively search for files ending with a specific name in ZooKeeper nodes, starting from the base path.
0 commit comments