Skip to content

Conversation

mithunbharadwaj
Copy link
Collaborator

@mithunbharadwaj mithunbharadwaj commented Sep 26, 2025

Summary by CodeRabbit

  • New Features

    • Added a personal scope alongside tenant and global libraries; reads and listings now follow personal → tenant → global precedence. Personal items are prepended in listings and labeled by source.
    • Subscriptions/watch support personal and specific personal-credential targets.
  • Bug Fixes

    • More resilient handling when items are missing or unreadable.
    • More reliable size retrieval with graceful fallbacks.
  • Documentation

    • Updated docs and inline guidance to clarify scope behavior and precedence.

@mithunbharadwaj mithunbharadwaj self-assigned this Sep 26, 2025
Copy link

coderabbitai bot commented Sep 26, 2025

Note

Other AI code review bot(s) detected

CodeRabbit has detected other AI code review bot(s) in this pull request and will avoid duplicating their findings in the review comments. This may lead to a less comprehensive review.

Walkthrough

Extends ZooKeeperLibraryProvider with Authz-aware helpers and credential-scoped semantics: read/list now consider personal → tenant → global precedence, process_nodes and subscribe support personal targets, subscribe watches personal scopes, and change handling and docstrings updated accordingly.

Changes

Cohort / File(s) Summary
ZooKeeper provider core
asab/library/providers/zookeeper.py
- Added Authz-aware imports and helpers (_current_tenant_id, _current_credentials_id, _personal_node_path, _get_personals, _get_tenants). - read(path) now resolves candidates in precedence (personal → tenant → global) and returns Optional[IO]. - list() aggregates children across personal, tenant, global; personal items are prepended when credentials exist. - process_nodes(nodes, base_path, target="global") accepts personal target, computes per-target sizes, and assigns top-layer labels. - subscribe(path, target: Union[str, tuple, None]) extended to watch personal scopes (all personals or specific credentials). - _on_library_changed updated to handle personal-target changes. - Docstrings and comments updated for target-aware semantics; minor whitespace/comments adjusted.
Service docstrings
asab/library/service.py
- Docstrings for list and subscribe expanded to describe target semantics including "personal" and ("personal", CREDENTIALS_ID) options; no behavioral changes.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  actor Caller
  participant Provider as ZooKeeperLibraryProvider
  participant ZK as ZooKeeper

  Caller->>Provider: list(path)
  rect rgb(250,252,255)
    note right of Provider #cfe9ff: iterate targets in precedence\n(personal → tenant → global)
    loop for each target
      Provider->>Provider: resolve tenant / credentials (if needed)
      Provider->>Provider: build target_path(path, target)
      Provider->>ZK: get_children(target_path)
      ZK-->>Provider: children[]
      Provider->>Provider: process_nodes(children, base_path, target)
      alt size/data required
        Provider->>ZK: get_data(size_path_for_target)
        ZK-->>Provider: data or NoNode
      end
      Provider->>Provider: label items with target layer
    end
  end
  Provider-->>Caller: aggregated items (personal → tenant → global)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

I hop through znodes, nose twitching for keys,
Personal, tenant, global—each layer I please.
I sniff out credentials, prepend my little list,
Follow precedence paths, none of them missed.
Thump—watching changes, I cheer at the gist. 🐇

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 53.33% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title Check ✅ Passed The title succinctly highlights the addition of the new personal target concept introduced across the provider and service code paths, which aligns directly with the main feature implemented in this PR.
✨ Finishing touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feature/reading-personal-targets

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@Copilot Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull Request Overview

This PR introduces support for "personal" targets in the Zookeeper provider, enabling user-specific data storage and retrieval alongside existing global and tenant-specific functionality.

  • Adds "personal" target type that uses authenticated user's credential ID for path construction
  • Refactors the list method to process personal, tenant, and global nodes in sequence
  • Updates the build_path method to handle personal target with credential-based path resolution

Tip: Customize your code reviews with copilot-instructions.md. Create the file or learn how to get started.

Comment on lines 383 to 391
if target == "personal":
try:
from ...contextvars import Authz
authz = Authz.get()
cred_id = getattr(authz, "CredentialsId", None)
except LookupError:
cred_id = None
if cred_id not in (None, ""):
node_path = "{}/.personal/{}{}".format(self.BasePath, cred_id, path)
Copy link
Preview

Copilot AI Sep 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The import statement should be moved to the top of the file rather than being imported within the function. Local imports can impact performance and make dependencies less clear.

Copilot uses AI. Check for mistakes.

Comment on lines 390 to 391
if cred_id not in (None, ""):
node_path = "{}/.personal/{}{}".format(self.BasePath, cred_id, path)
Copy link
Preview

Copilot AI Sep 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When cred_id is None or empty, the personal target falls back to using the original node_path, which means it would use the same path as global target. This could lead to unintended access to global data when personal authentication fails.

Suggested change
if cred_id not in (None, ""):
node_path = "{}/.personal/{}{}".format(self.BasePath, cred_id, path)
if cred_id in (None, ""):
raise PermissionError("Personal authentication failed: cred_id is missing or empty. Access to personal data is denied.")
node_path = "{}/.personal/{}{}".format(self.BasePath, cred_id, path)

Copilot uses AI. Check for mistakes.

# Assign correct layer
if self.Layer == 0: # Only apply this logic to layer `0`
layer_label = "0:global" if target == "global" else "0:tenant"
# 👇 Add your block here
Copy link
Preview

Copilot AI Sep 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment appears to be a development placeholder and should be removed before merging to production.

Suggested change
# 👇 Add your block here

Copilot uses AI. Check for mistakes.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
asab/library/providers/zookeeper.py (1)

376-406: Update callers after the build_path signature change

Changing the signature to target="..." breaks existing call sites that still pass tenant_specific=.... For example, read() now raises TypeError: build_path() got an unexpected keyword argument 'tenant_specific'. Please migrate read() to the new target-aware flow and keep the personal/tenant/global fallback logic consistent.

@@
-		try:
-			# Try tenant-specific path first
-			node_path = self.build_path(path, tenant_specific=True)
-			node_data = await self.Zookeeper.get_data(node_path)
-
-			# If not found, try the normal path
-			if node_data is None:
-				node_path = self.build_path(path, tenant_specific=False)
-				node_data = await self.Zookeeper.get_data(node_path)
-
-			if node_data is not None:
-				return io.BytesIO(initial_bytes=node_data)
-			else:
-				return None
+		try:
+			global_node_path = self.build_path(path, target="global")
+			target_paths = []
+
+			personal_node_path = self.build_path(path, target="personal")
+			if personal_node_path != global_node_path:
+				target_paths.append(personal_node_path)
+
+			tenant_node_path = self.build_path(path, target="tenant")
+			if tenant_node_path not in {global_node_path, personal_node_path}:
+				target_paths.append(tenant_node_path)
+
+			target_paths.append(global_node_path)
+
+			for node_path in target_paths:
+				node_data = await self.Zookeeper.get_data(node_path)
+				if node_data is not None:
+					return io.BytesIO(initial_bytes=node_data)
+			return None
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between a671b55 and c0fd2a1.

📒 Files selected for processing (1)
  • asab/library/providers/zookeeper.py (5 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
asab/library/providers/zookeeper.py (2)
asab/zookeeper/wrapper.py (2)
  • get_children (80-91)
  • get (59-77)
asab/zookeeper/container.py (1)
  • get_children (249-250)

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
asab/library/providers/zookeeper.py (1)

388-436: Fix root-path handling and strengthen assertions in build_path.

For BasePath == "" and path == "/", node_path becomes "", and node_path[0] raises IndexError. Also, prefer startswith for safety.

Apply this diff:

@@
-		if path != '/':
-			node_path = "{}{}".format(self.BasePath, path)
-		else:
-			node_path = "{}".format(self.BasePath)
+		if path != '/':
+			node_path = f"{self.BasePath}{path}"
+		else:
+			node_path = (self.BasePath or "/")
@@
-		assert '//' not in node_path, "Directory path cannot contain double slashes (//). Example format: /library/Templates/"
-		assert node_path[0] == '/', "Directory path must start with a forward slash (/). For example: /library/Templates/"
+		assert '//' not in node_path, "Directory path cannot contain double slashes (//). Example format: /library/Templates/"
+		assert node_path.startswith('/'), "Directory path must start with a forward slash (/). For example: /library/Templates/"

Additionally, consider validating identifiers to prevent malformed paths:

  • Reject '/' in cred_id and tenant values.

Proposed snippet:

if target == "personal":
    ...
    if cred_id not in (None, ""):
        if "/" in cred_id:
            raise ValueError("Invalid credentials id contains '/'.")
        node_path = f"{self.BasePath}/.personal/{cred_id}{path}"
elif target == "tenant":
    ...
    if tenant:
        if "/" in tenant:
            raise ValueError("Invalid tenant id contains '/'.")
        node_path = f"{self.BasePath}/.tenants/{tenant}{path}"
🧹 Nitpick comments (2)
asab/library/providers/zookeeper.py (2)

498-560: Narrow broad exception handlers in change processing.

Replace blind except Exception with kazoo-specific exceptions and only catch generic exceptions at the outermost boundary if needed. This improves signal/noise and satisfies BLE001.

Apply this diff in both personal branches:

-					except Exception as e:
+					except kazoo.exceptions.KazooException as e:
 						L.exception(
 							"Failed to process library changes: '{}'".format(e),
 							struct_data={"path": path, "cred_id": cred_id},
 						)

And similarly for the single-cred branch.


562-574: LGTM on _get_personals.

Consider sorting for deterministic order, if useful for tests/logs.

-		return cred_ids
+		return sorted(cred_ids)
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between c0fd2a1 and 6980627.

📒 Files selected for processing (1)
  • asab/library/providers/zookeeper.py (6 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
asab/library/providers/zookeeper.py (5)
asab/library/providers/abc.py (3)
  • LibraryProviderABC (4-66)
  • read (19-28)
  • subscribe (49-66)
asab/library/item.py (1)
  • LibraryItem (6-25)
asab/zookeeper/container.py (3)
  • ZooKeeperContainer (25-259)
  • get_data (252-256)
  • get_children (249-250)
asab/library/service.py (2)
  • read (203-239)
  • subscribe (702-746)
asab/zookeeper/wrapper.py (4)
  • get_data (94-105)
  • get_children (80-91)
  • exists (49-56)
  • get (59-77)
🪛 Ruff (0.13.1)
asab/library/providers/zookeeper.py

284-284: Avoid specifying long messages outside the exception class

(TRY003)


292-292: Consider moving this statement to an else block

(TRY300)


296-296: Within an except clause, raise exceptions with raise ... from err or raise ... from None to distinguish them from errors in exception handling

(B904)


296-296: Avoid specifying long messages outside the exception class

(TRY003)


312-312: Avoid specifying long messages outside the exception class

(TRY003)


360-360: Do not catch blind exception: Exception

(BLE001)


543-543: Do not catch blind exception: Exception

(BLE001)


553-553: Do not catch blind exception: Exception

(BLE001)

🔇 Additional comments (6)
asab/library/providers/zookeeper.py (6)

8-9: LGTM on imports for Optional/BytesIO.

Consistent with the new return type and usage.


17-17: LGTM on importing Tenant/Authz.

Needed for scope resolution.


299-309: Docstring update looks good.

Precedence and return semantics are clearly stated.


314-331: Avoid duplicate/global-mislabeled items when personal/tenant resolve to global.

As previously noted, when personal/tenant contexts are absent, their paths equal the global path, causing duplicates and misleading layer tags.

Apply this diff to guard against re-processing identical paths:

@@
-		items = []
-
-		# Personal scope
-		personal_node_path = self.build_path(path, target="personal")
-		personal_nodes = await self.Zookeeper.get_children(personal_node_path) or []
-		items += await self.process_nodes(personal_nodes, path, target="personal")
-
-		# Tenant scope
-		tenant_node_path = self.build_path(path, target="tenant")
-		tenant_nodes = await self.Zookeeper.get_children(tenant_node_path) or []
-		items += await self.process_nodes(tenant_nodes, path, target="tenant")
-
-		# Global scope
-		global_node_path = self.build_path(path, target="global")
-		global_nodes = await self.Zookeeper.get_children(global_node_path) or []
-		items += await self.process_nodes(global_nodes, path, target="global")
+		items = []
+		global_node_path = self.build_path(path, target="global")
+
+		# Personal scope
+		personal_node_path = self.build_path(path, target="personal")
+		if personal_node_path != global_node_path:
+			personal_nodes = await self.Zookeeper.get_children(personal_node_path) or []
+			items += await self.process_nodes(personal_nodes, path, target="personal")
+
+		# Tenant scope
+		tenant_node_path = self.build_path(path, target="tenant")
+		if tenant_node_path not in {global_node_path, personal_node_path}:
+			tenant_nodes = await self.Zookeeper.get_children(tenant_node_path) or []
+			items += await self.process_nodes(tenant_nodes, path, target="tenant")
+
+		# Global scope
+		global_nodes = await self.Zookeeper.get_children(global_node_path) or []
+		items += await self.process_nodes(global_nodes, path, target="global")

409-419: Don’t fall back to global path on missing cred_id for 'personal'.

Falling back to global can yield unintended access and mislabeling. Either skip personal scope (let caller fall back to tenant/global) or raise a permission error when 'personal' is explicitly requested without cred.

Suggested change (option 1: skip silently):

-			if cred_id not in (None, ""):
-				node_path = "{}/.personal/{}{}".format(self.BasePath, cred_id, path)
+			if cred_id not in (None, ""):
+				node_path = "{}/.personal/{}{}".format(self.BasePath, cred_id, path)
+			else:
+				# Leave node_path unchanged; callers should detect and skip this as 'no personal'
+				pass

Or (option 2: strict):

-			if cred_id not in (None, ""):
-				node_path = "{}/.personal/{}{}".format(self.BasePath, cred_id, path)
+			if cred_id in (None, ""):
+				raise PermissionError("Personal authentication failed: cred_id is missing or empty.")
+			node_path = "{}/.personal/{}{}".format(self.BasePath, cred_id, path)

437-473: LGTM on subscribe API and scope handling.

Covers global, tenant(s), and personal(s); NodeDigests prefilled appropriately.

Please confirm intended behavior for large numbers of credentials/tenants; we may need batching/backoff to avoid long subscribe times if thousands exist.

Comment on lines 269 to 297
async def read(self, path: str) -> Optional[BytesIO]:
"""
Read a node with precedence personal → tenant → global.
Args:
path: Logical library path starting with '/'.
Returns:
BytesIO or None if not found in any scope.
Raises:
RuntimeError: If ZooKeeper is not ready.
"""
if self.Zookeeper is None:
L.warning("Zookeeper Client has not been established (yet). Cannot read {}".format(path))
raise RuntimeError("Zookeeper Client has not been established (yet). Not ready.")

try:
# Try tenant-specific path first
node_path = self.build_path(path, tenant_specific=True)
node_data = await self.Zookeeper.get_data(node_path)

# If not found, try the normal path
if node_data is None:
node_path = self.build_path(path, tenant_specific=False)
for target in ("personal", "tenant", "global"):
node_path = self.build_path(path, target=target)
node_data = await self.Zookeeper.get_data(node_path)

if node_data is not None:
return io.BytesIO(initial_bytes=node_data)
else:
return None
if node_data is not None:
return io.BytesIO(initial_bytes=node_data)
return None

except kazoo.exceptions.ConnectionClosedError:
L.warning("Zookeeper library provider is not ready")
raise RuntimeError("Zookeeper library provider is not ready")

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Fix precedence bug: global can overshadow tenant when personal/tenant context is absent.

When Authz/Tenant are not set, build_path("personal"/"tenant") resolves to the global path. The current loop will read global at the “personal” step and return early, skipping tenant. This breaks the intended personal → tenant → global precedence.

Also, raise from the original exception to preserve traceback (B904).

Apply this diff:

@@
-		try:
-			for target in ("personal", "tenant", "global"):
-				node_path = self.build_path(path, target=target)
-				node_data = await self.Zookeeper.get_data(node_path)
-				if node_data is not None:
-					return io.BytesIO(initial_bytes=node_data)
-			return None
+		try:
+			global_node_path = self.build_path(path, target="global")
+			candidates = []
+
+			# Include personal only if a CredentialsId is available
+			try:
+				authz = Authz.get()
+				cred_id = getattr(authz, "CredentialsId", None)
+			except LookupError:
+				cred_id = None
+			if cred_id not in (None, ""):
+				candidates.append(self.build_path(path, target="personal"))
+
+			# Include tenant only if a tenant is available
+			try:
+				tenant = Tenant.get()
+			except LookupError:
+				tenant = None
+			if tenant:
+				candidates.append(self.build_path(path, target="tenant"))
+
+			# Always include global last
+			candidates.append(global_node_path)
+
+			# De-duplicate while preserving order
+			seen = set()
+			for node_path in candidates:
+				if node_path in seen:
+					continue
+				seen.add(node_path)
+				node_data = await self.Zookeeper.get_data(node_path)
+				if node_data is not None:
+					return io.BytesIO(initial_bytes=node_data)
+			return None
 		except kazoo.exceptions.ConnectionClosedError:
 			L.warning("Zookeeper library provider is not ready")
-			raise RuntimeError("Zookeeper library provider is not ready")
+			raise RuntimeError("Zookeeper library provider is not ready") from None
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async def read(self, path: str) -> Optional[BytesIO]:
"""
Read a node with precedence personaltenantglobal.
Args:
path: Logical library path starting with '/'.
Returns:
BytesIO or None if not found in any scope.
Raises:
RuntimeError: If ZooKeeper is not ready.
"""
if self.Zookeeper is None:
L.warning("Zookeeper Client has not been established (yet). Cannot read {}".format(path))
raise RuntimeError("Zookeeper Client has not been established (yet). Not ready.")
try:
# Try tenant-specific path first
node_path = self.build_path(path, tenant_specific=True)
node_data = await self.Zookeeper.get_data(node_path)
# If not found, try the normal path
if node_data is None:
node_path = self.build_path(path, tenant_specific=False)
for target in ("personal", "tenant", "global"):
node_path = self.build_path(path, target=target)
node_data = await self.Zookeeper.get_data(node_path)
if node_data is not None:
return io.BytesIO(initial_bytes=node_data)
else:
return None
if node_data is not None:
return io.BytesIO(initial_bytes=node_data)
return None
except kazoo.exceptions.ConnectionClosedError:
L.warning("Zookeeper library provider is not ready")
raise RuntimeError("Zookeeper library provider is not ready")
async def read(self, path: str) -> Optional[BytesIO]:
"""
Read a node with precedence personaltenantglobal.
Args:
path: Logical library path starting with '/'.
Returns:
BytesIO or None if not found in any scope.
Raises:
RuntimeError: If ZooKeeper is not ready.
"""
if self.Zookeeper is None:
L.warning("Zookeeper Client has not been established (yet). Cannot read {}".format(path))
raise RuntimeError("Zookeeper Client has not been established (yet). Not ready.")
try:
global_node_path = self.build_path(path, target="global")
candidates = []
# Include personal only if a CredentialsId is available
try:
authz = Authz.get()
cred_id = getattr(authz, "CredentialsId", None)
except LookupError:
cred_id = None
if cred_id not in (None, ""):
candidates.append(self.build_path(path, target="personal"))
# Include tenant only if a tenant is available
try:
tenant = Tenant.get()
except LookupError:
tenant = None
if tenant:
candidates.append(self.build_path(path, target="tenant"))
# Always include global last
candidates.append(global_node_path)
# De-duplicate while preserving order
seen = set()
for node_path in candidates:
if node_path in seen:
continue
seen.add(node_path)
node_data = await self.Zookeeper.get_data(node_path)
if node_data is not None:
return io.BytesIO(initial_bytes=node_data)
return None
except kazoo.exceptions.ConnectionClosedError:
L.warning("Zookeeper library provider is not ready")
raise RuntimeError("Zookeeper library provider is not ready") from None
🧰 Tools
🪛 Ruff (0.13.1)

284-284: Avoid specifying long messages outside the exception class

(TRY003)


292-292: Consider moving this statement to an else block

(TRY300)


296-296: Within an except clause, raise exceptions with raise ... from err or raise ... from None to distinguish them from errors in exception handling

(B904)


296-296: Avoid specifying long messages outside the exception class

(TRY003)

🤖 Prompt for AI Agents
In asab/library/providers/zookeeper.py around lines 269 to 297, the loop that
checks ("personal","tenant","global") can return the same global path early when
Authz/Tenant are unset because build_path("personal"/"tenant") resolves to the
global path; change the loop to track already-seen node_path values and skip
duplicates so precedence is honored (only return when a previously-unchecked
path yields data), and in the except block capture the original exception (e)
and re-raise the RuntimeError using "raise ... from e" so the traceback is
preserved.

Comment on lines 351 to 363
if '.' in node and not node.endswith(('.io', '.d')):
fname = "{}/{}".format(base_path.rstrip("/"), node)
ftype = "item"

# Retrieve node size for items only
try:
node_path = self.build_path(fname, tenant_specific=(target == "tenant"))
node_path = self.build_path(fname, target=target)
zstat = self.Zookeeper.Client.exists(node_path)
size = zstat.dataLength if zstat else 0
except kazoo.exceptions.NoNodeError:
size = None
except Exception as e:
L.warning("Failed to retrieve size for node {}: {}".format(node_path, e))
size = None
else: # Directory check
else:
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Avoid blocking the event loop; use async exists and fix size semantics.

Calling self.Zookeeper.Client.exists() here blocks the event loop. Use the async wrapper and return None when size is unknown.

Apply this diff:

-					node_path = self.build_path(fname, target=target)
-					zstat = self.Zookeeper.Client.exists(node_path)
-					size = zstat.dataLength if zstat else 0
-				except kazoo.exceptions.NoNodeError:
-					size = None
-				except Exception as e:
-					L.warning("Failed to retrieve size for node {}: {}".format(node_path, e))
-					size = None
+					node_path = self.build_path(fname, target=target)
+					zstat = await self.Zookeeper.exists(node_path)
+					size = zstat.dataLength if zstat else None
+				except kazoo.exceptions.KazooException as e:
+					L.warning("Failed to retrieve size for node {}: {}".format(fname, e))
+					size = None
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if '.' in node and not node.endswith(('.io', '.d')):
fname = "{}/{}".format(base_path.rstrip("/"), node)
ftype = "item"
# Retrieve node size for items only
try:
node_path = self.build_path(fname, tenant_specific=(target == "tenant"))
node_path = self.build_path(fname, target=target)
zstat = self.Zookeeper.Client.exists(node_path)
size = zstat.dataLength if zstat else 0
except kazoo.exceptions.NoNodeError:
size = None
except Exception as e:
L.warning("Failed to retrieve size for node {}: {}".format(node_path, e))
size = None
else: # Directory check
else:
if '.' in node and not node.endswith(('.io', '.d')):
fname = "{}/{}".format(base_path.rstrip("/"), node)
ftype = "item"
try:
node_path = self.build_path(fname, target=target)
zstat = await self.Zookeeper.exists(node_path)
size = zstat.dataLength if zstat else None
except kazoo.exceptions.KazooException as e:
L.warning("Failed to retrieve size for node {}: {}".format(fname, e))
size = None
else:
🧰 Tools
🪛 Ruff (0.13.1)

360-360: Do not catch blind exception: Exception

(BLE001)

🤖 Prompt for AI Agents
In asab/library/providers/zookeeper.py around lines 351 to 363, the synchronous
call to self.Zookeeper.Client.exists(...) blocks the event loop and the code
treats a missing znode as size 0; replace the blocking call with the async
exists wrapper and await it, handle kazoo.exceptions.NoNodeError by setting size
= None, and also ensure that when exists returns falsy you set size = None (not
0); update the surrounding function signature to be async if required and keep
the existing exception handling to log unexpected errors and set size = None.

Comment on lines 368 to 377
if self.Layer == 0:
if target == "global":
layer_label = "0:global"
elif target == "tenant":
layer_label = "0:tenant"
elif target == "personal":
layer_label = "0:personal"
else:
layer_label = self.Layer # Keep normal numbering for other layers
layer_label = self.Layer

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Do not store string labels in LibraryItem.layers (expects List[int]).

Using "0:global"/"0:tenant"/"0:personal" breaks type guarantees and downstream consumers.

Apply this diff:

-			if self.Layer == 0:
-				if target == "global":
-					layer_label = "0:global"
-				elif target == "tenant":
-					layer_label = "0:tenant"
-				elif target == "personal":
-					layer_label = "0:personal"
-			else:
-				layer_label = self.Layer
+			layer_label = self.Layer

If you need to expose scope, consider adding a separate metadata field in LibraryItem rather than overloading layers. Based on learnings

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if self.Layer == 0:
if target == "global":
layer_label = "0:global"
elif target == "tenant":
layer_label = "0:tenant"
elif target == "personal":
layer_label = "0:personal"
else:
layer_label = self.Layer # Keep normal numbering for other layers
layer_label = self.Layer
layer_label = self.Layer
🤖 Prompt for AI Agents
In asab/library/providers/zookeeper.py around lines 368–377, the code currently
writes string labels like "0:global" into LibraryItem.layers which is typed as
List[int>; instead, keep layers as integers (e.g., 0) and do not concatenate
scope into the layer value. Set layer_label to the integer self.Layer in all
branches and, if scope needs to be recorded, add or populate a separate metadata
field on the LibraryItem (e.g., item.metadata["scope"] =
"global"|"tenant"|"personal") rather than overloading the layers list.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (6)
asab/library/providers/zookeeper.py (6)

322-331: Remove blind except and simplify personal candidate building

No need to catch a broad Exception; _current_credentials_id() already safely returns None. Use the new helper and explicit None check.

Apply this diff:

-		cred_id = None
-		try:
-			cred_id = self._current_credentials_id()
-		except Exception:
-			cred_id = None
-		personal_candidate = self._personal_node_path(path, cred_id)
-		if personal_candidate:
+		cred_id = self._current_credentials_id()
+		personal_candidate = self._personal_node_path(path, cred_id)
+		if personal_candidate is not None:
 			candidate_paths.append(personal_candidate)

332-336: Skip tenant candidate when no tenant is set (micro-optimization, clearer intent)

Avoid appending a tenant path that collapses to global when no tenant is present.

Apply this diff:

-		tenant_candidate = self.build_path(path, tenant_specific=True)
-		if tenant_candidate not in candidate_paths:
-			candidate_paths.append(tenant_candidate)
+		if self._current_tenant_id():
+			tenant_candidate = self.build_path(path, tenant_specific=True)
+			if tenant_candidate not in candidate_paths:
+				candidate_paths.append(tenant_candidate)

349-351: Preserve/clear traceback when re-raising (B904)

Chain the RuntimeError with “from None” to make the source clearer.

Apply this diff:

-			raise RuntimeError("Zookeeper library provider is not ready")
+			raise RuntimeError("Zookeeper library provider is not ready") from None

371-389: Use helper for personal path and drop redundant NoNodeError catch

await self.Zookeeper.get_children already returns None when the node doesn’t exist; no exception is raised. Also avoid manual replace("//", "/").

Apply this diff:

-		cred_id = None
-		try:
-			cred_id = self._current_credentials_id()
-		except Exception:
-			cred_id = None
+		cred_id = self._current_credentials_id()
 
-		if cred_id:
-			personal_node_path = "{}/.personal/{}{}".format(self.BasePath, cred_id, path)
-			personal_node_path = personal_node_path.replace("//", "/")
-			try:
-				personal_nodes = await self.Zookeeper.get_children(personal_node_path) or []
-			except kazoo.exceptions.NoNodeError:
-				personal_nodes = []
+		if cred_id:
+			personal_node_path = self._personal_node_path(path, cred_id)
+			personal_nodes = await self.Zookeeper.get_children(personal_node_path) or []
 			personal_items = await self.process_nodes(personal_nodes, path, target="personal")
 		else:
 			personal_items = []

267-281: Narrow exception handling in contextvar helpers

Catch LookupError explicitly; current implementation is fine functionally, but reflect the actual failure modes.

Apply this diff:

-	def _current_tenant_id(self):
-		try:
-			return Tenant.get()
-		except LookupError:
-			return None
+	def _current_tenant_id(self):
+		try:
+			return Tenant.get()
+		except LookupError:
+			return None

And similarly _current_credentials_id() already follows this; no further changes needed.


578-592: Avoid blind excepts in change watcher

Limit to LookupError when fetching credentials; keep the outer try/except for digest computations.

Apply this diff:

-			elif target == "personal":
-				cred_id = None
-				try:
-					cred_id = self._current_credentials_id()
-				except Exception:
-					cred_id = None
+			elif target == "personal":
+				cred_id = self._current_credentials_id()
 				if cred_id:
 					try:
 						await do_check_path(actual_path="/.personal/{}{}".format(cred_id, path))
 					except Exception as e:
 						L.exception(
 							"Failed to process library changes: '{}'".format(e),
 							struct_data={"path": path, "credentials_id": cred_id},
 						)
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 2f5ab56 and 87793c2.

📒 Files selected for processing (1)
  • asab/library/providers/zookeeper.py (8 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
asab/library/providers/zookeeper.py (4)
asab/zookeeper/wrapper.py (4)
  • get (59-77)
  • get_data (94-105)
  • get_children (80-91)
  • exists (49-56)
asab/library/service.py (1)
  • read (203-239)
asab/library/providers/filesystem.py (1)
  • read (67-85)
asab/zookeeper/container.py (2)
  • get_data (252-256)
  • get_children (249-250)
🪛 Ruff (0.13.1)
asab/library/providers/zookeeper.py

299-299: Avoid specifying long messages outside the exception class

(TRY003)


317-317: Avoid specifying long messages outside the exception class

(TRY003)


326-326: Do not catch blind exception: Exception

(BLE001)


347-347: Consider moving this statement to an else block

(TRY300)


375-375: Do not catch blind exception: Exception

(BLE001)


427-427: Do not catch blind exception: Exception

(BLE001)


582-582: Do not catch blind exception: Exception

(BLE001)


587-587: Do not catch blind exception: Exception

(BLE001)

🔇 Additional comments (2)
asab/library/providers/zookeeper.py (2)

417-429: Don’t block the event loop; use async exists and correct size semantics

self.Zookeeper.Client.exists is synchronous; use the async wrapper and return None when size is unknown. Also, exists never raises NoNodeError.

Apply this diff:

-				# Retrieve node size for items only
-				try:
-					if target == "personal":
-						node_path = self._personal_node_path(fname, self._current_credentials_id())
-					else:
-						node_path = self.build_path(fname, tenant_specific=(target == "tenant"))
-					zstat = self.Zookeeper.Client.exists(node_path)
-					size = zstat.dataLength if zstat else 0
-				except kazoo.exceptions.NoNodeError:
-					size = None
-				except Exception as e:
-					L.warning("Failed to retrieve size for node {}: {}".format(fname, e))
-					size = None
+				# Retrieve node size for items only
+				try:
+					if target == "personal":
+						node_path = self._personal_node_path(fname, self._current_credentials_id())
+					else:
+						node_path = self.build_path(fname, tenant_specific=(target == "tenant"))
+					if node_path is None:
+						size = None
+					else:
+						zstat = await self.Zookeeper.exists(node_path)
+						size = zstat.dataLength if zstat else None
+				except kazoo.exceptions.KazooException as e:
+					L.warning("Failed to retrieve size for node {}: {}".format(fname, e))
+					size = None

435-447: Keep LibraryItem.layers as List[int], not strings

Writing "0:global"/"0:tenant"/"0:personal" breaks type guarantees. Use the integer layer only.

Apply this diff:

-			# Assign correct layer label for top layer 0 (presentational only)
-			if self.Layer == 0:
-				if target == "global":
-					layer_label = "0:global"
-				elif target == "tenant":
-					layer_label = "0:tenant"
-				elif target == "personal":
-					layer_label = "0:personal"
-				else:
-					layer_label = "0:{}".format(target)
-			else:
-				layer_label = self.Layer
+			# Keep layers as integers
+			layer_label = self.Layer

If you need to expose scope, prefer a separate metadata field instead of overloading layers.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

🧹 Nitpick comments (5)
asab/library/providers/zookeeper.py (5)

353-356: Preserve exception context (B904): raise from None

Improve error signaling and satisfy tooling.

Apply:

 		except kazoo.exceptions.ConnectionClosedError:
 			L.warning("Zookeeper library provider is not ready")
-			raise RuntimeError("Zookeeper library provider is not ready")
+			raise RuntimeError("Zookeeper library provider is not ready") from None

327-335: Remove redundant broad try/except around credentials lookup

_current_credentials_id() already handles LookupError and returns None; catching Exception is noisy (BLE001).

Apply:

-		cred_id = None
-		try:
-			cred_id = self._current_credentials_id()
-		except Exception:
-			cred_id = None
+		cred_id = self._current_credentials_id()
 		personal_candidate = self._personal_node_path(path, cred_id)
 		if personal_candidate:
 			candidate_paths.append(personal_candidate)

Based on learnings


375-392: Reuse helper and drop unnecessary exception handling for personal nodes

get_children wrapper returns None on NoNodeError; no need for try/except. Also avoid manual path building.

Apply:

-		cred_id = None
-		try:
-			cred_id = self._current_credentials_id()
-		except Exception:
-			cred_id = None
-
-		if cred_id:
-			personal_node_path = "{}/.personal/{}{}".format(self.BasePath, cred_id, path)
-			personal_node_path = personal_node_path.replace("//", "/")
-			try:
-				personal_nodes = await self.Zookeeper.get_children(personal_node_path) or []
-			except kazoo.exceptions.NoNodeError:
-				personal_nodes = []
-			personal_items = await self.process_nodes(personal_nodes, path, target="personal")
-		else:
-			personal_items = []
+		cred_id = self._current_credentials_id()
+		personal_node_path = self._personal_node_path(path, cred_id)
+		if personal_node_path:
+			personal_nodes = await self.Zookeeper.get_children(personal_node_path) or []
+			personal_items = await self.process_nodes(personal_nodes, path, target="personal")
+		else:
+			personal_items = []

Based on learnings


295-302: Avoid blind exception and simplify lookup

The extra try/except is redundant; _current_credentials_id() already returns None when unavailable.

Apply:

-		if cred_id is None:
-			try:
-				cred_id = self._current_credentials_id()
-			except Exception:
-				cred_id = None
+		if cred_id is None:
+			cred_id = self._current_credentials_id()

Based on learnings


412-413: Simplify dot-component detection

Use any(...) for readability.

Apply:

-			startswithdot = functools.reduce(lambda x, y: x or y.startswith('.'), node.split(os.path.sep), False)
+			startswithdot = any(part.startswith('.') for part in node.split(os.path.sep))
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 87793c2 and 334911e.

📒 Files selected for processing (1)
  • asab/library/providers/zookeeper.py (8 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
asab/library/providers/zookeeper.py (2)
asab/zookeeper/wrapper.py (4)
  • get (59-77)
  • get_data (94-105)
  • get_children (80-91)
  • exists (49-56)
asab/zookeeper/container.py (2)
  • get_data (252-256)
  • get_children (249-250)
🪛 Ruff (0.13.1)
asab/library/providers/zookeeper.py

298-298: Do not catch blind exception: Exception

(BLE001)


321-321: Avoid specifying long messages outside the exception class

(TRY003)


330-330: Do not catch blind exception: Exception

(BLE001)


351-351: Consider moving this statement to an else block

(TRY300)


379-379: Do not catch blind exception: Exception

(BLE001)


431-431: Do not catch blind exception: Exception

(BLE001)


586-586: Do not catch blind exception: Exception

(BLE001)


591-591: Do not catch blind exception: Exception

(BLE001)

🔇 Additional comments (3)
asab/library/providers/zookeeper.py (3)

265-266: Remove placeholder comment

This looks like a leftover note inside the class.

Apply:

-	# inside class ZooKeeperLibraryProvider
-

439-451: Fix type violation: layers must be List[int], not strings

LibraryItem.layers is typed as List[int]; writing "0:global"/"0:tenant"/"0:personal" will break consumers.

Apply:

-			# Assign correct layer label for top layer 0 (presentational only)
-			if self.Layer == 0:
-				if target == "global":
-					layer_label = "0:global"
-				elif target == "tenant":
-					layer_label = "0:tenant"
-				elif target == "personal":
-					layer_label = "0:personal"
-				else:
-					layer_label = "0:{}".format(target)
-			else:
-				layer_label = self.Layer
+			# Preserve integer layer semantics; do not encode scope into layers
+			layer_label = self.Layer

If you need to expose scope, add a separate metadata field rather than overloading layers. Based on learnings


421-433: Don’t block the event loop; use async exists and guard None for personal path

This calls the synchronous Kazoo client on the event loop and may pass None when cred_id is absent.

Apply:

-				# Retrieve node size for items only
-				try:
-					if target == "personal":
-						node_path = self._personal_node_path(fname, self._current_credentials_id())
-					else:
-						node_path = self.build_path(fname, tenant_specific=(target == "tenant"))
-					zstat = self.Zookeeper.Client.exists(node_path)
-					size = zstat.dataLength if zstat else 0
-				except kazoo.exceptions.NoNodeError:
-					size = None
-				except Exception as e:
-					L.warning("Failed to retrieve size for node {}: {}".format(fname, e))
-					size = None
+				# Retrieve node size for items only
+				try:
+					if target == "personal":
+						node_path = self._personal_node_path(fname, self._current_credentials_id())
+					else:
+						node_path = self.build_path(fname, tenant_specific=(target == "tenant"))
+					if not node_path:
+						size = None
+					else:
+						zstat = await self.Zookeeper.exists(node_path)
+						size = zstat.dataLength if zstat else None
+				except kazoo.exceptions.KazooException as e:
+					L.warning("Failed to retrieve size for node {}: {}".format(fname, e))
+					size = None

Use the async wrapper (see asab/zookeeper/wrapper.py: exists). Based on learnings

Mithun Shivashankar added 2 commits October 2, 2025 17:09
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 334911e and 7d516c9.

📒 Files selected for processing (1)
  • asab/library/providers/zookeeper.py (8 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
asab/library/providers/zookeeper.py (3)
asab/zookeeper/wrapper.py (4)
  • get (59-77)
  • get_data (94-105)
  • get_children (80-91)
  • exists (49-56)
asab/library/service.py (1)
  • read (203-239)
asab/zookeeper/container.py (2)
  • get_data (252-256)
  • get_children (249-250)
🪛 Ruff (0.13.2)
asab/library/providers/zookeeper.py

316-316: Avoid specifying long messages outside the exception class

(TRY003)


325-325: Do not catch blind exception: Exception

(BLE001)


346-346: Consider moving this statement to an else block

(TRY300)


422-422: Do not catch blind exception: Exception

(BLE001)


578-578: Do not catch blind exception: Exception

(BLE001)

Comment on lines +414 to 424
if target == "personal":
node_path = self._personal_node_path(fname, self._current_credentials_id())
else:
node_path = self.build_path(fname, tenant_specific=(target == "tenant"))
zstat = self.Zookeeper.Client.exists(node_path)
size = zstat.dataLength if zstat else 0
except kazoo.exceptions.NoNodeError:
size = None
except Exception as e:
L.warning("Failed to retrieve size for node {}: {}".format(node_path, e))
L.warning("Failed to retrieve size for node {}: {}".format(fname, e))
size = None
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Avoid blocking the loop when computing item size

self.Zookeeper.Client.exists(...) runs synchronously, so every node blocks the event loop while hitting ZooKeeper. On top of that, a missing node is reported as size 0, which is indistinguishable from empty content. Switch to the async wrapper and surface “missing” as None.

-				try:
-					if target == "personal":
-						node_path = self._personal_node_path(fname, self._current_credentials_id())
-					else:
-						node_path = self.build_path(fname, tenant_specific=(target == "tenant"))
-					zstat = self.Zookeeper.Client.exists(node_path)
-					size = zstat.dataLength if zstat else 0
-				except kazoo.exceptions.NoNodeError:
-					size = None
-				except Exception as e:
-					L.warning("Failed to retrieve size for node {}: {}".format(fname, e))
-					size = None
+				try:
+					if target == "personal":
+						node_path = self._personal_node_path(fname, self._current_credentials_id())
+					else:
+						node_path = self.build_path(fname, tenant_specific=(target == "tenant"))
+					if node_path is None:
+						size = None
+					else:
+						zstat = await self.Zookeeper.exists(node_path)
+						size = zstat.dataLength if zstat else None
+				except kazoo.exceptions.NoNodeError:
+					size = None
+				except kazoo.exceptions.KazooException as e:
+					L.warning("Failed to retrieve size for node {}: {}".format(fname, e))
+					size = None
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if target == "personal":
node_path = self._personal_node_path(fname, self._current_credentials_id())
else:
node_path = self.build_path(fname, tenant_specific=(target == "tenant"))
zstat = self.Zookeeper.Client.exists(node_path)
size = zstat.dataLength if zstat else 0
except kazoo.exceptions.NoNodeError:
size = None
except Exception as e:
L.warning("Failed to retrieve size for node {}: {}".format(node_path, e))
L.warning("Failed to retrieve size for node {}: {}".format(fname, e))
size = None
try:
if target == "personal":
node_path = self._personal_node_path(fname, self._current_credentials_id())
else:
node_path = self.build_path(fname, tenant_specific=(target == "tenant"))
if node_path is None:
size = None
else:
zstat = await self.Zookeeper.exists(node_path)
size = zstat.dataLength if zstat else None
except kazoo.exceptions.NoNodeError:
size = None
except kazoo.exceptions.KazooException as e:
L.warning("Failed to retrieve size for node {}: {}".format(fname, e))
size = None
🧰 Tools
🪛 Ruff (0.13.2)

422-422: Do not catch blind exception: Exception

(BLE001)

🤖 Prompt for AI Agents
In asab/library/providers/zookeeper.py around lines 414 to 424, the code uses
the synchronous self.Zookeeper.Client.exists(...) which blocks the event loop
and treats a missing node the same as an empty node (size 0); replace the sync
call with the async exists wrapper (await the async exists method on the
Zookeeper client), set size = None if the async exists returns no zstat or
raises kazoo.exceptions.NoNodeError, otherwise set size = zstat.dataLength, and
keep the existing generic Exception handling but ensure it does not call the
synchronous API so the loop is non-blocking.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (2)
asab/library/providers/zookeeper.py (2)

407-418: Use async exists wrapper to avoid blocking the event loop.

Line 412 calls the synchronous self.Zookeeper.Client.exists(...), which blocks the event loop. This was flagged in a past review comment.

Apply this diff to use the async wrapper:

 				try:
 					if target == "personal":
 						node_path = self._personal_node_path(fname, self._current_credentials_id())
 					else:
 						node_path = self.build_path(fname, tenant_specific=(target == "tenant"))
-					zstat = self.Zookeeper.Client.exists(node_path)
-					size = zstat.dataLength if zstat else 0
+					if node_path is None:
+						size = None
+					else:
+						zstat = await self.Zookeeper.exists(node_path)
+						size = zstat.dataLength if zstat else None
 				except kazoo.exceptions.NoNodeError:
 					size = None
-				except Exception as e:
+				except kazoo.exceptions.KazooException as e:
 					L.warning("Failed to retrieve size for node {}: {}".format(fname, e))
 					size = None

Based on learnings


424-435: Remove string layer labels; use integer only.

Lines 427-431 assign string values like "0:global", "0:tenant", "0:personal" to layer_label, which is then added to LibraryItem.layers (a List[int]). This breaks the type contract and was flagged as a critical issue in a past review.

Apply this diff:

-			# Assign correct layer label for top layer 0 (presentational only)
-			if self.Layer == 0:
-				if target == "global":
-					layer_label = "0:global"
-				elif target == "tenant":
-					layer_label = "0:tenant"
-				elif target == "personal":
-					layer_label = "0:personal"
-				else:
-					layer_label = "0:{}".format(target)
-			else:
-				layer_label = self.Layer
+			layer_label = self.Layer

If you need to track scope, add a separate metadata field on LibraryItem rather than overloading layers.

Based on learnings

🧹 Nitpick comments (1)
asab/library/providers/zookeeper.py (1)

370-379: Consider deduplicating personal path to prevent edge-case duplicates.

While the code checks tenant_node_path != global_node_path (line 364), there's no similar guard for personal_node_path. If personal_node_path somehow resolves to match tenant_node_path or global_node_path (unlikely but possible in edge cases), duplicate items will be returned.

Apply this diff to add defensive checks:

 		# Process personal nodes for current credentials (if any)
 		cred_id = self._current_credentials_id()
 		personal_node_path = self._personal_node_path(path, cred_id)
-		if personal_node_path:
+		if personal_node_path and personal_node_path not in {global_node_path, tenant_node_path}:
 			personal_nodes = await self.Zookeeper.get_children(personal_node_path) or []
 			personal_items = await self.process_nodes(personal_nodes, path, target="personal")
 		else:
 			personal_items = []
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 7d516c9 and a7f6548.

📒 Files selected for processing (1)
  • asab/library/providers/zookeeper.py (8 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
asab/library/providers/zookeeper.py (3)
asab/zookeeper/wrapper.py (4)
  • get (59-77)
  • get_data (94-105)
  • get_children (80-91)
  • exists (49-56)
asab/library/service.py (1)
  • read (203-239)
asab/zookeeper/container.py (2)
  • get_data (252-256)
  • get_children (249-250)
🪛 Ruff (0.13.2)
asab/library/providers/zookeeper.py

316-316: Avoid specifying long messages outside the exception class

(TRY003)


325-325: Do not catch blind exception: Exception

(BLE001)


346-346: Consider moving this statement to an else block

(TRY300)


416-416: Do not catch blind exception: Exception

(BLE001)


572-572: Do not catch blind exception: Exception

(BLE001)

🔇 Additional comments (4)
asab/library/providers/zookeeper.py (4)

302-350: LGTM! Precedence logic correctly implemented.

The updated read method properly implements personal → tenant → global precedence by:

  • Building candidates in the correct order
  • Deduplicating paths to avoid re-processing the same znode
  • Returning on the first match

This addresses the past critical review comment about precedence bugs.

Note: The broad Exception catch on line 325 is acceptable here as a defensive fallback when accessing context variables.


491-496: LGTM! Personal subscription logic is correct.

The implementation properly:

  • Checks for credentials before subscribing
  • Follows the same pattern as tenant subscriptions
  • Only establishes the watch when cred_id is available

567-576: LGTM! Personal change detection follows established patterns.

The implementation:

  • Correctly checks for cred_id before processing
  • Uses the same exception handling pattern as tenant/global branches
  • Properly structures the actual_path for personal targets

The broad Exception catch is acceptable here as defensive error handling to prevent one subscription failure from breaking others.


581-592: LGTM! Clean implementation mirroring _get_tenants.

The _get_personals helper:

  • Properly enumerates credentials with custom content
  • Filters hidden entries (starting with ".")
  • Gracefully handles missing /.personal directory
  • Follows the same pattern as the existing _get_tenants method

Comment on lines +283 to +300
def _personal_node_path(self, path: str, cred_id: typing.Optional[str] = None) -> typing.Optional[str]:
"""
Resolve the absolute znode path for the personal target.
Args:
path (str): Logical library path (must start with '/').
cred_id (str | None): CredentialsId; if None, it is looked up.
Returns:
str | None: '/<base>/.personal/<CredentialsId><path>' or None if not available.
"""
assert path[:1] == '/'
cred_id = self._current_credentials_id()
if not cred_id:
return None

base = "{}/.personal/{}{}".format(self.BasePath, cred_id, path)
return base.rstrip("/")
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Fix parameter shadowing bug in _personal_node_path.

Line 295 overwrites the cred_id parameter by calling self._current_credentials_id(), making the parameter completely ignored. Callers passing cred_id will have their value discarded.

Apply this diff:

-	def _personal_node_path(self, path: str, cred_id: typing.Optional[str] = None) -> typing.Optional[str]:
-		"""
-		Resolve the absolute znode path for the personal target.
-
-		Args:
-			path (str): Logical library path (must start with '/').
-			cred_id (str | None): CredentialsId; if None, it is looked up.
-
-		Returns:
-			str | None: '/<base>/.personal/<CredentialsId><path>' or None if not available.
-		"""
-		assert path[:1] == '/'
-		cred_id = self._current_credentials_id()
-		if not cred_id:
-			return None
-
-		base = "{}/.personal/{}{}".format(self.BasePath, cred_id, path)
-		return base.rstrip("/")
+	def _personal_node_path(self, path: str, cred_id: typing.Optional[str] = None) -> typing.Optional[str]:
+		"""
+		Resolve the absolute znode path for the personal target.
+
+		Args:
+			path (str): Logical library path (must start with '/').
+			cred_id (str | None): CredentialsId; if None, it is looked up.
+
+		Returns:
+			str | None: '/<base>/.personal/<CredentialsId><path>' or None if not available.
+		"""
+		assert path[:1] == '/'
+		if cred_id is None:
+			cred_id = self._current_credentials_id()
+		if not cred_id:
+			return None
+
+		base = "{}/.personal/{}{}".format(self.BasePath, cred_id, path)
+		return base.rstrip("/")
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def _personal_node_path(self, path: str, cred_id: typing.Optional[str] = None) -> typing.Optional[str]:
"""
Resolve the absolute znode path for the personal target.
Args:
path (str): Logical library path (must start with '/').
cred_id (str | None): CredentialsId; if None, it is looked up.
Returns:
str | None: '/<base>/.personal/<CredentialsId><path>' or None if not available.
"""
assert path[:1] == '/'
cred_id = self._current_credentials_id()
if not cred_id:
return None
base = "{}/.personal/{}{}".format(self.BasePath, cred_id, path)
return base.rstrip("/")
def _personal_node_path(self, path: str, cred_id: typing.Optional[str] = None) -> typing.Optional[str]:
"""
Resolve the absolute znode path for the personal target.
Args:
path (str): Logical library path (must start with '/').
cred_id (str | None): CredentialsId; if None, it is looked up.
Returns:
str | None: '/<base>/.personal/<CredentialsId><path>' or None if not available.
"""
assert path[:1] == '/'
if cred_id is None:
cred_id = self._current_credentials_id()
if not cred_id:
return None
base = "{}/.personal/{}{}".format(self.BasePath, cred_id, path)
return base.rstrip("/")
🤖 Prompt for AI Agents
In asab/library/providers/zookeeper.py around lines 283 to 300, the method
_personal_node_path currently overwrites the cred_id parameter by
unconditionally calling self._current_credentials_id(), which discards any
caller-provided cred_id; change the logic to only call
self._current_credentials_id() when the incoming cred_id is None (i.e., use the
passed-in cred_id if truthy, otherwise set cred_id =
self._current_credentials_id()); keep the existing early return if cred_id is
still falsy, and retain building base =
"{}/.personal/{}{}".format(self.BasePath, cred_id, path) and returning
base.rstrip("/") .

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

1 participant