From ce187c46bef7f46086d01f201f175c33dc7478f6 Mon Sep 17 00:00:00 2001 From: Luke Shaw Date: Thu, 13 Nov 2025 09:29:28 +0100 Subject: [PATCH 1/7] Introduce Dataset inheritance from blosc2.Operand --- caterva2/client.py | 14 +++++++++----- caterva2/tests/test_api.py | 10 ++++++++++ 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/caterva2/client.py b/caterva2/client.py index a923caeb..0b73244e 100644 --- a/caterva2/client.py +++ b/caterva2/client.py @@ -561,7 +561,7 @@ def remove(self): return self.client.remove(self.path) -class Dataset(File): +class Dataset(File, blosc2.Operand): def __init__(self, root, path): """ Represents a dataset within a Blosc2 container. @@ -1253,7 +1253,7 @@ def lazyexpr(self, name, expression, operands=None, compute=False): ---------- name : str Name of the dataset to be created (without extension). - expression : str + expression : str | blosc2.LazyExpr Expression to be evaluated, which must yield a lazy expression. operands : dict Mapping of variables in the expression to their corresponding dataset paths. @@ -1282,10 +1282,14 @@ def lazyexpr(self, name, expression, operands=None, compute=False): """ urlbase, _ = _format_paths(self.urlbase) # Convert possible Path objects in operands to strings so that they can be serialized - if operands is not None: - operands = {k: str(v) for k, v in operands.items()} + if isinstance(expression, blosc2.LazyExpr): + operands = expression.operands + expression = expression.expression else: - operands = {} + if operands is not None: + operands = {k: str(v) for k, v in operands.items()} + else: + operands = {} expr = {"name": name, "expression": expression, "operands": operands, "compute": compute} dataset = api_utils.post( f"{self.urlbase}/api/lazyexpr/", expr, auth_cookie=self.cookie, timeout=self.timeout diff --git a/caterva2/tests/test_api.py b/caterva2/tests/test_api.py index e3642f49..57643607 100644 --- a/caterva2/tests/test_api.py +++ b/caterva2/tests/test_api.py @@ -680,6 +680,16 @@ def test_lazyexpr(auth_client): b = auth_client.fetch(lxpath) np.testing.assert_array_equal(a[:], b[:]) + # test streamlined API + a = auth_client.get(oppt) + ls = blosc2.lazyexpr(f"linspace(0,1, {a.shape[0]})") + mylazyexpr = a + 0 + mylazyexpr += 2 * ls + res = a[:] + 2 * ls[:] + lxpath = auth_client.lazyexpr(lxname, mylazyexpr) + b = auth_client.fetch(lxpath) + np.testing.assert_array_equal(res, b[:]) + # More exercises for the expression evaluation with Blosc2 arrays @pytest.mark.parametrize( From 5be8bde4d1afa12e231725623fca37f0620ddb1d Mon Sep 17 00:00:00 2001 From: lshaw8317 Date: Thu, 13 Nov 2025 13:17:32 +0100 Subject: [PATCH 2/7] Now passes test with latest blosc2 --- caterva2/client.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/caterva2/client.py b/caterva2/client.py index 0b73244e..65ccfe8f 100644 --- a/caterva2/client.py +++ b/caterva2/client.py @@ -1285,11 +1285,10 @@ def lazyexpr(self, name, expression, operands=None, compute=False): if isinstance(expression, blosc2.LazyExpr): operands = expression.operands expression = expression.expression + if operands is not None: + operands = {k: str(v) for k, v in operands.items()} else: - if operands is not None: - operands = {k: str(v) for k, v in operands.items()} - else: - operands = {} + operands = {} expr = {"name": name, "expression": expression, "operands": operands, "compute": compute} dataset = api_utils.post( f"{self.urlbase}/api/lazyexpr/", expr, auth_cookie=self.cookie, timeout=self.timeout From e9cc73181212cf9a76a0be3748da8cff05025318 Mon Sep 17 00:00:00 2001 From: lshaw8317 Date: Thu, 13 Nov 2025 19:32:09 +0100 Subject: [PATCH 3/7] Add upload_lazyexpr --- caterva2/client.py | 47 ++++++++++++++++++++++++++++++++++--- caterva2/models.py | 2 +- caterva2/services/server.py | 28 ++++++++++++++-------- caterva2/tests/test_api.py | 4 ++-- 4 files changed, 65 insertions(+), 16 deletions(-) diff --git a/caterva2/client.py b/caterva2/client.py index 65ccfe8f..9c32c355 100644 --- a/caterva2/client.py +++ b/caterva2/client.py @@ -1282,9 +1282,6 @@ def lazyexpr(self, name, expression, operands=None, compute=False): """ urlbase, _ = _format_paths(self.urlbase) # Convert possible Path objects in operands to strings so that they can be serialized - if isinstance(expression, blosc2.LazyExpr): - operands = expression.operands - expression = expression.expression if operands is not None: operands = {k: str(v) for k, v in operands.items()} else: @@ -1295,6 +1292,50 @@ def lazyexpr(self, name, expression, operands=None, compute=False): ) return pathlib.PurePosixPath(dataset) + def upload_lazyexpr(self, remotepath, expression, compute=False): + """ + Creates a lazy expression dataset. + + A dataset at the specified path will be created or overwritten if already + exists. + + Parameters + ---------- + remotepath : str + Path to save the lazy expression to. + expression : blosc2.LazyExpr + Expression to be evaluated. + operands : dict + Mapping of variables in the expression to their corresponding dataset paths. + compute : bool, optional + If false, generate lazyexpr and do not compute anything. + If true, compute lazy expression on creation and save (full) result. + Default false. + + Returns + ------- + Path + Path of the created dataset. + """ + urlbase, _ = _format_paths(self.urlbase) + if not isinstance(expression, blosc2.LazyExpr): + raise ValueError("argument ``expression`` must be blosc2.LazyExpr instance.") + operands = expression.operands + if operands is not None: + operands = {k: str(v) for k, v in operands.items()} + else: + operands = {} + expr = { + "name": remotepath, + "expression": expression.expression, + "operands": operands, + "compute": compute, + } + dataset = api_utils.post( + f"{self.urlbase}/api/lazyexpr/", expr, auth_cookie=self.cookie, timeout=self.timeout + ) + return pathlib.PurePosixPath(dataset) + def adduser(self, newuser, password=None, superuser=False): """ Adds a user to the server. diff --git a/caterva2/models.py b/caterva2/models.py index 2f3677ff..33ae17e4 100644 --- a/caterva2/models.py +++ b/caterva2/models.py @@ -74,7 +74,7 @@ class LazyArray(pydantic.BaseModel): mtime: datetime.datetime | None -class NewLazyExpr(pydantic.BaseModel): +class Cat2LazyExpr(pydantic.BaseModel): name: str expression: str operands: dict[str, str] diff --git a/caterva2/services/server.py b/caterva2/services/server.py index ebfeca0e..03fd2b5d 100644 --- a/caterva2/services/server.py +++ b/caterva2/services/server.py @@ -665,7 +665,7 @@ def make_expr(name: str, expr: str, operands: dict[str, str], user: db.User, com Parameters ---------- name : str - The name of the dataset to be created (without extension). + The path (or name) of the dataset to be created with (or without) extension. expr : str The expression to be evaluated. It must result in a lazy expression. operands : dictionary of strings mapping to strings @@ -699,14 +699,22 @@ def make_expr(name: str, expr: str, operands: dict[str, str], user: db.User, com # Create the lazy expression dataset arr = blosc2.lazyexpr(expr, var_dict) - if not isinstance(arr, blosc2.LazyExpr): - cname = type(arr).__name__ - raise TypeError(f"Evaluates to {cname} instead of lazy expression") - # Save to filesystem - path = settings.personal / str(user.id) - path.mkdir(exist_ok=True, parents=True) - urlpath = f"{path / name}.b2nd" + # Handle name or path + if len(name.split(".")) > 1: # provided a full path + # Get the absolute path for this user + urlpath = get_writable_path(name, user) + abspath = urlpath.parent + if name.suffix != ".b2nd": + raise ValueError('If path extension provided must be ".b2nd".') + path = name + else: # just provided a name + abspath = settings.personal / str(user.id) + urlpath = f"{abspath / name}.b2nd" + path = f"@personal/{name}.b2nd" + + abspath.mkdir(exist_ok=True, parents=True) + if any(method in expr for method in linalg_funcs): compute = True if compute: @@ -714,12 +722,12 @@ def make_expr(name: str, expr: str, operands: dict[str, str], user: db.User, com else: arr.save(urlpath=urlpath, mode="w") - return f"@personal/{name}.b2nd" + return path @app.post("/api/lazyexpr/") async def lazyexpr( - expr: models.NewLazyExpr, + expr: models.Cat2LazyExpr, user: db.User = Depends(current_active_user), ) -> str: """ diff --git a/caterva2/tests/test_api.py b/caterva2/tests/test_api.py index 57643607..f376708d 100644 --- a/caterva2/tests/test_api.py +++ b/caterva2/tests/test_api.py @@ -682,11 +682,11 @@ def test_lazyexpr(auth_client): # test streamlined API a = auth_client.get(oppt) - ls = blosc2.lazyexpr(f"linspace(0,1, {a.shape[0]})") + ls = blosc2.lazyexpr(f"linspace(0, 1, {a.shape[0]})") mylazyexpr = a + 0 mylazyexpr += 2 * ls res = a[:] + 2 * ls[:] - lxpath = auth_client.lazyexpr(lxname, mylazyexpr) + lxpath = auth_client.upload_lazyexpr("@shared/newexpr.b2nd", mylazyexpr) b = auth_client.fetch(lxpath) np.testing.assert_array_equal(res, b[:]) From a9b534b02150b533c64ff398b874378efc183d05 Mon Sep 17 00:00:00 2001 From: lshaw8317 Date: Fri, 14 Nov 2025 07:50:56 +0100 Subject: [PATCH 4/7] Refactoring to separate upload_lazyexpr and lazyexpr --- caterva2/client.py | 9 ++++-- caterva2/services/server.py | 59 ++++++++++++++++++++++++++++++++----- 2 files changed, 57 insertions(+), 11 deletions(-) diff --git a/caterva2/client.py b/caterva2/client.py index 9c32c355..2dcefa2e 100644 --- a/caterva2/client.py +++ b/caterva2/client.py @@ -1317,7 +1317,7 @@ def upload_lazyexpr(self, remotepath, expression, compute=False): Path Path of the created dataset. """ - urlbase, _ = _format_paths(self.urlbase) + urlbase, remotepath = _format_paths(self.urlbase, remotepath) if not isinstance(expression, blosc2.LazyExpr): raise ValueError("argument ``expression`` must be blosc2.LazyExpr instance.") operands = expression.operands @@ -1326,13 +1326,16 @@ def upload_lazyexpr(self, remotepath, expression, compute=False): else: operands = {} expr = { - "name": remotepath, + "name": None, "expression": expression.expression, "operands": operands, "compute": compute, } dataset = api_utils.post( - f"{self.urlbase}/api/lazyexpr/", expr, auth_cookie=self.cookie, timeout=self.timeout + f"{self.urlbase}/api/upload_lazyexpr/{remotepath}", + expr, + auth_cookie=self.cookie, + timeout=self.timeout, ) return pathlib.PurePosixPath(dataset) diff --git a/caterva2/services/server.py b/caterva2/services/server.py index 03fd2b5d..9f7e0627 100644 --- a/caterva2/services/server.py +++ b/caterva2/services/server.py @@ -654,7 +654,14 @@ async def get_chunk( return responses.StreamingResponse(downloader) -def make_expr(name: str, expr: str, operands: dict[str, str], user: db.User, compute: bool = False) -> str: +def make_expr( + name: str | None, + expr: str, + operands: dict[str, str], + user: db.User, + compute: bool = False, + remotepath: pathlib.Path | None = None, +) -> str: """ Create a lazy expression dataset in personal space. @@ -665,12 +672,14 @@ def make_expr(name: str, expr: str, operands: dict[str, str], user: db.User, com Parameters ---------- name : str - The path (or name) of the dataset to be created with (or without) extension. + The name of the dataset to be created without extension. expr : str The expression to be evaluated. It must result in a lazy expression. operands : dictionary of strings mapping to strings The variables used in the expression and which dataset paths they refer to. + remotepath: pathlib.Path + Where to save the lazy expression. Only valid if name is None. Returns ------- @@ -682,10 +691,9 @@ def make_expr(name: str, expr: str, operands: dict[str, str], user: db.User, com raise srv_utils.raise_unauthorized("Creating lazy expressions requires authentication") # Parse expression - name = name.strip() expr = expr.strip() - if not name or not expr: - raise ValueError("Name or expression should not be empty") + if not expr and not remotepath and not name: + raise ValueError("Name, path or expression should not be empty") vars = blosc2.get_expr_operands(expr) # Open expression datasets @@ -701,14 +709,15 @@ def make_expr(name: str, expr: str, operands: dict[str, str], user: db.User, com arr = blosc2.lazyexpr(expr, var_dict) # Handle name or path - if len(name.split(".")) > 1: # provided a full path + if name is None: # provided a path # Get the absolute path for this user - urlpath = get_writable_path(name, user) + urlpath = get_writable_path(remotepath, user) abspath = urlpath.parent if name.suffix != ".b2nd": raise ValueError('If path extension provided must be ".b2nd".') - path = name + path = str(remotepath) else: # just provided a name + name = name.strip() abspath = settings.personal / str(user.id) urlpath = f"{abspath / name}.b2nd" path = f"@personal/{name}.b2nd" @@ -725,6 +734,40 @@ def make_expr(name: str, expr: str, operands: dict[str, str], user: db.User, com return path +@app.post("/api/upload_lazyexpr/{path:path}") +async def upload_lazyexpr( + path: pathlib.Path, + expr: models.Cat2LazyExpr, + user: db.User = Depends(current_active_user), +) -> str: + """ + Upload a lazy expression dataset in general directory. + + The JSON request body must contain a "name"=None for the dataset to be created, + an "expression" to be evaluated, which must result in + a lazy expression, and an "operands" object which maps variable names used + in the expression to the dataset paths that they refer to. + + Returns + ------- + str + The path of the newly created (or overwritten) dataset. + """ + if expr.name is not None: + raise ValueError("Cannot provide name and path.") + try: + result_path = make_expr(expr.name, expr.expression, expr.operands, user, expr.compute, path) + except (SyntaxError, ValueError, TypeError) as exc: + raise srv_utils.raise_bad_request(f"Invalid name or expression: {exc}") from exc + except KeyError as ke: + detail = f"Expression error: {ke.args[0]} is not in the list of available datasets" + raise srv_utils.raise_bad_request(detail) from ke + except RuntimeError as exc: + raise srv_utils.raise_bad_request(f"Runtime error: {exc}") from exc + + return result_path + + @app.post("/api/lazyexpr/") async def lazyexpr( expr: models.Cat2LazyExpr, From 035e8e7991dfa45beb344a9768f2feb2defe190a Mon Sep 17 00:00:00 2001 From: lshaw8317 Date: Fri, 14 Nov 2025 08:48:07 +0100 Subject: [PATCH 5/7] Bug fixes --- caterva2/models.py | 2 +- caterva2/services/server.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/caterva2/models.py b/caterva2/models.py index 33ae17e4..62087179 100644 --- a/caterva2/models.py +++ b/caterva2/models.py @@ -75,7 +75,7 @@ class LazyArray(pydantic.BaseModel): class Cat2LazyExpr(pydantic.BaseModel): - name: str + name: str | None expression: str operands: dict[str, str] compute: bool diff --git a/caterva2/services/server.py b/caterva2/services/server.py index 9f7e0627..a17eaa98 100644 --- a/caterva2/services/server.py +++ b/caterva2/services/server.py @@ -713,7 +713,7 @@ def make_expr( # Get the absolute path for this user urlpath = get_writable_path(remotepath, user) abspath = urlpath.parent - if name.suffix != ".b2nd": + if urlpath.suffix != ".b2nd": raise ValueError('If path extension provided must be ".b2nd".') path = str(remotepath) else: # just provided a name From d18afe719608ec2207bcb6862e6501e322461723 Mon Sep 17 00:00:00 2001 From: lshaw8317 Date: Fri, 14 Nov 2025 09:11:39 +0100 Subject: [PATCH 6/7] Final cleanup --- caterva2/client.py | 2 +- caterva2/services/server.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/caterva2/client.py b/caterva2/client.py index 2dcefa2e..45835101 100644 --- a/caterva2/client.py +++ b/caterva2/client.py @@ -1253,7 +1253,7 @@ def lazyexpr(self, name, expression, operands=None, compute=False): ---------- name : str Name of the dataset to be created (without extension). - expression : str | blosc2.LazyExpr + expression : str Expression to be evaluated, which must yield a lazy expression. operands : dict Mapping of variables in the expression to their corresponding dataset paths. diff --git a/caterva2/services/server.py b/caterva2/services/server.py index a17eaa98..94996d14 100644 --- a/caterva2/services/server.py +++ b/caterva2/services/server.py @@ -692,8 +692,8 @@ def make_expr( # Parse expression expr = expr.strip() - if not expr and not remotepath and not name: - raise ValueError("Name, path or expression should not be empty") + if not expr or (not remotepath and not name): + raise ValueError("Name/path and expression should not be empty") vars = blosc2.get_expr_operands(expr) # Open expression datasets From 4ff429f43caffc51aac9a28837b4bceab0a1ea9a Mon Sep 17 00:00:00 2001 From: lshaw8317 Date: Fri, 14 Nov 2025 14:58:07 +0100 Subject: [PATCH 7/7] Resolve comments --- caterva2/services/server.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/caterva2/services/server.py b/caterva2/services/server.py index 94996d14..c0701841 100644 --- a/caterva2/services/server.py +++ b/caterva2/services/server.py @@ -693,7 +693,7 @@ def make_expr( # Parse expression expr = expr.strip() if not expr or (not remotepath and not name): - raise ValueError("Name/path and expression should not be empty") + raise ValueError("Name/remotepath and expression should not be empty") vars = blosc2.get_expr_operands(expr) # Open expression datasets @@ -741,7 +741,7 @@ async def upload_lazyexpr( user: db.User = Depends(current_active_user), ) -> str: """ - Upload a lazy expression dataset in general directory. + Upload a lazy expression dataset (to any root). The JSON request body must contain a "name"=None for the dataset to be created, an "expression" to be evaluated, which must result in