Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 70 additions & 1 deletion asab/library/providers/filesystem.py
Original file line number Diff line number Diff line change
Expand Up @@ -199,9 +199,11 @@ def build_path(self, path, tenant_specific=False, tenant=None):
node_path = self.BasePath + path if path != '/' else self.BasePath

node_path = node_path.rstrip("/")
if node_path == "":
node_path = "/"

assert '//' not in node_path, "Directory path cannot contain double slashes (//). Example format: /library/Templates/"
assert node_path[0] == '/', "Directory path must start with '/'"
assert node_path.startswith('/'), "Directory path must start with '/'"

return node_path

Expand Down Expand Up @@ -241,6 +243,73 @@ async def read(self, path: str) -> typing.Optional[typing.IO]:
except (FileNotFoundError, IsADirectoryError):
return None

async def read_scoped(self, path: str, scope: str) -> typing.Optional[typing.IO]:
"""
Read from a single scope only.

Args:
path: Library file path (absolute, with extension).
scope: One of "global", "tenant", "personal".
"""
self._validate_read_path(path)

if scope == "global":
try:
global_path = self.build_path(path, tenant_specific=False)
return io.FileIO(global_path, "rb")
except (FileNotFoundError, IsADirectoryError):
return None

if scope == "tenant":
tenant_id = self._current_tenant_id()
if not tenant_id:
return None
try:
tenant_path = self.build_path(path, tenant_specific=True, tenant=tenant_id)
if os.path.isfile(tenant_path):
return io.FileIO(tenant_path, "rb")
return None
except Exception:
return None
Comment thread
coderabbitai[bot] marked this conversation as resolved.

if scope == "personal":
tenant_id = self._current_tenant_id()
cred_id = self._current_credentials_id()
personal_path = self._personal_path(path, tenant_id, cred_id)
if personal_path and os.path.isfile(personal_path):
return io.FileIO(personal_path, "rb")
return None

return None
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

Unknown scope silently returns None—consider raising ValueError.

If an invalid scope value is passed (e.g., a typo like "tenent"), the method silently returns None, making the bug invisible to callers. A ValueError would surface misuse immediately, consistent with how _resolve_fs_path_from_info (line 133) raises on unknown scopes.

Suggested change
-		return None
+		raise ValueError("Unknown scope: {!r}".format(scope))
📝 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
return None
raise ValueError("Unknown scope: {!r}".format(scope))
🤖 Prompt for AI Agents
In `@asab/library/providers/filesystem.py` at line 278, The function that
currently ends with "return None" for unrecognized scope values should instead
raise a ValueError (matching the behavior of _resolve_fs_path_from_info);
replace the silent return with raising ValueError(f"Unknown scope: {scope}") (or
similar) so callers immediately see misuse, and ensure the error message
includes the invalid scope value and context to aid debugging.


async def read_personal_scopes(
self,
path: str,
tenant_id: typing.Optional[str] = None,
) -> typing.List[typing.Tuple[str, str, typing.IO]]:
"""
Read personal variants for all credential scopes in a tenant.
"""
self._validate_read_path(path)

scopes = await self._get_personal_scopes()
results: typing.List[typing.Tuple[str, str, typing.IO]] = []
try:
for scope_tenant, scope_cred in scopes:
if tenant_id is not None and scope_tenant != tenant_id:
continue
personal_path = self._personal_path(path, scope_tenant, scope_cred)
if personal_path and os.path.isfile(personal_path):
results.append((scope_tenant, scope_cred, io.FileIO(personal_path, "rb")))
except Exception:
for _, _, file_handle in results:
try:
file_handle.close()
except Exception:
pass
raise
return results
Comment thread
coderabbitai[bot] marked this conversation as resolved.

async def list(self, path: str) -> list:
"""
List directory items from overlays and concatenate in precedence order:
Expand Down
97 changes: 89 additions & 8 deletions asab/library/providers/zookeeper.py
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,69 @@ async def read(self, path: str) -> typing.Optional[typing.IO]:
L.warning("Zookeeper library provider is not ready")
raise RuntimeError("Zookeeper library provider is not ready") from None

async def read_scoped(self, path: str, scope: str) -> typing.Optional[typing.IO]:
"""
Read from one explicit scope only.

Args:
path: Library file path (absolute).
scope: One of "global", "tenant", "personal".
"""
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:
if scope == "global":
node_path = self.build_path(path, tenant_specific=False)
elif scope == "tenant":
node_path = self.build_path(path, tenant_specific=True)
elif scope == "personal":
tenant_id = self._current_tenant_id()
cred_id = self._current_credentials_id()
node_path = self._personal_node_path(path, tenant_id, cred_id)
if node_path is None:
return None
else:
return None

node_data = await self.Zookeeper.get_data(node_path)
if node_data is None:
return None
return io.BytesIO(initial_bytes=node_data)

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

async def read_personal_scopes(
self,
path: str,
tenant_id: typing.Optional[str] = None,
) -> typing.List[typing.Tuple[str, str, typing.IO]]:
"""
Read personal variants for all credential scopes in a tenant.
Returns tuples: (tenant_id, credentials_id, stream).
"""
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.")

results: typing.List[typing.Tuple[str, str, typing.IO]] = []
scopes = await self._get_personal_scopes(tenant_id=tenant_id)
for scope_tenant, scope_cred in scopes:
node_path = self._personal_node_path(path, scope_tenant, scope_cred)
if node_path is None:
continue
try:
node_data = await self.Zookeeper.get_data(node_path)
except kazoo.exceptions.NoNodeError:
node_data = None
if node_data is None:
continue
results.append((scope_tenant, scope_cred, io.BytesIO(initial_bytes=node_data)))
return results

async def list(self, path: str) -> list:
if self.Zookeeper is None:
L.warning("Zookeeper Client has not been established (yet). Cannot list {}".format(path))
Expand Down Expand Up @@ -451,9 +514,11 @@ def build_path(self, path, tenant_specific=False):
node_path = self.BasePath + '/.tenants/' + tenant + path

node_path = node_path.rstrip("/")
if node_path == "":
node_path = "/"

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 node_path.startswith('/'), "Directory path must start with a forward slash (/). For example: /library/Templates/"

return node_path

Expand Down Expand Up @@ -564,18 +629,34 @@ async def do_check_path(actual_path):
else:
raise ValueError("Unexpected target: {!r}".format((target, path)))

async def _get_personals(self) -> typing.List[str]:
async def _get_personal_scopes(self, tenant_id: typing.Optional[str] = None) -> typing.List[typing.Tuple[str, str]]:
"""
List CredentialsIds that have custom content (i.e., directories) under /.personal.
List (tenant_id, credentials_id) scopes that have personal content.
"""
personal_root = "{}/.personal".format(self.BasePath)
try:
cred_ids = [
c for c in await self.Zookeeper.get_children("{}/.personal".format(self.BasePath)) or []
if not c.startswith(".")
tenants = [
t for t in await self.Zookeeper.get_children(personal_root) or []
if not t.startswith(".")
]
except kazoo.exceptions.NoNodeError:
cred_ids = []
return cred_ids
return []

scopes: typing.List[typing.Tuple[str, str]] = []
for tenant in tenants:
if tenant_id is not None and tenant != tenant_id:
continue
tenant_root = "{}/{}".format(personal_root.rstrip("/"), tenant)
try:
creds = [
c for c in await self.Zookeeper.get_children(tenant_root) or []
if not c.startswith(".")
]
except kazoo.exceptions.NoNodeError:
creds = []
for cred in creds:
scopes.append((tenant, cred))
return scopes


async def _get_tenants(self) -> typing.List[str]:
Expand Down
Loading