From f9ac42cf992e756bf850c8a0ed202c9136340a0c Mon Sep 17 00:00:00 2001 From: Jun Tan Date: Fri, 27 Sep 2019 17:10:46 -0400 Subject: [PATCH] add manager class that allows for python to be run on a server, add example tornado server --- packages/perspective/src/js/api/server.js | 2 +- python/perspective/examples/FTSE100.pkl | Bin 0 -> 81290 bytes .../examples/perspective_tornado_client.html | 70 +++++++ .../examples/perspective_tornado_server.py | 97 +++++++++ .../perspective/perspective/table/__init__.py | 3 +- .../perspective/table/_callback_cache.py | 3 + .../perspective/perspective/table/manager.py | 123 ++++++++++++ python/perspective/perspective/table/table.py | 5 +- python/perspective/perspective/table/view.py | 45 +++-- .../perspective/tests/node/test_node.py | 3 + .../perspective/tests/table/test_manager.py | 185 ++++++++++++++++++ .../tests/table/test_table_pandas.py | 15 +- .../perspective/tests/table/test_update.py | 71 ++++--- 13 files changed, 558 insertions(+), 64 deletions(-) create mode 100644 python/perspective/examples/FTSE100.pkl create mode 100644 python/perspective/examples/perspective_tornado_client.html create mode 100644 python/perspective/examples/perspective_tornado_server.py create mode 100644 python/perspective/perspective/table/manager.py create mode 100644 python/perspective/perspective/tests/table/test_manager.py diff --git a/packages/perspective/src/js/api/server.js b/packages/perspective/src/js/api/server.js index 36f9cd9d5e..9a444482e8 100644 --- a/packages/perspective/src/js/api/server.js +++ b/packages/perspective/src/js/api/server.js @@ -180,7 +180,7 @@ export class Server { if (callback) { obj[msg.method](callback, ...msg.args); } else { - console.error(`Callback not found for remote call "${msg}"`); + console.error(`Callback not found for remote call "${JSON.stringify(msg)}"`); } } catch (error) { this.process_error(msg, error); diff --git a/python/perspective/examples/FTSE100.pkl b/python/perspective/examples/FTSE100.pkl new file mode 100644 index 0000000000000000000000000000000000000000..fe5d5fd33cd770753ffd6fe41fc2ec65bff23842 GIT binary patch literal 81290 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 uhDSDUB4 literal 0 HcmV?d00001 diff --git a/python/perspective/examples/perspective_tornado_client.html b/python/perspective/examples/perspective_tornado_client.html new file mode 100644 index 0000000000..5612707f3f --- /dev/null +++ b/python/perspective/examples/perspective_tornado_client.html @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/python/perspective/examples/perspective_tornado_server.py b/python/perspective/examples/perspective_tornado_server.py new file mode 100644 index 0000000000..a98df4c26f --- /dev/null +++ b/python/perspective/examples/perspective_tornado_server.py @@ -0,0 +1,97 @@ +from perspective import Table, PerspectiveManager +from datetime import date, datetime +import json +import tornado.websocket +import tornado.web +import tornado.ioloop +import pandas as pd +import sys +import os +sys.path.insert(1, os.path.join(sys.path[0], '..')) + +''' +Import the Table and PerspectiveManager classes. + +A Perspective `Table` is instantiated either with data or a `schema`. + +The `PerspectiveManager` class handles incoming messages from the client Perspective through a WebSocket connection. +''' + + +class DateTimeEncoder(json.JSONEncoder): + '''Create a custom JSON encoder that allows serialization of datetime and date objects.''' + + def default(self, obj): + if isinstance(obj, datetime): + # Convert to milliseconds - perspective.js expects millisecond timestamps, but python generates them in seconds. + return obj.timestamp() * 1000 + return super(DateTimeEncoder, self).default(obj) + + +class MainHandler(tornado.web.RequestHandler): + + def set_default_headers(self): + self.set_header("Access-Control-Allow-Origin", "*") + self.set_header("Access-Control-Allow-Headers", "x-requested-with") + self.set_header('Access-Control-Allow-Methods', 'POST, GET, OPTIONS') + + def get(self): + self.render("perspective_tornado_client.html") + + +class SimpleWebSocket(tornado.websocket.WebSocketHandler): + + def on_message(self, message): + '''When the websocket receives a message, send it to the `process` method of the `PerspectiveManager` with a reference to the `post` callback.''' + if message == "heartbeat": + return + message = json.loads(message) + MANAGER.process(message, self.post) + + def post(self, message): + '''When `post` is called by `PerspectiveManager`, serialize the data to JSON and send it to the client.''' + message = json.dumps(message, cls=DateTimeEncoder) + self.write_message(message) + + +def make_app(): + return tornado.web.Application([ + (r"/", MainHandler), + # create a websocket endpoint that the client Javascript can access + (r"/websocket", SimpleWebSocket) + ]) + + +if __name__ == "__main__": + '''Create an instance of the `PerspectiveManager`. + + The manager instance tracks tables and views, manages method calls on them, and parses messages from the client. + ''' + MANAGER = PerspectiveManager() + + '''Perspective can load data in row, column, and dataframe format. + + - Row format (list[dict{string:value}]): [{"column_1": 1, "column_2": "abc", "column_3": True, "column_4": datetime.now(), "column_5": date.today()}] + * Each element in the list is a dict, which represents a row of data. + - Column format (dict{string: list}): {"column": [1, 2, 3]} + * The keys of the dict are string column names, and the values are lists that contain the value for each row. + * Numpy arrays can also be used in this format, i.e. {"a": numpy.arange(100)} + - DataFrame (pandas.DataFrame): Perspective has full support for dataframe loading, updating, and editing. + + For this example, we'll load a sample dataframe from a pickle, and provide it to the Table. + ''' + tbl = Table(pd.read_pickle("FTSE100.pkl")) + + '''Once the Table is created, pass it to the manager instance with a name. + + Make sure that the name here is used in the client HTML when we call `open_table`. + + Once the manager has the table, commands from the client will be tracked and applied. + ''' + MANAGER.host_table("data_source_one", tbl) + + # start the Tornado server + app = make_app() + app.listen(8888) + loop = tornado.ioloop.IOLoop.current() + loop.start() diff --git a/python/perspective/perspective/table/__init__.py b/python/perspective/perspective/table/__init__.py index 89c64cee5d..6764b6347f 100644 --- a/python/perspective/perspective/table/__init__.py +++ b/python/perspective/perspective/table/__init__.py @@ -6,5 +6,6 @@ # the Apache License 2.0. The full license can be found in the LICENSE file. # from .table import Table +from .manager import PerspectiveManager -__all__ = ["Table"] +__all__ = ["Table", "PerspectiveManager"] diff --git a/python/perspective/perspective/table/_callback_cache.py b/python/perspective/perspective/table/_callback_cache.py index 6c2a241243..aec1aae1ab 100644 --- a/python/perspective/perspective/table/_callback_cache.py +++ b/python/perspective/perspective/table/_callback_cache.py @@ -26,3 +26,6 @@ def remove_callbacks(self, condition): def get_callbacks(self): return self._callbacks + + def __repr__(self): + return str(self._callbacks) diff --git a/python/perspective/perspective/table/manager.py b/python/perspective/perspective/table/manager.py new file mode 100644 index 0000000000..563a1e1ed8 --- /dev/null +++ b/python/perspective/perspective/table/manager.py @@ -0,0 +1,123 @@ +# ***************************************************************************** +# +# Copyright (c) 2019, the Perspective Authors. +# +# This file is part of the Perspective library, distributed under the terms of +# the Apache License 2.0. The full license can be found in the LICENSE file. +# +from functools import partial +from .table import Table +from ._exception import PerspectiveError + + +class PerspectiveManager(object): + def __init__(self): + self._tables = {} + self._views = {} + self._callback_cache = {} + + def host_table(self, name, table): + '''Given a reference to a `Table`, manage it and allow operations on it to occur through the Manager.''' + self._tables[name] = table + + def process(self, msg, post_callback): + '''Given a message from the client, process it through the Perspective engine. + + Params: + msg (dict) : a message from the client with instructions that map to engine operations + post_callback (callable) : a function that returns data to the client + ''' + if not isinstance(msg, dict): + raise PerspectiveError("Message passed into `process()` should be a dict, i.e. JSON strings should have been deserialized using `json.dumps()`.") + + cmd = msg["cmd"] + + if cmd == "init": + # return empty response + post_callback(self._make_message(msg["id"], None)) + elif cmd == "table": + try: + # create a new Table and track it + data_or_schema = msg["args"][0] + self._tables[msg["name"]] = Table(data_or_schema, msg.get("options", {})) + except IndexError: + self._tables[msg["name"]] = [] + elif cmd == "view": + # create a new view and track it + new_view = self._tables[msg["table_name"]].view(msg.get("config", {})) + self._views[msg["view_name"]] = new_view + elif cmd == "table_method" or cmd == "view_method": + self._process_method_call(msg, post_callback) + + def _process_method_call(self, msg, post_callback): + '''When the client calls a method, validate the instance it calls on and return the result.''' + if msg["cmd"] == "table_method": + table_or_view = self._tables.get(msg["name"], None) + else: + table_or_view = self._views.get(msg["name"], None) + if table_or_view is None: + post_callback(self._make_error_message(msg["id"], "View is not initialized")) + try: + if msg.get("subscribe", False) is True: + self._process_subscribe(msg, table_or_view, post_callback) + else: + args = msg.get("args", []) + if msg["method"] == "schema": + args = [True] # make sure schema returns string types + if msg["method"] != "delete": + result = getattr(table_or_view, msg["method"])(*args) + post_callback(self._make_message(msg["id"], result)) + else: + if msg["cmd"] == "view_method": + del self._views[msg["name"]] + except Exception as error: + print(self._make_error_message(msg["id"], error)) + + def _process_subscribe(self, msg, table_or_view, post_callback): + '''When the client attempts to add or remove a subscription callback, validate and perform the requested operation. + + Params: + msg (dict) : the message from the client + table_or_view {Table|View} : the instance that the subscription will be called on + post_callback (callable) : a method that notifies the client with new data + ''' + try: + callback = None + callback_id = msg.get("callback_id", None) + method = msg.get("method", None) + if method and method[:2] == "on": + # wrap the callback + callback = partial(PerspectiveManager.callback, msg=msg, post_callback=post_callback, self=self) + if callback_id: + self._callback_cache[callback_id] = callback + elif callback_id is not None: + # remove the callback with `callback_id` + del self._callback_cache[callback_id] + if callback is not None: + # call the underlying method on the Table or View + getattr(table_or_view, method)(callback, *msg.get("args", [])) + else: + print("callback not found for remote call {}".format(msg)) + except Exception as error: + print(self._make_error_message(msg["id"], error)) + + def callback(self, **kwargs): + '''Return a message to the client using the `post_callback` method.''' + id = kwargs.get("msg")["id"] + data = kwargs.get("event", None) + post_callback = kwargs.get("post_callback") + post_callback(self._make_message(id, data)) + + def _make_message(self, id, result): + '''Return a serializable message for a successful result.''' + return { + "id": id, + "data": result + } + + def _make_error_message(self, id, error): + '''Return a serializable message for an error result.''' + return { + "id": id, + "error": error + } diff --git a/python/perspective/perspective/table/table.py b/python/perspective/perspective/table/table.py index 7090410d69..a6b45b2d9e 100644 --- a/python/perspective/perspective/table/table.py +++ b/python/perspective/perspective/table/table.py @@ -15,6 +15,7 @@ class Table(object): + # TODO: make config kwargs def __init__(self, data_or_schema, config=None): '''Construct a Table using the provided data or schema and optional configuration dictionary. @@ -187,7 +188,7 @@ def view(self, config=None): config = config or {} if config.get("columns") is None: config["columns"] = self.columns() # TODO: push into C++ - view = View(self, self._callbacks, config) + view = View(self, config) self._views.append(view._name) return view @@ -208,7 +209,7 @@ def delete(self): def _update_callback(self): cache = {} for callback in self._callbacks.get_callbacks(): - callback["callback"](cache) + callback["callback"](cache=cache) def __del__(self): '''Before GC, clean up internal resources to C++ objects''' diff --git a/python/perspective/perspective/table/view.py b/python/perspective/perspective/table/view.py index bdb9b87396..6ba268809c 100644 --- a/python/perspective/perspective/table/view.py +++ b/python/perspective/perspective/table/view.py @@ -6,7 +6,7 @@ # the Apache License 2.0. The full license can be found in the LICENSE file. # import pandas -from functools import wraps +from functools import partial, wraps from random import random from perspective.table.libbinding import make_view_zero, make_view_one, make_view_two from .view_config import ViewConfig @@ -16,7 +16,7 @@ class View(object): - def __init__(self, Table, callbacks, config=None): + def __init__(self, Table, config=None): '''Private constructor for a View object - use the Table.view() method to create Views. A View object represents a specific transform (configuration or pivot, @@ -27,7 +27,7 @@ def __init__(self, Table, callbacks, config=None): View objects are immutable, and will remain in memory and actively process updates until its delete() method is called. ''' - self._name = str(random()) + self._name = "py_" + str(random()) self._table = Table self._config = ViewConfig(config or {}) self._sides = self.sides() @@ -41,7 +41,7 @@ def __init__(self, Table, callbacks, config=None): self._view = make_view_two(self._table._table, self._name, COLUMN_SEPARATOR_STRING, self._config, date_validator) self._column_only = self._view.is_column_only() - self._callbacks = callbacks + self._callbacks = self._table._callbacks def get_config(self): '''Returns the original dictionary config passed in by the user.''' @@ -118,19 +118,7 @@ def on_update(self, callback, mode=None): if not self._view.get_deltas_enabled(): self._view.set_deltas_enabled(True) - # get deltas back from the view, and then call the user-defined callback - def wrapped_callback(cache): - if mode == "cell": - if cache.get("step_delta") is None: - raise NotImplementedError("not implemented get_step_delta") - callback(cache["step_delta"]) - elif mode == "row": - if cache.get("row_delta") is None: - raise NotImplementedError("not implemented get_row_delta") - callback(cache["row_delta"]) - else: - callback() - + wrapped_callback = partial(self._wrapped_on_update_callback, mode=mode, callback=callback) self._callbacks.add_callback({ "name": self._name, "orig_callback": callback, @@ -160,10 +148,15 @@ def on_delete(self, callback): def delete(self): '''Delete the view and clean up associated resources and references.''' self._table._views.pop(self._table._views.index(self._name)) + # remove the callbacks associated with this view self._callbacks.remove_callbacks(lambda cb: cb["name"] != self._name) if hasattr(self, "_delete_callback"): self._delete_callback() + def remove_delete(self): + '''Remove the delete callback associated with this view.''' + delattr(self, "_delete_callback") + def to_records(self, options=None): '''Serialize the view's dataset into a `list` of `dict`s containing each individual row. @@ -267,5 +260,23 @@ def _num_hidden_cols(self): hidden += 1 return hidden + def _wrapped_on_update_callback(self, **kwargs): + '''Provide the user-defined callback function with additional metadata from the view.''' + mode = kwargs["mode"] + cache = kwargs["cache"] + callback = kwargs["callback"] + + if mode == "cell": + if cache.get("step_delta") is None: + raise NotImplementedError("not implemented get_step_delta") + callback(cache["step_delta"]) + elif mode == "row": + if cache.get("row_delta") is None: + raise NotImplementedError("not implemented get_row_delta") + callback(cache["row_delta"]) + else: + callback() + def __del__(self): + '''Make sure callbacks are cleaned up when GC is called.''' self.delete() diff --git a/python/perspective/perspective/tests/node/test_node.py b/python/perspective/perspective/tests/node/test_node.py index c7e4eb7f20..48d6335cdb 100644 --- a/python/perspective/perspective/tests/node/test_node.py +++ b/python/perspective/perspective/tests/node/test_node.py @@ -6,7 +6,10 @@ # the Apache License 2.0. The full license can be found in the LICENSE file. # from perspective.node import Perspective +from pytest import mark + +@mark.skip class TestNode(object): def test_table(self): psp = Perspective() diff --git a/python/perspective/perspective/tests/table/test_manager.py b/python/perspective/perspective/tests/table/test_manager.py new file mode 100644 index 0000000000..93b53eb47a --- /dev/null +++ b/python/perspective/perspective/tests/table/test_manager.py @@ -0,0 +1,185 @@ +# ***************************************************************************** +# +# Copyright (c) 2019, the Perspective Authors. +# +# This file is part of the Perspective library, distributed under the terms of +# the Apache License 2.0. The full license can be found in the LICENSE file. +# +from perspective.table import Table, PerspectiveManager + +data = {"a": [1, 2, 3], "b": ["a", "b", "c"]} + + +class TestPerspectiveManager(object): + + def post(self, msg): + '''boilerplate callback to simulate a client's `post()` method.''' + assert msg["id"] is not None + + def test_manager_host_table(self): + message = {"id": 1, "name": "table1", "cmd": "table_method", "method": "schema", "args": []} + manager = PerspectiveManager() + table = Table(data) + manager.host_table("table1", table) + manager.process(message, self.post) + assert manager._tables["table1"].schema() == { + "a": int, + "b": str + } + + def test_manager_create_table(self): + message = {"id": 1, "name": "table1", "cmd": "table", "args": [data]} + manager = PerspectiveManager() + manager.process(message, self.post) + assert manager._tables["table1"].schema() == { + "a": int, + "b": str + } + + def test_manager_create_indexed_table(self): + message = {"id": 1, "name": "table1", "cmd": "table", "args": [data], "options": {"index": "a"}} + manager = PerspectiveManager() + table = Table(data) + manager.host_table("table1", table) + manager.process(message, self.post) + assert manager._tables["table1"].schema() == { + "a": int, + "b": str + } + + assert manager._tables["table1"]._index == "a" + + def test_manager_create_indexed_table_and_update(self): + message = {"id": 1, "name": "table1", "cmd": "table", "args": [data], "options": {"index": "a"}} + manager = PerspectiveManager() + table = Table(data) + manager.host_table("table1", table) + manager.process(message, self.post) + assert manager._tables["table1"].schema() == { + "a": int, + "b": str + } + assert manager._tables["table1"]._index == "a" + update_message = {"id": 2, "name": "table1", "cmd": "table_method", "method": "update", "args": [{"a": [1, 2, 3], "b": ["str1", "str2", "str3"]}]} + manager.process(update_message, self.post) + assert manager._tables["table1"].view().to_dict() == { + "a": [1, 2, 3], + "b": ["str1", "str2", "str3"] + } + + def test_manager_create_view_zero(self): + message = {"id": 1, "table_name": "table1", "view_name": "view1", "cmd": "view"} + manager = PerspectiveManager() + table = Table(data) + manager.host_table("table1", table) + manager.process(message, self.post) + assert manager._views["view1"].num_rows() == 3 + + def test_manager_create_view_one(self): + message = {"id": 1, "table_name": "table1", "view_name": "view1", "cmd": "view", "config": {"row_pivots": ["a"]}} + manager = PerspectiveManager() + table = Table(data) + manager.host_table("table1", table) + manager.process(message, self.post) + assert manager._views["view1"].to_dict() == { + "__ROW_PATH__": [[], ["1"], ["2"], ["3"]], + "a": [6, 1, 2, 3], + "b": [3, 1, 1, 1] + } + + def test_manager_create_view_two(self): + message = {"id": 1, "table_name": "table1", "view_name": "view1", "cmd": "view", "config": {"row_pivots": ["a"], "column_pivots": ["b"]}} + manager = PerspectiveManager() + table = Table(data) + manager.host_table("table1", table) + manager.process(message, self.post) + assert manager._views["view1"].to_dict() == { + "__ROW_PATH__": [[], ["1"], ["2"], ["3"]], + "a|a": [1, 1, None, None], + "a|b": [1, 1, None, None], + "b|a": [2, None, 2, None], + "b|b": [1, None, 1, None], + "c|a": [3, None, None, 3], + "c|b": [1, None, None, 1] + } + + def test_manager_to_dict(self): + def handle_to_dict(msg): + assert msg["data"] == data + message = {"id": 1, "table_name": "table1", "view_name": "view1", "cmd": "view"} + manager = PerspectiveManager() + table = Table(data) + manager.host_table("table1", table) + manager.process(message, self.post) + to_dict_message = {"id": 2, "name": "view1", "cmd": "view_method", "method": "to_dict"} + manager.process(to_dict_message, handle_to_dict) + + def test_manager_create_view_and_update_table(self): + message = {"id": 1, "table_name": "table1", "view_name": "view1", "cmd": "view"} + manager = PerspectiveManager() + table = Table(data) + manager.host_table("table1", table) + manager.process(message, self.post) + table.update([{"a": 4, "b": "d"}]) + assert manager._views["view1"].num_rows() == 4 + + def test_manager_on_update(self): + sentinel = 0 + + def update_callback(): + nonlocal sentinel + sentinel += 1 + + # create a table and view using manager + make_table = {"id": 1, "name": "table1", "cmd": "table", "args": [data]} + manager = PerspectiveManager() + manager.process(make_table, self.post) + make_view = {"id": 2, "table_name": "table1", "view_name": "view1", "cmd": "view"} + manager.process(make_view, self.post) + + # hook into the created view and pass it the callback + view = manager._views["view1"] + view.on_update(update_callback) + + # call updates + update1 = {"id": 3, "name": "table1", "cmd": "table_method", "method": "update", "args": [{"a": [4], "b": ["d"]}]} + update2 = {"id": 4, "name": "table1", "cmd": "table_method", "method": "update", "args": [{"a": [5], "b": ["e"]}]} + manager.process(update1, self.post) + manager.process(update2, self.post) + assert sentinel == 2 + + def test_manager_remove_update(self): + sentinel = 0 + + def update_callback(): + nonlocal sentinel + sentinel += 1 + + # create a table and view using manager + make_table = {"id": 1, "name": "table1", "cmd": "table", "args": [data]} + manager = PerspectiveManager() + manager.process(make_table, self.post) + make_view = {"id": 2, "table_name": "table1", "view_name": "view1", "cmd": "view"} + manager.process(make_view, self.post) + + # hook into the created view and pass it the callback + view = manager._views["view1"] + view.on_update(update_callback) + view.remove_update(update_callback) + + # call updates + update1 = {"id": 4, "name": "table1", "cmd": "table_method", "method": "update", "args": [{"a": [4], "b": ["d"]}]} + update2 = {"id": 5, "name": "table1", "cmd": "table_method", "method": "update", "args": [{"a": [5], "b": ["e"]}]} + manager.process(update1, self.post) + manager.process(update2, self.post) + assert sentinel == 0 + + def test_manager_delete_view(self): + make_table = {"id": 1, "name": "table1", "cmd": "table", "args": [data]} + manager = PerspectiveManager() + manager.process(make_table, self.post) + make_view = {"id": 2, "table_name": "table1", "view_name": "view1", "cmd": "view"} + manager.process(make_view, self.post) + delete_view = {"id": 3, "name": "view1", "cmd": "view_method", "method": "delete"} + manager.process(delete_view, self.post) + assert len(manager._views) == 0 diff --git a/python/perspective/perspective/tests/table/test_table_pandas.py b/python/perspective/perspective/tests/table/test_table_pandas.py index 823b2d723d..72f8884982 100644 --- a/python/perspective/perspective/tests/table/test_table_pandas.py +++ b/python/perspective/perspective/tests/table/test_table_pandas.py @@ -45,6 +45,7 @@ def superstore(count=10): data.append(dat) return pd.DataFrame(data) + class TestTableNumpy(object): def test_empty_table(self): tbl = Table([]) @@ -60,7 +61,7 @@ def test_table_read_nan_int_col(self): tbl = Table(data) assert tbl.schema() == { "str": str, - "int": float # np.nan is float type - ints convert to floats when filled in + "int": float # np.nan is float type - ints convert to floats when filled in } assert tbl.size() == 3 assert tbl.view().to_dict() == { @@ -73,7 +74,7 @@ def test_table_read_nan_float_col(self): tbl = Table(data) assert tbl.schema() == { "str": str, - "float": float # can only promote to string or float + "float": float # can only promote to string or float } assert tbl.size() == 3 assert tbl.view().to_dict() == { @@ -101,7 +102,7 @@ def test_table_read_nan_date_col(self): tbl = Table(data) assert tbl.schema() == { "str": str, - "date": str # can only promote to string or float + "date": str # can only promote to string or float } assert tbl.size() == 2 assert tbl.view().to_dict() == { @@ -114,7 +115,7 @@ def test_table_read_nan_datetime_col(self): tbl = Table(data) assert tbl.schema() == { "str": str, - "datetime": datetime # can only promote to string or float + "datetime": datetime # can only promote to string or float } assert tbl.size() == 2 assert tbl.view().to_dict() == { @@ -127,7 +128,7 @@ def test_table_read_nan_datetime_as_date_col(self): tbl = Table(data) assert tbl.schema() == { "str": str, - "datetime": datetime # can only promote to string or float + "datetime": datetime # can only promote to string or float } assert tbl.size() == 2 assert tbl.view().to_dict() == { @@ -140,7 +141,7 @@ def test_table_read_nan_datetime_no_seconds(self): tbl = Table(data) assert tbl.schema() == { "str": str, - "datetime": datetime # can only promote to string or float + "datetime": datetime # can only promote to string or float } assert tbl.size() == 2 assert tbl.view().to_dict() == { @@ -153,7 +154,7 @@ def test_table_read_nan_datetime_milliseconds(self): tbl = Table(data) assert tbl.schema() == { "str": str, - "datetime": datetime # can only promote to string or float + "datetime": datetime # can only promote to string or float } assert tbl.size() == 2 assert tbl.view().to_dict() == { diff --git a/python/perspective/perspective/tests/table/test_update.py b/python/perspective/perspective/tests/table/test_update.py index 2aadd23b72..67bca1823d 100644 --- a/python/perspective/perspective/tests/table/test_update.py +++ b/python/perspective/perspective/tests/table/test_update.py @@ -114,15 +114,15 @@ def test_update_np_datetime_partial(self): "a": [datetime(2019, 7, 12, 11, 0)], "b": [1] } - + def test_update_np_nonseq_partial(self): tbl = Table({ - "a": [1, 2, 3, 4], + "a": [1, 2, 3, 4], "b": ["a", "b", "c", "d"] }, {"index": "b"}) tbl.update({ - "a": np.array([5, 6, 7]), + "a": np.array([5, 6, 7]), "b": np.array(["a", "c", "d"], dtype=object)} ) @@ -130,31 +130,31 @@ def test_update_np_nonseq_partial(self): "a": [5, 2, 6, 7], "b": ["a", "b", "c", "d"] } - + def test_update_np_with_none_partial(self): tbl = Table({ - "a": [1, np.nan, 3], + "a": [1, np.nan, 3], "b": ["a", None, "d"] }, {"index": "b"}) tbl.update({ - "a": np.array([4, 5]), + "a": np.array([4, 5]), "b": np.array(["a", "d"], dtype=object) }) assert tbl.view().to_dict() == { "a": [None, 4, 5], - "b": [None, "a", "d"] # pkeys are ordered + "b": [None, "a", "d"] # pkeys are ordered } def test_update_np_unset_partial(self): tbl = Table({ - "a": [1, 2, 3], + "a": [1, 2, 3], "b": ["a", "b", "c"] }, {"index": "b"}) tbl.update({ - "a": np.array([None, None]), + "a": np.array([None, None]), "b": np.array(["a", "c"], dtype=object) }) @@ -162,15 +162,15 @@ def test_update_np_unset_partial(self): "a": [None, 2, None], "b": ["a", "b", "c"] } - + def test_update_np_nan_partial(self): tbl = Table({ - "a": [1, 2, 3], + "a": [1, 2, 3], "b": ["a", "b", "c"] }, {"index": "b"}) tbl.update({ - "a": np.array([None, None]), + "a": np.array([None, None]), "b": np.array(["a", "c"], dtype=object) }) @@ -205,10 +205,10 @@ def test_update_df_datetime(self): assert tbl.view().to_dict() == { "a": [datetime(2019, 7, 11, 11, 0), datetime(2019, 7, 12, 11, 0)] } - + def test_update_df_partial(self): tbl = Table({ - "a": [1, 2, 3, 4], + "a": [1, 2, 3, 4], "b": ["a", "b", "c", "d"] }, {"index": "b"}) @@ -255,10 +255,10 @@ def test_update_df_datetime_partial(self): "a": [datetime(2019, 7, 12, 11, 0)], "b": [1] } - + def test_update_df_nonseq_partial(self): tbl = Table({ - "a": [1, 2, 3, 4], + "a": [1, 2, 3, 4], "b": ["a", "b", "c", "d"] }, {"index": "b"}) @@ -273,10 +273,10 @@ def test_update_df_nonseq_partial(self): "a": [5, 2, 6, 7], "b": ["a", "b", "c", "d"] } - + def test_update_df_with_none_partial(self): tbl = Table({ - "a": [1, np.nan, 3], + "a": [1, np.nan, 3], "b": ["a", None, "d"] }, {"index": "b"}) @@ -289,15 +289,15 @@ def test_update_df_with_none_partial(self): assert tbl.view().to_dict() == { "a": [None, 4, 5], - "b": [None, "a", "d"] # pkeys are ordered + "b": [None, "a", "d"] # pkeys are ordered } def test_update_df_unset_partial(self): tbl = Table({ - "a": [1, 2, 3], + "a": [1, 2, 3], "b": ["a", "b", "c"] }, {"index": "b"}) - + update_data = pd.DataFrame({ "a": [None, None], "b": ["a", "c"] @@ -309,10 +309,10 @@ def test_update_df_unset_partial(self): "a": [None, 2, None], "b": ["a", "b", "c"] } - + def test_update_df_nan_partial(self): tbl = Table({ - "a": [1, 2, 3], + "a": [1, 2, 3], "b": ["a", "b", "c"] }, {"index": "b"}) @@ -333,15 +333,15 @@ def test_update_date(self): tbl = Table({"a": [date(2019, 7, 11)]}) tbl.update([{"a": date(2019, 7, 12)}]) assert tbl.view().to_records() == [ - {"a": datetime(2019, 7, 11, 0, 0)}, + {"a": datetime(2019, 7, 11, 0, 0)}, {"a": datetime(2019, 7, 12, 0, 0)} ] - + def test_update_date_np(self): tbl = Table({"a": [date(2019, 7, 11)]}) tbl.update([{"a": np.datetime64(date(2019, 7, 12))}]) assert tbl.view().to_records() == [ - {"a": datetime(2019, 7, 11, 0, 0)}, + {"a": datetime(2019, 7, 11, 0, 0)}, {"a": datetime(2019, 7, 12, 0, 0)} ] @@ -349,15 +349,15 @@ def test_update_datetime(self): tbl = Table({"a": [datetime(2019, 7, 11, 11, 0)]}) tbl.update([{"a": datetime(2019, 7, 12, 11, 0)}]) assert tbl.view().to_records() == [ - {"a": datetime(2019, 7, 11, 11, 0)}, + {"a": datetime(2019, 7, 11, 11, 0)}, {"a": datetime(2019, 7, 12, 11, 0)} ] - + def test_update_datetime_np(self): tbl = Table({"a": [datetime(2019, 7, 11, 11, 0)]}) tbl.update([{"a": np.datetime64(datetime(2019, 7, 12, 11, 0))}]) assert tbl.view().to_records() == [ - {"a": datetime(2019, 7, 11, 11, 0)}, + {"a": datetime(2019, 7, 11, 11, 0)}, {"a": datetime(2019, 7, 12, 11, 0)} ] @@ -365,7 +365,7 @@ def test_update_datetime_np_ts(self): tbl = Table({"a": [datetime(2019, 7, 11, 11, 0)]}) tbl.update([{"a": np.datetime64("2019-07-12T11:00")}]) assert tbl.view().to_records() == [ - {"a": datetime(2019, 7, 11, 11, 0)}, + {"a": datetime(2019, 7, 11, 11, 0)}, {"a": datetime(2019, 7, 12, 11, 0)} ] @@ -375,7 +375,7 @@ def test_update_date_partial(self): tbl = Table({"a": [date(2019, 7, 11)], "b": [1]}, {"index": "b"}) tbl.update([{"a": date(2019, 7, 12), "b": 1}]) assert tbl.view().to_records() == [{"a": datetime(2019, 7, 12, 0, 0), "b": 1}] - + def test_update_date_np_partial(self): tbl = Table({"a": [date(2019, 7, 11)], "b": [1]}, {"index": "b"}) tbl.update([{"a": np.datetime64(date(2019, 7, 12)), "b": 1}]) @@ -385,7 +385,7 @@ def test_update_datetime_partial(self): tbl = Table({"a": [datetime(2019, 7, 11, 11, 0)], "b": [1]}, {"index": "b"}) tbl.update([{"a": datetime(2019, 7, 12, 11, 0), "b": 1}]) assert tbl.view().to_records() == [{"a": datetime(2019, 7, 12, 11, 0), "b": 1}] - + def test_update_datetime_np_partial(self): tbl = Table({"a": [datetime(2019, 7, 11, 11, 0)], "b": [1]}, {"index": "b"}) tbl.update([{"a": np.datetime64(datetime(2019, 7, 12, 11, 0)), "b": 1}]) @@ -402,7 +402,7 @@ def test_update_date_partial_implicit(self): tbl = Table({"a": [date(2019, 7, 11)]}) tbl.update([{"a": date(2019, 7, 12), "__INDEX__": 0}]) assert tbl.view().to_records() == [{"a": datetime(2019, 7, 12, 0, 0)}] - + def test_update_date_np_partial_implicit(self): tbl = Table({"a": [date(2019, 7, 11)]}) tbl.update([{"a": np.datetime64(date(2019, 7, 12)), "__INDEX__": 0}]) @@ -412,7 +412,7 @@ def test_update_datetime_partial_implicit(self): tbl = Table({"a": [datetime(2019, 7, 11, 11, 0)]}) tbl.update([{"a": datetime(2019, 7, 12, 11, 0), "__INDEX__": 0}]) assert tbl.view().to_records() == [{"a": datetime(2019, 7, 12, 11, 0)}] - + def test_update_datetime_np_partial_implicit(self): tbl = Table({"a": [datetime(2019, 7, 11, 11, 0)]}) tbl.update([{"a": np.datetime64(datetime(2019, 7, 12, 11, 0)), "__INDEX__": 0}]) @@ -423,7 +423,6 @@ def test_update_datetime_np_ts_partial_implicit(self): tbl.update([{"a": np.datetime64("2019-07-12T11:00"), "__INDEX__": 0}]) assert tbl.view().to_records() == [{"a": datetime(2019, 7, 12, 11, 0)}] - # implicit index def test_update_implicit_index(self): @@ -536,7 +535,7 @@ def test_update_implicit_index_with_explicit_set(self): view = tbl.view() tbl.update([{ "__INDEX__": [1], - "a": 1, # should ignore re-specification of pkey + "a": 1, # should ignore re-specification of pkey "b": 3 }]) assert view.to_records() == [{"a": 1, "b": 3}, {"a": 2, "b": 3}]