sift-stack-py 0.3.2__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.
- google/__init__.py +1 -0
- google/api/__init__.py +0 -0
- google/api/annotations_pb2.py +27 -0
- google/api/annotations_pb2.pyi +29 -0
- google/api/annotations_pb2_grpc.py +4 -0
- google/api/annotations_pb2_grpc.pyi +30 -0
- google/api/field_behavior_pb2.py +30 -0
- google/api/field_behavior_pb2.pyi +175 -0
- google/api/field_behavior_pb2_grpc.py +4 -0
- google/api/field_behavior_pb2_grpc.pyi +30 -0
- google/api/http_pb2.py +31 -0
- google/api/http_pb2.pyi +433 -0
- google/api/http_pb2_grpc.py +4 -0
- google/api/http_pb2_grpc.pyi +30 -0
- protoc_gen_openapiv2/__init__.py +0 -0
- protoc_gen_openapiv2/options/__init__.py +0 -0
- protoc_gen_openapiv2/options/annotations_pb2.py +27 -0
- protoc_gen_openapiv2/options/annotations_pb2.pyi +48 -0
- protoc_gen_openapiv2/options/annotations_pb2_grpc.py +4 -0
- protoc_gen_openapiv2/options/annotations_pb2_grpc.pyi +17 -0
- protoc_gen_openapiv2/options/openapiv2_pb2.py +132 -0
- protoc_gen_openapiv2/options/openapiv2_pb2.pyi +1533 -0
- protoc_gen_openapiv2/options/openapiv2_pb2_grpc.py +4 -0
- protoc_gen_openapiv2/options/openapiv2_pb2_grpc.pyi +17 -0
- sift/__init__.py +0 -0
- sift/annotation_logs/__init__.py +0 -0
- sift/annotation_logs/v1/__init__.py +0 -0
- sift/annotation_logs/v1/annotation_logs_pb2.py +115 -0
- sift/annotation_logs/v1/annotation_logs_pb2.pyi +370 -0
- sift/annotation_logs/v1/annotation_logs_pb2_grpc.py +135 -0
- sift/annotation_logs/v1/annotation_logs_pb2_grpc.pyi +84 -0
- sift/annotations/__init__.py +0 -0
- sift/annotations/v1/__init__.py +0 -0
- sift/annotations/v1/annotations_pb2.py +180 -0
- sift/annotations/v1/annotations_pb2.pyi +539 -0
- sift/annotations/v1/annotations_pb2_grpc.py +237 -0
- sift/annotations/v1/annotations_pb2_grpc.pyi +144 -0
- sift/assets/__init__.py +0 -0
- sift/assets/v1/__init__.py +0 -0
- sift/assets/v1/assets_pb2.py +90 -0
- sift/assets/v1/assets_pb2.pyi +235 -0
- sift/assets/v1/assets_pb2_grpc.py +168 -0
- sift/assets/v1/assets_pb2_grpc.pyi +101 -0
- sift/calculated_channels/__init__.py +0 -0
- sift/calculated_channels/v1/__init__.py +0 -0
- sift/calculated_channels/v1/calculated_channels_pb2.py +99 -0
- sift/calculated_channels/v1/calculated_channels_pb2.pyi +280 -0
- sift/calculated_channels/v1/calculated_channels_pb2_grpc.py +101 -0
- sift/calculated_channels/v1/calculated_channels_pb2_grpc.pyi +64 -0
- sift/campaigns/__init__.py +0 -0
- sift/campaigns/v1/__init__.py +0 -0
- sift/campaigns/v1/campaigns_pb2.py +144 -0
- sift/campaigns/v1/campaigns_pb2.pyi +383 -0
- sift/campaigns/v1/campaigns_pb2_grpc.py +169 -0
- sift/campaigns/v1/campaigns_pb2_grpc.pyi +104 -0
- sift/channel_schemas/__init__.py +0 -0
- sift/channel_schemas/v1/__init__.py +0 -0
- sift/channel_schemas/v1/channel_schemas_pb2.py +69 -0
- sift/channel_schemas/v1/channel_schemas_pb2.pyi +117 -0
- sift/channel_schemas/v1/channel_schemas_pb2_grpc.py +101 -0
- sift/channel_schemas/v1/channel_schemas_pb2_grpc.pyi +64 -0
- sift/channels/__init__.py +0 -0
- sift/channels/v2/__init__.py +0 -0
- sift/channels/v2/channels_pb2.py +88 -0
- sift/channels/v2/channels_pb2.pyi +183 -0
- sift/channels/v2/channels_pb2_grpc.py +101 -0
- sift/channels/v2/channels_pb2_grpc.pyi +64 -0
- sift/common/__init__.py +0 -0
- sift/common/type/__init__.py +0 -0
- sift/common/type/v1/__init__.py +0 -0
- sift/common/type/v1/channel_bit_field_element_pb2.py +34 -0
- sift/common/type/v1/channel_bit_field_element_pb2.pyi +33 -0
- sift/common/type/v1/channel_bit_field_element_pb2_grpc.py +4 -0
- sift/common/type/v1/channel_bit_field_element_pb2_grpc.pyi +17 -0
- sift/common/type/v1/channel_data_type_pb2.py +29 -0
- sift/common/type/v1/channel_data_type_pb2.pyi +50 -0
- sift/common/type/v1/channel_data_type_pb2_grpc.py +4 -0
- sift/common/type/v1/channel_data_type_pb2_grpc.pyi +17 -0
- sift/common/type/v1/channel_enum_type_pb2.py +32 -0
- sift/common/type/v1/channel_enum_type_pb2.pyi +29 -0
- sift/common/type/v1/channel_enum_type_pb2_grpc.py +4 -0
- sift/common/type/v1/channel_enum_type_pb2_grpc.pyi +17 -0
- sift/common/type/v1/organization_pb2.py +27 -0
- sift/common/type/v1/organization_pb2.pyi +29 -0
- sift/common/type/v1/organization_pb2_grpc.py +4 -0
- sift/common/type/v1/organization_pb2_grpc.pyi +17 -0
- sift/common/type/v1/resource_identifier_pb2.py +46 -0
- sift/common/type/v1/resource_identifier_pb2.pyi +145 -0
- sift/common/type/v1/resource_identifier_pb2_grpc.py +4 -0
- sift/common/type/v1/resource_identifier_pb2_grpc.pyi +17 -0
- sift/common/type/v1/user_pb2.py +33 -0
- sift/common/type/v1/user_pb2.pyi +36 -0
- sift/common/type/v1/user_pb2_grpc.py +4 -0
- sift/common/type/v1/user_pb2_grpc.pyi +17 -0
- sift/data/__init__.py +0 -0
- sift/data/v1/__init__.py +0 -0
- sift/data/v1/data_pb2.py +212 -0
- sift/data/v1/data_pb2.pyi +745 -0
- sift/data/v1/data_pb2_grpc.py +67 -0
- sift/data/v1/data_pb2_grpc.pyi +44 -0
- sift/ingest/__init__.py +0 -0
- sift/ingest/v1/__init__.py +0 -0
- sift/ingest/v1/ingest_pb2.py +35 -0
- sift/ingest/v1/ingest_pb2.pyi +118 -0
- sift/ingest/v1/ingest_pb2_grpc.py +66 -0
- sift/ingest/v1/ingest_pb2_grpc.pyi +41 -0
- sift/ingestion_configs/__init__.py +0 -0
- sift/ingestion_configs/v1/__init__.py +0 -0
- sift/ingestion_configs/v1/ingestion_configs_pb2.py +115 -0
- sift/ingestion_configs/v1/ingestion_configs_pb2.pyi +332 -0
- sift/ingestion_configs/v1/ingestion_configs_pb2_grpc.py +203 -0
- sift/ingestion_configs/v1/ingestion_configs_pb2_grpc.pyi +124 -0
- sift/notifications/__init__.py +0 -0
- sift/notifications/v1/__init__.py +0 -0
- sift/notifications/v1/notifications_pb2.py +64 -0
- sift/notifications/v1/notifications_pb2.pyi +225 -0
- sift/notifications/v1/notifications_pb2_grpc.py +101 -0
- sift/notifications/v1/notifications_pb2_grpc.pyi +64 -0
- sift/ping/__init__.py +0 -0
- sift/ping/v1/__init__.py +0 -0
- sift/ping/v1/ping_pb2.py +38 -0
- sift/ping/v1/ping_pb2.pyi +36 -0
- sift/ping/v1/ping_pb2_grpc.py +66 -0
- sift/ping/v1/ping_pb2_grpc.pyi +41 -0
- sift/remote_files/__init__.py +0 -0
- sift/remote_files/v1/__init__.py +0 -0
- sift/remote_files/v1/remote_files_pb2.py +174 -0
- sift/remote_files/v1/remote_files_pb2.pyi +472 -0
- sift/remote_files/v1/remote_files_pb2_grpc.py +271 -0
- sift/remote_files/v1/remote_files_pb2_grpc.pyi +164 -0
- sift/report_templates/__init__.py +0 -0
- sift/report_templates/v1/__init__.py +0 -0
- sift/report_templates/v1/report_templates_pb2.py +146 -0
- sift/report_templates/v1/report_templates_pb2.pyi +381 -0
- sift/report_templates/v1/report_templates_pb2_grpc.py +169 -0
- sift/report_templates/v1/report_templates_pb2_grpc.pyi +104 -0
- sift/reports/__init__.py +0 -0
- sift/reports/v1/__init__.py +0 -0
- sift/reports/v1/reports_pb2.py +193 -0
- sift/reports/v1/reports_pb2.pyi +562 -0
- sift/reports/v1/reports_pb2_grpc.py +205 -0
- sift/reports/v1/reports_pb2_grpc.pyi +136 -0
- sift/rule_evaluation/__init__.py +0 -0
- sift/rule_evaluation/v1/__init__.py +0 -0
- sift/rule_evaluation/v1/rule_evaluation_pb2.py +89 -0
- sift/rule_evaluation/v1/rule_evaluation_pb2.pyi +263 -0
- sift/rule_evaluation/v1/rule_evaluation_pb2_grpc.py +101 -0
- sift/rule_evaluation/v1/rule_evaluation_pb2_grpc.pyi +64 -0
- sift/rules/__init__.py +0 -0
- sift/rules/v1/__init__.py +0 -0
- sift/rules/v1/rules_pb2.py +420 -0
- sift/rules/v1/rules_pb2.pyi +1355 -0
- sift/rules/v1/rules_pb2_grpc.py +577 -0
- sift/rules/v1/rules_pb2_grpc.pyi +351 -0
- sift/runs/__init__.py +0 -0
- sift/runs/v2/__init__.py +0 -0
- sift/runs/v2/runs_pb2.py +150 -0
- sift/runs/v2/runs_pb2.pyi +413 -0
- sift/runs/v2/runs_pb2_grpc.py +271 -0
- sift/runs/v2/runs_pb2_grpc.pyi +164 -0
- sift/saved_searches/__init__.py +0 -0
- sift/saved_searches/v1/__init__.py +0 -0
- sift/saved_searches/v1/saved_searches_pb2.py +144 -0
- sift/saved_searches/v1/saved_searches_pb2.pyi +385 -0
- sift/saved_searches/v1/saved_searches_pb2_grpc.py +237 -0
- sift/saved_searches/v1/saved_searches_pb2_grpc.pyi +144 -0
- sift/tags/__init__.py +0 -0
- sift/tags/v1/__init__.py +0 -0
- sift/tags/v1/tags_pb2.py +49 -0
- sift/tags/v1/tags_pb2.pyi +71 -0
- sift/tags/v1/tags_pb2_grpc.py +4 -0
- sift/tags/v1/tags_pb2_grpc.pyi +17 -0
- sift/users/__init__.py +0 -0
- sift/users/v2/__init__.py +0 -0
- sift/users/v2/users_pb2.py +61 -0
- sift/users/v2/users_pb2.pyi +142 -0
- sift/users/v2/users_pb2_grpc.py +135 -0
- sift/users/v2/users_pb2_grpc.pyi +84 -0
- sift/views/__init__.py +0 -0
- sift/views/v1/__init__.py +0 -0
- sift/views/v1/views_pb2.py +130 -0
- sift/views/v1/views_pb2.pyi +466 -0
- sift/views/v1/views_pb2_grpc.py +305 -0
- sift/views/v1/views_pb2_grpc.pyi +184 -0
- sift_grafana/py.typed +0 -0
- sift_grafana/sift_query_model.py +64 -0
- sift_py/__init__.py +923 -0
- sift_py/_internal/__init__.py +5 -0
- sift_py/_internal/cel.py +18 -0
- sift_py/_internal/channel.py +42 -0
- sift_py/_internal/convert/__init__.py +3 -0
- sift_py/_internal/convert/json.py +24 -0
- sift_py/_internal/convert/protobuf.py +34 -0
- sift_py/_internal/convert/timestamp.py +9 -0
- sift_py/_internal/test_util/__init__.py +0 -0
- sift_py/_internal/test_util/channel.py +136 -0
- sift_py/_internal/test_util/fn.py +14 -0
- sift_py/_internal/test_util/server_interceptor.py +62 -0
- sift_py/_internal/time.py +48 -0
- sift_py/_internal/user.py +39 -0
- sift_py/data/__init__.py +171 -0
- sift_py/data/_channel.py +38 -0
- sift_py/data/_deserialize.py +208 -0
- sift_py/data/_deserialize_test.py +134 -0
- sift_py/data/_service_test.py +276 -0
- sift_py/data/_validate.py +10 -0
- sift_py/data/error.py +5 -0
- sift_py/data/query.py +299 -0
- sift_py/data/service.py +497 -0
- sift_py/data_import/__init__.py +130 -0
- sift_py/data_import/_config.py +167 -0
- sift_py/data_import/_config_test.py +166 -0
- sift_py/data_import/_csv_test.py +395 -0
- sift_py/data_import/_status_test.py +176 -0
- sift_py/data_import/_tdms_test.py +238 -0
- sift_py/data_import/ch10.py +157 -0
- sift_py/data_import/config.py +19 -0
- sift_py/data_import/csv.py +259 -0
- sift_py/data_import/status.py +113 -0
- sift_py/data_import/tdms.py +206 -0
- sift_py/data_import/tempfile.py +30 -0
- sift_py/data_import/time_format.py +39 -0
- sift_py/error.py +11 -0
- sift_py/file_attachment/__init__.py +88 -0
- sift_py/file_attachment/_internal/__init__.py +0 -0
- sift_py/file_attachment/_internal/download.py +13 -0
- sift_py/file_attachment/_internal/upload.py +100 -0
- sift_py/file_attachment/_service_test.py +161 -0
- sift_py/file_attachment/entity.py +30 -0
- sift_py/file_attachment/metadata.py +107 -0
- sift_py/file_attachment/service.py +142 -0
- sift_py/grpc/__init__.py +15 -0
- sift_py/grpc/_async_interceptors/__init__.py +0 -0
- sift_py/grpc/_async_interceptors/base.py +72 -0
- sift_py/grpc/_async_interceptors/metadata.py +36 -0
- sift_py/grpc/_interceptors/__init__.py +0 -0
- sift_py/grpc/_interceptors/base.py +61 -0
- sift_py/grpc/_interceptors/context.py +25 -0
- sift_py/grpc/_interceptors/metadata.py +33 -0
- sift_py/grpc/_retry.py +70 -0
- sift_py/grpc/keepalive.py +34 -0
- sift_py/grpc/transport.py +250 -0
- sift_py/grpc/transport_test.py +170 -0
- sift_py/ingestion/__init__.py +6 -0
- sift_py/ingestion/_internal/__init__.py +6 -0
- sift_py/ingestion/_internal/channel.py +12 -0
- sift_py/ingestion/_internal/error.py +10 -0
- sift_py/ingestion/_internal/ingest.py +350 -0
- sift_py/ingestion/_internal/ingest_test.py +357 -0
- sift_py/ingestion/_internal/ingestion_config.py +130 -0
- sift_py/ingestion/_internal/run.py +46 -0
- sift_py/ingestion/_service_test.py +478 -0
- sift_py/ingestion/buffer.py +189 -0
- sift_py/ingestion/channel.py +422 -0
- sift_py/ingestion/config/__init__.py +3 -0
- sift_py/ingestion/config/telemetry.py +281 -0
- sift_py/ingestion/config/telemetry_test.py +405 -0
- sift_py/ingestion/config/yaml/__init__.py +0 -0
- sift_py/ingestion/config/yaml/error.py +44 -0
- sift_py/ingestion/config/yaml/load.py +126 -0
- sift_py/ingestion/config/yaml/spec.py +58 -0
- sift_py/ingestion/config/yaml/test_load.py +25 -0
- sift_py/ingestion/flow.py +73 -0
- sift_py/ingestion/manager.py +99 -0
- sift_py/ingestion/rule/__init__.py +4 -0
- sift_py/ingestion/rule/config.py +11 -0
- sift_py/ingestion/service.py +237 -0
- sift_py/py.typed +0 -0
- sift_py/report_templates/__init__.py +0 -0
- sift_py/report_templates/_config_test.py +34 -0
- sift_py/report_templates/_service_test.py +94 -0
- sift_py/report_templates/config.py +36 -0
- sift_py/report_templates/service.py +171 -0
- sift_py/rest.py +29 -0
- sift_py/rule/__init__.py +0 -0
- sift_py/rule/_config_test.py +109 -0
- sift_py/rule/_service_test.py +168 -0
- sift_py/rule/config.py +229 -0
- sift_py/rule/service.py +484 -0
- sift_py/yaml/__init__.py +0 -0
- sift_py/yaml/_channel_test.py +169 -0
- sift_py/yaml/_rule_test.py +207 -0
- sift_py/yaml/channel.py +224 -0
- sift_py/yaml/report_templates.py +73 -0
- sift_py/yaml/rule.py +321 -0
- sift_py/yaml/utils.py +15 -0
- sift_stack_py-0.3.2.dist-info/LICENSE +7 -0
- sift_stack_py-0.3.2.dist-info/METADATA +109 -0
- sift_stack_py-0.3.2.dist-info/RECORD +291 -0
- sift_stack_py-0.3.2.dist-info/WHEEL +5 -0
- sift_stack_py-0.3.2.dist-info/top_level.txt +5 -0
sift_py/_internal/cel.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Utilities to interact with APIs that have a CEL-based interface.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import Iterable
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def cel_in(field: str, values: Iterable[str]) -> str:
|
|
9
|
+
"""
|
|
10
|
+
Produces a list membership CEL expression. Example:
|
|
11
|
+
|
|
12
|
+
```python
|
|
13
|
+
> print(cel_in("name", ["foo", "bar"]))
|
|
14
|
+
name in ["foo", "bar"]
|
|
15
|
+
```
|
|
16
|
+
"""
|
|
17
|
+
items = ",".join([f'"{val}"' for val in values])
|
|
18
|
+
return f"{field} in [{items}]"
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from typing import List, Optional, cast
|
|
2
|
+
|
|
3
|
+
from sift.channels.v2.channels_pb2 import Channel as ChannelPb
|
|
4
|
+
from sift.channels.v2.channels_pb2 import ListChannelsRequest, ListChannelsResponse
|
|
5
|
+
from sift.channels.v2.channels_pb2_grpc import ChannelServiceStub
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def channel_fqn(name: str, component: Optional[str]) -> str:
|
|
9
|
+
return name if component is None or len(component) == 0 else f"{component}.{name}"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def get_channels(
|
|
13
|
+
channel_service: ChannelServiceStub,
|
|
14
|
+
filter: str,
|
|
15
|
+
page_size: int = 1_000,
|
|
16
|
+
page_token: str = "",
|
|
17
|
+
) -> List[ChannelPb]:
|
|
18
|
+
"""
|
|
19
|
+
Queries all channels with the given filter. Filter must be a CEL expression.
|
|
20
|
+
"""
|
|
21
|
+
channels_pb: List[ChannelPb] = []
|
|
22
|
+
|
|
23
|
+
req = ListChannelsRequest(
|
|
24
|
+
filter=filter,
|
|
25
|
+
page_size=page_size,
|
|
26
|
+
page_token=page_token,
|
|
27
|
+
)
|
|
28
|
+
res = cast(ListChannelsResponse, channel_service.ListChannels(req))
|
|
29
|
+
channels_pb.extend(res.channels)
|
|
30
|
+
next_page_token = res.next_page_token
|
|
31
|
+
|
|
32
|
+
while len(next_page_token) > 0:
|
|
33
|
+
req = ListChannelsRequest(
|
|
34
|
+
filter=filter,
|
|
35
|
+
page_size=page_size,
|
|
36
|
+
page_token=page_token,
|
|
37
|
+
)
|
|
38
|
+
res = cast(ListChannelsResponse, channel_service.ListChannels(req))
|
|
39
|
+
channels_pb.extend(res.channels)
|
|
40
|
+
next_page_token = res.next_page_token
|
|
41
|
+
|
|
42
|
+
return channels_pb
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from abc import ABC, abstractmethod
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AsJson(ABC):
|
|
9
|
+
"""
|
|
10
|
+
Utility sub-types that require custom-serialization meant to be used in conjunction with the
|
|
11
|
+
`to_json` function. Sub-types should implement `as_json` which should return the object that
|
|
12
|
+
you want passed to `json.dumps`.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
@abstractmethod
|
|
16
|
+
def as_json(self) -> Any:
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def to_json(value: Any) -> str:
|
|
21
|
+
"""
|
|
22
|
+
Serializes `value` to a JSON string uses the `AsJson.as_json` implementation of the type.
|
|
23
|
+
"""
|
|
24
|
+
return json.dumps(value, default=lambda x: x.as_json())
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from typing import Generic, Type, TypeVar
|
|
5
|
+
|
|
6
|
+
from google.protobuf.message import Message
|
|
7
|
+
|
|
8
|
+
ProtobufMessage = Message
|
|
9
|
+
|
|
10
|
+
T = TypeVar("T", bound=ProtobufMessage)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AsProtobuf(ABC, Generic[T]):
|
|
14
|
+
"""
|
|
15
|
+
Abstract base class used to create create sub-types that can be treated
|
|
16
|
+
as an object that can be converted into an instance of `ProtobufMessage`.
|
|
17
|
+
|
|
18
|
+
If there are multiple possible protobuf targets then `as_pb` may be overloaded.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
@abstractmethod
|
|
22
|
+
def as_pb(self, klass: Type[T]) -> T:
|
|
23
|
+
"""
|
|
24
|
+
Performs the conversion into a sub-type of `ProtobufMessage`.
|
|
25
|
+
"""
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
@classmethod
|
|
29
|
+
@abstractmethod
|
|
30
|
+
def from_pb(cls, message: T) -> T:
|
|
31
|
+
"""
|
|
32
|
+
Converts a protobuf object to the type of the sub-class class.
|
|
33
|
+
"""
|
|
34
|
+
pass
|
|
File without changes
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
from collections.abc import AsyncIterable, Iterable
|
|
2
|
+
from typing import Any, Callable, Optional, Union
|
|
3
|
+
|
|
4
|
+
import grpc
|
|
5
|
+
import grpc.aio as grpc_aio
|
|
6
|
+
from grpc.aio import Channel as AsyncChannel
|
|
7
|
+
from grpc_testing import Channel
|
|
8
|
+
|
|
9
|
+
SerializingFunction = Callable[[Any], bytes]
|
|
10
|
+
DeserializingFunction = Callable[[bytes], Any]
|
|
11
|
+
DoneCallbackType = Callable[[Any], None]
|
|
12
|
+
RequestIterableType = Union[Iterable, AsyncIterable]
|
|
13
|
+
ResponseIterableType = AsyncIterable
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class MockChannel(Channel):
|
|
17
|
+
"""
|
|
18
|
+
Used as a mock gRPC channel
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def take_unary_unary(self, method_descriptor):
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
def take_unary_stream(self, method_descriptor):
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
def take_stream_unary(self, method_descriptor):
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
def take_stream_stream(self, method_descriptor):
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
def subscribe(self, callback, try_to_connect=False):
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
def unsubscribe(self, callback):
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
def unary_unary(
|
|
40
|
+
self,
|
|
41
|
+
method,
|
|
42
|
+
request_serializer=None,
|
|
43
|
+
response_deserializer=None,
|
|
44
|
+
_registered_method=False,
|
|
45
|
+
):
|
|
46
|
+
pass
|
|
47
|
+
|
|
48
|
+
def unary_stream(
|
|
49
|
+
self,
|
|
50
|
+
method,
|
|
51
|
+
request_serializer=None,
|
|
52
|
+
response_deserializer=None,
|
|
53
|
+
_registered_method=False,
|
|
54
|
+
):
|
|
55
|
+
pass
|
|
56
|
+
|
|
57
|
+
def stream_unary(
|
|
58
|
+
self,
|
|
59
|
+
method,
|
|
60
|
+
request_serializer=None,
|
|
61
|
+
response_deserializer=None,
|
|
62
|
+
_registered_method=False,
|
|
63
|
+
):
|
|
64
|
+
pass
|
|
65
|
+
|
|
66
|
+
def stream_stream(
|
|
67
|
+
self,
|
|
68
|
+
method,
|
|
69
|
+
request_serializer=None,
|
|
70
|
+
response_deserializer=None,
|
|
71
|
+
_registered_method=False,
|
|
72
|
+
):
|
|
73
|
+
pass
|
|
74
|
+
|
|
75
|
+
def close(self):
|
|
76
|
+
pass
|
|
77
|
+
|
|
78
|
+
def __enter__(self):
|
|
79
|
+
pass
|
|
80
|
+
|
|
81
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
82
|
+
pass
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class MockAsyncChannel(AsyncChannel):
|
|
86
|
+
async def __aenter__(self):
|
|
87
|
+
pass
|
|
88
|
+
|
|
89
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
90
|
+
pass
|
|
91
|
+
|
|
92
|
+
async def close(self, grace: Optional[float] = None):
|
|
93
|
+
pass
|
|
94
|
+
|
|
95
|
+
def get_state(self, try_to_connect: bool = False) -> grpc.ChannelConnectivity: ...
|
|
96
|
+
|
|
97
|
+
async def wait_for_state_change(
|
|
98
|
+
self,
|
|
99
|
+
last_observed_state: grpc.ChannelConnectivity,
|
|
100
|
+
) -> None:
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
async def channel_ready(self) -> None:
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
def unary_unary(
|
|
107
|
+
self,
|
|
108
|
+
method: str,
|
|
109
|
+
request_serializer: Optional[SerializingFunction] = None,
|
|
110
|
+
response_deserializer: Optional[DeserializingFunction] = None,
|
|
111
|
+
_registered_method: Optional[bool] = False,
|
|
112
|
+
) -> grpc_aio.UnaryUnaryMultiCallable: ...
|
|
113
|
+
|
|
114
|
+
def unary_stream(
|
|
115
|
+
self,
|
|
116
|
+
method: str,
|
|
117
|
+
request_serializer: Optional[SerializingFunction] = None,
|
|
118
|
+
response_deserializer: Optional[DeserializingFunction] = None,
|
|
119
|
+
_registered_method: Optional[bool] = False,
|
|
120
|
+
) -> grpc_aio.UnaryStreamMultiCallable: ...
|
|
121
|
+
|
|
122
|
+
def stream_unary(
|
|
123
|
+
self,
|
|
124
|
+
method: str,
|
|
125
|
+
request_serializer: Optional[SerializingFunction] = None,
|
|
126
|
+
response_deserializer: Optional[DeserializingFunction] = None,
|
|
127
|
+
_registered_method: Optional[bool] = False,
|
|
128
|
+
) -> grpc_aio.StreamUnaryMultiCallable: ...
|
|
129
|
+
|
|
130
|
+
def stream_stream(
|
|
131
|
+
self,
|
|
132
|
+
method: str,
|
|
133
|
+
request_serializer: Optional[SerializingFunction] = None,
|
|
134
|
+
response_deserializer: Optional[DeserializingFunction] = None,
|
|
135
|
+
_registered_method: Optional[bool] = False,
|
|
136
|
+
) -> grpc_aio.StreamStreamMultiCallable: ...
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from types import ModuleType
|
|
2
|
+
from typing import Callable
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def _mock_path(subject_module: ModuleType) -> Callable[[Callable], str]:
|
|
6
|
+
"""
|
|
7
|
+
Returns a function that can be used to conveniently generate the mock path
|
|
8
|
+
for a function which could then be passed to `pytest_mock.MockFixture.patch`
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
def mock_fn(fn: Callable) -> str:
|
|
12
|
+
return f"{subject_module.__name__}.{fn.__name__}"
|
|
13
|
+
|
|
14
|
+
return mock_fn
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import abc
|
|
2
|
+
from typing import Any, Callable, Optional, Tuple, cast
|
|
3
|
+
|
|
4
|
+
import grpc
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ServerInterceptor(grpc.ServerInterceptor, metaclass=abc.ABCMeta):
|
|
8
|
+
@abc.abstractmethod
|
|
9
|
+
def intercept(
|
|
10
|
+
self,
|
|
11
|
+
method: Callable,
|
|
12
|
+
request_or_iterator: Any,
|
|
13
|
+
context: grpc.ServicerContext,
|
|
14
|
+
method_name: str,
|
|
15
|
+
) -> Any:
|
|
16
|
+
return method(request_or_iterator, context)
|
|
17
|
+
|
|
18
|
+
def intercept_service(self, continuation, handler_call_details):
|
|
19
|
+
next_handler = continuation(handler_call_details)
|
|
20
|
+
if next_handler is None:
|
|
21
|
+
return
|
|
22
|
+
|
|
23
|
+
handler_factory, next_handler_method = _get_factory_and_method(next_handler)
|
|
24
|
+
|
|
25
|
+
def invoke_intercept_method(request_or_iterator, context):
|
|
26
|
+
method_name = handler_call_details.method
|
|
27
|
+
return self.intercept(
|
|
28
|
+
next_handler_method,
|
|
29
|
+
request_or_iterator,
|
|
30
|
+
context,
|
|
31
|
+
method_name,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
return handler_factory(
|
|
35
|
+
invoke_intercept_method,
|
|
36
|
+
request_deserializer=next_handler.request_deserializer,
|
|
37
|
+
response_serializer=next_handler.response_serializer,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class _RpcHandler(grpc.RpcMethodHandler):
|
|
42
|
+
unary_unary: Optional[Callable]
|
|
43
|
+
unary_stream: Optional[Callable]
|
|
44
|
+
stream_unary: Optional[Callable]
|
|
45
|
+
stream_stream: Optional[Callable]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _get_factory_and_method(
|
|
49
|
+
rpc_handler: grpc.RpcMethodHandler,
|
|
50
|
+
) -> Tuple[Callable, Callable]:
|
|
51
|
+
handler = cast(_RpcHandler, rpc_handler)
|
|
52
|
+
|
|
53
|
+
if handler.unary_unary:
|
|
54
|
+
return grpc.unary_unary_rpc_method_handler, handler.unary_unary
|
|
55
|
+
elif handler.unary_stream:
|
|
56
|
+
return grpc.unary_stream_rpc_method_handler, handler.unary_stream
|
|
57
|
+
elif handler.stream_unary:
|
|
58
|
+
return grpc.stream_unary_rpc_method_handler, handler.stream_unary
|
|
59
|
+
elif handler.stream_stream:
|
|
60
|
+
return grpc.stream_stream_rpc_method_handler, handler.stream_stream
|
|
61
|
+
else:
|
|
62
|
+
raise Exception("Unreachable")
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
from datetime import datetime, timezone
|
|
2
|
+
from typing import Union, cast
|
|
3
|
+
|
|
4
|
+
import pandas as pd
|
|
5
|
+
from google.protobuf.timestamp_pb2 import Timestamp as TimestampPb
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def to_timestamp_nanos(arg: Union[TimestampPb, pd.Timestamp, datetime, str, int]) -> pd.Timestamp:
|
|
9
|
+
"""
|
|
10
|
+
Converts a variety of time-types to a pandas timestamp which supports nano-second precision.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
if isinstance(arg, pd.Timestamp):
|
|
14
|
+
return arg
|
|
15
|
+
elif isinstance(arg, TimestampPb):
|
|
16
|
+
seconds = arg.seconds
|
|
17
|
+
nanos = arg.nanos
|
|
18
|
+
|
|
19
|
+
dt = datetime.fromtimestamp(seconds, tz=timezone.utc)
|
|
20
|
+
ts = pd.Timestamp(dt)
|
|
21
|
+
|
|
22
|
+
return cast(pd.Timestamp, ts + pd.Timedelta(nanos, unit="ns"))
|
|
23
|
+
|
|
24
|
+
elif isinstance(arg, int):
|
|
25
|
+
dt = datetime.fromtimestamp(arg, tz=timezone.utc)
|
|
26
|
+
return cast(pd.Timestamp, pd.Timestamp(dt))
|
|
27
|
+
|
|
28
|
+
else:
|
|
29
|
+
return cast(pd.Timestamp, pd.Timestamp(arg))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def to_timestamp_pb(arg: Union[datetime, str, int]) -> TimestampPb:
|
|
33
|
+
"""
|
|
34
|
+
Mainly used for testing at the moment. If using this for non-testing purposes
|
|
35
|
+
should probably make this more robust and support nano-second precision.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
ts = TimestampPb()
|
|
39
|
+
|
|
40
|
+
if isinstance(arg, datetime):
|
|
41
|
+
ts.FromDatetime(arg)
|
|
42
|
+
return ts
|
|
43
|
+
elif isinstance(arg, int):
|
|
44
|
+
ts.FromDatetime(datetime.fromtimestamp(arg))
|
|
45
|
+
return ts
|
|
46
|
+
else:
|
|
47
|
+
ts.FromDatetime(datetime.fromisoformat(arg))
|
|
48
|
+
return ts
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from typing import List, cast
|
|
2
|
+
|
|
3
|
+
from sift.common.type.v1.user_pb2 import User
|
|
4
|
+
from sift.users.v2.users_pb2 import ListActiveUsersRequest, ListActiveUsersResponse
|
|
5
|
+
from sift.users.v2.users_pb2_grpc import UserServiceStub
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def get_active_users(
|
|
9
|
+
user_service: UserServiceStub,
|
|
10
|
+
filter: str,
|
|
11
|
+
page_size: int = 1_000,
|
|
12
|
+
page_token: str = "",
|
|
13
|
+
) -> List[User]:
|
|
14
|
+
"""
|
|
15
|
+
Get active users from the user service with the given filter.
|
|
16
|
+
The filter must be a CEL expression.
|
|
17
|
+
"""
|
|
18
|
+
users_pb: List[User] = []
|
|
19
|
+
|
|
20
|
+
req = ListActiveUsersRequest(
|
|
21
|
+
filter=filter,
|
|
22
|
+
page_size=page_size,
|
|
23
|
+
page_token=page_token,
|
|
24
|
+
)
|
|
25
|
+
res = cast(ListActiveUsersResponse, user_service.ListActiveUsers(req))
|
|
26
|
+
users_pb.extend(res.users)
|
|
27
|
+
next_page_token = res.next_page_token
|
|
28
|
+
|
|
29
|
+
while len(next_page_token) > 0:
|
|
30
|
+
req = ListActiveUsersRequest(
|
|
31
|
+
filter=filter,
|
|
32
|
+
page_size=page_size,
|
|
33
|
+
page_token=page_token,
|
|
34
|
+
)
|
|
35
|
+
res = cast(ListActiveUsersResponse, user_service.ListActiveUsers(req))
|
|
36
|
+
users_pb.extend(res.users)
|
|
37
|
+
next_page_token = res.next_page_token
|
|
38
|
+
|
|
39
|
+
return users_pb
|
sift_py/data/__init__.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""
|
|
2
|
+
This module contains tools to download telemetry from the Sift data API. The
|
|
3
|
+
core component of this module is the `sift_py.data.service.DataService` and the
|
|
4
|
+
`sift_py.data.query` module. The former is what's used to execute a data query,
|
|
5
|
+
while the latter is what's used to actually construct the query. A typical query could look
|
|
6
|
+
something like this:
|
|
7
|
+
|
|
8
|
+
```python
|
|
9
|
+
query = DataQuery(
|
|
10
|
+
asset_name="NostromoLV426",
|
|
11
|
+
start_time="2024-07-04T18:09:08.555-07:00",
|
|
12
|
+
end_time="2024-07-04T18:09:11.556-07:00",
|
|
13
|
+
sample_ms=16,
|
|
14
|
+
channels=[
|
|
15
|
+
ChannelQuery(
|
|
16
|
+
channel_name="voltage",
|
|
17
|
+
run_name="[NostromoLV426].1720141748.047512"
|
|
18
|
+
),
|
|
19
|
+
ChannelQuery(
|
|
20
|
+
channel_name="velocity",
|
|
21
|
+
component="mainmotors",
|
|
22
|
+
run_name="[NostromoLV426].1720141748.047512",
|
|
23
|
+
),
|
|
24
|
+
ChannelQuery(
|
|
25
|
+
channel_name="gpio",
|
|
26
|
+
run_name="[NostromoLV426].1720141748.047512",
|
|
27
|
+
),
|
|
28
|
+
],
|
|
29
|
+
)
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
This query, once passed to the `sift_py.data.service.DataService.execute` method, will
|
|
33
|
+
fetch data between `start_time` and `end_time` at the sampling rate given by `sample_ms`.
|
|
34
|
+
|
|
35
|
+
> ⚠️ **Warning**: Note on Performance
|
|
36
|
+
>
|
|
37
|
+
> Currently the results of a query are all buffered in memory, so it it best to be mindful
|
|
38
|
+
> about your memory limitations and overall performance requirements when requesting data
|
|
39
|
+
> within a large time range and a slow sampling rate. Full-fidelity data is returned
|
|
40
|
+
> when the `sample_ms` is set to `0`.
|
|
41
|
+
|
|
42
|
+
The data API allows you to download telemetry for both channels as well as calculated
|
|
43
|
+
channels. The following examples demonstrate how to download data for both channels and
|
|
44
|
+
calculated channels, respectively.
|
|
45
|
+
|
|
46
|
+
* [Regular Channels](#regular-channels)
|
|
47
|
+
* [Calculated Channels](#calculated-channels)
|
|
48
|
+
|
|
49
|
+
## Regular Channels
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
import asyncio
|
|
53
|
+
import functools
|
|
54
|
+
import pandas as pd
|
|
55
|
+
from sift_py.data.query import ChannelQuery, DataQuery
|
|
56
|
+
from sift_py.grpc.transport import SiftChannelConfig, use_sift_async_channel
|
|
57
|
+
from sift_py.data.service import DataService
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
async def channel_demo():
|
|
61
|
+
channel_config: SiftChannelConfig = {
|
|
62
|
+
"apikey": "my-key"
|
|
63
|
+
"uri": "sift-uri"
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async with use_sift_async_channel(channel_config) as channel:
|
|
67
|
+
data_service = DataService(channel)
|
|
68
|
+
|
|
69
|
+
query = DataQuery(
|
|
70
|
+
asset_name="NostromoLV426",
|
|
71
|
+
start_time="2024-07-04T18:09:08.555-07:00",
|
|
72
|
+
end_time="2024-07-04T18:09:11.556-07:00",
|
|
73
|
+
channels=[
|
|
74
|
+
ChannelQuery(
|
|
75
|
+
channel_name="voltage",
|
|
76
|
+
run_name="[NostromoLV426].1720141748.047512"
|
|
77
|
+
),
|
|
78
|
+
ChannelQuery(
|
|
79
|
+
channel_name="velocity",
|
|
80
|
+
component="mainmotors",
|
|
81
|
+
run_name="[NostromoLV426].1720141748.047512",
|
|
82
|
+
),
|
|
83
|
+
ChannelQuery(
|
|
84
|
+
channel_name="gpio",
|
|
85
|
+
run_name="[NostromoLV426].1720141748.047512",
|
|
86
|
+
),
|
|
87
|
+
],
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
result = await data_service.execute(query)
|
|
91
|
+
|
|
92
|
+
data_frames = [
|
|
93
|
+
pd.DataFrame(data.columns())
|
|
94
|
+
for data in result.channels("voltage", "mainmotors.velocity", "gpio.12v")
|
|
95
|
+
]
|
|
96
|
+
|
|
97
|
+
merged_frame = functools.reduce(
|
|
98
|
+
lambda x, y: pd.merge_asof(x, y, on="time"), data_frames
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
merged_frame.to_csv("my_csv.csv")
|
|
102
|
+
|
|
103
|
+
if __name__ == "__main__":
|
|
104
|
+
asyncio.run(example())
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Calculated Channels
|
|
108
|
+
|
|
109
|
+
```python
|
|
110
|
+
import asyncio
|
|
111
|
+
import functools
|
|
112
|
+
import pandas as pd
|
|
113
|
+
from sift_py.data.query import ChannelQuery, DataQuery
|
|
114
|
+
from sift_py.grpc.transport import SiftChannelConfig, use_sift_async_channel
|
|
115
|
+
from sift_py.data.service import DataService
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
async def channel_demo():
|
|
119
|
+
channel_config: SiftChannelConfig = {
|
|
120
|
+
"apikey": "my-key"
|
|
121
|
+
"uri": "sift-uri"
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async with use_sift_async_channel(channel_config) as channel:
|
|
125
|
+
data_service = DataService(channel)
|
|
126
|
+
|
|
127
|
+
query = DataQuery(
|
|
128
|
+
asset_name="NostromoLV426",
|
|
129
|
+
start_time="2024-07-04T18:09:08.555-07:00",
|
|
130
|
+
end_time="2024-07-04T18:09:11.556-07:00",
|
|
131
|
+
channels=[
|
|
132
|
+
CalculatedChannelQuery(
|
|
133
|
+
channel_key="calc-voltage",
|
|
134
|
+
expression="$1 + 10",
|
|
135
|
+
expression_channel_references=[
|
|
136
|
+
{
|
|
137
|
+
"reference": "$1",
|
|
138
|
+
"channel_name": "voltage",
|
|
139
|
+
},
|
|
140
|
+
],
|
|
141
|
+
run_name="[NostromoLV426].1720141748.047512",
|
|
142
|
+
),
|
|
143
|
+
CalculatedChannelQuery(
|
|
144
|
+
channel_key="calc-velocity",
|
|
145
|
+
expression="$1 * 2",
|
|
146
|
+
expression_channel_references=[
|
|
147
|
+
{
|
|
148
|
+
"reference": "$1",
|
|
149
|
+
"channel_name": "velocity",
|
|
150
|
+
"component": "mainmotors",
|
|
151
|
+
},
|
|
152
|
+
],
|
|
153
|
+
run_name="[NostromoLV426].1720141748.047512",
|
|
154
|
+
),
|
|
155
|
+
],
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
result = await data_service.execute(query)
|
|
159
|
+
calc_voltage, calc_velocity = result.channels("calc-voltage", "calc-velocity")
|
|
160
|
+
|
|
161
|
+
calc_voltage_df = pd.DataFrame(calc_voltage.columns())
|
|
162
|
+
calc_velocity_df = pd.DataFrame(calc_velocity.columns())
|
|
163
|
+
|
|
164
|
+
merged_frame = pd.merge_asof(calc_voltage_df, calc_velocity_df, on="time")
|
|
165
|
+
|
|
166
|
+
merged_frame.to_csv("my_csv.csv")
|
|
167
|
+
|
|
168
|
+
if __name__ == "__main__":
|
|
169
|
+
asyncio.run(example())
|
|
170
|
+
```
|
|
171
|
+
"""
|
sift_py/data/_channel.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from typing import Any, List
|
|
2
|
+
|
|
3
|
+
import pandas as pd
|
|
4
|
+
|
|
5
|
+
from sift_py.ingestion.channel import ChannelDataType
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ChannelTimeSeries:
|
|
9
|
+
data_type: ChannelDataType
|
|
10
|
+
time_column: List[pd.Timestamp]
|
|
11
|
+
value_column: List[Any]
|
|
12
|
+
|
|
13
|
+
def __init__(
|
|
14
|
+
self,
|
|
15
|
+
data_type: ChannelDataType,
|
|
16
|
+
time_column: List[pd.Timestamp],
|
|
17
|
+
value_column: List[Any],
|
|
18
|
+
):
|
|
19
|
+
if len(time_column) != len(value_column):
|
|
20
|
+
raise Exception("Both arguments, `time_column` and `value_column` must equal lengths.")
|
|
21
|
+
|
|
22
|
+
self.data_type = data_type
|
|
23
|
+
self.time_column = time_column
|
|
24
|
+
self.value_column = value_column
|
|
25
|
+
|
|
26
|
+
def sort_time_series(self):
|
|
27
|
+
points = [(t, v) for t, v in zip(self.time_column, self.value_column)]
|
|
28
|
+
points.sort(key=lambda x: x[0])
|
|
29
|
+
|
|
30
|
+
time_column = []
|
|
31
|
+
value_column = []
|
|
32
|
+
|
|
33
|
+
for ts, val in points:
|
|
34
|
+
time_column.append(ts)
|
|
35
|
+
value_column.append(val)
|
|
36
|
+
|
|
37
|
+
self.time_column = time_column
|
|
38
|
+
self.value_column = value_column
|