chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,403 @@
|
||||
# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
# ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
# ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
# ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
# ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
# ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
__version__ = "4.5.2"
|
||||
__all__ = [
|
||||
"_jupyter_labextension_paths",
|
||||
"Server",
|
||||
"Client",
|
||||
"Table",
|
||||
"View",
|
||||
"PerspectiveError",
|
||||
"ProxySession",
|
||||
"AsyncClient",
|
||||
"AsyncServer",
|
||||
"GenericSQLVirtualServerModel",
|
||||
"VirtualDataSlice",
|
||||
"VirtualServer",
|
||||
"num_cpus",
|
||||
"set_num_cpus",
|
||||
"system_info",
|
||||
]
|
||||
|
||||
__doc__ = """
|
||||
The Python language bindings for [Perspective](https://perspective-dev.github.io), a
|
||||
high performance data-visualization and analytics component for the web browser.
|
||||
|
||||
A simple example which loads an [Apache Arrow](https://arrow.apache.org/) and
|
||||
computes a "Group By" operation, returning a new Arrow.
|
||||
|
||||
```python
|
||||
from perspective import Server
|
||||
|
||||
client = Server().new_local_client()
|
||||
table = client.table(arrow_bytes_data)
|
||||
view = table.view(group_by = ["CounterParty", "Security"])
|
||||
arrow = view.to_arrow()
|
||||
```
|
||||
|
||||
Perspective for Python uses the exact same C++ data engine used by the
|
||||
[WebAssembly version](https://docs.rs/perspective-js/latest/perspective_js/) and
|
||||
[Rust version](https://docs.rs/crate/perspective/latest). The library consists
|
||||
of many of the same abstractions and API as in JavaScript, as well as
|
||||
Python-specific data loading support for [NumPy](https://numpy.org/),
|
||||
[Pandas](https://pandas.pydata.org/) (and
|
||||
[Apache Arrow](https://arrow.apache.org/), as in JavaScript).
|
||||
|
||||
Additionally, `perspective-python` provides a session manager suitable for
|
||||
integration into server systems such as
|
||||
[Tornado websockets](https://www.tornadoweb.org/en/stable/websocket.html),
|
||||
[AIOHTTP](https://docs.aiohttp.org/en/stable/web_quickstart.html#websockets), or
|
||||
[Starlette](https://www.starlette.io/websockets/)/[FastAPI](https://fastapi.tiangolo.com/advanced/websockets/),
|
||||
which allows fully _virtual_ Perspective tables to be interacted with by
|
||||
multiple `<perspective-viewer>` in a web browser. You can also interact with a
|
||||
Perspective table from python clients, and to that end client libraries are
|
||||
implemented for both Tornado and AIOHTTP.
|
||||
|
||||
As `<perspective-viewer>` will only consume the data necessary to render the
|
||||
current screen, this runtime mode allows _ludicrously-sized_ datasets with
|
||||
instant-load after they've been manifest on the server (at the expense of
|
||||
network latency on UI interaction).
|
||||
|
||||
The included `PerspectiveWidget` allows running such a viewer in
|
||||
[JupyterLab](https://jupyterlab.readthedocs.io/en/stable/) in either server or
|
||||
client (via WebAssembly) mode, and the included `PerspectiveTornadoHandler`
|
||||
makes it simple to extend a Tornado server with virtual Perspective support.
|
||||
|
||||
The `perspective` module exports several tools:
|
||||
|
||||
- `Server` the constructor for a new isntance of the Perspective data engine.
|
||||
- The `perspective.widget` module exports `PerspectiveWidget`, the JupyterLab
|
||||
widget for interactive visualization in a notebook cell.
|
||||
- The `perspective.handlers` modules exports web frameworks handlers that
|
||||
interface with a `perspective-client` in JavaScript.
|
||||
- `perspective.handlers.tornado.PerspectiveTornadoHandler` for
|
||||
[Tornado](https://www.tornadoweb.org/)
|
||||
- `perspective.handlers.starlette.PerspectiveStarletteHandler` for
|
||||
[Starlette](https://www.starlette.io/) and
|
||||
[FastAPI](https://fastapi.tiangolo.com)
|
||||
- `perspective.handlers.aiohttp.PerspectiveAIOHTTPHandler` for
|
||||
[AIOHTTP](https://docs.aiohttp.org),
|
||||
|
||||
This user's guide provides an overview of the most common ways to use
|
||||
Perspective in Python: the `Table` API, the JupyterLab widget, and the Tornado
|
||||
handler.
|
||||
|
||||
[More Examples](https://github.com/perspective-dev/perspective/tree/master/examples) are
|
||||
available on GitHub.
|
||||
|
||||
## Installation
|
||||
|
||||
`perspective-python` contains full bindings to the Perspective API, a JupyterLab
|
||||
widget, and a WebSocket handlers for several webserver libraries that allow you
|
||||
to host Perspective using server-side Python.
|
||||
|
||||
`perspective-python` can be installed from [PyPI](https://pypi.org) via `pip`:
|
||||
|
||||
```bash
|
||||
pip install perspective-python
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
A `Table` can be created from a dataset or a schema, the specifics of which are
|
||||
[discussed](#loading-data-with-table) in the JavaScript section of the user's
|
||||
guide. In Python, however, Perspective supports additional data types that are
|
||||
commonly used when processing data:
|
||||
|
||||
- `pandas.DataFrame`
|
||||
- `polars.DataFrame`
|
||||
- `bytes` (encoding an Apache Arrow)
|
||||
- `objects` (either extracting a repr or via reference)
|
||||
- `str` (encoding as a CSV)
|
||||
|
||||
A `Table` is created in a similar fashion to its JavaScript equivalent:
|
||||
|
||||
```python
|
||||
from datetime import date, datetime
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import perspective
|
||||
|
||||
data = pd.DataFrame({
|
||||
"int": np.arange(100),
|
||||
"float": [i * 1.5 for i in range(100)],
|
||||
"bool": [True for i in range(100)],
|
||||
"date": [date.today() for i in range(100)],
|
||||
"datetime": [datetime.now() for i in range(100)],
|
||||
"string": [str(i) for i in range(100)]
|
||||
})
|
||||
|
||||
table = perspective.table(data, index="float")
|
||||
```
|
||||
|
||||
Likewise, a `View` can be created via the `view()` method:
|
||||
|
||||
```python
|
||||
view = table.view(group_by=["float"], filter=[["bool", "==", True]])
|
||||
column_data = view.to_columns()
|
||||
row_data = view.to_json()
|
||||
```
|
||||
|
||||
#### Pandas and Polars Support
|
||||
|
||||
Perspective's `Table` can be constructed from `pandas.DataFrame` and
|
||||
`polars.DataFrame` objects. Internally, this just uses
|
||||
[`pyarrow::from_pandas`](https://arrow.apache.org/docs/python/pandas.html),
|
||||
which dictates behavior of this feature including type support.
|
||||
|
||||
If the dataframe does not have an index set, an integer-typed column named
|
||||
`"index"` is created. If you want to preserve the indexing behavior of the
|
||||
dataframe passed into Perspective, simply create the `Table` with
|
||||
`index="index"` as a keyword argument. This tells Perspective to once again
|
||||
treat the index as a primary key:
|
||||
|
||||
```python
|
||||
data.set_index("datetime")
|
||||
table = perspective.table(data, index="index")
|
||||
```
|
||||
|
||||
#### Time Zone Handling
|
||||
|
||||
When parsing `"datetime"` strings, times are assumed _local time_ unless an
|
||||
explicit timezone offset is parsed. All `"datetime"` columns (regardless of
|
||||
input time zone) are _output_ to the user as `datetime.datetime` objects in
|
||||
_local time_ according to the Python runtime.
|
||||
|
||||
This behavior is consistent with Perspective's behavior in JavaScript. For more
|
||||
details, see this in-depth
|
||||
[explanation](https://github.com/perspective-dev/perspective/pull/867) of
|
||||
`perspective-python` semantics around time zone handling.
|
||||
|
||||
#### Callbacks and Events
|
||||
|
||||
`perspective.Table` allows for `on_update` and `on_delete` callbacks to be
|
||||
set—simply call `on_update` or `on_delete` with a reference to a function or a
|
||||
lambda without any parameters:
|
||||
|
||||
```python
|
||||
def update_callback():
|
||||
print("Updated!")
|
||||
|
||||
# set the update callback
|
||||
on_update_id = view.on_update(update_callback)
|
||||
|
||||
|
||||
def delete_callback():
|
||||
print("Deleted!")
|
||||
|
||||
# set the delete callback
|
||||
on_delete_id = view.on_delete(delete_callback)
|
||||
|
||||
# set a lambda as a callback
|
||||
view.on_delete(lambda: print("Deleted x2!"))
|
||||
```
|
||||
|
||||
If the callback is a named reference to a function, it can be removed with
|
||||
`remove_update` or `remove_delete`:
|
||||
|
||||
```python
|
||||
view.remove_update(on_update_id)
|
||||
view.remove_delete(on_delete_id)
|
||||
```
|
||||
|
||||
Callbacks defined with a lambda function cannot be removed, as lambda functions
|
||||
have no identifier.
|
||||
|
||||
### Hosting `Table` and `View` instances
|
||||
|
||||
`Server` "hosts" all `perspective.Table` and `perspective.View` instances
|
||||
created by its connected `Client`s. Hosted tables/views can have their methods
|
||||
called from other sources than the Python server, i.e. by a `perspective-viewer`
|
||||
running in a JavaScript client over the network, interfacing with
|
||||
`perspective-python` through the websocket API.
|
||||
|
||||
The server has full control of all hosted `Table` and `View` instances, and can
|
||||
call any public API method on hosted instances. This makes it extremely easy to
|
||||
stream data to a hosted `Table` using `.update()`:
|
||||
|
||||
```python
|
||||
server = perspective.Server()
|
||||
client = server.new_local_client()
|
||||
table = client.table(data, name="data_source")
|
||||
|
||||
for i in range(10):
|
||||
# updates continue to propagate automatically
|
||||
table.update(new_data)
|
||||
```
|
||||
|
||||
The `name` provided is important, as it enables Perspective in JavaScript to
|
||||
look up a `Table` and get a handle to it over the network. Otherwise, `name`
|
||||
will be assigned randomlu and the `Client` must look this up with
|
||||
`CLient.get_hosted_table_names()`
|
||||
|
||||
### Client/Server Replicated Mode
|
||||
|
||||
Using Tornado and
|
||||
[`PerspectiveTornadoHandler`](python.md#perspectivetornadohandler), as well as
|
||||
`Perspective`'s JavaScript library, we can set up "distributed" Perspective
|
||||
instances that allows multiple browser `perspective-viewer` clients to read from
|
||||
a common `perspective-python` server, as in the
|
||||
[Tornado Example Project](https://github.com/perspective-dev/perspective/tree/master/examples/python-tornado).
|
||||
|
||||
This architecture works by maintaining two `Tables`—one on the server, and one
|
||||
on the client that mirrors the server's `Table` automatically using `on_update`.
|
||||
All updates to the table on the server are automatically applied to each client,
|
||||
which makes this architecture a natural fit for streaming dashboards and other
|
||||
distributed use-cases. In conjunction with [multithreading](#multi-threading),
|
||||
distributed Perspective offers consistently high performance over large numbers
|
||||
of clients and large datasets.
|
||||
|
||||
_*server.py*_
|
||||
|
||||
```python
|
||||
from perspective import Server
|
||||
from perspective.hadnlers.tornado import PerspectiveTornadoHandler
|
||||
|
||||
# Create an instance of Server, and host a Table
|
||||
SERVER = Server()
|
||||
CLIENT = SERVER.new_local_client()
|
||||
|
||||
# The Table is exposed at `localhost:8888/websocket` with the name `data_source`
|
||||
client.table(data, name = "data_source")
|
||||
|
||||
app = tornado.web.Application([
|
||||
# create a websocket endpoint that the client JavaScript can access
|
||||
(r"/websocket", PerspectiveTornadoHandler, {"perspective_server": SERVER})
|
||||
])
|
||||
|
||||
# Start the Tornado server
|
||||
app.listen(8888)
|
||||
loop = tornado.ioloop.IOLoop.current()
|
||||
loop.start()
|
||||
```
|
||||
|
||||
Instead of calling `load(server_table)`, create a `View` using `server_table`
|
||||
and pass that into `viewer.load()`. This will automatically register an
|
||||
`on_update` callback that synchronizes state between the server and the client.
|
||||
|
||||
_*index.html*_
|
||||
|
||||
```html
|
||||
<perspective-viewer id="viewer" editable></perspective-viewer>
|
||||
|
||||
<script type="module">
|
||||
// Create a client that expects a Perspective server
|
||||
// to accept connections at the specified URL.
|
||||
const websocket = await perspective.websocket(
|
||||
"ws://localhost:8888/websocket"
|
||||
);
|
||||
|
||||
// Get a handle to the Table on the server
|
||||
const server_table = await websocket.open_table("data_source_one");
|
||||
|
||||
// Create a new view
|
||||
const server_view = await table.view();
|
||||
|
||||
// Create a Table on the client using `perspective.worker()`
|
||||
const worker = await perspective.worker();
|
||||
const client_table = await worker.table(view);
|
||||
|
||||
// Load the client table in the `<perspective-viewer>`.
|
||||
document.getElementById("viewer").load(client_table);
|
||||
</script>
|
||||
```
|
||||
|
||||
For a more complex example that offers distributed editing of the server
|
||||
dataset, see
|
||||
[client_server_editing.html](https://github.com/perspective-dev/perspective/blob/master/examples/python-tornado/client_server_editing.html).
|
||||
|
||||
We also provide examples for Starlette/FastAPI and AIOHTTP:
|
||||
|
||||
- [Starlette Example Project](https://github.com/perspective-dev/perspective/tree/master/examples/python-starlette).
|
||||
- [AIOHTTP Example Project](https://github.com/perspective-dev/perspective/tree/master/examples/python-aiohttp).
|
||||
|
||||
### Server-only Mode
|
||||
|
||||
The server setup is identical to [Distributed Mode](#distributed-mode) above,
|
||||
but instead of creating a view, the client calls `load(server_table)`: In
|
||||
Python, use `Server` and `PerspectiveTornadoHandler` to create a websocket
|
||||
server that exposes a `Table`. In this example, `table` is a proxy for the
|
||||
`Table` we created on the server. All API methods are available on _proxies_,
|
||||
the.g.us calling `view()`, `schema()`, `update()` on `table` will pass those
|
||||
operations to the Python `Table`, execute the commands, and return the result
|
||||
back to Javascript.
|
||||
|
||||
```html
|
||||
<perspective-viewer id="viewer" editable></perspective-viewer>
|
||||
```
|
||||
|
||||
```javascript
|
||||
const websocket = perspective.websocket("ws://localhost:8888/websocket");
|
||||
const table = websocket.open_table("data_source");
|
||||
document.getElementById("viewer").load(table);
|
||||
```
|
||||
|
||||
"""
|
||||
|
||||
|
||||
import functools
|
||||
|
||||
from .perspective import (
|
||||
Client,
|
||||
PerspectiveError,
|
||||
ProxySession,
|
||||
Server,
|
||||
AsyncServer,
|
||||
AsyncClient,
|
||||
VirtualServer,
|
||||
VirtualDataSlice,
|
||||
GenericSQLVirtualServerModel,
|
||||
# NOTE: these are classes without constructors,
|
||||
# so we import them just for type hinting
|
||||
Table, # noqa: F401
|
||||
View, # noqa: F401
|
||||
num_cpus,
|
||||
set_num_cpus,
|
||||
)
|
||||
|
||||
|
||||
GLOBAL_SERVER = Server()
|
||||
GLOBAL_CLIENT = GLOBAL_SERVER.new_local_client()
|
||||
|
||||
|
||||
@functools.wraps(Client.table)
|
||||
def table(*args, **kwargs):
|
||||
return GLOBAL_CLIENT.table(*args, **kwargs)
|
||||
|
||||
|
||||
@functools.wraps(Client.open_table)
|
||||
def open_table(*args, **kwargs):
|
||||
return GLOBAL_CLIENT.table(*args, **kwargs)
|
||||
|
||||
|
||||
@functools.wraps(Client.get_hosted_table_names)
|
||||
def get_hosted_table_names(*args, **kwargs):
|
||||
return GLOBAL_CLIENT.get_hosted_table_names(*args, **kwargs)
|
||||
|
||||
|
||||
@functools.wraps(Client.join)
|
||||
def join(*args, **kwargs):
|
||||
return GLOBAL_CLIENT.join(*args, **kwargs)
|
||||
|
||||
|
||||
@functools.wraps(Client.system_info)
|
||||
def system_info(*args, **kwargs):
|
||||
return GLOBAL_CLIENT.system_info(*args, **kwargs)
|
||||
|
||||
|
||||
def _jupyter_labextension_paths():
|
||||
"""
|
||||
Read by `jupyter labextension develop`
|
||||
@private
|
||||
"""
|
||||
return [{"src": "labextension", "dest": "@perspective-dev/jupyterlab"}]
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"load_extensions": {
|
||||
"@perspective-dev/jupyterlab/extension": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
# ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
# ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
# ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
# ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
# ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
@@ -0,0 +1,69 @@
|
||||
# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
# ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
# ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
# ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
# ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
# ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
from aiohttp import web, WSMsgType
|
||||
import perspective
|
||||
import asyncio
|
||||
|
||||
|
||||
class PerspectiveAIOHTTPHandler(object):
|
||||
"""`PerspectiveAIOHTTPHandler` is a drop-in implementation of Perspective.
|
||||
|
||||
Use it inside AIOHTTP routing to create a server-side Perspective that is
|
||||
ready to receive websocket messages from the front-end `perspective-viewer`.
|
||||
|
||||
The Perspective client and server will automatically keep the Websocket
|
||||
alive without timing out.
|
||||
|
||||
# Security
|
||||
|
||||
`PerspectiveAIOHTTPHandler` is a reference integration with no
|
||||
authentication, authorization, origin enforcement, or rate limiting,
|
||||
and is not safe to expose to untrusted networks — see
|
||||
[`SECURITY.md`](https://github.com/perspective-dev/perspective/blob/master/SECURITY.md)
|
||||
for the full threat model.
|
||||
|
||||
# Examples
|
||||
|
||||
>>> server = Server()
|
||||
>>> async def websocket_handler(request):
|
||||
... handler = PerspectiveAIOHTTPHandler(perspective_server=server, request=request)
|
||||
... await handler.run()
|
||||
|
||||
>>> app = web.Application()
|
||||
>>> app.router.add_get("/websocket", websocket_handler)
|
||||
"""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
self.server = kwargs.pop("perspective_server", perspective.GLOBAL_SERVER)
|
||||
self._request = kwargs.pop("request")
|
||||
self._executor = kwargs.pop("executor", None)
|
||||
self._loop = kwargs.pop("loop", asyncio.get_event_loop())
|
||||
super().__init__(**kwargs)
|
||||
|
||||
async def run(self) -> web.WebSocketResponse:
|
||||
def inner(msg):
|
||||
self._loop.create_task(self._ws.send_bytes(msg))
|
||||
|
||||
self.session = self.server.new_session(inner)
|
||||
try:
|
||||
self._ws = web.WebSocketResponse()
|
||||
await self._ws.prepare(self._request)
|
||||
async for msg in self._ws:
|
||||
if msg.type == WSMsgType.BINARY:
|
||||
if self._executor is not None:
|
||||
self._executor.submit(self.session.handle_request, msg.data)
|
||||
else:
|
||||
self.session.handle_request(msg.data)
|
||||
finally:
|
||||
self.session.close()
|
||||
return self._ws
|
||||
@@ -0,0 +1,63 @@
|
||||
# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
# ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
# ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
# ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
# ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
# ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
import asyncio
|
||||
import perspective
|
||||
|
||||
|
||||
class PerspectiveStarletteHandler(object):
|
||||
"""`PerspectiveStarletteHandler` is a drop-in implementation of Perspective.
|
||||
|
||||
# Security
|
||||
|
||||
`PerspectiveStarletteHandler` is a reference integration with no
|
||||
authentication, authorization, origin enforcement, or rate limiting,
|
||||
and is not safe to expose to untrusted networks — see
|
||||
[`SECURITY.md`](https://github.com/perspective-dev/perspective/blob/master/SECURITY.md)
|
||||
for the full threat model.
|
||||
|
||||
# Examples
|
||||
|
||||
>>> server = Server()
|
||||
>>> client = server.client()
|
||||
>>> client.table(pd.read_csv("superstore.csv"), name="data_source_one")
|
||||
>>> app = FastAPI()
|
||||
>>> async def endpoint(websocket: Websocket):
|
||||
... handler = PerspectiveStarletteHandler(server, websocket)
|
||||
... await handler.run()
|
||||
... app.add_api_websocket_route('/websocket', endpoint)
|
||||
"""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
self._server = kwargs.pop("perspective_server", perspective.GLOBAL_SERVER)
|
||||
self._websocket = kwargs.pop("websocket")
|
||||
self._executor = kwargs.pop("executor", None)
|
||||
self._loop = kwargs.pop("loop", asyncio.get_event_loop())
|
||||
super().__init__(**kwargs)
|
||||
|
||||
async def run(self) -> None:
|
||||
def inner(msg):
|
||||
self._loop.create_task(self._websocket.send_bytes(msg))
|
||||
|
||||
self.session = self._server.new_session(inner)
|
||||
|
||||
try:
|
||||
await self._websocket.accept()
|
||||
while True:
|
||||
message = await self._websocket.receive()
|
||||
self._websocket._raise_on_disconnect(message)
|
||||
if self._executor is not None:
|
||||
self._executor.submit(self.session.handle_request, message["bytes"])
|
||||
else:
|
||||
self.session.handle_request(message["bytes"])
|
||||
finally:
|
||||
self.session.close()
|
||||
@@ -0,0 +1,192 @@
|
||||
# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
# ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
# ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
# ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
# ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
# ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
from tornado.websocket import WebSocketHandler, WebSocketClosedError
|
||||
from tornado.ioloop import IOLoop
|
||||
import perspective
|
||||
|
||||
__doc__ = """
|
||||
Perspective ships with a pre-built Tornado handler that makes integration with
|
||||
`tornado.websockets` extremely easy. This allows you to run an instance of
|
||||
`Perspective` on a server using Python, open a websocket to a `Table`, and
|
||||
access the `Table` in JavaScript and through `<perspective-viewer>`. All
|
||||
instructions sent to the `Table` are processed in Python, which executes the
|
||||
commands, and returns its output through the websocket back to Javascript.
|
||||
|
||||
### Python setup
|
||||
|
||||
To use the handler, we need to first have a `Server`, a `Client` and an instance
|
||||
of a `Table`:
|
||||
|
||||
```python
|
||||
SERVER = Server()
|
||||
CLIENT = SERVER.new_local_client()
|
||||
```
|
||||
|
||||
Once the server has been created, create a `Table` instance with a name. The
|
||||
name that you host the table under is important — it acts as a unique accessor
|
||||
on the JavaScript side, which will look for a Table hosted at the websocket with
|
||||
the name you specify.
|
||||
|
||||
```python
|
||||
TABLE = client.table(data, name="data_source_one")
|
||||
```
|
||||
|
||||
After the server and table setup is complete, create a websocket endpoint and
|
||||
provide it a reference to `PerspectiveTornadoHandler`. You must provide the
|
||||
configuration object in the route tuple, and it must contain
|
||||
`"perspective_server"`, which is a reference to the `Server` you just created.
|
||||
|
||||
```python
|
||||
from perspective.handlers.tornado import PerspectiveTornadoHandler
|
||||
|
||||
app = tornado.web.Application([
|
||||
|
||||
# ... other handlers ...
|
||||
|
||||
# Create a websocket endpoint that the client JavaScript can access
|
||||
(r"/websocket", PerspectiveTornadoHandler, {"perspective_server": SERVER, "check_origin": True})
|
||||
])
|
||||
```
|
||||
|
||||
Optionally, the configuration object can also include `check_origin`, a boolean
|
||||
that determines whether the websocket accepts requests from origins other than
|
||||
where the server is hosted. See
|
||||
[Tornado docs](https://www.tornadoweb.org/en/stable/websocket.html#tornado.websocket.WebSocketHandler.check_origin)
|
||||
for more details.
|
||||
|
||||
### JavaScript setup
|
||||
|
||||
Once the server is up and running, you can access the Table you just hosted
|
||||
using `perspective.websocket` and `open_table()`. First, create a client that
|
||||
expects a Perspective server to accept connections at the specified URL:
|
||||
|
||||
```javascript
|
||||
const websocket = await perspective.websocket("ws://localhost:8888/websocket");
|
||||
```
|
||||
|
||||
Next open the `Table` we created on the server by name:
|
||||
|
||||
```javascript
|
||||
const table = await websocket.open_table("data_source_one");
|
||||
```
|
||||
|
||||
`table` is a proxy for the `Table` we created on the server. All operations that
|
||||
are possible through the JavaScript API are possible on the Python API as well,
|
||||
thus calling `view()`, `schema()`, `update()` etc. on `const table` will pass
|
||||
those operations to the Python `Table`, execute the commands, and return the
|
||||
result back to JavaScript. Similarly, providing this `table` to a
|
||||
`<perspective-viewer>` instance will allow virtual rendering:
|
||||
|
||||
```javascript
|
||||
await viewer.load(table);
|
||||
```
|
||||
|
||||
`perspective.websocket` expects a Websocket URL where it will send instructions.
|
||||
When `open_table` is called, the name to a hosted Table is passed through, and a
|
||||
request is sent through the socket to fetch the Table. No actual `Table`
|
||||
instance is passed inbetween the runtimes; all instructions are proxied through
|
||||
websockets.
|
||||
|
||||
This provides for great flexibility — while `Perspective.js` is full of
|
||||
features, browser WebAssembly runtimes currently have some performance
|
||||
restrictions on memory and CPU feature utilization, and the architecture in
|
||||
general suffers when the dataset itself is too large to download to the client
|
||||
in full.
|
||||
|
||||
The Python runtime does not suffer from memory limitations, utilizes Apache
|
||||
Arrow internal threadpools for threading and parallel processing, and generates
|
||||
architecture optimized code, which currently makes it more suitable as a
|
||||
server-side runtime than `node.js`.
|
||||
"""
|
||||
|
||||
|
||||
class PerspectiveTornadoHandler(WebSocketHandler):
|
||||
"""`PerspectiveTornadoHandler` is a `perspective.Server` API as a `tornado`
|
||||
websocket handler.
|
||||
|
||||
Use it inside `tornado` routing to create a `perspective.Server` that can
|
||||
connect to a JavaScript (Wasm) `Client`, providing a virtual interface to
|
||||
the `Server`'s resources for e.g. `<perspective-viewer>`.
|
||||
|
||||
You may need to increase the `websocket_max_message_size` kwarg
|
||||
to the `tornado.web.Application` constructor, as well as provide the
|
||||
`max_buffer_size` optional arg, for large datasets.
|
||||
|
||||
# Security
|
||||
|
||||
`PerspectiveTornadoHandler` is a reference integration with no
|
||||
authentication, authorization, origin enforcement, or rate limiting,
|
||||
and is not safe to expose to untrusted networks — see
|
||||
[`SECURITY.md`](https://github.com/perspective-dev/perspective/blob/master/SECURITY.md)
|
||||
for the full threat model.
|
||||
|
||||
# Arguments
|
||||
|
||||
- `loop`: An optional `IOLoop` instance to use for scheduling IO calls,
|
||||
defaults to `IOLoop.current()`.
|
||||
- `executor`: An optional executor for scheduling `perspective.Server`
|
||||
message processing calls from websocket `Client`s.
|
||||
|
||||
# Examples
|
||||
|
||||
>>> server = psp.Server()
|
||||
>>> client = server.new_local_client()
|
||||
>>> client.table(pd.read_csv("superstore.csv"), name="data_source_one")
|
||||
>>> app = tornado.web.Application([
|
||||
... (r"/", MainHandler),
|
||||
... (r"/websocket", PerspectiveTornadoHandler, {
|
||||
... "perspective_server": server,
|
||||
... })
|
||||
... ])
|
||||
"""
|
||||
|
||||
def check_origin(self, origin):
|
||||
return True
|
||||
|
||||
def initialize(
|
||||
self,
|
||||
perspective_server=perspective.GLOBAL_SERVER,
|
||||
loop=None,
|
||||
executor=None,
|
||||
max_buffer_size=None,
|
||||
):
|
||||
self.server = perspective_server
|
||||
self.loop = loop or IOLoop.current()
|
||||
self.executor = executor
|
||||
if max_buffer_size is not None:
|
||||
self.request.connection.stream.max_buffer_size = max_buffer_size
|
||||
|
||||
def open(self):
|
||||
def write(msg):
|
||||
try:
|
||||
self.write_message(msg, binary=True)
|
||||
except WebSocketClosedError:
|
||||
self.close()
|
||||
|
||||
def send_response(msg):
|
||||
self.loop.add_callback(write, msg)
|
||||
|
||||
self.session = self.server.new_session(send_response)
|
||||
|
||||
def on_close(self) -> None:
|
||||
self.session.close()
|
||||
del self.session
|
||||
|
||||
def on_message(self, msg: bytes):
|
||||
if not isinstance(msg, bytes):
|
||||
return
|
||||
|
||||
if self.executor is None:
|
||||
self.session.handle_request(msg)
|
||||
else:
|
||||
self.executor.submit(self.session.handle_request, msg)
|
||||
@@ -0,0 +1,35 @@
|
||||
<script type="module" src="$psp_cdn_perspective"></script>
|
||||
<script type="module" src="$psp_cdn_perspective_viewer"></script>
|
||||
<script type="module" src="$psp_cdn_perspective_viewer_datagrid"></script>
|
||||
<script type="module" src="$psp_cdn_perspective_viewer_charts"></script>
|
||||
<link rel="stylesheet" crossorigin="anonymous" href="$psp_cdn_perspective_viewer_themes" />
|
||||
|
||||
<div class="perspective-envelope" id="perspective-envelope-$viewer_id">
|
||||
<script type="application/vnd.apache.arrow.file">
|
||||
$b64_data
|
||||
</script>
|
||||
<perspective-viewer style="height: 690px;"></perspective-viewer>
|
||||
<script type="module">
|
||||
// from MDN
|
||||
function base64ToBytes(base64) {
|
||||
const binString = atob(base64);
|
||||
return Uint8Array.from(binString, (m) => m.codePointAt(0));
|
||||
}
|
||||
|
||||
import * as perspective from "$psp_cdn_perspective";
|
||||
const viewerId = $viewer_id;
|
||||
const currentScript = document.scripts[document.scripts.length - 1];
|
||||
const envelope = document.getElementById(`perspective-envelope-$${viewerId}`);
|
||||
const dataScript = envelope.querySelector('script[type="application/vnd.apache.arrow.file"]');;
|
||||
if (!dataScript)
|
||||
throw new Error('data script missing for viewer', viewerId);
|
||||
const data = base64ToBytes(dataScript.textContent);
|
||||
const viewerAttrs = $viewer_attrs;
|
||||
|
||||
// Create a new worker, then a new table promise on that worker.
|
||||
const table = await perspective.worker().table(data.buffer);
|
||||
const viewer = envelope.querySelector('perspective-viewer');
|
||||
viewer.load(table);
|
||||
viewer.restore(viewerAttrs);
|
||||
</script>
|
||||
</div>
|
||||
@@ -0,0 +1,11 @@
|
||||
# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
# ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
# ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
# ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
# ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
# ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
@@ -0,0 +1,83 @@
|
||||
# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
# ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
# ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
# ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
# ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
# ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
from perspective import (
|
||||
AsyncClient,
|
||||
AsyncServer,
|
||||
PerspectiveError,
|
||||
)
|
||||
|
||||
|
||||
import pytest
|
||||
from unittest.mock import Mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def server():
|
||||
return AsyncServer()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(server):
|
||||
async def send_request(msg):
|
||||
await sess.handle_request(msg)
|
||||
|
||||
async def send_response(msg):
|
||||
await client.handle_response(msg)
|
||||
|
||||
sess = server.new_session(send_response)
|
||||
client = AsyncClient(send_request)
|
||||
return client
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_request_callback_awaited(client):
|
||||
table_names = await client.get_hosted_table_names()
|
||||
assert table_names == []
|
||||
|
||||
|
||||
# This test also exists in the pyodide-tests/ pytest suite, with the same name.
|
||||
# Unfortunately code sharing between the two test suites is currently not easy.
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_client_kitchen_sink(client):
|
||||
"""run various things on the async client"""
|
||||
table = await client.table({"a": [0]}, name="my-cool-data", limit=100)
|
||||
view = await table.view()
|
||||
# synchronous methods
|
||||
assert table.get_index() is None
|
||||
assert table.get_name() == "my-cool-data"
|
||||
limit = table.get_limit()
|
||||
assert limit == 100
|
||||
|
||||
# view update callbacks
|
||||
view_updated = Mock()
|
||||
await view.on_update(view_updated)
|
||||
for i in range(1, limit):
|
||||
await table.update([{"a": i}])
|
||||
await table.size() # force update to process
|
||||
assert (await table.size()) == limit
|
||||
assert view_updated.call_count == limit - 1
|
||||
rex = await view.to_records()
|
||||
assert rex == [{"a": i} for i in range(limit)]
|
||||
|
||||
# view/table delete callbacks
|
||||
view_deleted = Mock()
|
||||
table_deleted = Mock()
|
||||
await table.on_delete(table_deleted)
|
||||
await view.on_delete(view_deleted)
|
||||
with pytest.raises(PerspectiveError) as excinfo:
|
||||
await table.delete()
|
||||
assert excinfo.match(r"Cannot delete table with views")
|
||||
await view.delete()
|
||||
view_deleted.assert_called_once()
|
||||
await table.delete()
|
||||
table_deleted.assert_called_once()
|
||||
@@ -0,0 +1,124 @@
|
||||
# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
# ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
# ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
# ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
# ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
# ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
import asyncio
|
||||
import threading
|
||||
import websocket
|
||||
|
||||
import tornado.websocket
|
||||
import tornado.web
|
||||
import tornado.ioloop
|
||||
|
||||
import perspective
|
||||
import perspective.handlers.tornado
|
||||
|
||||
PORT = 8082
|
||||
|
||||
|
||||
def test_big_multi_thing(superstore):
|
||||
async def init_table(client):
|
||||
global SERVER_DATA
|
||||
global SERVER_TABLE
|
||||
|
||||
SERVER_DATA = "x,y\n1,2\n3,4"
|
||||
# with open(file_path, mode="rb") as file:
|
||||
SERVER_TABLE = client.table(SERVER_DATA, name="superstore")
|
||||
|
||||
global ws
|
||||
ws = websocket.WebSocketApp(
|
||||
"ws://localhost:{}/websocket".format(PORT),
|
||||
on_open=on_open,
|
||||
on_message=on_message,
|
||||
# on_error=on_error,
|
||||
# on_close=on_close,
|
||||
)
|
||||
|
||||
global ws_thread
|
||||
ws_thread = threading.Thread(target=ws.run_forever)
|
||||
ws_thread.start()
|
||||
|
||||
def server_thread():
|
||||
def make_app(perspective_server):
|
||||
return tornado.web.Application(
|
||||
[
|
||||
(
|
||||
r"/websocket",
|
||||
perspective.handlers.tornado.PerspectiveTornadoHandler,
|
||||
{"perspective_server": perspective_server},
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
perspective_server = perspective.Server()
|
||||
app = make_app(perspective_server)
|
||||
global server
|
||||
server = app.listen(PORT, "0.0.0.0")
|
||||
|
||||
global server_loop
|
||||
server_loop = tornado.ioloop.IOLoop.current()
|
||||
client = perspective_server.new_local_client()
|
||||
server_loop.call_later(0, init_table, client)
|
||||
server_loop.start()
|
||||
|
||||
server_thread = threading.Thread(target=server_thread)
|
||||
server_thread.start()
|
||||
|
||||
client_loop = asyncio.new_event_loop()
|
||||
client_loop.set_debug(True)
|
||||
client_thread = threading.Thread(target=client_loop.run_forever)
|
||||
client_thread.start()
|
||||
|
||||
async def send_request(msg):
|
||||
global ws
|
||||
ws.send(msg, websocket.ABNF.OPCODE_BINARY)
|
||||
|
||||
def on_message(ws, message):
|
||||
async def poke_client():
|
||||
await client.handle_response(message)
|
||||
|
||||
asyncio.run_coroutine_threadsafe(poke_client(), client_loop)
|
||||
|
||||
# def on_error(ws, error):
|
||||
# print(f"Error!: {error}")
|
||||
|
||||
# def on_close(ws, close_status_code, close_msg):
|
||||
# print("Connection closed")
|
||||
|
||||
def on_open(ws):
|
||||
global client
|
||||
client = perspective.AsyncClient(send_request)
|
||||
asyncio.run_coroutine_threadsafe(test(client), client_loop)
|
||||
|
||||
global count
|
||||
count = 0
|
||||
|
||||
def update(x):
|
||||
global count
|
||||
count += 1
|
||||
|
||||
async def test(client):
|
||||
table = await client.open_table("superstore")
|
||||
view = await table.view()
|
||||
await view.on_update(update)
|
||||
SERVER_TABLE.update(SERVER_DATA)
|
||||
assert await table.size() == 4
|
||||
assert count == 1
|
||||
await server.close_all_connections()
|
||||
client_loop.stop()
|
||||
|
||||
client_thread.join()
|
||||
client_loop.close()
|
||||
ws.close()
|
||||
ws_thread.join()
|
||||
server_loop.add_callback(server_loop.stop)
|
||||
server_thread.join()
|
||||
server_loop.close()
|
||||
@@ -0,0 +1,276 @@
|
||||
# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
# ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
# ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
# ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
# ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
# ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
from datetime import datetime, date, timezone
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pyarrow as pa
|
||||
from pytest import fixture
|
||||
from random import random, randint, choice
|
||||
from faker import Faker
|
||||
|
||||
|
||||
# Our tests construct naive datetimes everywhere
|
||||
# so setting it here is an easy way to fix it globally.
|
||||
import os
|
||||
|
||||
# Perspective used to support datetime.date and datetime.datetime
|
||||
# as Table() constructor arguments, but now we forward the parameters
|
||||
# directly to JSON.loads. So to make sure the tests dont need to be
|
||||
# so utterly transmogrified, we have this little hack :)
|
||||
import json
|
||||
|
||||
|
||||
os.environ["TZ"] = "UTC"
|
||||
|
||||
|
||||
def new_encoder(self, obj):
|
||||
if isinstance(obj, datetime):
|
||||
return str(obj)
|
||||
elif isinstance(obj, date):
|
||||
return str(obj)
|
||||
else:
|
||||
return old(self, obj)
|
||||
|
||||
|
||||
old = json.JSONEncoder.default
|
||||
json.JSONEncoder.default = new_encoder
|
||||
|
||||
fake = Faker()
|
||||
|
||||
|
||||
def _make_date_time_index(size, time_unit):
|
||||
return pd.date_range("2000-01-01", periods=size, freq=time_unit)
|
||||
|
||||
|
||||
def _make_period_index(size, time_unit):
|
||||
return pd.period_range(start="2000", periods=size, freq=time_unit)
|
||||
|
||||
|
||||
def _make_dataframe(index, size=10):
|
||||
"""Create a new random dataframe of `size` and with a DateTimeIndex of
|
||||
frequency `time_unit`.
|
||||
"""
|
||||
return pd.DataFrame(
|
||||
index=index,
|
||||
data={
|
||||
"a": np.random.rand(size),
|
||||
"b": np.random.rand(size),
|
||||
"c": np.random.rand(size),
|
||||
"d": np.random.rand(size),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class Util:
|
||||
@staticmethod
|
||||
def make_arrow(names, data, types=None, legacy=False):
|
||||
"""Create an arrow binary that can be loaded and manipulated from memory.
|
||||
|
||||
Args:
|
||||
names (list): a list of str column names
|
||||
data (list): a list of lists containing data for each column
|
||||
types (list): an optional list of `pyarrow.type` function references.
|
||||
Types will be inferred if not provided.
|
||||
legacy (bool): if True, use legacy IPC format (pre-pyarrow 0.15). Defaults to False.
|
||||
|
||||
Returns:
|
||||
bytes : a bytes object containing the arrow-serialized output.
|
||||
"""
|
||||
stream = pa.BufferOutputStream()
|
||||
arrays = []
|
||||
|
||||
for idx, column in enumerate(data):
|
||||
# only apply types if array is present
|
||||
kwargs = {}
|
||||
if types:
|
||||
kwargs["type"] = types[idx]
|
||||
arrays.append(pa.array(column, **kwargs))
|
||||
|
||||
batch = pa.RecordBatch.from_arrays(arrays, names)
|
||||
table = pa.Table.from_batches([batch])
|
||||
writer = pa.RecordBatchStreamWriter(
|
||||
stream, table.schema
|
||||
)
|
||||
|
||||
writer.write_table(table)
|
||||
writer.close()
|
||||
return stream.getvalue().to_pybytes()
|
||||
|
||||
@staticmethod
|
||||
def make_arrow_from_pandas(df, schema=None, legacy=False):
|
||||
"""Create a pyarrow Table from a Pandas dataframe.
|
||||
|
||||
Args:
|
||||
df (:obj:`pandas.DataFrame`)
|
||||
schema (:obj:`pyarrow.Schema`)
|
||||
legacy (bool): unused; retained for backwards compatibility.
|
||||
|
||||
Returns:
|
||||
pyarrow.Table
|
||||
"""
|
||||
return pa.Table.from_pandas(df, schema=schema)
|
||||
|
||||
@staticmethod
|
||||
def make_dictionary_arrow(names, data, types=None, legacy=False):
|
||||
"""Create an arrow binary that can be loaded and manipulated from memory, with
|
||||
each column being a dictionary array of `str` values and `int` indices.
|
||||
|
||||
Args:
|
||||
names (list): a list of str column names
|
||||
data (list:tuple): a list of tuples, the first value being a list of indices,
|
||||
and the second value being a list of values.
|
||||
types (list:list:pyarrow.func): a list of lists, containing the indices type and
|
||||
dictionary value type for each array.
|
||||
legacy (bool): if True, use legacy IPC format (pre-pyarrow 0.15). Defaults to False.
|
||||
|
||||
Returns:
|
||||
bytes : a bytes object containing the arrow-serialized output.
|
||||
"""
|
||||
stream = pa.BufferOutputStream()
|
||||
|
||||
arrays = []
|
||||
for idx, column in enumerate(data):
|
||||
indice_type = pa.int64()
|
||||
value_type = pa.string()
|
||||
|
||||
if types is not None:
|
||||
indice_type = types[idx][0]
|
||||
value_type = types[idx][1]
|
||||
|
||||
indices = pa.array(column[0], type=indice_type)
|
||||
values = pa.array(column[1], type=value_type)
|
||||
parray = pa.DictionaryArray.from_arrays(indices, values)
|
||||
arrays.append(parray)
|
||||
|
||||
batch = pa.RecordBatch.from_arrays(arrays, names)
|
||||
table = pa.Table.from_batches([batch])
|
||||
writer = pa.RecordBatchStreamWriter(
|
||||
stream, table.schema
|
||||
)
|
||||
|
||||
writer.write_table(table)
|
||||
writer.close()
|
||||
return stream.getvalue().to_pybytes()
|
||||
|
||||
@staticmethod
|
||||
def to_timestamp(obj):
|
||||
"""Return an integer timestamp based on a date/datetime object.
|
||||
|
||||
A `date` is a timezone-agnostic calendar day and serializes to epoch
|
||||
ms at *UTC* midnight regardless of the host process timezone, so it
|
||||
is converted here in UTC. A naive `datetime` is a wall-clock reading
|
||||
in the process-local timezone (callers express expected instants
|
||||
this way, e.g. `aware.astimezone(TZ).replace(tzinfo=None)`), so it is
|
||||
converted via local `timestamp()`.
|
||||
"""
|
||||
classname = obj.__class__.__name__
|
||||
if classname == "date":
|
||||
return int(
|
||||
datetime(
|
||||
obj.year, obj.month, obj.day, tzinfo=timezone.utc
|
||||
).timestamp()
|
||||
* 1000
|
||||
)
|
||||
elif classname == "datetime":
|
||||
return int(obj.timestamp() * 1000)
|
||||
else:
|
||||
return -1
|
||||
|
||||
@staticmethod
|
||||
def make_dataframe(size=10, freq="D"):
|
||||
index = _make_date_time_index(size, freq)
|
||||
return _make_dataframe(index, size)
|
||||
|
||||
@staticmethod
|
||||
def make_period_dataframe(size=10):
|
||||
index = _make_period_index(size, "M")
|
||||
return _make_dataframe(index, size)
|
||||
|
||||
@staticmethod
|
||||
def make_series(size=10, freq="D"):
|
||||
index = _make_date_time_index(size, freq)
|
||||
return pd.Series(data=np.random.rand(size), index=index)
|
||||
|
||||
|
||||
class Sentinel(object):
|
||||
"""Generic sentinel class for testing side-effectful code in Python 2 and
|
||||
3.
|
||||
"""
|
||||
|
||||
def __init__(self, value):
|
||||
self.value = value
|
||||
|
||||
def get(self):
|
||||
return self.value
|
||||
|
||||
def set(self, new_value):
|
||||
self.value = new_value
|
||||
|
||||
|
||||
@fixture()
|
||||
def sentinel():
|
||||
"""Pass `sentinel` into a test and call it with `value` to create a new
|
||||
instance of the Sentinel class.
|
||||
|
||||
Example:
|
||||
>>> def test_with_sentinel(self, sentinel):
|
||||
>>> s = sentinel(True)
|
||||
>>> s.set(False)
|
||||
>>> s.get() # returns False
|
||||
"""
|
||||
|
||||
def _sentinel(value):
|
||||
return Sentinel(value)
|
||||
|
||||
return _sentinel
|
||||
|
||||
|
||||
@fixture
|
||||
def util():
|
||||
"""Pass the `Util` class in to a test."""
|
||||
return Util
|
||||
|
||||
|
||||
@fixture
|
||||
def superstore(count=100):
|
||||
data = []
|
||||
for id in range(count):
|
||||
dat = {}
|
||||
dat["Row ID"] = id
|
||||
dat["Order ID"] = "{}-{}".format(fake.ein(), fake.zipcode())
|
||||
dat["Order Date"] = fake.date_this_year()
|
||||
dat["Ship Date"] = fake.date_between_dates(dat["Order Date"]).strftime(
|
||||
"%Y-%m-%d"
|
||||
)
|
||||
dat["Order Date"] = dat["Order Date"].strftime("%Y-%m-%d")
|
||||
dat["Ship Mode"] = choice(["First Class", "Standard Class", "Second Class"])
|
||||
dat["Ship Mode"] = choice(["First Class", "Standard Class", "Second Class"])
|
||||
dat["Customer ID"] = fake.zipcode()
|
||||
dat["Segment"] = choice(["A", "B", "C", "D"])
|
||||
dat["Country"] = "US"
|
||||
dat["City"] = fake.city()
|
||||
dat["State"] = fake.state()
|
||||
dat["Postal Code"] = fake.zipcode()
|
||||
dat["Region"] = choice(["Region %d" % i for i in range(5)])
|
||||
dat["Product ID"] = fake.bban()
|
||||
sector = choice(["Industrials", "Technology", "Financials"])
|
||||
industry = choice(["A", "B", "C"])
|
||||
dat["Category"] = sector
|
||||
dat["Sub-Category"] = industry
|
||||
dat["Sales"] = randint(1, 100) * 100
|
||||
dat["Quantity"] = randint(1, 100) * 10
|
||||
dat["Discount"] = round(random() * 100, 2)
|
||||
dat["Profit"] = round(random() * 1000, 2)
|
||||
data.append(dat)
|
||||
return pd.DataFrame(data)
|
||||
@@ -0,0 +1,11 @@
|
||||
# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
# ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
# ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
# ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
# ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
# ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
@@ -0,0 +1,351 @@
|
||||
# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
# ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
# ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
# ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
# ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
# ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
import queue
|
||||
import random
|
||||
import threading
|
||||
from functools import partial
|
||||
import tornado
|
||||
|
||||
from perspective import (
|
||||
Server,
|
||||
Client,
|
||||
)
|
||||
from pytest import mark
|
||||
|
||||
|
||||
def syncify(f):
|
||||
"""Given a function `f` that must be run on `TestAsync.loop`, queue `f` on
|
||||
the loop, block until it is evaluated, and return the result.
|
||||
"""
|
||||
sem = queue.Queue()
|
||||
|
||||
def _syncify_task():
|
||||
assert threading.current_thread().ident == TestAsync.thread.ident
|
||||
result = f()
|
||||
TestAsync.loop.add_callback(lambda: sem.put(result))
|
||||
|
||||
def _syncify():
|
||||
TestAsync.loop.add_callback(_syncify_task)
|
||||
return sem.get()
|
||||
|
||||
return _syncify
|
||||
|
||||
|
||||
data = [{"a": i, "b": i * 0.5, "c": str(i)} for i in range(10)]
|
||||
|
||||
|
||||
class TestAsync(object):
|
||||
@classmethod
|
||||
def setup_class(cls):
|
||||
import asyncio
|
||||
|
||||
# Storing the current loop, which was set up by the pytest-asyncio
|
||||
# tests running in tests/async, delays its cleanup, which foregoes a
|
||||
# pytest exception about an unclosed socket. A cleaner way to fix this
|
||||
# probably exists (use an event loop fixture? convert these tests to
|
||||
# pytest-asyncio?)
|
||||
cls.save_old_loop_to_prevent_unclosed_socket_exception = (
|
||||
asyncio.get_event_loop()
|
||||
)
|
||||
asyncio.set_event_loop(asyncio.new_event_loop())
|
||||
cls.loop = tornado.ioloop.IOLoop.current()
|
||||
cls.thread = threading.Thread(target=cls.loop.start)
|
||||
cls.thread.daemon = True
|
||||
cls.thread.start()
|
||||
|
||||
@classmethod
|
||||
def teardown_class(cls):
|
||||
cls.loop.add_callback(lambda: tornado.ioloop.IOLoop.current().stop())
|
||||
cls.thread.join()
|
||||
cls.loop.close(all_fds=True)
|
||||
|
||||
@classmethod
|
||||
def loop_is_running(cls):
|
||||
return cls.loop.asyncio_loop.is_running()
|
||||
|
||||
def test_async_queue_process(self):
|
||||
server = Server()
|
||||
client = Client.from_server(
|
||||
server,
|
||||
)
|
||||
|
||||
tbl = client.table({"a": "integer", "b": "float", "c": "string"})
|
||||
|
||||
@syncify
|
||||
def _task():
|
||||
assert tbl.size() == 0
|
||||
for i in range(5):
|
||||
tbl.update([data[i]])
|
||||
return tbl.size()
|
||||
|
||||
assert _task() == 5
|
||||
tbl.delete()
|
||||
|
||||
def test_async_queue_process_csv(self):
|
||||
"""Make sure GIL release during CSV loading works"""
|
||||
server = Server()
|
||||
client = Client.from_server(
|
||||
server,
|
||||
)
|
||||
tbl = client.table("x,y,z\n1,a,true\n2,b,false\n3,c,true\n4,d,false")
|
||||
|
||||
@syncify
|
||||
def _task():
|
||||
assert tbl.size() == 4
|
||||
for i in range(5):
|
||||
tbl.update("x,y,z\n1,a,true\n2,b,false\n3,c,true\n4,d,false")
|
||||
return tbl.size()
|
||||
|
||||
assert _task() == 24
|
||||
|
||||
tbl.delete()
|
||||
|
||||
def test_async_call_loop(self):
|
||||
server = Server()
|
||||
client = Client.from_server(
|
||||
server,
|
||||
)
|
||||
tbl = client.table({"a": "integer", "b": "float", "c": "string"})
|
||||
tbl.update(data)
|
||||
|
||||
@syncify
|
||||
def _task():
|
||||
return tbl.size()
|
||||
|
||||
assert _task() == 10
|
||||
tbl.delete()
|
||||
|
||||
def test_async_multiple_managers_queue_process(self):
|
||||
server = Server()
|
||||
client = Client.from_server(server)
|
||||
server2 = Server()
|
||||
client2 = Client.from_server(server2)
|
||||
tbl = client.table({"a": "integer", "b": "float", "c": "string"})
|
||||
tbl2 = client2.table({"a": "integer", "b": "float", "c": "string"})
|
||||
|
||||
@syncify
|
||||
def _update_task():
|
||||
for i in range(5):
|
||||
tbl.update([data[i]])
|
||||
tbl2.update([data[i]])
|
||||
return tbl.size()
|
||||
|
||||
assert _update_task() == 5
|
||||
|
||||
@syncify
|
||||
def _flush_to_process():
|
||||
view = tbl2.view()
|
||||
records = view.to_records()
|
||||
for i in range(5):
|
||||
tbl2.update([data[i]])
|
||||
|
||||
view.delete()
|
||||
return records
|
||||
|
||||
assert _flush_to_process() == data[:5]
|
||||
|
||||
@syncify
|
||||
def _delete_task():
|
||||
tbl2.delete()
|
||||
tbl.delete()
|
||||
|
||||
_delete_task()
|
||||
|
||||
@mark.skip(
|
||||
reason="This test is failing because we're not calling process after each update like before"
|
||||
)
|
||||
def test_async_multiple_managers_mixed_queue_process(self):
|
||||
sentinel = {"called": 0}
|
||||
|
||||
def sync_queue_process(f, *args, **kwargs):
|
||||
sentinel["called"] += 1
|
||||
f(*args, **kwargs)
|
||||
|
||||
server = Server()
|
||||
client = Client.from_server(server)
|
||||
|
||||
server2 = Server()
|
||||
client2 = Client.from_server(server2)
|
||||
tbl = client.table({"a": "integer", "b": "float", "c": "string"})
|
||||
tbl2 = client2.table({"a": "integer", "b": "float", "c": "string"})
|
||||
|
||||
@syncify
|
||||
def _tbl_task():
|
||||
for i in range(5):
|
||||
tbl.update([data[i]])
|
||||
return tbl.size()
|
||||
|
||||
assert _tbl_task() == 5
|
||||
|
||||
for i in range(5):
|
||||
tbl2.update([data[i]])
|
||||
|
||||
assert sentinel["called"] == 5
|
||||
|
||||
@syncify
|
||||
def _tbl_task2():
|
||||
view = tbl.view()
|
||||
records = view.to_records()
|
||||
view.delete()
|
||||
tbl.delete()
|
||||
return records
|
||||
|
||||
assert _tbl_task2() == data[:5]
|
||||
|
||||
view = tbl2.view()
|
||||
assert view.to_records() == data[:5]
|
||||
|
||||
view.delete()
|
||||
tbl2.delete()
|
||||
|
||||
@mark.skip(
|
||||
reason="This test is failing because we're not calling process after each update like before"
|
||||
)
|
||||
def test_async_multiple_managers_delayed_process(self):
|
||||
sentinel = {"async": 0, "sync": 0}
|
||||
|
||||
def _counter(key, f, *args, **kwargs):
|
||||
sentinel[key] += 1
|
||||
return f(*args, **kwargs)
|
||||
|
||||
short_delay_queue_process = partial(_counter, "sync")
|
||||
long_delay_queue_process = partial(
|
||||
TestAsync.loop.add_timeout, 1, _counter, "async"
|
||||
)
|
||||
|
||||
server = Server(on_poll_request=short_delay_queue_process)
|
||||
client = Client.from_server(server)
|
||||
|
||||
server2 = Server(on_poll_request=long_delay_queue_process)
|
||||
client2 = Client.from_server(server2)
|
||||
tbl = client.table({"a": "integer", "b": "float", "c": "string"})
|
||||
tbl2 = client2.table({"a": "integer", "b": "float", "c": "string"})
|
||||
|
||||
@syncify
|
||||
def _tbl_task():
|
||||
for i in range(10):
|
||||
tbl2.update([data[i]])
|
||||
|
||||
_tbl_task()
|
||||
for i in range(10):
|
||||
tbl.update([data[i]])
|
||||
|
||||
@syncify
|
||||
def _tbl_task2():
|
||||
size = tbl2.size()
|
||||
tbl2.delete()
|
||||
return size
|
||||
|
||||
assert _tbl_task2() == 10
|
||||
assert tbl.size() == 10
|
||||
assert sentinel["async"] == 1
|
||||
assert sentinel["sync"] == 10
|
||||
|
||||
tbl.delete()
|
||||
|
||||
def test_async_single_manager_tables_chained(self):
|
||||
def call_loop(fn, *args):
|
||||
TestAsync.loop.add_callback(fn, *args)
|
||||
|
||||
server = Server()
|
||||
client = Client.from_server(server)
|
||||
columns = {"index": "integer", "num1": "integer", "num2": "integer"}
|
||||
# tbl = client.table(columns, index="index")
|
||||
tbl = client.table(columns)
|
||||
view = tbl.view()
|
||||
tbl2 = client.table(view.to_arrow())
|
||||
|
||||
def _update(port_id, delta):
|
||||
print("Updating tbl2", delta)
|
||||
tbl2.update(delta)
|
||||
|
||||
view.on_update(_update, mode="row")
|
||||
for i in range(1000):
|
||||
call_loop(tbl.update, [{"index": i, "num1": i, "num2": 2 * i}])
|
||||
i += 1
|
||||
|
||||
call_loop(tbl.size)
|
||||
|
||||
q = queue.Queue()
|
||||
call_loop(q.put, True)
|
||||
q.get()
|
||||
|
||||
@syncify
|
||||
def _tbl_task2():
|
||||
size = tbl2.size()
|
||||
return size
|
||||
|
||||
assert _tbl_task2() == 1000
|
||||
# assert tbl2.size() == 1000
|
||||
view.delete()
|
||||
tbl.delete()
|
||||
tbl2.delete()
|
||||
|
||||
def test_async_queue_process_multiple_ports(self):
|
||||
server = Server()
|
||||
client = Client.from_server(server)
|
||||
tbl = client.table({"a": "integer", "b": "float", "c": "string"})
|
||||
port_ids = [0]
|
||||
port_data = [{"a": 0, "b": 0, "c": "0"}]
|
||||
|
||||
for i in range(10):
|
||||
port_id = tbl.make_port()
|
||||
port_ids.append(port_id)
|
||||
port_data.append({"a": port_id, "b": port_id * 1.5, "c": str(port_id)})
|
||||
|
||||
assert port_ids == list(range(0, 11))
|
||||
|
||||
assert syncify(lambda: tbl.size())() == 0
|
||||
|
||||
random.shuffle(port_ids)
|
||||
|
||||
@syncify
|
||||
def _tbl_task():
|
||||
for port_id in port_ids:
|
||||
idx = port_id if port_id < len(port_ids) else len(port_ids) - 1
|
||||
tbl.update([port_data[idx]], port_id=port_id)
|
||||
size = tbl.size()
|
||||
tbl.delete()
|
||||
return size
|
||||
|
||||
assert len(port_ids) == 11
|
||||
assert _tbl_task() == 11
|
||||
|
||||
def test_async_multiple_managers_queue_process_multiple_ports(self):
|
||||
server = Server()
|
||||
client = Client.from_server(server)
|
||||
|
||||
server2 = Server()
|
||||
client2 = Client.from_server(server2)
|
||||
tbl = client.table({"a": "integer", "b": "float", "c": "string"})
|
||||
tbl2 = client2.table({"a": "integer", "b": "float", "c": "string"})
|
||||
port_ids = [0]
|
||||
port_data = [{"a": 0, "b": 0, "c": "0"}]
|
||||
|
||||
for i in range(10):
|
||||
port_id = tbl.make_port()
|
||||
port_id2 = tbl2.make_port()
|
||||
assert port_id == port_id2
|
||||
port_ids.append(port_id)
|
||||
port_data.append({"a": port_id, "b": port_id * 1.5, "c": str(port_id)})
|
||||
|
||||
@syncify
|
||||
def _task():
|
||||
random.shuffle(port_ids)
|
||||
for port_id in port_ids:
|
||||
idx = port_id if port_id < len(port_ids) else len(port_ids) - 1
|
||||
tbl.update([port_data[idx]], port_id=port_id)
|
||||
tbl2.update([port_data[idx]], port_id=port_id)
|
||||
return (tbl.size(), tbl2.size())
|
||||
|
||||
assert _task() == (11, 11)
|
||||
@@ -0,0 +1,11 @@
|
||||
# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
# ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
# ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
# ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
# ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
# ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
@@ -0,0 +1,201 @@
|
||||
# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
# ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
# ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
# ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
# ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
# ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
from perspective import Server
|
||||
|
||||
import asyncio
|
||||
import random
|
||||
import threading
|
||||
|
||||
from random import sample
|
||||
from string import ascii_letters
|
||||
from threading import Thread
|
||||
from time import sleep
|
||||
|
||||
class TestServer(object):
|
||||
def test_sync_updates_with_loop_callback_are_sync(self):
|
||||
def feed(table):
|
||||
y = 1000
|
||||
while y > 0:
|
||||
y -= 1
|
||||
table.update([{"a": random.randint(0, 10), "index": y}])
|
||||
|
||||
perspective_server = Server()
|
||||
loop = asyncio.new_event_loop()
|
||||
thread = threading.Thread(target=loop.run_forever)
|
||||
thread.start()
|
||||
|
||||
client = perspective_server.new_local_client()
|
||||
table = client.table(
|
||||
{"a": "integer", "index": "integer"}, index="index", name="test"
|
||||
)
|
||||
|
||||
view = table.view()
|
||||
global count
|
||||
count = 0
|
||||
|
||||
def update(x):
|
||||
global count
|
||||
count += 1
|
||||
|
||||
view.on_update(update)
|
||||
feed(table)
|
||||
assert table.size() == 1000
|
||||
assert count == 1000
|
||||
loop.call_soon_threadsafe(loop.stop)
|
||||
thread.join()
|
||||
loop.close()
|
||||
|
||||
def test_concurrent_updates(self):
|
||||
async def feed(table, loop):
|
||||
y = 1000
|
||||
while y > 0:
|
||||
y -= 1
|
||||
table.update([{"a": random.randint(0, 10), "index": y}])
|
||||
await asyncio.sleep(0.001)
|
||||
|
||||
perspective_server = Server()
|
||||
loop = asyncio.new_event_loop()
|
||||
thread = threading.Thread(target=loop.run_forever)
|
||||
thread.start()
|
||||
|
||||
client = perspective_server.new_local_client()
|
||||
table = client.table(
|
||||
{"a": "integer", "index": "integer"}, index="index", name="test"
|
||||
)
|
||||
|
||||
view = table.view()
|
||||
global count
|
||||
count = 0
|
||||
|
||||
def update(x):
|
||||
global count
|
||||
count += 1
|
||||
|
||||
view.on_update(update)
|
||||
asyncio.run_coroutine_threadsafe(feed(table, loop), loop).result()
|
||||
assert table.size() == 1000
|
||||
assert count == 1000
|
||||
loop.call_soon_threadsafe(loop.stop)
|
||||
thread.join()
|
||||
loop.close()
|
||||
|
||||
def test_concurrent_updates_with_limit_tables_are_threadsafe(self):
|
||||
TEST_ITERATIONS = 100
|
||||
global running
|
||||
perspective_server = Server()
|
||||
client = perspective_server.new_local_client()
|
||||
table = client.table(
|
||||
{"col{}".format(i): "integer" for i in range(100)}, limit=100
|
||||
)
|
||||
|
||||
running = True
|
||||
|
||||
# Create an updating thread that overlaps the index alot
|
||||
def feed(table):
|
||||
row = {"col{}".format(i): random.randint(0, 100) for i in range(100)}
|
||||
while running:
|
||||
table.update([row for _ in range(100)])
|
||||
|
||||
thread = threading.Thread(target=feed, args=(table,))
|
||||
thread.start()
|
||||
|
||||
results = []
|
||||
|
||||
# Create a thread that serialized the table alot, checking for nulls
|
||||
def feed2(table):
|
||||
global running
|
||||
view = table.view()
|
||||
while len(results) < TEST_ITERATIONS:
|
||||
arr = view.to_arrow()
|
||||
table2 = client.table(arr)
|
||||
view2 = table2.view()
|
||||
json = view2.to_json(end_row=1)
|
||||
view2.delete()
|
||||
table2.delete()
|
||||
results.append(json)
|
||||
|
||||
view.delete()
|
||||
running = False
|
||||
|
||||
thread2 = threading.Thread(target=feed2, args=(table,))
|
||||
thread2.start()
|
||||
|
||||
thread.join()
|
||||
thread2.join()
|
||||
|
||||
assert table.size() == 100
|
||||
for result in results:
|
||||
for row in result:
|
||||
for col, val in row.items():
|
||||
assert val is not None
|
||||
|
||||
table.delete()
|
||||
|
||||
def test_concurrent_view_creation_on_separate_servers_are_threadsafe(self):
|
||||
def run_perspective():
|
||||
x = 1000
|
||||
s = Server()
|
||||
c = s.new_local_client()
|
||||
t = c.table({"a": "string", "b": int})
|
||||
t.view(expressions=["//tmp\nfalse"])
|
||||
while x > 0:
|
||||
t.update([{"a": "foo", "b": 42}])
|
||||
x -= 1
|
||||
|
||||
thread1 = threading.Thread(target=run_perspective, daemon=True)
|
||||
thread2 = threading.Thread(target=run_perspective, daemon=True)
|
||||
thread1.start()
|
||||
thread2.start()
|
||||
thread1.join()
|
||||
thread2.join()
|
||||
|
||||
def test_concurrent_view_creation_with_updates_are_threadsafe(self):
|
||||
global running
|
||||
s = Server()
|
||||
schema = {
|
||||
"a": "string",
|
||||
"b": "string",
|
||||
"c": "string",
|
||||
}
|
||||
|
||||
group_bys = ["a", "b", "c"]
|
||||
c = s.new_local_client()
|
||||
t = c.table(schema, limit=10000)
|
||||
running = True
|
||||
|
||||
def gen_views():
|
||||
global running
|
||||
for _ in range(100):
|
||||
t.view(columns=list(schema.keys()), group_by=group_bys)
|
||||
sleep(0.01)
|
||||
running = False
|
||||
|
||||
def run_psp():
|
||||
global running
|
||||
while running:
|
||||
t.update(
|
||||
[
|
||||
{
|
||||
"a": "".join(sample(ascii_letters, 4)),
|
||||
"b": "".join(sample(ascii_letters, 4)),
|
||||
"c": "".join(sample(ascii_letters, 4)),
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
thread1 = Thread(target=run_psp, daemon=True)
|
||||
thread2 = Thread(target=gen_views, daemon=True)
|
||||
thread1.start()
|
||||
thread2.start()
|
||||
thread1.join()
|
||||
thread2.join()
|
||||
@@ -0,0 +1,11 @@
|
||||
# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
# ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
# ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
# ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
# ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
# ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,110 @@
|
||||
# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
# ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
# ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
# ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
# ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
# ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
from perspective import Server, Client, ProxySession, AsyncClient
|
||||
import pytest
|
||||
|
||||
|
||||
client = Server().new_local_client()
|
||||
Table = client.table
|
||||
|
||||
|
||||
data = {"a": [1, 2, 3], "b": ["a", "b", "c"]}
|
||||
|
||||
|
||||
class TestProxySession(object):
|
||||
def test_proxy_client(self):
|
||||
server = Server()
|
||||
client = server.new_local_client()
|
||||
|
||||
def handle_request(bytes):
|
||||
sub_session.handle_request(bytes)
|
||||
|
||||
def handle_response(bytes):
|
||||
sub_client.handle_response(bytes)
|
||||
|
||||
sub_session = ProxySession(client, handle_response)
|
||||
sub_client = Client(handle_request, sub_session.close)
|
||||
table = sub_client.table(data, name="table1")
|
||||
assert table.schema() == {"a": "integer", "b": "string"}
|
||||
|
||||
def test_proxy_and_local_client_can_share_handles(self):
|
||||
server = Server()
|
||||
client = server.new_local_client()
|
||||
|
||||
def handle_request(bytes):
|
||||
sub_session.handle_request(bytes)
|
||||
|
||||
def handle_response(bytes):
|
||||
sub_client.handle_response(bytes)
|
||||
|
||||
sub_session = ProxySession(client, handle_response)
|
||||
sub_client = Client(handle_request, sub_session.close)
|
||||
table = sub_client.table(data, name="table1")
|
||||
table2 = client.open_table("table1")
|
||||
assert table.size() == 3
|
||||
table2.update([{"a": 4, "d": 5}])
|
||||
assert table.size() == 4
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_request_async(self):
|
||||
"""tests use of ProxySession.handle_request_async() in an AsyncClient callback"""
|
||||
server = Server()
|
||||
client = server.new_local_client()
|
||||
|
||||
def handle_response(bytes):
|
||||
import asyncio
|
||||
|
||||
asyncio.create_task(sub_client.handle_response(bytes))
|
||||
|
||||
sub_session = ProxySession(client, handle_response)
|
||||
sub_client = AsyncClient(sub_session.handle_request_async, sub_session.close)
|
||||
table = await sub_client.table(data, name="table1")
|
||||
table2 = client.open_table("table1")
|
||||
assert (await table.size()) == 3
|
||||
table2.update([{"a": 4, "d": 5}])
|
||||
assert (await table.size()) == 4
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dismabiguate_message_ids_in_proxy_session(self):
|
||||
"""tests concurrent calls from clients on Session + ProxySession"""
|
||||
server = Server()
|
||||
client = server.new_local_client()
|
||||
|
||||
def send_response(bytes):
|
||||
import asyncio
|
||||
|
||||
asyncio.create_task(sub_client.handle_response(bytes))
|
||||
|
||||
async def send_request(bytes):
|
||||
await sub_session.handle_request_async(bytes)
|
||||
|
||||
sub_session = ProxySession(client, send_response)
|
||||
sub_client = AsyncClient(send_request, sub_session.close)
|
||||
|
||||
table = client.table("x\n1", name="test_table")
|
||||
table2 = await sub_client.open_table("test_table")
|
||||
view = table.view()
|
||||
view2 = await table2.view()
|
||||
|
||||
sentinel = []
|
||||
|
||||
def callback1(*args):
|
||||
sentinel.append("Callback 1")
|
||||
|
||||
def callback2(*args):
|
||||
sentinel.append("Callback 2")
|
||||
|
||||
view.on_update(callback1)
|
||||
await view2.on_update(callback2)
|
||||
await table2.update("x\n2")
|
||||
assert sentinel == ["Callback 1", "Callback 2"]
|
||||
@@ -0,0 +1,11 @@
|
||||
# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
# ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
# ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
# ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
# ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
# ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,402 @@
|
||||
# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
# ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
# ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
# ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
# ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
# ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
import sys
|
||||
from random import randint, choice
|
||||
import perspective as psp
|
||||
|
||||
client = psp.Server().new_local_client()
|
||||
Table = client.table
|
||||
|
||||
|
||||
class CustomObjectStore(object):
|
||||
def __init__(self, value):
|
||||
self._value = value
|
||||
|
||||
def _psp_dtype_(self):
|
||||
return "object"
|
||||
|
||||
def __int__(self):
|
||||
return int(self._value)
|
||||
|
||||
def __repr__(self):
|
||||
return "test" if self._value == 1 else "test{}".format(self._value)
|
||||
|
||||
|
||||
def run():
|
||||
t = CustomObjectStore(1)
|
||||
t2 = CustomObjectStore(2)
|
||||
|
||||
assert sys.getrefcount(t) == 2
|
||||
assert sys.getrefcount(t2) == 2
|
||||
|
||||
data = {"a": [0], "b": [t]}
|
||||
assert sys.getrefcount(t) == 3
|
||||
|
||||
tbl = Table(data, index="a")
|
||||
assert sys.getrefcount(t) == 4
|
||||
|
||||
assert tbl.schema() == {"a": int, "b": object}
|
||||
assert tbl.size() == 1
|
||||
assert tbl.view().to_columns() == {"a": [0], "b": [t]}
|
||||
|
||||
# Count references
|
||||
# 1 for `t`, 1 for `data`, 1 for argument to sys.getrefcount, and 1 for the table
|
||||
print(sys.getrefcount(t), "should be", 4)
|
||||
print("t:", id(t))
|
||||
assert sys.getrefcount(t) == 4
|
||||
|
||||
print(sys.getrefcount(t2), "should be", 2)
|
||||
print("t2:", id(t2))
|
||||
assert sys.getrefcount(t2) == 2
|
||||
|
||||
tbl.update([{"a": i, "b": None} for i in range(1, 6)])
|
||||
|
||||
assert tbl.size() == 6
|
||||
# 1 for `t`, 1 for `data`, 1 for argument to sys.getrefcount, and 1 for the table
|
||||
print(sys.getrefcount(t), "should be", 4)
|
||||
assert sys.getrefcount(t) == 4
|
||||
|
||||
print()
|
||||
tbl.update([data])
|
||||
# 1 for `t`, 1 for `data`, 1 for argument to sys.getrefcount, and 1 for the table
|
||||
print(sys.getrefcount(t), "should be", 4)
|
||||
assert sys.getrefcount(t) == 4
|
||||
|
||||
print()
|
||||
tbl.update([data])
|
||||
# 1 for `t`, 1 for `data`, 1 for argument to sys.getrefcount, and 1 for the table
|
||||
print(sys.getrefcount(t), "should be", 4)
|
||||
assert sys.getrefcount(t) == 4
|
||||
|
||||
print()
|
||||
tbl.update([data])
|
||||
# 1 for `t`, 1 for `data`, 1 for argument to sys.getrefcount, and 1 for the table
|
||||
print(sys.getrefcount(t), "should be", 4)
|
||||
assert sys.getrefcount(t) == 4
|
||||
|
||||
print()
|
||||
tbl.update([{"a": 1, "b": t}])
|
||||
# 1 for `t`, 1 for `data`, 1 for argument to sys.getrefcount, and 2 for the table
|
||||
print(sys.getrefcount(t), "should be", 5)
|
||||
assert sys.getrefcount(t) == 5
|
||||
|
||||
print()
|
||||
tbl.update([{"a": 1, "b": t}])
|
||||
# 1 for `t`, 1 for `data`, 1 for argument to sys.getrefcount, and 2 for the table
|
||||
print(sys.getrefcount(t), "should be", 5)
|
||||
assert sys.getrefcount(t) == 5
|
||||
|
||||
print()
|
||||
tbl.update([{"a": 3, "b": t}])
|
||||
# 1 for `t`, 1 for `data`, 1 for argument to sys.getrefcount, and 3 for the table
|
||||
print(sys.getrefcount(t), "should be", 6)
|
||||
assert sys.getrefcount(t) == 6
|
||||
|
||||
print()
|
||||
tbl.update([{"a": 3, "b": t}])
|
||||
# 1 for `t`, 1 for `data`, 1 for argument to sys.getrefcount, and 3 for the table
|
||||
print(sys.getrefcount(t), "should be", 6)
|
||||
assert sys.getrefcount(t) == 6
|
||||
|
||||
print()
|
||||
tbl.update([{"a": 5, "b": t}])
|
||||
# 1 for `t`, 1 for `data`, 1 for argument to sys.getrefcount, and 4for the table
|
||||
print(sys.getrefcount(t), "should be", 7)
|
||||
# assert sys.getrefcount(t) == 7
|
||||
print(tbl.view().to_columns()["b"])
|
||||
assert list(_ is not None for _ in tbl.view().to_columns()["b"]) == [
|
||||
True,
|
||||
True,
|
||||
False,
|
||||
True,
|
||||
False,
|
||||
True,
|
||||
]
|
||||
|
||||
print()
|
||||
# clear some, overwrite some with same
|
||||
tbl.update([{"a": 0, "b": t}])
|
||||
# 1 for `t`, 1 for `data`, 1 for argument to sys.getrefcount, and 4 for the table
|
||||
print(sys.getrefcount(t), "should be", 7)
|
||||
assert list(_ is not None for _ in tbl.view().to_columns()["b"]) == [
|
||||
True,
|
||||
True,
|
||||
False,
|
||||
True,
|
||||
False,
|
||||
True,
|
||||
]
|
||||
|
||||
print()
|
||||
tbl.update([{"a": 1, "b": t2}])
|
||||
# 1 for `t`, 1 for `data`, 1 for argument to sys.getrefcount, and 3 for the table
|
||||
print(sys.getrefcount(t), "should be", 6)
|
||||
# 1 for `t2`, 1 for argument to sys.getrefcount, and 1 for the table
|
||||
print(sys.getrefcount(t2), "should be", 3)
|
||||
print(tbl.view().to_columns()["b"])
|
||||
assert list(_ is not None for _ in tbl.view().to_columns()["b"]) == [
|
||||
True,
|
||||
True,
|
||||
False,
|
||||
True,
|
||||
False,
|
||||
True,
|
||||
]
|
||||
|
||||
print()
|
||||
tbl.update([{"a": 1, "b": t2}])
|
||||
# 1 for `t`, 1 for `data`, 1 for argument to sys.getrefcount, and 3 for the table
|
||||
print(sys.getrefcount(t), "should be", 6)
|
||||
# 1 for `t2`, 1 for argument to sys.getrefcount, and 1 for the table
|
||||
print(sys.getrefcount(t2), "should be", 3)
|
||||
print(tbl.view().to_columns()["b"])
|
||||
assert list(_ is not None for _ in tbl.view().to_columns()["b"]) == [
|
||||
True,
|
||||
True,
|
||||
False,
|
||||
True,
|
||||
False,
|
||||
True,
|
||||
]
|
||||
|
||||
print()
|
||||
tbl.update([{"a": 1, "b": t2}])
|
||||
# 1 for `t`, 1 for `data`, 1 for argument to sys.getrefcount, and 3 for the table
|
||||
print(sys.getrefcount(t), "should be", 6)
|
||||
# 1 for `t2`, 1 for argument to sys.getrefcount, and 1 for the table
|
||||
print(sys.getrefcount(t2), "should be", 3)
|
||||
print(tbl.view().to_columns()["b"])
|
||||
assert list(_ is not None for _ in tbl.view().to_columns()["b"]) == [
|
||||
True,
|
||||
True,
|
||||
False,
|
||||
True,
|
||||
False,
|
||||
True,
|
||||
]
|
||||
|
||||
print()
|
||||
tbl.update([{"a": 1, "b": t2}])
|
||||
# 1 for `t`, 1 for `data`, 1 for argument to sys.getrefcount, and 3 for the table
|
||||
print(sys.getrefcount(t), "should be", 6)
|
||||
# 1 for `t2`, 1 for argument to sys.getrefcount, and 1 for the table
|
||||
print(sys.getrefcount(t2), "should be", 3)
|
||||
print(tbl.view().to_columns()["b"])
|
||||
assert list(_ is not None for _ in tbl.view().to_columns()["b"]) == [
|
||||
True,
|
||||
True,
|
||||
False,
|
||||
True,
|
||||
False,
|
||||
True,
|
||||
]
|
||||
|
||||
print()
|
||||
tbl.update([{"a": 2, "b": t2}])
|
||||
# 1 for `t`, 1 for `data`, 1 for argument to sys.getrefcount, and 3 for the table
|
||||
print(sys.getrefcount(t), "should be", 6)
|
||||
# 1 for `t2`, 1 for argument to sys.getrefcount, and 1 for the table
|
||||
print(sys.getrefcount(t2), "should be", 4)
|
||||
print(tbl.view().to_columns()["b"])
|
||||
assert list(_ is not None for _ in tbl.view().to_columns()["b"]) == [
|
||||
True,
|
||||
True,
|
||||
True,
|
||||
True,
|
||||
False,
|
||||
True,
|
||||
]
|
||||
|
||||
print()
|
||||
tbl.update([{"a": 2, "b": None}])
|
||||
# 1 for `t`, 1 for `data`, 1 for argument to sys.getrefcount, and 3 for the table
|
||||
print(sys.getrefcount(t), "should be", 6)
|
||||
# 1 for `t2`, 1 for argument to sys.getrefcount, and 1 for the table
|
||||
print(sys.getrefcount(t2), "should be", 3)
|
||||
print(tbl.view().to_columns()["b"])
|
||||
assert list(_ is not None for _ in tbl.view().to_columns()["b"]) == [
|
||||
True,
|
||||
True,
|
||||
False,
|
||||
True,
|
||||
False,
|
||||
True,
|
||||
]
|
||||
|
||||
print()
|
||||
tbl.update([{"a": 2, "b": t2}])
|
||||
# 1 for `t`, 1 for `data`, 1 for argument to sys.getrefcount, and 3 for the table
|
||||
print(sys.getrefcount(t), "should be", 6)
|
||||
# 1 for `t2`, 1 for argument to sys.getrefcount, and 1 for the table
|
||||
print(sys.getrefcount(t2), "should be", 4)
|
||||
print(tbl.view().to_columns()["b"])
|
||||
assert list(_ is not None for _ in tbl.view().to_columns()["b"]) == [
|
||||
True,
|
||||
True,
|
||||
True,
|
||||
True,
|
||||
False,
|
||||
True,
|
||||
]
|
||||
|
||||
print()
|
||||
tbl.update([{"a": 2, "b": None}])
|
||||
# 1 for `t`, 1 for `data`, 1 for argument to sys.getrefcount, and 3 for the table
|
||||
print(sys.getrefcount(t), "should be", 6)
|
||||
# 1 for `t2`, 1 for argument to sys.getrefcount, and 1 for the table
|
||||
print(sys.getrefcount(t2), "should be", 3)
|
||||
print(tbl.view().to_columns()["b"])
|
||||
assert list(_ is not None for _ in tbl.view().to_columns()["b"]) == [
|
||||
True,
|
||||
True,
|
||||
False,
|
||||
True,
|
||||
False,
|
||||
True,
|
||||
]
|
||||
|
||||
print()
|
||||
tbl.update([{"a": 3, "b": None}])
|
||||
# 1 for `t`, 1 for `data`, 1 for argument to sys.getrefcount, and 2 for the table
|
||||
print(sys.getrefcount(t), "should be", 5)
|
||||
# 1 for `t2`, 1 for argument to sys.getrefcount, and 1 for the table
|
||||
print(sys.getrefcount(t2), "should be", 3)
|
||||
print(tbl.view().to_columns()["b"])
|
||||
assert list(_ is not None for _ in tbl.view().to_columns()["b"]) == [
|
||||
True,
|
||||
True,
|
||||
False,
|
||||
False,
|
||||
False,
|
||||
True,
|
||||
]
|
||||
|
||||
print()
|
||||
tbl.update([{"a": 3, "b": None}])
|
||||
# 1 for `t`, 1 for `data`, 1 for argument to sys.getrefcount, and 2 for the table
|
||||
print(sys.getrefcount(t), "should be", 5)
|
||||
# 1 for `t2`, 1 for argument to sys.getrefcount, and 1 for the table
|
||||
print(sys.getrefcount(t2), "should be", 3)
|
||||
print(tbl.view().to_columns()["b"])
|
||||
assert list(_ is not None for _ in tbl.view().to_columns()["b"]) == [
|
||||
True,
|
||||
True,
|
||||
False,
|
||||
False,
|
||||
False,
|
||||
True,
|
||||
]
|
||||
|
||||
print()
|
||||
tbl.update([{"a": 5, "b": None}])
|
||||
# 1 for `t`, 1 for `data`, 1 for argument to sys.getrefcount, and 1 for the table
|
||||
print(sys.getrefcount(t), "should be", 4)
|
||||
# 1 for `t2`, 1 for argument to sys.getrefcount, and 1 for the table
|
||||
print(sys.getrefcount(t2), "should be", 3)
|
||||
print(tbl.view().to_columns()["b"])
|
||||
assert list(_ is not None for _ in tbl.view().to_columns()["b"]) == [
|
||||
True,
|
||||
True,
|
||||
False,
|
||||
False,
|
||||
False,
|
||||
False,
|
||||
]
|
||||
|
||||
print()
|
||||
tbl.update([{"a": 5, "b": None}])
|
||||
# 1 for `t`, 1 for `data`, 1 for argument to sys.getrefcount, and 1 for the table
|
||||
print(sys.getrefcount(t), "should be", 4)
|
||||
# 1 for `t2`, 1 for argument to sys.getrefcount, and 1 for the table
|
||||
print(sys.getrefcount(t2), "should be", 3)
|
||||
print(tbl.view().to_columns()["b"])
|
||||
assert list(_ is not None for _ in tbl.view().to_columns()["b"]) == [
|
||||
True,
|
||||
True,
|
||||
False,
|
||||
False,
|
||||
False,
|
||||
False,
|
||||
]
|
||||
|
||||
print()
|
||||
tbl.clear()
|
||||
assert tbl.size() == 0
|
||||
assert tbl.view().to_columns() == {}
|
||||
# 1 for `t`, one for `data`, one for argument to sys.getrefcount
|
||||
print(sys.getrefcount(t), "should be", 3)
|
||||
assert sys.getrefcount(t) == 3
|
||||
|
||||
|
||||
def run2():
|
||||
t = CustomObjectStore(1)
|
||||
t_ref_count = 2
|
||||
assert sys.getrefcount(t) == t_ref_count
|
||||
|
||||
indexes = set([0])
|
||||
|
||||
tbl = Table({"a": [0], "b": [t]}, index="a")
|
||||
assert sys.getrefcount(t) == 3
|
||||
t_ref_count += 1
|
||||
|
||||
assert tbl.schema() == {"a": int, "b": object}
|
||||
assert tbl.size() == 1
|
||||
assert tbl.view().to_columns() == {"a": [0], "b": [t]}
|
||||
|
||||
# seed a few to check
|
||||
tbl.remove([1])
|
||||
tbl.remove([1])
|
||||
tbl.remove([1])
|
||||
|
||||
for _ in range(10):
|
||||
pick = randint(1, 2) if indexes else 1
|
||||
if pick == 1:
|
||||
ind = randint(1, 10)
|
||||
while ind in indexes:
|
||||
ind = randint(1, 100)
|
||||
|
||||
print(
|
||||
"adding", ind, "refcount", t_ref_count, "should be", sys.getrefcount(t)
|
||||
)
|
||||
tbl.update({"a": [ind], "b": [t]})
|
||||
t_ref_count += 1
|
||||
indexes.add(ind)
|
||||
assert sys.getrefcount(t) == t_ref_count
|
||||
|
||||
else:
|
||||
ind = choice(list(indexes))
|
||||
indexes.remove(ind)
|
||||
tbl.remove([ind])
|
||||
t_ref_count -= 1
|
||||
print(
|
||||
"removing",
|
||||
ind,
|
||||
"refcount",
|
||||
t_ref_count,
|
||||
"should be",
|
||||
sys.getrefcount(t),
|
||||
)
|
||||
assert sys.getrefcount(t) == t_ref_count
|
||||
|
||||
print(t_ref_count)
|
||||
print(tbl.view().to_columns())
|
||||
|
||||
assert sys.getrefcount(t) == t_ref_count
|
||||
|
||||
print()
|
||||
tbl.clear()
|
||||
assert tbl.size() == 0
|
||||
assert tbl.view().to_columns() == {}
|
||||
# 1 for `t`, one for `data`, one for argument to sys.getrefcount
|
||||
print(sys.getrefcount(t), "should be", 2)
|
||||
assert sys.getrefcount(t) == 2
|
||||
@@ -0,0 +1,89 @@
|
||||
# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
# ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
# ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
# ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
# ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
# ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
import perspective as psp
|
||||
|
||||
client = psp.Server().new_local_client()
|
||||
|
||||
|
||||
class TestViewColumnPaths(object):
|
||||
def test_column_paths(self, superstore):
|
||||
tbl = client.table(superstore)
|
||||
view = tbl.view()
|
||||
paths = view.column_paths()
|
||||
assert paths == [
|
||||
"index",
|
||||
"Row ID",
|
||||
"Order ID",
|
||||
"Order Date",
|
||||
"Ship Date",
|
||||
"Ship Mode",
|
||||
"Customer ID",
|
||||
"Segment",
|
||||
"Country",
|
||||
"City",
|
||||
"State",
|
||||
"Postal Code",
|
||||
"Region",
|
||||
"Product ID",
|
||||
"Category",
|
||||
"Sub-Category",
|
||||
"Sales",
|
||||
"Quantity",
|
||||
"Discount",
|
||||
"Profit",
|
||||
]
|
||||
|
||||
view.delete()
|
||||
tbl.delete()
|
||||
|
||||
def test_column_paths_split_by(self, superstore):
|
||||
tbl = client.table(superstore)
|
||||
view = tbl.view(group_by=["State"], columns=["Sales"], split_by=["Ship Mode"])
|
||||
paths = view.column_paths()
|
||||
assert paths == [
|
||||
"First Class|Sales",
|
||||
"Second Class|Sales",
|
||||
"Standard Class|Sales",
|
||||
]
|
||||
|
||||
view.delete()
|
||||
tbl.delete()
|
||||
|
||||
def test_column_paths_split_by_range(self, superstore):
|
||||
tbl = client.table(superstore)
|
||||
view = tbl.view(group_by=["State"], columns=["Sales"], split_by=["Ship Mode"])
|
||||
paths = view.column_paths(start_col=1)
|
||||
assert paths == [
|
||||
"Second Class|Sales",
|
||||
"Standard Class|Sales",
|
||||
]
|
||||
|
||||
view.delete()
|
||||
view = tbl.view(group_by=["State"], columns=["Sales"], split_by=["Ship Mode"])
|
||||
paths = view.column_paths(start_col=1, end_col=2)
|
||||
assert paths == [
|
||||
"Second Class|Sales",
|
||||
"Standard Class|Sales",
|
||||
]
|
||||
|
||||
view.delete()
|
||||
tbl = client.table(superstore)
|
||||
view = tbl.view(group_by=["State"], columns=["Sales"], split_by=["Ship Mode"])
|
||||
paths = view.column_paths(end_col=1)
|
||||
assert paths == [
|
||||
"First Class|Sales",
|
||||
"Second Class|Sales",
|
||||
]
|
||||
|
||||
view.delete()
|
||||
tbl.delete()
|
||||
@@ -0,0 +1,124 @@
|
||||
# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
# ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
# ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
# ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
# ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
# ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
import perspective as psp
|
||||
|
||||
client = psp.Server().new_local_client()
|
||||
Table = client.table
|
||||
|
||||
|
||||
class TestDelete(object):
|
||||
# delete
|
||||
|
||||
def test_table_delete(self):
|
||||
data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}]
|
||||
tbl = Table(data)
|
||||
tbl.delete()
|
||||
# don't segfault
|
||||
|
||||
def test_table_delete_callback(self, sentinel):
|
||||
s = sentinel(False)
|
||||
|
||||
def callback():
|
||||
s.set(True)
|
||||
|
||||
data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}]
|
||||
tbl = Table(data)
|
||||
tbl.on_delete(callback)
|
||||
tbl.delete()
|
||||
assert s.get() is True
|
||||
|
||||
def test_table_delete_with_view(self, sentinel):
|
||||
s = sentinel(False)
|
||||
|
||||
def callback():
|
||||
s.set(True)
|
||||
|
||||
data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}]
|
||||
tbl = Table(data)
|
||||
tbl.on_delete(callback)
|
||||
view = tbl.view()
|
||||
view.delete()
|
||||
tbl.delete()
|
||||
assert s.get() is True
|
||||
|
||||
def test_table_delete_multiple_callback(self, sentinel):
|
||||
s1 = sentinel(False)
|
||||
s2 = sentinel(False)
|
||||
|
||||
def callback1():
|
||||
s1.set(True)
|
||||
|
||||
def callback2():
|
||||
s2.set(True)
|
||||
|
||||
tbl = Table([{"a": 1}])
|
||||
tbl.on_delete(callback1)
|
||||
tbl.on_delete(callback2)
|
||||
|
||||
tbl.delete()
|
||||
|
||||
assert s1.get() is True
|
||||
assert s2.get() is True
|
||||
|
||||
def test_table_remove_delete_callback(self, sentinel):
|
||||
s = sentinel(False)
|
||||
|
||||
def callback():
|
||||
s.set(True)
|
||||
|
||||
tbl = Table([{"a": 1}])
|
||||
callback_id = tbl.on_delete(callback)
|
||||
tbl.remove_delete(callback_id)
|
||||
|
||||
tbl.delete()
|
||||
|
||||
assert s.get() is False
|
||||
|
||||
def test_view_delete_multiple_callback(self, sentinel):
|
||||
s1 = sentinel(False)
|
||||
s2 = sentinel(False)
|
||||
|
||||
def callback1():
|
||||
s1.set(True)
|
||||
|
||||
def callback2():
|
||||
s2.set(True)
|
||||
|
||||
tbl = Table([{"a": 1}])
|
||||
view = tbl.view()
|
||||
|
||||
view.on_delete(callback1)
|
||||
view.on_delete(callback2)
|
||||
|
||||
view.delete()
|
||||
tbl.delete()
|
||||
|
||||
assert s1.get() is True
|
||||
assert s2.get() is True
|
||||
|
||||
def test_view_remove_delete_callback(self, sentinel):
|
||||
s = sentinel(False)
|
||||
|
||||
def callback():
|
||||
s.set(True)
|
||||
|
||||
tbl = Table([{"a": 1}])
|
||||
view = tbl.view()
|
||||
|
||||
callback_id = view.on_delete(callback)
|
||||
view.remove_delete(callback_id)
|
||||
|
||||
view.delete()
|
||||
tbl.delete()
|
||||
|
||||
assert s.get() is False
|
||||
@@ -0,0 +1,65 @@
|
||||
# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
# ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
# ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
# ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
# ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
# ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
from pytest import raises
|
||||
from perspective import PerspectiveError
|
||||
|
||||
import perspective as psp
|
||||
|
||||
client = psp.Server().new_local_client()
|
||||
Table = client.table
|
||||
|
||||
|
||||
class TestException(object):
|
||||
def test_instantiation_exception_table(self):
|
||||
from perspective import Table
|
||||
with raises(TypeError) as ex:
|
||||
Table()
|
||||
assert "Do not call Table's constructor directly" in str(ex.value)
|
||||
|
||||
def test_instantiation_exception_view(self):
|
||||
from perspective import View
|
||||
with raises(TypeError) as ex:
|
||||
View()
|
||||
assert "Do not call View's constructor directly" in str(ex.value)
|
||||
|
||||
def test_exception_from_core(self):
|
||||
tbl = Table({"a": [1, 2, 3]})
|
||||
|
||||
with raises(PerspectiveError) as ex:
|
||||
# creating view with unknown column should throw
|
||||
tbl.view(group_by=["b"])
|
||||
|
||||
assert str(ex.value) == "Abort(): Invalid column 'b' found in View group_by.\n"
|
||||
|
||||
def test_exception_from_core_catch_generic(self):
|
||||
tbl = Table({"a": [1, 2, 3]})
|
||||
# `PerspectiveCppError` should inherit from `Exception`
|
||||
with raises(Exception) as ex:
|
||||
tbl.view(group_by=["b"])
|
||||
|
||||
assert str(ex.value) == "Abort(): Invalid column 'b' found in View group_by.\n"
|
||||
|
||||
def test_exception_from_core_correct_types(self):
|
||||
tbl = Table({"a": [1, 2, 3]})
|
||||
|
||||
# `PerspectiveError` should be raised from the Python layer
|
||||
with raises(PerspectiveError) as ex:
|
||||
tbl.view()
|
||||
tbl.delete()
|
||||
|
||||
assert str(ex.value) == "Abort(): Cannot delete table with views"
|
||||
|
||||
with raises(PerspectiveError) as ex:
|
||||
tbl.view(group_by=["b"])
|
||||
|
||||
assert str(ex.value) == "Abort(): Invalid column 'b' found in View group_by.\n"
|
||||
@@ -0,0 +1,115 @@
|
||||
# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
# ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
# ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
# ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
# ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
# ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
import perspective as psp
|
||||
|
||||
client = psp.Server().new_local_client()
|
||||
|
||||
|
||||
class TestJoin:
|
||||
def test_inner_join_two_tables(self):
|
||||
left = client.table(
|
||||
[{"id": 1, "x": 10}, {"id": 2, "x": 20}, {"id": 3, "x": 30}]
|
||||
)
|
||||
right = client.table(
|
||||
[{"id": 1, "y": "a"}, {"id": 2, "y": "b"}, {"id": 4, "y": "d"}]
|
||||
)
|
||||
joined = client.join(left, right, "id")
|
||||
view = joined.view()
|
||||
json = view.to_json()
|
||||
assert len(json) == 2
|
||||
view.delete()
|
||||
joined.delete()
|
||||
right.delete()
|
||||
left.delete()
|
||||
|
||||
def test_join_has_correct_schema(self):
|
||||
left = client.table({"id": "integer", "x": "float"})
|
||||
right = client.table({"id": "integer", "y": "string"})
|
||||
joined = client.join(left, right, "id")
|
||||
schema = joined.schema()
|
||||
assert schema == {"id": "integer", "x": "float", "y": "string"}
|
||||
joined.delete()
|
||||
right.delete()
|
||||
left.delete()
|
||||
|
||||
def test_join_reacts_to_left_updates(self):
|
||||
left = client.table(
|
||||
[{"id": 1, "x": 10}, {"id": 2, "x": 20}]
|
||||
)
|
||||
right = client.table(
|
||||
[{"id": 1, "y": "a"}, {"id": 2, "y": "b"}]
|
||||
)
|
||||
joined = client.join(left, right, "id")
|
||||
view = joined.view()
|
||||
left.update([{"id": 1, "x": 99}])
|
||||
json = view.to_json()
|
||||
assert json == [
|
||||
{"id": 1, "x": 10, "y": "a"},
|
||||
{"id": 2, "x": 20, "y": "b"},
|
||||
{"id": 1, "x": 99, "y": "a"},
|
||||
]
|
||||
view.delete()
|
||||
joined.delete()
|
||||
right.delete()
|
||||
left.delete()
|
||||
|
||||
def test_left_join(self):
|
||||
left = client.table(
|
||||
[{"id": 1, "x": 10}, {"id": 2, "x": 20}, {"id": 3, "x": 30}]
|
||||
)
|
||||
right = client.table(
|
||||
[{"id": 1, "y": "a"}, {"id": 2, "y": "b"}]
|
||||
)
|
||||
joined = client.join(left, right, "id", "left")
|
||||
view = joined.view()
|
||||
json = view.to_json()
|
||||
assert len(json) == 3
|
||||
view.delete()
|
||||
joined.delete()
|
||||
right.delete()
|
||||
left.delete()
|
||||
|
||||
def test_join_by_table_names(self):
|
||||
left = client.table(
|
||||
[{"id": 1, "x": 10}, {"id": 2, "x": 20}],
|
||||
name="left_py",
|
||||
)
|
||||
right = client.table(
|
||||
[{"id": 1, "y": "a"}, {"id": 2, "y": "b"}],
|
||||
name="right_py",
|
||||
)
|
||||
joined = client.join("left_py", "right_py", "id")
|
||||
view = joined.view()
|
||||
json = view.to_json()
|
||||
assert len(json) == 2
|
||||
view.delete()
|
||||
joined.delete()
|
||||
right.delete()
|
||||
left.delete()
|
||||
|
||||
def test_join_mixed_table_and_string(self):
|
||||
left = client.table(
|
||||
[{"id": 1, "x": 10}, {"id": 2, "x": 20}]
|
||||
)
|
||||
right = client.table(
|
||||
[{"id": 1, "y": "a"}, {"id": 2, "y": "b"}],
|
||||
name="right_py_mixed",
|
||||
)
|
||||
joined = client.join(left, "right_py_mixed", "id")
|
||||
view = joined.view()
|
||||
json = view.to_json()
|
||||
assert len(json) == 2
|
||||
view.delete()
|
||||
joined.delete()
|
||||
right.delete()
|
||||
left.delete()
|
||||
@@ -0,0 +1,105 @@
|
||||
# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
# ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
# ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
# ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
# ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
# ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
|
||||
import psutil
|
||||
import os
|
||||
import perspective as psp
|
||||
|
||||
client = psp.Server().new_local_client()
|
||||
Table = client.table
|
||||
|
||||
|
||||
class TestDelete(object):
|
||||
# delete
|
||||
|
||||
def test_table_delete(self):
|
||||
process = psutil.Process(os.getpid())
|
||||
data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}]
|
||||
tbl = Table(data)
|
||||
tbl.delete()
|
||||
mem = process.memory_info().rss
|
||||
|
||||
for x in range(10000):
|
||||
data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}]
|
||||
tbl = Table(data)
|
||||
tbl.delete()
|
||||
|
||||
mem2 = process.memory_info().rss
|
||||
|
||||
# assert 1 < (max2 / max) < 1.01
|
||||
assert (mem2 - mem) < 2000000
|
||||
|
||||
def test_table_delete_with_view(self, sentinel):
|
||||
data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}]
|
||||
tbl = Table(data)
|
||||
|
||||
process = psutil.Process(os.getpid())
|
||||
mem = process.memory_info().rss
|
||||
for x in range(10000):
|
||||
view = tbl.view()
|
||||
view.delete()
|
||||
|
||||
tbl.delete()
|
||||
mem2 = process.memory_info().rss
|
||||
assert (mem2 - mem) < 2000000
|
||||
|
||||
|
||||
class TestExpressionVocab(object):
|
||||
# Regression tests for the `t_expression_vocab` leak fixed in PR #3175.
|
||||
# Pre-fix, updates against a view with a string-producing expression
|
||||
# interned each result into a new 4KB vocab page once the prior page
|
||||
# filled, and never deduplicated against older pages. RSS grew without
|
||||
# bound. The fix adds cross-page deduplication; these tests assert the
|
||||
# steady state is flat.
|
||||
|
||||
def test_string_expression_update_no_leak(self):
|
||||
long_literal = "X" * 256
|
||||
tbl = Table({"x": "integer", "c": "string"}, index="x")
|
||||
view = tbl.view(expressions={"e": 'concat("c", \'' + long_literal + "')"})
|
||||
|
||||
for _ in range(100):
|
||||
tbl.update([{"x": 1, "c": "value"}])
|
||||
|
||||
process = psutil.Process(os.getpid())
|
||||
mem = process.memory_info().rss
|
||||
for _ in range(5000):
|
||||
tbl.update([{"x": 1, "c": "value"}])
|
||||
mem2 = process.memory_info().rss
|
||||
|
||||
view.delete()
|
||||
tbl.delete()
|
||||
|
||||
assert (mem2 - mem) < 2000000
|
||||
|
||||
def test_string_expression_update_bounded_vocab_no_leak(self):
|
||||
# Cycles through a small fixed set of distinct values. Exercises the
|
||||
# cross-page `string_exists` lookup the fix relies on: once the
|
||||
# vocabulary is fully populated, subsequent interns must resolve to
|
||||
# existing pointers rather than allocating new ones.
|
||||
values = ["alpha", "bravo", "charlie", "delta", "echo"]
|
||||
tbl = Table({"x": "integer", "c": "string"}, index="x")
|
||||
view = tbl.view(expressions={"e": 'upper("c")'})
|
||||
|
||||
for i in range(100):
|
||||
tbl.update([{"x": 1, "c": values[i % len(values)]}])
|
||||
|
||||
process = psutil.Process(os.getpid())
|
||||
mem = process.memory_info().rss
|
||||
for i in range(5000):
|
||||
tbl.update([{"x": 1, "c": values[i % len(values)]}])
|
||||
mem2 = process.memory_info().rss
|
||||
|
||||
view.delete()
|
||||
tbl.delete()
|
||||
|
||||
assert (mem2 - mem) < 2000000
|
||||
@@ -0,0 +1,178 @@
|
||||
# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
# ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
# ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
# ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
# ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
# ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
import random
|
||||
import perspective as psp
|
||||
|
||||
client = psp.Server().new_local_client()
|
||||
Table = client.table
|
||||
|
||||
data = {"a": [1, 2, 3, 4], "b": ["a", "b", "c", "d"], "c": [True, False, True, False]}
|
||||
|
||||
|
||||
class TestPorts(object):
|
||||
def test_make_port_sequential(self):
|
||||
table = Table(data)
|
||||
port_ids = []
|
||||
|
||||
for i in range(10):
|
||||
port_ids.append(table.make_port())
|
||||
|
||||
assert port_ids == list(range(1, 11))
|
||||
|
||||
def test_make_port_sequential_and_update(self):
|
||||
table = Table(data)
|
||||
port_ids = []
|
||||
|
||||
for i in range(10):
|
||||
port_ids.append(table.make_port())
|
||||
|
||||
assert port_ids == list(range(1, 11))
|
||||
|
||||
for i in range(1, 11):
|
||||
table.update({"a": [i], "b": ["a"], "c": [True]}, port_id=i)
|
||||
|
||||
view = table.view()
|
||||
result = view.to_columns()
|
||||
|
||||
assert result == {
|
||||
"a": [1, 2, 3, 4] + [i for i in range(1, 11)],
|
||||
"b": ["a", "b", "c", "d"] + ["a" for i in range(10)],
|
||||
"c": [True, False, True, False] + [True for i in range(10)],
|
||||
}
|
||||
|
||||
def test_arbitary_port_updates(self):
|
||||
table = Table(data)
|
||||
port_ids = []
|
||||
|
||||
for i in range(10):
|
||||
port_ids.append(table.make_port())
|
||||
|
||||
assert port_ids == list(range(1, 11))
|
||||
|
||||
port = random.randint(0, 10)
|
||||
|
||||
table.update(data, port_id=port)
|
||||
|
||||
assert table.size() == 8
|
||||
|
||||
assert table.view().to_columns() == {
|
||||
"a": [1, 2, 3, 4] * 2,
|
||||
"b": ["a", "b", "c", "d"] * 2,
|
||||
"c": [True, False, True, False] * 2,
|
||||
}
|
||||
|
||||
def test_ports_should_only_notify_if_they_have_a_queued_update(self):
|
||||
table = Table(data)
|
||||
port_ids = []
|
||||
|
||||
for i in range(10):
|
||||
port_ids.append(table.make_port())
|
||||
|
||||
assert port_ids == list(range(1, 11))
|
||||
|
||||
view = table.view()
|
||||
ports_to_update = [random.randint(0, 10) for i in range(5)]
|
||||
|
||||
def callback(port_id):
|
||||
assert port_id in ports_to_update
|
||||
|
||||
view.on_update(callback)
|
||||
|
||||
for port in ports_to_update:
|
||||
table.update(data, port_id=port)
|
||||
|
||||
def test_ports_should_have_unique_deltas(self):
|
||||
table = Table(data)
|
||||
port_ids = []
|
||||
|
||||
for i in range(10):
|
||||
port_ids.append(table.make_port())
|
||||
|
||||
assert port_ids == list(range(1, 11))
|
||||
|
||||
view = table.view()
|
||||
ports_to_update = [random.randint(0, 10) for i in range(5)]
|
||||
unique_data = {
|
||||
port: [{"a": port, "b": str(port), "c": True}] for port in ports_to_update
|
||||
}
|
||||
|
||||
def callback(port_id, delta):
|
||||
assert port_id in ports_to_update
|
||||
_t = Table(delta)
|
||||
_v = _t.view()
|
||||
assert _v.to_records() == unique_data[port]
|
||||
_v.delete()
|
||||
_t.delete()
|
||||
|
||||
view.on_update(callback, mode="row")
|
||||
|
||||
for port in ports_to_update:
|
||||
table.update(unique_data[port], port_id=port)
|
||||
|
||||
def test_ports_should_queue_updates_properly(self):
|
||||
table = Table(data)
|
||||
port_ids = []
|
||||
|
||||
for i in range(10):
|
||||
port_ids.append(table.make_port())
|
||||
|
||||
assert port_ids == list(range(1, 11))
|
||||
|
||||
view = table.view()
|
||||
ports_to_update = [random.randint(0, 10) for i in range(5)]
|
||||
|
||||
def callback(port_id):
|
||||
assert port_id in ports_to_update
|
||||
|
||||
view.on_update(callback)
|
||||
|
||||
for port in ports_to_update:
|
||||
table.update(data, port_id=port)
|
||||
|
||||
def test_ports_multiple_tables_with_different_ports(self):
|
||||
server = Table(data)
|
||||
client = Table(data)
|
||||
|
||||
for i in range(random.randint(5, 15)):
|
||||
# reserve an arbitary number of ports
|
||||
server.make_port()
|
||||
|
||||
# port for client is now far above the ports "ON" the client, as the
|
||||
# client ports will begin creation at 1.
|
||||
server_port_for_client = server.make_port()
|
||||
client_port = client.make_port()
|
||||
|
||||
server_view = server.view()
|
||||
client_view = client.view()
|
||||
|
||||
# when the client updates, check whether the port id matches that
|
||||
# of the server, and complete the test.
|
||||
def client_callback(port_id, delta):
|
||||
if port_id == client_port:
|
||||
print("UPDATING SERVER")
|
||||
server.update(delta, port_id=server_port_for_client)
|
||||
|
||||
# when the server updates, pass the update back to the client
|
||||
def server_callback(port_id, delta):
|
||||
print("UPDATING CLIENT")
|
||||
assert port_id == server_port_for_client
|
||||
assert server.size() == 8
|
||||
server_view.delete()
|
||||
server.delete()
|
||||
client_view.delete()
|
||||
client.delete()
|
||||
|
||||
client_view.on_update(client_callback, mode="row")
|
||||
server_view.on_update(server_callback, mode="row")
|
||||
|
||||
client.update(data, port_id=client_port)
|
||||
@@ -0,0 +1,102 @@
|
||||
# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
# ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
# ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
# ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
# ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
# ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
import perspective as psp
|
||||
|
||||
client = psp.Server().new_local_client()
|
||||
Table = client.table
|
||||
|
||||
|
||||
class TestRemove(object):
|
||||
def test_remove_all(self):
|
||||
tbl = Table([{"a": "abc", "b": 123}], index="a")
|
||||
tbl.remove(["abc"])
|
||||
assert tbl.view().to_records() == []
|
||||
# assert tbl.size() == 0
|
||||
|
||||
def test_remove_nonsequential(self):
|
||||
tbl = Table(
|
||||
[{"a": "abc", "b": 123}, {"a": "def", "b": 456}, {"a": "efg", "b": 789}],
|
||||
index="a",
|
||||
)
|
||||
tbl.remove(["abc", "efg"])
|
||||
assert tbl.view().to_records() == [{"a": "def", "b": 456}]
|
||||
# assert tbl.size() == 1
|
||||
|
||||
def test_remove_multiple_single(self):
|
||||
tbl = Table({"a": "integer", "b": "string"}, index="a")
|
||||
for i in range(0, 10):
|
||||
tbl.update([{"a": i, "b": str(i)}])
|
||||
for i in range(1, 10):
|
||||
tbl.remove([i])
|
||||
assert tbl.view().to_records() == [{"a": 0, "b": "0"}]
|
||||
# assert tbl.size() == 0
|
||||
|
||||
def test_remove_expressions(self):
|
||||
schema = {"key": "string", "delta$": "float", "business_line": "string"}
|
||||
data = [
|
||||
{
|
||||
"key": "A",
|
||||
"delta$": 46412.3804275,
|
||||
},
|
||||
{
|
||||
"key": "B",
|
||||
"delta$": 2317615.875,
|
||||
},
|
||||
]
|
||||
|
||||
table = Table(schema, index="key")
|
||||
table.update(data)
|
||||
table.remove(["A"])
|
||||
view = table.view(
|
||||
group_by=["business_line"],
|
||||
columns=["delta$", "alias"],
|
||||
expressions={
|
||||
"alias": '"delta$"',
|
||||
},
|
||||
)
|
||||
|
||||
records = view.to_records()
|
||||
assert records == [
|
||||
{"__ROW_PATH__": [], "delta$": 2317615.875, "alias": 2317615.875},
|
||||
{"__ROW_PATH__": [None], "delta$": 2317615.875, "alias": 2317615.875},
|
||||
]
|
||||
|
||||
def test_remove_expressions_after_view(self):
|
||||
schema = {"key": "string", "delta$": "float", "business_line": "string"}
|
||||
data = [
|
||||
{
|
||||
"key": "A",
|
||||
"delta$": 46412.3804275,
|
||||
},
|
||||
{
|
||||
"key": "B",
|
||||
"delta$": 2317615.875,
|
||||
},
|
||||
]
|
||||
|
||||
table = Table(schema, index="key")
|
||||
table.update(data)
|
||||
view = table.view(
|
||||
group_by=["business_line"],
|
||||
columns=["delta$", "alias"],
|
||||
expressions={
|
||||
"alias": '"delta$"',
|
||||
},
|
||||
)
|
||||
|
||||
table.remove(["A"])
|
||||
records = view.to_records()
|
||||
assert records == [
|
||||
{"__ROW_PATH__": [], "delta$": 2317615.875, "alias": 2317615.875},
|
||||
{"__ROW_PATH__": [None], "delta$": 2317615.875, "alias": 2317615.875},
|
||||
]
|
||||
@@ -0,0 +1,641 @@
|
||||
# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
# ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
# ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
# ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
# ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
# ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
from datetime import date, datetime
|
||||
import perspective
|
||||
from pytest import mark, raises
|
||||
import pytest
|
||||
import perspective as psp
|
||||
|
||||
client = psp.Server().new_local_client()
|
||||
Table = client.table
|
||||
|
||||
|
||||
class TestTable:
|
||||
# table constructors
|
||||
|
||||
@mark.skip
|
||||
def test_empty_table(self):
|
||||
tbl = Table([])
|
||||
assert tbl.size() == 0
|
||||
|
||||
def test_table_not_iterable(self):
|
||||
data = {"a": 1}
|
||||
with raises(TypeError):
|
||||
Table(data)
|
||||
|
||||
# def test_table_synchronous_process(self):
|
||||
# tbl = Table({"a": [1, 2, 3]})
|
||||
# assert _PerspectiveStateManager.TO_PROCESS == {}
|
||||
# tbl.update({"a": [4, 5, 6]})
|
||||
# assert _PerspectiveStateManager.TO_PROCESS == {}
|
||||
|
||||
def test_table_csv(self):
|
||||
data = "x,y,z\n1,a,true\n2,b,false\n3,c,true\n4,d,false"
|
||||
tbl = Table(data)
|
||||
assert tbl.schema() == {"x": "integer", "y": "string", "z": "boolean"}
|
||||
view = tbl.view()
|
||||
assert view.to_columns() == {
|
||||
"x": [1, 2, 3, 4],
|
||||
"y": ["a", "b", "c", "d"],
|
||||
"z": [True, False, True, False],
|
||||
}
|
||||
|
||||
def test_table_csv_with_nulls(self):
|
||||
tbl = Table("x,y\n1,")
|
||||
assert tbl.schema() == {"x": "integer", "y": "string"}
|
||||
view = tbl.view()
|
||||
assert view.to_columns() == {"x": [1], "y": [None]}
|
||||
|
||||
def test_table_csv_with_nulls_updated(self):
|
||||
tbl = Table("x,y\n1,", index="x")
|
||||
assert tbl.schema() == {"x": "integer", "y": "string"}
|
||||
view = tbl.view()
|
||||
assert view.to_columns() == {"x": [1], "y": [None]}
|
||||
tbl.update("x,y\n1,abc\n2,123")
|
||||
assert view.to_columns() == {"x": [1, 2], "y": ["abc", "123"]}
|
||||
|
||||
def test_table_correct_csv_nan_end(self):
|
||||
tbl = Table("string,integer\n,1\n,2\nabc,3")
|
||||
assert tbl.schema() == {"string": "string", "integer": "integer"}
|
||||
assert tbl.size() == 3
|
||||
assert tbl.view().to_columns() == {
|
||||
"string": ["", "", "abc"],
|
||||
"integer": [1, 2, 3],
|
||||
}
|
||||
|
||||
def test_table_correct_csv_nan_intermittent(self):
|
||||
tbl = Table("string,float\nabc,\n,2.5\nghi,")
|
||||
assert tbl.schema() == {"string": "string", "float": "float"}
|
||||
assert tbl.size() == 3
|
||||
assert tbl.view().to_columns() == {
|
||||
"string": ["abc", "", "ghi"],
|
||||
"float": [None, 2.5, None],
|
||||
}
|
||||
|
||||
def test_table_records_from_string_with_format_override(self):
|
||||
data = '{"x": [1,2,3], "y": [4,5,6]}'
|
||||
tbl = Table(data, format="columns")
|
||||
view = tbl.view()
|
||||
assert view.to_columns() == {
|
||||
"x": [1, 2, 3],
|
||||
"y": [4, 5, 6],
|
||||
}
|
||||
|
||||
def test_table_string_column_with_nulls_update_and_filter(self):
|
||||
tbl = Table(
|
||||
[
|
||||
{"a": "1", "b": 2, "c": "3"},
|
||||
{"a": "2", "b": 3, "c": "4"},
|
||||
{"a": "3", "b": 3, "c": None},
|
||||
],
|
||||
index="a",
|
||||
)
|
||||
view = tbl.view(filter=[["c", "==", "4"]])
|
||||
records = view.to_records()
|
||||
tbl.update([{"a": "4", "b": 10}])
|
||||
assert records == view.to_records()
|
||||
|
||||
def test_table_int(self):
|
||||
data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}]
|
||||
tbl = Table(data)
|
||||
assert tbl.size() == 2
|
||||
assert tbl.schema() == {"a": "integer", "b": "integer"}
|
||||
|
||||
def test_table_int_column_names(self):
|
||||
data = {"a": [1, 2, 3], 0: [4, 5, 6]}
|
||||
Table(data)
|
||||
|
||||
def test_table_nones(self):
|
||||
none_data = [{"a": 1, "b": None}, {"a": None, "b": 2}]
|
||||
tbl = Table(none_data)
|
||||
assert tbl.size() == 2
|
||||
assert tbl.schema() == {"a": "integer", "b": "integer"}
|
||||
|
||||
def test_table_bool(self):
|
||||
bool_data = [{"a": True, "b": False}, {"a": True, "b": True}]
|
||||
tbl = Table(bool_data)
|
||||
assert tbl.size() == 2
|
||||
assert tbl.schema() == {"a": "boolean", "b": "boolean"}
|
||||
|
||||
def test_table_bool_str(self):
|
||||
bool_data = [{"a": "True", "b": "False"}, {"a": "True", "b": "True"}]
|
||||
tbl = Table(bool_data)
|
||||
assert tbl.size() == 2
|
||||
assert tbl.schema() == {"a": "boolean", "b": "boolean"}
|
||||
assert tbl.view().to_columns() == {"a": [True, True], "b": [False, True]}
|
||||
|
||||
def test_table_float(self):
|
||||
float_data = [{"a": 1.5, "b": 2.5}, {"a": 3.2, "b": 3.1}]
|
||||
tbl = Table(float_data)
|
||||
assert tbl.size() == 2
|
||||
assert tbl.schema() == {"a": "float", "b": "float"}
|
||||
|
||||
def test_table_str(self):
|
||||
str_data = [{"a": "b", "b": "b"}, {"a": "3", "b": "3"}]
|
||||
tbl = Table(str_data)
|
||||
assert tbl.size() == 2
|
||||
assert tbl.schema() == {"a": "string", "b": "string"}
|
||||
|
||||
def test_table_str_with_escape(self):
|
||||
str_data = [
|
||||
{"a": 'abc"def"', "b": 'abc"def"'},
|
||||
{"a": "abc'def'", "b": "abc'def'"},
|
||||
]
|
||||
tbl = Table(str_data)
|
||||
assert tbl.size() == 2
|
||||
assert tbl.schema() == {"a": "string", "b": "string"}
|
||||
assert tbl.view().to_records() == str_data
|
||||
|
||||
def test_table_str_unicode(self):
|
||||
str_data = [{"a": "ȀȁȀȃȀȁȀȃȀȁȀȃȀȁȀȃ", "b": "ЖДфйЖДфйЖДфйЖДфй"}]
|
||||
tbl = Table(str_data)
|
||||
assert tbl.size() == 1
|
||||
assert tbl.schema() == {"a": "string", "b": "string"}
|
||||
assert tbl.view().to_records() == str_data
|
||||
|
||||
def test_table_date(self):
|
||||
str_data = [{"a": date.today(), "b": date.today()}]
|
||||
tbl = Table(str_data)
|
||||
assert tbl.size() == 1
|
||||
assert tbl.schema() == {"a": "date", "b": "date"}
|
||||
|
||||
def test_table_datetime(self):
|
||||
str_data = [{"a": datetime.now(), "b": datetime.now()}]
|
||||
tbl = Table(str_data)
|
||||
assert tbl.size() == 1
|
||||
assert tbl.schema() == {"a": "datetime", "b": "datetime"}
|
||||
|
||||
def test_table_columnar(self):
|
||||
data = {"a": [1, 2, 3], "b": [4, 5, 6]}
|
||||
tbl = Table(data)
|
||||
assert tbl.columns() == ["a", "b"]
|
||||
assert tbl.size() == 3
|
||||
assert tbl.schema() == {"a": "integer", "b": "integer"}
|
||||
|
||||
def test_table_columnar_mixed_length(self):
|
||||
data = [{"a": 1.5, "b": 2.5}, {"a": 3.2}]
|
||||
tbl = Table(data)
|
||||
assert tbl.size() == 2
|
||||
assert tbl.schema() == {"a": "float", "b": "float"}
|
||||
assert tbl.view().to_records() == [{"a": 1.5, "b": 2.5}, {"a": 3.2, "b": None}]
|
||||
|
||||
# schema
|
||||
|
||||
def test_table_schema(self):
|
||||
data = {
|
||||
"a": "integer",
|
||||
"b": "float",
|
||||
"c": "string",
|
||||
"d": "boolean",
|
||||
"e": "date",
|
||||
"f": "datetime",
|
||||
}
|
||||
|
||||
tbl = Table(data)
|
||||
|
||||
assert tbl.schema() == {
|
||||
"a": "integer",
|
||||
"b": "float",
|
||||
"c": "string",
|
||||
"d": "boolean",
|
||||
"e": "date",
|
||||
"f": "datetime",
|
||||
}
|
||||
|
||||
def test_table_readable_string_schema(self):
|
||||
data = {
|
||||
"a": "integer",
|
||||
"b": "float",
|
||||
"c": "string",
|
||||
"d": "boolean",
|
||||
"e": "date",
|
||||
"f": "datetime",
|
||||
}
|
||||
|
||||
tbl = Table(data)
|
||||
|
||||
assert tbl.schema() == {
|
||||
"a": "integer",
|
||||
"b": "float",
|
||||
"c": "string",
|
||||
"d": "boolean",
|
||||
"e": "date",
|
||||
"f": "datetime",
|
||||
}
|
||||
|
||||
def test_table_output_readable_schema(self):
|
||||
data = {
|
||||
"a": "integer",
|
||||
"b": "float",
|
||||
"c": "string",
|
||||
"d": "boolean",
|
||||
"e": "date",
|
||||
"f": "datetime",
|
||||
}
|
||||
|
||||
tbl = Table(data)
|
||||
|
||||
assert tbl.schema() == {
|
||||
"a": "integer",
|
||||
"b": "float",
|
||||
"c": "string",
|
||||
"d": "boolean",
|
||||
"e": "date",
|
||||
"f": "datetime",
|
||||
}
|
||||
|
||||
def test_table_mixed_schema(self):
|
||||
data = {
|
||||
"a": "integer",
|
||||
"b": "float",
|
||||
"c": "string",
|
||||
"d": "boolean",
|
||||
"e": "date",
|
||||
"f": "datetime",
|
||||
}
|
||||
|
||||
tbl = Table(data)
|
||||
|
||||
assert tbl.schema() == {
|
||||
"a": "integer",
|
||||
"b": "float",
|
||||
"c": "string",
|
||||
"d": "boolean",
|
||||
"e": "date",
|
||||
"f": "datetime",
|
||||
}
|
||||
|
||||
def test_table_output_string_schema(self):
|
||||
data = {
|
||||
"a": "integer",
|
||||
"b": "float",
|
||||
"c": "string",
|
||||
"d": "boolean",
|
||||
"e": "date",
|
||||
"f": "datetime",
|
||||
}
|
||||
|
||||
tbl = Table(data)
|
||||
|
||||
assert tbl.schema() == {
|
||||
"a": "integer",
|
||||
"b": "float",
|
||||
"c": "string",
|
||||
"d": "boolean",
|
||||
"e": "date",
|
||||
"f": "datetime",
|
||||
}
|
||||
|
||||
def test_table_symmetric_schema(self):
|
||||
data = {
|
||||
"a": [1, 2, 3],
|
||||
"b": [1.5, 2.5, 3.5],
|
||||
"c": ["a", "b", "c"],
|
||||
"d": [True, False, True],
|
||||
"e": [date.today(), date.today(), date.today()],
|
||||
"f": [datetime.now(), datetime.now(), datetime.now()],
|
||||
}
|
||||
|
||||
tbl = Table(data)
|
||||
schema = tbl.schema()
|
||||
|
||||
assert schema == {
|
||||
"a": "integer",
|
||||
"b": "float",
|
||||
"c": "string",
|
||||
"d": "boolean",
|
||||
"e": "date",
|
||||
"f": "datetime",
|
||||
}
|
||||
|
||||
tbl2 = Table(schema)
|
||||
|
||||
assert tbl2.schema() == schema
|
||||
|
||||
def test_table_symmetric_string_schema(self):
|
||||
data = {
|
||||
"a": [1, 2, 3],
|
||||
"b": [1.5, 2.5, 3.5],
|
||||
"c": ["a", "b", "c"],
|
||||
"d": [True, False, True],
|
||||
"e": [date.today(), date.today(), date.today()],
|
||||
"f": [datetime.now(), datetime.now(), datetime.now()],
|
||||
}
|
||||
|
||||
tbl = Table(data)
|
||||
schema = tbl.schema()
|
||||
|
||||
assert schema == {
|
||||
"a": "integer",
|
||||
"b": "float",
|
||||
"c": "string",
|
||||
"d": "boolean",
|
||||
"e": "date",
|
||||
"f": "datetime",
|
||||
}
|
||||
|
||||
tbl2 = Table(schema)
|
||||
|
||||
assert tbl2.schema() == schema
|
||||
|
||||
def test_table_python_schema(self):
|
||||
data = {
|
||||
"a": int,
|
||||
"b": float,
|
||||
"c": str,
|
||||
"d": bool,
|
||||
"e": date,
|
||||
"f": datetime,
|
||||
}
|
||||
|
||||
tbl = Table(data)
|
||||
assert tbl.schema() == {
|
||||
"a": "integer",
|
||||
"b": "float",
|
||||
"c": "string",
|
||||
"d": "boolean",
|
||||
"e": "date",
|
||||
"f": "datetime",
|
||||
}
|
||||
|
||||
# is_valid_filter
|
||||
|
||||
# def test_table_is_valid_filter_str(self):
|
||||
# filter = ["a", "<", 1]
|
||||
# data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}]
|
||||
# tbl = Table(data)
|
||||
# assert tbl.is_valid_filter(filter) is True
|
||||
|
||||
# def test_table_not_is_valid_filter_str(self):
|
||||
# filter = ["a", "<", None]
|
||||
# data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}]
|
||||
# tbl = Table(data)
|
||||
# assert tbl.is_valid_filter(filter) is False
|
||||
|
||||
# def test_table_is_valid_filter_filter_op(self):
|
||||
# filter = ["a", t_filter_op.FILTER_OP_IS_NULL]
|
||||
# data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}]
|
||||
# tbl = Table(data)
|
||||
# assert tbl.is_valid_filter(filter) is True
|
||||
|
||||
# def test_table_not_is_valid_filter_filter_op(self):
|
||||
# filter = ["a", t_filter_op.FILTER_OP_GT, None]
|
||||
# data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}]
|
||||
# tbl = Table(data)
|
||||
# assert tbl.is_valid_filter(filter) is False
|
||||
|
||||
# def test_table_is_valid_filter_date(self):
|
||||
# filter = ["a", t_filter_op.FILTER_OP_GT, date.today()]
|
||||
# tbl = Table({"a": "date"})
|
||||
# assert tbl.is_valid_filter(filter) is True
|
||||
|
||||
# def test_table_not_is_valid_filter_date(self):
|
||||
# filter = ["a", t_filter_op.FILTER_OP_GT, None]
|
||||
# tbl = Table({"a": "date"})
|
||||
# assert tbl.is_valid_filter(filter) is False
|
||||
|
||||
# def test_table_is_valid_filter_datetime(self):
|
||||
# filter = ["a", t_filter_op.FILTER_OP_GT, datetime.now()]
|
||||
# tbl = Table({"a": "datetime"})
|
||||
# assert tbl.is_valid_filter(filter) is True
|
||||
|
||||
# def test_table_not_is_valid_filter_datetime(self):
|
||||
# filter = ["a", t_filter_op.FILTER_OP_GT, None]
|
||||
# tbl = Table({"a": "datetime"})
|
||||
# assert tbl.is_valid_filter(filter) is False
|
||||
|
||||
# def test_table_is_valid_filter_datetime_str(self):
|
||||
# filter = ["a", t_filter_op.FILTER_OP_GT, "7/11/2019 5:30PM"]
|
||||
# tbl = Table({"a": "datetime"})
|
||||
# assert tbl.is_valid_filter(filter) is True
|
||||
|
||||
# def test_table_not_is_valid_filter_datetime_str(self):
|
||||
# filter = ["a", t_filter_op.FILTER_OP_GT, None]
|
||||
# tbl = Table({"a": "datetime"})
|
||||
# assert tbl.is_valid_filter(filter) is False
|
||||
|
||||
# def test_table_is_valid_filter_ignores_not_in_schema(self):
|
||||
# filter = ["not in schema", "<", 1]
|
||||
# data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}]
|
||||
# tbl = Table(data)
|
||||
# assert tbl.is_valid_filter(filter) is True
|
||||
|
||||
# index
|
||||
|
||||
def test_table_index(self):
|
||||
data = [{"a": 1, "b": 2}, {"a": 1, "b": 4}]
|
||||
tbl = Table(data, index="a")
|
||||
assert tbl.size() == 1
|
||||
assert tbl.view().to_records() == [{"a": 1, "b": 4}]
|
||||
|
||||
def test_table_index_from_schema(self):
|
||||
data = [{"a": 1, "b": 2}, {"a": 1, "b": 4}]
|
||||
tbl = Table({"a": "integer", "b": "integer"}, index="a")
|
||||
assert tbl.size() == 0
|
||||
tbl.update(data)
|
||||
assert tbl.view().to_records() == [{"a": 1, "b": 4}]
|
||||
|
||||
# index with None in column
|
||||
|
||||
def test_table_index_int_with_none(self):
|
||||
tbl = Table({"a": [0, 1, 2, None, None], "b": [4, 3, 2, 1, 0]}, index="a")
|
||||
assert tbl.view().to_columns() == {
|
||||
"a": [None, 0, 1, 2],
|
||||
"b": [0, 4, 3, 2],
|
||||
} # second `None` replaces first
|
||||
|
||||
def test_table_index_float_with_none(self):
|
||||
tbl = Table({"a": [0.0, 1.5, 2.5, None, None], "b": [4, 3, 2, 1, 0]}, index="a")
|
||||
assert tbl.view().to_columns() == {
|
||||
"a": [None, 0, 1.5, 2.5],
|
||||
"b": [0, 4, 3, 2],
|
||||
} # second `None` replaces first
|
||||
|
||||
def test_table_index_bool_with_none(self):
|
||||
# bools cannot be used as primary key columns
|
||||
with raises(perspective.PerspectiveError):
|
||||
Table({"a": [True, False, None, True], "b": [4, 3, 2, 1]}, index="a")
|
||||
|
||||
def test_table_index_date_with_none(self):
|
||||
tbl = Table(
|
||||
{
|
||||
"a": [date(2019, 7, 11), None, date(2019, 3, 12), date(2011, 3, 10)],
|
||||
"b": [4, 3, 2, 1],
|
||||
},
|
||||
index="a",
|
||||
)
|
||||
|
||||
def ts(x):
|
||||
return int(datetime.timestamp(x) * 1000)
|
||||
|
||||
assert tbl.view().to_columns() == {
|
||||
"a": [
|
||||
None,
|
||||
ts(datetime(2011, 3, 10)),
|
||||
ts(datetime(2019, 3, 12)),
|
||||
ts(datetime(2019, 7, 11)),
|
||||
],
|
||||
"b": [3, 1, 2, 4],
|
||||
}
|
||||
|
||||
def test_table_index_datetime_with_none(self, util):
|
||||
tbl = Table(
|
||||
{
|
||||
"a": [
|
||||
datetime(2019, 7, 11, 15, 30),
|
||||
None,
|
||||
datetime(2019, 7, 11, 12, 10),
|
||||
datetime(2019, 7, 11, 5, 0),
|
||||
],
|
||||
"b": [4, 3, 2, 1],
|
||||
},
|
||||
index="a",
|
||||
)
|
||||
assert tbl.view().to_columns() == {
|
||||
"a": [
|
||||
None,
|
||||
util.to_timestamp(datetime(2019, 7, 11, 5, 0)),
|
||||
util.to_timestamp(datetime(2019, 7, 11, 12, 10)),
|
||||
util.to_timestamp(datetime(2019, 7, 11, 15, 30)),
|
||||
],
|
||||
"b": [3, 1, 2, 4],
|
||||
}
|
||||
|
||||
def test_table_index_str_with_none(self):
|
||||
tbl = Table({"a": ["", "a", None, "b"], "b": [4, 3, 2, 1]}, index="a")
|
||||
assert tbl.view().to_columns() == {"a": [None, "", "a", "b"], "b": [2, 4, 3, 1]}
|
||||
|
||||
def test_table_get_index(self):
|
||||
tbl = Table({"a": ["", "a", None, "b"], "b": [4, 3, 2, 1]}, index="a")
|
||||
assert tbl.get_index() == "a"
|
||||
|
||||
def test_table_get_index_none(self):
|
||||
tbl = Table({"a": ["", "a", None, "b"], "b": [4, 3, 2, 1]})
|
||||
assert tbl.get_index() is None
|
||||
|
||||
# limit
|
||||
|
||||
def test_table_limit(self):
|
||||
data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}]
|
||||
tbl = Table(data, limit=1)
|
||||
assert tbl.size() == 1
|
||||
assert tbl.view().to_records() == [{"a": 3, "b": 4}]
|
||||
|
||||
def test_table_get_limit(self):
|
||||
data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}]
|
||||
tbl = Table(data, limit=1)
|
||||
assert tbl.get_limit() == 1
|
||||
|
||||
def test_table_get_limit_none(self):
|
||||
data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}]
|
||||
tbl = Table(data)
|
||||
assert tbl.get_limit() is None
|
||||
|
||||
# num_views
|
||||
|
||||
@pytest.mark.skip
|
||||
def test_table_get_num_views(self):
|
||||
data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}]
|
||||
tbl = Table(data)
|
||||
assert tbl.get_num_views() == 0
|
||||
v1 = tbl.view()
|
||||
v2 = tbl.view()
|
||||
v3 = tbl.view()
|
||||
assert tbl.get_num_views() == 3
|
||||
|
||||
v1.delete()
|
||||
v2.delete()
|
||||
assert tbl.get_num_views() == 1
|
||||
failed = False
|
||||
try:
|
||||
tbl.delete()
|
||||
except perspective.PerspectiveError:
|
||||
failed = True
|
||||
assert failed
|
||||
v3.delete()
|
||||
assert tbl.get_num_views() == 0
|
||||
tbl.delete()
|
||||
|
||||
# clear
|
||||
|
||||
def test_table_clear(self):
|
||||
data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}]
|
||||
tbl = Table(data)
|
||||
tbl.clear()
|
||||
view = tbl.view()
|
||||
assert view.to_records() == []
|
||||
|
||||
# replace
|
||||
|
||||
def test_table_replace(self):
|
||||
data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}]
|
||||
data2 = [{"a": 3, "b": 4}, {"a": 1, "b": 2}]
|
||||
tbl = Table(data)
|
||||
tbl.replace(data2)
|
||||
assert tbl.view().to_records() == data2
|
||||
|
||||
def test_table_replace_views_should_preserve(self):
|
||||
data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}]
|
||||
data2 = [{"a": 3, "b": 4}, {"a": 1, "b": 2}]
|
||||
tbl = Table(data)
|
||||
view = tbl.view(group_by=["a"], split_by=["b"])
|
||||
first = view.to_records()
|
||||
tbl.replace(data2)
|
||||
assert view.to_records() == first
|
||||
|
||||
def test_table_replace_should_fire_on_update(self, sentinel):
|
||||
data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}]
|
||||
data2 = [{"a": 3, "b": 4}, {"a": 1, "b": 2}]
|
||||
tbl = Table(data)
|
||||
view = tbl.view()
|
||||
|
||||
s = sentinel(False)
|
||||
|
||||
def updater(port_id):
|
||||
s.set(True)
|
||||
|
||||
view.on_update(updater)
|
||||
tbl.replace(data2)
|
||||
tbl.size()
|
||||
assert s.get()
|
||||
|
||||
def test_table_replace_should_fire_on_update_with_delta(self, sentinel):
|
||||
data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}]
|
||||
data2 = [{"a": 3, "b": 4}, {"a": 1, "b": 2}]
|
||||
tbl = Table(data)
|
||||
view = tbl.view()
|
||||
|
||||
s = sentinel(False)
|
||||
|
||||
def updater(port_id, delta):
|
||||
# assert port_id == 0
|
||||
table2 = Table(delta)
|
||||
assert table2.view().to_records() == data2
|
||||
s.set(True)
|
||||
|
||||
view.on_update(updater, mode="row")
|
||||
tbl.replace(data2)
|
||||
tbl.size()
|
||||
assert s.get() is True
|
||||
|
||||
def test_float32_table_construction(self):
|
||||
float_data = [{"a": 1.5, "b": 2.5}, {"a": 3.2, "b": 3.1}]
|
||||
tbl = Table(float_data, index="a")
|
||||
assert tbl.size() == 2
|
||||
assert tbl.schema() == {"a": "float", "b": "float"}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import pytest
|
||||
|
||||
pytest.main(["-vv", "-s", __file__])
|
||||
@@ -0,0 +1,500 @@
|
||||
# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
# ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
# ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
# ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
# ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
# ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
import os.path
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from perspective.tests.conftest import Util
|
||||
import pyarrow as pa
|
||||
from datetime import date, datetime
|
||||
import perspective as psp
|
||||
|
||||
|
||||
client = psp.Server().new_local_client()
|
||||
Table = client.table
|
||||
|
||||
DATE32_ARROW = os.path.join(os.path.dirname(__file__), "arrow", "date32.arrow")
|
||||
DATE64_ARROW = os.path.join(os.path.dirname(__file__), "arrow", "date64.arrow")
|
||||
DICT_ARROW = os.path.join(os.path.dirname(__file__), "arrow", "dict.arrow")
|
||||
|
||||
|
||||
names = ["a", "b", "c", "d"]
|
||||
|
||||
# Create sample data for every integer type
|
||||
ALL_INTEGERS_DATA = {
|
||||
"int8": pa.array([1, 2, 3], type=pa.int8()),
|
||||
"int16": pa.array([1000, 2000, 3000], type=pa.int16()),
|
||||
"int32": pa.array([100000, 200000, 300000], type=pa.int32()),
|
||||
"int64": pa.array([10000000000, 20000000000, 30000000000], type=pa.int64()),
|
||||
"uint8": pa.array([1, 2, 3], type=pa.uint8()),
|
||||
"uint16": pa.array([1000, 2000, 3000], type=pa.uint16()),
|
||||
"uint32": pa.array([100000, 200000, 300000], type=pa.uint32()),
|
||||
"uint64": pa.array([10000000000, 20000000000, 30000000000], type=pa.uint64()),
|
||||
"float32": pa.array([100000.0, 200000.0, 300000.0], type=pa.float32()),
|
||||
"float64": pa.array(
|
||||
[10000000000.0, 20000000000.0, 30000000000.0], type=pa.float64()
|
||||
),
|
||||
}
|
||||
|
||||
ALL_INTEGERS_TABLE = pa.Table.from_pydict(ALL_INTEGERS_DATA)
|
||||
|
||||
|
||||
class TestTableArrow(object):
|
||||
def test_table_with_integer_types(self):
|
||||
tbl = Table(ALL_INTEGERS_TABLE)
|
||||
assert tbl.size() == 3
|
||||
assert tbl.schema() == {
|
||||
"int8": "integer",
|
||||
"int16": "integer",
|
||||
"int32": "integer",
|
||||
"int64": "integer",
|
||||
"uint8": "integer",
|
||||
"uint16": "integer",
|
||||
"uint32": "integer",
|
||||
"uint64": "integer",
|
||||
"float32": "float",
|
||||
"float64": "float",
|
||||
}
|
||||
for k, values in ALL_INTEGERS_DATA.items():
|
||||
v = tbl.view(filter=[[k, "==", values[0].as_py()]])
|
||||
assert len(v.to_json()) == 1
|
||||
|
||||
def test_table_arrow_loads_date32_file(self, util: Util):
|
||||
with open(DATE32_ARROW, mode="rb") as file: # b is important -> binary
|
||||
tbl = Table(file.read())
|
||||
assert tbl.schema() == {
|
||||
"jan-2019": "date",
|
||||
"feb-2020": "date",
|
||||
"mar-2019": "date",
|
||||
"apr-2020": "date",
|
||||
}
|
||||
assert tbl.size() == 31
|
||||
view = tbl.view()
|
||||
assert view.to_columns() == {
|
||||
"jan-2019": [
|
||||
util.to_timestamp(datetime(2019, 1, i)) for i in range(1, 32)
|
||||
],
|
||||
"feb-2020": [
|
||||
util.to_timestamp(datetime(2020, 2, i)) for i in range(1, 30)
|
||||
]
|
||||
+ [None, None],
|
||||
"mar-2019": [
|
||||
util.to_timestamp(datetime(2019, 3, i)) for i in range(1, 32)
|
||||
],
|
||||
"apr-2020": [
|
||||
util.to_timestamp(datetime(2020, 4, i)) for i in range(1, 31)
|
||||
]
|
||||
+ [None],
|
||||
}
|
||||
|
||||
def test_table_arrow_loads_date64_file(self, util: Util):
|
||||
with open(DATE64_ARROW, mode="rb") as file: # b is important -> binary
|
||||
tbl = Table(file.read())
|
||||
assert tbl.schema() == {
|
||||
"jan-2019": "date",
|
||||
"feb-2020": "date",
|
||||
"mar-2019": "date",
|
||||
"apr-2020": "date",
|
||||
}
|
||||
assert tbl.size() == 31
|
||||
view = tbl.view()
|
||||
assert view.to_columns() == {
|
||||
"jan-2019": [
|
||||
util.to_timestamp(datetime(2019, 1, i)) for i in range(1, 32)
|
||||
],
|
||||
"feb-2020": [
|
||||
util.to_timestamp(datetime(2020, 2, i)) for i in range(1, 30)
|
||||
]
|
||||
+ [None, None],
|
||||
"mar-2019": [
|
||||
util.to_timestamp(datetime(2019, 3, i)) for i in range(1, 32)
|
||||
],
|
||||
"apr-2020": [
|
||||
util.to_timestamp(datetime(2020, 4, i)) for i in range(1, 31)
|
||||
]
|
||||
+ [None],
|
||||
}
|
||||
|
||||
def test_table_arrow_loads_dict_file(self):
|
||||
with open(DICT_ARROW, mode="rb") as file: # b is important -> binary
|
||||
tbl = Table(file.read())
|
||||
assert tbl.schema() == {"a": "string", "b": "string"}
|
||||
assert tbl.size() == 5
|
||||
assert tbl.view().to_columns() == {
|
||||
"a": ["abc", "def", "def", None, "abc"],
|
||||
"b": ["klm", "hij", None, "hij", "klm"],
|
||||
}
|
||||
|
||||
# streams
|
||||
|
||||
def test_table_arrow_loads_int_stream(self, util):
|
||||
data = [list(range(10)) for i in range(4)]
|
||||
arrow_data = util.make_arrow(names, data)
|
||||
tbl = Table(arrow_data)
|
||||
assert tbl.size() == 10
|
||||
assert tbl.schema() == {
|
||||
"a": "integer",
|
||||
"b": "integer",
|
||||
"c": "integer",
|
||||
"d": "integer",
|
||||
}
|
||||
assert tbl.view().to_columns() == {
|
||||
"a": data[0],
|
||||
"b": data[1],
|
||||
"c": data[2],
|
||||
"d": data[3],
|
||||
}
|
||||
|
||||
def test_empty_arrow(self, util):
|
||||
table = pa.table(
|
||||
{
|
||||
"col1": [1, 2, 3],
|
||||
"col2": ["abc", "foo", "bar"],
|
||||
}
|
||||
)
|
||||
|
||||
empty_table = table.schema.empty_table()
|
||||
assert client.table(table, name="table2").size() == 3
|
||||
assert client.table(empty_table, name="table_empty_bad").size() == 0
|
||||
assert client.table(table, name="table3").schema() == {
|
||||
"col1": "integer",
|
||||
"col2": "string",
|
||||
}
|
||||
|
||||
assert client.table(empty_table, name="table4").schema() == {
|
||||
"col1": "integer",
|
||||
"col2": "string",
|
||||
}
|
||||
|
||||
def test_table_arrow_loads_float_stream(self, util):
|
||||
data = [[i for i in range(10)], [i * 1.5 for i in range(10)]]
|
||||
arrow_data = util.make_arrow(["a", "b"], data)
|
||||
tbl = Table(arrow_data)
|
||||
assert tbl.size() == 10
|
||||
assert tbl.schema() == {
|
||||
"a": "integer",
|
||||
"b": "float",
|
||||
}
|
||||
assert tbl.view().to_columns() == {"a": data[0], "b": data[1]}
|
||||
|
||||
def test_table_arrow_loads_decimal_stream(self, util):
|
||||
data = [[i * 1000 for i in range(10)]]
|
||||
arrow_data = util.make_arrow(["a"], data, types=[pa.decimal128(4)])
|
||||
tbl = Table(arrow_data)
|
||||
assert tbl.size() == 10
|
||||
assert tbl.schema() == {
|
||||
"a": "float",
|
||||
}
|
||||
assert tbl.view().to_columns() == {"a": data[0]}
|
||||
|
||||
def test_table_arrow_loads_bool_stream(self, util):
|
||||
data = [[True if i % 2 == 0 else False for i in range(10)]]
|
||||
arrow_data = util.make_arrow(["a"], data)
|
||||
tbl = Table(arrow_data)
|
||||
assert tbl.size() == 10
|
||||
assert tbl.schema() == {"a": "boolean"}
|
||||
assert tbl.view().to_columns() == {"a": data[0]}
|
||||
|
||||
def test_table_arrow_loads_date32_stream(self, util):
|
||||
data = [[date(2019, 2, i) for i in range(1, 11)]]
|
||||
arrow_data = util.make_arrow(["a"], data, types=[pa.date32()])
|
||||
tbl = Table(arrow_data)
|
||||
assert tbl.size() == 10
|
||||
assert tbl.schema() == {"a": "date"}
|
||||
assert tbl.view().to_columns() == {
|
||||
"a": [util.to_timestamp(datetime(2019, 2, i)) for i in range(1, 11)]
|
||||
}
|
||||
|
||||
def test_table_arrow_loads_date64_stream(self, util):
|
||||
data = [[date(2019, 2, i) for i in range(1, 11)]]
|
||||
arrow_data = util.make_arrow(["a"], data, types=[pa.date64()])
|
||||
tbl = Table(arrow_data)
|
||||
assert tbl.size() == 10
|
||||
assert tbl.schema() == {"a": "date"}
|
||||
assert tbl.view().to_columns() == {
|
||||
"a": [util.to_timestamp(datetime(2019, 2, i)) for i in range(1, 11)]
|
||||
}
|
||||
|
||||
def test_table_arrow_loads_timestamp_all_formats_stream(self, util):
|
||||
data = [
|
||||
[datetime(2019, 2, i, 9) for i in range(1, 11)],
|
||||
[datetime(2019, 2, i, 10) for i in range(1, 11)],
|
||||
[datetime(2019, 2, i, 11) for i in range(1, 11)],
|
||||
[datetime(2019, 2, i, 12) for i in range(1, 11)],
|
||||
]
|
||||
arrow_data = util.make_arrow(
|
||||
names,
|
||||
data,
|
||||
types=[
|
||||
pa.timestamp("s"),
|
||||
pa.timestamp("ms"),
|
||||
pa.timestamp("us"),
|
||||
pa.timestamp("ns"),
|
||||
],
|
||||
)
|
||||
tbl = Table(arrow_data)
|
||||
assert tbl.size() == 10
|
||||
assert tbl.schema() == {
|
||||
"a": "datetime",
|
||||
"b": "datetime",
|
||||
"c": "datetime",
|
||||
"d": "datetime",
|
||||
}
|
||||
assert tbl.view().to_columns() == {
|
||||
"a": [util.to_timestamp(i) for i in data[0]],
|
||||
"b": [util.to_timestamp(i) for i in data[1]],
|
||||
"c": [util.to_timestamp(i) for i in data[2]],
|
||||
"d": [util.to_timestamp(i) for i in data[3]],
|
||||
}
|
||||
|
||||
def test_table_arrow_loads_string_stream(self, util):
|
||||
data = [[str(i) for i in range(10)]]
|
||||
arrow_data = util.make_arrow(["a"], data, types=[pa.string()])
|
||||
tbl = Table(arrow_data)
|
||||
assert tbl.size() == 10
|
||||
assert tbl.schema() == {"a": "string"}
|
||||
assert tbl.view().to_columns() == {"a": data[0]}
|
||||
|
||||
def test_table_arrow_loads_dictionary_stream_int8(self, util):
|
||||
data = [
|
||||
([0, 1, 1, None], ["abc", "def"]),
|
||||
([0, 1, None, 2], ["xx", "yy", "zz"]),
|
||||
]
|
||||
types = [[pa.int8(), pa.string()]] * 2
|
||||
arrow_data = util.make_dictionary_arrow(["a", "b"], data, types=types)
|
||||
tbl = Table(arrow_data)
|
||||
|
||||
assert tbl.size() == 4
|
||||
assert tbl.schema() == {"a": "string", "b": "string"}
|
||||
assert tbl.view().to_columns() == {
|
||||
"a": ["abc", "def", "def", None],
|
||||
"b": ["xx", "yy", None, "zz"],
|
||||
}
|
||||
|
||||
def test_table_arrow_loads_dictionary_stream_int16(self, util):
|
||||
data = [
|
||||
([0, 1, 1, None], ["abc", "def"]),
|
||||
([0, 1, None, 2], ["xx", "yy", "zz"]),
|
||||
]
|
||||
types = [[pa.int16(), pa.string()]] * 2
|
||||
arrow_data = util.make_dictionary_arrow(["a", "b"], data, types=types)
|
||||
tbl = Table(arrow_data)
|
||||
|
||||
assert tbl.size() == 4
|
||||
assert tbl.schema() == {"a": "string", "b": "string"}
|
||||
assert tbl.view().to_columns() == {
|
||||
"a": ["abc", "def", "def", None],
|
||||
"b": ["xx", "yy", None, "zz"],
|
||||
}
|
||||
|
||||
def test_table_arrow_loads_dictionary_stream_int32(self, util):
|
||||
data = [
|
||||
([0, 1, 1, None], ["abc", "def"]),
|
||||
([0, 1, None, 2], ["xx", "yy", "zz"]),
|
||||
]
|
||||
types = [[pa.int32(), pa.string()]] * 2
|
||||
arrow_data = util.make_dictionary_arrow(["a", "b"], data, types=types)
|
||||
tbl = Table(arrow_data)
|
||||
|
||||
assert tbl.size() == 4
|
||||
assert tbl.schema() == {"a": "string", "b": "string"}
|
||||
assert tbl.view().to_columns() == {
|
||||
"a": ["abc", "def", "def", None],
|
||||
"b": ["xx", "yy", None, "zz"],
|
||||
}
|
||||
|
||||
def test_table_arrow_loads_dictionary_stream_int64(self, util):
|
||||
data = [
|
||||
([0, 1, 1, None], ["abc", "def"]),
|
||||
([0, 1, None, 2], ["xx", "yy", "zz"]),
|
||||
]
|
||||
arrow_data = util.make_dictionary_arrow(["a", "b"], data)
|
||||
tbl = Table(arrow_data)
|
||||
|
||||
assert tbl.size() == 4
|
||||
assert tbl.schema() == {"a": "string", "b": "string"}
|
||||
assert tbl.view().to_columns() == {
|
||||
"a": ["abc", "def", "def", None],
|
||||
"b": ["xx", "yy", None, "zz"],
|
||||
}
|
||||
|
||||
def test_table_arrow_loads_dictionary_stream_nones(self, util):
|
||||
data = [([None, 0, 1, 2], ["", "abc", "def"])]
|
||||
arrow_data = util.make_dictionary_arrow(["a"], data)
|
||||
tbl = Table(arrow_data)
|
||||
|
||||
assert tbl.size() == 4
|
||||
assert tbl.schema() == {"a": "string"}
|
||||
assert tbl.view().to_columns() == {"a": [None, "", "abc", "def"]}
|
||||
|
||||
def test_table_arrow_loads_dictionary_stream_nones_indexed(self, util):
|
||||
data = [
|
||||
([1, None, 0, 2], ["", "abc", "def"]),
|
||||
([2, 1, 0, None], ["", "hij", "klm"]),
|
||||
] # ["abc", None, "", "def"] # ["klm", "hij", "", None]
|
||||
arrow_data = util.make_dictionary_arrow(["a", "b"], data)
|
||||
tbl = Table(arrow_data, index="a") # column "a" is sorted
|
||||
|
||||
assert tbl.schema() == {"a": "string", "b": "string"}
|
||||
assert tbl.view().to_columns() == {
|
||||
"a": [None, "", "abc", "def"],
|
||||
"b": ["hij", "", "klm", None],
|
||||
}
|
||||
|
||||
def test_table_arrow_loads_dictionary_stream_nones_indexed_2(self, util):
|
||||
"""Test the other column, just in case."""
|
||||
data = [
|
||||
([1, None, 0, 2], ["", "abc", "def"]),
|
||||
([2, 1, 0, None], ["", "hij", "klm"]),
|
||||
] # ["abc", None, "", "def"] # ["klm", "hij", "", None]
|
||||
arrow_data = util.make_dictionary_arrow(["a", "b"], data)
|
||||
tbl = Table(arrow_data, index="b") # column "b" is sorted
|
||||
|
||||
assert tbl.schema() == {"a": "string", "b": "string"}
|
||||
assert tbl.view().to_columns() == {
|
||||
"a": ["def", "", None, "abc"],
|
||||
"b": [None, "", "hij", "klm"],
|
||||
}
|
||||
|
||||
# legacy
|
||||
|
||||
def test_table_arrow_loads_int_legacy(self, util):
|
||||
data = [list(range(10)) for i in range(4)]
|
||||
arrow_data = util.make_arrow(names, data, legacy=True)
|
||||
tbl = Table(arrow_data)
|
||||
assert tbl.size() == 10
|
||||
assert tbl.schema() == {
|
||||
"a": "integer",
|
||||
"b": "integer",
|
||||
"c": "integer",
|
||||
"d": "integer",
|
||||
}
|
||||
|
||||
def test_table_arrow_loads_float_legacy(self, util):
|
||||
data = [[i for i in range(10)], [i * 1.5 for i in range(10)]]
|
||||
arrow_data = util.make_arrow(["a", "b"], data, legacy=True)
|
||||
tbl = Table(arrow_data)
|
||||
assert tbl.size() == 10
|
||||
assert tbl.schema() == {
|
||||
"a": "integer",
|
||||
"b": "float",
|
||||
}
|
||||
assert tbl.view().to_columns() == {"a": data[0], "b": data[1]}
|
||||
|
||||
def test_table_arrow_loads_decimal128_legacy(self, util):
|
||||
data = [[i * 1000 for i in range(10)]]
|
||||
arrow_data = util.make_arrow(["a"], data, types=[pa.decimal128(4)], legacy=True)
|
||||
tbl = Table(arrow_data)
|
||||
assert tbl.size() == 10
|
||||
assert tbl.schema() == {
|
||||
"a": "float",
|
||||
}
|
||||
assert tbl.view().to_columns() == {"a": data[0]}
|
||||
|
||||
def test_table_arrow_loads_bool_legacy(self, util):
|
||||
data = [[True if i % 2 == 0 else False for i in range(10)]]
|
||||
arrow_data = util.make_arrow(["a"], data, legacy=True)
|
||||
tbl = Table(arrow_data)
|
||||
assert tbl.size() == 10
|
||||
assert tbl.schema() == {"a": "boolean"}
|
||||
assert tbl.view().to_columns() == {"a": data[0]}
|
||||
|
||||
def test_table_arrow_loads_date32_legacy(self, util):
|
||||
data = [[date(2019, 2, i) for i in range(1, 11)]]
|
||||
arrow_data = util.make_arrow(["a"], data, types=[pa.date32()], legacy=True)
|
||||
tbl = Table(arrow_data)
|
||||
assert tbl.size() == 10
|
||||
assert tbl.schema() == {"a": "date"}
|
||||
assert tbl.view().to_columns() == {
|
||||
"a": [util.to_timestamp(datetime(2019, 2, i)) for i in range(1, 11)]
|
||||
}
|
||||
|
||||
def test_table_arrow_loads_date64_legacy(self, util):
|
||||
data = [[date(2019, 2, i) for i in range(1, 11)]]
|
||||
arrow_data = util.make_arrow(["a"], data, types=[pa.date64()], legacy=True)
|
||||
tbl = Table(arrow_data)
|
||||
assert tbl.size() == 10
|
||||
assert tbl.schema() == {"a": "date"}
|
||||
assert tbl.view().to_columns() == {
|
||||
"a": [util.to_timestamp(datetime(2019, 2, i)) for i in range(1, 11)]
|
||||
}
|
||||
|
||||
def test_table_arrow_loads_timestamp_all_formats_legacy(self, util):
|
||||
data = [
|
||||
[datetime(2019, 2, i, 9) for i in range(1, 11)],
|
||||
[datetime(2019, 2, i, 10) for i in range(1, 11)],
|
||||
[datetime(2019, 2, i, 11) for i in range(1, 11)],
|
||||
[datetime(2019, 2, i, 12) for i in range(1, 11)],
|
||||
]
|
||||
arrow_data = util.make_arrow(
|
||||
names,
|
||||
data,
|
||||
types=[
|
||||
pa.timestamp("s"),
|
||||
pa.timestamp("ms"),
|
||||
pa.timestamp("us"),
|
||||
pa.timestamp("ns"),
|
||||
],
|
||||
legacy=True,
|
||||
)
|
||||
tbl = Table(arrow_data)
|
||||
assert tbl.size() == 10
|
||||
assert tbl.schema() == {
|
||||
"a": "datetime",
|
||||
"b": "datetime",
|
||||
"c": "datetime",
|
||||
"d": "datetime",
|
||||
}
|
||||
assert tbl.view().to_columns() == {
|
||||
"a": [util.to_timestamp(i) for i in data[0]],
|
||||
"b": [util.to_timestamp(i) for i in data[1]],
|
||||
"c": [util.to_timestamp(i) for i in data[2]],
|
||||
"d": [util.to_timestamp(i) for i in data[3]],
|
||||
}
|
||||
|
||||
def test_table_arrow_loads_string_legacy(self, util):
|
||||
data = [[str(i) for i in range(10)]]
|
||||
arrow_data = util.make_arrow(["a"], data, types=[pa.string()], legacy=True)
|
||||
tbl = Table(arrow_data)
|
||||
assert tbl.size() == 10
|
||||
assert tbl.schema() == {"a": "string"}
|
||||
assert tbl.view().to_columns() == {"a": data[0]}
|
||||
|
||||
def test_table_arrow_loads_dictionary_legacy(self, util):
|
||||
data = [([0, 1, 1, None], ["a", "b"]), ([0, 1, None, 2], ["x", "y", "z"])]
|
||||
arrow_data = util.make_dictionary_arrow(["a", "b"], data, legacy=True)
|
||||
tbl = Table(arrow_data)
|
||||
|
||||
assert tbl.size() == 4
|
||||
assert tbl.schema() == {"a": "string", "b": "string"}
|
||||
assert tbl.view().to_columns() == {
|
||||
"a": ["a", "b", "b", None],
|
||||
"b": ["x", "y", None, "z"],
|
||||
}
|
||||
|
||||
def test_table_arrow_loads_arrow_from_df_with_nan(self):
|
||||
data = pd.DataFrame({"a": [1.5, 2.5, np.nan, 3.5, 4.5, np.nan, np.nan, np.nan]})
|
||||
|
||||
arrow_table = pa.Table.from_pandas(data, preserve_index=False)
|
||||
|
||||
assert arrow_table["a"].null_count == 4
|
||||
|
||||
tbl = Table(arrow_table)
|
||||
assert tbl.size() == 8
|
||||
|
||||
# check types
|
||||
assert tbl.schema() == {"a": "float"}
|
||||
|
||||
# check nans
|
||||
json = tbl.view().to_columns()
|
||||
|
||||
assert json["a"] == [1.5, 2.5, None, 3.5, 4.5, None, None, None]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,201 @@
|
||||
# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
# ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
# ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
# ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
# ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
# ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
from datetime import date, datetime
|
||||
from pytest import mark
|
||||
import perspective as psp
|
||||
|
||||
client = psp.Server().new_local_client()
|
||||
Table = client.table
|
||||
|
||||
|
||||
class TestTableInfer(object):
|
||||
def test_table_infer_int(self):
|
||||
data = {"a": [None, None, None, None, 1, 0, 1, 1, 1]}
|
||||
tbl = Table(data)
|
||||
assert tbl.schema() == {"a": "integer"}
|
||||
|
||||
def test_table_infer_float(self):
|
||||
data = {"a": [None, None, None, None, 1.0, 2.0]}
|
||||
tbl = Table(data)
|
||||
assert tbl.schema() == {"a": "float"}
|
||||
|
||||
def test_table_infer_bool(self):
|
||||
bool_data = [{"a": True, "b": False}, {"a": True, "b": True}]
|
||||
tbl = Table(bool_data)
|
||||
assert tbl.size() == 2
|
||||
assert tbl.schema() == {"a": "boolean", "b": "boolean"}
|
||||
|
||||
def test_table_infer_bool_str(self):
|
||||
bool_data = [{"a": "True", "b": "False"}, {"a": "True", "b": "True"}]
|
||||
tbl = Table(bool_data)
|
||||
assert tbl.size() == 2
|
||||
assert tbl.schema() == {"a": "boolean", "b": "boolean"}
|
||||
|
||||
@mark.skip(reason="Unsupported python specific behavior")
|
||||
def test_table_bool_infer_str_all_formats_from_schema(self):
|
||||
bool_data = [
|
||||
{"a": "True", "b": "False"},
|
||||
{"a": "t", "b": "f"},
|
||||
{"a": "true", "b": "false"},
|
||||
{"a": 1, "b": 0},
|
||||
{"a": "on", "b": "off"},
|
||||
]
|
||||
tbl = Table(bool_data)
|
||||
assert tbl.schema() == {"a": "boolean", "b": "boolean"}
|
||||
assert tbl.size() == 5
|
||||
assert tbl.view().to_columns() == {
|
||||
"a": [True, True, True, True, True],
|
||||
"b": [False, False, False, False, False],
|
||||
}
|
||||
|
||||
def test_table_infer_bool_variant(self):
|
||||
data = {"a": [None, None, None, None, True, True, True]}
|
||||
tbl = Table(data)
|
||||
assert tbl.schema() == {"a": "boolean"}
|
||||
|
||||
def test_table_infer_str(self):
|
||||
data = {"a": [None, None, None, None, None, None, "abc"]}
|
||||
tbl = Table(data)
|
||||
assert tbl.schema() == {"a": "string"}
|
||||
|
||||
@mark.skip(reason="Time is not a valid JSON type")
|
||||
def test_table_infer_time_as_string(self):
|
||||
# time objects are inferred as string
|
||||
data = {
|
||||
"a": [
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
datetime(2019, 7, 11, 12, 30, 5).time(),
|
||||
]
|
||||
}
|
||||
tbl = Table(data)
|
||||
assert tbl.schema() == {"a": "string"}
|
||||
|
||||
@mark.skip(reason="Unsupported python specific behavior")
|
||||
def test_table_infer_date_from_datetime(self):
|
||||
# inferrence on non-pandas datasets defaults to datetime
|
||||
data = {"a": [None, None, None, None, None, None, datetime(2019, 7, 11)]}
|
||||
tbl = Table(data)
|
||||
assert tbl.schema() == {"a": "datetime"}
|
||||
|
||||
def test_table_infer_date_from_date(self):
|
||||
# pass in a `date` to make sure it infers as date
|
||||
data = {"a": [None, None, None, None, None, None, date(2019, 7, 11)]}
|
||||
tbl = Table(data)
|
||||
assert tbl.schema() == {"a": "date"}
|
||||
|
||||
def test_table_infer_valid_date(self):
|
||||
data = {"a": [None, None, None, None, None, None, "08/31/2019"]}
|
||||
tbl = Table(data)
|
||||
assert tbl.schema() == {"a": "date"}
|
||||
|
||||
def test_table_infer_ambiguous_date(self):
|
||||
data = {"a": [None, None, None, None, None, None, "01/03/2019"]}
|
||||
tbl = Table(data)
|
||||
assert tbl.schema() == {"a": "date"}
|
||||
|
||||
def test_table_infer_ymd_date(self):
|
||||
data = {"a": [None, None, None, None, None, None, "2019/01/03"]}
|
||||
tbl = Table(data)
|
||||
assert tbl.schema() == {"a": "date"}
|
||||
|
||||
def test_table_infer_invalid_date(self):
|
||||
data = {"a": [None, None, None, None, None, None, "08/55/2019"]}
|
||||
tbl = Table(data)
|
||||
assert tbl.schema() == {"a": "string"}
|
||||
|
||||
def test_table_infer_date_edge(self):
|
||||
data = {"a": [None, None, None, None, None, None, "08/31/2019 00:00:00"]}
|
||||
tbl = Table(data)
|
||||
assert tbl.schema() == {"a": "date"}
|
||||
|
||||
def test_table_infer_datetime_edge(self):
|
||||
data = {"a": [None, None, None, None, None, None, "08/31/2019 00:00:01"]}
|
||||
tbl = Table(data)
|
||||
assert tbl.schema() == {"a": "datetime"}
|
||||
|
||||
def test_table_infer_valid_datetime(self):
|
||||
data = {"a": [None, None, None, None, None, None, "08/31/2019 07:30:00"]}
|
||||
tbl = Table(data)
|
||||
assert tbl.schema() == {"a": "datetime"}
|
||||
|
||||
def test_table_infer_iso_datetime(self):
|
||||
data = {"a": [None, None, None, None, None, None, "2019/07/25T09:00:00"]}
|
||||
tbl = Table(data)
|
||||
assert tbl.schema() == {"a": "datetime"}
|
||||
|
||||
def test_table_infer_datetime_separators(self):
|
||||
data = {
|
||||
"a": [
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
"2019-07-25T09:00:00",
|
||||
"2019/07/25T09:00:00",
|
||||
]
|
||||
}
|
||||
tbl = Table(data)
|
||||
assert tbl.schema() == {"a": "datetime"}
|
||||
|
||||
def test_table_infer_datetime_tz(self):
|
||||
data = {"a": [None, None, None, None, None, "2019-07-25T09:00:00-05:00"]}
|
||||
tbl = Table(data)
|
||||
assert tbl.schema() == {"a": "datetime"}
|
||||
|
||||
def test_table_infer_invalid_datetime(self):
|
||||
data = {"a": [None, None, None, None, None, None, "08/31/2019 25:30:00"]}
|
||||
tbl = Table(data)
|
||||
assert tbl.schema() == {"a": "string"}
|
||||
|
||||
def test_table_infer_mixed_date(self):
|
||||
data = {"a": [None, None, None, None, None, "08/11/2019"]}
|
||||
tbl = Table(data)
|
||||
assert tbl.schema() == {"a": "date"}
|
||||
|
||||
def test_table_infer_mixed_datetime(self):
|
||||
data = {"a": [None, None, None, None, None, "08/11/2019 13:14:15"]}
|
||||
tbl = Table(data)
|
||||
assert tbl.schema() == {"a": "datetime"}
|
||||
|
||||
@mark.skip(reason="Numeric strings are not inferred as numbers")
|
||||
def test_table_strict_datetime_infer(self):
|
||||
data = {"a": ["10", "9", "8", "7", "6", "5", "4", "3", "2", "1"]}
|
||||
tbl = Table(data)
|
||||
assert tbl.schema() == {"a": "string"}
|
||||
|
||||
def test_table_strict_date_infer(self):
|
||||
data = {"a": ["2019 09 10"]}
|
||||
tbl = Table(data)
|
||||
assert tbl.schema() == {"a": "date"}
|
||||
|
||||
def test_table_strict_datetime_separator_infer(self):
|
||||
data = {"a": ["2019-10-01 7:30"]}
|
||||
tbl = Table(data)
|
||||
assert tbl.schema() == {"a": "datetime"}
|
||||
|
||||
def test_table_datetime_infer_no_false_positive(self):
|
||||
data = {"a": [" . - / but clearly not a date"]}
|
||||
tbl = Table(data)
|
||||
assert tbl.schema() == {"a": "string"}
|
||||
|
||||
@mark.skip
|
||||
def test_table_datetime_infer_from_string_with_time(self):
|
||||
data = {"a": ["11:00 ABCD"]}
|
||||
tbl = Table(data)
|
||||
assert tbl.schema() == {"a": "string"}
|
||||
@@ -0,0 +1,45 @@
|
||||
# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
# ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
# ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
# ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
# ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
# ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
import perspective as psp
|
||||
|
||||
client = psp.Server().new_local_client()
|
||||
Table = client.table
|
||||
|
||||
|
||||
class TestTableInfer(object):
|
||||
def test_table_limit_wraparound_does_not_respect_partial_none(self):
|
||||
t = Table({"a": "float", "b": "float"}, limit=3)
|
||||
t.update([{"a": 10}, {"b": 1}, {"a": 20}, {"a": None, "b": 2}])
|
||||
d1 = t.view().to_json()
|
||||
|
||||
t2 = Table({"a": "float", "b": "float"}, limit=3)
|
||||
t2.update([{"a": 10}, {"b": 1}, {"a": 20}, {"b": 2}])
|
||||
d2 = t2.view().to_json()
|
||||
|
||||
assert d1[0] == d2[0]
|
||||
assert d1[1:] == d2[1:]
|
||||
|
||||
def test_table_limit_wraparound_does_not_respect_partial(self):
|
||||
t = Table({"a": "float", "b": "float"}, limit=3)
|
||||
t.update([{"a": 10}, {"b": 1}, {"a": 20}, {"a": 10, "b": 2}])
|
||||
d1 = t.view().to_columns()
|
||||
|
||||
t2 = Table({"a": "float", "b": "float"}, limit=3)
|
||||
t2.update([{"a": 10}, {"b": 1}, {"a": 20}, {"b": 2}])
|
||||
d2 = t2.view().to_columns()
|
||||
|
||||
assert d1 != d2
|
||||
|
||||
def test_table_limit_with_json(self):
|
||||
t = Table({"a": [1, 2, 3]}, limit=1)
|
||||
assert t.size() == 1
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,304 @@
|
||||
# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
# ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
# ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
# ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
# ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
# ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import perspective as psp
|
||||
from pytest import mark
|
||||
|
||||
client = psp.Server().new_local_client()
|
||||
Table = client.table
|
||||
|
||||
|
||||
def _perspective_temp_dirs():
|
||||
"""All `perspective_*` directories currently in the OS temp directory.
|
||||
|
||||
`BACKING_STORE_DISK` columns are written to a unique
|
||||
`<tempdir>/perspective_<uuid>/` directory.
|
||||
"""
|
||||
tmp = tempfile.gettempdir()
|
||||
try:
|
||||
entries = os.listdir(tmp)
|
||||
except OSError:
|
||||
return set()
|
||||
|
||||
print(tmp)
|
||||
return {
|
||||
os.path.join(tmp, e)
|
||||
for e in entries
|
||||
if e.startswith("perspective_") and os.path.isdir(os.path.join(tmp, e))
|
||||
}
|
||||
|
||||
|
||||
class TestTableOnDisk:
|
||||
def test_page_to_disk_schema_and_view(self):
|
||||
data = {
|
||||
"x": [1, 2, 3, 4],
|
||||
"y": ["a", "b", "c", "d"],
|
||||
"z": [True, False, True, False],
|
||||
}
|
||||
|
||||
mem = Table(data)
|
||||
disk = Table(data, page_to_disk=True)
|
||||
assert disk.schema() == mem.schema()
|
||||
assert disk.view().to_columns() == mem.view().to_columns()
|
||||
|
||||
def test_page_to_disk_csv(self):
|
||||
data = "x,y,z\n1,a,true\n2,b,false\n3,c,true\n4,d,false"
|
||||
tbl = Table(data, page_to_disk=True)
|
||||
assert tbl.schema() == {"x": "integer", "y": "string", "z": "boolean"}
|
||||
assert tbl.view().to_columns() == {
|
||||
"x": [1, 2, 3, 4],
|
||||
"y": ["a", "b", "c", "d"],
|
||||
"z": [True, False, True, False],
|
||||
}
|
||||
|
||||
def test_page_to_disk_update_indexed(self):
|
||||
mem = Table({"x": [1, 2, 3], "y": [1.0, 2.0, 3.0]}, index="x")
|
||||
disk = Table(
|
||||
{"x": [1, 2, 3], "y": [1.0, 2.0, 3.0]}, index="x", page_to_disk=True
|
||||
)
|
||||
update = {"x": [2, 4], "y": [20.0, 40.0]}
|
||||
mem.update(update)
|
||||
disk.update(update)
|
||||
assert disk.view().to_columns() == mem.view().to_columns()
|
||||
assert disk.size() == mem.size()
|
||||
|
||||
def test_page_to_disk_group_by_aggregation(self):
|
||||
data = {"g": ["a", "b", "a", "b", "a"], "v": [1, 2, 3, 4, 5]}
|
||||
mem = Table(data)
|
||||
disk = Table(data, page_to_disk=True)
|
||||
config = {"group_by": ["g"], "columns": ["v"], "aggregates": {"v": "sum"}}
|
||||
assert disk.view(**config).to_columns() == mem.view(**config).to_columns()
|
||||
|
||||
def test_page_to_disk_arrow_roundtrip(self):
|
||||
data = {"x": list(range(100)), "y": [float(i) / 2 for i in range(100)]}
|
||||
mem = Table(data)
|
||||
disk = Table(data, page_to_disk=True)
|
||||
assert disk.view().to_arrow() == mem.view().to_arrow()
|
||||
|
||||
def test_page_to_disk_creates_backing_files(self):
|
||||
before = _perspective_temp_dirs()
|
||||
tbl = Table({"x": [1, 2, 3], "y": ["a", "b", "c"]}, page_to_disk=True)
|
||||
|
||||
# Touch the table so it is not optimized away before we inspect the FS.
|
||||
assert tbl.size() == 3
|
||||
after = _perspective_temp_dirs()
|
||||
new_dirs = after - before
|
||||
assert new_dirs, "expected a new perspective_<uuid> directory on disk"
|
||||
assert any(os.listdir(d) for d in new_dirs), "expected column files on disk"
|
||||
|
||||
def test_memory_table_creates_no_backing_files(self):
|
||||
before = _perspective_temp_dirs()
|
||||
tbl = Table({"x": [1, 2, 3]})
|
||||
assert tbl.size() == 3
|
||||
after = _perspective_temp_dirs()
|
||||
assert after == before, "in-memory table must not write to disk"
|
||||
|
||||
def test_page_to_disk_growth_forces_resize(self):
|
||||
tbl = Table({"x": [0], "y": [0.0]}, index="x", page_to_disk=True)
|
||||
n = 50000
|
||||
tbl.update({"x": list(range(n)), "y": [float(i) for i in range(n)]})
|
||||
assert tbl.size() == n
|
||||
cols = tbl.view().to_columns()
|
||||
assert cols["x"][0] == 0
|
||||
assert cols["x"][-1] == n - 1
|
||||
assert cols["y"][-1] == float(n - 1)
|
||||
|
||||
def test_page_to_disk_clone_keeps_master_files(self):
|
||||
# Cloning a disk-backed column (e.g. the masked clone that serializing a
|
||||
# table with removed rows performs) must give the clone its OWN backing
|
||||
# file. Otherwise the clone aliases — and on teardown `rmfile`s — the
|
||||
# master's file, silently unlinking the master's named backing store.
|
||||
before = _perspective_temp_dirs()
|
||||
tbl = Table(
|
||||
{"x": [1, 2, 3, 4], "y": [10.0, 20.0, 30.0, 40.0]},
|
||||
index="x",
|
||||
page_to_disk=True,
|
||||
)
|
||||
view = tbl.view()
|
||||
new_dirs = _perspective_temp_dirs() - before
|
||||
master_dirs = [
|
||||
d
|
||||
for d in new_dirs
|
||||
if not os.path.basename(d).startswith("perspective_expr_")
|
||||
]
|
||||
assert len(master_dirs) == 1
|
||||
master_dir = master_dirs[0]
|
||||
master_files = set(os.listdir(master_dir))
|
||||
assert master_files, "expected master backing files on disk"
|
||||
|
||||
# Removing rows triggers a masked clone of the disk master columns.
|
||||
tbl.remove([2, 3])
|
||||
assert view.to_columns()["x"] == [1, 4]
|
||||
# The master's own backing files must survive the clone's teardown.
|
||||
survived = set(os.listdir(master_dir))
|
||||
assert master_files <= survived, (
|
||||
"clone teardown unlinked master backing files: {}".format(
|
||||
master_files - survived
|
||||
)
|
||||
)
|
||||
|
||||
# And the master must remain usable for subsequent updates.
|
||||
tbl.update({"x": [5, 6], "y": [50.0, 60.0]})
|
||||
assert sorted(view.to_columns()["x"]) == [1, 4, 5, 6]
|
||||
|
||||
def test_page_to_disk_larger_dataset_matches_memory(self):
|
||||
n = 20000
|
||||
data = {
|
||||
"i": list(range(n)),
|
||||
"f": [float(i) * 1.5 for i in range(n)],
|
||||
"s": ["row_{}".format(i % 97) for i in range(n)],
|
||||
}
|
||||
|
||||
mem = Table(data)
|
||||
disk = Table(data, page_to_disk=True)
|
||||
assert disk.view().to_arrow() == mem.view().to_arrow()
|
||||
|
||||
|
||||
def _perspective_expr_dirs():
|
||||
"""`perspective_expr_*` directories — the on-disk expression `m_master`."""
|
||||
tmp = tempfile.gettempdir()
|
||||
try:
|
||||
entries = os.listdir(tmp)
|
||||
except OSError:
|
||||
return set()
|
||||
|
||||
return {
|
||||
os.path.join(tmp, e)
|
||||
for e in entries
|
||||
if e.startswith("perspective_expr_") and os.path.isdir(os.path.join(tmp, e))
|
||||
}
|
||||
|
||||
|
||||
class TestTableOnDiskExpressions:
|
||||
def test_expression_numeric_equivalence(self):
|
||||
data = {"x": [1, 2, 3, 4], "y": [10.0, 20.0, 30.0, 40.0]}
|
||||
mem = Table(data)
|
||||
disk = Table(data, page_to_disk=True)
|
||||
exprs = {"sum": '"x" + "y"', "prod": '"x" * "y"'}
|
||||
assert (
|
||||
disk.view(expressions=exprs).to_columns()
|
||||
== mem.view(expressions=exprs).to_columns()
|
||||
)
|
||||
|
||||
def test_expression_string_equivalence(self):
|
||||
data = {"a": ["foo", "bar", "baz"], "b": ["AA", "BB", "CC"]}
|
||||
mem = Table(data)
|
||||
disk = Table(data, page_to_disk=True)
|
||||
exprs = {"up": 'upper("a")', "lo": 'lower("b")'}
|
||||
assert (
|
||||
disk.view(expressions=exprs).to_columns()
|
||||
== mem.view(expressions=exprs).to_columns()
|
||||
)
|
||||
|
||||
def test_expression_with_group_by_equivalence(self):
|
||||
data = {"g": ["a", "b", "a", "b"], "v": [1, 2, 3, 4]}
|
||||
mem = Table(data)
|
||||
disk = Table(data, page_to_disk=True)
|
||||
config = {
|
||||
"expressions": {"v2": '"v" * 2'},
|
||||
"group_by": ["g"],
|
||||
"columns": ["v2"],
|
||||
"aggregates": {"v2": "sum"},
|
||||
}
|
||||
|
||||
assert disk.view(**config).to_columns() == mem.view(**config).to_columns()
|
||||
|
||||
def test_expression_master_is_page_to_disk(self):
|
||||
before = _perspective_expr_dirs()
|
||||
tbl = Table({"x": [1, 2, 3], "y": [10.0, 20.0, 30.0]}, page_to_disk=True)
|
||||
view = tbl.view(expressions={"e": '"x" + "y"'})
|
||||
assert view.to_columns()["e"] == [11.0, 22.0, 33.0]
|
||||
new_dirs = _perspective_expr_dirs() - before
|
||||
assert new_dirs, (
|
||||
"expected a perspective_expr_<uuid> dir for the expression master"
|
||||
)
|
||||
|
||||
assert any(os.listdir(d) for d in new_dirs), (
|
||||
"expected expression backing files on disk"
|
||||
)
|
||||
|
||||
def test_memory_expression_creates_no_expr_dir(self):
|
||||
before = _perspective_expr_dirs()
|
||||
tbl = Table({"x": [1, 2, 3], "y": [10.0, 20.0, 30.0]})
|
||||
view = tbl.view(expressions={"e": '"x" + "y"'})
|
||||
assert view.to_columns()["e"] == [11.0, 22.0, 33.0]
|
||||
assert _perspective_expr_dirs() == before, (
|
||||
"in-memory table must not write expression data to disk"
|
||||
)
|
||||
|
||||
def test_expression_page_to_disk_update_and_larger(self):
|
||||
n = 10000
|
||||
tbl = Table({"x": [0], "y": [0.0]}, index="x", page_to_disk=True)
|
||||
tbl.update({"x": list(range(n)), "y": [float(i) for i in range(n)]})
|
||||
cols = tbl.view(expressions={"e": '"x" + "y"'}).to_columns()
|
||||
assert cols["e"][0] == 0.0
|
||||
assert cols["e"][-1] == float((n - 1) + (n - 1))
|
||||
|
||||
|
||||
class TestResidency:
|
||||
# The residency manager evicts disk-backed column buffers to their files
|
||||
# when over the `PSP_MEMORY_BUDGET` and restores them transparently on
|
||||
# access. Under a tiny budget, eviction fires aggressively and data must
|
||||
# still round-trip identically to an in-memory table.
|
||||
|
||||
@mark.skip(reason="No secret hooks in the engine")
|
||||
def test_residency_evicts_and_data_is_correct(self):
|
||||
stats_fd, stats_path = tempfile.mkstemp(prefix="psp_residency_")
|
||||
os.close(stats_fd)
|
||||
os.environ["PSP_MEMORY_BUDGET"] = "1024"
|
||||
os.environ["PSP_RESIDENCY_STATS_FILE"] = stats_path
|
||||
try:
|
||||
n = 5000
|
||||
data = {
|
||||
"x": list(range(n)),
|
||||
"y": [float(i) * 1.5 for i in range(n)],
|
||||
"s": ["row_{}".format(i % 50) for i in range(n)],
|
||||
}
|
||||
mem = Table(data)
|
||||
disk = Table(data, page_to_disk=True)
|
||||
|
||||
# Each request is a safepoint that trims to budget; data read back
|
||||
# must be correct (round-tripped through evict -> restore).
|
||||
assert disk.view().to_columns() == mem.view().to_columns()
|
||||
assert disk.view().to_arrow() == mem.view().to_arrow()
|
||||
|
||||
upd = {
|
||||
"x": list(range(n, n + 200)),
|
||||
"y": [float(i) for i in range(n, n + 200)],
|
||||
"s": ["upd_{}".format(i) for i in range(200)],
|
||||
}
|
||||
mem.update(upd)
|
||||
disk.update(upd)
|
||||
assert disk.view().to_columns() == mem.view().to_columns()
|
||||
|
||||
# An expression view on an evicted disk table must still compute.
|
||||
exprs = {"e": '"x" + "y"'}
|
||||
assert (
|
||||
disk.view(expressions=exprs).to_columns()
|
||||
== mem.view(expressions=exprs).to_columns()
|
||||
)
|
||||
|
||||
# Confirm eviction actually occurred (otherwise the test is vacuous).
|
||||
with open(stats_path) as f:
|
||||
stats = f.read()
|
||||
evictions = int(stats.split("evictions=")[1].split()[0])
|
||||
assert evictions > 0, "expected evictions under a tiny budget: " + stats
|
||||
finally:
|
||||
os.environ.pop("PSP_MEMORY_BUDGET", None)
|
||||
os.environ.pop("PSP_RESIDENCY_STATS_FILE", None)
|
||||
os.remove(stats_path)
|
||||
# Drain a safepoint with residency disabled so any evicted stores
|
||||
# from other live tables are restored before subsequent tests.
|
||||
Table({"_": [0]}).view().to_columns()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,251 @@
|
||||
# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
# ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
# ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
# ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
# ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
# ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
from datetime import date, datetime
|
||||
import numpy as np
|
||||
import polars as pl
|
||||
from pytest import mark
|
||||
import perspective as psp
|
||||
|
||||
client = psp.Server().new_local_client()
|
||||
Table = client.table
|
||||
|
||||
|
||||
def arrow_bytes_to_polars(view):
|
||||
import pyarrow
|
||||
|
||||
with pyarrow.ipc.open_stream(pyarrow.BufferReader(view.to_arrow())) as reader:
|
||||
return pl.from_dataframe(reader.read_pandas())
|
||||
|
||||
|
||||
class TestTablePolars(object):
|
||||
def test_empty_table(self):
|
||||
tbl = Table([])
|
||||
assert tbl.size() == 0
|
||||
assert tbl.schema() == {}
|
||||
|
||||
def test_table_dataframe(self):
|
||||
d = [{"a": 1, "b": 2}, {"a": 3, "b": 4}]
|
||||
data = pl.DataFrame(d)
|
||||
tbl = Table(data)
|
||||
assert tbl.size() == 2
|
||||
assert tbl.schema() == {"a": "integer", "b": "integer"}
|
||||
assert tbl.view().to_records() == [
|
||||
{"a": 1, "b": 2},
|
||||
{"a": 3, "b": 4},
|
||||
]
|
||||
|
||||
def test_table_lazyframe(self):
|
||||
d = [{"a": 1, "b": 2}, {"a": 3, "b": 4}]
|
||||
data = pl.DataFrame(d).lazy()
|
||||
tbl = Table(data)
|
||||
assert tbl.size() == 2
|
||||
assert tbl.schema() == {"a": "integer", "b": "integer"}
|
||||
assert tbl.view().to_records() == [
|
||||
{"a": 1, "b": 2},
|
||||
{"a": 3, "b": 4},
|
||||
]
|
||||
|
||||
def test_table_dataframe_column_order(self):
|
||||
d = [{"a": 1, "b": 2, "c": 3, "d": 4}, {"a": 3, "b": 4, "c": 5, "d": 6}]
|
||||
data = pl.DataFrame(d).select(["b", "c", "a", "d"])
|
||||
tbl = Table(data)
|
||||
assert tbl.size() == 2
|
||||
assert tbl.columns() == ["b", "c", "a", "d"]
|
||||
|
||||
def test_table_dataframe_selective_column_order(self):
|
||||
d = [{"a": 1, "b": 2, "c": 3, "d": 4}, {"a": 3, "b": 4, "c": 5, "d": 6}]
|
||||
data = pl.DataFrame(d).select(["b", "c", "a"])
|
||||
tbl = Table(data)
|
||||
assert tbl.size() == 2
|
||||
assert tbl.columns() == ["b", "c", "a"]
|
||||
|
||||
def test_table_dataframe_does_not_mutate(self):
|
||||
# make sure we don't mutate the dataframe that a user passes in
|
||||
data = pl.DataFrame(
|
||||
{
|
||||
"a": [None, 1, None, 2],
|
||||
"b": [1.5, None, 2.5, None],
|
||||
}
|
||||
)
|
||||
assert data["a"].to_list() == [None, 1, None, 2]
|
||||
assert data["b"].to_list() == [1.5, None, 2.5, None]
|
||||
|
||||
tbl = Table(data)
|
||||
assert tbl.size() == 4
|
||||
assert tbl.schema() == {"a": "integer", "b": "float"}
|
||||
|
||||
assert data["a"].to_list() == [None, 1, None, 2]
|
||||
assert data["b"].to_list() == [1.5, None, 2.5, None]
|
||||
|
||||
def test_table_polars_from_schema_int(self):
|
||||
data = [None, 1, None, 2, None, 3, 4]
|
||||
df = pl.DataFrame({"a": data})
|
||||
table = Table({"a": "integer"})
|
||||
table.update(df)
|
||||
assert table.view().to_columns()["a"] == data
|
||||
|
||||
def test_table_polars_from_schema_bool(self):
|
||||
data = [True, False, True, False]
|
||||
df = pl.DataFrame({"a": data})
|
||||
table = Table({"a": "boolean"})
|
||||
table.update(df)
|
||||
assert table.view().to_columns()["a"] == data
|
||||
|
||||
def test_table_polars_from_schema_float(self):
|
||||
data = [None, 1.5, None, 2.5, None, 3.5, 4.5]
|
||||
df = pl.DataFrame({"a": data})
|
||||
table = Table({"a": "float"})
|
||||
table.update(df)
|
||||
assert table.view().to_columns()["a"] == data
|
||||
|
||||
def test_table_polars_from_schema_float_all_nan(self):
|
||||
data = [np.nan, np.nan, np.nan, np.nan]
|
||||
df = pl.DataFrame({"a": data})
|
||||
table = Table({"a": "float"})
|
||||
table.update(df)
|
||||
assert table.view().to_columns()["a"] == [None, None, None, None]
|
||||
|
||||
def test_table_polars_from_schema_float_to_int(self):
|
||||
data = [None, 1.5, None, 2.5, None, 3.5, 4.5]
|
||||
df = pl.DataFrame({"a": data})
|
||||
table = Table({"a": "integer"})
|
||||
table.update(df)
|
||||
# truncates decimal
|
||||
assert table.view().to_columns()["a"] == [None, 1, None, 2, None, 3, 4]
|
||||
|
||||
def test_table_polars_from_schema_int_to_float(self):
|
||||
data = [None, 1, None, 2, None, 3, 4]
|
||||
df = pl.DataFrame({"a": data})
|
||||
table = Table({"a": "float"})
|
||||
table.update(df)
|
||||
assert table.view().to_columns()["a"] == [None, 1.0, None, 2.0, None, 3.0, 4.0]
|
||||
|
||||
def test_table_polars_from_schema_date(self, util):
|
||||
data = [date(2019, 8, 15), None, date(2019, 8, 16)]
|
||||
df = pl.DataFrame({"a": data})
|
||||
table = Table({"a": "date"})
|
||||
table.update(df)
|
||||
assert table.view().to_columns()["a"] == [
|
||||
util.to_timestamp(datetime(2019, 8, 15)),
|
||||
None,
|
||||
util.to_timestamp(datetime(2019, 8, 16)),
|
||||
]
|
||||
|
||||
def test_table_polars_from_schema_str(self):
|
||||
data = ["a", None, "b", None, "c"]
|
||||
df = pl.DataFrame({"a": data})
|
||||
table = Table({"a": "string"})
|
||||
table.update(df)
|
||||
assert table.view().to_columns()["a"] == data
|
||||
|
||||
def test_table_polars_none(self):
|
||||
data = [None, None, None]
|
||||
df = pl.DataFrame({"a": data})
|
||||
table = Table(df)
|
||||
assert table.view().to_columns()["a"] == data
|
||||
|
||||
def test_table_polars_symmetric_table(self):
|
||||
# make sure that updates are symmetric to table creation
|
||||
df = pl.DataFrame({"a": [1, 2, 3, 4], "b": [1.5, 2.5, 3.5, 4.5]})
|
||||
t1 = Table(df)
|
||||
t2 = Table({"a": "integer", "b": "float"})
|
||||
t2.update(df)
|
||||
assert t1.view().to_columns() == {
|
||||
"a": [1, 2, 3, 4],
|
||||
"b": [1.5, 2.5, 3.5, 4.5],
|
||||
}
|
||||
|
||||
def test_table_polars_symmetric_stacked_updates(self):
|
||||
# make sure that updates are symmetric to table creation
|
||||
df = pl.DataFrame({"a": [1, 2, 3, 4], "b": [1.5, 2.5, 3.5, 4.5]})
|
||||
|
||||
t1 = Table(df)
|
||||
t1.update(df)
|
||||
|
||||
t2 = Table({"a": "integer", "b": "float"})
|
||||
t2.update(df)
|
||||
t2.update(df)
|
||||
|
||||
assert t1.view().to_columns() == {
|
||||
"a": [1, 2, 3, 4, 1, 2, 3, 4],
|
||||
"b": [1.5, 2.5, 3.5, 4.5, 1.5, 2.5, 3.5, 4.5],
|
||||
}
|
||||
|
||||
@mark.skip(reason="Not supported, polars doesnt like input")
|
||||
def test_table_polars_transitive(self):
|
||||
# serialized output -> table -> serialized output
|
||||
records = {
|
||||
"a": [1, 2, 3, 4],
|
||||
"b": [1.5, 2.5, 3.5, 4.5],
|
||||
"c": [np.nan, np.nan, "abc", np.nan],
|
||||
"d": [None, True, None, False],
|
||||
"e": [
|
||||
float("nan"),
|
||||
datetime(2019, 7, 11, 12, 30),
|
||||
float("nan"),
|
||||
datetime(2019, 7, 11, 12, 30),
|
||||
],
|
||||
}
|
||||
|
||||
df = pl.DataFrame(records, strict=False)
|
||||
t1 = Table(df)
|
||||
out1 = arrow_bytes_to_polars(t1.view(columns=["a", "b", "c", "d", "e"]))
|
||||
t2 = Table(out1)
|
||||
assert t1.schema() == t2.schema()
|
||||
out2 = t2.view().to_columns()
|
||||
assert t1.view().to_columns() == out2
|
||||
|
||||
# dtype=object should have correct inferred types
|
||||
|
||||
def test_table_polars_object_to_int(self):
|
||||
df = pl.DataFrame({"a": [1, 2, None, 2, None, 3, 4]})
|
||||
table = Table(df)
|
||||
assert table.schema() == {"a": "integer"}
|
||||
assert table.view().to_columns()["a"] == [1, 2, None, 2, None, 3, 4]
|
||||
|
||||
def test_table_polars_object_to_float(self):
|
||||
df = pl.DataFrame({"a": [None, 1, None, 2, None, 3, 4]})
|
||||
table = Table(df)
|
||||
assert table.schema() == {"a": "integer"}
|
||||
assert table.view().to_columns()["a"] == [None, 1.0, None, 2.0, None, 3.0, 4.0]
|
||||
|
||||
def test_table_polars_object_to_bool(self):
|
||||
df = pl.DataFrame({"a": [True, False, True, False, True, False]})
|
||||
table = Table(df)
|
||||
assert table.schema() == {"a": "boolean"}
|
||||
assert table.view().to_columns()["a"] == [True, False, True, False, True, False]
|
||||
|
||||
def test_table_polars_object_to_datetime(self):
|
||||
df = pl.DataFrame(
|
||||
{
|
||||
"a": [
|
||||
datetime(2019, 7, 11, 1, 2, 3),
|
||||
datetime(2019, 7, 12, 1, 2, 3),
|
||||
None,
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
table = Table(df)
|
||||
assert table.schema() == {"a": "datetime"}
|
||||
assert table.view().to_columns()["a"] == [
|
||||
datetime(2019, 7, 11, 1, 2, 3).timestamp() * 1000,
|
||||
datetime(2019, 7, 12, 1, 2, 3).timestamp() * 1000,
|
||||
None,
|
||||
]
|
||||
|
||||
def test_table_polars_object_to_str(self):
|
||||
df = pl.DataFrame({"a": np.array(["abc", "def", None, "ghi"], dtype=object)})
|
||||
table = Table(df)
|
||||
assert table.schema() == {"a": "string"}
|
||||
assert table.view().to_columns()["a"] == ["abc", "def", None, "ghi"]
|
||||
@@ -0,0 +1,130 @@
|
||||
# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
# ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
# ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
# ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
# ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
# ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
from perspective import PerspectiveError
|
||||
from datetime import date, datetime
|
||||
from pytest import approx, mark, raises
|
||||
|
||||
import perspective as psp
|
||||
|
||||
client = psp.Server().new_local_client()
|
||||
Table = client.table
|
||||
|
||||
|
||||
def date_timestamp(date):
|
||||
return int(datetime.combine(date, datetime.min.time()).timestamp()) * 1000
|
||||
|
||||
|
||||
def compare_delta(received, expected):
|
||||
"""Compare an arrow-serialized row delta by constructing a Table."""
|
||||
tbl = Table(received)
|
||||
assert tbl.view().to_columns() == expected
|
||||
|
||||
|
||||
class TestView(object):
|
||||
def test_view_zero(self):
|
||||
data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}]
|
||||
tbl = Table(data)
|
||||
view = tbl.view()
|
||||
tbl2 = Table(view)
|
||||
view2 = tbl2.view()
|
||||
dimms = view2.dimensions()
|
||||
assert dimms["num_view_rows"] == 2
|
||||
assert dimms["num_view_columns"] == 2
|
||||
assert view2.schema() == {"a": "integer", "b": "integer"}
|
||||
assert view2.to_records() == data
|
||||
|
||||
def test_view_one(self):
|
||||
data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}]
|
||||
tbl = Table(data)
|
||||
view = tbl.view(group_by=["a"])
|
||||
tbl2 = Table(view)
|
||||
view2 = tbl2.view()
|
||||
|
||||
dimms = view2.dimensions()
|
||||
assert dimms["num_view_rows"] == 3
|
||||
assert dimms["num_view_columns"] == 3
|
||||
assert view2.schema() == {
|
||||
"a (Group by 1)": "integer",
|
||||
"a": "integer",
|
||||
"b": "integer",
|
||||
}
|
||||
|
||||
assert view2.to_records() == [
|
||||
{"a (Group by 1)": None, "a": 4, "b": 6},
|
||||
{"a (Group by 1)": 1, "a": 1, "b": 2},
|
||||
{"a (Group by 1)": 3, "a": 3, "b": 4},
|
||||
]
|
||||
|
||||
def test_view_two(self):
|
||||
data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}]
|
||||
tbl = Table(data)
|
||||
view = tbl.view(group_by=["a"], split_by=["b"])
|
||||
tbl2 = Table(view)
|
||||
view2 = tbl2.view()
|
||||
dimms = view2.dimensions()
|
||||
assert dimms["num_view_rows"] == 3
|
||||
assert dimms["num_view_columns"] == 5
|
||||
assert view2.schema() == {
|
||||
"a (Group by 1)": "integer",
|
||||
"2|a": "integer",
|
||||
"2|b": "integer",
|
||||
"4|a": "integer",
|
||||
"4|b": "integer",
|
||||
}
|
||||
|
||||
assert view2.to_records() == [
|
||||
{
|
||||
"a (Group by 1)": None,
|
||||
"2|a": 1,
|
||||
"2|b": 2,
|
||||
"4|a": 3,
|
||||
"4|b": 4,
|
||||
},
|
||||
{
|
||||
"a (Group by 1)": 1,
|
||||
"2|a": 1,
|
||||
"2|b": 2,
|
||||
"4|a": None,
|
||||
"4|b": None,
|
||||
},
|
||||
{
|
||||
"a (Group by 1)": 3,
|
||||
"2|a": None,
|
||||
"2|b": None,
|
||||
"4|a": 3,
|
||||
"4|b": 4,
|
||||
},
|
||||
]
|
||||
|
||||
def test_view_two_column_only(self):
|
||||
data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}]
|
||||
tbl = Table(data)
|
||||
view = tbl.view(split_by=["b"])
|
||||
tbl2 = Table(view)
|
||||
view2 = tbl2.view()
|
||||
dimms = view2.dimensions()
|
||||
assert dimms["num_view_rows"] == 2
|
||||
assert dimms["num_view_columns"] == 4
|
||||
assert view2.schema() == {
|
||||
"2|a": "integer",
|
||||
"2|b": "integer",
|
||||
"4|a": "integer",
|
||||
"4|b": "integer",
|
||||
}
|
||||
|
||||
assert view2.to_records() == [
|
||||
{"2|a": 1, "2|b": 2, "4|a": None, "4|b": None},
|
||||
{"2|a": None, "2|b": None, "4|a": 3, "4|b": 4},
|
||||
]
|
||||
@@ -0,0 +1,417 @@
|
||||
# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
# ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
# ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
# ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
# ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
# ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
import pyarrow as pa
|
||||
from datetime import date, datetime
|
||||
import perspective as psp
|
||||
|
||||
client = psp.Server().new_local_client()
|
||||
Table = client.table
|
||||
|
||||
|
||||
class TestToArrow(object):
|
||||
def test_to_arrow_nones_symmetric(self):
|
||||
data = {"a": [None, 1, None, 2, 3], "b": [1.5, 2.5, None, 3.5, None]}
|
||||
tbl = Table(data)
|
||||
assert tbl.schema() == {"a": "integer", "b": "float"}
|
||||
arr = tbl.view().to_arrow()
|
||||
tbl2 = Table(arr)
|
||||
assert tbl2.view().to_columns() == data
|
||||
|
||||
def test_to_arrow_big_numbers_symmetric(self):
|
||||
data = {
|
||||
"a": [1, 2, 3, 4],
|
||||
"b": [
|
||||
1.7976931348623157e308,
|
||||
1.7976931348623157e308,
|
||||
1.7976931348623157e308,
|
||||
1.7976931348623157e308,
|
||||
],
|
||||
}
|
||||
tbl = Table(data)
|
||||
assert tbl.schema() == {"a": "integer", "b": "float"}
|
||||
arr = tbl.view().to_arrow()
|
||||
tbl2 = Table(arr)
|
||||
assert tbl2.view().to_columns() == data
|
||||
|
||||
def test_to_arrow_boolean_symmetric(self):
|
||||
data = {"a": [True, False, None, False, True, None]}
|
||||
tbl = Table(data)
|
||||
assert tbl.schema() == {"a": "boolean"}
|
||||
arr = tbl.view().to_arrow()
|
||||
tbl2 = Table(arr)
|
||||
assert tbl2.view().to_columns() == data
|
||||
|
||||
def test_to_arrow_str_symmetric(self):
|
||||
data = {"a": ["a", "b", "c", "d", "e", None]}
|
||||
tbl = Table(data)
|
||||
assert tbl.schema() == {"a": "string"}
|
||||
arr = tbl.view().to_arrow()
|
||||
tbl2 = Table(arr)
|
||||
assert tbl2.view().to_columns() == data
|
||||
|
||||
def test_to_arrow_str_dict(self):
|
||||
data = {
|
||||
"a": ["abcdefg", "abcdefg", "h"],
|
||||
"b": ["aaa", "bbb", "bbb"],
|
||||
"c": ["hello", "world", "world"],
|
||||
}
|
||||
tbl = Table(data)
|
||||
assert tbl.schema() == {"a": "string", "b": "string", "c": "string"}
|
||||
arr = tbl.view().to_arrow()
|
||||
|
||||
# assert that we are actually generating dict arrays
|
||||
buf = pa.BufferReader(arr)
|
||||
reader = pa.ipc.open_stream(buf)
|
||||
arrow_table = reader.read_all()
|
||||
arrow_schema = arrow_table.schema
|
||||
|
||||
for name in ("a", "b", "c"):
|
||||
arrow_type = arrow_schema.field(name).type
|
||||
assert pa.types.is_dictionary(arrow_type)
|
||||
|
||||
# assert that data is symmetrical
|
||||
tbl2 = Table(arr)
|
||||
assert tbl2.view().to_columns() == data
|
||||
|
||||
def test_to_arrow_date_symmetric(self):
|
||||
# data = {"a": [date(2019, 7, 11), date(2016, 2, 29), date(2019, 12, 10)]}
|
||||
# tbl = Table(data)
|
||||
tbl = Table({"a": "date"})
|
||||
# data = {"a": map(lambda x: x.getTime(), [date(2019, 7, 11), date(2016, 2, 29), date(2019, 12, 10)])}
|
||||
data = {"a": [date(2019, 7, 11), date(2016, 2, 29), date(2019, 12, 10)]}
|
||||
tbl.update(data)
|
||||
assert tbl.schema() == {"a": "date"}
|
||||
arr = tbl.view().to_arrow()
|
||||
tbl2 = Table(arr)
|
||||
|
||||
def ts(x):
|
||||
return int(datetime.timestamp(x) * 1000)
|
||||
|
||||
assert tbl2.schema() == tbl.schema()
|
||||
assert tbl2.view().to_columns() == {
|
||||
"a": [
|
||||
ts(datetime(2019, 7, 11)),
|
||||
ts(datetime(2016, 2, 29)),
|
||||
ts(datetime(2019, 12, 10)),
|
||||
]
|
||||
}
|
||||
|
||||
def test_to_arrow_date_symmetric_january(self, util):
|
||||
data = {"a": [date(2019, 1, 1), date(2016, 1, 1), date(2019, 1, 1)]}
|
||||
tbl = Table(data)
|
||||
assert tbl.schema() == {"a": "date"}
|
||||
arr = tbl.view().to_arrow()
|
||||
tbl2 = Table(arr)
|
||||
assert tbl2.schema() == tbl.schema()
|
||||
assert tbl2.view().to_columns() == {
|
||||
"a": [
|
||||
util.to_timestamp(x)
|
||||
for x in [
|
||||
datetime(2019, 1, 1),
|
||||
datetime(2016, 1, 1),
|
||||
datetime(2019, 1, 1),
|
||||
]
|
||||
]
|
||||
}
|
||||
|
||||
def test_to_arrow_datetime_symmetric(self, util):
|
||||
data = {
|
||||
"a": [
|
||||
datetime(2019, 7, 11, 12, 30),
|
||||
datetime(2016, 2, 29, 11, 0),
|
||||
datetime(2019, 12, 10, 12, 0),
|
||||
]
|
||||
}
|
||||
tbl = Table(data)
|
||||
assert tbl.schema() == {"a": "datetime"}
|
||||
arr = tbl.view().to_arrow()
|
||||
tbl2 = Table(arr)
|
||||
assert tbl2.schema() == tbl.schema()
|
||||
assert tbl2.view().to_columns() == {
|
||||
"a": [
|
||||
util.to_timestamp(x)
|
||||
for x in [
|
||||
datetime(2019, 7, 11, 12, 30),
|
||||
datetime(2016, 2, 29, 11, 0),
|
||||
datetime(2019, 12, 10, 12, 0),
|
||||
]
|
||||
]
|
||||
}
|
||||
|
||||
def test_to_arrow_one_symmetric(self):
|
||||
data = {
|
||||
"a": [1, 2, 3, 4],
|
||||
"b": ["a", "b", "c", "d"],
|
||||
"c": [
|
||||
datetime(2019, 7, 11, 12, 0),
|
||||
datetime(2019, 7, 11, 12, 10),
|
||||
datetime(2019, 7, 11, 12, 20),
|
||||
datetime(2019, 7, 11, 12, 30),
|
||||
],
|
||||
}
|
||||
tbl = Table(data)
|
||||
view = tbl.view(group_by=["a"])
|
||||
arrow = view.to_arrow()
|
||||
tbl2 = Table(arrow)
|
||||
assert tbl2.schema() == {
|
||||
"a (Group by 1)": "integer",
|
||||
"a": "integer",
|
||||
"b": "integer",
|
||||
"c": "integer",
|
||||
}
|
||||
d = view.to_columns()
|
||||
d["a (Group by 1)"] = [
|
||||
x[0] if len(x) > 0 else None for x in d.pop("__ROW_PATH__")
|
||||
]
|
||||
assert tbl2.view().to_columns() == d
|
||||
|
||||
def test_to_arrow_two_symmetric(self):
|
||||
data = {
|
||||
"a": [1, 2, 3, 4],
|
||||
"b": ["hello", "world", "hello2", "world2"],
|
||||
"c": [datetime(2019, 7, 11, 12, i) for i in range(0, 40, 10)],
|
||||
}
|
||||
tbl = Table(data)
|
||||
view = tbl.view(group_by=["a"], split_by=["b"])
|
||||
arrow = view.to_arrow()
|
||||
tbl2 = Table(arrow)
|
||||
assert tbl2.schema() == {
|
||||
"a (Group by 1)": "integer",
|
||||
"hello|a": "integer",
|
||||
"hello|b": "integer",
|
||||
"hello|c": "integer",
|
||||
"world|a": "integer",
|
||||
"world|b": "integer",
|
||||
"world|c": "integer",
|
||||
"hello2|a": "integer",
|
||||
"hello2|b": "integer",
|
||||
"hello2|c": "integer",
|
||||
"world2|a": "integer",
|
||||
"world2|b": "integer",
|
||||
"world2|c": "integer",
|
||||
}
|
||||
d = view.to_columns()
|
||||
d["a (Group by 1)"] = [
|
||||
x[0] if len(x) > 0 else None for x in d.pop("__ROW_PATH__")
|
||||
]
|
||||
assert tbl2.view().to_columns() == d
|
||||
|
||||
def test_to_arrow_column_only_symmetric(self):
|
||||
data = {
|
||||
"a": [1, 2, 3, 4],
|
||||
"b": ["a", "b", "c", "d"],
|
||||
"c": [datetime(2019, 7, 11, 12, i) for i in range(0, 40, 10)],
|
||||
}
|
||||
tbl = Table(data)
|
||||
view = tbl.view(split_by=["a"])
|
||||
arrow = view.to_arrow()
|
||||
tbl2 = Table(arrow)
|
||||
assert tbl2.schema() == {
|
||||
"1|a": "integer",
|
||||
"1|b": "string",
|
||||
"1|c": "datetime",
|
||||
"2|a": "integer",
|
||||
"2|b": "string",
|
||||
"2|c": "datetime",
|
||||
"3|a": "integer",
|
||||
"3|b": "string",
|
||||
"3|c": "datetime",
|
||||
"4|a": "integer",
|
||||
"4|b": "string",
|
||||
"4|c": "datetime",
|
||||
}
|
||||
d = view.to_columns()
|
||||
assert tbl2.view().to_columns() == d
|
||||
|
||||
# start and end row
|
||||
def test_to_arrow_start_row(self):
|
||||
data = {"a": [None, 1, None, 2, 3], "b": [1.5, 2.5, None, 3.5, None]}
|
||||
tbl = Table(data)
|
||||
assert tbl.schema() == {"a": "integer", "b": "float"}
|
||||
arr = tbl.view().to_arrow(start_row=3)
|
||||
tbl2 = Table(arr)
|
||||
assert tbl2.view().to_columns() == {"a": data["a"][3:], "b": data["b"][3:]}
|
||||
|
||||
def test_to_arrow_end_row(self):
|
||||
data = {"a": [None, 1, None, 2, 3], "b": [1.5, 2.5, None, 3.5, None]}
|
||||
tbl = Table(data)
|
||||
assert tbl.schema() == {"a": "integer", "b": "float"}
|
||||
arr = tbl.view().to_arrow(end_row=2)
|
||||
tbl2 = Table(arr)
|
||||
assert tbl2.view().to_columns() == {"a": data["a"][:2], "b": data["b"][:2]}
|
||||
|
||||
def test_to_arrow_start_end_row(self):
|
||||
data = {"a": [None, 1, None, 2, 3], "b": [1.5, 2.5, None, 3.5, None]}
|
||||
tbl = Table(data)
|
||||
assert tbl.schema() == {"a": "integer", "b": "float"}
|
||||
arr = tbl.view().to_arrow(start_row=2, end_row=3)
|
||||
tbl2 = Table(arr)
|
||||
assert tbl2.view().to_columns() == {"a": data["a"][2:3], "b": data["b"][2:3]}
|
||||
|
||||
def test_to_arrow_start_end_row_equiv(self):
|
||||
data = {"a": [None, 1, None, 2, 3], "b": [1.5, 2.5, None, 3.5, None]}
|
||||
tbl = Table(data)
|
||||
assert tbl.schema() == {"a": "integer", "b": "float"}
|
||||
arr = tbl.view().to_arrow(start_row=2, end_row=2)
|
||||
tbl2 = Table(arr)
|
||||
assert tbl2.view().to_columns() == {"a": [], "b": []}
|
||||
|
||||
def test_to_arrow_start_row_invalid(self):
|
||||
data = {"a": [None, 1, None, 2, 3], "b": [1.5, 2.5, None, 3.5, None]}
|
||||
tbl = Table(data)
|
||||
assert tbl.schema() == {"a": "integer", "b": "float"}
|
||||
arr = tbl.view().to_arrow(start_row=-1)
|
||||
tbl2 = Table(arr)
|
||||
assert tbl2.view().to_columns() == data
|
||||
|
||||
def test_to_arrow_end_row_invalid(self):
|
||||
data = {"a": [None, 1, None, 2, 3], "b": [1.5, 2.5, None, 3.5, None]}
|
||||
tbl = Table(data)
|
||||
assert tbl.schema() == {"a": "integer", "b": "float"}
|
||||
arr = tbl.view().to_arrow(end_row=6)
|
||||
tbl2 = Table(arr)
|
||||
assert tbl2.view().to_columns() == data
|
||||
|
||||
def test_to_arrow_start_end_row_invalid(self):
|
||||
data = {"a": [None, 1, None, 2, 3], "b": [1.5, 2.5, None, 3.5, None]}
|
||||
tbl = Table(data)
|
||||
assert tbl.schema() == {"a": "integer", "b": "float"}
|
||||
arr = tbl.view().to_arrow(start_row=-1, end_row=6)
|
||||
tbl2 = Table(arr)
|
||||
assert tbl2.view().to_columns() == data
|
||||
|
||||
def test_to_arrow_start_col(self):
|
||||
data = {"a": [None, 1, None, 2, 3], "b": [1.5, 2.5, None, 3.5, None]}
|
||||
tbl = Table(data)
|
||||
assert tbl.schema() == {"a": "integer", "b": "float"}
|
||||
arr = tbl.view().to_arrow(start_col=1)
|
||||
tbl2 = Table(arr)
|
||||
assert tbl2.view().to_columns() == {"b": data["b"]}
|
||||
|
||||
def test_to_arrow_end_col(self):
|
||||
data = {"a": [None, 1, None, 2, 3], "b": [1.5, 2.5, None, 3.5, None]}
|
||||
tbl = Table(data)
|
||||
assert tbl.schema() == {"a": "integer", "b": "float"}
|
||||
arr = tbl.view().to_arrow(end_col=1)
|
||||
tbl2 = Table(arr)
|
||||
assert tbl2.view().to_columns() == {"a": data["a"]}
|
||||
|
||||
def test_to_arrow_start_end_col(self):
|
||||
data = {
|
||||
"a": [None, 1, None, 2, 3],
|
||||
"b": [1.5, 2.5, None, 3.5, None],
|
||||
"c": [None, 1, None, 2, 3],
|
||||
"d": [1.5, 2.5, None, 3.5, None],
|
||||
}
|
||||
tbl = Table(data)
|
||||
assert tbl.schema() == {
|
||||
"a": "integer",
|
||||
"b": "float",
|
||||
"c": "integer",
|
||||
"d": "float",
|
||||
}
|
||||
arr = tbl.view().to_arrow(start_col=1, end_col=3)
|
||||
tbl2 = Table(arr)
|
||||
assert tbl2.view().to_columns() == {"b": data["b"], "c": data["c"]}
|
||||
|
||||
def test_to_arrow_start_col_invalid(self):
|
||||
data = {"a": [None, 1, None, 2, 3], "b": [1.5, 2.5, None, 3.5, None]}
|
||||
tbl = Table(data)
|
||||
assert tbl.schema() == {"a": "integer", "b": "float"}
|
||||
arr = tbl.view().to_arrow(start_col=-1)
|
||||
tbl2 = Table(arr)
|
||||
assert tbl2.view().to_columns() == data
|
||||
|
||||
def test_to_arrow_end_col_invalid(self):
|
||||
data = {"a": [None, 1, None, 2, 3], "b": [1.5, 2.5, None, 3.5, None]}
|
||||
tbl = Table(data)
|
||||
assert tbl.schema() == {"a": "integer", "b": "float"}
|
||||
arr = tbl.view().to_arrow(end_col=6)
|
||||
tbl2 = Table(arr)
|
||||
assert tbl2.view().to_columns() == data
|
||||
|
||||
def test_to_arrow_start_end_col_invalid(self):
|
||||
data = {"a": [None, 1, None, 2, 3], "b": [1.5, 2.5, None, 3.5, None]}
|
||||
tbl = Table(data)
|
||||
assert tbl.schema() == {"a": "integer", "b": "float"}
|
||||
arr = tbl.view().to_arrow(start_col=-1, end_col=6)
|
||||
tbl2 = Table(arr)
|
||||
assert tbl2.view().to_columns() == data
|
||||
|
||||
def test_to_arrow_start_end_col_equiv_row(self):
|
||||
data = {"a": [None, 1, None, 2, 3], "b": [1.5, 2.5, None, 3.5, None]}
|
||||
tbl = Table(data)
|
||||
assert tbl.schema() == {"a": "integer", "b": "float"}
|
||||
arr = tbl.view().to_arrow(start_col=1, end_col=1, start_row=2, end_row=3)
|
||||
tbl2 = Table(arr)
|
||||
# start/end col is a range - thus start=end provides no columns
|
||||
assert tbl2.view().to_columns() == {}
|
||||
|
||||
def test_to_arrow_start_end_col_equiv(self):
|
||||
data = {"a": [None, 1, None, 2, 3], "b": [1.5, 2.5, None, 3.5, None]}
|
||||
tbl = Table(data)
|
||||
assert tbl.schema() == {"a": "integer", "b": "float"}
|
||||
arr = tbl.view().to_arrow(start_col=1, end_col=1)
|
||||
tbl2 = Table(arr)
|
||||
assert tbl2.view().to_columns() == {}
|
||||
|
||||
def test_to_arrow_start_end_row_end_col(self):
|
||||
data = {"a": [None, 1, None, 2, 3], "b": [1.5, 2.5, None, 3.5, None]}
|
||||
tbl = Table(data)
|
||||
assert tbl.schema() == {"a": "integer", "b": "float"}
|
||||
arr = tbl.view().to_arrow(end_col=1, start_row=2, end_row=3)
|
||||
tbl2 = Table(arr)
|
||||
assert tbl2.view().to_columns() == tbl.view().to_columns(
|
||||
end_col=1, start_row=2, end_row=3
|
||||
)
|
||||
|
||||
def test_to_arrow_start_end_col_start_row(self):
|
||||
data = {
|
||||
"a": [None, 1, None, 2, 3],
|
||||
"b": [1.5, 2.5, None, 3.5, None],
|
||||
"c": [1.5, 2.5, None, 4.5, None],
|
||||
}
|
||||
tbl = Table(data)
|
||||
assert tbl.schema() == {"a": "integer", "b": "float", "c": "float"}
|
||||
arr = tbl.view().to_arrow(start_col=1, end_col=2, start_row=2)
|
||||
tbl2 = Table(arr)
|
||||
assert tbl2.view().to_columns() == tbl.view().to_columns(
|
||||
start_col=1, end_col=2, start_row=2
|
||||
)
|
||||
|
||||
def test_to_arrow_start_end_col_end_row(self):
|
||||
data = {
|
||||
"a": [None, 1, None, 2, 3],
|
||||
"b": [1.5, 2.5, None, 3.5, None],
|
||||
"c": [1.5, 2.5, None, 4.5, None],
|
||||
}
|
||||
tbl = Table(data)
|
||||
assert tbl.schema() == {"a": "integer", "b": "float", "c": "float"}
|
||||
arr = tbl.view().to_arrow(start_col=1, end_col=2, end_row=2)
|
||||
tbl2 = Table(arr)
|
||||
assert tbl2.view().to_columns() == tbl.view().to_columns(
|
||||
start_col=1, end_col=2, end_row=2
|
||||
)
|
||||
|
||||
def test_to_arrow_one_mean(self):
|
||||
data = {"a": [1, 2, 3, 4], "b": ["a", "a", "b", "b"]}
|
||||
|
||||
table = Table(data)
|
||||
view = table.view(group_by=["b"], columns=["a"], aggregates={"a": "mean"})
|
||||
arrow = view.to_arrow()
|
||||
|
||||
table2 = Table(arrow)
|
||||
view2 = table2.view()
|
||||
result = view2.to_columns()
|
||||
|
||||
assert result == {"b (Group by 1)": [None, "a", "b"], "a": [2.5, 1.5, 3.5]}
|
||||
@@ -0,0 +1,32 @@
|
||||
# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
# ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
# ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
# ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
# ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
# ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
import perspective as psp
|
||||
|
||||
client = psp.Server().new_local_client()
|
||||
Table = client.table
|
||||
|
||||
|
||||
class TestToArrowLZ4(object):
|
||||
def test_to_arrow_lz4_roundtrip(self, superstore):
|
||||
original_tbl = Table(superstore.to_dict(orient="records"))
|
||||
arrow_uncompressed = original_tbl.view().to_arrow(compression=None)
|
||||
|
||||
tbl = Table(arrow_uncompressed)
|
||||
arr = tbl.view().to_arrow(compression="lz4")
|
||||
assert len(arr) < len(arrow_uncompressed)
|
||||
tbl2 = Table(arr)
|
||||
arr2 = tbl2.view().to_arrow(compression=None)
|
||||
assert len(arr2) > len(arr)
|
||||
tbl3 = Table(arr)
|
||||
arr3 = tbl3.view().to_arrow(compression="lz4")
|
||||
assert len(arr3) == len(arr)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
||||
# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
# ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
# ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
# ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
# ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
# ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
import perspective as psp
|
||||
|
||||
client = psp.Server().new_local_client()
|
||||
Table = client.table
|
||||
|
||||
|
||||
class TestToPolars(object):
|
||||
def test_to_polars(self, superstore):
|
||||
original_tbl = Table(superstore.to_dict(orient="records"))
|
||||
df = original_tbl.view().to_polars()
|
||||
tbl = Table(df)
|
||||
assert tbl.size() == original_tbl.size()
|
||||
df2 = tbl.view().to_polars()
|
||||
assert df.equals(df2)
|
||||
@@ -0,0 +1,536 @@
|
||||
# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
# ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
# ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
# ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
# ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
# ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
import numpy as np
|
||||
import pyarrow as pa
|
||||
import pandas as pd
|
||||
from datetime import date, datetime
|
||||
from pytest import mark
|
||||
import perspective as psp
|
||||
|
||||
client = psp.Server().new_local_client()
|
||||
Table = client.table
|
||||
|
||||
|
||||
class TestUpdate(object):
|
||||
@mark.skip(reason="Fix for null values update yet to be implemented")
|
||||
def test_update_with_missing_or_null_values(self):
|
||||
tbl = Table([{"a": "1", "b": "2"}, {"a": "3", "b": "4"}], index="a")
|
||||
tbl.update([{"a": "1", "b": None}])
|
||||
assert tbl.view().to_records() == [{"a": "1", "b": None}, {"a": "3", "b": "4"}]
|
||||
|
||||
data = pd.DataFrame({"a": ["1"], "b": [None]})
|
||||
|
||||
arrow_table = pa.Table.from_pandas(data, preserve_index=False)
|
||||
|
||||
tbl.update(arrow_table)
|
||||
assert tbl.size() == 2
|
||||
assert tbl.view().to_records() == [{"a": "1", "b": ""}, {"a": "3", "b": "4"}]
|
||||
|
||||
def test_update_from_schema(self):
|
||||
tbl = Table({"a": "string", "b": "integer"})
|
||||
tbl.update([{"a": "abc", "b": 123}])
|
||||
assert tbl.view().to_records() == [{"a": "abc", "b": 123}]
|
||||
|
||||
def test_update_columnar_from_schema(self):
|
||||
tbl = Table({"a": "string", "b": "integer"})
|
||||
tbl.update({"a": ["abc"], "b": [123]})
|
||||
assert tbl.view().to_records() == [{"a": "abc", "b": 123}]
|
||||
|
||||
def test_update_csv(self):
|
||||
tbl = Table({"a": "string", "b": "integer"})
|
||||
|
||||
view = tbl.view()
|
||||
tbl.update("a,b\nxyz,123\ndef,100000000")
|
||||
|
||||
assert view.to_columns() == {"a": ["xyz", "def"], "b": [123, 100000000]}
|
||||
|
||||
def test_update_csv_indexed(self):
|
||||
tbl = Table({"a": "string", "b": "float"}, index="a")
|
||||
|
||||
view = tbl.view()
|
||||
tbl.update("a,b\nxyz,1.23456718\ndef,100000000.1")
|
||||
|
||||
assert view.to_columns() == {
|
||||
"a": ["def", "xyz"],
|
||||
"b": [100000000.1, 1.23456718],
|
||||
}
|
||||
|
||||
tbl.update("a,b\nxyz,0.00000001\ndef,1234.5678\nefg,100.2")
|
||||
|
||||
assert view.to_columns() == {
|
||||
"a": ["def", "efg", "xyz"],
|
||||
"b": [1234.5678, 100.2, 0.00000001],
|
||||
}
|
||||
|
||||
def test_update_append(self):
|
||||
tbl = Table([{"a": "abc", "b": 123}])
|
||||
tbl.update([{"a": "def", "b": 456}])
|
||||
assert tbl.view().to_records() == [
|
||||
{"a": "abc", "b": 123},
|
||||
{"a": "def", "b": 456},
|
||||
]
|
||||
|
||||
def test_update_partial(self):
|
||||
tbl = Table([{"a": "abc", "b": 123}], index="a")
|
||||
tbl.update([{"a": "abc", "b": 456}])
|
||||
assert tbl.view().to_records() == [{"a": "abc", "b": 456}]
|
||||
|
||||
def test_update_partial_add_row(self):
|
||||
tbl = Table([{"a": "abc", "b": 123}], index="a")
|
||||
tbl.update([{"a": "abc", "b": 456}, {"a": "def"}])
|
||||
assert tbl.view().to_records() == [
|
||||
{"a": "abc", "b": 456},
|
||||
{"a": "def", "b": None},
|
||||
]
|
||||
|
||||
def test_update_partial_noop(self):
|
||||
tbl = Table([{"a": "abc", "b": 1, "c": 2}], index="a")
|
||||
tbl.update([{"a": "abc", "b": 456}])
|
||||
assert tbl.view().to_records() == [{"a": "abc", "b": 456, "c": 2}]
|
||||
|
||||
def test_update_partial_unset(self):
|
||||
tbl = Table(
|
||||
[{"a": "abc", "b": 1, "c": 2}, {"a": "def", "b": 3, "c": 4}], index="a"
|
||||
)
|
||||
tbl.update([{"a": "abc"}, {"a": "def", "c": None}])
|
||||
assert tbl.view().to_records() == [
|
||||
{"a": "abc", "b": 1, "c": 2},
|
||||
{"a": "def", "b": 3, "c": None},
|
||||
]
|
||||
|
||||
def test_update_columnar_partial_add_row(self):
|
||||
tbl = Table([{"a": "abc", "b": 123}], index="a")
|
||||
|
||||
tbl.update({"a": ["abc", "def"], "b": [456, None]})
|
||||
|
||||
assert tbl.view().to_records() == [
|
||||
{"a": "abc", "b": 456},
|
||||
{"a": "def", "b": None},
|
||||
]
|
||||
|
||||
def test_update_columnar_partial_noop(self):
|
||||
tbl = Table([{"a": "abc", "b": 1, "c": 2}], index="a")
|
||||
|
||||
# no-op because "c" is not in the update dataset
|
||||
tbl.update({"a": ["abc"], "b": [456]})
|
||||
|
||||
assert tbl.view().to_records() == [{"a": "abc", "b": 456, "c": 2}]
|
||||
|
||||
def test_update_columnar_partial_unset(self):
|
||||
tbl = Table(
|
||||
[{"a": "abc", "b": 1, "c": 2}, {"a": "def", "b": 3, "c": 4}], index="a"
|
||||
)
|
||||
|
||||
tbl.update({"a": ["abc"], "b": [None]})
|
||||
|
||||
assert tbl.view().to_records() == [
|
||||
{"a": "abc", "b": None, "c": 2},
|
||||
{"a": "def", "b": 3, "c": 4},
|
||||
]
|
||||
|
||||
def test_update_partial_subcolumn(self):
|
||||
tbl = Table([{"a": "abc", "b": 123, "c": 456}], index="a")
|
||||
tbl.update([{"a": "abc", "c": 1234}])
|
||||
assert tbl.view().to_records() == [{"a": "abc", "b": 123, "c": 1234}]
|
||||
|
||||
def test_update_partial_subcolumn_dict(self):
|
||||
tbl = Table([{"a": "abc", "b": 123, "c": 456}], index="a")
|
||||
tbl.update({"a": ["abc"], "c": [1234]})
|
||||
assert tbl.view().to_records() == [{"a": "abc", "b": 123, "c": 1234}]
|
||||
|
||||
def test_update_partial_cols_more_columns_than_table(self):
|
||||
tbl = Table([{"a": "abc", "b": 123}], index="a")
|
||||
tbl.update([{"a": "abc", "b": 456, "c": 789}])
|
||||
assert tbl.view().to_records() == [{"a": "abc", "b": 456}]
|
||||
|
||||
def test_update_columnar_append(self):
|
||||
tbl = Table({"a": ["abc"], "b": [123]})
|
||||
tbl.update({"a": ["def"], "b": [456]})
|
||||
assert tbl.view().to_records() == [
|
||||
{"a": "abc", "b": 123},
|
||||
{"a": "def", "b": 456},
|
||||
]
|
||||
|
||||
def test_update_columnar_partial(self):
|
||||
tbl = Table({"a": ["abc"], "b": [123]}, index="a")
|
||||
tbl.update({"a": ["abc"], "b": [456]})
|
||||
assert tbl.view().to_records() == [{"a": "abc", "b": 456}]
|
||||
|
||||
# make sure already created views are notified properly
|
||||
def test_update_from_schema_notify(self):
|
||||
tbl = Table({"a": "string", "b": "integer"})
|
||||
view = tbl.view()
|
||||
assert view.num_rows() == 0
|
||||
tbl.update([{"a": "abc", "b": 123}])
|
||||
assert view.to_records() == [{"a": "abc", "b": 123}]
|
||||
|
||||
def test_update_columnar_from_schema_notify(self):
|
||||
tbl = Table({"a": "string", "b": "integer"})
|
||||
view = tbl.view()
|
||||
assert view.num_rows() == 0
|
||||
tbl.update({"a": ["abc"], "b": [123]})
|
||||
assert view.to_records() == [{"a": "abc", "b": 123}]
|
||||
|
||||
def test_update_append_notify(self):
|
||||
tbl = Table([{"a": "abc", "b": 123}])
|
||||
view = tbl.view()
|
||||
assert view.num_rows() == 1
|
||||
tbl.update([{"a": "def", "b": 456}])
|
||||
assert view.to_records() == [{"a": "abc", "b": 123}, {"a": "def", "b": 456}]
|
||||
|
||||
def test_update_partial_notify(self):
|
||||
tbl = Table([{"a": "abc", "b": 123}], index="a")
|
||||
view = tbl.view()
|
||||
assert view.num_rows() == 1
|
||||
tbl.update([{"a": "abc", "b": 456}])
|
||||
assert view.to_records() == [{"a": "abc", "b": 456}]
|
||||
|
||||
def test_update_partial_cols_not_in_schema_notify(self):
|
||||
tbl = Table([{"a": "abc", "b": 123}], index="a")
|
||||
view = tbl.view()
|
||||
assert view.num_rows() == 1
|
||||
tbl.update([{"a": "abc", "b": 456, "c": 789}])
|
||||
assert view.to_records() == [{"a": "abc", "b": 456}]
|
||||
|
||||
def test_update_columnar_append_notify(self):
|
||||
tbl = Table({"a": ["abc"], "b": [123]})
|
||||
view = tbl.view()
|
||||
assert view.num_rows() == 1
|
||||
tbl.update({"a": ["def"], "b": [456]})
|
||||
assert view.to_records() == [{"a": "abc", "b": 123}, {"a": "def", "b": 456}]
|
||||
|
||||
def test_update_columnar_partial_notify(self):
|
||||
tbl = Table({"a": ["abc"], "b": [123]}, index="a")
|
||||
view = tbl.view()
|
||||
assert view.num_rows() == 1
|
||||
tbl.update({"a": ["abc"], "b": [456]})
|
||||
assert view.to_records() == [{"a": "abc", "b": 456}]
|
||||
|
||||
# bool
|
||||
|
||||
def test_update_bool_from_schema(self):
|
||||
bool_data = [{"a": True, "b": False}, {"a": True, "b": True}]
|
||||
tbl = Table({"a": "boolean", "b": "boolean"})
|
||||
tbl.update(bool_data)
|
||||
assert tbl.size() == 2
|
||||
assert tbl.view().to_records() == bool_data
|
||||
|
||||
def test_update_bool_str_from_schema(self):
|
||||
bool_data = [{"a": "True", "b": "False"}, {"a": "True", "b": "True"}]
|
||||
tbl = Table({"a": "boolean", "b": "boolean"})
|
||||
tbl.update(bool_data)
|
||||
assert tbl.size() == 2
|
||||
assert tbl.view().to_records() == [
|
||||
{"a": True, "b": False},
|
||||
{"a": True, "b": True},
|
||||
]
|
||||
|
||||
@mark.skip(reason="We may not want to support this")
|
||||
def test_update_bool_str_all_formats_from_schema(self):
|
||||
bool_data = [
|
||||
{"a": "True", "b": "False"},
|
||||
{"a": "t", "b": "f"},
|
||||
{"a": "true", "b": "false"},
|
||||
{"a": 1, "b": 0},
|
||||
{"a": "on", "b": "off"},
|
||||
]
|
||||
tbl = Table({"a": "boolean", "b": "boolean"})
|
||||
tbl.update(bool_data)
|
||||
assert tbl.size() == 5
|
||||
assert tbl.view().to_columns() == {
|
||||
"a": [True, True, True, True, True],
|
||||
"b": [False, False, False, False, False],
|
||||
}
|
||||
|
||||
def test_update_bool_int_from_schema(self):
|
||||
bool_data = [{"a": 1, "b": 0}, {"a": 1, "b": 0}]
|
||||
tbl = Table({"a": "boolean", "b": "boolean"})
|
||||
tbl.update(bool_data)
|
||||
assert tbl.size() == 2
|
||||
assert tbl.view().to_columns() == {"a": [True, True], "b": [False, False]}
|
||||
|
||||
# dates and datetimes
|
||||
def test_update_date(self, util):
|
||||
tbl = Table({"a": [date(2019, 7, 11)]})
|
||||
tbl.update([{"a": date(2019, 7, 12)}])
|
||||
assert tbl.view().to_records() == [
|
||||
{"a": util.to_timestamp(datetime(2019, 7, 11))},
|
||||
{"a": util.to_timestamp(datetime(2019, 7, 12))},
|
||||
]
|
||||
|
||||
@mark.skip # We do not support numpy.
|
||||
def test_update_date_np(self, util):
|
||||
tbl = Table({"a": [date(2019, 7, 11)]})
|
||||
tbl.update([{"a": np.datetime64(date(2019, 7, 12))}])
|
||||
assert tbl.view().to_records() == [
|
||||
{"a": util.to_timestamp(datetime(2019, 7, 11))},
|
||||
{"a": util.to_timestamp(datetime(2019, 7, 12))},
|
||||
]
|
||||
|
||||
def test_update_datetime(self, util):
|
||||
tbl = Table({"a": [datetime(2019, 7, 11, 11, 0)]})
|
||||
tbl.update([{"a": datetime(2019, 7, 12, 11, 0)}])
|
||||
assert tbl.view().to_records() == [
|
||||
{"a": util.to_timestamp(datetime(2019, 7, 11, 11, 0))},
|
||||
{"a": util.to_timestamp(datetime(2019, 7, 12, 11, 0))},
|
||||
]
|
||||
|
||||
@mark.skip # We do not support numpy.
|
||||
def test_update_datetime_np(self, util):
|
||||
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": util.to_timestamp(datetime(2019, 7, 11, 11, 0))},
|
||||
{"a": util.to_timestamp(datetime(2019, 7, 12, 11, 0))},
|
||||
]
|
||||
|
||||
@mark.skip # We do not support numpy.
|
||||
def test_update_datetime_np_ts(self, util):
|
||||
tbl = Table({"a": [datetime(2019, 7, 11, 11, 0)]})
|
||||
tbl.update([{"a": np.datetime64("2019-07-12T11:00")}])
|
||||
assert tbl.view().to_records() == [
|
||||
{"a": util.to_timestamp(datetime(2019, 7, 11, 11, 0))},
|
||||
{"a": util.to_timestamp(datetime(2019, 7, 12, 11, 0))},
|
||||
]
|
||||
|
||||
def test_update_datetime_timestamp_seconds(self, util):
|
||||
ts = util.to_timestamp(datetime(2019, 7, 12, 11, 0, 0))
|
||||
tbl = Table({"a": [datetime(2019, 7, 11, 11, 0)]})
|
||||
tbl.update([{"a": ts}])
|
||||
assert tbl.view().to_records() == [
|
||||
{"a": util.to_timestamp(datetime(2019, 7, 11, 11, 0))},
|
||||
{"a": util.to_timestamp(datetime(2019, 7, 12, 11, 0))},
|
||||
]
|
||||
|
||||
def test_update_datetime_timestamp_ms(self, util):
|
||||
ts = util.to_timestamp(datetime(2019, 7, 12, 11, 0, 0))
|
||||
tbl = Table({"a": [datetime(2019, 7, 11, 11, 0)]})
|
||||
tbl.update([{"a": ts}])
|
||||
assert tbl.view().to_records() == [
|
||||
{"a": util.to_timestamp(datetime(2019, 7, 11, 11, 0))},
|
||||
{"a": util.to_timestamp(datetime(2019, 7, 12, 11, 0))},
|
||||
]
|
||||
|
||||
# partial date & datetime updates
|
||||
|
||||
def test_update_date_partial(self, util):
|
||||
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": util.to_timestamp(datetime(2019, 7, 12)), "b": 1}
|
||||
]
|
||||
|
||||
@mark.skip # We do not support numpy anymore.
|
||||
def test_update_date_np_partial(self, util):
|
||||
tbl = Table({"a": [date(2019, 7, 11)], "b": [1]}, index="b")
|
||||
tbl.update([{"a": np.datetime64(date(2019, 7, 12)), "b": 1}])
|
||||
assert tbl.view().to_records() == [
|
||||
{"a": util.to_timestamp(datetime(2019, 7, 12)), "b": 1}
|
||||
]
|
||||
|
||||
def test_update_datetime_partial(self, util):
|
||||
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": util.to_timestamp(datetime(2019, 7, 12, 11, 0)), "b": 1}
|
||||
]
|
||||
|
||||
@mark.skip # We do not support numpy anymore.
|
||||
def test_update_datetime_np_partial(self, util):
|
||||
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}])
|
||||
assert tbl.view().to_records() == [
|
||||
{"a": util.to_timestamp(datetime(2019, 7, 12, 11, 0)), "b": 1}
|
||||
]
|
||||
|
||||
@mark.skip # We do not support numpy anymore.
|
||||
def test_update_datetime_np_ts_partial(self, util):
|
||||
tbl = Table({"a": [datetime(2019, 7, 11, 11, 0)], "b": [1]}, index="b")
|
||||
tbl.update([{"a": np.datetime64("2019-07-12T11:00"), "b": 1}])
|
||||
assert tbl.view().to_records() == [
|
||||
{"a": util.to_timestamp(datetime(2019, 7, 12, 11, 0)), "b": 1}
|
||||
]
|
||||
|
||||
def test_update_datetime_timestamp_seconds_partial(self, util):
|
||||
ts = util.to_timestamp(datetime(2019, 7, 12, 11, 0, 0))
|
||||
tbl = Table({"a": [datetime(2019, 7, 11, 11, 0)], "idx": [1]}, index="idx")
|
||||
tbl.update([{"a": ts, "idx": 1}])
|
||||
assert tbl.view().to_records() == [
|
||||
{"a": util.to_timestamp(datetime(2019, 7, 12, 11, 0)), "idx": 1}
|
||||
]
|
||||
|
||||
def test_update_datetime_timestamp_ms_partial(self, util):
|
||||
ts = util.to_timestamp(datetime(2019, 7, 12, 11, 0, 0))
|
||||
tbl = Table({"a": [datetime(2019, 7, 11, 11, 0)], "idx": [1]}, index="idx")
|
||||
tbl.update([{"a": ts, "idx": 1}])
|
||||
assert tbl.view().to_records() == [
|
||||
{"a": util.to_timestamp(datetime(2019, 7, 12, 11, 0)), "idx": 1}
|
||||
]
|
||||
|
||||
# updating dates using implicit index
|
||||
|
||||
def test_update_date_partial_implicit(self, util):
|
||||
tbl = Table({"a": [date(2019, 7, 11)]})
|
||||
tbl.update([{"a": date(2019, 7, 12), "__INDEX__": 0}])
|
||||
assert tbl.view().to_records() == [
|
||||
{"a": util.to_timestamp(datetime(2019, 7, 12))}
|
||||
]
|
||||
|
||||
@mark.skip # We do not support numpy anymore.
|
||||
def test_update_date_np_partial_implicit(self, util):
|
||||
tbl = Table({"a": [date(2019, 7, 11)]})
|
||||
tbl.update([{"a": np.datetime64(date(2019, 7, 12)), "__INDEX__": 0}])
|
||||
assert tbl.view().to_records() == [
|
||||
{"a": util.to_timestamp(datetime(2019, 7, 12))}
|
||||
]
|
||||
|
||||
def test_update_datetime_partial_implicit(self, util):
|
||||
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": util.to_timestamp(datetime(2019, 7, 12, 11, 0))}
|
||||
]
|
||||
|
||||
@mark.skip # We do not support numpy anymore.
|
||||
def test_update_datetime_np_partial_implicit(self, util):
|
||||
tbl = Table({"a": [datetime(2019, 7, 11, 11, 0)]})
|
||||
tbl.update([{"a": np.datetime64(datetime(2019, 7, 12, 11, 0)), "__INDEX__": 0}])
|
||||
assert tbl.view().to_records() == [
|
||||
{"a": util.to_timestamp(datetime(2019, 7, 12, 11, 0))}
|
||||
]
|
||||
|
||||
@mark.skip # We do not support numpy anymore.
|
||||
def test_update_datetime_np_ts_partial_implicit(self, util):
|
||||
tbl = Table({"a": [datetime(2019, 7, 11, 11, 0)]})
|
||||
tbl.update([{"a": np.datetime64("2019-07-12T11:00"), "__INDEX__": 0}])
|
||||
assert tbl.view().to_records() == [
|
||||
{"a": util.to_timestamp(datetime(2019, 7, 12, 11, 0))}
|
||||
]
|
||||
|
||||
# implicit index
|
||||
|
||||
def test_update_implicit_index(self):
|
||||
data = [{"a": 1, "b": 2}, {"a": 2, "b": 3}]
|
||||
tbl = Table(data)
|
||||
view = tbl.view()
|
||||
tbl.update([{"__INDEX__": 0, "a": 3, "b": 15}])
|
||||
assert view.to_records() == [{"a": 3, "b": 15}, {"a": 2, "b": 3}]
|
||||
|
||||
def test_update_implicit_index_dict_noop(self):
|
||||
data = [{"a": 1, "b": 2}, {"a": 2, "b": 3}]
|
||||
tbl = Table(data)
|
||||
view = tbl.view()
|
||||
|
||||
# "b" does not exist in dataset, so no-op
|
||||
tbl.update(
|
||||
{
|
||||
"__INDEX__": [0],
|
||||
"a": [3],
|
||||
}
|
||||
)
|
||||
assert view.to_records() == [{"a": 3, "b": 2}, {"a": 2, "b": 3}]
|
||||
|
||||
def test_update_implicit_index_dict_unset_with_null(self):
|
||||
data = [{"a": 1, "b": 2}, {"a": 2, "b": 3}]
|
||||
tbl = Table(data)
|
||||
view = tbl.view()
|
||||
|
||||
# unset because "b" is null
|
||||
tbl.update({"__INDEX__": [0], "a": [3], "b": [None]})
|
||||
assert view.to_records() == [{"a": 3, "b": None}, {"a": 2, "b": 3}]
|
||||
|
||||
def test_update_implicit_index_multi(self):
|
||||
data = [{"a": 1, "b": 2}, {"a": 2, "b": 3}, {"a": 4, "b": 5}]
|
||||
tbl = Table(data)
|
||||
view = tbl.view()
|
||||
tbl.update(
|
||||
[
|
||||
{
|
||||
"__INDEX__": 0,
|
||||
"a": 3,
|
||||
},
|
||||
{"__INDEX__": 2, "a": 5},
|
||||
]
|
||||
)
|
||||
assert view.to_records() == [
|
||||
{"a": 3, "b": 2},
|
||||
{"a": 2, "b": 3},
|
||||
{"a": 5, "b": 5},
|
||||
]
|
||||
|
||||
def test_update_implicit_index_symmetric(self):
|
||||
data = [{"a": 1, "b": 2}, {"a": 2, "b": 3}]
|
||||
tbl = Table(data)
|
||||
view = tbl.view()
|
||||
records = view.to_records(index=True)
|
||||
idx = records[0]["__INDEX__"][0] # XXX: How should we grab this?
|
||||
tbl.update([{"__INDEX__": idx, "a": 3}])
|
||||
assert view.to_records() == [{"a": 3, "b": 2}, {"a": 2, "b": 3}]
|
||||
|
||||
def test_update_explicit_index(self):
|
||||
data = [{"a": 1, "b": 2}, {"a": 2, "b": 3}]
|
||||
tbl = Table(data, index="a")
|
||||
view = tbl.view()
|
||||
tbl.update([{"a": 1, "b": 3}])
|
||||
assert view.to_records() == [{"a": 1, "b": 3}, {"a": 2, "b": 3}]
|
||||
|
||||
def test_update_explicit_index_multi(self):
|
||||
data = [{"a": 1, "b": 2}, {"a": 2, "b": 3}, {"a": 3, "b": 4}]
|
||||
tbl = Table(data, index="a")
|
||||
view = tbl.view()
|
||||
tbl.update([{"a": 1, "b": 3}, {"a": 3, "b": 5}])
|
||||
assert view.to_records() == [
|
||||
{"a": 1, "b": 3},
|
||||
{"a": 2, "b": 3},
|
||||
{"a": 3, "b": 5},
|
||||
]
|
||||
|
||||
def test_update_explicit_index_multi_append(self):
|
||||
data = [{"a": 1, "b": 2}, {"a": 2, "b": 3}, {"a": 3, "b": 4}]
|
||||
tbl = Table(data, index="a")
|
||||
view = tbl.view()
|
||||
tbl.update([{"a": 1, "b": 3}, {"a": 12, "b": 5}])
|
||||
assert view.to_records() == [
|
||||
{"a": 1, "b": 3},
|
||||
{"a": 2, "b": 3},
|
||||
{"a": 3, "b": 4},
|
||||
{"a": 12, "b": 5},
|
||||
]
|
||||
|
||||
def test_update_explicit_index_multi_append_noindex(self):
|
||||
data = [{"a": 1, "b": 2}, {"a": 2, "b": 3}, {"a": 3, "b": 4}]
|
||||
tbl = Table(data, index="a")
|
||||
view = tbl.view()
|
||||
tbl.update([{"a": 1, "b": 3}, {"b": 5}])
|
||||
assert view.to_records() == [
|
||||
{"a": None, "b": 5},
|
||||
{"a": 1, "b": 3},
|
||||
{"a": 2, "b": 3},
|
||||
{"a": 3, "b": 4},
|
||||
]
|
||||
|
||||
def test_update_implicit_index_with_explicit_unset(self):
|
||||
data = [{"a": 1, "b": 2}, {"a": 2, "b": 3}]
|
||||
tbl = Table(data, index="a")
|
||||
view = tbl.view()
|
||||
tbl.update([{"__INDEX__": 1, "b": 3}])
|
||||
assert view.to_records() == [{"a": 1, "b": 3}, {"a": 2, "b": 3}]
|
||||
|
||||
def test_update_implicit_index_with_explicit_set(self):
|
||||
data = [{"a": 1, "b": 2}, {"a": 2, "b": 3}]
|
||||
tbl = Table(data, index="a")
|
||||
view = tbl.view()
|
||||
tbl.update(
|
||||
[{"__INDEX__": 1, "a": 1, "b": 3}]
|
||||
) # should ignore re-specification of pkey
|
||||
assert view.to_records() == [{"a": 1, "b": 3}, {"a": 2, "b": 3}]
|
||||
@@ -0,0 +1,980 @@
|
||||
# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
# ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
# ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
# ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
# ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
# ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
import os
|
||||
import random
|
||||
import uuid
|
||||
import pyarrow as pa
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
from datetime import date, datetime
|
||||
from pytest import mark
|
||||
import perspective as psp
|
||||
|
||||
client = psp.Server().new_local_client()
|
||||
Table = client.table
|
||||
|
||||
SOURCE_STREAM_ARROW = os.path.join(
|
||||
os.path.dirname(__file__), "arrow", "int_float_str.arrow"
|
||||
)
|
||||
SOURCE_FILE_ARROW = os.path.join(
|
||||
os.path.dirname(__file__), "arrow", "int_float_str.arrow"
|
||||
)
|
||||
PARTIAL_ARROW = os.path.join(
|
||||
os.path.dirname(__file__), "arrow", "int_float_str_update.arrow"
|
||||
)
|
||||
DICT_ARROW = os.path.join(os.path.dirname(__file__), "arrow", "dict.arrow")
|
||||
DICT_UPDATE_ARROW = os.path.join(
|
||||
os.path.dirname(__file__), "arrow", "dict_update.arrow"
|
||||
)
|
||||
|
||||
names = ["a", "b", "c", "d"]
|
||||
|
||||
|
||||
class TestUpdateArrow(object):
|
||||
# files
|
||||
|
||||
def test_update_arrow_updates_stream_file(self):
|
||||
tbl = Table({"a": "integer", "b": "float", "c": "string"})
|
||||
|
||||
with open(SOURCE_STREAM_ARROW, mode="rb") as file: # b is important -> binary
|
||||
tbl.update(file.read())
|
||||
assert tbl.size() == 4
|
||||
assert tbl.schema() == {"a": "integer", "b": "float", "c": "string"}
|
||||
|
||||
with open(SOURCE_FILE_ARROW, mode="rb") as file:
|
||||
tbl.update(file.read())
|
||||
assert tbl.size() == 8
|
||||
assert tbl.view().to_columns() == {
|
||||
"a": [1, 2, 3, 4] * 2,
|
||||
"b": [1.5, 2.5, 3.5, 4.5] * 2,
|
||||
"c": ["a", "b", "c", "d"] * 2,
|
||||
}
|
||||
|
||||
def test_update_arrow_partial_updates_file(self):
|
||||
tbl = Table({"a": "integer", "b": "float", "c": "string"}, index="a")
|
||||
|
||||
with open(SOURCE_STREAM_ARROW, mode="rb") as src:
|
||||
tbl.update(src.read())
|
||||
assert tbl.size() == 4
|
||||
|
||||
with open(PARTIAL_ARROW, mode="rb") as partial:
|
||||
tbl.update(partial.read())
|
||||
assert tbl.size() == 4
|
||||
assert tbl.view().to_columns() == {
|
||||
"a": [1, 2, 3, 4],
|
||||
"b": [100.5, 2.5, 3.5, 400.5],
|
||||
"c": ["x", "b", "c", "y"],
|
||||
}
|
||||
|
||||
def test_update_arrow_updates_dict_file(self):
|
||||
tbl = Table({"a": "string", "b": "string"})
|
||||
|
||||
with open(DICT_ARROW, mode="rb") as src:
|
||||
tbl.update(src.read())
|
||||
assert tbl.size() == 5
|
||||
|
||||
with open(DICT_UPDATE_ARROW, mode="rb") as partial:
|
||||
tbl.update(partial.read())
|
||||
assert tbl.size() == 8
|
||||
assert tbl.view().to_columns() == {
|
||||
"a": ["abc", "def", "def", None, "abc", None, "update1", "update2"],
|
||||
"b": ["klm", "hij", None, "hij", "klm", "update3", None, "update4"],
|
||||
}
|
||||
|
||||
@mark.skip
|
||||
def test_update_arrow_updates_dict_partial_file(self):
|
||||
tbl = None
|
||||
v = None
|
||||
|
||||
with open(DICT_ARROW, mode="rb") as src:
|
||||
tbl = Table(src.read(), index="a")
|
||||
v = tbl.view()
|
||||
assert v.num_rows() == 2
|
||||
assert v.to_columns() == {"a": ["abc", "def"], "b": ["klm", "hij"]}
|
||||
|
||||
with open(DICT_UPDATE_ARROW, mode="rb") as partial:
|
||||
tbl.update(partial.read())
|
||||
v.num_rows() == 4
|
||||
assert v.to_columns() == {
|
||||
"a": ["abc", "def", "update1", "update2"],
|
||||
"b": ["klm", "hij", None, "update4"],
|
||||
}
|
||||
|
||||
# update with file arrow with more columns than in schema
|
||||
|
||||
def test_update_arrow_updates_more_columns_stream_file(self):
|
||||
tbl = Table(
|
||||
{
|
||||
"a": "integer",
|
||||
"b": "float",
|
||||
}
|
||||
)
|
||||
|
||||
with open(SOURCE_STREAM_ARROW, mode="rb") as file: # b is important -> binary
|
||||
tbl.update(file.read())
|
||||
assert tbl.size() == 4
|
||||
assert tbl.schema() == {"a": "integer", "b": "float"}
|
||||
|
||||
with open(SOURCE_FILE_ARROW, mode="rb") as file:
|
||||
tbl.update(file.read())
|
||||
assert tbl.size() == 8
|
||||
assert tbl.view().to_columns() == {
|
||||
"a": [1, 2, 3, 4] * 2,
|
||||
"b": [1.5, 2.5, 3.5, 4.5] * 2,
|
||||
}
|
||||
|
||||
def test_update_arrow_partial_updates_more_columns_file(self):
|
||||
tbl = Table({"a": "integer", "c": "string"}, index="a")
|
||||
|
||||
with open(SOURCE_STREAM_ARROW, mode="rb") as src:
|
||||
tbl.update(src.read())
|
||||
assert tbl.size() == 4
|
||||
|
||||
with open(PARTIAL_ARROW, mode="rb") as partial:
|
||||
tbl.update(partial.read())
|
||||
assert tbl.size() == 4
|
||||
assert tbl.view().to_columns() == {
|
||||
"a": [1, 2, 3, 4],
|
||||
"c": ["x", "b", "c", "y"],
|
||||
}
|
||||
|
||||
def test_update_arrow_updates_dict_more_columns_file(self):
|
||||
tbl = Table(
|
||||
{
|
||||
"a": "string",
|
||||
}
|
||||
)
|
||||
|
||||
with open(DICT_ARROW, mode="rb") as src:
|
||||
tbl.update(src.read())
|
||||
assert tbl.size() == 5
|
||||
|
||||
with open(DICT_UPDATE_ARROW, mode="rb") as partial:
|
||||
tbl.update(partial.read())
|
||||
assert tbl.size() == 8
|
||||
assert tbl.view().to_columns() == {
|
||||
"a": ["abc", "def", "def", None, "abc", None, "update1", "update2"]
|
||||
}
|
||||
|
||||
@mark.skip
|
||||
def test_update_arrow_updates_dict_more_columns_partial_file(self):
|
||||
tbl = Table({"a": "string"}, index="a")
|
||||
|
||||
with open(DICT_ARROW, mode="rb") as src:
|
||||
tbl.update(src.read())
|
||||
assert tbl.size() == 4
|
||||
|
||||
with open(DICT_UPDATE_ARROW, mode="rb") as partial:
|
||||
tbl.update(partial.read())
|
||||
assert tbl.size() == 4
|
||||
assert tbl.view().to_columns() == {
|
||||
"a": ["abc", "def", "update1", "update2"]
|
||||
}
|
||||
|
||||
# update with file arrow with less columns than in schema
|
||||
|
||||
def test_update_arrow_updates_less_columns_stream_file(self):
|
||||
tbl = Table(
|
||||
{
|
||||
"a": "integer",
|
||||
"x": "float",
|
||||
}
|
||||
)
|
||||
|
||||
with open(SOURCE_STREAM_ARROW, mode="rb") as file: # b is important -> binary
|
||||
tbl.update(file.read())
|
||||
assert tbl.size() == 4
|
||||
assert tbl.schema() == {"a": "integer", "x": "float"}
|
||||
|
||||
with open(SOURCE_FILE_ARROW, mode="rb") as file:
|
||||
tbl.update(file.read())
|
||||
assert tbl.size() == 8
|
||||
assert tbl.view().to_columns() == {
|
||||
"a": [1, 2, 3, 4] * 2,
|
||||
"x": [None for i in range(8)],
|
||||
}
|
||||
|
||||
def test_update_arrow_partial_updates_less_columns_file(self):
|
||||
tbl = Table({"a": "integer", "x": "string"}, index="a")
|
||||
|
||||
with open(SOURCE_STREAM_ARROW, mode="rb") as src:
|
||||
tbl.update(src.read())
|
||||
assert tbl.size() == 4
|
||||
|
||||
with open(PARTIAL_ARROW, mode="rb") as partial:
|
||||
tbl.update(partial.read())
|
||||
assert tbl.size() == 4
|
||||
assert tbl.view().to_columns() == {
|
||||
"a": [1, 2, 3, 4],
|
||||
"x": [None for i in range(4)],
|
||||
}
|
||||
|
||||
def test_update_arrow_updates_dict_less_columns_file(self):
|
||||
tbl = Table({"a": "string", "x": "string"})
|
||||
|
||||
with open(DICT_ARROW, mode="rb") as src:
|
||||
tbl.update(src.read())
|
||||
assert tbl.size() == 5
|
||||
|
||||
with open(DICT_UPDATE_ARROW, mode="rb") as partial:
|
||||
tbl.update(partial.read())
|
||||
assert tbl.size() == 8
|
||||
assert tbl.view().to_columns() == {
|
||||
"a": ["abc", "def", "def", None, "abc", None, "update1", "update2"],
|
||||
"x": [None for i in range(8)],
|
||||
}
|
||||
|
||||
@mark.skip
|
||||
def test_update_arrow_updates_dict_less_columns_partial_file(self):
|
||||
tbl = Table({"a": "string", "x": "string"}, index="a")
|
||||
|
||||
with open(DICT_ARROW, mode="rb") as src:
|
||||
tbl.update(src.read())
|
||||
assert tbl.size() == 4
|
||||
|
||||
with open(DICT_UPDATE_ARROW, mode="rb") as partial:
|
||||
tbl.update(partial.read())
|
||||
assert tbl.size() == 4
|
||||
assert tbl.view().to_columns() == {
|
||||
"a": ["abc", "def", "update1", "update2"],
|
||||
"x": [None for i in range(4)],
|
||||
}
|
||||
|
||||
# update int schema with int
|
||||
|
||||
def test_update_arrow_update_int_schema_with_uint8(self, util):
|
||||
array = [random.randint(0, 127) for i in range(100)]
|
||||
data = pd.DataFrame({"a": np.array(array, dtype=np.uint8)})
|
||||
|
||||
schema = pa.schema({"a": pa.uint8()})
|
||||
|
||||
arrow = util.make_arrow_from_pandas(data, schema)
|
||||
tbl = Table({"a": "integer"})
|
||||
tbl.update(arrow)
|
||||
assert tbl.view().to_columns()["a"] == array
|
||||
|
||||
def test_update_arrow_update_int_schema_with_uint16(self, util):
|
||||
array = [random.randint(0, 32767) for i in range(100)]
|
||||
data = pd.DataFrame({"a": np.array(array, dtype=np.uint16)})
|
||||
|
||||
schema = pa.schema({"a": pa.uint16()})
|
||||
|
||||
arrow = util.make_arrow_from_pandas(data, schema)
|
||||
tbl = Table({"a": "integer"})
|
||||
tbl.update(arrow)
|
||||
assert tbl.view().to_columns()["a"] == array
|
||||
|
||||
def test_update_arrow_update_int_schema_with_uint32(self, util):
|
||||
array = [random.randint(0, 2000000) for i in range(100)]
|
||||
data = pd.DataFrame({"a": np.array(array, dtype=np.uint32)})
|
||||
|
||||
schema = pa.schema({"a": pa.uint32()})
|
||||
|
||||
arrow = util.make_arrow_from_pandas(data, schema)
|
||||
tbl = Table({"a": "integer"})
|
||||
tbl.update(arrow)
|
||||
assert tbl.view().to_columns()["a"] == array
|
||||
|
||||
def test_update_arrow_update_int_schema_with_uint64(self, util):
|
||||
array = [random.randint(0, 20000000) for i in range(100)]
|
||||
data = pd.DataFrame({"a": np.array(array, dtype=np.uint64)})
|
||||
|
||||
schema = pa.schema({"a": pa.uint64()})
|
||||
|
||||
arrow = util.make_arrow_from_pandas(data, schema)
|
||||
tbl = Table({"a": "integer"})
|
||||
tbl.update(arrow)
|
||||
assert tbl.view().to_columns()["a"] == array
|
||||
|
||||
def test_update_arrow_update_int_schema_with_int8(self, util):
|
||||
array = [random.randint(-127, 127) for i in range(100)]
|
||||
data = pd.DataFrame({"a": np.array(array, dtype=np.int8)})
|
||||
|
||||
schema = pa.schema({"a": pa.int8()})
|
||||
|
||||
arrow = util.make_arrow_from_pandas(data, schema)
|
||||
tbl = Table({"a": "integer"})
|
||||
tbl.update(arrow)
|
||||
assert tbl.view().to_columns()["a"] == array
|
||||
|
||||
def test_update_arrow_update_int_schema_with_int16(self, util):
|
||||
array = [random.randint(-32767, 32767) for i in range(100)]
|
||||
data = pd.DataFrame({"a": np.array(array, dtype=np.int16)})
|
||||
|
||||
schema = pa.schema({"a": pa.int16()})
|
||||
|
||||
arrow = util.make_arrow_from_pandas(data, schema)
|
||||
tbl = Table({"a": "integer"})
|
||||
tbl.update(arrow)
|
||||
assert tbl.view().to_columns()["a"] == array
|
||||
|
||||
def test_update_arrow_update_int_schema_with_int32(self, util):
|
||||
array = [random.randint(-2000000, 2000000) for i in range(100)]
|
||||
data = pd.DataFrame({"a": np.array(array, dtype=np.int32)})
|
||||
|
||||
schema = pa.schema({"a": pa.int32()})
|
||||
|
||||
arrow = util.make_arrow_from_pandas(data, schema)
|
||||
tbl = Table({"a": "integer"})
|
||||
tbl.update(arrow)
|
||||
assert tbl.view().to_columns()["a"] == array
|
||||
|
||||
def test_update_arrow_update_int_schema_with_int64(self, util):
|
||||
array = [random.randint(-20000000, 20000000) for i in range(100)]
|
||||
data = pd.DataFrame({"a": np.array(array, dtype=np.int64)})
|
||||
|
||||
schema = pa.schema({"a": pa.int64()})
|
||||
|
||||
arrow = util.make_arrow_from_pandas(data, schema)
|
||||
tbl = Table({"a": "integer"})
|
||||
tbl.update(arrow)
|
||||
assert tbl.view().to_columns()["a"] == [x * 1.0 for x in array]
|
||||
|
||||
# updating float schema with int
|
||||
|
||||
def test_update_arrow_update_float_schema_with_uint8(self, util):
|
||||
array = [random.randint(0, 127) for i in range(100)]
|
||||
data = pd.DataFrame({"a": np.array(array, dtype=np.uint8)})
|
||||
|
||||
schema = pa.schema({"a": pa.uint8()})
|
||||
|
||||
arrow = util.make_arrow_from_pandas(data, schema)
|
||||
tbl = Table({"a": "float"})
|
||||
tbl.update(arrow)
|
||||
assert tbl.view().to_columns()["a"] == array
|
||||
|
||||
def test_update_arrow_update_float_schema_with_uint16(self, util):
|
||||
array = [random.randint(0, 32767) for i in range(100)]
|
||||
data = pd.DataFrame({"a": np.array(array, dtype=np.uint16)})
|
||||
|
||||
schema = pa.schema({"a": pa.uint16()})
|
||||
|
||||
arrow = util.make_arrow_from_pandas(data, schema)
|
||||
tbl = Table({"a": "float"})
|
||||
tbl.update(arrow)
|
||||
assert tbl.view().to_columns()["a"] == array
|
||||
|
||||
def test_update_arrow_update_float_schema_with_uint32(self, util):
|
||||
array = [random.randint(0, 2000000) for i in range(100)]
|
||||
data = pd.DataFrame({"a": np.array(array, dtype=np.uint32)})
|
||||
|
||||
schema = pa.schema({"a": pa.uint32()})
|
||||
|
||||
arrow = util.make_arrow_from_pandas(data, schema)
|
||||
tbl = Table({"a": "float"})
|
||||
tbl.update(arrow)
|
||||
assert tbl.view().to_columns()["a"] == array
|
||||
|
||||
def test_update_arrow_update_float_schema_with_uint64(self, util):
|
||||
array = [random.randint(0, 20000000) for i in range(100)]
|
||||
data = pd.DataFrame({"a": np.array(array, dtype=np.uint64)})
|
||||
|
||||
schema = pa.schema({"a": pa.uint64()})
|
||||
|
||||
arrow = util.make_arrow_from_pandas(data, schema)
|
||||
tbl = Table({"a": "float"})
|
||||
tbl.update(arrow)
|
||||
assert tbl.view().to_columns()["a"] == array
|
||||
|
||||
def test_update_arrow_update_float_schema_with_int8(self, util):
|
||||
array = [random.randint(-127, 127) for i in range(100)]
|
||||
data = pd.DataFrame({"a": np.array(array, dtype=np.int8)})
|
||||
|
||||
schema = pa.schema({"a": pa.int8()})
|
||||
|
||||
arrow = util.make_arrow_from_pandas(data, schema)
|
||||
tbl = Table({"a": "float"})
|
||||
tbl.update(arrow)
|
||||
assert tbl.view().to_columns()["a"] == array
|
||||
|
||||
def test_update_arrow_update_float_schema_with_int16(self, util):
|
||||
array = [random.randint(-32767, 32767) for i in range(100)]
|
||||
data = pd.DataFrame({"a": np.array(array, dtype=np.int16)})
|
||||
|
||||
schema = pa.schema({"a": pa.int16()})
|
||||
|
||||
arrow = util.make_arrow_from_pandas(data, schema)
|
||||
tbl = Table({"a": "float"})
|
||||
tbl.update(arrow)
|
||||
assert tbl.view().to_columns()["a"] == array
|
||||
|
||||
def test_update_arrow_update_float_schema_with_int32(self, util):
|
||||
array = [random.randint(-2000000, 2000000) for i in range(100)]
|
||||
data = pd.DataFrame({"a": np.array(array, dtype=np.int32)})
|
||||
|
||||
schema = pa.schema({"a": pa.int32()})
|
||||
|
||||
arrow = util.make_arrow_from_pandas(data, schema)
|
||||
tbl = Table({"a": "float"})
|
||||
tbl.update(arrow)
|
||||
assert tbl.view().to_columns()["a"] == array
|
||||
|
||||
def test_update_arrow_update_float_schema_with_int64(self, util):
|
||||
array = [random.randint(-20000000, 20000000) for i in range(100)]
|
||||
data = pd.DataFrame({"a": np.array(array, dtype=np.int64)})
|
||||
|
||||
schema = pa.schema({"a": pa.int64()})
|
||||
|
||||
arrow = util.make_arrow_from_pandas(data, schema)
|
||||
tbl = Table({"a": "float"})
|
||||
tbl.update(arrow)
|
||||
assert tbl.view().to_columns()["a"] == array
|
||||
|
||||
# updating int schema with float
|
||||
def test_update_arrow_update_int_schema_with_float32(self, util):
|
||||
array = [random.randint(-2000000, 2000000) * 0.5 for i in range(100)]
|
||||
data = pd.DataFrame({"a": np.array(array, dtype=np.float32)})
|
||||
|
||||
schema = pa.schema({"a": pa.float32()})
|
||||
|
||||
arrow = util.make_arrow_from_pandas(data, schema)
|
||||
tbl = Table({"a": "integer"})
|
||||
tbl.update(arrow)
|
||||
assert tbl.view().to_columns()["a"] == [int(x) for x in array]
|
||||
|
||||
def test_update_arrow_update_int_schema_with_float64(self, util):
|
||||
array = [
|
||||
random.randint(-20000000, 20000000) * random.random() for i in range(100)
|
||||
]
|
||||
data = pd.DataFrame({"a": np.array(array, dtype=np.float64)})
|
||||
|
||||
schema = pa.schema({"a": pa.float64()})
|
||||
|
||||
arrow = util.make_arrow_from_pandas(data, schema)
|
||||
tbl = Table({"a": "integer"})
|
||||
tbl.update(arrow)
|
||||
assert tbl.view().to_columns()["a"] == [int(x) for x in array]
|
||||
|
||||
# updating float schema with float
|
||||
|
||||
def test_update_arrow_update_float_schema_with_float32(self, util):
|
||||
array = [random.randint(-2000000, 2000000) * 0.5 for i in range(100)]
|
||||
data = pd.DataFrame({"a": np.array(array, dtype=np.float32)})
|
||||
|
||||
schema = pa.schema({"a": pa.float32()})
|
||||
|
||||
arrow = util.make_arrow_from_pandas(data, schema)
|
||||
tbl = Table({"a": "float"})
|
||||
tbl.update(arrow)
|
||||
assert tbl.view().to_columns()["a"] == array
|
||||
|
||||
def test_update_arrow_update_float_schema_with_float64(self, util):
|
||||
array = [
|
||||
random.randint(-20000000, 20000000) * random.random() for i in range(100)
|
||||
]
|
||||
data = pd.DataFrame({"a": np.array(array, dtype=np.float64)})
|
||||
|
||||
schema = pa.schema({"a": pa.float64()})
|
||||
|
||||
arrow = util.make_arrow_from_pandas(data, schema)
|
||||
tbl = Table({"a": "float"})
|
||||
tbl.update(arrow)
|
||||
assert tbl.view().to_columns()["a"] == array
|
||||
|
||||
# updating date schema
|
||||
|
||||
def test_update_arrow_update_date_schema_with_date32(self, util):
|
||||
array = [date(2019, 2, i) for i in range(1, 11)]
|
||||
data = pd.DataFrame({"a": array})
|
||||
|
||||
schema = pa.schema({"a": pa.date32()})
|
||||
|
||||
arrow = util.make_arrow_from_pandas(data, schema)
|
||||
|
||||
tbl = Table({"a": "date"})
|
||||
|
||||
tbl.update(arrow)
|
||||
|
||||
assert tbl.view().to_columns()["a"] == [
|
||||
util.to_timestamp(datetime(2019, 2, i)) for i in range(1, 11)
|
||||
]
|
||||
|
||||
def test_update_arrow_update_date_schema_with_date64(self, util):
|
||||
array = [date(2019, 2, i) for i in range(1, 11)]
|
||||
data = pd.DataFrame({"a": array})
|
||||
|
||||
schema = pa.schema({"a": pa.date64()})
|
||||
|
||||
arrow = util.make_arrow_from_pandas(data, schema)
|
||||
|
||||
tbl = Table({"a": "date"})
|
||||
|
||||
tbl.update(arrow)
|
||||
|
||||
assert tbl.view().to_columns()["a"] == [
|
||||
util.to_timestamp(datetime(2019, 2, i)) for i in range(1, 11)
|
||||
]
|
||||
|
||||
def test_update_arrow_update_datetime_schema_with_timestamp(self, util):
|
||||
data = [
|
||||
[datetime(2019, 2, i, 9) for i in range(1, 11)],
|
||||
[datetime(2019, 2, i, 10) for i in range(1, 11)],
|
||||
[datetime(2019, 2, i, 11) for i in range(1, 11)],
|
||||
[datetime(2019, 2, i, 12) for i in range(1, 11)],
|
||||
]
|
||||
|
||||
arrow_data = util.make_arrow(
|
||||
names,
|
||||
data,
|
||||
types=[
|
||||
pa.timestamp("s"),
|
||||
pa.timestamp("ms"),
|
||||
pa.timestamp("us"),
|
||||
pa.timestamp("ns"),
|
||||
],
|
||||
)
|
||||
|
||||
tbl = Table(
|
||||
{
|
||||
"a": "datetime",
|
||||
"b": "datetime",
|
||||
"c": "datetime",
|
||||
"d": "datetime",
|
||||
}
|
||||
)
|
||||
tbl.update(arrow_data)
|
||||
assert tbl.size() == 10
|
||||
assert tbl.view().to_columns() == {
|
||||
"a": [util.to_timestamp(d) for d in data[0]],
|
||||
"b": [util.to_timestamp(d) for d in data[1]],
|
||||
"c": [util.to_timestamp(d) for d in data[2]],
|
||||
"d": [util.to_timestamp(d) for d in data[3]],
|
||||
}
|
||||
|
||||
# streams
|
||||
|
||||
def test_update_arrow_updates_int_stream(self, util):
|
||||
data = [list(range(10)) for i in range(4)]
|
||||
arrow_data = util.make_arrow(names, data)
|
||||
tbl = Table({"a": "integer", "b": "integer", "c": "integer", "d": "integer"})
|
||||
tbl.update(arrow_data)
|
||||
assert tbl.size() == 10
|
||||
assert tbl.view().to_columns() == {
|
||||
"a": data[0],
|
||||
"b": data[1],
|
||||
"c": data[2],
|
||||
"d": data[3],
|
||||
}
|
||||
|
||||
def test_update_arrow_updates_float_stream(self, util):
|
||||
data = [[i for i in range(10)], [i * 1.5 for i in range(10)]]
|
||||
arrow_data = util.make_arrow(["a", "b"], data)
|
||||
tbl = Table(
|
||||
{
|
||||
"a": "integer",
|
||||
"b": "float",
|
||||
}
|
||||
)
|
||||
tbl.update(arrow_data)
|
||||
assert tbl.size() == 10
|
||||
assert tbl.view().to_columns() == {"a": data[0], "b": data[1]}
|
||||
|
||||
@mark.skip(reason="Decimal128 isn't part of our schema yet")
|
||||
def test_update_arrow_updates_decimal128_stream(self, util):
|
||||
data = [[i * 1000000000 for i in range(10)]]
|
||||
arrow_data = util.make_arrow(["a"], data, types=[pa.decimal128(10)])
|
||||
tbl = Table({"a": "integer"})
|
||||
tbl.update(arrow_data)
|
||||
assert tbl.size() == 10
|
||||
assert tbl.view().to_columns() == {"a": data[0]}
|
||||
|
||||
def test_update_arrow_updates_bool_stream(self, util):
|
||||
data = [[True if i % 2 == 0 else False for i in range(10)]]
|
||||
arrow_data = util.make_arrow(["a"], data)
|
||||
tbl = Table({"a": "boolean"})
|
||||
tbl.update(arrow_data)
|
||||
assert tbl.size() == 10
|
||||
assert tbl.view().to_columns() == {"a": data[0]}
|
||||
|
||||
def test_update_arrow_updates_date32_stream(self, util):
|
||||
data = [[date(2019, 2, i) for i in range(1, 11)]]
|
||||
arrow_data = util.make_arrow(["a"], data, types=[pa.date32()])
|
||||
tbl = Table({"a": "date"})
|
||||
tbl.update(arrow_data)
|
||||
assert tbl.size() == 10
|
||||
assert tbl.view().to_columns() == {
|
||||
"a": [int(1000 * datetime(2019, 2, i).timestamp()) for i in range(1, 11)]
|
||||
}
|
||||
|
||||
def test_update_arrow_updates_date64_stream(self, util):
|
||||
data = [[date(2019, 2, i) for i in range(1, 11)]]
|
||||
arrow_data = util.make_arrow(["a"], data, types=[pa.date64()])
|
||||
tbl = Table({"a": "date"})
|
||||
tbl.update(arrow_data)
|
||||
assert tbl.size() == 10
|
||||
assert tbl.view().to_columns() == {
|
||||
"a": [util.to_timestamp(datetime(2019, 2, i)) for i in range(1, 11)]
|
||||
}
|
||||
|
||||
def test_update_arrow_updates_timestamp_all_formats_stream(self, util):
|
||||
data = [
|
||||
[datetime(2019, 2, i, 9) for i in range(1, 11)],
|
||||
[datetime(2019, 2, i, 10) for i in range(1, 11)],
|
||||
[datetime(2019, 2, i, 11) for i in range(1, 11)],
|
||||
[datetime(2019, 2, i, 12) for i in range(1, 11)],
|
||||
]
|
||||
arrow_data = util.make_arrow(
|
||||
names,
|
||||
data,
|
||||
types=[
|
||||
pa.timestamp("s"),
|
||||
pa.timestamp("ms"),
|
||||
pa.timestamp("us"),
|
||||
pa.timestamp("ns"),
|
||||
],
|
||||
)
|
||||
tbl = Table(
|
||||
{"a": "datetime", "b": "datetime", "c": "datetime", "d": "datetime"}
|
||||
)
|
||||
tbl.update(arrow_data)
|
||||
assert tbl.size() == 10
|
||||
assert tbl.view().to_columns() == {
|
||||
"a": [util.to_timestamp(d) for d in data[0]],
|
||||
"b": [util.to_timestamp(d) for d in data[1]],
|
||||
"c": [util.to_timestamp(d) for d in data[2]],
|
||||
"d": [util.to_timestamp(d) for d in data[3]],
|
||||
}
|
||||
|
||||
def test_update_arrow_updates_string_stream(self, util):
|
||||
data = [[str(i) for i in range(10)]]
|
||||
arrow_data = util.make_arrow(["a"], data, types=[pa.string()])
|
||||
tbl = Table({"a": "string"})
|
||||
tbl.update(arrow_data)
|
||||
assert tbl.size() == 10
|
||||
assert tbl.view().to_columns() == {"a": data[0]}
|
||||
|
||||
def test_update_arrow_updates_dictionary_stream(self, util):
|
||||
data = [([0, 1, 1, None], ["a", "b"]), ([0, 1, None, 2], ["x", "y", "z"])]
|
||||
arrow_data = util.make_dictionary_arrow(["a", "b"], data)
|
||||
tbl = Table({"a": "string", "b": "string"})
|
||||
tbl.update(arrow_data)
|
||||
|
||||
assert tbl.size() == 4
|
||||
assert tbl.view().to_columns() == {
|
||||
"a": ["a", "b", "b", None],
|
||||
"b": ["x", "y", None, "z"],
|
||||
}
|
||||
|
||||
@mark.skip(reason="Arrow no longer supports partial updates per row")
|
||||
def test_update_arrow_partial_updates_dictionary_stream(self, util):
|
||||
data = [([0, 1, 1, None], ["a", "b"]), ([0, 1, None, 2], ["x", "y", "z"])]
|
||||
arrow_data = util.make_dictionary_arrow(["a", "b"], data)
|
||||
tbl = Table({"a": "string", "b": "string"}, index="a")
|
||||
tbl.update(arrow_data)
|
||||
assert tbl.size() == 3
|
||||
assert tbl.view().to_columns() == {"a": [None, "a", "b"], "b": ["z", "x", "y"]}
|
||||
|
||||
@mark.skip
|
||||
def test_update_arrow_partial_updates_dictionary_stream_duplicates(self, util):
|
||||
"""If there are duplicate values in the dictionary, primary keys
|
||||
may be duplicated if the column is used as an index. Skip this test
|
||||
for now - still looking for the best way to fix."""
|
||||
data = [
|
||||
([0, 1, 1, None, 2], ["a", "b", "a"]),
|
||||
([0, 1, None, 2, 1], ["x", "y", "z"]),
|
||||
]
|
||||
arrow_data = util.make_dictionary_arrow(["a", "b"], data)
|
||||
|
||||
tbl = Table({"a": "string", "b": "string"}, index="a")
|
||||
|
||||
tbl.update(arrow_data)
|
||||
|
||||
assert tbl.size() == 3
|
||||
assert tbl.view().to_columns() == {"a": [None, "a", "b"], "b": ["z", "x", "y"]}
|
||||
|
||||
def test_update_arrow_partial_updates_more_columns_dictionary_stream(self, util):
|
||||
data = [([0, 1, 1, None], ["a", "b"]), ([0, 1, None, 2], ["x", "y", "z"])]
|
||||
arrow_data = util.make_dictionary_arrow(["a", "b"], data)
|
||||
|
||||
tbl = Table({"a": "string"}, index="a")
|
||||
|
||||
tbl.update(arrow_data)
|
||||
|
||||
assert tbl.size() == 3
|
||||
assert tbl.view().to_columns() == {"a": [None, "a", "b"]}
|
||||
|
||||
@mark.skip(reason="Arrow no longer supports partial updates per row")
|
||||
def test_update_arrow_partial_updates_less_columns_dictionary_stream(self, util):
|
||||
data = [([0, 1, 1, None], ["a", "b"]), ([0, 1, None, 2], ["x", "y", "z"])]
|
||||
arrow_data = util.make_dictionary_arrow(["a", "b"], data)
|
||||
tbl = Table({"a": "string", "b": "string", "x": "string"}, index="a")
|
||||
tbl.update(arrow_data)
|
||||
|
||||
assert tbl.size() == 3
|
||||
assert tbl.view().to_columns() == {
|
||||
"a": [None, "a", "b"],
|
||||
"b": ["z", "x", "y"],
|
||||
"x": [None, None, None],
|
||||
}
|
||||
|
||||
def test_update_arrow_arbitary_order(self, util):
|
||||
data = [[1, 2, 3, 4], ["a", "b", "c", "d"], [1, 2, 3, 4], ["a", "b", "c", "d"]]
|
||||
update_data = [[5, 6], ["e", "f"], [5, 6], ["e", "f"]]
|
||||
arrow = util.make_arrow(["a", "b", "c", "d"], data)
|
||||
update_arrow = util.make_arrow(["c", "b", "a", "d"], update_data)
|
||||
tbl = Table(arrow)
|
||||
assert tbl.schema() == {
|
||||
"a": "integer",
|
||||
"b": "string",
|
||||
"c": "integer",
|
||||
"d": "string",
|
||||
}
|
||||
tbl.update(update_arrow)
|
||||
assert tbl.size() == 6
|
||||
assert tbl.view().to_columns() == {
|
||||
"a": [1, 2, 3, 4, 5, 6],
|
||||
"b": ["a", "b", "c", "d", "e", "f"],
|
||||
"c": [1, 2, 3, 4, 5, 6],
|
||||
"d": ["a", "b", "c", "d", "e", "f"],
|
||||
}
|
||||
|
||||
# append
|
||||
|
||||
def test_update_arrow_updates_append_int_stream(self, util):
|
||||
data = [list(range(10)) for i in range(4)]
|
||||
arrow_data = util.make_arrow(names, data)
|
||||
tbl = Table(arrow_data)
|
||||
tbl.update(arrow_data)
|
||||
assert tbl.size() == 20
|
||||
assert tbl.view().to_columns() == {
|
||||
"a": data[0] + data[0],
|
||||
"b": data[1] + data[1],
|
||||
"c": data[2] + data[2],
|
||||
"d": data[3] + data[3],
|
||||
}
|
||||
|
||||
def test_update_arrow_updates_append_float_stream(self, util):
|
||||
data = [[i for i in range(10)], [i * 1.5 for i in range(10)]]
|
||||
arrow_data = util.make_arrow(["a", "b"], data)
|
||||
tbl = Table(arrow_data)
|
||||
tbl.update(arrow_data)
|
||||
assert tbl.size() == 20
|
||||
assert tbl.view().to_columns() == {
|
||||
"a": data[0] + data[0],
|
||||
"b": data[1] + data[1],
|
||||
}
|
||||
|
||||
def test_update_arrow_updates_append_decimal_stream(self, util):
|
||||
data = [[i * 1000 for i in range(10)]]
|
||||
arrow_data = util.make_arrow(["a"], data, types=[pa.decimal128(4)])
|
||||
tbl = Table(arrow_data)
|
||||
tbl.update(arrow_data)
|
||||
assert tbl.size() == 20
|
||||
assert tbl.view().to_columns() == {"a": data[0] + data[0]}
|
||||
|
||||
def test_update_arrow_updates_append_bool_stream(self, util):
|
||||
data = [[True if i % 2 == 0 else False for i in range(10)]]
|
||||
arrow_data = util.make_arrow(["a"], data)
|
||||
tbl = Table(arrow_data)
|
||||
tbl.update(arrow_data)
|
||||
assert tbl.size() == 20
|
||||
assert tbl.view().to_columns() == {"a": data[0] + data[0]}
|
||||
|
||||
def test_update_arrow_updates_append_date32_stream(self, util):
|
||||
data = [[date(2019, 2, i) for i in range(1, 11)]]
|
||||
out_data = [datetime(2019, 2, i) for i in range(1, 11)]
|
||||
arrow_data = util.make_arrow(["a"], data, types=[pa.date32()])
|
||||
tbl = Table(arrow_data)
|
||||
tbl.update(arrow_data)
|
||||
assert tbl.size() == 20
|
||||
assert tbl.view().to_columns() == {
|
||||
"a": [util.to_timestamp(d) for d in out_data + out_data]
|
||||
}
|
||||
|
||||
def test_update_arrow_updates_append_date64_stream(self, util):
|
||||
data = [[date(2019, 2, i) for i in range(1, 11)]]
|
||||
out_data = [datetime(2019, 2, i) for i in range(1, 11)]
|
||||
arrow_data = util.make_arrow(["a"], data, types=[pa.date64()])
|
||||
tbl = Table(arrow_data)
|
||||
tbl.update(arrow_data)
|
||||
assert tbl.size() == 20
|
||||
assert tbl.view().to_columns() == {
|
||||
"a": [util.to_timestamp(d) for d in out_data + out_data]
|
||||
}
|
||||
|
||||
def test_update_arrow_updates_append_timestamp_all_formats_stream(self, util):
|
||||
data = [
|
||||
[datetime(2019, 2, i, 9) for i in range(1, 11)],
|
||||
[datetime(2019, 2, i, 10) for i in range(1, 11)],
|
||||
[datetime(2019, 2, i, 11) for i in range(1, 11)],
|
||||
[datetime(2019, 2, i, 12) for i in range(1, 11)],
|
||||
]
|
||||
arrow_data = util.make_arrow(
|
||||
names,
|
||||
data,
|
||||
types=[
|
||||
pa.timestamp("s"),
|
||||
pa.timestamp("ms"),
|
||||
pa.timestamp("us"),
|
||||
pa.timestamp("ns"),
|
||||
],
|
||||
)
|
||||
tbl = Table(arrow_data)
|
||||
tbl.update(arrow_data)
|
||||
assert tbl.size() == 20
|
||||
assert tbl.view().to_columns() == {
|
||||
"a": [util.to_timestamp(d) for d in data[0] + data[0]],
|
||||
"b": [util.to_timestamp(d) for d in data[1] + data[1]],
|
||||
"c": [util.to_timestamp(d) for d in data[2] + data[2]],
|
||||
"d": [util.to_timestamp(d) for d in data[3] + data[3]],
|
||||
}
|
||||
|
||||
def test_update_arrow_updates_append_string_stream(self, util):
|
||||
data = [[str(i) for i in range(10)]]
|
||||
arrow_data = util.make_arrow(["a"], data, types=[pa.string()])
|
||||
tbl = Table(arrow_data)
|
||||
tbl.update(arrow_data)
|
||||
assert tbl.size() == 20
|
||||
assert tbl.view().to_columns() == {"a": data[0] + data[0]}
|
||||
|
||||
def test_update_arrow_updates_append_dictionary_stream(self, util):
|
||||
data = [([0, 1, 1, None], ["a", "b"]), ([0, 1, None, 2], ["x", "y", "z"])]
|
||||
arrow_data = util.make_dictionary_arrow(["a", "b"], data)
|
||||
tbl = Table(arrow_data)
|
||||
tbl.update(arrow_data)
|
||||
|
||||
assert tbl.size() == 8
|
||||
assert tbl.view().to_columns() == {
|
||||
"a": ["a", "b", "b", None, "a", "b", "b", None],
|
||||
"b": ["x", "y", None, "z", "x", "y", None, "z"],
|
||||
}
|
||||
|
||||
def test_update_arrow_updates_append_dictionary_stream_legacy(self, util):
|
||||
data = [([0, 1, 1, None], ["a", "b"]), ([0, 1, None, 2], ["x", "y", "z"])]
|
||||
arrow_data = util.make_dictionary_arrow(["a", "b"], data, legacy=True)
|
||||
tbl = Table(arrow_data)
|
||||
tbl.update(arrow_data)
|
||||
|
||||
assert tbl.size() == 8
|
||||
assert tbl.view().to_columns() == {
|
||||
"a": ["a", "b", "b", None, "a", "b", "b", None],
|
||||
"b": ["x", "y", None, "z", "x", "y", None, "z"],
|
||||
}
|
||||
|
||||
# indexed
|
||||
|
||||
def test_update_arrow_partial_indexed(self, util):
|
||||
data = [[1, 2, 3, 4], ["a", "b", "c", "d"]]
|
||||
update_data = [[2, 4], ["x", "y"]]
|
||||
arrow = util.make_arrow(["a", "b"], data)
|
||||
update_arrow = util.make_arrow(["a", "b"], update_data)
|
||||
tbl = Table(arrow, index="a")
|
||||
assert tbl.schema() == {"a": "integer", "b": "string"}
|
||||
tbl.update(update_arrow)
|
||||
assert tbl.size() == 4
|
||||
assert tbl.view().to_columns() == {"a": [1, 2, 3, 4], "b": ["a", "x", "c", "y"]}
|
||||
|
||||
# update specific columns
|
||||
|
||||
def test_update_arrow_specific_column(self, util):
|
||||
data = [[1, 2, 3, 4], ["a", "b", "c", "d"]]
|
||||
update_data = [[2, 3, 4]]
|
||||
arrow = util.make_arrow(["a", "b"], data)
|
||||
update_arrow = util.make_arrow(["a"], update_data)
|
||||
tbl = Table(arrow)
|
||||
assert tbl.schema() == {"a": "integer", "b": "string"}
|
||||
tbl.update(update_arrow)
|
||||
assert tbl.size() == 7
|
||||
assert tbl.view().to_columns() == {
|
||||
"a": [1, 2, 3, 4, 2, 3, 4],
|
||||
"b": ["a", "b", "c", "d", None, None, None],
|
||||
}
|
||||
|
||||
# try to fuzz column order
|
||||
|
||||
def test_update_arrow_column_order_str(self, util):
|
||||
# use str so it doesn't get promoted
|
||||
data = [["a", "b", "c"] for i in range(10)]
|
||||
names = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"]
|
||||
names_scrambled = names[::-1]
|
||||
arrow = util.make_arrow(names_scrambled, data)
|
||||
tbl = Table({name: "string" for name in names})
|
||||
tbl.update(arrow)
|
||||
assert tbl.size() == 3
|
||||
assert tbl.view().to_columns() == {name: data[0] for name in names}
|
||||
|
||||
def test_update_arrow_column_order_int(self, util):
|
||||
data = [[1, 2, 3] for i in range(10)]
|
||||
names = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"]
|
||||
names_scrambled = names[::-1]
|
||||
arrow = util.make_arrow(names_scrambled, data)
|
||||
tbl = Table({name: "integer" for name in names})
|
||||
tbl.update(arrow)
|
||||
assert tbl.size() == 3
|
||||
assert tbl.view().to_columns() == {name: data[0] for name in names}
|
||||
|
||||
def test_update_arrow_thread_safe_int_index(self, util):
|
||||
data = [["a", "b", "c"] for i in range(10)]
|
||||
data += [[1, 2, 3]]
|
||||
names = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "uid"]
|
||||
arrow = util.make_arrow(names, data)
|
||||
tbl = Table(arrow, index="uid")
|
||||
|
||||
for i in range(100):
|
||||
idx = (1, 2, 3)[random.randint(0, 2)]
|
||||
update_data = [
|
||||
[str(uuid.uuid4()) + str(random.randint(100, 1000000000))],
|
||||
[idx],
|
||||
]
|
||||
update_names = [names[random.randint(0, 9)], "uid"]
|
||||
update_arrow = util.make_arrow(update_names, update_data)
|
||||
tbl.update(update_arrow)
|
||||
|
||||
assert tbl.size() == 3
|
||||
|
||||
def test_update_arrow_thread_safe_datetime_index(self, util):
|
||||
data = [["a", "b", "c"] for i in range(10)]
|
||||
data += [
|
||||
[
|
||||
datetime(2020, 1, 15, 12, 17),
|
||||
datetime(2020, 1, 15, 12, 18),
|
||||
datetime(2020, 1, 15, 12, 19),
|
||||
]
|
||||
]
|
||||
names = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "uid"]
|
||||
arrow = util.make_arrow(names, data)
|
||||
tbl = Table(arrow, index="uid")
|
||||
|
||||
for i in range(100):
|
||||
idx = (
|
||||
datetime(2020, 1, 15, 12, 17),
|
||||
datetime(2020, 1, 15, 12, 18),
|
||||
datetime(2020, 1, 15, 12, 19),
|
||||
)[random.randint(0, 2)]
|
||||
update_data = [
|
||||
[str(uuid.uuid4()) + str(random.randint(100, 1000000000))],
|
||||
[idx],
|
||||
]
|
||||
update_names = [names[random.randint(0, 9)], "uid"]
|
||||
update_arrow = util.make_arrow(update_names, update_data)
|
||||
tbl.update(update_arrow)
|
||||
|
||||
assert tbl.size() == 3
|
||||
|
||||
def test_update_arrow_thread_safe_str_index(self, util):
|
||||
data = [["a", "b", "c"] for i in range(11)]
|
||||
names = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "uid"]
|
||||
arrow = util.make_arrow(names, data)
|
||||
tbl = Table(arrow, index="uid")
|
||||
|
||||
for i in range(100):
|
||||
idx = ("a", "b", "c")[random.randint(0, 2)]
|
||||
update_data = [
|
||||
[str(uuid.uuid4()) + str(random.randint(100, 1000000000))],
|
||||
[idx],
|
||||
]
|
||||
update_names = [names[random.randint(0, 9)], "uid"]
|
||||
update_arrow = util.make_arrow(update_names, update_data)
|
||||
tbl.update(update_arrow)
|
||||
|
||||
assert tbl.size() == 3
|
||||
@@ -0,0 +1,211 @@
|
||||
# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
# ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
# ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
# ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
# ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
# ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from datetime import date, datetime
|
||||
from pytest import mark
|
||||
import pytest
|
||||
import perspective as psp
|
||||
|
||||
client = psp.Server().new_local_client()
|
||||
Table = client.table
|
||||
|
||||
|
||||
class TestUpdatePandas(object):
|
||||
def test_update_df(self):
|
||||
tbl = Table({"a": [1, 2, 3, 4]})
|
||||
|
||||
update_data = pd.DataFrame({"a": [5, 6, 7, 8]})
|
||||
|
||||
tbl.update(update_data)
|
||||
|
||||
assert tbl.view().to_columns() == {"a": [1, 2, 3, 4, 5, 6, 7, 8]}
|
||||
|
||||
def test_update_df_i32_vs_i64(self):
|
||||
tbl = Table({"a": "integer"})
|
||||
|
||||
update_data = pd.DataFrame({"a": np.array([5, 6, 7, 8], dtype="int64")})
|
||||
|
||||
tbl.update(update_data)
|
||||
|
||||
assert tbl.view().to_columns() == {"a": [5, 6, 7, 8]}
|
||||
|
||||
def test_update_df_bool(self):
|
||||
tbl = Table({"a": [True, False, True, False]})
|
||||
|
||||
update_data = pd.DataFrame({"a": [True, False, True, False]})
|
||||
|
||||
tbl.update(update_data)
|
||||
|
||||
assert tbl.view().to_columns() == {
|
||||
"a": [True, False, True, False, True, False, True, False]
|
||||
}
|
||||
|
||||
def test_update_df_str(self):
|
||||
tbl = Table({"a": ["a", "b", "c", "d"]})
|
||||
|
||||
update_data = pd.DataFrame({"a": ["a", "b", "c", "d"]})
|
||||
|
||||
tbl.update(update_data)
|
||||
|
||||
assert tbl.view().to_columns() == {
|
||||
"a": ["a", "b", "c", "d", "a", "b", "c", "d"]
|
||||
}
|
||||
|
||||
def test_update_df_date(self, util):
|
||||
# set_global_serializer(serializer)
|
||||
tbl = Table({"a": [date(2019, 7, 11)]})
|
||||
|
||||
assert tbl.schema() == {"a": "date"}
|
||||
|
||||
update_data = pd.DataFrame({"a": [date(2019, 7, 12)]})
|
||||
|
||||
tbl.update(update_data)
|
||||
assert tbl.view().to_columns() == {
|
||||
"a": [
|
||||
util.to_timestamp(d)
|
||||
for d in [datetime(2019, 7, 11), datetime(2019, 7, 12)]
|
||||
]
|
||||
}
|
||||
|
||||
@mark.skip(reason="this test relies on a lossy conversion from datetime -> date.")
|
||||
def test_update_df_date_timestamp(self, util):
|
||||
tbl = Table({"a": [date(2019, 7, 11)]})
|
||||
|
||||
assert tbl.schema() == {"a": "date"}
|
||||
|
||||
update_data = pd.DataFrame({"a": [datetime(2019, 7, 12, 0, 0, 0)]})
|
||||
|
||||
tbl.update(update_data)
|
||||
assert tbl.view().to_columns() == {
|
||||
"a": [
|
||||
util.to_timestamp(datetime(2019, 7, 11)),
|
||||
util.to_timestamp(datetime(2019, 7, 12)),
|
||||
]
|
||||
}
|
||||
|
||||
def test_update_df_datetime(self, util):
|
||||
tbl = Table({"a": [datetime(2019, 7, 11, 11, 0)]})
|
||||
|
||||
update_data = pd.DataFrame({"a": [datetime(2019, 7, 12, 11, 0)]})
|
||||
|
||||
tbl.update(update_data)
|
||||
assert tbl.view().to_columns() == {
|
||||
"a": [
|
||||
util.to_timestamp(datetime(2019, 7, 11, 11, 0)),
|
||||
util.to_timestamp(datetime(2019, 7, 12, 11, 0)),
|
||||
]
|
||||
}
|
||||
|
||||
def test_update_df_datetime_timestamp_seconds(self, util):
|
||||
tbl = Table({"a": [datetime(2019, 7, 11, 11, 0)]})
|
||||
|
||||
update_data = pd.DataFrame({"a": [datetime(2019, 7, 12, 11, 0)]})
|
||||
|
||||
tbl.update(update_data)
|
||||
assert tbl.view().to_columns() == {
|
||||
"a": [
|
||||
util.to_timestamp(datetime(2019, 7, 11, 11, 0)),
|
||||
util.to_timestamp(datetime(2019, 7, 12, 11, 0)),
|
||||
]
|
||||
}
|
||||
|
||||
def test_update_df_datetime_timestamp_ms(self, util):
|
||||
tbl = Table({"a": [datetime(2019, 7, 11, 11, 0)]})
|
||||
|
||||
update_data = pd.DataFrame({"a": [datetime(2019, 7, 12, 11, 0)]})
|
||||
|
||||
tbl.update(update_data)
|
||||
assert tbl.view().to_columns() == {
|
||||
"a": [
|
||||
util.to_timestamp(datetime(2019, 7, 11, 11, 0)),
|
||||
util.to_timestamp(datetime(2019, 7, 12, 11, 0)),
|
||||
]
|
||||
}
|
||||
|
||||
def test_update_df_partial(self):
|
||||
tbl = Table({"a": [1, 2, 3, 4], "b": ["a", "b", "c", "d"]}, index="b")
|
||||
|
||||
update_data = pd.DataFrame({"a": [5, 6, 7, 8], "b": ["a", "b", "c", "d"]})
|
||||
|
||||
tbl.update(update_data)
|
||||
|
||||
assert tbl.view().to_columns() == {"a": [5, 6, 7, 8], "b": ["a", "b", "c", "d"]}
|
||||
|
||||
def test_df_update_index(self):
|
||||
tbl = Table(pd.DataFrame({"a": [1, 2, 3, 4]}))
|
||||
update_data = pd.DataFrame({"a": [5, 6, 7, 8], "__INDEX__": [0, 1, 2, 3]})
|
||||
tbl.update(update_data)
|
||||
assert tbl.view().to_columns() == {"a": [5, 6, 7, 8], "index": [0, 1, 2, 3]}
|
||||
|
||||
def test_update_df_partial_implicit(self):
|
||||
tbl = Table({"a": [1, 2, 3, 4]})
|
||||
|
||||
update_data = pd.DataFrame({"a": [5, 6, 7, 8], "__INDEX__": [0, 1, 2, 3]})
|
||||
|
||||
tbl.update(update_data)
|
||||
|
||||
assert tbl.view().to_columns() == {"a": [5, 6, 7, 8]}
|
||||
|
||||
def test_update_df_datetime_partial(self, util):
|
||||
tbl = Table({"a": [datetime(2019, 7, 11, 11, 0)], "b": [1]}, index="b")
|
||||
|
||||
update_data = pd.DataFrame({"a": [datetime(2019, 7, 12, 11, 0)], "b": [1]})
|
||||
|
||||
tbl.update(update_data)
|
||||
|
||||
assert tbl.view().to_columns() == {
|
||||
"a": [util.to_timestamp(datetime(2019, 7, 12, 11, 0))],
|
||||
"b": [1],
|
||||
}
|
||||
|
||||
def test_update_df_one_col(self):
|
||||
tbl = Table({"a": [1, 2, 3, 4], "b": ["a", "b", "c", "d"]})
|
||||
update_data = pd.DataFrame({"a": [5, 6, 7]})
|
||||
tbl.update(update_data)
|
||||
assert tbl.view().to_columns() == {
|
||||
"a": [1, 2, 3, 4, 5, 6, 7],
|
||||
"b": ["a", "b", "c", "d", None, None, None],
|
||||
}
|
||||
|
||||
def test_update_df_nonseq_partial(self):
|
||||
tbl = Table({"a": [1, 2, 3, 4], "b": ["a", "b", "c", "d"]}, index="b")
|
||||
update_data = pd.DataFrame({"a": [5, 6, 7], "b": ["a", "c", "d"]})
|
||||
tbl.update(update_data)
|
||||
assert tbl.view().to_columns() == {"a": [5, 2, 6, 7], "b": ["a", "b", "c", "d"]}
|
||||
|
||||
@pytest.mark.skip
|
||||
def test_update_df_with_none_partial(self):
|
||||
tbl = Table({"a": [1, float("nan"), 3], "b": ["a", None, "d"]}, index="b")
|
||||
update_data = pd.DataFrame({"a": [4, 5], "b": ["a", "d"]})
|
||||
tbl.update(update_data)
|
||||
assert tbl.view().to_columns() == {
|
||||
"a": [None, 4, 5],
|
||||
"b": [None, "a", "d"],
|
||||
} # pkeys are ordered
|
||||
|
||||
def test_update_df_unset_partial(self):
|
||||
tbl = Table({"a": [1, 2, 3], "b": ["a", "b", "c"]}, index="b")
|
||||
update_data = pd.DataFrame(
|
||||
{"a": pd.Series([None, None], dtype=pd.Int32Dtype()), "b": ["a", "c"]}
|
||||
)
|
||||
tbl.update(update_data)
|
||||
assert tbl.view().to_columns() == {"a": [None, 2, None], "b": ["a", "b", "c"]}
|
||||
|
||||
def test_update_df_nan_partial(self):
|
||||
tbl = Table({"a": [1, 2, 3], "b": ["a", "b", "c"]}, index="b")
|
||||
update_data = pd.DataFrame(
|
||||
{"a": pd.Series([None, None], dtype=pd.Int32Dtype()), "b": ["a", "c"]}
|
||||
)
|
||||
tbl.update(update_data)
|
||||
assert tbl.view().to_columns() == {"a": [None, 2, None], "b": ["a", "b", "c"]}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,53 @@
|
||||
# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
# ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
# ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
# ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
# ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
# ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# test that loading perspective and calling a common constructor code path does
|
||||
# not load various "expensive" modules. "Expensive" is in quotes because this is
|
||||
# utter nonsense.
|
||||
@pytest.mark.skip(reason="https://github.com/pyca/bcrypt/issues/694")
|
||||
def test_lazy_modules():
|
||||
import sys
|
||||
|
||||
cache = {}
|
||||
for key in list(sys.modules.keys()):
|
||||
if (
|
||||
key.startswith("perspective")
|
||||
or key.startswith("test")
|
||||
or key.startswith("pandas")
|
||||
or key.startswith("pyarrow")
|
||||
or key.startswith("tornado")
|
||||
):
|
||||
cache[key] = sys.modules[key]
|
||||
del sys.modules[key]
|
||||
|
||||
import perspective
|
||||
|
||||
t1 = perspective.table("x\n1")
|
||||
t1.delete()
|
||||
|
||||
assert "perspective" in sys.modules
|
||||
assert "pandas" not in sys.modules
|
||||
assert "pyarrow" not in sys.modules
|
||||
assert "tornado" not in sys.modules
|
||||
|
||||
for k, v in cache.items():
|
||||
sys.modules[k] = v
|
||||
|
||||
|
||||
def test_all():
|
||||
import perspective
|
||||
|
||||
for key in perspective.__all__:
|
||||
assert hasattr(perspective, key)
|
||||
@@ -0,0 +1,11 @@
|
||||
# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
# ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
# ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
# ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
# ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
# ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
@@ -0,0 +1,246 @@
|
||||
# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
# ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
# ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
# ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
# ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
# ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
from perspective.widget.viewer import PerspectiveViewer
|
||||
import perspective as psp
|
||||
|
||||
client = psp.Server().new_local_client()
|
||||
Table = client.table
|
||||
|
||||
|
||||
class TestViewer:
|
||||
def test_viewer_get_table(self):
|
||||
table = Table({"a": [1, 2, 3]})
|
||||
viewer = PerspectiveViewer()
|
||||
viewer.load(table)
|
||||
assert viewer.table == table
|
||||
|
||||
# loading
|
||||
|
||||
def test_viewer_load_table(self):
|
||||
table = Table({"a": [1, 2, 3]})
|
||||
viewer = PerspectiveViewer()
|
||||
viewer.load(table)
|
||||
assert viewer.columns == ["a"]
|
||||
|
||||
def test_viewer_load_named_table(self):
|
||||
table = Table({"a": [1, 2, 3]}, name="data_1")
|
||||
viewer = PerspectiveViewer()
|
||||
viewer.load(table)
|
||||
assert viewer.columns == ["a"]
|
||||
assert viewer.table_name == "data_1"
|
||||
assert viewer.table == table
|
||||
|
||||
def test_viewer_load_data(self):
|
||||
viewer = PerspectiveViewer()
|
||||
viewer.load({"a": [1, 2, 3]})
|
||||
assert viewer.columns == ["a"]
|
||||
|
||||
def test_viewer_load_named_data(self):
|
||||
viewer = PerspectiveViewer()
|
||||
viewer.load({"a": [1, 2, 3]}, name="data_1")
|
||||
assert viewer.columns == ["a"]
|
||||
assert viewer.table_name == "data_1"
|
||||
|
||||
def test_viewer_load_schema(self):
|
||||
viewer = PerspectiveViewer()
|
||||
viewer.load({"a": "string", "b": "integer", "c": "boolean", "d": "string"})
|
||||
for col in viewer.columns:
|
||||
assert col in ["a", "b", "c", "d"]
|
||||
|
||||
def test_viewer_load_table_with_options(self):
|
||||
table = Table({"a": [1, 2, 3]})
|
||||
viewer = PerspectiveViewer()
|
||||
# options should be disregarded when loading Table
|
||||
viewer.load(table, limit=1)
|
||||
assert viewer.columns == ["a"]
|
||||
assert viewer.table.size() == 3
|
||||
|
||||
def test_viewer_load_data_with_options(self):
|
||||
viewer = PerspectiveViewer()
|
||||
# options should be forwarded to the Table constructor
|
||||
viewer.load({"a": [1, 2, 3]}, limit=1)
|
||||
assert viewer.columns == ["a"]
|
||||
assert viewer.table.size() == 1
|
||||
|
||||
def test_viewer_load_clears_state(self):
|
||||
table = Table({"a": [1, 2, 3]})
|
||||
viewer = PerspectiveViewer(theme="Pro Dark", group_by=["a"])
|
||||
viewer.load(table)
|
||||
assert viewer.group_by == ["a"]
|
||||
viewer.load({"b": [1, 2, 3]})
|
||||
assert viewer.group_by == []
|
||||
assert viewer.theme == "Pro Dark" # should not break UI
|
||||
|
||||
@pytest.mark.skip
|
||||
def test_viewer_load_np(self):
|
||||
table = Table({"a": np.arange(1, 100)})
|
||||
viewer = PerspectiveViewer()
|
||||
viewer.load(table)
|
||||
assert viewer.columns == ["a"]
|
||||
|
||||
@pytest.mark.skip
|
||||
def test_viewer_load_np_data(self):
|
||||
viewer = PerspectiveViewer()
|
||||
viewer.load({"a": np.arange(1, 100)})
|
||||
assert viewer.columns == ["a"]
|
||||
assert viewer.table.size() == 99
|
||||
|
||||
@pytest.mark.skip
|
||||
def test_viewer_load_df(self):
|
||||
table = Table(pd.DataFrame({"a": np.arange(1, 100)}))
|
||||
viewer = PerspectiveViewer()
|
||||
viewer.load(table)
|
||||
for col in viewer.columns:
|
||||
assert col in ["index", "a"]
|
||||
assert viewer.table.size() == 99
|
||||
|
||||
def test_viewer_load_df_data(self):
|
||||
viewer = PerspectiveViewer()
|
||||
viewer.load(pd.DataFrame({"a": np.arange(1, 100)}))
|
||||
for col in viewer.columns:
|
||||
assert col in ["index", "a"]
|
||||
|
||||
# update
|
||||
|
||||
def test_viewer_update_dict(self):
|
||||
table = Table({"a": [1, 2, 3]})
|
||||
viewer = PerspectiveViewer()
|
||||
viewer.load(table)
|
||||
viewer.update({"a": [4, 5, 6]})
|
||||
assert table.size() == 6
|
||||
assert viewer.table.size() == 6
|
||||
assert viewer.table.view().to_columns() == {"a": [1, 2, 3, 4, 5, 6]}
|
||||
|
||||
def test_viewer_update_list(self):
|
||||
table = Table({"a": [1, 2, 3]})
|
||||
viewer = PerspectiveViewer()
|
||||
viewer.load(table)
|
||||
viewer.update([{"a": 4}, {"a": 5}, {"a": 6}])
|
||||
assert table.size() == 6
|
||||
assert viewer.table.size() == 6
|
||||
assert viewer.table.view().to_columns() == {"a": [1, 2, 3, 4, 5, 6]}
|
||||
|
||||
def test_viewer_update_df(self):
|
||||
table = Table({"a": [1, 2, 3]})
|
||||
viewer = PerspectiveViewer()
|
||||
viewer.load(table)
|
||||
viewer.update(pd.DataFrame({"a": [4, 5, 6]}))
|
||||
assert table.size() == 6
|
||||
assert viewer.table.size() == 6
|
||||
assert viewer.table.view().to_columns() == {"a": [1, 2, 3, 4, 5, 6]}
|
||||
|
||||
def test_viewer_update_dict_partial(self):
|
||||
table = Table({"a": [1, 2, 3], "b": [5, 6, 7]}, index="a")
|
||||
viewer = PerspectiveViewer()
|
||||
viewer.load(table)
|
||||
viewer.update({"a": [1, 2, 3], "b": [8, 9, 10]})
|
||||
assert table.size() == 3
|
||||
assert viewer.table.size() == 3
|
||||
assert viewer.table.view().to_columns() == {"a": [1, 2, 3], "b": [8, 9, 10]}
|
||||
|
||||
# clear
|
||||
|
||||
def test_viewer_clear(self):
|
||||
table = Table({"a": [1, 2, 3]})
|
||||
viewer = PerspectiveViewer()
|
||||
viewer.load(table)
|
||||
viewer.clear()
|
||||
assert viewer.table.size() == 0
|
||||
assert viewer.table.schema() == {"a": "integer"}
|
||||
|
||||
# replace
|
||||
|
||||
def test_viewer_replace(self):
|
||||
table = Table({"a": [1, 2, 3]})
|
||||
viewer = PerspectiveViewer()
|
||||
viewer.load(table)
|
||||
viewer.replace({"a": [4, 5, 6]})
|
||||
assert viewer.table.size() == 3
|
||||
assert viewer.table.schema() == {"a": "integer"}
|
||||
assert viewer.table.view().to_columns() == {"a": [4, 5, 6]}
|
||||
|
||||
# reset
|
||||
|
||||
def test_viewer_reset(self):
|
||||
table = Table({"a": [1, 2, 3]})
|
||||
viewer = PerspectiveViewer(plugin="X Bar", filter=[["a", "==", 2]])
|
||||
viewer.load(table)
|
||||
assert viewer.filter == [["a", "==", 2]]
|
||||
viewer.reset()
|
||||
assert viewer.plugin == "Datagrid"
|
||||
assert viewer.filter == []
|
||||
|
||||
# delete
|
||||
|
||||
def test_viewer_delete(self):
|
||||
table = Table({"a": [1, 2, 3]})
|
||||
viewer = PerspectiveViewer(plugin="X Bar", filter=[["a", "==", 2]])
|
||||
viewer.load(table)
|
||||
assert viewer.filter == [["a", "==", 2]]
|
||||
viewer.delete()
|
||||
assert viewer.table_name is None
|
||||
assert viewer.table is None
|
||||
|
||||
def test_viewer_delete_without_table(self):
|
||||
table = Table({"a": [1, 2, 3]})
|
||||
viewer = PerspectiveViewer(plugin="X Bar", filter=[["a", "==", 2]])
|
||||
viewer.load(table)
|
||||
assert viewer.filter == [["a", "==", 2]]
|
||||
viewer.delete(delete_table=False)
|
||||
assert viewer.table_name is not None
|
||||
assert viewer.table is not None
|
||||
assert viewer.filter == []
|
||||
|
||||
def test_save_restore(self):
|
||||
table = Table({"a": [1, 2, 3]})
|
||||
viewer = PerspectiveViewer(
|
||||
plugin="X Bar", filter=[["a", "==", 2]], expressions={'"a" * 2': '"a" * 2'}
|
||||
)
|
||||
viewer.load(table)
|
||||
|
||||
# Save config
|
||||
config = viewer.save()
|
||||
assert viewer.filter == [["a", "==", 2]]
|
||||
assert config["filter"] == [["a", "==", 2]]
|
||||
assert viewer.plugin == "X Bar"
|
||||
assert config["plugin"] == "X Bar"
|
||||
assert config["expressions"] == {'"a" * 2': '"a" * 2'}
|
||||
|
||||
# reset configuration
|
||||
viewer.reset()
|
||||
assert viewer.plugin == "Datagrid"
|
||||
assert viewer.filter == []
|
||||
assert viewer.expressions == {}
|
||||
|
||||
# restore configuration
|
||||
viewer.restore(**config)
|
||||
assert viewer.filter == [["a", "==", 2]]
|
||||
assert viewer.plugin == "X Bar"
|
||||
assert viewer.expressions == {'"a" * 2': '"a" * 2'}
|
||||
|
||||
def test_save_restore_plugin_config(self):
|
||||
viewer = PerspectiveViewer(
|
||||
plugin="Datagrid", plugin_config={"columns": {"a": {"fixed": 4}}}
|
||||
)
|
||||
config = viewer.save()
|
||||
|
||||
assert config["plugin_config"] == {"columns": {"a": {"fixed": 4}}}
|
||||
|
||||
viewer.reset()
|
||||
assert viewer.plugin_config == {}
|
||||
|
||||
viewer.restore(**config)
|
||||
assert viewer.plugin_config == config["plugin_config"]
|
||||
@@ -0,0 +1,12 @@
|
||||
# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
# ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
# ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
# ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
# ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
# ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
# ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
# ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
# ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
# ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
# ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
import io
|
||||
import json
|
||||
from decimal import Decimal
|
||||
|
||||
import pyarrow as pa
|
||||
import pyarrow.ipc as ipc
|
||||
|
||||
from perspective import VirtualDataSlice
|
||||
|
||||
|
||||
def round_trip(arrow_table):
|
||||
"""Serialize a PyArrow table to IPC, feed through VirtualDataSlice, read back as JSON."""
|
||||
buf = io.BytesIO()
|
||||
with ipc.new_stream(buf, arrow_table.schema) as writer:
|
||||
writer.write_table(arrow_table)
|
||||
ds = VirtualDataSlice()
|
||||
ds.from_arrow_ipc(buf.getvalue())
|
||||
return json.loads(ds.render_to_columns_json())
|
||||
|
||||
|
||||
class TestCoerceSmallIntegers:
|
||||
def test_coerce_int8(self):
|
||||
table = pa.table({"col": pa.array([-1, 127, None], type=pa.int8())})
|
||||
result = round_trip(table)
|
||||
assert result["col"] == [-1, 127, None]
|
||||
|
||||
def test_coerce_int16(self):
|
||||
table = pa.table({"col": pa.array([-300, 32000, None], type=pa.int16())})
|
||||
result = round_trip(table)
|
||||
assert result["col"] == [-300, 32000, None]
|
||||
|
||||
|
||||
class TestCoerceUnsignedIntegers:
|
||||
def test_coerce_uint8(self):
|
||||
table = pa.table({"col": pa.array([0, 255, None], type=pa.uint8())})
|
||||
result = round_trip(table)
|
||||
assert result["col"] == [0, 255, None]
|
||||
|
||||
def test_coerce_uint16(self):
|
||||
table = pa.table({"col": pa.array([0, 65535, None], type=pa.uint16())})
|
||||
result = round_trip(table)
|
||||
assert result["col"] == [0, 65535, None]
|
||||
|
||||
def test_coerce_uint32(self):
|
||||
table = pa.table({"col": pa.array([0, 4_294_967_295, None], type=pa.uint32())})
|
||||
result = round_trip(table)
|
||||
assert result["col"] == [0.0, 4_294_967_295.0, None]
|
||||
|
||||
def test_coerce_uint64(self):
|
||||
table = pa.table({"col": pa.array([0, 1 << 53, None], type=pa.uint64())})
|
||||
result = round_trip(table)
|
||||
assert result["col"] == [0.0, 9_007_199_254_740_992.0, None]
|
||||
|
||||
|
||||
class TestCoerceFloat32:
|
||||
def test_coerce_float32(self):
|
||||
table = pa.table({"col": pa.array([3.14, -0.0, None], type=pa.float32())})
|
||||
result = round_trip(table)
|
||||
assert abs(result["col"][0] - 3.14) < 0.001
|
||||
assert result["col"][1] == 0.0
|
||||
assert result["col"][2] is None
|
||||
|
||||
|
||||
class TestCoerceDate64:
|
||||
def test_coerce_date64(self):
|
||||
day = 19738
|
||||
table = pa.table({"col": pa.array([day * 86_400_000, None], type=pa.date64())})
|
||||
result = round_trip(table)
|
||||
assert result["col"] == [day * 86_400_000, None]
|
||||
|
||||
|
||||
class TestCoerceTime:
|
||||
def test_coerce_time32_second(self):
|
||||
table = pa.table({"col": pa.array([49530, None], type=pa.time32("s"))})
|
||||
result = round_trip(table)
|
||||
assert result["col"] == [49_530_000, None]
|
||||
|
||||
def test_coerce_time32_millisecond(self):
|
||||
table = pa.table({"col": pa.array([49_530_000, None], type=pa.time32("ms"))})
|
||||
result = round_trip(table)
|
||||
assert result["col"] == [49_530_000, None]
|
||||
|
||||
def test_coerce_time64_microsecond(self):
|
||||
table = pa.table(
|
||||
{"col": pa.array([49_530_000_000, None], type=pa.time64("us"))}
|
||||
)
|
||||
result = round_trip(table)
|
||||
assert result["col"] == [49_530_000, None]
|
||||
|
||||
def test_coerce_time64_nanosecond(self):
|
||||
table = pa.table(
|
||||
{"col": pa.array([49_530_000_000_000, None], type=pa.time64("ns"))}
|
||||
)
|
||||
result = round_trip(table)
|
||||
assert result["col"] == [49_530_000, None]
|
||||
|
||||
|
||||
class TestCoerceLargeUtf8:
|
||||
def test_coerce_large_utf8(self):
|
||||
table = pa.table({"col": pa.array(["hello", "", None], type=pa.large_utf8())})
|
||||
result = round_trip(table)
|
||||
assert result["col"] == ["hello", "", None]
|
||||
|
||||
|
||||
class TestCoerceDecimal128:
|
||||
def test_coerce_decimal128(self):
|
||||
arr = pa.array([Decimal("1234.5678"), None], type=pa.decimal128(10, 4))
|
||||
table = pa.table({"col": arr})
|
||||
result = round_trip(table)
|
||||
assert result["col"] == [1234.5678, None]
|
||||
|
||||
|
||||
class TestCoerceInt64:
|
||||
def test_coerce_int64(self):
|
||||
table = pa.table({"col": pa.array([1, -1, None], type=pa.int64())})
|
||||
result = round_trip(table)
|
||||
assert result["col"] == [1.0, -1.0, None]
|
||||
|
||||
|
||||
class TestCoerceTimestamp:
|
||||
def test_coerce_timestamp_second(self):
|
||||
table = pa.table({"col": pa.array([1000, None], type=pa.timestamp("s"))})
|
||||
result = round_trip(table)
|
||||
assert result["col"] == [1_000_000, None]
|
||||
|
||||
def test_coerce_timestamp_microsecond(self):
|
||||
table = pa.table({"col": pa.array([1_000_000, None], type=pa.timestamp("us"))})
|
||||
result = round_trip(table)
|
||||
assert result["col"] == [1000, None]
|
||||
|
||||
def test_coerce_timestamp_nanosecond(self):
|
||||
table = pa.table(
|
||||
{"col": pa.array([1_000_000_000, None], type=pa.timestamp("ns"))}
|
||||
)
|
||||
result = round_trip(table)
|
||||
assert result["col"] == [1000, None]
|
||||
|
||||
|
||||
class TestPassthrough:
|
||||
def test_passthrough_bool(self):
|
||||
table = pa.table({"col": pa.array([True, False, None], type=pa.bool_())})
|
||||
result = round_trip(table)
|
||||
assert result["col"] == [True, False, None]
|
||||
|
||||
def test_passthrough_utf8(self):
|
||||
table = pa.table({"col": pa.array(["a", "b", None], type=pa.utf8())})
|
||||
result = round_trip(table)
|
||||
assert result["col"] == ["a", "b", None]
|
||||
|
||||
def test_passthrough_float64(self):
|
||||
table = pa.table({"col": pa.array([1.5, -2.5, None], type=pa.float64())})
|
||||
result = round_trip(table)
|
||||
assert result["col"] == [1.5, -2.5, None]
|
||||
|
||||
def test_passthrough_int32(self):
|
||||
table = pa.table({"col": pa.array([1, -1, None], type=pa.int32())})
|
||||
result = round_trip(table)
|
||||
assert result["col"] == [1, -1, None]
|
||||
|
||||
def test_passthrough_date32(self):
|
||||
table = pa.table({"col": pa.array([19738, None], type=pa.date32())})
|
||||
result = round_trip(table)
|
||||
assert result["col"] == [19738 * 86_400_000, None]
|
||||
|
||||
def test_passthrough_timestamp_millisecond(self):
|
||||
table = pa.table({"col": pa.array([1000, None], type=pa.timestamp("ms"))})
|
||||
result = round_trip(table)
|
||||
assert result["col"] == [1000, None]
|
||||
|
||||
|
||||
class TestFallback:
|
||||
def test_fallback_fixed_size_binary(self):
|
||||
table = pa.table(
|
||||
{"col": pa.array([b"\x01\x02", b"\x03\x04", None], type=pa.binary(2))}
|
||||
)
|
||||
result = round_trip(table)
|
||||
col = result["col"]
|
||||
assert isinstance(col[0], str)
|
||||
assert isinstance(col[1], str)
|
||||
assert col[0] != col[1]
|
||||
assert col[2] is None
|
||||
|
||||
|
||||
class TestEmpty:
|
||||
def test_empty_batch(self):
|
||||
# PyArrow's new_stream with an empty table writes no record batches,
|
||||
# so we use RecordBatchStreamWriter directly to produce a valid empty batch.
|
||||
schema = pa.schema([("col", pa.int8())])
|
||||
batch = pa.record_batch({"col": pa.array([], type=pa.int8())})
|
||||
buf = io.BytesIO()
|
||||
writer = ipc.RecordBatchStreamWriter(buf, schema)
|
||||
writer.write_batch(batch)
|
||||
writer.close()
|
||||
ds = VirtualDataSlice()
|
||||
ds.from_arrow_ipc(buf.getvalue())
|
||||
result = json.loads(ds.render_to_columns_json())
|
||||
assert result["col"] == []
|
||||
@@ -0,0 +1,908 @@
|
||||
# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
# ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
# ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
# ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
# ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
# ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import urllib.request
|
||||
|
||||
import pytest
|
||||
import duckdb
|
||||
|
||||
from perspective import Client
|
||||
from perspective.virtual_servers.duckdb import DuckDBVirtualServer
|
||||
|
||||
_SUPERSTORE_LOCAL = os.path.join(
|
||||
os.path.dirname(__file__),
|
||||
"..",
|
||||
"..",
|
||||
"..",
|
||||
"node_modules",
|
||||
"superstore-arrow",
|
||||
"superstore.parquet",
|
||||
)
|
||||
|
||||
_SUPERSTORE_URL = (
|
||||
"https://cdn.jsdelivr.net/npm/superstore-arrow@3.2.0/superstore.parquet"
|
||||
)
|
||||
|
||||
|
||||
def _get_superstore_parquet():
|
||||
if os.path.exists(_SUPERSTORE_LOCAL):
|
||||
return _SUPERSTORE_LOCAL
|
||||
path = os.path.join(tempfile.gettempdir(), "superstore.parquet")
|
||||
if not os.path.exists(path):
|
||||
urllib.request.urlretrieve(_SUPERSTORE_URL, path)
|
||||
return path
|
||||
|
||||
|
||||
SUPERSTORE_PARQUET = _get_superstore_parquet()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def client():
|
||||
db = duckdb.connect()
|
||||
db.execute("SET default_null_order=NULLS_FIRST_ON_ASC_LAST_ON_DESC")
|
||||
db.execute(
|
||||
f"CREATE TABLE superstore AS SELECT * FROM read_parquet('{SUPERSTORE_PARQUET}')"
|
||||
)
|
||||
|
||||
server = DuckDBVirtualServer(db)
|
||||
|
||||
def handle_request(msg):
|
||||
session.handle_request(msg)
|
||||
|
||||
def handle_response(msg):
|
||||
c.handle_response(msg)
|
||||
|
||||
session = server.new_session(handle_response)
|
||||
c = Client(handle_request)
|
||||
return c
|
||||
|
||||
|
||||
class TestDuckDBClient:
|
||||
def test_get_hosted_table_names(self, client):
|
||||
tables = client.get_hosted_table_names()
|
||||
assert tables == ["memory.superstore"]
|
||||
|
||||
|
||||
class TestDuckDBTable:
|
||||
def test_schema(self, client):
|
||||
table = client.open_table("memory.superstore")
|
||||
schema = table.schema()
|
||||
assert schema == {
|
||||
"Product Name": "string",
|
||||
"Ship Date": "date",
|
||||
"City": "string",
|
||||
"Row ID": "integer",
|
||||
"Customer Name": "string",
|
||||
"Quantity": "integer",
|
||||
"Discount": "float",
|
||||
"Sub-Category": "string",
|
||||
"Segment": "string",
|
||||
"Category": "string",
|
||||
"Order Date": "date",
|
||||
"Order ID": "string",
|
||||
"Sales": "float",
|
||||
"State": "string",
|
||||
"Postal Code": "float",
|
||||
"Country": "string",
|
||||
"Customer ID": "string",
|
||||
"Ship Mode": "string",
|
||||
"Region": "string",
|
||||
"Profit": "float",
|
||||
"Product ID": "string",
|
||||
}
|
||||
|
||||
def test_columns(self, client):
|
||||
table = client.open_table("memory.superstore")
|
||||
columns = table.columns()
|
||||
assert columns == [
|
||||
"Row ID",
|
||||
"Order ID",
|
||||
"Order Date",
|
||||
"Ship Date",
|
||||
"Ship Mode",
|
||||
"Customer ID",
|
||||
"Customer Name",
|
||||
"Segment",
|
||||
"Country",
|
||||
"City",
|
||||
"State",
|
||||
"Postal Code",
|
||||
"Region",
|
||||
"Product ID",
|
||||
"Category",
|
||||
"Sub-Category",
|
||||
"Product Name",
|
||||
"Sales",
|
||||
"Quantity",
|
||||
"Discount",
|
||||
"Profit",
|
||||
]
|
||||
|
||||
def test_size(self, client):
|
||||
table = client.open_table("memory.superstore")
|
||||
size = table.size()
|
||||
assert size == 9994
|
||||
|
||||
|
||||
class TestDuckDBView:
|
||||
def test_num_rows(self, client):
|
||||
table = client.open_table("memory.superstore")
|
||||
view = table.view(columns=["Sales", "Profit"])
|
||||
num_rows = view.num_rows()
|
||||
assert num_rows == 9994
|
||||
view.delete()
|
||||
|
||||
def test_num_columns(self, client):
|
||||
table = client.open_table("memory.superstore")
|
||||
view = table.view(columns=["Sales", "Profit", "State"])
|
||||
num_columns = view.num_columns()
|
||||
assert num_columns == 3
|
||||
view.delete()
|
||||
|
||||
def test_schema(self, client):
|
||||
table = client.open_table("memory.superstore")
|
||||
view = table.view(columns=["Sales", "Profit", "State"])
|
||||
schema = view.schema()
|
||||
assert schema == {
|
||||
"Sales": "float",
|
||||
"Profit": "float",
|
||||
"State": "string",
|
||||
}
|
||||
view.delete()
|
||||
|
||||
def test_to_json(self, client):
|
||||
table = client.open_table("memory.superstore")
|
||||
view = table.view(columns=["Sales", "Quantity"])
|
||||
json = view.to_json(start_row=0, end_row=5)
|
||||
assert json == [
|
||||
{"Sales": 261.96, "Quantity": 2},
|
||||
{"Sales": 731.94, "Quantity": 3},
|
||||
{"Sales": 14.62, "Quantity": 2},
|
||||
{"Sales": 957.5775, "Quantity": 5},
|
||||
{"Sales": 22.368, "Quantity": 2},
|
||||
]
|
||||
view.delete()
|
||||
|
||||
def test_to_columns(self, client):
|
||||
table = client.open_table("memory.superstore")
|
||||
view = table.view(columns=["Sales", "Quantity"])
|
||||
columns = view.to_columns(start_row=0, end_row=5)
|
||||
assert columns == {
|
||||
"Sales": [261.96, 731.94, 14.62, 957.5775, 22.368],
|
||||
"Quantity": [2, 3, 2, 5, 2],
|
||||
}
|
||||
view.delete()
|
||||
|
||||
def test_column_paths(self, client):
|
||||
table = client.open_table("memory.superstore")
|
||||
view = table.view(columns=["Sales", "Profit", "State"])
|
||||
paths = view.column_paths()
|
||||
assert paths == ["Sales", "Profit", "State"]
|
||||
view.delete()
|
||||
|
||||
|
||||
class TestDuckDBGroupBy:
|
||||
def test_single_group_by(self, client):
|
||||
table = client.open_table("memory.superstore")
|
||||
view = table.view(
|
||||
columns=["Sales"],
|
||||
group_by=["Region"],
|
||||
aggregates={"Sales": "sum"},
|
||||
)
|
||||
num_rows = view.num_rows()
|
||||
assert num_rows == 5
|
||||
json = view.to_json()
|
||||
assert json == [
|
||||
{"__ROW_PATH__": [], "Sales": 2297200.860299955},
|
||||
{"__ROW_PATH__": ["Central"], "Sales": 501239.8908000005},
|
||||
{"__ROW_PATH__": ["East"], "Sales": 678781.2399999979},
|
||||
{"__ROW_PATH__": ["South"], "Sales": 391721.9050000003},
|
||||
{"__ROW_PATH__": ["West"], "Sales": 725457.8245000006},
|
||||
]
|
||||
view.delete()
|
||||
|
||||
def test_multi_level_group_by(self, client):
|
||||
table = client.open_table("memory.superstore")
|
||||
view = table.view(
|
||||
columns=["Sales"],
|
||||
group_by=["Region", "Category"],
|
||||
aggregates={"Sales": "sum"},
|
||||
)
|
||||
json = view.to_json()
|
||||
assert json == [
|
||||
{"__ROW_PATH__": [], "Sales": 2297200.860299955},
|
||||
{"__ROW_PATH__": ["Central"], "Sales": 501239.8908000005},
|
||||
{"__ROW_PATH__": ["Central", "Furniture"], "Sales": 163797.16380000004},
|
||||
{
|
||||
"__ROW_PATH__": ["Central", "Office Supplies"],
|
||||
"Sales": 167026.41500000027,
|
||||
},
|
||||
{"__ROW_PATH__": ["Central", "Technology"], "Sales": 170416.3119999999},
|
||||
{"__ROW_PATH__": ["East"], "Sales": 678781.2399999979},
|
||||
{"__ROW_PATH__": ["East", "Furniture"], "Sales": 208291.20400000009},
|
||||
{"__ROW_PATH__": ["East", "Office Supplies"], "Sales": 205516.0549999999},
|
||||
{"__ROW_PATH__": ["East", "Technology"], "Sales": 264973.9810000003},
|
||||
{"__ROW_PATH__": ["South"], "Sales": 391721.9050000003},
|
||||
{"__ROW_PATH__": ["South", "Furniture"], "Sales": 117298.6840000001},
|
||||
{"__ROW_PATH__": ["South", "Office Supplies"], "Sales": 125651.31299999992},
|
||||
{"__ROW_PATH__": ["South", "Technology"], "Sales": 148771.9079999999},
|
||||
{"__ROW_PATH__": ["West"], "Sales": 725457.8245000006},
|
||||
{"__ROW_PATH__": ["West", "Furniture"], "Sales": 252612.7435000003},
|
||||
{"__ROW_PATH__": ["West", "Office Supplies"], "Sales": 220853.24900000007},
|
||||
{"__ROW_PATH__": ["West", "Technology"], "Sales": 251991.83199999997},
|
||||
]
|
||||
view.delete()
|
||||
|
||||
def test_group_by_with_count_aggregate(self, client):
|
||||
table = client.open_table("memory.superstore")
|
||||
view = table.view(
|
||||
columns=["Sales"],
|
||||
group_by=["Region"],
|
||||
aggregates={"Sales": "count"},
|
||||
)
|
||||
json = view.to_json()
|
||||
assert json == [
|
||||
{"__ROW_PATH__": [], "Sales": 9994},
|
||||
{"__ROW_PATH__": ["Central"], "Sales": 2323},
|
||||
{"__ROW_PATH__": ["East"], "Sales": 2848},
|
||||
{"__ROW_PATH__": ["South"], "Sales": 1620},
|
||||
{"__ROW_PATH__": ["West"], "Sales": 3203},
|
||||
]
|
||||
view.delete()
|
||||
|
||||
def test_group_by_with_avg_aggregate(self, client):
|
||||
table = client.open_table("memory.superstore")
|
||||
view = table.view(
|
||||
columns=["Sales"],
|
||||
group_by=["Category"],
|
||||
aggregates={"Sales": "avg"},
|
||||
)
|
||||
json = view.to_json()
|
||||
assert json == [
|
||||
{"__ROW_PATH__": [], "Sales": 229.8580008304938},
|
||||
{"__ROW_PATH__": ["Furniture"], "Sales": 349.83488698727007},
|
||||
{"__ROW_PATH__": ["Office Supplies"], "Sales": 119.32410089611732},
|
||||
{"__ROW_PATH__": ["Technology"], "Sales": 452.70927612344155},
|
||||
]
|
||||
view.delete()
|
||||
|
||||
def test_group_by_with_min_aggregate(self, client):
|
||||
table = client.open_table("memory.superstore")
|
||||
view = table.view(
|
||||
columns=["Quantity"],
|
||||
group_by=["Region"],
|
||||
aggregates={"Quantity": "min"},
|
||||
)
|
||||
json = view.to_json()
|
||||
assert json == [
|
||||
{"__ROW_PATH__": [], "Quantity": 1},
|
||||
{"__ROW_PATH__": ["Central"], "Quantity": 1},
|
||||
{"__ROW_PATH__": ["East"], "Quantity": 1},
|
||||
{"__ROW_PATH__": ["South"], "Quantity": 1},
|
||||
{"__ROW_PATH__": ["West"], "Quantity": 1},
|
||||
]
|
||||
view.delete()
|
||||
|
||||
def test_group_by_with_max_aggregate(self, client):
|
||||
table = client.open_table("memory.superstore")
|
||||
view = table.view(
|
||||
columns=["Quantity"],
|
||||
group_by=["Region"],
|
||||
aggregates={"Quantity": "max"},
|
||||
)
|
||||
json = view.to_json()
|
||||
assert json == [
|
||||
{"__ROW_PATH__": [], "Quantity": 14},
|
||||
{"__ROW_PATH__": ["Central"], "Quantity": 14},
|
||||
{"__ROW_PATH__": ["East"], "Quantity": 14},
|
||||
{"__ROW_PATH__": ["South"], "Quantity": 14},
|
||||
{"__ROW_PATH__": ["West"], "Quantity": 14},
|
||||
]
|
||||
view.delete()
|
||||
|
||||
|
||||
class TestDuckDBSplitBy:
|
||||
def test_single_split_by(self, client):
|
||||
table = client.open_table("memory.superstore")
|
||||
view = table.view(
|
||||
columns=["Sales"],
|
||||
split_by=["Region"],
|
||||
group_by=["Category"],
|
||||
aggregates={"Sales": "sum"},
|
||||
)
|
||||
|
||||
column_paths = view.column_paths()
|
||||
assert column_paths == [
|
||||
"Central_Sales",
|
||||
"East_Sales",
|
||||
"South_Sales",
|
||||
"West_Sales",
|
||||
]
|
||||
|
||||
json = view.to_json()
|
||||
assert json == [
|
||||
{
|
||||
"__ROW_PATH__": [],
|
||||
"Central|Sales": 501239.8908000005,
|
||||
"East|Sales": 678781.2399999979,
|
||||
"South|Sales": 391721.9050000003,
|
||||
"West|Sales": 725457.8245000006,
|
||||
},
|
||||
{
|
||||
"__ROW_PATH__": ["Furniture"],
|
||||
"Central|Sales": 163797.16380000004,
|
||||
"East|Sales": 208291.20400000009,
|
||||
"South|Sales": 117298.6840000001,
|
||||
"West|Sales": 252612.7435000003,
|
||||
},
|
||||
{
|
||||
"__ROW_PATH__": ["Office Supplies"],
|
||||
"Central|Sales": 167026.41500000027,
|
||||
"East|Sales": 205516.0549999999,
|
||||
"South|Sales": 125651.31299999992,
|
||||
"West|Sales": 220853.24900000007,
|
||||
},
|
||||
{
|
||||
"__ROW_PATH__": ["Technology"],
|
||||
"Central|Sales": 170416.3119999999,
|
||||
"East|Sales": 264973.9810000003,
|
||||
"South|Sales": 148771.9079999999,
|
||||
"West|Sales": 251991.83199999997,
|
||||
},
|
||||
]
|
||||
view.delete()
|
||||
|
||||
def test_split_by_without_group_by(self, client):
|
||||
table = client.open_table("memory.superstore")
|
||||
view = table.view(
|
||||
columns=["Sales"],
|
||||
split_by=["Category"],
|
||||
)
|
||||
paths = view.column_paths()
|
||||
assert any("Furniture" in c for c in paths)
|
||||
assert any("Office Supplies" in c for c in paths)
|
||||
assert any("Technology" in c for c in paths)
|
||||
view.delete()
|
||||
|
||||
|
||||
class TestDuckDBFilter:
|
||||
def test_filter_with_equals(self, client):
|
||||
table = client.open_table("memory.superstore")
|
||||
view = table.view(
|
||||
columns=["Sales", "Region"],
|
||||
filter=[["Region", "==", "West"]],
|
||||
)
|
||||
json = view.to_json(start_row=0, end_row=5)
|
||||
assert json == [
|
||||
{"Sales": 14.62, "Region": "West"},
|
||||
{"Sales": 48.86, "Region": "West"},
|
||||
{"Sales": 7.28, "Region": "West"},
|
||||
{"Sales": 907.152, "Region": "West"},
|
||||
{"Sales": 18.504, "Region": "West"},
|
||||
]
|
||||
view.delete()
|
||||
|
||||
def test_filter_with_not_equals(self, client):
|
||||
table = client.open_table("memory.superstore")
|
||||
view = table.view(
|
||||
columns=["Sales", "Region"],
|
||||
filter=[["Region", "!=", "West"]],
|
||||
)
|
||||
json = view.to_json(start_row=0, end_row=5)
|
||||
assert json == [
|
||||
{"Sales": 261.96, "Region": "South"},
|
||||
{"Sales": 731.94, "Region": "South"},
|
||||
{"Sales": 957.5775, "Region": "South"},
|
||||
{"Sales": 22.368, "Region": "South"},
|
||||
{"Sales": 15.552, "Region": "South"},
|
||||
]
|
||||
view.delete()
|
||||
|
||||
def test_filter_with_greater_than(self, client):
|
||||
table = client.open_table("memory.superstore")
|
||||
view = table.view(
|
||||
columns=["Sales", "Quantity"],
|
||||
filter=[["Quantity", ">", 5]],
|
||||
)
|
||||
json = view.to_json(start_row=0, end_row=5)
|
||||
assert json == [
|
||||
{"Sales": 48.86, "Quantity": 7},
|
||||
{"Sales": 907.152, "Quantity": 6},
|
||||
{"Sales": 1706.184, "Quantity": 9},
|
||||
{"Sales": 665.88, "Quantity": 6},
|
||||
{"Sales": 19.46, "Quantity": 7},
|
||||
]
|
||||
view.delete()
|
||||
|
||||
def test_filter_with_less_than(self, client):
|
||||
table = client.open_table("memory.superstore")
|
||||
view = table.view(
|
||||
columns=["Sales", "Quantity"],
|
||||
filter=[["Quantity", "<", 3]],
|
||||
)
|
||||
json = view.to_json(start_row=0, end_row=5)
|
||||
assert json == [
|
||||
{"Sales": 261.96, "Quantity": 2},
|
||||
{"Sales": 14.62, "Quantity": 2},
|
||||
{"Sales": 22.368, "Quantity": 2},
|
||||
{"Sales": 55.5, "Quantity": 2},
|
||||
{"Sales": 8.56, "Quantity": 2},
|
||||
]
|
||||
view.delete()
|
||||
|
||||
def test_filter_with_greater_than_or_equal(self, client):
|
||||
table = client.open_table("memory.superstore")
|
||||
view = table.view(
|
||||
columns=["Sales", "Quantity"],
|
||||
filter=[["Quantity", ">=", 10]],
|
||||
)
|
||||
json = view.to_json(start_row=0, end_row=5)
|
||||
assert json == [
|
||||
{"Sales": 40.096, "Quantity": 14},
|
||||
{"Sales": 43.12, "Quantity": 14},
|
||||
{"Sales": 384.45, "Quantity": 11},
|
||||
{"Sales": 3347.37, "Quantity": 13},
|
||||
{"Sales": 100.24, "Quantity": 10},
|
||||
]
|
||||
view.delete()
|
||||
|
||||
def test_filter_with_less_than_or_equal(self, client):
|
||||
table = client.open_table("memory.superstore")
|
||||
view = table.view(
|
||||
columns=["Sales", "Quantity"],
|
||||
filter=[["Quantity", "<=", 2]],
|
||||
)
|
||||
json = view.to_json(start_row=0, end_row=5)
|
||||
assert json == [
|
||||
{"Sales": 261.96, "Quantity": 2},
|
||||
{"Sales": 14.62, "Quantity": 2},
|
||||
{"Sales": 22.368, "Quantity": 2},
|
||||
{"Sales": 55.5, "Quantity": 2},
|
||||
{"Sales": 8.56, "Quantity": 2},
|
||||
]
|
||||
view.delete()
|
||||
|
||||
def test_filter_with_like(self, client):
|
||||
table = client.open_table("memory.superstore")
|
||||
view = table.view(
|
||||
columns=["Sales", "State"],
|
||||
filter=[["State", "LIKE", "Cal%"]],
|
||||
)
|
||||
json = view.to_json(start_row=0, end_row=5)
|
||||
assert json == [
|
||||
{"Sales": 14.62, "State": "California"},
|
||||
{"Sales": 48.86, "State": "California"},
|
||||
{"Sales": 7.28, "State": "California"},
|
||||
{"Sales": 907.152, "State": "California"},
|
||||
{"Sales": 18.504, "State": "California"},
|
||||
]
|
||||
view.delete()
|
||||
|
||||
def test_multiple_filters(self, client):
|
||||
table = client.open_table("memory.superstore")
|
||||
view = table.view(
|
||||
columns=["Sales", "Region", "Quantity"],
|
||||
filter=[
|
||||
["Region", "==", "West"],
|
||||
["Quantity", ">", 3],
|
||||
],
|
||||
)
|
||||
json = view.to_json(start_row=0, end_row=5)
|
||||
assert json == [
|
||||
{"Sales": 48.86, "Region": "West", "Quantity": 7},
|
||||
{"Sales": 7.28, "Region": "West", "Quantity": 4},
|
||||
{"Sales": 907.152, "Region": "West", "Quantity": 6},
|
||||
{"Sales": 114.9, "Region": "West", "Quantity": 5},
|
||||
{"Sales": 1706.184, "Region": "West", "Quantity": 9},
|
||||
]
|
||||
view.delete()
|
||||
|
||||
def test_filter_with_group_by(self, client):
|
||||
table = client.open_table("memory.superstore")
|
||||
view = table.view(
|
||||
columns=["Sales"],
|
||||
group_by=["Category"],
|
||||
filter=[["Region", "==", "West"]],
|
||||
aggregates={"Sales": "sum"},
|
||||
)
|
||||
num_rows = view.num_rows()
|
||||
assert num_rows == 4
|
||||
json = view.to_json()
|
||||
assert json == [
|
||||
{"__ROW_PATH__": [], "Sales": 725457.8245000006},
|
||||
{"__ROW_PATH__": ["Furniture"], "Sales": 252612.7435000003},
|
||||
{"__ROW_PATH__": ["Office Supplies"], "Sales": 220853.24900000007},
|
||||
{"__ROW_PATH__": ["Technology"], "Sales": 251991.83199999997},
|
||||
]
|
||||
view.delete()
|
||||
|
||||
|
||||
class TestDuckDBSort:
|
||||
def test_sort_ascending(self, client):
|
||||
table = client.open_table("memory.superstore")
|
||||
view = table.view(
|
||||
columns=["Sales", "Quantity"],
|
||||
sort=[["Sales", "asc"]],
|
||||
)
|
||||
json = view.to_json(start_row=0, end_row=5)
|
||||
assert json == [
|
||||
{"Sales": 0.444, "Quantity": 1},
|
||||
{"Sales": 0.556, "Quantity": 1},
|
||||
{"Sales": 0.836, "Quantity": 1},
|
||||
{"Sales": 0.852, "Quantity": 1},
|
||||
{"Sales": 0.876, "Quantity": 1},
|
||||
]
|
||||
view.delete()
|
||||
|
||||
def test_sort_descending(self, client):
|
||||
table = client.open_table("memory.superstore")
|
||||
view = table.view(
|
||||
columns=["Sales", "Quantity"],
|
||||
sort=[["Sales", "desc"]],
|
||||
)
|
||||
json = view.to_json(start_row=0, end_row=5)
|
||||
assert json == [
|
||||
{"Sales": 22638.48, "Quantity": 6},
|
||||
{"Sales": 17499.95, "Quantity": 5},
|
||||
{"Sales": 13999.96, "Quantity": 4},
|
||||
{"Sales": 11199.968, "Quantity": 4},
|
||||
{"Sales": 10499.97, "Quantity": 3},
|
||||
]
|
||||
view.delete()
|
||||
|
||||
def test_sort_with_group_by(self, client):
|
||||
table = client.open_table("memory.superstore")
|
||||
view = table.view(
|
||||
columns=["Sales"],
|
||||
group_by=["Region"],
|
||||
sort=[["Sales", "desc"]],
|
||||
aggregates={"Sales": "sum"},
|
||||
)
|
||||
json = view.to_json()
|
||||
assert json == [
|
||||
{"__ROW_PATH__": [], "Sales": 2297200.860299955},
|
||||
{"__ROW_PATH__": ["West"], "Sales": 725457.8245000006},
|
||||
{"__ROW_PATH__": ["East"], "Sales": 678781.2399999979},
|
||||
{"__ROW_PATH__": ["Central"], "Sales": 501239.8908000005},
|
||||
{"__ROW_PATH__": ["South"], "Sales": 391721.9050000003},
|
||||
]
|
||||
view.delete()
|
||||
|
||||
def test_multi_column_sort(self, client):
|
||||
table = client.open_table("memory.superstore")
|
||||
view = table.view(
|
||||
columns=["Region", "Sales", "Quantity"],
|
||||
sort=[
|
||||
["Region", "asc"],
|
||||
["Sales", "desc"],
|
||||
],
|
||||
)
|
||||
json = view.to_json(start_row=0, end_row=5)
|
||||
assert json == [
|
||||
{"Region": "Central", "Sales": 17499.95, "Quantity": 5},
|
||||
{"Region": "Central", "Sales": 9892.74, "Quantity": 13},
|
||||
{"Region": "Central", "Sales": 9449.95, "Quantity": 5},
|
||||
{"Region": "Central", "Sales": 8159.952, "Quantity": 8},
|
||||
{"Region": "Central", "Sales": 5443.96, "Quantity": 4},
|
||||
]
|
||||
view.delete()
|
||||
|
||||
|
||||
class TestDuckDBExpressions:
|
||||
def test_simple_expression(self, client):
|
||||
table = client.open_table("memory.superstore")
|
||||
view = table.view(
|
||||
columns=["Sales", "doublesales"],
|
||||
expressions={"doublesales": '"Sales" * 2'},
|
||||
)
|
||||
json = view.to_json(start_row=0, end_row=5)
|
||||
assert json == [
|
||||
{"Sales": 261.96, "doublesales": 523.92},
|
||||
{"Sales": 731.94, "doublesales": 1463.88},
|
||||
{"Sales": 14.62, "doublesales": 29.24},
|
||||
{"Sales": 957.5775, "doublesales": 1915.155},
|
||||
{"Sales": 22.368, "doublesales": 44.736},
|
||||
]
|
||||
view.delete()
|
||||
|
||||
def test_expression_with_multiple_columns(self, client):
|
||||
table = client.open_table("memory.superstore")
|
||||
view = table.view(
|
||||
columns=["Sales", "Profit", "margin"],
|
||||
expressions={"margin": '"Profit" / "Sales"'},
|
||||
)
|
||||
json = view.to_json(start_row=0, end_row=5)
|
||||
assert json == [
|
||||
{"Sales": 261.96, "Profit": 41.9136, "margin": 0.16000000000000003},
|
||||
{"Sales": 731.94, "Profit": 219.582, "margin": 0.3},
|
||||
{"Sales": 14.62, "Profit": 6.8714, "margin": 0.47000000000000003},
|
||||
{"Sales": 957.5775, "Profit": -383.031, "margin": -0.4},
|
||||
{"Sales": 22.368, "Profit": 2.5164, "margin": 0.1125},
|
||||
]
|
||||
view.delete()
|
||||
|
||||
def test_expression_with_group_by(self, client):
|
||||
table = client.open_table("memory.superstore")
|
||||
view = table.view(
|
||||
columns=["total"],
|
||||
group_by=["Region"],
|
||||
expressions={"total": '"Sales" + "Profit"'},
|
||||
aggregates={"total": "sum"},
|
||||
)
|
||||
json = view.to_json()
|
||||
assert json == [
|
||||
{"__ROW_PATH__": [], "total": 2583597.882000014},
|
||||
{"__ROW_PATH__": ["Central"], "total": 540946.2532999996},
|
||||
{"__ROW_PATH__": ["East"], "total": 770304.0199999991},
|
||||
{"__ROW_PATH__": ["South"], "total": 438471.33530000027},
|
||||
{"__ROW_PATH__": ["West"], "total": 833876.2733999988},
|
||||
]
|
||||
view.delete()
|
||||
|
||||
|
||||
class TestDuckDBViewport:
|
||||
def test_start_row_and_end_row(self, client):
|
||||
table = client.open_table("memory.superstore")
|
||||
view = table.view(columns=["Sales", "Profit"])
|
||||
json = view.to_json(start_row=10, end_row=15)
|
||||
assert json == [
|
||||
{"Sales": 1706.184, "Profit": 85.3092},
|
||||
{"Sales": 911.424, "Profit": 68.3568},
|
||||
{"Sales": 15.552, "Profit": 5.4432},
|
||||
{"Sales": 407.976, "Profit": 132.5922},
|
||||
{"Sales": 68.81, "Profit": -123.858},
|
||||
]
|
||||
view.delete()
|
||||
|
||||
def test_start_col_and_end_col(self, client):
|
||||
table = client.open_table("memory.superstore")
|
||||
view = table.view(
|
||||
columns=["Sales", "Profit", "Quantity", "Discount"],
|
||||
)
|
||||
json = view.to_json(start_row=0, end_row=5, start_col=1, end_col=3)
|
||||
assert json == [
|
||||
{"Profit": 41.9136, "Quantity": 2},
|
||||
{"Profit": 219.582, "Quantity": 3},
|
||||
{"Profit": 6.8714, "Quantity": 2},
|
||||
{"Profit": -383.031, "Quantity": 5},
|
||||
{"Profit": 2.5164, "Quantity": 2},
|
||||
]
|
||||
view.delete()
|
||||
|
||||
|
||||
class TestDuckDBDataTypes:
|
||||
def test_integer_columns(self, client):
|
||||
table = client.open_table("memory.superstore")
|
||||
view = table.view(columns=["Quantity"])
|
||||
json = view.to_json(start_row=0, end_row=5)
|
||||
assert json == [
|
||||
{"Quantity": 2},
|
||||
{"Quantity": 3},
|
||||
{"Quantity": 2},
|
||||
{"Quantity": 5},
|
||||
{"Quantity": 2},
|
||||
]
|
||||
view.delete()
|
||||
|
||||
def test_float_columns(self, client):
|
||||
table = client.open_table("memory.superstore")
|
||||
view = table.view(columns=["Sales", "Profit"])
|
||||
json = view.to_json(start_row=0, end_row=5)
|
||||
assert json == [
|
||||
{"Sales": 261.96, "Profit": 41.9136},
|
||||
{"Sales": 731.94, "Profit": 219.582},
|
||||
{"Sales": 14.62, "Profit": 6.8714},
|
||||
{"Sales": 957.5775, "Profit": -383.031},
|
||||
{"Sales": 22.368, "Profit": 2.5164},
|
||||
]
|
||||
view.delete()
|
||||
|
||||
def test_string_columns(self, client):
|
||||
table = client.open_table("memory.superstore")
|
||||
view = table.view(columns=["Region", "State", "City"])
|
||||
json = view.to_json(start_row=0, end_row=5)
|
||||
assert json == [
|
||||
{"Region": "South", "State": "Kentucky", "City": "Henderson"},
|
||||
{"Region": "South", "State": "Kentucky", "City": "Henderson"},
|
||||
{"Region": "West", "State": "California", "City": "Los Angeles"},
|
||||
{"Region": "South", "State": "Florida", "City": "Fort Lauderdale"},
|
||||
{"Region": "South", "State": "Florida", "City": "Fort Lauderdale"},
|
||||
]
|
||||
view.delete()
|
||||
|
||||
def test_date_columns(self, client):
|
||||
table = client.open_table("memory.superstore")
|
||||
view = table.view(columns=["Order Date"])
|
||||
json = view.to_json(start_row=0, end_row=5)
|
||||
assert json == [
|
||||
{"Order Date": 1478563200000},
|
||||
{"Order Date": 1478563200000},
|
||||
{"Order Date": 1465689600000},
|
||||
{"Order Date": 1444521600000},
|
||||
{"Order Date": 1444521600000},
|
||||
]
|
||||
view.delete()
|
||||
|
||||
|
||||
class TestDuckDBCombinedOperations:
|
||||
def test_group_by_filter_sort(self, client):
|
||||
table = client.open_table("memory.superstore")
|
||||
view = table.view(
|
||||
columns=["Sales"],
|
||||
group_by=["Category"],
|
||||
filter=[["Region", "==", "West"]],
|
||||
sort=[["Sales", "desc"]],
|
||||
aggregates={"Sales": "sum"},
|
||||
)
|
||||
json = view.to_json()
|
||||
assert json == [
|
||||
{"__ROW_PATH__": [], "Sales": 725457.8245000006},
|
||||
{"__ROW_PATH__": ["Furniture"], "Sales": 252612.7435000003},
|
||||
{"__ROW_PATH__": ["Technology"], "Sales": 251991.83199999997},
|
||||
{"__ROW_PATH__": ["Office Supplies"], "Sales": 220853.24900000007},
|
||||
]
|
||||
view.delete()
|
||||
|
||||
def test_split_by_group_by_filter(self, client):
|
||||
table = client.open_table("memory.superstore")
|
||||
view = table.view(
|
||||
columns=["Sales"],
|
||||
group_by=["Category"],
|
||||
split_by=["Region"],
|
||||
filter=[["Quantity", ">", 3]],
|
||||
aggregates={"Sales": "sum"},
|
||||
)
|
||||
|
||||
paths = view.column_paths()
|
||||
assert paths == [
|
||||
"Central_Sales",
|
||||
"East_Sales",
|
||||
"South_Sales",
|
||||
"West_Sales",
|
||||
]
|
||||
|
||||
num_rows = view.num_rows()
|
||||
assert num_rows == 4
|
||||
|
||||
json = view.to_json()
|
||||
assert json == [
|
||||
{
|
||||
"__ROW_PATH__": [],
|
||||
"Central|Sales": 332883.0567999998,
|
||||
"East|Sales": 455143.735,
|
||||
"South|Sales": 274208.7699999999,
|
||||
"West|Sales": 470561.28350000136,
|
||||
},
|
||||
{
|
||||
"__ROW_PATH__": ["Furniture"],
|
||||
"Central|Sales": 111457.73279999988,
|
||||
"East|Sales": 140376.95899999997,
|
||||
"South|Sales": 80859.618,
|
||||
"West|Sales": 165219.5734999998,
|
||||
},
|
||||
{
|
||||
"__ROW_PATH__": ["Office Supplies"],
|
||||
"Central|Sales": 103937.78599999992,
|
||||
"East|Sales": 135823.893,
|
||||
"South|Sales": 84393.3579999999,
|
||||
"West|Sales": 140206.93099999975,
|
||||
},
|
||||
{
|
||||
"__ROW_PATH__": ["Technology"],
|
||||
"Central|Sales": 117487.53800000002,
|
||||
"East|Sales": 178942.883,
|
||||
"South|Sales": 108955.79400000005,
|
||||
"West|Sales": 165134.77900000007,
|
||||
},
|
||||
]
|
||||
view.delete()
|
||||
|
||||
def test_split_by_only(self, client):
|
||||
table = client.open_table("memory.superstore")
|
||||
view = table.view(
|
||||
columns=["Sales"],
|
||||
split_by=["Region"],
|
||||
filter=[["Quantity", ">", 3]],
|
||||
)
|
||||
|
||||
paths = view.column_paths()
|
||||
assert paths == [
|
||||
"Central_Sales",
|
||||
"East_Sales",
|
||||
"South_Sales",
|
||||
"West_Sales",
|
||||
]
|
||||
|
||||
num_rows = view.num_rows()
|
||||
assert num_rows == 4284
|
||||
|
||||
json = view.to_json(start_row=0, end_row=1)
|
||||
assert json == [
|
||||
{
|
||||
"Central|Sales": None,
|
||||
"East|Sales": None,
|
||||
"South|Sales": 957.5775,
|
||||
"West|Sales": None,
|
||||
},
|
||||
]
|
||||
view.delete()
|
||||
|
||||
def test_expressions_group_by_sort(self, client):
|
||||
table = client.open_table("memory.superstore")
|
||||
view = table.view(
|
||||
columns=["profitmargin"],
|
||||
group_by=["Region"],
|
||||
expressions={"profitmargin": '"Profit" / "Sales" * 100'},
|
||||
sort=[["profitmargin", "desc"]],
|
||||
aggregates={"profitmargin": "avg"},
|
||||
)
|
||||
json = view.to_json()
|
||||
assert json == [
|
||||
{"__ROW_PATH__": [], "profitmargin": 12.031392972104467},
|
||||
{"__ROW_PATH__": ["West"], "profitmargin": 21.948661793784012},
|
||||
{"__ROW_PATH__": ["East"], "profitmargin": 16.722695960406636},
|
||||
{"__ROW_PATH__": ["South"], "profitmargin": 16.35190329218107},
|
||||
{"__ROW_PATH__": ["Central"], "profitmargin": -10.407293926323575},
|
||||
]
|
||||
view.delete()
|
||||
|
||||
|
||||
class TestDuckDBMinMax:
|
||||
def test_min_max_integer(self, client):
|
||||
table = client.open_table("memory.superstore")
|
||||
view = table.view(columns=["Quantity"])
|
||||
min_val, max_val = view.get_min_max("Quantity")
|
||||
assert min_val == 1
|
||||
assert max_val == 14
|
||||
view.delete()
|
||||
|
||||
def test_min_max_float(self, client):
|
||||
table = client.open_table("memory.superstore")
|
||||
view = table.view(columns=["Sales"])
|
||||
min_val, max_val = view.get_min_max("Sales")
|
||||
assert min_val == 0.444
|
||||
assert max_val == 22638.48
|
||||
view.delete()
|
||||
|
||||
def test_min_max_string(self, client):
|
||||
table = client.open_table("memory.superstore")
|
||||
view = table.view(columns=["Category"])
|
||||
min_val, max_val = view.get_min_max("Category")
|
||||
assert min_val == "Furniture"
|
||||
assert max_val == "Technology"
|
||||
view.delete()
|
||||
|
||||
def test_min_max_with_group_by(self, client):
|
||||
table = client.open_table("memory.superstore")
|
||||
view = table.view(
|
||||
columns=["Sales"],
|
||||
group_by=["Region"],
|
||||
aggregates={"Sales": "sum"},
|
||||
)
|
||||
min_val, max_val = view.get_min_max("Sales")
|
||||
assert min_val > 0
|
||||
assert max_val > 0
|
||||
assert max_val >= min_val
|
||||
view.delete()
|
||||
|
||||
def test_min_max_with_filter(self, client):
|
||||
table = client.open_table("memory.superstore")
|
||||
view = table.view(
|
||||
columns=["Quantity"],
|
||||
filter=[["Quantity", ">", 10]],
|
||||
)
|
||||
min_val, max_val = view.get_min_max("Quantity")
|
||||
assert min_val >= 11
|
||||
assert max_val == 14
|
||||
view.delete()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,11 @@
|
||||
# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
# ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
# ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
# ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
# ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
# ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
@@ -0,0 +1,278 @@
|
||||
# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
# ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
# ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
# ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
# ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
# ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
from functools import partial
|
||||
from types import MethodType
|
||||
import numpy as np
|
||||
from perspective.widget import PerspectiveWidget
|
||||
from pytest import raises
|
||||
import pytest
|
||||
import perspective as psp
|
||||
|
||||
client = psp.Server().new_local_client()
|
||||
Table = client.table
|
||||
|
||||
pytest.skip(allow_module_level=True)
|
||||
|
||||
|
||||
def mock_post(self, msg, msg_id=None, assert_msg=None):
|
||||
"""Mock the widget's `post()` method so we can introspect the contents."""
|
||||
assert msg == assert_msg
|
||||
|
||||
|
||||
class TestWidget:
|
||||
def test_widget(self):
|
||||
data = {"a": np.arange(0, 50)}
|
||||
widget = PerspectiveWidget(data, plugin="X Bar")
|
||||
assert widget.plugin == "X Bar"
|
||||
load_msg = widget._make_load_message()
|
||||
assert load_msg.to_columns() == {
|
||||
"id": -2,
|
||||
"type": "table",
|
||||
"data": {"table_name": widget.table_name, "options": {}},
|
||||
}
|
||||
|
||||
def test_widget_indexed(self):
|
||||
data = {"a": np.arange(0, 50)}
|
||||
widget = PerspectiveWidget(data, plugin="X Bar", index="a")
|
||||
assert widget.plugin == "X Bar"
|
||||
load_msg = widget._make_load_message()
|
||||
assert load_msg.to_columns() == {
|
||||
"id": -2,
|
||||
"type": "table",
|
||||
"data": {"table_name": widget.table_name, "options": {"index": "a"}},
|
||||
}
|
||||
|
||||
def test_widget_no_data(self):
|
||||
widget = PerspectiveWidget(None, plugin="X Bar", group_by=["a"])
|
||||
assert widget.plugin == "X Bar"
|
||||
assert widget.group_by == ["a"]
|
||||
|
||||
def test_widget_schema(self):
|
||||
schema = {
|
||||
"a": "integer",
|
||||
"b": "float",
|
||||
"c": "boolean",
|
||||
"d": "date",
|
||||
"e": "datetime",
|
||||
"f": "string",
|
||||
}
|
||||
widget = PerspectiveWidget(schema)
|
||||
assert widget.table.schema() == schema
|
||||
|
||||
def test_widget_schema_with_index(self):
|
||||
widget = PerspectiveWidget({"a": "integer"}, index="a")
|
||||
assert widget.table.get_index() == "a"
|
||||
|
||||
def test_widget_schema_with_limit(self):
|
||||
widget = PerspectiveWidget({"a": "integer"}, limit=5)
|
||||
assert widget.table.get_limit() == 5
|
||||
|
||||
def test_widget_no_data_with_index(self):
|
||||
# should fail
|
||||
with raises(TypeError):
|
||||
PerspectiveWidget(None, index="a")
|
||||
|
||||
def test_widget_no_data_with_limit(self):
|
||||
# should fail
|
||||
with raises(TypeError):
|
||||
PerspectiveWidget(None, limit=5)
|
||||
|
||||
def test_widget_eventual_data(self):
|
||||
table = Table({"a": np.arange(0, 50)})
|
||||
widget = PerspectiveWidget(None, plugin="X Bar")
|
||||
assert widget.plugin == "X Bar"
|
||||
|
||||
with raises(TypeError):
|
||||
widget._make_load_message()
|
||||
|
||||
widget.load(table)
|
||||
|
||||
load_msg = widget._make_load_message()
|
||||
assert load_msg.to_columns() == {
|
||||
"id": -2,
|
||||
"type": "table",
|
||||
"data": {"table_name": widget.table_name, "options": {}},
|
||||
}
|
||||
|
||||
def test_widget_eventual_data_server(self):
|
||||
widget = PerspectiveWidget(None, plugin="X Bar", server=True)
|
||||
assert widget.plugin == "X Bar"
|
||||
widget.load({"a": np.arange(0, 50)}, index="a")
|
||||
load_msg = widget._make_load_message()
|
||||
assert load_msg.to_columns() == {
|
||||
"id": -2,
|
||||
"type": "table",
|
||||
"data": {
|
||||
"table_name": widget.table_name,
|
||||
},
|
||||
}
|
||||
|
||||
def test_widget_eventual_data_indexed(self):
|
||||
widget = PerspectiveWidget(None, plugin="X Bar")
|
||||
assert widget.plugin == "X Bar"
|
||||
widget.load({"a": np.arange(0, 50)}, index="a")
|
||||
load_msg = widget._make_load_message()
|
||||
assert load_msg.to_columns() == {
|
||||
"id": -2,
|
||||
"type": "table",
|
||||
"data": {"table_name": widget.table_name, "options": {"index": "a"}},
|
||||
}
|
||||
|
||||
def test_widget_eventual_table_indexed(self):
|
||||
table = Table({"a": np.arange(0, 50)}, index="a")
|
||||
widget = PerspectiveWidget(None, plugin="X Bar")
|
||||
assert widget.plugin == "X Bar"
|
||||
widget.load(table)
|
||||
load_msg = widget._make_load_message()
|
||||
assert load_msg.to_columns() == {
|
||||
"id": -2,
|
||||
"type": "table",
|
||||
"data": {"table_name": widget.table_name, "options": {"index": "a"}},
|
||||
}
|
||||
|
||||
def test_widget_load_table(self):
|
||||
table = Table({"a": np.arange(0, 50)})
|
||||
widget = PerspectiveWidget(table, plugin="X Bar")
|
||||
assert widget.plugin == "X Bar"
|
||||
load_msg = widget._make_load_message()
|
||||
assert load_msg.to_columns() == {
|
||||
"id": -2,
|
||||
"type": "table",
|
||||
"data": {"table_name": widget.table_name, "options": {}},
|
||||
}
|
||||
|
||||
def test_widget_load_table_indexed(self):
|
||||
table = Table({"a": np.arange(0, 50)}, index="a")
|
||||
widget = PerspectiveWidget(table, plugin="X Bar")
|
||||
assert widget.plugin == "X Bar"
|
||||
load_msg = widget._make_load_message()
|
||||
assert load_msg.to_columns() == {
|
||||
"id": -2,
|
||||
"type": "table",
|
||||
"data": {"table_name": widget.table_name, "options": {"index": "a"}},
|
||||
}
|
||||
|
||||
def test_widget_load_table_ignore_limit(self):
|
||||
table = Table({"a": np.arange(0, 50)})
|
||||
widget = PerspectiveWidget(table, limit=1)
|
||||
assert widget.table.size() == 50
|
||||
|
||||
def test_widget_pass_index(self):
|
||||
data = {"a": [1, 2, 3, 1]}
|
||||
widget = PerspectiveWidget(data, index="a")
|
||||
assert widget.table.size() == 3
|
||||
|
||||
def test_widget_pass_limit(self):
|
||||
data = {"a": np.arange(0, 50)}
|
||||
widget = PerspectiveWidget(data, limit=1)
|
||||
assert widget.table.size() == 1
|
||||
|
||||
def test_widget_pass_options_invalid(self):
|
||||
data = {"a": np.arange(0, 50)}
|
||||
with raises(TypeError):
|
||||
PerspectiveWidget(data, index="index", limit=1)
|
||||
|
||||
# server mode
|
||||
def test_widget_load_table_server(self):
|
||||
table = Table({"a": np.arange(0, 50)})
|
||||
widget = PerspectiveWidget(table, server=True)
|
||||
load_msg = widget._make_load_message()
|
||||
assert load_msg.to_columns() == {
|
||||
"id": -2,
|
||||
"type": "table",
|
||||
"data": {"table_name": widget.table_name},
|
||||
}
|
||||
|
||||
def test_widget_no_data_with_server(self):
|
||||
# should fail
|
||||
widget = PerspectiveWidget(None, server=True)
|
||||
with raises(TypeError):
|
||||
widget._make_load_message()
|
||||
|
||||
def test_widget_eventual_data_with_server(self):
|
||||
# should fail
|
||||
widget = PerspectiveWidget(None, server=True)
|
||||
|
||||
with raises(TypeError):
|
||||
widget._make_load_message()
|
||||
|
||||
# then succeed
|
||||
widget.load(Table({"a": np.arange(0, 50)}))
|
||||
load_msg = widget._make_load_message()
|
||||
assert load_msg.to_columns() == {
|
||||
"id": -2,
|
||||
"type": "table",
|
||||
"data": {"table_name": widget.table_name},
|
||||
}
|
||||
|
||||
# clear
|
||||
|
||||
def test_widget_clear(self):
|
||||
data = {"a": np.arange(0, 50)}
|
||||
widget = PerspectiveWidget(data)
|
||||
widget.clear()
|
||||
assert widget.table.size() == 0
|
||||
|
||||
def test_widget_clear_server(self):
|
||||
data = {"a": np.arange(0, 50)}
|
||||
widget = PerspectiveWidget(data, server=True)
|
||||
widget.clear()
|
||||
assert widget.table.size() == 0
|
||||
|
||||
# replace
|
||||
|
||||
def test_widget_replace(self):
|
||||
data = {"a": np.arange(0, 50)}
|
||||
widget = PerspectiveWidget(data)
|
||||
widget.replace({"a": [1]})
|
||||
assert widget.table.size() == 1
|
||||
|
||||
def test_widget_replace_server(self):
|
||||
data = {"a": np.arange(0, 50)}
|
||||
widget = PerspectiveWidget(data, server=True)
|
||||
widget.replace({"a": [1]})
|
||||
assert widget.table.size() == 1
|
||||
|
||||
# delete
|
||||
|
||||
def test_widget_delete(self):
|
||||
data = {"a": np.arange(0, 50)}
|
||||
widget = PerspectiveWidget(data)
|
||||
mocked_post = partial(mock_post, assert_msg={"cmd": "delete"})
|
||||
widget.post = MethodType(mocked_post, widget)
|
||||
widget.delete()
|
||||
assert widget.table is None
|
||||
|
||||
def test_widget_delete_with_view(self):
|
||||
data = {"a": np.arange(0, 50)}
|
||||
widget = PerspectiveWidget(data)
|
||||
|
||||
# create a view on the manager
|
||||
table_name, table = list(widget.manager._tables.items())[0]
|
||||
make_view_message = {
|
||||
"id": 1,
|
||||
"table_name": table_name,
|
||||
"view_name": "view1",
|
||||
"cmd": "view",
|
||||
"config": {"group_by": ["a"]},
|
||||
}
|
||||
widget.manager._process(make_view_message, lambda x: True)
|
||||
|
||||
assert len(widget.manager._views) == 1
|
||||
|
||||
mocked_post = partial(mock_post, assert_msg={"cmd": "delete"})
|
||||
|
||||
widget.post = MethodType(mocked_post, widget)
|
||||
widget.delete()
|
||||
|
||||
assert widget.table is None
|
||||
@@ -0,0 +1,453 @@
|
||||
# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
# ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
# ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
# ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
# ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
# ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
import pytest
|
||||
from perspective.widget import PerspectiveWidget
|
||||
import perspective as psp
|
||||
|
||||
client = psp.Server().new_local_client()
|
||||
Table = client.table
|
||||
|
||||
|
||||
pytest.skip(allow_module_level=True)
|
||||
|
||||
|
||||
class TestWidgetPandas:
|
||||
def test_widget_load_table_df(self, superstore):
|
||||
table = Table(superstore)
|
||||
widget = PerspectiveWidget(table)
|
||||
assert widget.table.schema() == {
|
||||
"index": "integer",
|
||||
"Country": "string",
|
||||
"Region": "string",
|
||||
"Category": "string",
|
||||
"City": "string",
|
||||
"Customer ID": "string",
|
||||
"Discount": "float",
|
||||
"Order Date": "date",
|
||||
"Order ID": "string",
|
||||
"Postal Code": "string",
|
||||
"Product ID": "string",
|
||||
"Profit": "float",
|
||||
"Quantity": "integer",
|
||||
"Row ID": "integer",
|
||||
"Sales": "integer",
|
||||
"Segment": "string",
|
||||
"Ship Date": "date",
|
||||
"Ship Mode": "string",
|
||||
"State": "string",
|
||||
"Sub-Category": "string",
|
||||
}
|
||||
|
||||
assert sorted(widget.columns) == sorted(
|
||||
[
|
||||
"index",
|
||||
"Category",
|
||||
"City",
|
||||
"Country",
|
||||
"Customer ID",
|
||||
"Discount",
|
||||
"Order Date",
|
||||
"Order ID",
|
||||
"Postal Code",
|
||||
"Product ID",
|
||||
"Profit",
|
||||
"Quantity",
|
||||
"Region",
|
||||
"Row ID",
|
||||
"Sales",
|
||||
"Segment",
|
||||
"Ship Date",
|
||||
"Ship Mode",
|
||||
"State",
|
||||
"Sub-Category",
|
||||
]
|
||||
)
|
||||
view = widget.table.view()
|
||||
assert view.num_rows() == len(superstore)
|
||||
assert view.num_columns() == len(superstore.columns) + 1 # index
|
||||
|
||||
def test_widget_load_data_df(self, superstore):
|
||||
widget = PerspectiveWidget(superstore)
|
||||
assert sorted(widget.columns) == sorted(
|
||||
[
|
||||
"index",
|
||||
"Category",
|
||||
"City",
|
||||
"Country",
|
||||
"Customer ID",
|
||||
"Discount",
|
||||
"Order Date",
|
||||
"Order ID",
|
||||
"Postal Code",
|
||||
"Product ID",
|
||||
"Profit",
|
||||
"Quantity",
|
||||
"Region",
|
||||
"Row ID",
|
||||
"Sales",
|
||||
"Segment",
|
||||
"Ship Date",
|
||||
"Ship Mode",
|
||||
"State",
|
||||
"Sub-Category",
|
||||
]
|
||||
)
|
||||
view = widget.table.view()
|
||||
assert view.num_rows() == len(superstore)
|
||||
assert view.num_columns() == 20
|
||||
|
||||
def test_widget_load_series(self, superstore):
|
||||
series = pd.Series(superstore["Profit"].values, name="profit")
|
||||
widget = PerspectiveWidget(series)
|
||||
assert widget.table.schema() == {"index": "integer", "profit": "float"}
|
||||
|
||||
assert sorted(widget.columns) == sorted(["index", "profit"])
|
||||
view = widget.table.view()
|
||||
assert view.num_rows() == len(superstore)
|
||||
assert view.num_columns() == 2
|
||||
|
||||
def test_widget_load_pivot_table(self, superstore):
|
||||
pivot_table = pd.pivot_table(
|
||||
superstore,
|
||||
values="Discount",
|
||||
index=["Country", "Region"],
|
||||
columns=["Category", "Segment"],
|
||||
)
|
||||
widget = PerspectiveWidget(pivot_table)
|
||||
assert widget.group_by == ["Country", "Region"]
|
||||
assert widget.split_by == ["Category", "Segment"]
|
||||
assert widget.columns == ["value"]
|
||||
# table should host flattened data
|
||||
view = widget.table.view()
|
||||
assert view.num_rows() == 60
|
||||
assert view.num_columns() == 6
|
||||
|
||||
def test_widget_load_pivot_table_with_user_pivots(self, superstore):
|
||||
pivot_table = pd.pivot_table(
|
||||
superstore,
|
||||
values="Discount",
|
||||
index=["Country", "Region"],
|
||||
columns="Category",
|
||||
)
|
||||
widget = PerspectiveWidget(pivot_table, group_by=["Category", "Segment"])
|
||||
assert widget.group_by == ["Category", "Segment"]
|
||||
assert widget.split_by == []
|
||||
assert widget.columns == [
|
||||
"index",
|
||||
"Country",
|
||||
"Region",
|
||||
"Financials",
|
||||
"Industrials",
|
||||
"Technology",
|
||||
]
|
||||
# table should host flattened data
|
||||
view = widget.table.view()
|
||||
assert view.num_rows() == 5
|
||||
assert view.num_columns() == 6
|
||||
|
||||
def test_widget_load_group_by(self, superstore):
|
||||
df_pivoted = superstore.set_index(["Country", "Region"])
|
||||
widget = PerspectiveWidget(df_pivoted)
|
||||
assert widget.group_by == ["Country", "Region"]
|
||||
assert widget.split_by == []
|
||||
assert sorted(widget.columns) == sorted(
|
||||
[
|
||||
"index",
|
||||
"Category",
|
||||
"Country",
|
||||
"City",
|
||||
"Customer ID",
|
||||
"Discount",
|
||||
"Order Date",
|
||||
"Order ID",
|
||||
"Postal Code",
|
||||
"Product ID",
|
||||
"Profit",
|
||||
"Quantity",
|
||||
"Region",
|
||||
"Row ID",
|
||||
"Sales",
|
||||
"Segment",
|
||||
"Ship Date",
|
||||
"Ship Mode",
|
||||
"State",
|
||||
"Sub-Category",
|
||||
]
|
||||
)
|
||||
assert widget.table.size() == 100
|
||||
view = widget.table.view()
|
||||
assert view.num_rows() == len(superstore)
|
||||
assert view.num_columns() == len(superstore.columns) + 1 # index
|
||||
|
||||
def test_widget_load_group_by_with_user_pivots(self, superstore):
|
||||
df_pivoted = superstore.set_index(["Country", "Region"])
|
||||
widget = PerspectiveWidget(df_pivoted, group_by=["Category", "Segment"])
|
||||
assert widget.group_by == ["Category", "Segment"]
|
||||
assert widget.split_by == []
|
||||
assert sorted(widget.columns) == sorted(
|
||||
[
|
||||
"index",
|
||||
"Category",
|
||||
"Country",
|
||||
"City",
|
||||
"Customer ID",
|
||||
"Discount",
|
||||
"Order Date",
|
||||
"Order ID",
|
||||
"Postal Code",
|
||||
"Product ID",
|
||||
"Profit",
|
||||
"Quantity",
|
||||
"Region",
|
||||
"Row ID",
|
||||
"Sales",
|
||||
"Segment",
|
||||
"Ship Date",
|
||||
"Ship Mode",
|
||||
"State",
|
||||
"Sub-Category",
|
||||
]
|
||||
)
|
||||
assert widget.table.size() == 100
|
||||
view = widget.table.view()
|
||||
assert view.num_rows() == len(superstore)
|
||||
assert view.num_columns() == len(superstore.columns) + 1 # index
|
||||
|
||||
def test_widget_load_split_by(self, superstore):
|
||||
arrays = [
|
||||
np.array(
|
||||
[
|
||||
"bar",
|
||||
"bar",
|
||||
"bar",
|
||||
"bar",
|
||||
"baz",
|
||||
"baz",
|
||||
"baz",
|
||||
"baz",
|
||||
"foo",
|
||||
"foo",
|
||||
"foo",
|
||||
"foo",
|
||||
"qux",
|
||||
"qux",
|
||||
"qux",
|
||||
"qux",
|
||||
]
|
||||
),
|
||||
np.array(
|
||||
[
|
||||
"one",
|
||||
"one",
|
||||
"two",
|
||||
"two",
|
||||
"one",
|
||||
"one",
|
||||
"two",
|
||||
"two",
|
||||
"one",
|
||||
"one",
|
||||
"two",
|
||||
"two",
|
||||
"one",
|
||||
"one",
|
||||
"two",
|
||||
"two",
|
||||
]
|
||||
),
|
||||
np.array(
|
||||
[
|
||||
"X",
|
||||
"Y",
|
||||
"X",
|
||||
"Y",
|
||||
"X",
|
||||
"Y",
|
||||
"X",
|
||||
"Y",
|
||||
"X",
|
||||
"Y",
|
||||
"X",
|
||||
"Y",
|
||||
"X",
|
||||
"Y",
|
||||
"X",
|
||||
"Y",
|
||||
]
|
||||
),
|
||||
]
|
||||
tuples = list(zip(*arrays))
|
||||
index = pd.MultiIndex.from_tuples(tuples, names=["first", "second", "third"])
|
||||
df_both = pd.DataFrame(
|
||||
np.random.randn(3, 16), index=["A", "B", "C"], columns=index
|
||||
)
|
||||
widget = PerspectiveWidget(df_both)
|
||||
assert widget.columns == ["value"]
|
||||
assert widget.split_by == ["first", "second", "third"]
|
||||
assert widget.group_by == ["index"]
|
||||
|
||||
def test_widget_load_split_by_preserve_user_settings(self, superstore):
|
||||
arrays = [
|
||||
np.array(
|
||||
[
|
||||
"bar",
|
||||
"bar",
|
||||
"bar",
|
||||
"bar",
|
||||
"baz",
|
||||
"baz",
|
||||
"baz",
|
||||
"baz",
|
||||
"foo",
|
||||
"foo",
|
||||
"foo",
|
||||
"foo",
|
||||
"qux",
|
||||
"qux",
|
||||
"qux",
|
||||
"qux",
|
||||
]
|
||||
),
|
||||
np.array(
|
||||
[
|
||||
"one",
|
||||
"one",
|
||||
"two",
|
||||
"two",
|
||||
"one",
|
||||
"one",
|
||||
"two",
|
||||
"two",
|
||||
"one",
|
||||
"one",
|
||||
"two",
|
||||
"two",
|
||||
"one",
|
||||
"one",
|
||||
"two",
|
||||
"two",
|
||||
]
|
||||
),
|
||||
np.array(
|
||||
[
|
||||
"X",
|
||||
"Y",
|
||||
"X",
|
||||
"Y",
|
||||
"X",
|
||||
"Y",
|
||||
"X",
|
||||
"Y",
|
||||
"X",
|
||||
"Y",
|
||||
"X",
|
||||
"Y",
|
||||
"X",
|
||||
"Y",
|
||||
"X",
|
||||
"Y",
|
||||
]
|
||||
),
|
||||
]
|
||||
tuples = list(zip(*arrays))
|
||||
index = pd.MultiIndex.from_tuples(tuples, names=["first", "second", "third"])
|
||||
df_both = pd.DataFrame(
|
||||
np.random.randn(3, 16), index=["A", "B", "C"], columns=index
|
||||
)
|
||||
widget = PerspectiveWidget(df_both, columns=["first", "third"])
|
||||
assert widget.columns == ["first", "third"]
|
||||
assert widget.split_by == ["first", "second", "third"]
|
||||
assert widget.group_by == ["index"]
|
||||
|
||||
def test_pivottable_values_index(self, superstore):
|
||||
arrays = {
|
||||
"A": [
|
||||
"bar",
|
||||
"bar",
|
||||
"bar",
|
||||
"bar",
|
||||
"baz",
|
||||
"baz",
|
||||
"baz",
|
||||
"baz",
|
||||
"foo",
|
||||
"foo",
|
||||
"foo",
|
||||
"foo",
|
||||
"qux",
|
||||
"qux",
|
||||
"qux",
|
||||
"qux",
|
||||
],
|
||||
"B": [
|
||||
"one",
|
||||
"one",
|
||||
"two",
|
||||
"two",
|
||||
"one",
|
||||
"one",
|
||||
"two",
|
||||
"two",
|
||||
"one",
|
||||
"one",
|
||||
"two",
|
||||
"two",
|
||||
"one",
|
||||
"one",
|
||||
"two",
|
||||
"two",
|
||||
],
|
||||
"C": [
|
||||
"X",
|
||||
"Y",
|
||||
"X",
|
||||
"Y",
|
||||
"X",
|
||||
"Y",
|
||||
"X",
|
||||
"Y",
|
||||
"X",
|
||||
"Y",
|
||||
"X",
|
||||
"Y",
|
||||
"X",
|
||||
"Y",
|
||||
"X",
|
||||
"Y",
|
||||
],
|
||||
"D": np.arange(16),
|
||||
}
|
||||
|
||||
df = pd.DataFrame(arrays)
|
||||
df_pivot = df.pivot_table(
|
||||
values=["D"], index=["A"], columns=["B", "C"], aggfunc={"D": "count"}
|
||||
)
|
||||
widget = PerspectiveWidget(df_pivot)
|
||||
assert widget.columns == ["value"]
|
||||
assert widget.split_by == ["B", "C"]
|
||||
assert widget.group_by == ["A"]
|
||||
|
||||
def test_pivottable_multi_values(self, superstore):
|
||||
pt = pd.pivot_table(
|
||||
superstore,
|
||||
values=["Discount", "Sales"],
|
||||
index=["Country", "Region"],
|
||||
aggfunc={"Discount": "count", "Sales": "sum"},
|
||||
columns=["State", "Quantity"],
|
||||
)
|
||||
widget = PerspectiveWidget(pt)
|
||||
assert widget.columns == ["Discount", "Sales"]
|
||||
assert widget.split_by == ["State", "Quantity"]
|
||||
assert widget.group_by == ["Country", "Region"]
|
||||
@@ -0,0 +1,142 @@
|
||||
# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
# ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
# ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
# ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
# ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
# ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
|
||||
class VirtualServerHandler:
|
||||
"""
|
||||
An interface for implementing a Perspective `VirtualServer`. It operates
|
||||
thusly:
|
||||
|
||||
- A table is selected by name (validated via `get_hosted_tables`).
|
||||
|
||||
- The UI will ask the model to create a temporary table with the results
|
||||
of querying this table with a specific query `config`, a simple struct
|
||||
which reflects the UI configurable fields (see `get_features`).
|
||||
|
||||
- The UI will query slices of the temporary table as it needs them to
|
||||
render. This may be a rectangular slice, a whole column or the entire
|
||||
set, and it is returned from teh model via a custom push-only
|
||||
struct `PerspectiveColumn` for now, though in the future we will support
|
||||
e.g. Polars and other arrow-native formats directly.
|
||||
|
||||
- The UI will delete its own temporary tables via `view_delete` but it is
|
||||
ok for them to die intermittently, the UI will recover automatically.
|
||||
"""
|
||||
|
||||
def get_features(self):
|
||||
"""
|
||||
[OPTIONAL] Toggle UI features through data model support. For example,
|
||||
setting `"group_by": False` would hide the "Group By" UI control, as
|
||||
well as prevent this field from appearing in `config` dicts later
|
||||
provided to `table_make_view`.
|
||||
|
||||
This API defaults to just "columns", e.g. a simple flat datagrid in
|
||||
which you can just scroll, select and format columns.
|
||||
|
||||
# Example
|
||||
|
||||
```python
|
||||
return {
|
||||
"group_by": True,
|
||||
"split_by": True,
|
||||
"sort": True,
|
||||
"expressions": True,
|
||||
"filter_ops": {
|
||||
"integer": ["==", "<"],
|
||||
},
|
||||
"aggregates": {
|
||||
"string": ["count"],
|
||||
"float": ["count", "sum"],
|
||||
},
|
||||
}
|
||||
```
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
def get_hosted_tables(self) -> list[str]:
|
||||
"""
|
||||
List of `Table` names available to query from.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
def table_schema(self, table_name):
|
||||
"""
|
||||
Get the _Perspective Schema_ for a `Table`, a mapping of column name to
|
||||
Perspective column types, a simplified set of six visually-relevant
|
||||
types mapped from DuckDB's much richer type system. Optionally,
|
||||
a model may also implement `view_schema` which describes temporary
|
||||
tables, but for DuckDB this method is identical.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
def table_size(self, table_name):
|
||||
"""
|
||||
Get a table's row count. Optionally, a model may also implement the
|
||||
`view_size` method to get the row count for temporary tables, but for
|
||||
DuckDB this method is identical.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
def view_schema(self, view_name, config):
|
||||
return self.table_schema(view_name)
|
||||
|
||||
def view_size(self, view_name):
|
||||
return self.table_size(view_name)
|
||||
|
||||
def table_make_view(self, table_name, view_name, config):
|
||||
"""
|
||||
Create a temporary table `view_name` from the results of querying
|
||||
`table_name` with a query configuration `config`.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
def table_validate_expression(self, view_name, expression):
|
||||
"""
|
||||
[OPTIONAL] Given a temporary table `view_name`, validate the type of
|
||||
a column expression string `expression`, or raise an error if the
|
||||
expression is invalid. This is enabeld by `"expressions"` via
|
||||
`get_features` and defaults to allow all expressions.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
def view_delete(self, view_name):
|
||||
"""
|
||||
Delete a temporary table. The UI will do this automatically, and it
|
||||
can recover.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
def view_get_min_max(self, view_name, column_name, config):
|
||||
"""
|
||||
[OPTIONAL] Get the min and max values of a column in a view.
|
||||
Returns a tuple of (min, max) as native Python values.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
def view_get_data(self, view_name, config, viewport, data):
|
||||
"""
|
||||
Serialize a rectangular slice `viewport` from temporary table
|
||||
`view_name`, into the `PerspectiveColumn` serialization API injected
|
||||
via `data`. The push-only `PerspectiveColumn` type can handle casting
|
||||
Python types as input, but once a type is pushed to a column name it
|
||||
must not be changed.
|
||||
"""
|
||||
|
||||
pass
|
||||
@@ -0,0 +1,240 @@
|
||||
# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
# ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
# ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
# ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
# ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
# ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
import perspective
|
||||
|
||||
from datetime import datetime
|
||||
import logging
|
||||
|
||||
from perspective.virtual_servers import VirtualServerHandler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
NUMBER_AGGS = [
|
||||
"sum",
|
||||
"count",
|
||||
"any_value",
|
||||
"arbitrary",
|
||||
"array_agg",
|
||||
"avg",
|
||||
"bit_and",
|
||||
"bit_or",
|
||||
"bit_xor",
|
||||
"bitstring_agg",
|
||||
"bool_and",
|
||||
"bool_or",
|
||||
"countif",
|
||||
"favg",
|
||||
"fsum",
|
||||
"geomean",
|
||||
"kahan_sum",
|
||||
"last",
|
||||
"max",
|
||||
"min",
|
||||
"product",
|
||||
"string_agg",
|
||||
"sumkahan",
|
||||
]
|
||||
|
||||
STRING_AGGS = [
|
||||
"count",
|
||||
"any_value",
|
||||
"arbitrary",
|
||||
"first",
|
||||
"countif",
|
||||
"last",
|
||||
"string_agg",
|
||||
]
|
||||
|
||||
FILTER_OPS = [
|
||||
"==",
|
||||
"!=",
|
||||
"LIKE",
|
||||
"IS DISTINCT FROM",
|
||||
"IS NOT DISTINCT FROM",
|
||||
">=",
|
||||
"<=",
|
||||
">",
|
||||
"<",
|
||||
]
|
||||
|
||||
|
||||
class ClickhouseVirtualSession:
|
||||
def __init__(self, callback, db):
|
||||
self.session = perspective.VirtualServer(ClickhouseVirtualServerHandler(db))
|
||||
self.callback = callback
|
||||
|
||||
def handle_request(self, msg):
|
||||
self.callback(self.session.handle_request(msg))
|
||||
|
||||
|
||||
class ClickhouseVirtualServer:
|
||||
def __init__(self, db):
|
||||
self.db = db
|
||||
|
||||
def new_session(self, callback):
|
||||
return ClickhouseVirtualSession(callback, self.db)
|
||||
|
||||
|
||||
class ClickhouseVirtualServerHandler(VirtualServerHandler):
|
||||
"""
|
||||
An implementation of a `perspective.VirtualServerHandler` for ClickHouse.
|
||||
"""
|
||||
|
||||
def __init__(self, db):
|
||||
self.db = db
|
||||
self.sql_builder = perspective.GenericSQLVirtualServerModel(
|
||||
{"create_entity": "VIEW", "grouping_fn": "GROUPING"}
|
||||
)
|
||||
|
||||
def get_features(self):
|
||||
return {
|
||||
"group_by": True,
|
||||
"split_by": False,
|
||||
"sort": True,
|
||||
"expressions": True,
|
||||
"group_rollup_mode": ["rollup", "flat", "total"],
|
||||
"filter_ops": {
|
||||
"integer": FILTER_OPS,
|
||||
"float": FILTER_OPS,
|
||||
"string": FILTER_OPS,
|
||||
"boolean": FILTER_OPS,
|
||||
"date": FILTER_OPS,
|
||||
"datetime": FILTER_OPS,
|
||||
},
|
||||
"aggregates": {
|
||||
"integer": NUMBER_AGGS,
|
||||
"float": NUMBER_AGGS,
|
||||
"string": STRING_AGGS,
|
||||
"boolean": STRING_AGGS,
|
||||
"date": STRING_AGGS,
|
||||
"datetime": STRING_AGGS,
|
||||
},
|
||||
}
|
||||
|
||||
def get_hosted_tables(self):
|
||||
query = "SHOW TABLES"
|
||||
results = run_query(self.db, query)
|
||||
return [result[0] for result in results]
|
||||
|
||||
def table_schema(self, table_name, config=None):
|
||||
query = self.sql_builder.table_schema(table_name)
|
||||
results = run_query(self.db, query)
|
||||
schema = {}
|
||||
for result in results:
|
||||
col_name = result[0]
|
||||
if not col_name.startswith("__"):
|
||||
schema[col_name] = clickhouse_type_to_psp(result[1])
|
||||
|
||||
return schema
|
||||
|
||||
def view_column_size(self, view_name, config):
|
||||
query = f"SELECT COUNT() FROM system.columns WHERE table = '{view_name}'"
|
||||
results = run_query(self.db, query)
|
||||
gs = len(config["group_by"])
|
||||
return results[0][0] - (
|
||||
0 if gs == 0 else gs + (1 if len(config["split_by"]) == 0 else 0)
|
||||
)
|
||||
|
||||
def table_size(self, table_name):
|
||||
query = self.sql_builder.table_size(table_name)
|
||||
results = run_query(self.db, query)
|
||||
return results[0][0]
|
||||
|
||||
def table_make_view(self, table_name, view_name, config):
|
||||
query = self.sql_builder.table_make_view(table_name, view_name, config)
|
||||
run_query(self.db, query, execute=True)
|
||||
|
||||
def table_validate_expression(self, view_name, expression):
|
||||
query = self.sql_builder.table_validate_expression(view_name, expression)
|
||||
results = run_query(self.db, query)
|
||||
return clickhouse_type_to_psp(results[0][1])
|
||||
|
||||
def view_delete(self, view_name):
|
||||
query = self.sql_builder.view_delete(view_name)
|
||||
run_query(self.db, query, execute=True)
|
||||
|
||||
def view_get_min_max(self, view_name, column_name, config):
|
||||
query = self.sql_builder.view_get_min_max(view_name, column_name, config)
|
||||
results = run_query(self.db, query)
|
||||
row = results[0]
|
||||
return (row[0], row[1])
|
||||
|
||||
def view_get_data(self, view_name, config, schema, viewport, data):
|
||||
group_by = config["group_by"]
|
||||
query = self.sql_builder.view_get_data(view_name, config, viewport, schema)
|
||||
results, columns, dtypes = run_query(self.db, query, columns=True)
|
||||
for cidx, col in enumerate(columns):
|
||||
dtype = clickhouse_type_to_psp(str(dtypes[cidx]))
|
||||
for ridx, row in enumerate(results):
|
||||
grouping_id = row[0] if len(group_by) > 0 else None
|
||||
|
||||
value = row[cidx]
|
||||
if dtype == "string" and not isinstance(value, str):
|
||||
value = str(value)
|
||||
|
||||
data.set_col(dtype, col, ridx, value, grouping_id)
|
||||
|
||||
|
||||
################################################################################
|
||||
#
|
||||
# ClickHouse Utils
|
||||
|
||||
|
||||
def clickhouse_type_to_psp(name):
|
||||
"""Convert a ClickHouse `dtype` to a Perspective `ColumnType`."""
|
||||
if name.startswith("Nullable(") and name.endswith(")"):
|
||||
name = name[9:-1]
|
||||
|
||||
if name.startswith("Array"):
|
||||
return "string"
|
||||
|
||||
if name in ("Int64", "UInt64", "Float64"):
|
||||
return "float"
|
||||
|
||||
if name == "String":
|
||||
return "string"
|
||||
|
||||
if name == "DateTime":
|
||||
return "datetime"
|
||||
|
||||
if name == "Date":
|
||||
return "date"
|
||||
|
||||
msg = f"Unknown type '{name}'"
|
||||
raise ValueError(msg)
|
||||
|
||||
|
||||
def run_query(db, query, execute=False, columns=False):
|
||||
query = " ".join(query.split())
|
||||
start = datetime.now()
|
||||
result = None
|
||||
try:
|
||||
if execute:
|
||||
db.command(query)
|
||||
else:
|
||||
req = db.query(query)
|
||||
result = req.result_rows
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
logger.error(f"{query}")
|
||||
raise e
|
||||
else:
|
||||
logger.debug(f"{datetime.now() - start} {query}")
|
||||
if columns:
|
||||
return (
|
||||
result,
|
||||
req.column_names,
|
||||
[(x.name if hasattr(x, "name") else str(x)) for x in req.column_types],
|
||||
)
|
||||
else:
|
||||
return result
|
||||
@@ -0,0 +1,236 @@
|
||||
# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
# ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
# ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
# ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
# ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
# ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
import io
|
||||
import duckdb
|
||||
import perspective
|
||||
import pyarrow.ipc as ipc
|
||||
|
||||
from datetime import datetime
|
||||
import logging
|
||||
|
||||
from perspective.virtual_servers import VirtualServerHandler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
NUMBER_AGGS = [
|
||||
"sum",
|
||||
"count",
|
||||
"any_value",
|
||||
"arbitrary",
|
||||
# "arg_max",
|
||||
# "arg_max_null",
|
||||
# "arg_min",
|
||||
# "arg_min_null",
|
||||
"array_agg",
|
||||
"avg",
|
||||
"bit_and",
|
||||
"bit_or",
|
||||
"bit_xor",
|
||||
"bitstring_agg",
|
||||
"bool_and",
|
||||
"bool_or",
|
||||
"countif",
|
||||
"favg",
|
||||
"fsum",
|
||||
"geomean",
|
||||
# "histogram",
|
||||
# "histogram_values",
|
||||
"kahan_sum",
|
||||
"last",
|
||||
# "list"
|
||||
"max",
|
||||
# "max_by"
|
||||
"min",
|
||||
# "min_by"
|
||||
"product",
|
||||
"string_agg",
|
||||
"sumkahan",
|
||||
# "weighted_avg",
|
||||
]
|
||||
|
||||
STRING_AGGS = [
|
||||
"count",
|
||||
"any_value",
|
||||
"arbitrary",
|
||||
"first",
|
||||
"countif",
|
||||
"last",
|
||||
"string_agg",
|
||||
]
|
||||
|
||||
FILTER_OPS = [
|
||||
"==",
|
||||
"!=",
|
||||
"LIKE",
|
||||
"IS DISTINCT FROM",
|
||||
"IS NOT DISTINCT FROM",
|
||||
">=",
|
||||
"<=",
|
||||
">",
|
||||
"<",
|
||||
]
|
||||
|
||||
|
||||
class DuckDBVirtualSession:
|
||||
def __init__(self, callback, db):
|
||||
self.session = perspective.VirtualServer(DuckDBVirtualServerHandler(db))
|
||||
self.callback = callback
|
||||
|
||||
def handle_request(self, msg):
|
||||
self.callback(self.session.handle_request(msg))
|
||||
|
||||
|
||||
class DuckDBVirtualServer:
|
||||
def __init__(self, db):
|
||||
self.db = db
|
||||
|
||||
def new_session(self, callback):
|
||||
return DuckDBVirtualSession(callback, self.db)
|
||||
|
||||
|
||||
class DuckDBVirtualServerHandler(VirtualServerHandler):
|
||||
"""
|
||||
An implementation of a `perspective.VirtualServerHandler` for DuckDB.
|
||||
"""
|
||||
|
||||
def __init__(self, db):
|
||||
self.db = db
|
||||
self.sql_builder = perspective.GenericSQLVirtualServerModel({})
|
||||
|
||||
def get_features(self):
|
||||
return {
|
||||
"group_by": True,
|
||||
"split_by": True,
|
||||
"sort": True,
|
||||
"expressions": True,
|
||||
"group_rollup_mode": ["rollup", "flat", "total"],
|
||||
"filter_ops": {
|
||||
"integer": FILTER_OPS,
|
||||
"float": FILTER_OPS,
|
||||
"string": FILTER_OPS,
|
||||
"boolean": FILTER_OPS,
|
||||
"date": FILTER_OPS,
|
||||
"datetime": FILTER_OPS,
|
||||
},
|
||||
"aggregates": {
|
||||
"integer": NUMBER_AGGS,
|
||||
"float": NUMBER_AGGS,
|
||||
"string": STRING_AGGS,
|
||||
"boolean": STRING_AGGS,
|
||||
"date": STRING_AGGS,
|
||||
"datetime": STRING_AGGS,
|
||||
},
|
||||
}
|
||||
|
||||
def get_hosted_tables(self):
|
||||
query = self.sql_builder.get_hosted_tables()
|
||||
results = run_query(self.db, query)
|
||||
return [f"{result[0]}.{result[2]}" for result in results]
|
||||
|
||||
def table_schema(self, table_name, config=None):
|
||||
query = self.sql_builder.table_schema(table_name)
|
||||
results = run_query(self.db, query)
|
||||
schema = {}
|
||||
for result in results:
|
||||
col_name = result[0]
|
||||
if not col_name.startswith("__"):
|
||||
schema[col_name] = duckdb_type_to_psp(result[1])
|
||||
|
||||
return schema
|
||||
|
||||
def view_column_size(self, table_name, config):
|
||||
query = self.sql_builder.view_column_size(table_name)
|
||||
results = run_query(self.db, query)
|
||||
gs = len(config["group_by"])
|
||||
return results[0][0] - (
|
||||
0 if gs == 0 else gs + (1 if len(config["split_by"]) == 0 else 0)
|
||||
)
|
||||
|
||||
def table_size(self, table_name):
|
||||
query = self.sql_builder.table_size(table_name)
|
||||
results = run_query(self.db, query)
|
||||
return results[0][0]
|
||||
|
||||
def table_make_view(self, table_name, view_name, config):
|
||||
query = self.sql_builder.table_make_view(table_name, view_name, config)
|
||||
run_query(self.db, query, execute=True)
|
||||
|
||||
def table_validate_expression(self, view_name, expression):
|
||||
query = self.sql_builder.table_validate_expression(view_name, expression)
|
||||
results = run_query(self.db, query)
|
||||
return duckdb_type_to_psp(results[0][1])
|
||||
|
||||
def view_delete(self, view_name):
|
||||
query = self.sql_builder.view_delete(view_name)
|
||||
run_query(self.db, query, execute=True)
|
||||
|
||||
def view_get_min_max(self, view_name, column_name, config):
|
||||
query = self.sql_builder.view_get_min_max(view_name, column_name, config)
|
||||
results = run_query(self.db, query)
|
||||
row = results[0]
|
||||
return (row[0], row[1])
|
||||
|
||||
def view_get_data(self, view_name, config, schema, viewport, data):
|
||||
query = self.sql_builder.view_get_data(view_name, config, viewport, schema)
|
||||
result = self.db.sql(query)
|
||||
arrow_table = result.fetch_arrow_table()
|
||||
buf = io.BytesIO()
|
||||
with ipc.new_stream(buf, arrow_table.schema) as writer:
|
||||
writer.write_table(arrow_table)
|
||||
data.from_arrow_ipc(buf.getvalue())
|
||||
|
||||
|
||||
################################################################################
|
||||
#
|
||||
# DuckDB Utils
|
||||
|
||||
|
||||
def duckdb_type_to_psp(name):
|
||||
"""Convert a DuckDB `dtype` to a Perspective `ColumnType`."""
|
||||
if name == "VARCHAR":
|
||||
return "string"
|
||||
if name in ("DOUBLE", "BIGINT", "HUGEINT"):
|
||||
return "float"
|
||||
if name == "INTEGER":
|
||||
return "integer"
|
||||
if name == "DATE":
|
||||
return "date"
|
||||
if name == "BOOLEAN":
|
||||
return "boolean"
|
||||
if name == "TIMESTAMP":
|
||||
return "datetime"
|
||||
|
||||
msg = f"Unknown type '{name}'"
|
||||
raise ValueError(msg)
|
||||
|
||||
|
||||
def run_query(db, query, execute=False, columns=False):
|
||||
query = " ".join(query.split())
|
||||
start = datetime.now()
|
||||
result = None
|
||||
try:
|
||||
if execute:
|
||||
db.execute(query)
|
||||
else:
|
||||
req = db.sql(query)
|
||||
result = req.fetchall()
|
||||
except (duckdb.ParserException, duckdb.BinderException) as e:
|
||||
logger.error(e)
|
||||
logger.error(f"{query}")
|
||||
raise e
|
||||
else:
|
||||
logger.debug(f"{datetime.now() - start} {query}")
|
||||
if columns:
|
||||
return (result, req.columns, req.dtypes)
|
||||
else:
|
||||
return result
|
||||
@@ -0,0 +1,710 @@
|
||||
# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
# ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
# ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
# ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
# ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
# ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
import logging
|
||||
import polars as pl
|
||||
import perspective
|
||||
|
||||
from datetime import datetime
|
||||
import re
|
||||
|
||||
from perspective.virtual_servers import VirtualServerHandler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
NUMBER_AGGS = [
|
||||
"sum",
|
||||
"count",
|
||||
"any_value",
|
||||
"avg",
|
||||
"mean",
|
||||
"max",
|
||||
"min",
|
||||
"first",
|
||||
"last",
|
||||
]
|
||||
|
||||
STRING_AGGS = [
|
||||
"count",
|
||||
"any_value",
|
||||
"first",
|
||||
"last",
|
||||
]
|
||||
|
||||
FILTER_OPS = [
|
||||
"==",
|
||||
"!=",
|
||||
">=",
|
||||
"<=",
|
||||
">",
|
||||
"<",
|
||||
]
|
||||
|
||||
AGG_MAP = {
|
||||
"sum": lambda e: e.sum(),
|
||||
"count": lambda e: e.count(),
|
||||
"avg": lambda e: e.mean(),
|
||||
"mean": lambda e: e.mean(),
|
||||
"min": lambda e: e.min(),
|
||||
"max": lambda e: e.max(),
|
||||
"first": lambda e: e.first(),
|
||||
"last": lambda e: e.last(),
|
||||
"any_value": lambda e: e.first(),
|
||||
"arbitrary": lambda e: e.first(),
|
||||
}
|
||||
|
||||
|
||||
class PolarsVirtualSession:
|
||||
def __init__(self, callback, tables):
|
||||
self.session = perspective.VirtualServer(PolarsVirtualServerHandler(tables))
|
||||
self.callback = callback
|
||||
|
||||
def handle_request(self, msg):
|
||||
self.callback(self.session.handle_request(msg))
|
||||
|
||||
|
||||
class PolarsVirtualServer:
|
||||
def __init__(self, tables):
|
||||
self.tables = tables
|
||||
|
||||
def new_session(self, callback):
|
||||
return PolarsVirtualSession(callback, self.tables)
|
||||
|
||||
|
||||
class PolarsVirtualServerHandler(VirtualServerHandler):
|
||||
"""
|
||||
An implementation of a `perspective.VirtualServerHandler` for Polars.
|
||||
"""
|
||||
|
||||
def __init__(self, tables):
|
||||
self.tables = tables
|
||||
self.views = {}
|
||||
self.view_schemas = {}
|
||||
|
||||
def get_features(self):
|
||||
return {
|
||||
"group_by": True,
|
||||
"split_by": True,
|
||||
"sort": True,
|
||||
"expressions": True,
|
||||
"group_rollup_mode": ["rollup", "flat", "total"],
|
||||
"filter_ops": {
|
||||
"integer": FILTER_OPS,
|
||||
"float": FILTER_OPS,
|
||||
"string": FILTER_OPS,
|
||||
"boolean": ["==", "!="],
|
||||
"date": FILTER_OPS,
|
||||
"datetime": FILTER_OPS,
|
||||
},
|
||||
"aggregates": {
|
||||
"integer": NUMBER_AGGS,
|
||||
"float": NUMBER_AGGS,
|
||||
"string": STRING_AGGS,
|
||||
"boolean": STRING_AGGS,
|
||||
"date": STRING_AGGS,
|
||||
"datetime": STRING_AGGS,
|
||||
},
|
||||
}
|
||||
|
||||
def get_hosted_tables(self):
|
||||
return list(self.tables.keys())
|
||||
|
||||
def table_schema(self, table_name, config=None):
|
||||
df = self.tables[table_name]
|
||||
schema = {}
|
||||
for col_name, dtype in df.schema.items():
|
||||
if not col_name.startswith("__"):
|
||||
schema[col_name] = polars_type_to_psp(dtype)
|
||||
return schema
|
||||
|
||||
def table_size(self, table_name):
|
||||
return self.tables[table_name].height
|
||||
|
||||
def view_schema(self, view_name, config):
|
||||
if view_name in self.view_schemas:
|
||||
return self.view_schemas[view_name]
|
||||
return self.table_schema(view_name)
|
||||
|
||||
def view_size(self, view_name):
|
||||
if view_name in self.views:
|
||||
return self.views[view_name].height
|
||||
return self.table_size(view_name)
|
||||
|
||||
def table_validate_expression(self, table_name, expression):
|
||||
df = self.tables.get(table_name)
|
||||
if df is None:
|
||||
return None
|
||||
expr = parse_expression(expression)
|
||||
result = df.select(expr.alias("__expr__"))
|
||||
return polars_type_to_psp(result["__expr__"].dtype)
|
||||
|
||||
def table_make_view(self, table_name, view_name, config):
|
||||
start = datetime.now()
|
||||
df = self.tables[table_name]
|
||||
group_by = config.get("group_by", [])
|
||||
columns = [c for c in config.get("columns", []) if c is not None]
|
||||
aggregates = config.get("aggregates", {})
|
||||
sort = config.get("sort", [])
|
||||
filters = config.get("filter", [])
|
||||
split_by = config.get("split_by", [])
|
||||
expressions = config.get("expressions", {})
|
||||
group_rollup_mode = config.get("group_rollup_mode", "rollup")
|
||||
|
||||
if expressions:
|
||||
for expr_name, expr_str in expressions.items():
|
||||
expr = parse_expression(expr_str)
|
||||
df = df.with_columns(expr.alias(expr_name))
|
||||
|
||||
df = apply_filters(df, filters)
|
||||
|
||||
col_alias = lambda c: c.replace("_", "-")
|
||||
is_flat = group_rollup_mode == "flat"
|
||||
is_total = group_rollup_mode == "total"
|
||||
|
||||
if is_total:
|
||||
if split_by:
|
||||
result = build_split_by_total(
|
||||
df, split_by, columns, aggregates, col_alias
|
||||
)
|
||||
else:
|
||||
result = build_total(df, columns, aggregates, col_alias)
|
||||
elif split_by and group_by:
|
||||
if is_flat:
|
||||
result = build_split_by_grouped_flat(
|
||||
df, group_by, split_by, columns, aggregates, col_alias
|
||||
)
|
||||
result = apply_sort_split_by_flat(
|
||||
result, sort, columns, group_by, split_by
|
||||
)
|
||||
else:
|
||||
result = build_split_by_grouped(
|
||||
df, group_by, split_by, columns, aggregates, col_alias
|
||||
)
|
||||
result = apply_sort_grouped(result, sort, group_by, col_alias)
|
||||
elif split_by:
|
||||
result = build_split_by_flat(df, split_by, columns, col_alias)
|
||||
result = apply_sort_flat(result, sort, col_alias)
|
||||
elif group_by:
|
||||
if is_flat:
|
||||
result = build_flat_group_by(
|
||||
df, group_by, columns, aggregates, col_alias
|
||||
)
|
||||
result = apply_sort_flat(result, sort, col_alias)
|
||||
else:
|
||||
result = build_rollup(df, group_by, columns, aggregates, col_alias)
|
||||
result = apply_sort_grouped(result, sort, group_by, col_alias)
|
||||
else:
|
||||
select_exprs = [pl.col(c).alias(col_alias(c)) for c in columns]
|
||||
result = df.select(select_exprs)
|
||||
result = apply_sort_flat(result, sort, col_alias)
|
||||
|
||||
self.views[view_name] = result
|
||||
self.view_schemas[view_name] = compute_view_schema(result)
|
||||
logger.debug(
|
||||
f"{datetime.now() - start} table_make_view {table_name} -> {view_name}"
|
||||
)
|
||||
|
||||
def view_delete(self, view_name):
|
||||
self.views.pop(view_name, None)
|
||||
self.view_schemas.pop(view_name, None)
|
||||
|
||||
def view_get_min_max(self, view_name, column_name, config):
|
||||
df = self.views[view_name]
|
||||
col = df[column_name]
|
||||
min_val = col.min()
|
||||
max_val = col.max()
|
||||
return (min_val, max_val)
|
||||
|
||||
def view_get_data(self, view_name, config, schema, viewport, data):
|
||||
df = self.views.get(view_name)
|
||||
if df is None:
|
||||
return
|
||||
|
||||
group_by = config.get("group_by", [])
|
||||
split_by = config.get("split_by", [])
|
||||
group_rollup_mode = config.get("group_rollup_mode", "rollup")
|
||||
is_split_by = len(split_by) > 0
|
||||
is_flat = group_rollup_mode == "flat"
|
||||
|
||||
start_row = viewport.get("start_row", 0) or 0
|
||||
end_row = viewport.get("end_row") or df.height
|
||||
start_col = viewport.get("start_col", 0) or 0
|
||||
end_col = viewport.get("end_col")
|
||||
|
||||
length = min(end_row, df.height) - start_row
|
||||
if length <= 0:
|
||||
return
|
||||
df_slice = df.slice(start_row, length)
|
||||
|
||||
data_columns = [c for c in schema.keys() if not c.startswith("__")]
|
||||
if end_col is not None:
|
||||
data_columns = data_columns[start_col:end_col]
|
||||
else:
|
||||
data_columns = data_columns[start_col:]
|
||||
|
||||
has_group_by = len(group_by) > 0
|
||||
has_grouping_id = has_group_by and not is_flat
|
||||
|
||||
all_cols = []
|
||||
if has_grouping_id:
|
||||
all_cols.append("__GROUPING_ID__")
|
||||
for idx in range(len(group_by)):
|
||||
all_cols.append(f"__ROW_PATH_{idx}__")
|
||||
all_cols.extend(data_columns)
|
||||
|
||||
grouping_ids = None
|
||||
if has_grouping_id:
|
||||
grouping_ids = df_slice["__GROUPING_ID__"].to_list()
|
||||
|
||||
for cidx, col in enumerate(all_cols):
|
||||
if cidx == 0 and has_grouping_id:
|
||||
continue
|
||||
|
||||
series = df_slice[col]
|
||||
dtype = polars_type_to_psp(series.dtype)
|
||||
values = series.to_list()
|
||||
|
||||
push_col = col
|
||||
if is_split_by and not col.startswith("__"):
|
||||
push_col = col.replace("_", "|")
|
||||
|
||||
for ridx, value in enumerate(values):
|
||||
if grouping_ids:
|
||||
grouping_id = grouping_ids[ridx]
|
||||
elif has_group_by:
|
||||
grouping_id = 0
|
||||
else:
|
||||
grouping_id = None
|
||||
|
||||
if value is not None and isinstance(value, float) and value != value:
|
||||
value = None
|
||||
|
||||
data.set_col(dtype, push_col, ridx, value, grouping_id)
|
||||
|
||||
|
||||
################################################################################
|
||||
#
|
||||
# Polars Utils
|
||||
|
||||
|
||||
def polars_type_to_psp(dtype):
|
||||
"""Convert a Polars `dtype` to a Perspective `ColumnType`."""
|
||||
if dtype in (pl.Utf8, pl.String):
|
||||
return "string"
|
||||
if dtype == pl.Categorical:
|
||||
return "string"
|
||||
if dtype in (pl.Int8, pl.Int16, pl.Int32, pl.UInt8, pl.UInt16):
|
||||
return "integer"
|
||||
if dtype in (pl.Int64, pl.UInt64, pl.UInt32, pl.Float32, pl.Float64):
|
||||
return "float"
|
||||
if dtype == pl.Date:
|
||||
return "date"
|
||||
if dtype == pl.Boolean:
|
||||
return "boolean"
|
||||
if isinstance(dtype, pl.Datetime) or dtype == pl.Datetime:
|
||||
return "datetime"
|
||||
|
||||
msg = f"Unknown Polars type '{dtype}'"
|
||||
raise ValueError(msg)
|
||||
|
||||
|
||||
def apply_filters(df, filters):
|
||||
"""Apply a list of filter configs to a DataFrame."""
|
||||
if not filters:
|
||||
return df
|
||||
|
||||
mask = pl.lit(True)
|
||||
for filt in filters:
|
||||
col_name = filt[0]
|
||||
op = filt[1]
|
||||
value = filt[2] if len(filt) > 2 else None
|
||||
|
||||
if value is None:
|
||||
continue
|
||||
|
||||
col_expr = pl.col(col_name)
|
||||
if op == "==":
|
||||
mask = mask & (col_expr == value)
|
||||
elif op == "!=":
|
||||
mask = mask & (col_expr != value)
|
||||
elif op == ">":
|
||||
mask = mask & (col_expr > value)
|
||||
elif op == "<":
|
||||
mask = mask & (col_expr < value)
|
||||
elif op == ">=":
|
||||
mask = mask & (col_expr >= value)
|
||||
elif op == "<=":
|
||||
mask = mask & (col_expr <= value)
|
||||
|
||||
return df.filter(mask)
|
||||
|
||||
|
||||
def get_polars_agg_expr(col, agg_name, filter_expr=None):
|
||||
"""Convert an aggregate name to a Polars expression."""
|
||||
if isinstance(agg_name, list):
|
||||
agg_name = agg_name[0]
|
||||
if isinstance(agg_name, dict):
|
||||
agg_name = "first"
|
||||
expr = pl.col(col)
|
||||
if filter_expr is not None:
|
||||
expr = expr.filter(filter_expr)
|
||||
if agg_name in AGG_MAP:
|
||||
return AGG_MAP[agg_name](expr)
|
||||
|
||||
msg = f"Unknown aggregate '{agg_name}'"
|
||||
raise ValueError(msg)
|
||||
|
||||
|
||||
def default_aggregate(col_name, df):
|
||||
"""Return the default aggregate for a column based on its type."""
|
||||
dtype = df[col_name].dtype
|
||||
psp_type = polars_type_to_psp(dtype)
|
||||
if psp_type in ("integer", "float"):
|
||||
return "sum"
|
||||
return "count"
|
||||
|
||||
|
||||
def build_rollup(df, group_by, columns, aggregates, col_alias):
|
||||
"""Emulate GROUP BY ROLLUP using multiple group_by operations."""
|
||||
n = len(group_by)
|
||||
frames = []
|
||||
data_columns = [c for c in columns if c not in group_by]
|
||||
|
||||
for level in range(n + 1):
|
||||
num_groups = n - level
|
||||
active_groups = group_by[:num_groups]
|
||||
|
||||
agg_exprs = []
|
||||
for col in data_columns:
|
||||
agg_name = aggregates.get(col, default_aggregate(col, df))
|
||||
agg_exprs.append(get_polars_agg_expr(col, agg_name).alias(col_alias(col)))
|
||||
|
||||
if active_groups:
|
||||
grouped = df.group_by(active_groups, maintain_order=True).agg(agg_exprs)
|
||||
else:
|
||||
grouped = df.select(agg_exprs)
|
||||
|
||||
for idx in range(n):
|
||||
if idx < num_groups:
|
||||
grouped = grouped.with_columns(
|
||||
pl.col(group_by[idx]).alias(f"__ROW_PATH_{idx}__")
|
||||
)
|
||||
else:
|
||||
src_dtype = df[group_by[idx]].dtype
|
||||
grouped = grouped.with_columns(
|
||||
pl.lit(None).cast(src_dtype).alias(f"__ROW_PATH_{idx}__")
|
||||
)
|
||||
|
||||
grouping_id = sum(1 << i for i in range(num_groups, n))
|
||||
grouped = grouped.with_columns(
|
||||
pl.lit(grouping_id).cast(pl.Int64).alias("__GROUPING_ID__")
|
||||
)
|
||||
|
||||
for gb_col in active_groups:
|
||||
if gb_col in grouped.columns:
|
||||
grouped = grouped.drop(gb_col)
|
||||
|
||||
frames.append(grouped)
|
||||
|
||||
result = pl.concat(frames, how="diagonal")
|
||||
path_cols = [f"__ROW_PATH_{i}__" for i in range(n)]
|
||||
data_col_aliases = [col_alias(c) for c in data_columns]
|
||||
final_order = ["__GROUPING_ID__"] + path_cols + data_col_aliases
|
||||
result = result.select([c for c in final_order if c in result.columns])
|
||||
return result
|
||||
|
||||
|
||||
def build_flat_group_by(df, group_by, columns, aggregates, col_alias):
|
||||
"""Build a simple GROUP BY (no rollup) - only leaf-level rows."""
|
||||
n = len(group_by)
|
||||
data_columns = [c for c in columns if c not in group_by]
|
||||
|
||||
agg_exprs = []
|
||||
for col in data_columns:
|
||||
agg_name = aggregates.get(col, default_aggregate(col, df))
|
||||
agg_exprs.append(get_polars_agg_expr(col, agg_name).alias(col_alias(col)))
|
||||
|
||||
grouped = df.group_by(group_by, maintain_order=True).agg(agg_exprs)
|
||||
|
||||
for idx in range(n):
|
||||
grouped = grouped.with_columns(
|
||||
pl.col(group_by[idx]).alias(f"__ROW_PATH_{idx}__")
|
||||
)
|
||||
|
||||
for gb_col in group_by:
|
||||
if gb_col in grouped.columns:
|
||||
grouped = grouped.drop(gb_col)
|
||||
|
||||
path_cols = [f"__ROW_PATH_{i}__" for i in range(n)]
|
||||
data_col_aliases = [col_alias(c) for c in data_columns]
|
||||
final_order = path_cols + data_col_aliases
|
||||
result = grouped.select([c for c in final_order if c in grouped.columns])
|
||||
return result.sort(path_cols)
|
||||
|
||||
|
||||
def build_total(df, columns, aggregates, col_alias):
|
||||
"""Build a single total row aggregating the entire dataset."""
|
||||
agg_exprs = []
|
||||
for col in columns:
|
||||
agg_name = aggregates.get(col, default_aggregate(col, df))
|
||||
agg_exprs.append(get_polars_agg_expr(col, agg_name).alias(col_alias(col)))
|
||||
return df.select(agg_exprs)
|
||||
|
||||
|
||||
def build_split_by_total(df, split_by, columns, aggregates, col_alias):
|
||||
"""Build a single total row with split_by (pivot) columns."""
|
||||
split_col = split_by[0]
|
||||
data_columns = [c for c in columns if c not in split_by]
|
||||
split_values = sorted(df[split_col].unique().to_list())
|
||||
|
||||
agg_exprs = []
|
||||
for sv in split_values:
|
||||
filter_expr = pl.col(split_col) == sv
|
||||
for dc in data_columns:
|
||||
agg_name = aggregates.get(dc, default_aggregate(dc, df))
|
||||
col_name = f"{sv}_{col_alias(dc)}"
|
||||
agg_exprs.append(
|
||||
get_polars_agg_expr(dc, agg_name, filter_expr=filter_expr).alias(
|
||||
col_name
|
||||
)
|
||||
)
|
||||
|
||||
return df.select(agg_exprs)
|
||||
|
||||
|
||||
def build_split_by_grouped_flat(df, group_by, split_by, columns, aggregates, col_alias):
|
||||
"""Build a flat grouped view with split_by (pivot) columns - no rollup rows."""
|
||||
n = len(group_by)
|
||||
split_col = split_by[0]
|
||||
data_columns = [c for c in columns if c not in group_by and c not in split_by]
|
||||
split_values = sorted(df[split_col].unique().to_list())
|
||||
|
||||
agg_exprs = []
|
||||
for sv in split_values:
|
||||
filter_expr = pl.col(split_col) == sv
|
||||
for dc in data_columns:
|
||||
agg_name = aggregates.get(dc, default_aggregate(dc, df))
|
||||
col_name = f"{sv}_{col_alias(dc)}"
|
||||
agg_exprs.append(
|
||||
get_polars_agg_expr(dc, agg_name, filter_expr=filter_expr).alias(
|
||||
col_name
|
||||
)
|
||||
)
|
||||
|
||||
for dc in data_columns:
|
||||
agg_name = aggregates.get(dc, default_aggregate(dc, df))
|
||||
agg_exprs.append(get_polars_agg_expr(dc, agg_name).alias(f"__SORT_{dc}__"))
|
||||
|
||||
grouped = df.group_by(group_by, maintain_order=True).agg(agg_exprs)
|
||||
|
||||
for idx in range(n):
|
||||
grouped = grouped.with_columns(
|
||||
pl.col(group_by[idx]).alias(f"__ROW_PATH_{idx}__")
|
||||
)
|
||||
|
||||
for gb_col in group_by:
|
||||
if gb_col in grouped.columns:
|
||||
grouped = grouped.drop(gb_col)
|
||||
|
||||
path_cols = [f"__ROW_PATH_{i}__" for i in range(n)]
|
||||
data_col_names = []
|
||||
for sv in split_values:
|
||||
for dc in data_columns:
|
||||
data_col_names.append(f"{sv}_{col_alias(dc)}")
|
||||
sort_col_names = [f"__SORT_{dc}__" for dc in data_columns]
|
||||
final_order = path_cols + data_col_names + sort_col_names
|
||||
result = grouped.select([c for c in final_order if c in grouped.columns])
|
||||
return result.sort(path_cols)
|
||||
|
||||
|
||||
def apply_sort_grouped(df, sort_config, group_by, col_alias):
|
||||
"""Apply sort to a ROLLUP result DataFrame."""
|
||||
n = len(group_by)
|
||||
|
||||
sort_cols = []
|
||||
sort_desc = []
|
||||
for entry in sort_config:
|
||||
col = entry[0]
|
||||
direction = entry[1]
|
||||
if direction != "none":
|
||||
aliased = col_alias(col)
|
||||
if aliased in df.columns:
|
||||
sort_cols.append(aliased)
|
||||
sort_desc.append(direction in ("desc", "col desc"))
|
||||
|
||||
if not sort_cols:
|
||||
# Default: tree order by row path, nulls first
|
||||
path_cols = [f"__ROW_PATH_{i}__" for i in range(n)]
|
||||
return df.sort(path_cols, descending=[False] * n, nulls_last=False)
|
||||
|
||||
# With explicit sort: grand total first, then rest sorted
|
||||
is_total = pl.lit(True)
|
||||
for i in range(n):
|
||||
is_total = is_total & pl.col(f"__ROW_PATH_{i}__").is_null()
|
||||
|
||||
total_row = df.filter(is_total)
|
||||
rest = df.filter(~is_total)
|
||||
rest = rest.sort(sort_cols, descending=sort_desc)
|
||||
return pl.concat([total_row, rest])
|
||||
|
||||
|
||||
def apply_sort_split_by_flat(df, sort_config, columns, group_by, split_by):
|
||||
"""Apply sort to a flat split_by grouped DataFrame using __SORT__ columns."""
|
||||
data_columns = [c for c in columns if c not in group_by and c not in split_by]
|
||||
sort_cols = []
|
||||
sort_desc = []
|
||||
for entry in sort_config:
|
||||
col = entry[0]
|
||||
direction = entry[1]
|
||||
if direction != "none":
|
||||
sort_name = f"__SORT_{col}__"
|
||||
if sort_name in df.columns:
|
||||
sort_cols.append(sort_name)
|
||||
sort_desc.append(direction in ("desc", "col desc"))
|
||||
|
||||
if sort_cols:
|
||||
df = df.sort(sort_cols, descending=sort_desc)
|
||||
|
||||
drop_cols = [
|
||||
f"__SORT_{dc}__" for dc in data_columns if f"__SORT_{dc}__" in df.columns
|
||||
]
|
||||
if drop_cols:
|
||||
df = df.drop(drop_cols)
|
||||
return df
|
||||
|
||||
|
||||
def apply_sort_flat(df, sort_config, col_alias):
|
||||
"""Apply sort to a flat (non-grouped) DataFrame."""
|
||||
if not sort_config:
|
||||
return df
|
||||
|
||||
sort_cols = []
|
||||
sort_descending = []
|
||||
for sort_entry in sort_config:
|
||||
col = sort_entry[0]
|
||||
direction = sort_entry[1]
|
||||
if direction != "none":
|
||||
aliased = col_alias(col)
|
||||
if aliased in df.columns:
|
||||
sort_cols.append(aliased)
|
||||
sort_descending.append(direction in ("desc", "col desc"))
|
||||
|
||||
if sort_cols:
|
||||
return df.sort(sort_cols, descending=sort_descending)
|
||||
return df
|
||||
|
||||
|
||||
def compute_view_schema(df):
|
||||
"""Compute the Perspective schema for a view DataFrame."""
|
||||
schema = {}
|
||||
for col_name, dtype in df.schema.items():
|
||||
if col_name.startswith("__"):
|
||||
continue
|
||||
schema[col_name] = polars_type_to_psp(dtype)
|
||||
return schema
|
||||
|
||||
|
||||
def build_split_by_grouped(df, group_by, split_by, columns, aggregates, col_alias):
|
||||
"""Build a grouped rollup with split_by (pivot) columns."""
|
||||
n = len(group_by)
|
||||
split_col = split_by[0]
|
||||
data_columns = [c for c in columns if c not in group_by and c not in split_by]
|
||||
split_values = sorted(df[split_col].unique().to_list())
|
||||
|
||||
frames = []
|
||||
for level in range(n + 1):
|
||||
num_groups = n - level
|
||||
active_groups = group_by[:num_groups]
|
||||
|
||||
agg_exprs = []
|
||||
for sv in split_values:
|
||||
filter_expr = pl.col(split_col) == sv
|
||||
for dc in data_columns:
|
||||
agg_name = aggregates.get(dc, default_aggregate(dc, df))
|
||||
col_name = f"{sv}_{col_alias(dc)}"
|
||||
agg_exprs.append(
|
||||
get_polars_agg_expr(dc, agg_name, filter_expr=filter_expr).alias(
|
||||
col_name
|
||||
)
|
||||
)
|
||||
|
||||
if active_groups:
|
||||
grouped = df.group_by(active_groups, maintain_order=True).agg(agg_exprs)
|
||||
else:
|
||||
grouped = df.select(agg_exprs)
|
||||
|
||||
for idx in range(n):
|
||||
if idx < num_groups:
|
||||
grouped = grouped.with_columns(
|
||||
pl.col(group_by[idx]).alias(f"__ROW_PATH_{idx}__")
|
||||
)
|
||||
else:
|
||||
src_dtype = df[group_by[idx]].dtype
|
||||
grouped = grouped.with_columns(
|
||||
pl.lit(None).cast(src_dtype).alias(f"__ROW_PATH_{idx}__")
|
||||
)
|
||||
|
||||
grouping_id = sum(1 << i for i in range(num_groups, n))
|
||||
grouped = grouped.with_columns(
|
||||
pl.lit(grouping_id).cast(pl.Int64).alias("__GROUPING_ID__")
|
||||
)
|
||||
|
||||
for gb_col in active_groups:
|
||||
if gb_col in grouped.columns:
|
||||
grouped = grouped.drop(gb_col)
|
||||
|
||||
frames.append(grouped)
|
||||
|
||||
result = pl.concat(frames, how="diagonal")
|
||||
path_cols = [f"__ROW_PATH_{i}__" for i in range(n)]
|
||||
data_col_names = []
|
||||
for sv in split_values:
|
||||
for dc in data_columns:
|
||||
data_col_names.append(f"{sv}_{col_alias(dc)}")
|
||||
final_order = ["__GROUPING_ID__"] + path_cols + data_col_names
|
||||
result = result.select([c for c in final_order if c in result.columns])
|
||||
return result
|
||||
|
||||
|
||||
def build_split_by_flat(df, split_by, columns, col_alias):
|
||||
"""Build a flat (non-grouped) split_by view."""
|
||||
split_col = split_by[0]
|
||||
data_columns = [c for c in columns if c not in split_by]
|
||||
split_values = sorted(df[split_col].unique().to_list())
|
||||
|
||||
exprs = []
|
||||
for sv in split_values:
|
||||
for dc in data_columns:
|
||||
col_name = f"{sv}_{col_alias(dc)}"
|
||||
exprs.append(
|
||||
pl.when(pl.col(split_col) == sv)
|
||||
.then(pl.col(dc))
|
||||
.otherwise(None)
|
||||
.alias(col_name)
|
||||
)
|
||||
|
||||
return df.select(exprs)
|
||||
|
||||
|
||||
def parse_expression(expr_str):
|
||||
"""Parse a Perspective expression string into a Polars expression."""
|
||||
pattern = r'"([^"]*)"'
|
||||
parts = []
|
||||
last_end = 0
|
||||
for match in re.finditer(pattern, expr_str):
|
||||
parts.append(expr_str[last_end : match.start()])
|
||||
col_name = match.group(1)
|
||||
parts.append(f'pl.col("{col_name}")')
|
||||
last_end = match.end()
|
||||
parts.append(expr_str[last_end:])
|
||||
polars_expr_str = "".join(parts)
|
||||
return eval(polars_expr_str, {"pl": pl, "__builtins__": {}})
|
||||
@@ -0,0 +1,349 @@
|
||||
# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
# ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
# ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
# ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
# ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
# ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
import base64
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import importlib.metadata
|
||||
import inspect
|
||||
|
||||
from string import Template
|
||||
from ipywidgets import DOMWidget
|
||||
from traitlets import Unicode, observe
|
||||
from .viewer import PerspectiveViewer
|
||||
|
||||
__version__ = re.sub(".dev[0-9]+", "", importlib.metadata.version("perspective-python"))
|
||||
|
||||
__all__ = ["PerspectiveWidget"]
|
||||
|
||||
__doc__ = """
|
||||
`PerspectiveWidget` is a JupyterLab widget that implements the same API as
|
||||
`<perspective-viewer>`, allowing for fast, intuitive
|
||||
transformations/visualizations of various data formats within JupyterLab.
|
||||
|
||||
`PerspectiveWidget` is compatible with Jupyterlab 3 and Jupyter Notebook 6 via a
|
||||
[prebuilt extension](https://jupyterlab.readthedocs.io/en/stable/extension/extension_dev.html#prebuilt-extensions).
|
||||
To use it, simply install `perspective-python` and the extensions should be
|
||||
available.
|
||||
|
||||
`perspective-python`'s JupyterLab extension also provides convenient builtin
|
||||
viewers for `csv`, `json`, or `arrow` files. Simply right-click on a file with
|
||||
this extension and choose the appropriate `Perpective` option from the context
|
||||
menu.
|
||||
|
||||
## `PerspectiveWidget`
|
||||
|
||||
Building on top of the API provided by `perspective.Table`, the
|
||||
`PerspectiveWidget` is a JupyterLab plugin that offers the entire functionality
|
||||
of Perspective within the Jupyter environment. It supports the same API
|
||||
semantics of `<perspective-viewer>`, along with the additional data types
|
||||
supported by `perspective.Table`. `PerspectiveWidget` takes keyword arguments
|
||||
for the managed `View`:
|
||||
|
||||
```python
|
||||
from perspective.widget import PerspectiveWidget
|
||||
w = perspective.PerspectiveWidget(
|
||||
data,
|
||||
plugin="X Bar",
|
||||
aggregates={"datetime": "any"},
|
||||
sort=[["date", "desc"]]
|
||||
)
|
||||
```
|
||||
|
||||
### Creating a widget
|
||||
|
||||
A widget is created through the `PerspectiveWidget` constructor, which takes as
|
||||
its first, required parameter a `perspective.Table`, a dataset, a schema, or
|
||||
`None`, which serves as a special value that tells the Widget to defer loading
|
||||
any data until later. In maintaining consistency with the Javascript API,
|
||||
Widgets cannot be created with empty dictionaries or lists—`None` should be used
|
||||
if the intention is to await data for loading later on. A widget can be
|
||||
constructed from a dataset:
|
||||
|
||||
```python
|
||||
from perspective.widget import PerspectiveWidget
|
||||
PerspectiveWidget(data, group_by=["date"])
|
||||
```
|
||||
|
||||
.. or a schema:
|
||||
|
||||
```python
|
||||
PerspectiveWidget({"a": int, "b": str})
|
||||
```
|
||||
|
||||
.. or an instance of a `perspective.Table`:
|
||||
|
||||
```python
|
||||
table = perspective.table(data)
|
||||
PerspectiveWidget(table)
|
||||
```
|
||||
"""
|
||||
|
||||
|
||||
class PerspectiveWidget(DOMWidget, PerspectiveViewer):
|
||||
"""`PerspectiveWidget` allows for Perspective to be used as a Jupyter
|
||||
widget.
|
||||
|
||||
Using `perspective.Table`, you can create a widget that extends the full
|
||||
functionality of `perspective-viewer`. Changes on the viewer can be
|
||||
programatically set on the `PerspectiveWidget` instance.
|
||||
|
||||
# Examples
|
||||
|
||||
>>> from perspective.widget import PerspectiveWidget
|
||||
>>> data = {
|
||||
... "a": [1, 2, 3],
|
||||
... "b": [
|
||||
... "2019/07/11 7:30PM",
|
||||
... "2019/07/11 8:30PM",
|
||||
... "2019/07/11 9:30PM"
|
||||
... ]
|
||||
... }
|
||||
>>> widget = PerspectiveWidget(
|
||||
... data,
|
||||
... group_by=["a"],
|
||||
... sort=[["b", "desc"]],
|
||||
... filter=[["a", ">", 1]]
|
||||
... )
|
||||
>>> widget.sort
|
||||
[["b", "desc"]]
|
||||
>>> widget.sort.append(["a", "asc"])
|
||||
>>> widget.sort
|
||||
[["b", "desc"], ["a", "asc"]]
|
||||
>>> widget.table.update({"a": [4, 5]}) # Browser UI updates
|
||||
"""
|
||||
|
||||
# Required by ipywidgets for proper registration of the backend
|
||||
_model_name = Unicode("PerspectiveModel").tag(sync=True)
|
||||
_model_module = Unicode("@perspective-dev/jupyterlab").tag(sync=True)
|
||||
_model_module_version = Unicode("~{}".format(__version__)).tag(sync=True)
|
||||
_view_name = Unicode("PerspectiveView").tag(sync=True)
|
||||
_view_module = Unicode("@perspective-dev/jupyterlab").tag(sync=True)
|
||||
_view_module_version = Unicode("~{}".format(__version__)).tag(sync=True)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
data,
|
||||
index=None,
|
||||
limit=None,
|
||||
binding_mode="server",
|
||||
**kwargs,
|
||||
):
|
||||
"""Initialize an instance of `PerspectiveWidget`
|
||||
with the given table/data and viewer configuration.
|
||||
|
||||
If an `AsyncTable` is passed in, then certain widget methods like
|
||||
`update()` and `delete()` return coroutines which must be awaited.
|
||||
|
||||
# Arguments
|
||||
|
||||
- `data` (`Table`|`AsyncTable`|`dict`|`list`|`pandas.DataFrame`|`bytes`|`str`): a
|
||||
`perspective.Table` instance, a `perspective.AsyncTable` instance, or
|
||||
a dataset to be loaded in the widget.
|
||||
|
||||
# Keyword Arguments
|
||||
|
||||
- `index` (`str`): A column name to be used as the primary key.
|
||||
Ignored if `server` is True.
|
||||
- `binding_mode` (`str`): "client-server" or "server"
|
||||
- `limit` (`int`): A upper limit on the number of rows in the Table.
|
||||
Cannot be set at the same time as `index`, ignored if `server`
|
||||
is True.
|
||||
- `kwargs` (`dict`): configuration options for the `PerspectiveViewer`,
|
||||
and `Table` constructor if `data` is a dataset.
|
||||
|
||||
# Examples
|
||||
|
||||
>>> widget = PerspectiveWidget(
|
||||
... {"a": [1, 2, 3]},
|
||||
... aggregates={"a": "avg"},
|
||||
... group_by=["a"],
|
||||
... sort=[["b", "desc"]],
|
||||
... filter=[["a", ">", 1]],
|
||||
... expressions=["\"a\" + 100"])
|
||||
"""
|
||||
|
||||
self.binding_mode = binding_mode
|
||||
|
||||
# Pass table load options to the front-end, unless in server mode
|
||||
self._options = {}
|
||||
|
||||
if index is not None and limit is not None:
|
||||
raise TypeError("Index and Limit cannot be set at the same time!")
|
||||
|
||||
# Parse the dataset we pass in - if it's Pandas, preserve pivots
|
||||
# if isinstance(data, pandas.DataFrame) or isinstance(data, pandas.Series):
|
||||
# data, config = deconstruct_pandas(data)
|
||||
|
||||
# if config.get("group_by", None) and "group_by" not in kwargs:
|
||||
# kwargs.update({"group_by": config["group_by"]})
|
||||
|
||||
# if config.get("split_by", None) and "split_by" not in kwargs:
|
||||
# kwargs.update({"split_by": config["split_by"]})
|
||||
|
||||
# if config.get("columns", None) and "columns" not in kwargs:
|
||||
# kwargs.update({"columns": config["columns"]})
|
||||
|
||||
# Initialize the viewer
|
||||
super(PerspectiveWidget, self).__init__(**kwargs)
|
||||
|
||||
# Handle messages from the the front end
|
||||
self.on_msg(self.handle_message)
|
||||
self._sessions = {}
|
||||
|
||||
# If an empty dataset is provided, don't call `load()` and wait
|
||||
# for the user to call `load()`.
|
||||
if data is None:
|
||||
if index is not None or limit is not None:
|
||||
raise TypeError(
|
||||
"Cannot initialize PerspectiveWidget `index` or `limit` without a Table, data, or schema!"
|
||||
)
|
||||
else:
|
||||
if index is not None:
|
||||
self._options.update({"index": index})
|
||||
|
||||
if limit is not None:
|
||||
self._options.update({"limit": limit})
|
||||
|
||||
loading = self.load(data, **self._options)
|
||||
if inspect.isawaitable(loading):
|
||||
import asyncio
|
||||
|
||||
asyncio.create_task(loading)
|
||||
|
||||
def load(self, data, **options):
|
||||
"""Load the widget with data."""
|
||||
# Viewer will ignore **options if `data` is a Table or View.
|
||||
return super(PerspectiveWidget, self).load(data, **options)
|
||||
|
||||
def update(self, data):
|
||||
"""Update the widget with new data."""
|
||||
return super(PerspectiveWidget, self).update(data)
|
||||
|
||||
def clear(self):
|
||||
"""Clears the widget's underlying `Table`."""
|
||||
return super(PerspectiveWidget, self).clear()
|
||||
|
||||
def replace(self, data):
|
||||
"""Replaces the widget's `Table` with new data conforming to the same
|
||||
schema. Does not clear user-set state. If in client mode, serializes
|
||||
the data and sends it to the browser.
|
||||
"""
|
||||
return super(PerspectiveWidget, self).replace(data)
|
||||
|
||||
def delete(self, delete_table=True):
|
||||
"""Delete the Widget's data and clears its internal state.
|
||||
|
||||
# Arguments
|
||||
|
||||
- `delete_table` (`bool`): whether the underlying `Table` will be
|
||||
deleted. Defaults to True.
|
||||
"""
|
||||
ret = super(PerspectiveWidget, self).delete(delete_table)
|
||||
|
||||
# Close the underlying comm and remove widget from the front-end
|
||||
self.close()
|
||||
return ret
|
||||
|
||||
@observe("value")
|
||||
def handle_message(self, widget, content, buffers):
|
||||
"""Given a message from `PerspectiveJupyterClient.send`, process the
|
||||
message and return the result to `self.post`.
|
||||
|
||||
# Arguments
|
||||
|
||||
- `widget`: a reference to the `Widget` instance that received the
|
||||
message.
|
||||
- `content` (dict): - the message from the front-end. Automatically
|
||||
de-serialized by ipywidgets.
|
||||
- `buffers`: optional arraybuffers from the front-end, if any.
|
||||
"""
|
||||
if content["type"] == "connect":
|
||||
client_id = content["client_id"]
|
||||
logging.debug("view {} connected", client_id)
|
||||
|
||||
def send_response(msg):
|
||||
self.send({"type": "binary_msg", "client_id": client_id}, [msg])
|
||||
|
||||
self._sessions[client_id] = self.new_proxy_session(send_response)
|
||||
elif content["type"] == "binary_msg":
|
||||
[binary_msg] = buffers
|
||||
client_id = content["client_id"]
|
||||
session = self._sessions[client_id]
|
||||
if session is not None:
|
||||
import asyncio
|
||||
|
||||
asyncio.create_task(session.handle_request_async(binary_msg))
|
||||
else:
|
||||
logging.error("No session for client_id {}".format(client_id))
|
||||
elif content["type"] == "hangup":
|
||||
# XXX(tom): client won't reliably send this so shouldn't rely on it
|
||||
# to clean up; does jupyter notify us when the client on the
|
||||
# websocket, i.e. the view, disconnects?
|
||||
client_id = content["client_id"]
|
||||
logging.debug("view {} hangup", client_id)
|
||||
session = self._sessions.pop(client_id, None)
|
||||
if session:
|
||||
session.close()
|
||||
|
||||
def _repr_mimebundle_(self, **kwargs):
|
||||
super_bundle = super(DOMWidget, self)._repr_mimebundle_(**kwargs)
|
||||
if not _jupyter_html_export_enabled():
|
||||
return super_bundle
|
||||
|
||||
# Serialize viewer attrs + view data to be rendered in the template
|
||||
viewer_attrs = self.save()
|
||||
data = self.table.view().to_arrow()
|
||||
b64_data = base64.encodebytes(data)
|
||||
template_path = os.path.join(
|
||||
os.path.dirname(__file__), "../templates/exported_widget.html.template"
|
||||
)
|
||||
with open(template_path, "r") as template_data:
|
||||
template = Template(template_data.read())
|
||||
|
||||
def psp_cdn(module, path=None):
|
||||
if path is None:
|
||||
path = f"cdn/{module}.js"
|
||||
|
||||
# perspective developer affordance: works with your local `pnpm run start blocks`
|
||||
# return f"http://localhost:8080/node_modules/@perspective-dev/{module}/dist/{path}"
|
||||
return f"https://cdn.jsdelivr.net/npm/@perspective-dev/{module}@{__version__}/dist/{path}"
|
||||
|
||||
return super(DOMWidget, self)._repr_mimebundle_(**kwargs) | {
|
||||
"text/html": template.substitute(
|
||||
psp_cdn_perspective=psp_cdn("perspective"),
|
||||
psp_cdn_perspective_viewer=psp_cdn("perspective-viewer"),
|
||||
psp_cdn_perspective_viewer_datagrid=psp_cdn(
|
||||
"perspective-viewer-datagrid"
|
||||
),
|
||||
psp_cdn_perspective_viewer_charts=psp_cdn("perspective-viewer-charts"),
|
||||
psp_cdn_perspective_viewer_themes=psp_cdn(
|
||||
"perspective-viewer-themes", "css/themes.css"
|
||||
),
|
||||
viewer_id=self.model_id,
|
||||
viewer_attrs=viewer_attrs,
|
||||
b64_data=b64_data.decode("utf-8"),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
def _jupyter_html_export_enabled():
|
||||
return os.environ.get("PSP_JUPYTER_HTML_EXPORT", None) == "1"
|
||||
|
||||
|
||||
def set_jupyter_html_export(val):
|
||||
"""Enables HTML export for Jupyter widgets, when set to True.
|
||||
HTML export can also be enabled by setting the environment variable
|
||||
`PSP_JUPYTER_HTML_EXPORT` to the string `1`.
|
||||
"""
|
||||
os.environ["PSP_JUPYTER_HTML_EXPORT"] = "1" if val else "0"
|
||||
@@ -0,0 +1,15 @@
|
||||
# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
# ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
# ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
# ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
# ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
# ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
from .viewer import PerspectiveViewer
|
||||
|
||||
__all__ = ["PerspectiveViewer"]
|
||||
@@ -0,0 +1,22 @@
|
||||
# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
# ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
# ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
# ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
# ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
# ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
|
||||
def validate_version(version):
|
||||
# basic semver of form \d+\.\d+\.\d+(\+.+)?
|
||||
spl = version.split(".", 2)
|
||||
return (
|
||||
len(spl) == 3
|
||||
and spl[0].isdigit()
|
||||
and spl[1].isdigit()
|
||||
and (spl[2].split("+")[0]).isdigit()
|
||||
)
|
||||
@@ -0,0 +1,343 @@
|
||||
# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
# ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
# ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
# ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
# ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
# ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
from random import random
|
||||
from .viewer_traitlets import PerspectiveTraitlets
|
||||
import perspective
|
||||
|
||||
# from .. import Server
|
||||
|
||||
# ─ │ ┌ ┬ ┐
|
||||
# ┄ ┆ ├ ┼ ┤ ╲ ╱
|
||||
# ┈ ┊ └ ┴ ┘
|
||||
# ━ ┃ ┏ ┳ ┓ ┏ ┯ ┓ ┏ ┳ ┓ ┏ ┯ ┓
|
||||
# ┅ ┇ ┣ ╋ ┫ ┣ ┿ ┫ ┠ ╂ ┨ ┠ ┼ ┨
|
||||
# ┉ ┋ ┗ ┻ ┛ ┗ ┷ ┛ ┗ ┻ ┛ ┗ ┷ ┛
|
||||
|
||||
# global_server = PySyncServer()
|
||||
|
||||
|
||||
class PerspectiveViewer(PerspectiveTraitlets, object):
|
||||
"""PerspectiveViewer wraps the `perspective.Table` API and exposes an API
|
||||
around creating views, loading data, and updating data.
|
||||
"""
|
||||
|
||||
# Viewer attributes that should be saved in `save()` and restored using
|
||||
# `restore()`. Symmetric to `PERSISTENT_ATTRIBUTES` in `perspective-viewer`.
|
||||
PERSISTENT_ATTRIBUTES = (
|
||||
"group_by",
|
||||
"split_by",
|
||||
"filter",
|
||||
"sort",
|
||||
"aggregates",
|
||||
"columns",
|
||||
"expressions",
|
||||
"plugin",
|
||||
"plugin_config",
|
||||
"theme",
|
||||
"settings",
|
||||
"title",
|
||||
"version",
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
plugin="Datagrid",
|
||||
columns=None,
|
||||
group_by=None,
|
||||
split_by=None,
|
||||
aggregates=None,
|
||||
sort=None,
|
||||
filter=None,
|
||||
expressions=None,
|
||||
plugin_config=None,
|
||||
settings=True,
|
||||
theme=None,
|
||||
title=None,
|
||||
# ignored, here for restore compatibility
|
||||
version=None,
|
||||
):
|
||||
"""Initialize an instance of `PerspectiveViewer` with the given viewer
|
||||
configuration. Do not pass a `Table` or data into the constructor -
|
||||
use the :func:`load()` method to provide the viewer with data.
|
||||
|
||||
Keyword Arguments:
|
||||
columns (:obj:`list` of :obj:`str`): A list of column names to be
|
||||
visible to the user.
|
||||
group_by (:obj:`list` of :obj:`str`): A list of column names to
|
||||
use as group by.
|
||||
split_by (:obj:`list` of :obj:`str`): A list of column names
|
||||
to use as split by.
|
||||
aggregates (:obj:`dict` of :obj:`str` to :obj:`str`): A dictionary
|
||||
of column names to aggregate types, which specify aggregates
|
||||
for individual columns.
|
||||
sort (:obj:`list` of :obj:`list` of :obj:`str`): A list of lists,
|
||||
each list containing a column name and a sort direction
|
||||
(``asc``, ``desc``, ``asc abs``, ``desc abs``, ``col asc``,
|
||||
``col desc``, ``col asc abs``, ``col desc abs``).
|
||||
filter (:obj:`list` of :obj:`list` of :obj:`str`): A list of lists,
|
||||
each list containing a column name, a filter comparator, and a
|
||||
value to filter by.
|
||||
expressions (:obj:`list` of :obj:`str`): A list of string
|
||||
expressions which are applied to the view.
|
||||
plugin (:obj:`str`/:obj:`perspective.Plugin`): Which plugin to
|
||||
select by default.
|
||||
plugin_config (:obj:`dict`): A configuration for the plugin, i.e.
|
||||
the datagrid plugin or a chart plugin.
|
||||
settings(:obj:`bool`): Whether the perspective query settings
|
||||
panel should be open.
|
||||
theme (:obj:`str`): The color theme to use.
|
||||
version (:obj:`str`): The version this configuration is restored from.
|
||||
This should only be used when restoring a configuration,
|
||||
and should not be set manually.
|
||||
|
||||
Examples:
|
||||
>>> viewer = PerspectiveViewer(
|
||||
... aggregates={"a": "avg"},
|
||||
... group_by=["a"],
|
||||
... sort=[["b", "desc"]],
|
||||
... filter=[["a", ">", 1]],
|
||||
... expressions=["\"a\" + 100"]
|
||||
... )
|
||||
"""
|
||||
|
||||
# The Table under management by this viewer and its
|
||||
# attached PerspectiveManager
|
||||
self._table = None
|
||||
self._client = None
|
||||
|
||||
# Viewer configuration
|
||||
self.plugin = plugin # validate_plugin(plugin)
|
||||
self.columns = columns or [] # validate_columns(columns) or []
|
||||
self.group_by = group_by or [] # validate_group_by(group_by) or []
|
||||
self.split_by = split_by or [] # validate_split_by(split_by) or []
|
||||
self.aggregates = aggregates or {} # validate_aggregates(aggregates) or {}
|
||||
self.sort = sort or [] # validate_sort(sort) or []
|
||||
self.filter = filter or [] # validate_filter(filter) or []
|
||||
self.expressions = expressions or {} # validate_expressions(expressions) or {}
|
||||
self.plugin_config = (
|
||||
plugin_config or {}
|
||||
) # validate_plugin_config(plugin_config) or {}
|
||||
self.settings = settings
|
||||
self.theme = theme
|
||||
self.title = title
|
||||
|
||||
def new_proxy_session(self, cb):
|
||||
return perspective.ProxySession(self._client, cb)
|
||||
|
||||
@property
|
||||
def client(self):
|
||||
"""Returns the ``perspective.Client`` or ``perspective.AsyncClient`` under management by the viewer."""
|
||||
return self._client
|
||||
|
||||
@property
|
||||
def table(self):
|
||||
"""Returns the ``perspective.Table`` or ``perspective.AsyncTable`` under management by the viewer."""
|
||||
return self._table
|
||||
|
||||
def is_async(self):
|
||||
"""Returns whether this widget has an async interface or synchronous"""
|
||||
return isinstance(self._table, perspective.perspective.AsyncTable)
|
||||
|
||||
def load(self, data, **options):
|
||||
"""Given a ``perspective.Table``, a ``perspective.AsyncTable``,
|
||||
or data that can be handled by ``perspective.Table``, pass it to the
|
||||
viewer. Like `__init__`, load accepts a `perspective.Table`, a dataset,
|
||||
or a schema.
|
||||
|
||||
``load()`` resets the state of the viewer: if a ``perspective.Table``
|
||||
has already been loaded, ``**options`` is ignored as the options
|
||||
already set on the ``Table`` take precedence.
|
||||
|
||||
If data is passed in, a ``perspective.Table`` is automatically created
|
||||
by this method, and the options passed to ``**config`` are extended to
|
||||
the new Table. If the widget already has a dataset, and the new data
|
||||
has different columns to the old one, then the widget state (pivots,
|
||||
sort, etc.) is cleared to prevent applying settings on columns that
|
||||
don't exist.
|
||||
|
||||
When a ``perspective.AsyncTable`` is loaded, the widget's interface
|
||||
becomes async. Methods which operate on the underlying Perspective
|
||||
view, inclusive of the ``load()`` call itself, return coroutine values
|
||||
which must be awaited.
|
||||
|
||||
Loading a ``perspective.Table`` or plain data will make the interface
|
||||
synchronous again.
|
||||
|
||||
Args:
|
||||
data (:obj:`Table`|:obj:`AsyncTable`|:obj:`dict`|:obj:`list`|:obj:`pandas.DataFrame`|:obj:`bytes`|:obj:`str`): a
|
||||
`perspective.Table` instance, a `perspective.AsyncTable`
|
||||
instance, or a dataset to be loaded in the viewer.
|
||||
|
||||
Keyword Arguments:
|
||||
name (:obj:`str`): An optional name to reference the table by so it can
|
||||
be accessed from the front-end. If not provided, a name will
|
||||
be generated.
|
||||
index (:obj:`str`): A column name to be used as the primary key.
|
||||
Ignored if a ``Table`` or ``AsyncTable`` is supplied.
|
||||
limit (:obj:`int`): A upper limit on the number of rows in the Table.
|
||||
Cannot be set at the same time as `index`. Ignored if a
|
||||
``Table`` or ``AsyncTable`` is supplied.
|
||||
|
||||
Returns:
|
||||
coro (:obj:`coroutine`): when `AsyncTable` is passed, the `load()` call must be awaited
|
||||
"""
|
||||
name = options.pop("name", str(random()))
|
||||
|
||||
# Reset the viewer when `load()` is called multiple times.
|
||||
if self.table is not None:
|
||||
self.reset()
|
||||
|
||||
if isinstance(data, perspective.perspective.AsyncTable):
|
||||
self._table = data
|
||||
async def load_table():
|
||||
self._client = await self.table.get_client()
|
||||
self.table_name = self.table.get_name()
|
||||
# If the user does not set columns to show, synchronize viewer state
|
||||
# with dataset.
|
||||
if len(self.columns) == 0:
|
||||
self.columns = await self.table.columns()
|
||||
|
||||
return load_table()
|
||||
elif isinstance(data, perspective.perspective.Table):
|
||||
self._table = data
|
||||
self._client = data.get_client()
|
||||
elif isinstance(data, perspective.perspective.View) or isinstance(data, perspective.perspective.AsyncView):
|
||||
raise TypeError(
|
||||
"Views cannot be loaded directly, load a table or raw data instead"
|
||||
)
|
||||
else:
|
||||
self._table = perspective.table(data, name=name, **options)
|
||||
self._client = perspective.GLOBAL_CLIENT
|
||||
|
||||
# If the user does not set columns to show, synchronize viewer state
|
||||
# with dataset.
|
||||
if len(self.columns) == 0:
|
||||
self.columns = self.table.columns()
|
||||
|
||||
self.table_name = self.table.get_name()
|
||||
|
||||
def update(self, data):
|
||||
"""Update the table under management by the viewer with new data.
|
||||
This function follows the semantics of `Table.update()`, and will be
|
||||
affected by whether an index is set on the underlying table.
|
||||
|
||||
When this widget has loaded an ``AsyncTable``, returns a coroutine
|
||||
which must be awaited.
|
||||
|
||||
Args:
|
||||
data (:obj:`dict`|:obj:`list`|:obj:`pandas.DataFrame`): the
|
||||
update data for the table.
|
||||
|
||||
Returns:
|
||||
coro (:obj:`coroutine`): when async, must be awaited
|
||||
"""
|
||||
return self.table.update(data)
|
||||
|
||||
def clear(self):
|
||||
"""Clears the rows of this viewer's ``Table``."""
|
||||
if self.table is not None:
|
||||
return self.table.clear()
|
||||
|
||||
def replace(self, data):
|
||||
"""Replaces the rows of this viewer's `Table` with new data.
|
||||
|
||||
Args:
|
||||
data (:obj:`dict`|:obj:`list`|:obj:`pandas.DataFrame`): new data
|
||||
to set into the table - must conform to the table's schema.
|
||||
|
||||
Returns:
|
||||
coro (:obj:`coroutine`): when async, must be awaited
|
||||
"""
|
||||
if self.table is not None:
|
||||
return self.table.replace(data)
|
||||
|
||||
def save(self):
|
||||
"""Get the viewer's attributes as a dictionary, symmetric with `restore`
|
||||
so that a viewer's configuration can be reproduced."""
|
||||
return {
|
||||
attr: getattr(self, attr)
|
||||
for attr in PerspectiveViewer.PERSISTENT_ATTRIBUTES
|
||||
}
|
||||
|
||||
def restore(self, **kwargs):
|
||||
"""Restore a given set of attributes, passed as kwargs
|
||||
(e.g. dictionary). Symmetric with `save` so that a given viewer's
|
||||
configuration can be reproduced."""
|
||||
for k, v in kwargs.items():
|
||||
if k in PerspectiveViewer.PERSISTENT_ATTRIBUTES:
|
||||
setattr(self, k, v)
|
||||
|
||||
def to_kwargs(self):
|
||||
"""Get the viewer's attributes as a list of kwargs, which can be passed to
|
||||
the viewer constructor"""
|
||||
attrs = self.save()
|
||||
defaults = {
|
||||
"columns": [],
|
||||
"group_by": [],
|
||||
"split_by": [],
|
||||
"aggregates": {},
|
||||
"sort": [],
|
||||
"filter": [],
|
||||
"expressions": {},
|
||||
"plugin_config": {},
|
||||
"version": "2.10.0",
|
||||
}
|
||||
kwargs = {}
|
||||
for key, default in defaults.items():
|
||||
if attrs.get(key) != default:
|
||||
kwargs[key] = attrs[key]
|
||||
return ", ".join(
|
||||
["{}={}".format(attr, repr(val)) for attr, val in kwargs.items()]
|
||||
)
|
||||
|
||||
def reset(self):
|
||||
"""Resets the viewer's attributes and state, but does not delete or
|
||||
modify the underlying `Table`.
|
||||
|
||||
Example:
|
||||
widget = PerspectiveWidget(data, group_by=["date"], plugin=Plugin.XBAR)
|
||||
widget.reset()
|
||||
widget.plugin #
|
||||
"""
|
||||
self.group_by = []
|
||||
self.split_by = []
|
||||
self.filter = []
|
||||
self.sort = []
|
||||
self.expressions = {}
|
||||
self.aggregates = {}
|
||||
self.columns = []
|
||||
self.plugin = "Datagrid"
|
||||
self.plugin_config = {}
|
||||
|
||||
def delete(self, delete_table=True):
|
||||
"""Delete the Viewer's data and clears its internal state. If
|
||||
``delete_table`` is True, the underlying `perspective.Table` and the
|
||||
internal `View` object will be deleted.
|
||||
|
||||
Args:
|
||||
delete_table (:obj:`bool`) : whether the underlying `Table` will be
|
||||
deleted. Defaults to True.
|
||||
|
||||
Returns:
|
||||
coro (:obj:`coroutine`): when async and `delete_table` is `True`,
|
||||
must be awaited
|
||||
"""
|
||||
ret = None
|
||||
if delete_table:
|
||||
# Delete table
|
||||
ret = self.table.delete()
|
||||
self.table_name = None
|
||||
self._table = None
|
||||
|
||||
self.reset()
|
||||
return ret
|
||||
@@ -0,0 +1,101 @@
|
||||
# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
# ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
||||
# ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
||||
# ┃ This file is part of the Perspective library, distributed under the terms ┃
|
||||
# ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
||||
# ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
from traitlets import HasTraits, Unicode, List, Bool, Dict, validate, Enum
|
||||
|
||||
import importlib.metadata
|
||||
|
||||
__version__ = importlib.metadata.version("perspective-python")
|
||||
|
||||
from .validate import (
|
||||
validate_version,
|
||||
)
|
||||
|
||||
|
||||
class PerspectiveTraitlets(HasTraits):
|
||||
"""Define the traitlet interface with `PerspectiveJupyterWidget` on the
|
||||
front end. Attributes which are set here are synchronized between the
|
||||
front-end and back-end.
|
||||
|
||||
Examples:
|
||||
>>> widget = perspective.PerspectiveWidget(
|
||||
... data, group_by=["a", "b", "c"])
|
||||
PerspectiveWidget(group_by=["a", "b", "c"])
|
||||
>>> widget.split_by=["b"]
|
||||
>>> widget
|
||||
PerspectiveWidget(group_by=["a", "b", "c"], split_by=["b"])
|
||||
"""
|
||||
|
||||
# `perspective-viewer` options
|
||||
plugin = Unicode("Datagrid").tag(sync=True)
|
||||
columns = List(default_value=[]).tag(sync=True)
|
||||
group_by = List(trait=Unicode(), default_value=[]).tag(sync=True, o=True)
|
||||
split_by = List(trait=Unicode(), default_value=[]).tag(sync=True)
|
||||
aggregates = Dict(default_value={}).tag(sync=True)
|
||||
sort = List(default_value=[]).tag(sync=True)
|
||||
filter = List(default_value=[]).tag(sync=True)
|
||||
expressions = Dict(default_value=[]).tag(sync=True)
|
||||
plugin_config = Dict(default_value={}).tag(sync=True)
|
||||
settings = Bool(True).tag(sync=True)
|
||||
theme = Unicode("Pro Light", allow_none=True).tag(sync=True)
|
||||
|
||||
# used to tell the frontend which table to connect to
|
||||
table_name = Unicode(None, allow_none=True).tag(sync=True)
|
||||
|
||||
server = Bool(False).tag(sync=True)
|
||||
binding_mode = Enum(("server", "client-server")).tag(default="server", sync=True)
|
||||
title = Unicode(None, allow_none=True).tag(sync=True)
|
||||
version = Unicode(__version__).tag(sync=True)
|
||||
|
||||
# @validate("plugin")
|
||||
# def _validate_plugin(self, proposal):
|
||||
# return validate_plugin(proposal.value)
|
||||
|
||||
# @validate("columns")
|
||||
# def _validate_columns(self, proposal):
|
||||
# return validate_columns(proposal.value)
|
||||
|
||||
# @validate("group_by")
|
||||
# def _validate_group_by(self, proposal):
|
||||
# return validate_group_by(proposal.value)
|
||||
|
||||
# @validate("split_by")
|
||||
# def _validate_split_by(self, proposal):
|
||||
# return validate_split_by(proposal.value)
|
||||
|
||||
# @validate("aggregates")
|
||||
# def _validate_aggregates(self, proposal):
|
||||
# return validate_aggregates(proposal.value)
|
||||
|
||||
# @validate("sort")
|
||||
# def _validate_sort(self, proposal):
|
||||
# return validate_sort(proposal.value)
|
||||
|
||||
# @validate("filter")
|
||||
# def _validate_filter(self, proposal):
|
||||
# return validate_filter(proposal.value)
|
||||
|
||||
# @validate("expressions")
|
||||
# def _validate_expressions(self, proposal):
|
||||
# return validate_expressions(proposal.value)
|
||||
|
||||
# @validate("plugin_config")
|
||||
# def _validate_plugin_config(self, proposal):
|
||||
# return validate_plugin_config(proposal.value)
|
||||
|
||||
# @validate("title")
|
||||
# def _validate_title(self, proposal):
|
||||
# return validate_title(proposal.value)
|
||||
|
||||
@validate("version")
|
||||
def _validate_version(self, proposal):
|
||||
return validate_version(proposal.value)
|
||||
Reference in New Issue
Block a user