tensorwatch-api 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tensorwatch_api/__init__.py +11 -0
- tensorwatch_api/twapi.py +191 -0
- tensorwatch_api-0.1.0.dist-info/METADATA +141 -0
- tensorwatch_api-0.1.0.dist-info/RECORD +7 -0
- tensorwatch_api-0.1.0.dist-info/WHEEL +5 -0
- tensorwatch_api-0.1.0.dist-info/licenses/LICENSE.txt +21 -0
- tensorwatch_api-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import warnings
|
|
2
|
+
import logging
|
|
3
|
+
|
|
4
|
+
warnings.filterwarnings("ignore", category=UserWarning, module="pykafka")
|
|
5
|
+
warnings.filterwarnings("ignore", category=UserWarning, module='pkg_resources')
|
|
6
|
+
warnings.filterwarnings("ignore",message=".*cache_frame_data.*",category=UserWarning )
|
|
7
|
+
|
|
8
|
+
# Suppress "No partitions assigned" warnings from pykafka.balancedconsumer, which can be noisy during rebalancing.
|
|
9
|
+
logging.getLogger('pykafka.balancedconsumer').setLevel(logging.ERROR)
|
|
10
|
+
|
|
11
|
+
from .twapi import twapi
|
tensorwatch_api/twapi.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import tensorwatchext as tw
|
|
2
|
+
from tensorwatchext import kafka_connector as kc
|
|
3
|
+
from tensorwatchext import pykafka_connector as pyc
|
|
4
|
+
from IPython.display import display
|
|
5
|
+
from ipywidgets import widgets
|
|
6
|
+
import time
|
|
7
|
+
import logging
|
|
8
|
+
import matplotlib.pyplot as plt
|
|
9
|
+
|
|
10
|
+
class twapi:
|
|
11
|
+
"""TensorWatch API Wrapper for Kafka Streaming and Visualization"""
|
|
12
|
+
|
|
13
|
+
def __init__(self):
|
|
14
|
+
"""Initializes the twapi class, setting up the UI widgets and event handlers."""
|
|
15
|
+
self.default_value = 10
|
|
16
|
+
self.visualizer = None # Initialize visualizer as None
|
|
17
|
+
self.client = tw.WatcherClient()
|
|
18
|
+
self.out = widgets.Output(layout={})
|
|
19
|
+
|
|
20
|
+
# Initialize UI widgets
|
|
21
|
+
self.update_interval = 0.5 # Delay in seconds
|
|
22
|
+
self.my_slider = widgets.IntSlider(value=self.default_value, min=1, max=100, step=1, description="Window Size:")
|
|
23
|
+
self.my_slider2 = widgets.IntSlider(value=self.default_value, min=1, max=100, step=1, description="Window Width:")
|
|
24
|
+
self.datebutton = widgets.Checkbox(value=False, description="Date")
|
|
25
|
+
self.offsetbutton = widgets.Checkbox(value=False, description="Use Offset")
|
|
26
|
+
self.dimhistorybutton = widgets.Checkbox(value=True, description="Dim History")
|
|
27
|
+
self.colorpicker = widgets.ColorPicker(value="blue", description="Pick a Color")
|
|
28
|
+
|
|
29
|
+
self.button_reset = widgets.Button(description="Reset", tooltip="Reset stream settings")
|
|
30
|
+
self.button_apply = widgets.Button(description="Please wait", tooltip="Apply changes to the visualization", disabled=True)
|
|
31
|
+
|
|
32
|
+
# Group widgets for a cleaner UI
|
|
33
|
+
left_box = widgets.VBox([self.my_slider, self.my_slider2, self.colorpicker])
|
|
34
|
+
right_box = widgets.VBox([self.offsetbutton, self.dimhistorybutton, self.datebutton])
|
|
35
|
+
self.options_box = widgets.HBox([left_box, right_box])
|
|
36
|
+
self.accordion = widgets.Accordion(children=[self.options_box])
|
|
37
|
+
self.accordion.set_title(0, 'Visualization Options')
|
|
38
|
+
|
|
39
|
+
# Event handlers
|
|
40
|
+
self._last_update = time.time()
|
|
41
|
+
self.button_reset.on_click(self.reset)
|
|
42
|
+
self.button_apply.on_click(self.apply_with_debounce)
|
|
43
|
+
self.metrics_label = widgets.Label(value="")
|
|
44
|
+
|
|
45
|
+
# Observe widget changes directly
|
|
46
|
+
self.my_slider.observe(self.apply_with_debounce, names='value')
|
|
47
|
+
self.my_slider2.observe(self.apply_with_debounce, names='value')
|
|
48
|
+
self.colorpicker.observe(self.apply_with_debounce, names='value')
|
|
49
|
+
|
|
50
|
+
def stream(self, expr):
|
|
51
|
+
"""Creates a TensorWatch stream from an expression."""
|
|
52
|
+
self.expr = expr
|
|
53
|
+
try:
|
|
54
|
+
self.streamdata = self.client.create_stream(expr=expr)
|
|
55
|
+
logging.debug("Stream created successfully")
|
|
56
|
+
except Exception as e:
|
|
57
|
+
logging.error(f"Error creating stream: {e}")
|
|
58
|
+
print(f"Error creating stream: {e}")
|
|
59
|
+
return self
|
|
60
|
+
|
|
61
|
+
def apply_with_debounce(self, _=None):
|
|
62
|
+
"""Debounced apply function to prevent too frequent updates."""
|
|
63
|
+
now = time.time()
|
|
64
|
+
if now - self._last_update > self.update_interval:
|
|
65
|
+
self.update_visualizer()
|
|
66
|
+
self._last_update = now
|
|
67
|
+
if self.button_apply.description == "Start":
|
|
68
|
+
self.button_apply.description = "Apply Changes"
|
|
69
|
+
|
|
70
|
+
def update_visualizer(self, _=None):
|
|
71
|
+
"""Updates the TensorWatch visualizer with the latest widget values."""
|
|
72
|
+
if not hasattr(self, 'streamdata') or not self.streamdata:
|
|
73
|
+
self.out.clear_output(wait=True)
|
|
74
|
+
with self.out:
|
|
75
|
+
print("Stream data not available or empty yet. Please wait for data.")
|
|
76
|
+
return
|
|
77
|
+
|
|
78
|
+
try:
|
|
79
|
+
# Always clear output before drawing
|
|
80
|
+
self.out.clear_output(wait=True)
|
|
81
|
+
|
|
82
|
+
# Close previous visualizer if it exists to free resources
|
|
83
|
+
if self.visualizer:
|
|
84
|
+
# self.visualizer.close()
|
|
85
|
+
try:
|
|
86
|
+
plt.pause(0.05)
|
|
87
|
+
plt.close('all') # Also close any lingering matplotlib figures
|
|
88
|
+
except Exception:
|
|
89
|
+
pass
|
|
90
|
+
# Create a new visualizer with the current settings
|
|
91
|
+
self.visualizer = tw.Visualizer(
|
|
92
|
+
self.streamdata,
|
|
93
|
+
vis_type="line",
|
|
94
|
+
window_width=self.my_slider2.value,
|
|
95
|
+
window_size=self.my_slider.value,
|
|
96
|
+
Date=self.datebutton.value,
|
|
97
|
+
useOffset=self.offsetbutton.value,
|
|
98
|
+
dim_history=self.dimhistorybutton.value,
|
|
99
|
+
color=self.colorpicker.value,
|
|
100
|
+
)
|
|
101
|
+
with self.out:
|
|
102
|
+
self.visualizer.show()
|
|
103
|
+
|
|
104
|
+
except Exception as e:
|
|
105
|
+
self.out.clear_output(wait=True)
|
|
106
|
+
with self.out:
|
|
107
|
+
print(f"Error updating visualizer: {e}")
|
|
108
|
+
|
|
109
|
+
def enable_apply_button(self):
|
|
110
|
+
"""Enables the apply button and changes its description to 'Start'."""
|
|
111
|
+
logging.debug("Enabling apply button.")
|
|
112
|
+
self.button_apply.disabled = False
|
|
113
|
+
self.button_apply.description = "Start"
|
|
114
|
+
|
|
115
|
+
def reset(self, _=None):
|
|
116
|
+
"""Resets all widget values to their defaults and clears the visualization."""
|
|
117
|
+
self.my_slider.value = self.default_value
|
|
118
|
+
self.my_slider2.value = self.default_value
|
|
119
|
+
self.datebutton.value = False
|
|
120
|
+
self.offsetbutton.value = False
|
|
121
|
+
self.dimhistorybutton.value = True
|
|
122
|
+
self.colorpicker.value = "blue"
|
|
123
|
+
|
|
124
|
+
# Clear the output and close the visualizer
|
|
125
|
+
self.out.clear_output()
|
|
126
|
+
plt.close('all')
|
|
127
|
+
self.visualizer = None
|
|
128
|
+
|
|
129
|
+
def draw(self):
|
|
130
|
+
"""Displays the UI for controlling the visualization."""
|
|
131
|
+
ui = widgets.VBox([
|
|
132
|
+
widgets.HBox([self.button_reset, self.button_apply]),
|
|
133
|
+
self.accordion,
|
|
134
|
+
self.out
|
|
135
|
+
])
|
|
136
|
+
display(ui)
|
|
137
|
+
|
|
138
|
+
def draw_with_metrics(self):
|
|
139
|
+
"""Displays the UI for controlling the visualization with a metrics label."""
|
|
140
|
+
ui = widgets.VBox([
|
|
141
|
+
self.metrics_label,
|
|
142
|
+
widgets.HBox([self.button_reset, self.button_apply]),
|
|
143
|
+
self.accordion,
|
|
144
|
+
self.out
|
|
145
|
+
])
|
|
146
|
+
display(ui)
|
|
147
|
+
|
|
148
|
+
def update_metrics(self, metrics):
|
|
149
|
+
"""Updates the metrics label with the provided text."""
|
|
150
|
+
self.metrics_label.value = metrics
|
|
151
|
+
|
|
152
|
+
def connector(self, topic, host, parsetype="json", cluster_size=1, conn_type="kafka", queue_length=50000,
|
|
153
|
+
group_id="mygroup", schema_path=None, protobuf_message=None, parser_extra=None,
|
|
154
|
+
random_sampling=None, countmin_width=None, countmin_depth=None, ordering_field=None):
|
|
155
|
+
"""
|
|
156
|
+
Creates and returns a Kafka or PyKafka connector.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
topic (str): The Kafka topic to consume from.
|
|
160
|
+
host (str): The Kafka broker host.
|
|
161
|
+
parsetype (str): The message format (e.g., 'json', 'pickle', 'avro').
|
|
162
|
+
cluster_size (int): The number of consumer threads.
|
|
163
|
+
conn_type (str): The type of connector to use ('kafka' or 'pykafka').
|
|
164
|
+
queue_length (int): The maximum size of the message queue.
|
|
165
|
+
group_id (str): The Kafka consumer group ID.
|
|
166
|
+
schema_path (str): The path to the schema file.
|
|
167
|
+
protobuf_message (str): The name of the Protobuf message class.
|
|
168
|
+
parser_extra (str): Extra data for the parser (e.g., Avro schema for 'pykafka').
|
|
169
|
+
random_sampling (int): The percentage of messages to sample.
|
|
170
|
+
countmin_width (int): The width of the Count-Min Sketch.
|
|
171
|
+
countmin_depth (int): The depth of the Count-Min Sketch.
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
A KafkaConnector or pykafka_connector instance.
|
|
175
|
+
"""
|
|
176
|
+
if conn_type == "kafka":
|
|
177
|
+
return kc(
|
|
178
|
+
topic=topic, hosts=host, parsetype=parsetype, cluster_size=cluster_size, queue_length=queue_length, group_id=group_id,
|
|
179
|
+
schema_path=schema_path, protobuf_message=protobuf_message,parser_extra=parser_extra,
|
|
180
|
+
random_sampling=random_sampling, countmin_width=countmin_width,ordering_field=ordering_field,
|
|
181
|
+
countmin_depth=countmin_depth,
|
|
182
|
+
twapi_instance=self)
|
|
183
|
+
elif conn_type == "pykafka":
|
|
184
|
+
return pyc(
|
|
185
|
+
topic=topic, hosts=host, parsetype=parsetype, cluster_size=cluster_size,twapi_instance=self,
|
|
186
|
+
queue_length=queue_length, consumer_group=bytes(group_id, 'utf-8'),
|
|
187
|
+
parser_extra=parser_extra, schema_path=schema_path, protobuf_message=protobuf_message,
|
|
188
|
+
random_sampling=random_sampling, countmin_width=countmin_width,ordering_field=ordering_field,
|
|
189
|
+
countmin_depth=countmin_depth)
|
|
190
|
+
else:
|
|
191
|
+
raise ValueError("Invalid connector type. Choose 'kafka' or 'pykafka'.")
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tensorwatch-api
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Interactive Kafka stream visualization API for Jupyter using TensorWatch.
|
|
5
|
+
Home-page: https://github.com/costasrevi/jupyterstreamvis
|
|
6
|
+
Author: Konstantinos Revythis
|
|
7
|
+
Author-email: Konstantinos Revythis <krevythis@tuc.gr>
|
|
8
|
+
License: MIT
|
|
9
|
+
Project-URL: Homepage, https://github.com/costasrevi/jupyterstreamvis
|
|
10
|
+
Project-URL: Source, https://github.com/costasrevi/jupyterstreamvis
|
|
11
|
+
Keywords: kafka,tensorwatch,jupyter,streaming,visualization
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Development Status :: 4 - Beta
|
|
16
|
+
Classifier: Intended Audience :: Developers
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
18
|
+
Requires-Python: >=3.9
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE.txt
|
|
21
|
+
Requires-Dist: tensorwatch
|
|
22
|
+
Requires-Dist: ipywidgets
|
|
23
|
+
Requires-Dist: matplotlib
|
|
24
|
+
Requires-Dist: confluent-kafka; extra == "kafka"
|
|
25
|
+
Requires-Dist: pykafka; extra == "pykafka"
|
|
26
|
+
Dynamic: author
|
|
27
|
+
Dynamic: home-page
|
|
28
|
+
Dynamic: license-file
|
|
29
|
+
Dynamic: requires-python
|
|
30
|
+
|
|
31
|
+
# TensorWatch API (`tensorwatch-api`)
|
|
32
|
+
|
|
33
|
+
`tensorwatch-api` is a Python library designed to simplify **real-time streaming data visualization from Kafka** directly inside Jupyter notebooks.
|
|
34
|
+
It wraps **TensorWatch** and provides an interactive, high-level API with **ipywidgets** for controlling visualization parameters on the fly.
|
|
35
|
+
|
|
36
|
+
This package is ideal for data scientists, engineers, and analysts who want to inspect, monitor, and visualize streaming data **without leaving JupyterLab**.
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## Features
|
|
41
|
+
|
|
42
|
+
- **Interactive UI**: Control plot parameters like window size, colors, and history in real-time.
|
|
43
|
+
- **Chainable API**: Fluent API for connecting to Kafka, defining streams, and drawing visualizations.
|
|
44
|
+
- **Dual Kafka Backends**: Supports `confluent-kafka` (default) and `pykafka`.
|
|
45
|
+
- **Live Metrics**: Display real-time throughput and latency metrics alongside visualizations.
|
|
46
|
+
- **Custom Processing**: Apply Python lambda expressions for aggregation or preprocessing.
|
|
47
|
+
- **Efficient Updates**: Debouncing ensures smooth UI updates without overloading the kernel.
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
## Installation
|
|
52
|
+
|
|
53
|
+
Install the package and its dependencies:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
pip install tensorwatch-api
|
|
57
|
+
pip install tensorwatch ipywidgets matplotlib
|
|
58
|
+
```
|
|
59
|
+
Install a Kafka client:
|
|
60
|
+
```bash
|
|
61
|
+
# Default Kafka connector
|
|
62
|
+
pip install confluent-kafka
|
|
63
|
+
|
|
64
|
+
# Or pykafka connector
|
|
65
|
+
pip install pykafka
|
|
66
|
+
```
|
|
67
|
+
##Quick Start
|
|
68
|
+
# Enable interactive matplotlib backend
|
|
69
|
+
%matplotlib widget
|
|
70
|
+
|
|
71
|
+
# Import the library
|
|
72
|
+
from tensorwatch_api import twapi as tw
|
|
73
|
+
|
|
74
|
+
# Initialize the API
|
|
75
|
+
test = tw()
|
|
76
|
+
|
|
77
|
+
# Create a connector to a Kafka topic
|
|
78
|
+
test.connector(
|
|
79
|
+
topic='mytopic',
|
|
80
|
+
host='localhost:9092',
|
|
81
|
+
cluster_size=5,
|
|
82
|
+
queue_length=100000,
|
|
83
|
+
parsetype="protobuf", # optional: json, pickle, avro, protobuf
|
|
84
|
+
parser_extra="benchmark_pb2", # module name for protobuf
|
|
85
|
+
protobuf_message="BenchmarkMessage",# class name for protobuf
|
|
86
|
+
schema_path=r"C:\path\to\protobuf" # path to protobuf module
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
# Define the stream processing logic
|
|
90
|
+
test.stream(expr='lambda d: sum(msg["seq"] for msg in d.data) if d.data else 0')
|
|
91
|
+
|
|
92
|
+
# Draw the UI with metrics
|
|
93
|
+
test.draw_with_metrics()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
## API Reference
|
|
97
|
+
|
|
98
|
+
### `twapi()`
|
|
99
|
+
Initializes the API wrapper and UI components.
|
|
100
|
+
|
|
101
|
+
### `.connector(...)`
|
|
102
|
+
Creates a Kafka consumer in a background thread, fetching data and reporting metrics.
|
|
103
|
+
|
|
104
|
+
**Arguments:**
|
|
105
|
+
|
|
106
|
+
- `topic` (str): Kafka topic to consume.
|
|
107
|
+
- `host` (str): Broker host (`localhost:9092`).
|
|
108
|
+
- `conn_type` (str): Connector type (`'kafka'` or `'pykafka'`). Default is `'kafka'`.
|
|
109
|
+
- `parsetype` (str): Message format (`'json'`, `'pickle'`, `'avro'`, `'protobuf'`). Default `'json'`.
|
|
110
|
+
- `cluster_size` (int): Number of consumer threads. Default 1.
|
|
111
|
+
- `queue_length` (int): Max messages in memory. Default 50000.
|
|
112
|
+
- Other parameters: `schema_path`, `protobuf_message`, `parser_extra`, `random_sampling`, `countmin_width`, `countmin_depth`, `ordering_field`.
|
|
113
|
+
|
|
114
|
+
### `.stream(expr)`
|
|
115
|
+
Defines the data processing logic for the stream.
|
|
116
|
+
|
|
117
|
+
- `expr` (str): A Python lambda expression. Receives `d` (connector instance) and returns a single numerical value to plot.
|
|
118
|
+
|
|
119
|
+
### `.draw()` / `.draw_with_metrics()`
|
|
120
|
+
Renders interactive UI widgets and plot area.
|
|
121
|
+
|
|
122
|
+
- `.draw()`: Standard UI.
|
|
123
|
+
- `.draw_with_metrics()`: Includes real-time benchmark metrics.
|
|
124
|
+
|
|
125
|
+
---
|
|
126
|
+
|
|
127
|
+
## UI Controls
|
|
128
|
+
|
|
129
|
+
- **Reset Button**: Reset all options and clear the plot.
|
|
130
|
+
- **Start / Apply Changes Button**: Initially `"Start"`. Activates on first Kafka message, then applies UI changes.
|
|
131
|
+
- **Accordion Options**:
|
|
132
|
+
- **Window Size**: Number of points shown.
|
|
133
|
+
- **Window Width**: Width of plot.
|
|
134
|
+
- **Pick a Color**: Plot line color.
|
|
135
|
+
- **Date / Use Offset / Dim History**: TensorWatch visualization options.
|
|
136
|
+
|
|
137
|
+
---
|
|
138
|
+
|
|
139
|
+
## License
|
|
140
|
+
|
|
141
|
+
This project is licensed under the **MIT License**. See [LICENSE](LICENSE) for details.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
tensorwatch_api/__init__.py,sha256=RE2b0TnopwHIV_NcvhQA3M8Q4fEYItq-HJsPwdpzZWA,485
|
|
2
|
+
tensorwatch_api/twapi.py,sha256=WyISrhNbMwVt659oej8VA4gNZbzPs56i7DjZl8aC9O0,9016
|
|
3
|
+
tensorwatch_api-0.1.0.dist-info/licenses/LICENSE.txt,sha256=oHOsDZJ_S9nBtvRFJZv-9L31kmphhq2zf48PGQ-JHwU,1091
|
|
4
|
+
tensorwatch_api-0.1.0.dist-info/METADATA,sha256=99FoB0FFr9wC_58Avl2gwkT0yKIjmlmjOKDbzDaRrOg,5100
|
|
5
|
+
tensorwatch_api-0.1.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
6
|
+
tensorwatch_api-0.1.0.dist-info/top_level.txt,sha256=wL9uSNrri-CFUXFN-uolrvW4Bfg8_hHXhjH1uhaO7dk,16
|
|
7
|
+
tensorwatch_api-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Costa S. Revi
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
tensorwatch_api
|