diff --git a/docs/hx_client.md b/docs/hx_client.md new file mode 100644 index 0000000..ebd5680 --- /dev/null +++ b/docs/hx_client.md @@ -0,0 +1,3 @@ +# `HxClient` class + +::: phable.hx_client.HxClient \ No newline at end of file diff --git a/phable/__init__.py b/phable/__init__.py index c183b98..cfd5696 100644 --- a/phable/__init__.py +++ b/phable/__init__.py @@ -2,11 +2,11 @@ from phable.client import ( Client, - CommitFlag, HaystackErrorGridResponseError, HaystackHisWriteOpParametersError, HaystackReadOpUnknownRecError, ) +from phable.hx_client import HxClient from phable.kinds import ( NA, Coord, diff --git a/phable/client.py b/phable/client.py index 2573869..f8588e7 100644 --- a/phable/client.py +++ b/phable/client.py @@ -6,18 +6,12 @@ from phable.auth.scram import ScramScheme from phable.http import post -from phable.kinds import DateRange, DateTimeRange, Grid, Marker, Number, Ref +from phable.kinds import DateRange, DateTimeRange, Grid, Number, Ref from phable.parsers.grid import merge_pt_data_to_his_grid_cols if TYPE_CHECKING: - from ssl import SSLContext from datetime import datetime - -from enum import StrEnum, auto - -# ----------------------------------------------------------------------------- -# Module exceptions -# ----------------------------------------------------------------------------- + from ssl import SSLContext @dataclass @@ -45,22 +39,6 @@ class HaystackIncompleteDataResponseError(Exception): help_msg: str -# ----------------------------------------------------------------------------- -# Enums for string inputs -# ----------------------------------------------------------------------------- - - -class CommitFlag(StrEnum): - ADD = auto() - UPDATE = auto() - REMOVE = auto() - - -# ----------------------------------------------------------------------------- -# Client core interface -# ----------------------------------------------------------------------------- - - class Client: """A client interface to a Project Haystack defined server application used for authentication and operations. @@ -504,90 +482,6 @@ def point_write_array(self, id: Ref) -> Grid: return self._call("pointWrite", Grid.to_grid({"id": id})) - # ------------------------------------------------------------------------- - # Haxall ops - # ------------------------------------------------------------------------- - - def eval(self, expr: str) -> Grid: - """Evaluates an Axon string expression. - - **Errors** - - After the request `Grid` is successfully read by the server, the server - may respond with a `Grid` that triggers one of the following errors to be - raised: - - 1. `HaystackErrorGridResponseError` if the operation fails - 2. `HaystackIncompleteDataResponseError` if incomplete data is being returned - - **Additional info** - - See Haxall's Eval operation docs for more details - [here](https://haxall.io/doc/lib-hx/op~eval). - - Parameters: - expr: Axon string expression. - - Returns: - `Grid` with the server's response. - """ - - return self._call("eval", Grid.to_grid({"expr": expr})) - - def commit( - self, - data: list[dict[str, Any]], - flag: CommitFlag, - read_return: bool = False, - ) -> Grid: - """Commits one or more diffs to Haxall's Folio database. - - Commit access requires the API user to have admin permission. - - **Errors** - - After the request `Grid` is successfully read by the server, the server - may respond with a `Grid` that triggers one of the following errors to be - raised: - - 1. `HaystackErrorGridResponseError` if the operation fails - 2. `HaystackIncompleteDataResponseError` if incomplete data is being returned - - **Additional info** - - See Haxall's Commit operation docs for more details - [here](https://haxall.io/doc/lib-hx/op~commit). - - Parameters: - data: Changes to be commited to Haxall's Folio database. - flag: - `add`, `update`, and `remove` options are selected using `CommitFlag`. - `add` adds new records into the database and returns a grid with the - newly minted record identifiers. As a general rule you should not have - an `id` column in your commit grid. However if you wish to predefine - the id of the records, you can specify an `id` column in the commit - grid. `update` modifies existing records, the records must have both - an `id` and `mod` column. `remove` removes existing records, the - records should have only an `id` and `mod` column. - read_return: - If true the response contains the full tag definitions of the - new/updated records. - - Returns: - `Grid` with the server's response. - """ - - meta = {"commit": str(flag)} - - if read_return: - meta = meta | {"readReturn": Marker()} - - return self._call("commit", Grid.to_grid(data, meta)) - - # ------------------------------------------------------------------------- - # base to Haystack and SkySpark ops - # ------------------------------------------------------------------------- - def _call( self, op: str, diff --git a/phable/hx_client.py b/phable/hx_client.py new file mode 100644 index 0000000..e894a8f --- /dev/null +++ b/phable/hx_client.py @@ -0,0 +1,140 @@ +from typing import Any + +from phable.client import Client +from phable.kinds import Grid + + +class HxClient(Client): + """A superset of `Client` with support for Haxall specific operations. + + Learn more about Haxall [here](https://haxall.io/). + """ + + def commit_add(self, recs: dict[str, Any] | list[dict[str, Any]] | Grid) -> Grid: + """Adds one or more new records to the database. + + As a general rule you should not have an `id` column in your commit grid. + However if you wish to predefine the id of the records, you can specify an `id` + column in the commit grid. + + Commit access requires the API user to have admin permission. + + **Errors** + + After the request `Grid` is successfully read by the server, the server + may respond with a `Grid` that triggers one of the following errors to be + raised: + + 1. `HaystackErrorGridResponseError` if the operation fails + 2. `HaystackIncompleteDataResponseError` if incomplete data is being returned + + **Additional info** + + See Haxall's Commit operation docs for more details + [here](https://haxall.io/doc/lib-hx/op~commit). + + Parameters: + recs: Records to be added to the database. + + Returns: + The full tag definitions for each of the newly added records. + """ + meta = {"commit": "add"} + if isinstance(recs, Grid): + recs = recs.rows + return self._call("commit", Grid.to_grid(recs, meta)) + + def commit_remove(self, recs: dict[str, Any] | list[dict[str, Any]] | Grid) -> Grid: + """Removes one or more records from the database. + + Commit access requires the API user to have admin permission. + + **Errors** + + A `HaystackErrorGridResponseError` is raised if any of the recs do not exist on + the server. + + Also, after the request `Grid` is successfully read by the server, the server + may respond with a `Grid` that triggers one of the following errors to be + raised: + + 1. `HaystackErrorGridResponseError` if the operation fails + 2. `HaystackIncompleteDataResponseError` if incomplete data is being returned + + **Additional info** + + See Haxall's Commit operation docs for more details + [here](https://haxall.io/doc/lib-hx/op~commit). + + Parameters: + recs: + Records to be removed from the database. Each record (or row) must at + minimum define `id` and `mod` columns. + + Returns: + An empty `Grid`. + """ + meta = {"commit": "remove"} + if isinstance(recs, Grid): + recs = recs.rows + return self._call("commit", Grid.to_grid(recs, meta)) + + def commit_update(self, recs: dict[str, Any] | list[dict[str, Any]] | Grid) -> Grid: + """Updates one or more existing records within the database. + + Commit access requires the API user to have admin permission. + + **Errors** + + After the request `Grid` is successfully read by the server, the server + may respond with a `Grid` that triggers one of the following errors to be + raised: + + 1. `HaystackErrorGridResponseError` if the operation fails + 2. `HaystackIncompleteDataResponseError` if incomplete data is being returned + + **Additional info** + + See Haxall's Commit operation docs for more details + [here](https://haxall.io/doc/lib-hx/op~commit). + + Parameters: + recs: + Existing records within the database to be updated. Each record (or + row) must at minimum have tags for the rec's existing `id` and `mod` + columns (defined by the server) and the columns being updated (defined + by the client). + + Returns: + The latest full tag definitions for each of the updated records. + """ + meta = {"commit": "update"} + if isinstance(recs, Grid): + recs = recs.rows + return self._call("commit", Grid.to_grid(recs, meta)) + + def eval(self, expr: str) -> Grid: + """Evaluates an Axon string expression. + + **Errors** + + After the request `Grid` is successfully read by the server, the server + may respond with a `Grid` that triggers one of the following errors to be + raised: + + 1. `HaystackErrorGridResponseError` if the operation fails + 2. `HaystackIncompleteDataResponseError` if incomplete data is being returned + + **Additional info** + + See Haxall's Eval operation docs for more details + [here](https://haxall.io/doc/lib-hx/op~eval). + + Parameters: + expr: Axon string expression. + + Returns: + `Grid` with the server's response. + """ + + return self._call("eval", Grid.to_grid({"expr": expr})) diff --git a/tests/test_client.py b/tests/test_client.py index 10c446a..88e0197 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,18 +1,17 @@ from datetime import date, datetime, timedelta -from typing import Callable, Generator +from typing import Any, Callable, Generator from zoneinfo import ZoneInfo import pytest from phable import ( Client, - CommitFlag, DateRange, DateTimeRange, Grid, - HaystackErrorGridResponseError, HaystackHisWriteOpParametersError, HaystackReadOpUnknownRecError, + HxClient, Marker, Number, Ref, @@ -26,8 +25,9 @@ @pytest.fixture(scope="module") -def hc() -> Generator[Client, None, None]: - hc = Client(URI, USERNAME, PASSWORD) +def client() -> Generator[Client, None, None]: + # use HxClient's features to test Client + hc = HxClient(URI, USERNAME, PASSWORD) hc.open() yield hc @@ -36,24 +36,26 @@ def hc() -> Generator[Client, None, None]: @pytest.fixture(scope="module") -def create_kw_pt_fn(hc: Client) -> Generator[Callable[[], Ref], None, None]: +def create_kw_pt_rec_fn( + client: Client, +) -> Generator[Callable[[], dict[str, Any]], None, None]: axon_expr = ( """diff(null, {pytest, point, his, tz: "New_York", writable, """ """kind: "Number"}, {add}).commit""" ) created_pt_ids = [] - def _create_pt(): - response = hc.eval(axon_expr) - writable_kw_pt_id = response.rows[0]["id"] - created_pt_ids.append(writable_kw_pt_id) - return writable_kw_pt_id + def _create_pt_rec(): + response = client.eval(axon_expr) + pt_rec = response.rows[0] + created_pt_ids.append(pt_rec["id"]) + return pt_rec - yield _create_pt + yield _create_pt_rec for pt_id in created_pt_ids: axon_expr = f"readById(@{pt_id}).diff({{trash}}).commit" - hc.eval(axon_expr) + client.eval(axon_expr) # ----------------------------------------------------------------------------- @@ -67,8 +69,8 @@ def test_open(): hc.open() -def test_auth_token(hc: Client): - auth_token = hc._auth_token +def test_auth_token(client: Client): + auth_token = client._auth_token assert len(auth_token) > 40 assert "web-" in auth_token @@ -94,64 +96,64 @@ def test_context_manager(): # ----------------------------------------------------------------------------- -def test_about_op(hc: Client): - assert hc.about()["vendorName"] == "SkyFoundry" +def test_about_op(client: Client): + assert client.about()["vendorName"] == "SkyFoundry" -def test_read_site(hc: Client): - grid = hc.read('site and dis=="Carytown"') +def test_read_site(client: Client): + grid = client.read('site and dis=="Carytown"') assert grid.rows[0]["geoState"] == "VA" -def test_read_UnknownRecError(hc: Client): +def test_read_UnknownRecError(client: Client): with pytest.raises(HaystackReadOpUnknownRecError): - hc.read("hi") + client.read("hi") -def test_read_point(hc: Client): - grid = hc.read( +def test_read_point(client: Client): + grid = client.read( """point and siteRef->dis=="Carytown" and """ """equipRef->siteMeter and power""" ) assert isinstance(grid.rows[0]["power"], Marker) -def test_read_by_id(hc: Client): - id1 = hc.read("point and power and equipRef->siteMeter").rows[0]["id"] - response = hc.read_by_ids(id1) +def test_read_by_id(client: Client): + id1 = client.read("point and power and equipRef->siteMeter").rows[0]["id"] + response = client.read_by_ids(id1) assert response.rows[0]["navName"] == "kW" with pytest.raises(HaystackReadOpUnknownRecError): - response = hc.read_by_ids(Ref("invalid-id")) + response = client.read_by_ids(Ref("invalid-id")) -def test_read_by_ids(hc: Client): - ids = hc.read("point and power and equipRef->siteMeter") +def test_read_by_ids(client: Client): + ids = client.read("point and power and equipRef->siteMeter") id1 = ids.rows[0]["id"] id2 = ids.rows[1]["id"] - response = hc.read_by_ids([id1, id2]) + response = client.read_by_ids([id1, id2]) assert response.rows[0]["tz"] == "New_York" assert response.rows[1]["tz"] == "New_York" with pytest.raises(HaystackReadOpUnknownRecError): - response = hc.read_by_ids( + response = client.read_by_ids( [Ref("p:demo:r:2c26ff0c-d04a5b02"), Ref("invalid-id")] ) with pytest.raises(HaystackReadOpUnknownRecError): - response = hc.read_by_ids( + response = client.read_by_ids( [Ref("invalid-id"), Ref("p:demo:r:2c26ff0c-0b8c49a1")] ) with pytest.raises(HaystackReadOpUnknownRecError): - response = hc.read_by_ids([Ref("invalid-id1"), Ref("invalid-id2")]) + response = client.read_by_ids([Ref("invalid-id1"), Ref("invalid-id2")]) -def test_his_read_by_ids_with_date_range(hc: Client): +def test_his_read_by_ids_with_date_range(client: Client): # find the point id - point_grid = hc.read( + point_grid = client.read( """point and siteRef->dis=="Carytown" and """ """equipRef->siteMeter and power""" ) @@ -159,7 +161,7 @@ def test_his_read_by_ids_with_date_range(hc: Client): # get the his using Date as the range start = date.today() - timedelta(days=7) - his_grid = hc.his_read_by_ids(point_ref, start) + his_grid = client.his_read_by_ids(point_ref, start) # check his_grid cols = [col["name"] for col in his_grid.cols] @@ -170,9 +172,9 @@ def test_his_read_by_ids_with_date_range(hc: Client): assert his_grid.rows[-1][cols[0]].date() == start -def test_his_read_by_ids_with_datetime_range(hc: Client): +def test_his_read_by_ids_with_datetime_range(client: Client): # find the point id - point_grid = hc.read( + point_grid = client.read( """point and siteRef->dis=="Carytown" and """ """equipRef->siteMeter and power""" ) @@ -182,7 +184,7 @@ def test_his_read_by_ids_with_datetime_range(hc: Client): datetime_range = DateTimeRange( datetime(2023, 8, 20, 10, 12, 12, tzinfo=ZoneInfo("America/New_York")) ) - his_grid = hc.his_read_by_ids(point_ref, datetime_range) + his_grid = client.his_read_by_ids(point_ref, datetime_range) # check his_grid cols = [col["name"] for col in his_grid.cols] @@ -193,9 +195,9 @@ def test_his_read_by_ids_with_datetime_range(hc: Client): assert his_grid.rows[-1][cols[0]].date() == date.today() -def test_his_read_by_ids_with_date_slice(hc: Client): +def test_his_read_by_ids_with_date_slice(client: Client): # find the point id - point_grid = hc.read( + point_grid = client.read( """point and siteRef->dis=="Carytown" and """ """equipRef->siteMeter and power""" ) @@ -205,7 +207,7 @@ def test_his_read_by_ids_with_date_slice(hc: Client): start = date.today() - timedelta(days=7) end = date.today() date_range = DateRange(start, end) - his_grid = hc.his_read_by_ids(point_ref, date_range) + his_grid = client.his_read_by_ids(point_ref, date_range) # check his_grid cols = [col["name"] for col in his_grid.cols] @@ -216,9 +218,9 @@ def test_his_read_by_ids_with_date_slice(hc: Client): assert his_grid.rows[-1][cols[0]].date() == end -def test_his_read_by_ids_with_datetime_slice(hc: Client): +def test_his_read_by_ids_with_datetime_slice(client: Client): # find the point id - point_grid = hc.read( + point_grid = client.read( """point and siteRef->dis=="Carytown" and """ """equipRef->siteMeter and power""" ) @@ -230,7 +232,7 @@ def test_his_read_by_ids_with_datetime_slice(hc: Client): datetime_range = DateTimeRange(start, end) - his_grid = hc.his_read_by_ids(point_ref, datetime_range) + his_grid = client.his_read_by_ids(point_ref, datetime_range) # check his_grid cols = [col["name"] for col in his_grid.cols] @@ -241,15 +243,15 @@ def test_his_read_by_ids_with_datetime_slice(hc: Client): assert his_grid.rows[-1][cols[0]].date() == end.date() -def test_batch_his_read_by_ids(hc: Client): - ids = hc.read("point and power and equipRef->siteMeter") +def test_batch_his_read_by_ids(client: Client): + ids = client.read("point and power and equipRef->siteMeter") id1 = ids.rows[0]["id"] id2 = ids.rows[1]["id"] id3 = ids.rows[2]["id"] id4 = ids.rows[3]["id"] ids = [id1, id2, id3, id4] - his_grid = hc.his_read_by_ids(ids, date.today()) + his_grid = client.his_read_by_ids(ids, date.today()) cols = [col["name"] for col in his_grid.cols] assert isinstance(his_grid.rows[0][cols[0]], datetime) @@ -261,9 +263,9 @@ def test_batch_his_read_by_ids(hc: Client): assert his_grid.rows[0][cols[4]].val >= 0 -def test_his_read(hc: Client): - pt_grid = hc.read("power and point and equipRef->siteMeter") - his_grid = hc.his_read(pt_grid, date.today()) +def test_his_read(client: Client): + pt_grid = client.read("power and point and equipRef->siteMeter") + his_grid = client.his_read(pt_grid, date.today()) his_grid_cols = his_grid.cols @@ -279,8 +281,10 @@ def test_his_read(hc: Client): assert his_grid.rows[0]["v0"].val >= 0 -def test_single_his_write_by_ids(create_kw_pt_fn: Callable[[], Ref], hc: Client): - test_pt_id = create_kw_pt_fn() +def test_single_his_write_by_ids( + create_kw_pt_rec_fn: Callable[[], Ref], client: Client +): + test_pt_rec = create_kw_pt_rec_fn() ts_now = datetime.now(ZoneInfo("America/New_York")) rows = [ @@ -295,16 +299,16 @@ def test_single_his_write_by_ids(create_kw_pt_fn: Callable[[], Ref], hc: Client) ] # write the his data to the test pt - hc.his_write_by_ids(test_pt_id, rows) + client.his_write_by_ids(test_pt_rec["id"], rows) range = date.today() - his_grid = hc.his_read_by_ids(test_pt_id, range) + his_grid = client.his_read_by_ids(test_pt_rec["id"], range) assert his_grid.rows[0]["val"] == Number(72.19999694824219) assert his_grid.rows[1]["val"] == Number(76.30000305175781) -def test_single_his_write_by_ids_wrong_his_rows(hc: Client): +def test_single_his_write_by_ids_wrong_his_rows(client: Client): dt1 = datetime.now() his_rows1 = [ {"ts": dt1 - timedelta(minutes=5), "val1": Number(1)}, @@ -312,7 +316,7 @@ def test_single_his_write_by_ids_wrong_his_rows(hc: Client): ] with pytest.raises(HaystackHisWriteOpParametersError): - hc.his_write_by_ids(Ref("abc"), his_rows1) + client.his_write_by_ids(Ref("abc"), his_rows1) dt2 = datetime.now() his_rows2 = [ @@ -321,10 +325,10 @@ def test_single_his_write_by_ids_wrong_his_rows(hc: Client): ] with pytest.raises(HaystackHisWriteOpParametersError): - hc.his_write_by_ids(Ref("abc"), his_rows2) + client.his_write_by_ids(Ref("abc"), his_rows2) -def test_batch_his_write_by_ids_wrong_his_rows(hc: Client): +def test_batch_his_write_by_ids_wrong_his_rows(client: Client): dt1 = datetime.now() his_rows1 = [ {"ts": dt1 - timedelta(minutes=5), "val": Number(1)}, @@ -332,7 +336,7 @@ def test_batch_his_write_by_ids_wrong_his_rows(hc: Client): ] with pytest.raises(HaystackHisWriteOpParametersError): - hc.his_write_by_ids([Ref("abc"), Ref("def")], his_rows1) + client.his_write_by_ids([Ref("abc"), Ref("def")], his_rows1) dt2 = datetime.now() his_rows2 = [ @@ -341,12 +345,12 @@ def test_batch_his_write_by_ids_wrong_his_rows(hc: Client): ] with pytest.raises(HaystackHisWriteOpParametersError): - hc.his_write_by_ids([Ref("abc"), Ref("def")], his_rows2) + client.his_write_by_ids([Ref("abc"), Ref("def")], his_rows2) -def test_batch_his_write_by_ids(create_kw_pt_fn: Callable[[], Ref], hc: Client): - test_pt_id1 = create_kw_pt_fn() - test_pt_id2 = create_kw_pt_fn() +def test_batch_his_write_by_ids(create_kw_pt_rec_fn: Callable[[], Ref], client: Client): + test_pt_rec1 = create_kw_pt_rec_fn() + test_pt_rec2 = create_kw_pt_rec_fn() ts_now = datetime.now(ZoneInfo("America/New_York")) @@ -360,10 +364,10 @@ def test_batch_his_write_by_ids(create_kw_pt_fn: Callable[[], Ref], hc: Client): ] # write the his data to the test pt - hc.his_write_by_ids([test_pt_id1, test_pt_id2], rows) + client.his_write_by_ids([test_pt_rec1["id"], test_pt_rec2["id"]], rows) range = date.today() - his_grid = hc.his_read_by_ids([test_pt_id1, test_pt_id2], range) + his_grid = client.his_read_by_ids([test_pt_rec1["id"], test_pt_rec2["id"]], range) assert his_grid.rows[0]["v0"] == Number(72.19999694824219) assert his_grid.rows[1]["v0"] == Number(76.30000305175781) @@ -371,12 +375,12 @@ def test_batch_his_write_by_ids(create_kw_pt_fn: Callable[[], Ref], hc: Client): assert his_grid.rows[1]["v1"] == Number(72.19999694824219) -def test_client_his_read_with_pandas(hc: Client): +def test_client_his_read_with_pandas(client: Client): # We are importing pandas here only to check that it can be imported. # This can be improved in the future. pytest.importorskip("pandas") - pts = hc.read("point and power and equipRef->siteMeter") - pts_his_df = hc.his_read(pts, date.today()).to_pandas() + pts = client.read("point and power and equipRef->siteMeter") + pts_his_df = client.his_read(pts, date.today()).to_pandas() for col in pts_his_df.attrs["cols"]: if col["name"] == "ts": @@ -397,12 +401,12 @@ def test_client_his_read_with_pandas(hc: Client): assert "hisEnd" in pts_his_df.attrs["meta"].keys() -def test_client_his_read_by_ids_with_pandas(hc: Client): +def test_client_his_read_by_ids_with_pandas(client: Client): # We are importing pandas here only to check that it can be imported. # This can be improved in the future. pytest.importorskip("pandas") - pts = hc.read("point and power and equipRef->siteMeter") - pts_his_df = hc.his_read_by_ids( + pts = client.read("point and power and equipRef->siteMeter") + pts_his_df = client.his_read_by_ids( [pt_row["id"] for pt_row in pts.rows], date.today() ).to_pandas() @@ -424,124 +428,9 @@ def test_client_his_read_by_ids_with_pandas(hc: Client): assert "hisEnd" in pts_his_df.attrs["meta"].keys() -def test_failed_commit(create_kw_pt_fn: Callable[[], Ref], hc: Client): - pt_id = create_kw_pt_fn() - data = [{"id": pt_id, "dis": "TestRec", "testing": Marker(), "pytest": Marker()}] - - with pytest.raises(HaystackErrorGridResponseError): - hc.commit(data, CommitFlag.ADD, False) - - -def test_single_commit(hc: Client): - # create a new rec - data = [{"dis": "TestRec", "testing": Marker(), "pytest": Marker()}] - response: Grid = hc.commit(data, CommitFlag.ADD, False) - - new_rec_id = response.rows[0]["id"] - mod = response.rows[0]["mod"] - - temp_rows = [] - for row in response.rows: - del row["id"] - del row["mod"] - temp_rows.append(row) - - assert temp_rows == data - - assert isinstance(new_rec_id, Ref) - assert isinstance(mod, datetime) - - assert response.rows[0]["dis"] == "TestRec" - assert response.rows[0]["testing"] == Marker() - assert response.rows[0]["pytest"] == Marker() - - # add a new tag called foo to the newly created rec - # this time have the response return the full tag defs - data = [{"id": new_rec_id, "mod": mod, "foo": "new tag"}] - response: Grid = hc.commit(data, CommitFlag.UPDATE, True) - - new_rec_id = response.rows[0]["id"] - mod = response.rows[0]["mod"] - - # verify the response - assert isinstance(new_rec_id, Ref) - assert isinstance(mod, datetime) - assert response.rows[0]["dis"] == "TestRec" - assert response.rows[0]["testing"] == Marker() - assert response.rows[0]["pytest"] == Marker() - assert response.rows[0]["foo"] == "new tag" - - # remove the newly created rec - data = [{"id": new_rec_id, "mod": mod}] - response: Grid = hc.commit(data, CommitFlag.REMOVE) - - # Test invalid Refs - with pytest.raises(HaystackReadOpUnknownRecError): - response = hc.read_by_ids(new_rec_id) - - -def test_batch_commit(hc: Client): - data = [ - {"dis": "TestRec1", "testing1": Marker(), "pytest": Marker()}, - {"dis": "TestRec2", "testing2": Marker(), "pytest": Marker()}, - ] - response: Grid = hc.commit(data, CommitFlag.ADD, False) - - new_rec_id1 = response.rows[0]["id"] - mod1 = response.rows[0]["mod"] - - new_rec_id2 = response.rows[1]["id"] - mod2 = response.rows[1]["mod"] - - temp_rows = [] - for row in response.rows: - del row["id"] - del row["mod"] - temp_rows.append(row) - - assert temp_rows == data - - assert isinstance(new_rec_id1, Ref) - assert isinstance(mod1, datetime) - - assert isinstance(new_rec_id2, Ref) - assert isinstance(mod2, datetime) - - assert response.rows[0]["testing1"] == Marker() - assert response.rows[1]["testing2"] == Marker() - - # add a new tag called foo to the newly created rec - # this time have the response return the full tag defs - data = [ - {"id": new_rec_id1, "mod": mod1, "foo": "new tag1"}, - {"id": new_rec_id2, "mod": mod2, "foo": "new tag2"}, - ] - response: Grid = hc.commit(data, CommitFlag.UPDATE, True) - - new_rec_id1 = response.rows[0]["id"] - mod1 = response.rows[0]["mod"] - new_rec_id2 = response.rows[1]["id"] - mod2 = response.rows[1]["mod"] - - assert response.rows[0]["foo"] == "new tag1" - assert response.rows[1]["foo"] == "new tag2" - - data = [ - {"id": new_rec_id1, "mod": mod1}, - {"id": new_rec_id2, "mod": mod2}, - ] - response: Grid = hc.commit(data, CommitFlag.REMOVE) - - with pytest.raises(HaystackReadOpUnknownRecError): - response = hc.read_by_ids(new_rec_id1) - - with pytest.raises(HaystackReadOpUnknownRecError): - response = hc.read_by_ids(new_rec_id2) - - -def test_point_write_number(create_kw_pt_fn: Callable[[], Ref], hc: Client): - pt_id = create_kw_pt_fn() - response = hc.point_write(pt_id, 1, Number(0, "kW")) +def test_point_write_number(create_kw_pt_rec_fn: Callable[[], Ref], client: Client): + pt_rec = create_kw_pt_rec_fn() + response = client.point_write(pt_rec["id"], 1, Number(0, "kW")) assert isinstance(response, Grid) assert response.meta["ok"] == Marker() @@ -549,16 +438,16 @@ def test_point_write_number(create_kw_pt_fn: Callable[[], Ref], hc: Client): assert response.rows == [] -def test_point_write_number_who(create_kw_pt_fn: Callable[[], Ref], hc: Client): - pt_id = create_kw_pt_fn() - response = hc.point_write(pt_id, 1, Number(50, "kW"), "Phable") +def test_point_write_number_who(create_kw_pt_rec_fn: Callable[[], Ref], client: Client): + pt_rec = create_kw_pt_rec_fn() + response = client.point_write(pt_rec["id"], 1, Number(50, "kW"), "Phable") assert isinstance(response, Grid) assert response.meta["ok"] == Marker() assert response.cols[0]["name"] == "empty" assert response.rows == [] - check_response = hc.point_write_array(pt_id) + check_response = client.point_write_array(pt_rec["id"]) check_row = check_response.rows[0] assert check_row["val"] == Number(50, "kW") @@ -566,16 +455,20 @@ def test_point_write_number_who(create_kw_pt_fn: Callable[[], Ref], hc: Client): assert "expires" not in check_row.keys() -def test_point_write_number_who_dur(create_kw_pt_fn: Callable[[], Ref], hc: Client): - pt_id = create_kw_pt_fn() - response = hc.point_write(pt_id, 8, Number(100, "kW"), "Phable", Number(5, "min")) +def test_point_write_number_who_dur( + create_kw_pt_rec_fn: Callable[[], Ref], client: Client +): + pt_rec = create_kw_pt_rec_fn() + response = client.point_write( + pt_rec["id"], 8, Number(100, "kW"), "Phable", Number(5, "min") + ) assert isinstance(response, Grid) assert response.meta["ok"] == Marker() assert response.cols[0]["name"] == "empty" assert response.rows == [] - check_response = hc.point_write_array(pt_id) + check_response = client.point_write_array(pt_rec["id"]) check_row = check_response.rows[7] expires = check_row["expires"] @@ -585,9 +478,9 @@ def test_point_write_number_who_dur(create_kw_pt_fn: Callable[[], Ref], hc: Clie assert expires.val > 4.0 and expires.val < 5.0 -def test_point_write_null(create_kw_pt_fn: Callable[[], Ref], hc: Client): - pt_id = create_kw_pt_fn() - response = hc.point_write(pt_id, 1) +def test_point_write_null(create_kw_pt_rec_fn: Callable[[], Ref], client: Client): + pt_rec = create_kw_pt_rec_fn() + response = client.point_write(pt_rec["id"], 1) assert isinstance(response, Grid) assert response.meta["ok"] == Marker() @@ -595,9 +488,9 @@ def test_point_write_null(create_kw_pt_fn: Callable[[], Ref], hc: Client): assert response.rows == [] -def test_point_write_array(create_kw_pt_fn: Callable[[], Ref], hc: Client): - pt_id = create_kw_pt_fn() - response = hc.point_write_array(pt_id) +def test_point_write_array(create_kw_pt_rec_fn: Callable[[], Ref], client: Client): + pt_rec = create_kw_pt_rec_fn() + response = client.point_write_array(pt_rec["id"]) assert response.rows[0]["level"] == Number(1) assert response.rows[-1]["level"] == Number(17) diff --git a/tests/test_hx_client.py b/tests/test_hx_client.py new file mode 100644 index 0000000..73f32fd --- /dev/null +++ b/tests/test_hx_client.py @@ -0,0 +1,360 @@ +# flake8: noqa + +from datetime import datetime +from typing import Any, Callable, Generator + +import pytest + +from phable import ( + Grid, + HaystackErrorGridResponseError, + HaystackReadOpUnknownRecError, + HxClient, + Marker, + Number, + Ref, +) + +from .test_client import client, create_kw_pt_rec_fn + + +@pytest.fixture +def sample_recs() -> list[dict[str, Any]]: + data = [ + {"dis": "Rec1...", "testing": Marker()}, + {"dis": "Rec2...", "testing": Marker()}, + ] + return data + + +@pytest.fixture(scope="module") +def create_pt_that_is_not_removed_fn( + client: HxClient, +) -> Generator[Callable[[], dict[str, Any]], None, None]: + axon_expr = ( + """diff(null, {pytest, point, his, tz: "New_York", writable, """ + """kind: "Number"}, {add}).commit""" + ) + + def _create_pt(): + response = client.eval(axon_expr) + writable_kw_pt_rec = response.rows[0] + return writable_kw_pt_rec + + yield _create_pt + + +def test_commit_add_one_rec(client: HxClient, sample_recs: list[dict, Any]): + data = sample_recs[0].copy() + response = client.commit_add(data) + + actual_keys = list(response.rows[0].keys()) + actual_keys.sort() + expected_keys = list(data.keys()) + ["id", "mod"] + expected_keys.sort() + + assert actual_keys == expected_keys + assert response.rows[0]["dis"] == data["dis"] + assert response.rows[0]["testing"] == data["testing"] + assert isinstance(response.rows[0]["id"], Ref) + + client.commit_remove(Grid.to_grid(response.rows)) + + +def test_commit_add_multiple_recs(client: HxClient, sample_recs: list[dict, Any]): + data = sample_recs.copy() + response = client.commit_add(data) + + for row in response.rows: + actual_keys = list(row.keys()) + actual_keys.sort() + expected_keys = ["dis", "testing", "id", "mod"] + expected_keys.sort() + + assert actual_keys == expected_keys + assert row["testing"] == Marker() + + assert response.rows[0]["dis"] == sample_recs[0]["dis"] + assert response.rows[1]["dis"] == sample_recs[1]["dis"] + assert isinstance(response.rows[0]["id"], Ref) + assert isinstance(response.rows[1]["id"], Ref) + + client.commit_remove(Grid.to_grid(response.rows)) + + +def test_commit_add_multiple_recs_as_grid( + client: HxClient, sample_recs: list[dict, Any] +): + data = sample_recs.copy() + response = client.commit_add(Grid.to_grid(data)) + + for row in response.rows: + actual_keys = list(row.keys()) + actual_keys.sort() + expected_keys = ["dis", "testing", "id", "mod"] + expected_keys.sort() + + assert actual_keys == expected_keys + assert row["testing"] == Marker() + + assert response.rows[0]["dis"] == sample_recs[0]["dis"] + assert response.rows[1]["dis"] == sample_recs[1]["dis"] + assert isinstance(response.rows[0]["id"], Ref) + assert isinstance(response.rows[1]["id"], Ref) + + client.commit_remove(Grid.to_grid(response.rows)) + + +def test_commit_add_with_new_id_does_not_raise_error( + client: HxClient, sample_recs: list[dict, Any] +): + data = sample_recs[0].copy() + data["id"] = Ref("2e006480-5896960d") + + response = client.commit_add(data) + assert response.rows[0]["dis"] == sample_recs[0]["dis"] + assert isinstance(response.rows[0]["id"], Ref) + + client.commit_remove(response) + + +def test_commit_add_with_existing_id_raises_error( + create_kw_pt_rec_fn: Callable[[], dict[str, Any]], + client: HxClient, +): + pt_rec = create_kw_pt_rec_fn() + with pytest.raises(HaystackErrorGridResponseError): + client.commit_add(pt_rec) + + +def test_commit_update_with_one_rec( + create_kw_pt_rec_fn: Callable[[], dict[str, Any]], client: HxClient +): + pt_rec = create_kw_pt_rec_fn() + rec_sent = pt_rec.copy() + rec_sent["newTag"] = Marker() + + recs_recv = client.commit_update(rec_sent).rows + + assert_commit_update_recs_sent_and_recv_match([rec_sent], recs_recv) + + +def test_commit_update_with_multiple_recs( + create_kw_pt_rec_fn: Callable[[], dict[str, Any]], client: HxClient +): + pt_rec1 = create_kw_pt_rec_fn() + pt_rec2 = create_kw_pt_rec_fn() + + rec_sent1 = pt_rec1.copy() + rec_sent1["newTag"] = Marker() + + rec_sent2 = pt_rec2.copy() + rec_sent2["newTag"] = Marker() + + recs_sent = [rec_sent1, rec_sent2] + response = client.commit_update(recs_sent) + + assert_commit_update_recs_sent_and_recv_match(recs_sent, response.rows) + + +def test_commit_update_with_multiple_recs_as_grid( + create_kw_pt_rec_fn: Callable[[], dict[str, Any]], client: HxClient +): + pt_rec1 = create_kw_pt_rec_fn() + pt_rec2 = create_kw_pt_rec_fn() + + rec_sent1 = pt_rec1.copy() + rec_sent1["newTag"] = Marker() + + rec_sent2 = pt_rec2.copy() + rec_sent2["newTag"] = Marker() + + recs_sent = Grid.to_grid([rec_sent1, rec_sent2]) + response = client.commit_update(recs_sent) + + assert_commit_update_recs_sent_and_recv_match(recs_sent.rows, response.rows) + + +def assert_commit_update_recs_sent_and_recv_match( + recs_sent: list[dict[str, Any]], recs_recv: list[dict[str, Any]] +) -> None: + for count, rec_sent in enumerate(recs_sent): + rec_recv = recs_recv[count] + for key in rec_recv.keys(): + if key == "mod": + assert rec_recv[key] > rec_sent[key] + elif key == "writeLevel": + assert rec_recv[key] == Number(17) + else: + assert rec_sent[key] == rec_recv[key] + + +def test_commit_update_recs_with_only_id_and_mod_tags_sent( + create_kw_pt_rec_fn: Callable[[], dict[str, Any]], client: HxClient +) -> None: + pt_rec1 = create_kw_pt_rec_fn() + new_pt_rec1 = pt_rec1.copy() + + pt_rec2 = create_kw_pt_rec_fn() + new_pt_rec2 = pt_rec2.copy() + + rec_sent1 = {"id": pt_rec1["id"], "mod": pt_rec1["mod"]} + rec_sent2 = {"id": pt_rec2["id"], "mod": pt_rec2["mod"]} + + recs_sent = [rec_sent1, rec_sent2] + response = client.commit_update(recs_sent) + + assert_commit_update_recs_sent_and_recv_match( + [new_pt_rec1, new_pt_rec2], response.rows + ) + + +def test_commit_update_recs_with_only_id_mod_and_new_tag_sent( + create_kw_pt_rec_fn: Callable[[], dict[str, Any]], client: HxClient +) -> None: + pt_rec1 = create_kw_pt_rec_fn() + new_pt_rec1 = pt_rec1.copy() + new_pt_rec1["newTag"] = Marker() + + pt_rec2 = create_kw_pt_rec_fn() + new_pt_rec2 = pt_rec2.copy() + new_pt_rec2["newTag"] = Marker() + + rec_sent1 = {"id": pt_rec1["id"], "mod": pt_rec1["mod"], "newTag": Marker()} + rec_sent2 = {"id": pt_rec2["id"], "mod": pt_rec2["mod"], "newTag": Marker()} + + recs_sent = [rec_sent1, rec_sent2] + response = client.commit_update(recs_sent) + + assert_commit_update_recs_sent_and_recv_match( + [new_pt_rec1, new_pt_rec2], response.rows + ) + + +def test_commit_remove_with_only_id_rec_tags( + create_kw_pt_rec_fn: Callable[[], Ref], client: HxClient +): + pt_rec1 = create_kw_pt_rec_fn() + pt_rec2 = create_kw_pt_rec_fn() + + with pytest.raises(HaystackErrorGridResponseError): + response = client.commit_remove( + Grid.to_grid( + [ + {"id": pt_rec1["id"]}, + {"id": pt_rec2["id"]}, + ] + ) + ) + + +def test_commit_remove_with_id_and_mod_rec_tags( + create_pt_that_is_not_removed_fn: Callable[[], Ref], client: HxClient +): + pt_rec1 = create_pt_that_is_not_removed_fn() + pt_rec2 = create_pt_that_is_not_removed_fn() + + response = client.commit_remove( + [ + {"id": pt_rec1["id"], "mod": pt_rec1["mod"]}, + {"id": pt_rec2["id"], "mod": pt_rec2["mod"]}, + ] + ) + + # verify it returns an empty Grid + assert response.rows == [] + assert response.cols == [{"name": "empty"}] + assert response.meta == {"ver": "3.0"} + + with pytest.raises(HaystackReadOpUnknownRecError): + client.read_by_ids(pt_rec1["id"]) + + with pytest.raises(HaystackReadOpUnknownRecError): + client.read_by_ids(pt_rec2["id"]) + + +def test_commit_remove_with_id_and_mod_rec_tags_as_grid( + create_pt_that_is_not_removed_fn: Callable[[], Ref], client: HxClient +): + pt_rec1 = create_pt_that_is_not_removed_fn() + pt_rec2 = create_pt_that_is_not_removed_fn() + + response = client.commit_remove( + Grid.to_grid( + [ + {"id": pt_rec1["id"], "mod": pt_rec1["mod"]}, + {"id": pt_rec2["id"], "mod": pt_rec2["mod"]}, + ] + ) + ) + + # verify it returns an empty Grid + assert response.rows == [] + assert response.cols == [{"name": "empty"}] + assert response.meta == {"ver": "3.0"} + + with pytest.raises(HaystackReadOpUnknownRecError): + client.read_by_ids(pt_rec1["id"]) + + with pytest.raises(HaystackReadOpUnknownRecError): + client.read_by_ids(pt_rec2["id"]) + + +def test_commit_remove_one_rec( + create_pt_that_is_not_removed_fn: Callable[[], Ref], client: HxClient +): + pt_rec = create_pt_that_is_not_removed_fn() + + response = client.commit_remove(pt_rec) + + assert response.rows == [] + assert response.cols == [{"name": "empty"}] + assert response.meta == {"ver": "3.0"} + + with pytest.raises(HaystackReadOpUnknownRecError): + client.read_by_ids(pt_rec["id"]) + + +def test_commit_remove_with_all_rec_tags( + create_pt_that_is_not_removed_fn: Callable[[], Ref], client: HxClient +): + pt_rec1 = create_pt_that_is_not_removed_fn() + pt_rec2 = create_pt_that_is_not_removed_fn() + + response = client.commit_remove(Grid.to_grid([pt_rec1, pt_rec2])) + + # verify it returns an empty Grid + assert response.rows == [] + assert response.cols == [{"name": "empty"}] + assert response.meta == {"ver": "3.0"} + + with pytest.raises(HaystackReadOpUnknownRecError): + client.read_by_ids(pt_rec1["id"]) + + with pytest.raises(HaystackReadOpUnknownRecError): + client.read_by_ids(pt_rec2["id"]) + + +def test_commit_remove_with_non_existing_rec( + create_kw_pt_rec_fn: Callable[[], Ref], client: HxClient +): + pt_rec_mod1 = create_kw_pt_rec_fn() + pt_rec_mod2 = create_kw_pt_rec_fn()["mod"] + sent_recs = [ + {"id": pt_rec_mod1["id"], "mod": pt_rec_mod1["mod"]}, + {"id": Ref("dog"), "mod": pt_rec_mod2}, + ] + + with pytest.raises(HaystackErrorGridResponseError): + client.commit_remove(sent_recs) + + +def test_eval(client: HxClient): + axon_expr = ( + """diff(null, {pytest, point, his, tz: "New_York", writable, """ + """kind: "Number"}, {add}).commit""" + ) + + response = client.eval(axon_expr) + assert "id" in response.rows[0].keys() + assert "mod" in response.rows[0].keys()