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/grpc/_retry.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Dict, List, TypedDict
|
|
5
|
+
|
|
6
|
+
from grpc import StatusCode
|
|
7
|
+
from typing_extensions import Self
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class RetryPolicy:
|
|
11
|
+
"""
|
|
12
|
+
Retry policy meant to be used for `sift_py.grpc.transport.SiftChannel`. Users may have the ability to configure their own
|
|
13
|
+
custom retry policy in the future, but for now this is primarily intended for internal use.
|
|
14
|
+
|
|
15
|
+
- [Retry policy schema](https://github.com/grpc/grpc-proto/blob/ec30f589e2519d595688b9a42f88a91bdd6b733f/grpc/service_config/service_config.proto#L136)
|
|
16
|
+
- [Enable gRPC retry option](https://github.com/grpc/grpc/blob/9a5fdfc3d3a7fc575a394360be4532ee09a85620/include/grpc/impl/channel_arg_names.h#L311)
|
|
17
|
+
- [Service config option](https://github.com/grpc/grpc/blob/9a5fdfc3d3a7fc575a394360be4532ee09a85620/include/grpc/impl/channel_arg_names.h#L207)
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
config: RetryConfig
|
|
21
|
+
|
|
22
|
+
DEFAULT_POLICY: RetryConfig = {
|
|
23
|
+
"methodConfig": [
|
|
24
|
+
{
|
|
25
|
+
# We can configure this on a per-service and RPC basis but for now we'll
|
|
26
|
+
# apply this across all services and RPCs.
|
|
27
|
+
"name": [{}],
|
|
28
|
+
"retryPolicy": {
|
|
29
|
+
# gRPC does not allow more than 5 attempts
|
|
30
|
+
"maxAttempts": 5,
|
|
31
|
+
"initialBackoff": "0.05s",
|
|
32
|
+
"maxBackoff": "5s",
|
|
33
|
+
"backoffMultiplier": 4,
|
|
34
|
+
"retryableStatusCodes": [
|
|
35
|
+
StatusCode.UNKNOWN.name,
|
|
36
|
+
StatusCode.UNAVAILABLE.name,
|
|
37
|
+
StatusCode.ABORTED.name,
|
|
38
|
+
StatusCode.DEADLINE_EXCEEDED.name,
|
|
39
|
+
],
|
|
40
|
+
},
|
|
41
|
+
}
|
|
42
|
+
]
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
def __init__(self, config: RetryConfig):
|
|
46
|
+
self.config = config
|
|
47
|
+
|
|
48
|
+
def as_json(self) -> str:
|
|
49
|
+
return json.dumps(self.config)
|
|
50
|
+
|
|
51
|
+
@classmethod
|
|
52
|
+
def default(cls) -> Self:
|
|
53
|
+
return cls(config=cls.DEFAULT_POLICY)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class RetryConfig(TypedDict):
|
|
57
|
+
methodConfig: List[MethodConfigDict]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class MethodConfigDict(TypedDict):
|
|
61
|
+
name: List[Dict[str, str]]
|
|
62
|
+
retryPolicy: RetryConfigDict
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class RetryConfigDict(TypedDict):
|
|
66
|
+
maxAttempts: int
|
|
67
|
+
initialBackoff: str
|
|
68
|
+
maxBackoff: str
|
|
69
|
+
backoffMultiplier: int
|
|
70
|
+
retryableStatusCodes: List[str]
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from typing import TypedDict
|
|
2
|
+
|
|
3
|
+
DEFAULT_KEEPALIVE_TIME_MS = 20_000
|
|
4
|
+
"""Interval with which to send keepalive pings"""
|
|
5
|
+
|
|
6
|
+
DEFAULT_KEEPALIVE_TIMEOUT_MS = 20_000
|
|
7
|
+
"""Timeout while waiting for server to acknowledge keepalive ping"""
|
|
8
|
+
|
|
9
|
+
DEFAULT_KEEPALIVE_PERMIT_WITHOUT_CALLS = 1
|
|
10
|
+
"""Allows connection without any active RPCs"""
|
|
11
|
+
|
|
12
|
+
DEFAULT_MAX_PINGS_WITHOUT_DATA = 0
|
|
13
|
+
"""Disabled"""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# https://github.com/grpc/grpc/blob/master/doc/keepalive.md
|
|
17
|
+
class KeepaliveConfig(TypedDict):
|
|
18
|
+
"""
|
|
19
|
+
Make make this public in the future to allow folks to configure their own keepalive settings
|
|
20
|
+
if there is demand for it.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
keepalive_time_ms: int
|
|
24
|
+
keepalive_timeout_ms: int
|
|
25
|
+
keepalive_permit_without_calls: int
|
|
26
|
+
max_pings_without_data: int
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
DEFAULT_KEEPALIVE_CONFIG: KeepaliveConfig = {
|
|
30
|
+
"keepalive_time_ms": DEFAULT_KEEPALIVE_TIME_MS,
|
|
31
|
+
"keepalive_timeout_ms": DEFAULT_KEEPALIVE_TIMEOUT_MS,
|
|
32
|
+
"keepalive_permit_without_calls": DEFAULT_KEEPALIVE_PERMIT_WITHOUT_CALLS,
|
|
33
|
+
"max_pings_without_data": DEFAULT_MAX_PINGS_WITHOUT_DATA,
|
|
34
|
+
}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"""
|
|
2
|
+
This module is concerned with creating a gRPC transport channel specifically for
|
|
3
|
+
interacting with Sift's gRPC API. the `use_sift_channel` method creates said channel
|
|
4
|
+
and should generally be used within a with-block for correct resource management.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
10
|
+
from typing import Any, Dict, List, Optional, Tuple, TypedDict, Union, cast
|
|
11
|
+
from urllib.parse import ParseResult, urlparse
|
|
12
|
+
|
|
13
|
+
import grpc
|
|
14
|
+
import grpc.aio as grpc_aio
|
|
15
|
+
from typing_extensions import NotRequired, TypeAlias
|
|
16
|
+
|
|
17
|
+
from sift_py.grpc._async_interceptors.base import ClientAsyncInterceptor
|
|
18
|
+
from sift_py.grpc._async_interceptors.metadata import MetadataAsyncInterceptor
|
|
19
|
+
from sift_py.grpc._interceptors.base import ClientInterceptor
|
|
20
|
+
from sift_py.grpc._interceptors.metadata import Metadata, MetadataInterceptor
|
|
21
|
+
from sift_py.grpc._retry import RetryPolicy
|
|
22
|
+
from sift_py.grpc.keepalive import DEFAULT_KEEPALIVE_CONFIG, KeepaliveConfig
|
|
23
|
+
|
|
24
|
+
SiftChannel: TypeAlias = grpc.Channel
|
|
25
|
+
SiftAsyncChannel: TypeAlias = grpc_aio.Channel
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def get_ssl_credentials(cert_via_openssl: bool) -> grpc.ChannelCredentials:
|
|
29
|
+
"""
|
|
30
|
+
Returns SSL credentials for use with gRPC.
|
|
31
|
+
Workaround for this issue: https://github.com/grpc/grpc/issues/29682
|
|
32
|
+
"""
|
|
33
|
+
if not cert_via_openssl:
|
|
34
|
+
return grpc.ssl_channel_credentials()
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
import ssl
|
|
38
|
+
|
|
39
|
+
from OpenSSL import crypto
|
|
40
|
+
|
|
41
|
+
ssl_context = ssl.create_default_context()
|
|
42
|
+
certs_der = ssl_context.get_ca_certs(binary_form=True)
|
|
43
|
+
certs_x509 = [crypto.load_certificate(crypto.FILETYPE_ASN1, x) for x in certs_der]
|
|
44
|
+
certs_pem = [crypto.dump_certificate(crypto.FILETYPE_PEM, x) for x in certs_x509]
|
|
45
|
+
certs_bytes = b"".join(certs_pem)
|
|
46
|
+
|
|
47
|
+
return grpc.ssl_channel_credentials(certs_bytes)
|
|
48
|
+
except ImportError as e:
|
|
49
|
+
raise Exception(
|
|
50
|
+
"Missing required dependencies for cert_via_openssl. Run `pip install sift-stack-py[openssl]` to install the required dependencies."
|
|
51
|
+
) from e
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def use_sift_channel(
|
|
55
|
+
config: SiftChannelConfig, metadata: Optional[Dict[str, Any]] = None
|
|
56
|
+
) -> SiftChannel:
|
|
57
|
+
"""
|
|
58
|
+
Returns an intercepted channel that is meant to be used across all services that
|
|
59
|
+
make RPCs to Sift's API. It is highly encouraged to use this within a with-block
|
|
60
|
+
for correct resource clean-up.
|
|
61
|
+
|
|
62
|
+
Should an RPC fail for a reason that isn't explicitly controlled by Sift, `SiftChannel`
|
|
63
|
+
will automatically leverage gRPC's retry mechanism to try and recover until the max-attempts
|
|
64
|
+
are exceeded, after which the underlying exception will be raised.
|
|
65
|
+
"""
|
|
66
|
+
use_ssl = config.get("use_ssl", True)
|
|
67
|
+
cert_via_openssl = config.get("cert_via_openssl", False)
|
|
68
|
+
|
|
69
|
+
if not use_ssl:
|
|
70
|
+
return _use_insecure_sift_channel(config, metadata)
|
|
71
|
+
|
|
72
|
+
credentials = get_ssl_credentials(cert_via_openssl)
|
|
73
|
+
options = _compute_channel_options(config)
|
|
74
|
+
api_uri = _clean_uri(config["uri"], use_ssl)
|
|
75
|
+
channel = grpc.secure_channel(api_uri, credentials, options)
|
|
76
|
+
interceptors = _compute_sift_interceptors(config, metadata)
|
|
77
|
+
return grpc.intercept_channel(channel, *interceptors)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def use_sift_async_channel(
|
|
81
|
+
config: SiftChannelConfig, metadata: Optional[Dict[str, Any]] = None
|
|
82
|
+
) -> SiftAsyncChannel:
|
|
83
|
+
"""
|
|
84
|
+
Like `use_sift_channel` but returns a channel meant to be used within the context
|
|
85
|
+
of an async runtime when asynchonous I/O is required.
|
|
86
|
+
"""
|
|
87
|
+
use_ssl = config.get("use_ssl", True)
|
|
88
|
+
cert_via_openssl = config.get("cert_via_openssl", False)
|
|
89
|
+
|
|
90
|
+
if not use_ssl:
|
|
91
|
+
return _use_insecure_sift_async_channel(config, metadata)
|
|
92
|
+
|
|
93
|
+
return grpc_aio.secure_channel(
|
|
94
|
+
target=_clean_uri(config["uri"], use_ssl),
|
|
95
|
+
credentials=get_ssl_credentials(cert_via_openssl),
|
|
96
|
+
options=_compute_channel_options(config),
|
|
97
|
+
interceptors=_compute_sift_async_interceptors(config, metadata),
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _use_insecure_sift_channel(
|
|
102
|
+
config: SiftChannelConfig, metadata: Optional[Dict[str, Any]] = None
|
|
103
|
+
) -> SiftChannel:
|
|
104
|
+
"""
|
|
105
|
+
FOR DEVELOPMENT PURPOSES ONLY
|
|
106
|
+
"""
|
|
107
|
+
options = _compute_channel_options(config)
|
|
108
|
+
api_uri = _clean_uri(config["uri"], False)
|
|
109
|
+
channel = grpc.insecure_channel(api_uri, options)
|
|
110
|
+
interceptors = _compute_sift_interceptors(config, metadata)
|
|
111
|
+
return grpc.intercept_channel(channel, *interceptors)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _use_insecure_sift_async_channel(
|
|
115
|
+
config: SiftChannelConfig, metadata: Optional[Dict[str, Any]] = None
|
|
116
|
+
) -> SiftAsyncChannel:
|
|
117
|
+
"""
|
|
118
|
+
FOR DEVELOPMENT PURPOSES ONLY
|
|
119
|
+
"""
|
|
120
|
+
return grpc_aio.insecure_channel(
|
|
121
|
+
target=config["uri"],
|
|
122
|
+
options=_compute_channel_options(config),
|
|
123
|
+
interceptors=_compute_sift_async_interceptors(config, metadata),
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _compute_sift_interceptors(
|
|
128
|
+
config: SiftChannelConfig, metadata: Optional[Dict[str, Any]] = None
|
|
129
|
+
) -> List[ClientInterceptor]:
|
|
130
|
+
"""
|
|
131
|
+
Initialized all interceptors here.
|
|
132
|
+
"""
|
|
133
|
+
return [
|
|
134
|
+
_metadata_interceptor(config, metadata),
|
|
135
|
+
]
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _compute_sift_async_interceptors(
|
|
139
|
+
config: SiftChannelConfig, metadata: Optional[Dict[str, Any]] = None
|
|
140
|
+
) -> List[grpc_aio.ClientInterceptor]:
|
|
141
|
+
return [
|
|
142
|
+
_metadata_async_interceptor(config, metadata),
|
|
143
|
+
]
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _compute_channel_options(opts: SiftChannelConfig) -> List[Tuple[str, Any]]:
|
|
147
|
+
"""
|
|
148
|
+
Initialize all [channel options](https://github.com/grpc/grpc/blob/v1.64.x/include/grpc/impl/channel_arg_names.h) here.
|
|
149
|
+
"""
|
|
150
|
+
|
|
151
|
+
options = [
|
|
152
|
+
("grpc.enable_retries", 1),
|
|
153
|
+
("grpc.service_config", RetryPolicy.default().as_json()),
|
|
154
|
+
# Primary cannot be overriden:
|
|
155
|
+
# https://github.com/grpc/grpc/blob/0498194240f55d7f4b12633ad01339fb690621bf/src/core/ext/filters/http/client/http_client_filter.cc#L97
|
|
156
|
+
("grpc.secondary_user_agent", _compute_user_agent()),
|
|
157
|
+
]
|
|
158
|
+
|
|
159
|
+
enable_keepalive = opts.get("enable_keepalive", True)
|
|
160
|
+
if isinstance(enable_keepalive, dict):
|
|
161
|
+
config = cast(KeepaliveConfig, enable_keepalive)
|
|
162
|
+
options.extend(_compute_keep_alive_channel_opts(config))
|
|
163
|
+
elif enable_keepalive:
|
|
164
|
+
options.extend(_compute_keep_alive_channel_opts(DEFAULT_KEEPALIVE_CONFIG))
|
|
165
|
+
|
|
166
|
+
return options
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _metadata_interceptor(
|
|
170
|
+
config: SiftChannelConfig, metadata: Optional[Dict[str, Any]] = None
|
|
171
|
+
) -> ClientInterceptor:
|
|
172
|
+
"""
|
|
173
|
+
Any new metadata goes here.
|
|
174
|
+
"""
|
|
175
|
+
apikey = config["apikey"]
|
|
176
|
+
md: Metadata = [("authorization", f"Bearer {apikey}")]
|
|
177
|
+
|
|
178
|
+
if metadata:
|
|
179
|
+
for key, val in metadata.items():
|
|
180
|
+
md.append((key, val))
|
|
181
|
+
|
|
182
|
+
return MetadataInterceptor(md)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _metadata_async_interceptor(
|
|
186
|
+
config: SiftChannelConfig, metadata: Optional[Dict[str, Any]] = None
|
|
187
|
+
) -> ClientAsyncInterceptor:
|
|
188
|
+
"""
|
|
189
|
+
Any new metadata goes here for unary-unary calls.
|
|
190
|
+
"""
|
|
191
|
+
apikey = config["apikey"]
|
|
192
|
+
md: Metadata = [("authorization", f"Bearer {apikey}")]
|
|
193
|
+
|
|
194
|
+
if metadata:
|
|
195
|
+
for key, val in metadata.items():
|
|
196
|
+
md.append((key, val))
|
|
197
|
+
|
|
198
|
+
return MetadataAsyncInterceptor(md)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _clean_uri(uri: str, use_ssl: bool) -> str:
|
|
202
|
+
"""
|
|
203
|
+
This will automatically transform the URI to an acceptable form regardless of whether or not
|
|
204
|
+
users included the scheme in the URL or included trailing slashes.
|
|
205
|
+
"""
|
|
206
|
+
|
|
207
|
+
if "http://" in uri or "https://" in uri:
|
|
208
|
+
parsed: ParseResult = urlparse(uri)
|
|
209
|
+
return parsed.netloc
|
|
210
|
+
|
|
211
|
+
full_uri = f"https://{uri}" if use_ssl else f"http://{uri}"
|
|
212
|
+
parsed_res: ParseResult = urlparse(full_uri)
|
|
213
|
+
return parsed_res.netloc
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _compute_user_agent() -> str:
|
|
217
|
+
try:
|
|
218
|
+
return f"sift_stack_py/{version('sift_stack_py')}"
|
|
219
|
+
except PackageNotFoundError:
|
|
220
|
+
return "sift-stack-py"
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _compute_keep_alive_channel_opts(config: KeepaliveConfig) -> List[Tuple[str, int]]:
|
|
224
|
+
return [
|
|
225
|
+
("grpc.keepalive_time_ms", config["keepalive_time_ms"]),
|
|
226
|
+
("grpc.keepalive_timeout_ms", config["keepalive_timeout_ms"]),
|
|
227
|
+
("grpc.http2.max_pings_without_data", config["max_pings_without_data"]),
|
|
228
|
+
("grpc.keepalive_permit_without_calls", config["keepalive_permit_without_calls"]),
|
|
229
|
+
]
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
class SiftChannelConfig(TypedDict):
|
|
233
|
+
"""
|
|
234
|
+
Config class used to instantiate a `SiftChannel` via `use_sift_channel`.
|
|
235
|
+
- `uri`: The URI of Sift's gRPC API. The scheme portion of the URI i.e. `https://` should be ommitted.
|
|
236
|
+
- `apikey`: User-generated API key generated via the Sift application.
|
|
237
|
+
- `enable_keepalive`: Enabled by default, but can be disabled by passing in `False`. HTTP/2 keep-alive prevents connections from
|
|
238
|
+
being terminated during idle periods. A custom `sift_py.grpc.keepalive.KeepaliveConfig` may also be provided.
|
|
239
|
+
- `use_ssl`: INTERNAL USE. Meant to be used for local development.
|
|
240
|
+
- `cert_via_openssl`: Enable this if you want to use OpenSSL to load the certificates.
|
|
241
|
+
Run `pip install sift-stack-py[openssl]` to install the dependencies required to use this option.
|
|
242
|
+
This works around this issue with grpc loading SSL certificates: https://github.com/grpc/grpc/issues/29682.
|
|
243
|
+
Default is False.
|
|
244
|
+
"""
|
|
245
|
+
|
|
246
|
+
uri: str
|
|
247
|
+
apikey: str
|
|
248
|
+
enable_keepalive: NotRequired[Union[bool, KeepaliveConfig]]
|
|
249
|
+
use_ssl: NotRequired[bool]
|
|
250
|
+
cert_via_openssl: NotRequired[bool]
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# ruff: noqa: N802
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from concurrent import futures
|
|
5
|
+
from contextlib import contextmanager
|
|
6
|
+
from typing import Any, Callable, Iterator, cast
|
|
7
|
+
|
|
8
|
+
import grpc
|
|
9
|
+
import pytest
|
|
10
|
+
from pytest_mock import MockFixture, MockType
|
|
11
|
+
from sift.data.v1.data_pb2 import GetDataRequest, GetDataResponse
|
|
12
|
+
from sift.data.v1.data_pb2_grpc import (
|
|
13
|
+
DataServiceServicer,
|
|
14
|
+
DataServiceStub,
|
|
15
|
+
add_DataServiceServicer_to_server,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
from sift_py._internal.test_util.server_interceptor import ServerInterceptor
|
|
19
|
+
from sift_py.grpc.transport import SiftChannelConfig, use_sift_channel
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class DataService(DataServiceServicer):
|
|
23
|
+
def GetData(self, request: GetDataRequest, context: grpc.ServicerContext):
|
|
24
|
+
return GetDataResponse(next_page_token="next-page-token")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class AuthInterceptor(ServerInterceptor):
|
|
28
|
+
AUTH_REGEX = re.compile(r"^Bearer (.+)$")
|
|
29
|
+
|
|
30
|
+
def intercept(
|
|
31
|
+
self,
|
|
32
|
+
method: Callable,
|
|
33
|
+
request_or_iterator: Any,
|
|
34
|
+
context: grpc.ServicerContext,
|
|
35
|
+
method_name: str,
|
|
36
|
+
) -> Any:
|
|
37
|
+
authenticated = False
|
|
38
|
+
for metadata in context.invocation_metadata():
|
|
39
|
+
if metadata.key == "authorization":
|
|
40
|
+
auth = self.__class__.AUTH_REGEX.match(metadata.value)
|
|
41
|
+
|
|
42
|
+
if auth is not None and len(auth.group(1)) > 0:
|
|
43
|
+
authenticated = True
|
|
44
|
+
|
|
45
|
+
break
|
|
46
|
+
|
|
47
|
+
if authenticated:
|
|
48
|
+
return method(request_or_iterator, context)
|
|
49
|
+
else:
|
|
50
|
+
context.set_code(grpc.StatusCode.UNAUTHENTICATED)
|
|
51
|
+
context.set_details("Invalid or missing API key")
|
|
52
|
+
raise
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class ForceFailInterceptor(ServerInterceptor):
|
|
56
|
+
"""
|
|
57
|
+
Force RPC to fail a few times before letting it pass.
|
|
58
|
+
|
|
59
|
+
`failed_attempts`: Count of how many times failed
|
|
60
|
+
`expected_num_fails`: How many times you want call to fail
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
failed_attempts: int
|
|
64
|
+
expected_num_fails: int
|
|
65
|
+
|
|
66
|
+
def __init__(self, expected_num_fails: int):
|
|
67
|
+
self.expected_num_fails = expected_num_fails
|
|
68
|
+
self.failed_attempts = 0
|
|
69
|
+
super().__init__()
|
|
70
|
+
|
|
71
|
+
def intercept(
|
|
72
|
+
self,
|
|
73
|
+
method: Callable,
|
|
74
|
+
request_or_iterator: Any,
|
|
75
|
+
context: grpc.ServicerContext,
|
|
76
|
+
method_name: str,
|
|
77
|
+
) -> Any:
|
|
78
|
+
if self.failed_attempts < self.expected_num_fails:
|
|
79
|
+
self.failed_attempts += 1
|
|
80
|
+
context.set_code(grpc.StatusCode.UNKNOWN)
|
|
81
|
+
context.set_details("something unknown happened")
|
|
82
|
+
raise
|
|
83
|
+
|
|
84
|
+
return method(request_or_iterator, context)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_sift_channel(mocker: MockFixture):
|
|
88
|
+
@contextmanager
|
|
89
|
+
def test_server_spy(*interceptors: ServerInterceptor) -> Iterator[MockType]:
|
|
90
|
+
server = grpc.server(
|
|
91
|
+
thread_pool=futures.ThreadPoolExecutor(max_workers=1), interceptors=list(interceptors)
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
data_service = DataService()
|
|
95
|
+
spy = mocker.spy(data_service, "GetData")
|
|
96
|
+
|
|
97
|
+
add_DataServiceServicer_to_server(data_service, server)
|
|
98
|
+
server.add_insecure_port("[::]:50052")
|
|
99
|
+
server.start()
|
|
100
|
+
try:
|
|
101
|
+
yield spy
|
|
102
|
+
finally:
|
|
103
|
+
server.stop(None)
|
|
104
|
+
server.wait_for_termination()
|
|
105
|
+
|
|
106
|
+
with test_server_spy(AuthInterceptor()) as get_data_spy:
|
|
107
|
+
sift_channel_config_a: SiftChannelConfig = {
|
|
108
|
+
"uri": "localhost:50052",
|
|
109
|
+
"apikey": "",
|
|
110
|
+
"use_ssl": False,
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
with use_sift_channel(sift_channel_config_a) as channel:
|
|
114
|
+
with pytest.raises(grpc.RpcError, match="UNAUTHENTICATED"):
|
|
115
|
+
stub = DataServiceStub(channel)
|
|
116
|
+
_ = cast(GetDataResponse, stub.GetData(GetDataRequest()))
|
|
117
|
+
|
|
118
|
+
get_data_spy.assert_not_called()
|
|
119
|
+
|
|
120
|
+
sift_channel_config_b: SiftChannelConfig = {
|
|
121
|
+
"uri": "localhost:50052",
|
|
122
|
+
"apikey": "some-token",
|
|
123
|
+
"use_ssl": False,
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
with use_sift_channel(sift_channel_config_b) as channel:
|
|
127
|
+
stub = DataServiceStub(channel)
|
|
128
|
+
res = cast(GetDataResponse, stub.GetData(GetDataRequest()))
|
|
129
|
+
assert res.next_page_token == "next-page-token"
|
|
130
|
+
get_data_spy.assert_called_once()
|
|
131
|
+
|
|
132
|
+
force_fail_interceptor = ForceFailInterceptor(4)
|
|
133
|
+
with test_server_spy(AuthInterceptor(), force_fail_interceptor) as get_data_spy:
|
|
134
|
+
sift_channel_config_c: SiftChannelConfig = {
|
|
135
|
+
"uri": "localhost:50052",
|
|
136
|
+
"apikey": "some-token",
|
|
137
|
+
"use_ssl": False,
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
with use_sift_channel(sift_channel_config_c) as channel:
|
|
141
|
+
stub = DataServiceStub(channel)
|
|
142
|
+
# This will attempt 5 times: fail 4 times, succeed on 5th
|
|
143
|
+
res = cast(GetDataResponse, stub.GetData(GetDataRequest()))
|
|
144
|
+
assert res.next_page_token == "next-page-token"
|
|
145
|
+
get_data_spy.assert_called_once()
|
|
146
|
+
|
|
147
|
+
# fail 4 times, pass the 5th attempt
|
|
148
|
+
assert force_fail_interceptor.failed_attempts == 4
|
|
149
|
+
|
|
150
|
+
# Now we're going to fail beyond the max retry attempts
|
|
151
|
+
|
|
152
|
+
force_fail_interceptor_max = ForceFailInterceptor(7)
|
|
153
|
+
with test_server_spy(AuthInterceptor(), force_fail_interceptor_max) as get_data_spy:
|
|
154
|
+
sift_channel_config_d: SiftChannelConfig = {
|
|
155
|
+
"uri": "localhost:50052",
|
|
156
|
+
"apikey": "some-token",
|
|
157
|
+
"use_ssl": False,
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
with use_sift_channel(sift_channel_config_d) as channel:
|
|
161
|
+
stub = DataServiceStub(channel)
|
|
162
|
+
|
|
163
|
+
# This will go beyond the max number of attempts
|
|
164
|
+
with pytest.raises(Exception):
|
|
165
|
+
res = cast(GetDataResponse, stub.GetData(GetDataRequest()))
|
|
166
|
+
|
|
167
|
+
get_data_spy.assert_not_called()
|
|
168
|
+
|
|
169
|
+
# All attempts failed
|
|
170
|
+
assert force_fail_interceptor_max.failed_attempts == 5
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Module containing components necessary to leverage Sift's data ingestion and rule evaluation API.
|
|
3
|
+
The fundamental components of this module are the following:
|
|
4
|
+
- `sift_py.ingestion.config.telemetry.TelemetryConfig`
|
|
5
|
+
- `sift_py.ingestion.service.IngestionService`
|
|
6
|
+
"""
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from sift.rules.v1.rules_pb2 import ChannelReference
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def channel_reference_from_fqn(fqn: str) -> ChannelReference:
|
|
5
|
+
parts = fqn.split(".")
|
|
6
|
+
|
|
7
|
+
if len(parts) == 1:
|
|
8
|
+
return ChannelReference(name=parts[0])
|
|
9
|
+
|
|
10
|
+
component_parts = parts[: len(parts) - 1]
|
|
11
|
+
|
|
12
|
+
return ChannelReference(name=parts[-1], component=".".join(component_parts))
|