diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bc561e53..4d4aacc85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,5 @@ * Support the timezone-carrying types `TzDate`, `TzDatetime` and `TzTimestamp` in query results and parameters (reading them previously raised `AttributeError`) +* Fixed `DynamicConfigClient.get_config()` raising `TypeError` when parsing the server response * Drop support for Python 3.8 and 3.9; the minimum supported version is now Python 3.10 * Accept native `datetime.datetime` values for `Datetime` and `Datetime64` query parameters (previously only integer seconds since the epoch were accepted) diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/test_dynamic_config.py b/tests/unit/test_dynamic_config.py new file mode 100644 index 000000000..7b191d301 --- /dev/null +++ b/tests/unit/test_dynamic_config.py @@ -0,0 +1,156 @@ +# -*- coding: utf-8 -*- +import pytest + +from ydb import issues, operation +from ydb._apis import ydb_operation +from ydb.draft import dynamic_config as dc +from ydb.draft import _apis + + +class _FakeDriver: + def __init__(self, result="RESULT"): + self._result = result + self.calls = [] + + def __call__(self, *args): + self.calls.append(args) + return self._result + + def future(self, *args): + self.calls.append(args) + return self._result + + +def _get_config_response(version=7, cluster="cluster-1", config="cfg-text", status=issues.StatusCode.SUCCESS): + result = _apis.ydb_dynamic_config.GetConfigResult() + result.identity.version = version + result.identity.cluster = cluster + result.config = config + op = ydb_operation.Operation(status=status) + op.result.Pack(result) + return _apis.ydb_dynamic_config.GetConfigResponse(operation=op) + + +def _get_node_labels_response(labels, status=issues.StatusCode.SUCCESS): + result = _apis.ydb_dynamic_config.GetNodeLabelsResult() + for label, value in labels.items(): + result.labels.add(label=label, value=value) + op = ydb_operation.Operation(status=status) + op.result.Pack(result) + return _apis.ydb_dynamic_config.GetNodeLabelsResponse(operation=op) + + +def test_dynamic_config_dataclass(): + cfg = dc.DynamicConfig(3, "cl", "text", "extra") + assert cfg.version == 3 + assert cfg.cluster == "cl" + assert cfg.config == "text" + + +def test_node_labels_dataclass(): + nl = dc.NodeLabels({"dc": "vla"}) + assert nl.labels == {"dc": "vla"} + + +def test_replace_and_set_config_request_factory(): + replace = dc._replace_config_request_factory("cfg", True, False) + assert replace.config == "cfg" + assert replace.dry_run is True + assert replace.allow_unknown_fields is False + + set_req = dc._set_config_request_factory("cfg2", False, True) + assert set_req.config == "cfg2" + assert set_req.dry_run is False + assert set_req.allow_unknown_fields is True + + +def test_get_config_and_node_labels_request_factory(): + assert dc._get_config_request_factory() is not None + node_req = dc._get_node_labels_request_factory(42) + assert node_req.node_id == 42 + + +def test_wrap_dynamic_config(): + result = _apis.ydb_dynamic_config.GetConfigResult() + result.identity.version = 11 + result.identity.cluster = "prod" + result.config = "yaml-config" + wrapped = dc._wrap_dynamic_config(result) + assert wrapped.version == 11 + assert wrapped.cluster == "prod" + assert wrapped.config == "yaml-config" + + +def test_wrap_get_config_response_success_and_error(): + wrapped = dc._wrap_get_config_response(None, _get_config_response(version=9, cluster="c", config="cfg")) + assert isinstance(wrapped, dc.DynamicConfig) + assert wrapped.version == 9 + assert wrapped.cluster == "c" + assert wrapped.config == "cfg" + + with pytest.raises(issues.BadRequest): + dc._wrap_get_config_response(None, _get_config_response(status=issues.StatusCode.BAD_REQUEST)) + + +def test_wrap_node_labels(): + result = _apis.ydb_dynamic_config.GetNodeLabelsResult() + result.labels.add(label="dc", value="vla") + result.labels.add(label="rack", value="7") + wrapped = dc._wrap_node_labels(result) + assert wrapped.labels == {"dc": "vla", "rack": "7"} + + +def test_wrap_get_node_labels_response_success_and_error(): + wrapped = dc._wrap_get_node_labels_response(None, _get_node_labels_response({"dc": "sas"})) + assert isinstance(wrapped, dc.NodeLabels) + assert wrapped.labels == {"dc": "sas"} + + with pytest.raises(issues.PreconditionFailed): + dc._wrap_get_node_labels_response( + None, _get_node_labels_response({}, status=issues.StatusCode.PRECONDITION_FAILED) + ) + + +def test_base_client_dispatch(): + driver = _FakeDriver() + client = dc.BaseDynamicConfigClient(driver) + + client.replace_config("cfg", True, False) + request, stub, method, wrapper, settings = driver.calls[-1] + assert request.config == "cfg" + assert stub is _apis.DynamicConfigService.Stub + assert method == _apis.DynamicConfigService.ReplaceConfig + assert wrapper is operation.Operation + + client.set_config("cfg2", False, True) + assert driver.calls[-1][2] == _apis.DynamicConfigService.SetConfig + + client.get_config() + request, _, method, wrapper, _ = driver.calls[-1] + assert method == _apis.DynamicConfigService.GetConfig + assert wrapper is dc._wrap_get_config_response + + client.get_node_labels(5) + request, _, method, wrapper, _ = driver.calls[-1] + assert request.node_id == 5 + assert method == _apis.DynamicConfigService.GetNodeLabels + assert wrapper is dc._wrap_get_node_labels_response + + +def test_async_client_dispatch(): + driver = _FakeDriver() + client = dc.DynamicConfigClient(driver) + + client.async_replace_config("cfg", True, False) + assert driver.calls[-1][2] == _apis.DynamicConfigService.ReplaceConfig + + client.async_set_config("cfg", False, True) + assert driver.calls[-1][2] == _apis.DynamicConfigService.SetConfig + + client.async_get_config() + assert driver.calls[-1][3] is dc._wrap_get_config_response + + client.async_get_node_labels(9) + request, _, method, wrapper, _ = driver.calls[-1] + assert request.node_id == 9 + assert wrapper is dc._wrap_get_node_labels_response diff --git a/tests/unit/test_export.py b/tests/unit/test_export.py new file mode 100644 index 000000000..a5a4a8ed7 --- /dev/null +++ b/tests/unit/test_export.py @@ -0,0 +1,194 @@ +# -*- coding: utf-8 -*- +import pytest + +from ydb import export, issues, _apis +from ydb._grpc.common.protos import ydb_export_pb2 +from ydb._grpc.common import ydb_export_v1_pb2_grpc + + +class _FakeDriver: + def __init__(self, result="RESULT"): + self._result = result + self.calls = [] + + def __call__(self, *args): + self.calls.append(args) + return self._result + + def future(self, *args): + self.calls.append(args) + return self._result + + +def _s3_response(progress=ydb_export_pb2.ExportProgress.PROGRESS_DONE, operation_id="op-1"): + op = _apis.ydb_operation.Operation(id=operation_id, status=issues.StatusCode.SUCCESS) + op.metadata.Pack(ydb_export_pb2.ExportToS3Metadata(progress=progress)) + return _apis.ydb_operation.GetOperationResponse(operation=op) + + +def _yt_response(progress=ydb_export_pb2.ExportProgress.PROGRESS_TRANSFER_DATA, operation_id="op-2"): + op = _apis.ydb_operation.Operation(id=operation_id, status=issues.StatusCode.SUCCESS) + op.metadata.Pack(ydb_export_pb2.ExportToYtMetadata(progress=progress)) + return _apis.ydb_operation.GetOperationResponse(operation=op) + + +def test_export_progress_enum_initialized(): + # PROGRESS_* proto values are mapped onto the ExportProgress enum at import. + assert export._progresses[ydb_export_pb2.ExportProgress.PROGRESS_DONE] is export.ExportProgress.DONE + assert export._progresses[ydb_export_pb2.ExportProgress.PROGRESS_PREPARING] is export.ExportProgress.PREPARING + + +def test_s3_settings_builders(): + s = ( + export.ExportToS3Settings() + .with_bucket("bucket") + .with_endpoint("endpoint") + .with_access_key("ak") + .with_secret_key("sk") + .with_scheme(1) + .with_uid("uid-1") + .with_number_of_retries(5) + .with_storage_class(2) + .with_export_compression("zstd") + .with_source_and_destination("/table", "prefix") + .with_items(("/t2", "p2")) + .with_item(("/t3", "p3")) + ) + assert s.bucket == "bucket" + assert s.endpoint == "endpoint" + assert s.access_key == "ak" + assert s.secret_key == "sk" + assert s.scheme == 1 + assert s.uid == "uid-1" + assert s.number_of_retries == 5 + assert s.storage_class == 2 + assert s.export_compression == "zstd" + assert s.items == [("/table", "prefix"), ("/t2", "p2"), ("/t3", "p3")] + + +def test_s3_request_factory_defaults(): + request = export._export_to_s3_request_factory(export.ExportToS3Settings()) + assert request.settings.scheme == 2 + assert len(request.settings.items) == 0 + assert "uid" not in request.operation_params.labels + assert request.settings.number_of_retries == 0 + + +def test_s3_request_factory_full(): + settings = ( + export.ExportToS3Settings() + .with_bucket("b") + .with_endpoint("e") + .with_access_key("ak") + .with_secret_key("sk") + .with_uid("uid-9") + .with_number_of_retries(3) + .with_export_compression("gzip") + .with_source_and_destination("/table", "prefix") + ) + request = export._export_to_s3_request_factory(settings) + assert request.settings.bucket == "b" + assert request.settings.endpoint == "e" + assert request.operation_params.labels["uid"] == "uid-9" + assert request.settings.number_of_retries == 3 + assert request.settings.compression == "gzip" + assert request.settings.items[0].source_path == "/table" + assert request.settings.items[0].destination_prefix == "prefix" + + +def test_yt_settings_builders(): + s = ( + export.ExportToYTSettings() + .with_host("host") + .with_port(8080) + .with_uid("uid") + .with_token("token") + .with_number_of_retries(2) + .with_use_type_v3(True) + .with_source_and_destination("/a", "/b") + .with_items(("/c", "/d")) + ) + assert s.host == "host" + assert s.port == 8080 + assert s.uid == "uid" + assert s.token == "token" + assert s.number_of_retries == 2 + assert s.use_type_v3 is True + assert s.items == [("/a", "/b"), ("/c", "/d")] + + +def test_yt_request_factory_defaults(): + request = export._export_to_yt_request_factory(export.ExportToYTSettings()) + assert request.settings.number_of_retries == 0 + assert request.settings.port == 0 + assert request.settings.use_type_v3 is False + assert len(request.settings.items) == 0 + + +def test_yt_request_factory_full(): + settings = ( + export.ExportToYTSettings() + .with_host("host") + .with_token("token") + .with_port(9090) + .with_number_of_retries(4) + .with_use_type_v3(True) + .with_source_and_destination("/a", "/b") + ) + request = export._export_to_yt_request_factory(settings) + assert request.settings.host == "host" + assert request.settings.token == "token" + assert request.settings.port == 9090 + assert request.settings.number_of_retries == 4 + assert request.settings.use_type_v3 is True + assert request.settings.items[0].source_path == "/a" + assert request.settings.items[0].destination_path == "/b" + + +def test_export_operation_wrappers(): + s3 = export.ExportToS3Operation(None, _s3_response(), driver=None) + assert s3.id == "op-1" + assert s3.progress is export.ExportProgress.DONE + assert "ExportToS3Operation" in str(s3) + assert repr(s3) == str(s3) + + yt = export.ExportToYTOperation(None, _yt_response(), driver=None) + assert yt.id == "op-2" + assert yt.progress is export.ExportProgress.TRANSFER_DATA + assert "ExportToYTOperation" in str(yt) + assert repr(yt) == str(yt) + + +def test_export_client_dispatch(): + driver = _FakeDriver() + client = export.ExportClient(driver) + + client.export_to_s3(export.ExportToS3Settings()) + request, stub, method, wrapper, settings, wrap_args = driver.calls[-1] + assert stub is ydb_export_v1_pb2_grpc.ExportServiceStub + assert method == export._ExportToS3 + assert wrapper is export.ExportToS3Operation + assert wrap_args == (driver,) + + client.export_to_yt(export.ExportToYTSettings()) + _, _, method, wrapper, _, _ = driver.calls[-1] + assert method == export._ExportToYt + assert wrapper is export.ExportToYTOperation + + client.async_export_to_yt(export.ExportToYTSettings()) + _, _, method, wrapper, _, _ = driver.calls[-1] + assert method == export._ExportToYt + + client.get_export_to_s3_operation("op-x") + request, _, method, wrapper, _, _ = driver.calls[-1] + assert request.id == "op-x" + assert method == _apis.OperationService.GetOperation + assert wrapper is export.ExportToS3Operation + + +def test_export_operation_error_status_raises(): + op = _apis.ydb_operation.Operation(status=issues.StatusCode.BAD_REQUEST) + op.metadata.Pack(ydb_export_pb2.ExportToS3Metadata()) + response = _apis.ydb_operation.GetOperationResponse(operation=op) + with pytest.raises(issues.BadRequest): + export.ExportToS3Operation(None, response, driver=None) diff --git a/tests/unit/test_global_settings.py b/tests/unit/test_global_settings.py new file mode 100644 index 000000000..bdc33e251 --- /dev/null +++ b/tests/unit/test_global_settings.py @@ -0,0 +1,63 @@ +# -*- coding: utf-8 -*- +import warnings + +import pytest + +from ydb import convert, table, global_settings + + +@pytest.fixture(autouse=True) +def _restore_globals(): + truncated = convert._default_allow_truncated_result + split = table._default_allow_split_transaction + try: + yield + finally: + convert._default_allow_truncated_result = truncated + table._default_allow_split_transaction = split + + +def test_allow_truncated_result_enable_warns_and_updates(): + convert._default_allow_truncated_result = False + with pytest.warns(UserWarning): + global_settings.global_allow_truncated_result(True) + assert convert._default_allow_truncated_result is True + + +def test_allow_truncated_result_disable_does_not_warn(): + convert._default_allow_truncated_result = True + with warnings.catch_warnings(): + warnings.simplefilter("error") + global_settings.global_allow_truncated_result(False) + assert convert._default_allow_truncated_result is False + + +def test_allow_truncated_result_noop_when_unchanged(): + convert._default_allow_truncated_result = True + with warnings.catch_warnings(): + warnings.simplefilter("error") + global_settings.global_allow_truncated_result(True) + assert convert._default_allow_truncated_result is True + + +def test_allow_split_transactions_enable_warns_and_updates(): + table._default_allow_split_transaction = False + with pytest.warns(UserWarning): + global_settings.global_allow_split_transactions(True) + assert table._default_allow_split_transaction is True + + +def test_allow_split_transactions_disable_does_not_warn(): + table._default_allow_split_transaction = True + with warnings.catch_warnings(): + warnings.simplefilter("error") + global_settings.global_allow_split_transactions(False) + assert table._default_allow_split_transaction is False + + +def test_allow_split_transactions_noop_when_unchanged(): + table._default_allow_split_transaction = False + with warnings.catch_warnings(): + warnings.simplefilter("error") + global_settings.global_allow_split_transactions(False) + assert table._default_allow_split_transaction is False diff --git a/tests/unit/test_import_client.py b/tests/unit/test_import_client.py new file mode 100644 index 000000000..075446975 --- /dev/null +++ b/tests/unit/test_import_client.py @@ -0,0 +1,118 @@ +# -*- coding: utf-8 -*- +import pytest + +from ydb import import_client, issues, _apis +from ydb._grpc.common.protos import ydb_import_pb2 +from ydb._grpc.common import ydb_import_v1_pb2_grpc + + +class _FakeDriver: + def __init__(self, result="RESULT"): + self._result = result + self.calls = [] + + def __call__(self, *args): + self.calls.append(args) + return self._result + + +def _s3_response(progress=ydb_import_pb2.ImportProgress.PROGRESS_DONE, operation_id="imp-1"): + op = _apis.ydb_operation.Operation(id=operation_id, status=issues.StatusCode.SUCCESS) + op.metadata.Pack(ydb_import_pb2.ImportFromS3Metadata(progress=progress)) + return _apis.ydb_operation.GetOperationResponse(operation=op) + + +def test_import_progress_enum_initialized(): + assert import_client._progresses[ydb_import_pb2.ImportProgress.PROGRESS_DONE] is import_client.ImportProgress.DONE + assert ( + import_client._progresses[ydb_import_pb2.ImportProgress.PROGRESS_BUILD_INDEXES] + is import_client.ImportProgress.BUILD_INDEXES + ) + + +def test_s3_settings_builders(): + s = ( + import_client.ImportFromS3Settings() + .with_bucket("bucket") + .with_endpoint("endpoint") + .with_access_key("ak") + .with_secret_key("sk") + .with_scheme(1) + .with_uid("uid-1") + .with_number_of_retries(7) + .with_source_and_destination("src", "dst") + .with_items(("s2", "d2")) + .with_item(("s3", "d3")) + ) + assert s.bucket == "bucket" + assert s.endpoint == "endpoint" + assert s.access_key == "ak" + assert s.secret_key == "sk" + assert s.scheme == 1 + assert s.uid == "uid-1" + assert s.number_of_retries == 7 + assert s.items == [("src", "dst"), ("s2", "d2"), ("s3", "d3")] + + +def test_s3_request_factory_defaults(): + request = import_client._import_from_s3_request_factory(import_client.ImportFromS3Settings()) + assert request.settings.scheme == 2 + assert len(request.settings.items) == 0 + assert "uid" not in request.operation_params.labels + assert request.settings.number_of_retries == 0 + + +def test_s3_request_factory_full(): + settings = ( + import_client.ImportFromS3Settings() + .with_bucket("b") + .with_endpoint("e") + .with_access_key("ak") + .with_secret_key("sk") + .with_uid("uid-2") + .with_number_of_retries(2) + .with_source_and_destination("source-prefix", "/dest/table") + ) + request = import_client._import_from_s3_request_factory(settings) + assert request.settings.bucket == "b" + assert request.settings.endpoint == "e" + assert request.settings.access_key == "ak" + assert request.settings.secret_key == "sk" + assert request.operation_params.labels["uid"] == "uid-2" + assert request.settings.number_of_retries == 2 + assert request.settings.items[0].source_prefix == "source-prefix" + assert request.settings.items[0].destination_path == "/dest/table" + + +def test_import_operation_wrapper(): + op = import_client.ImportFromS3Operation(None, _s3_response(), driver=None) + assert op.id == "imp-1" + assert op.progress is import_client.ImportProgress.DONE + assert "ImportFromS3Operation" in str(op) + assert repr(op) == str(op) + + +def test_import_operation_error_status_raises(): + op = _apis.ydb_operation.Operation(status=issues.StatusCode.BAD_REQUEST) + op.metadata.Pack(ydb_import_pb2.ImportFromS3Metadata()) + response = _apis.ydb_operation.GetOperationResponse(operation=op) + with pytest.raises(issues.BadRequest): + import_client.ImportFromS3Operation(None, response, driver=None) + + +def test_import_client_dispatch(): + driver = _FakeDriver() + client = import_client.ImportClient(driver) + + client.import_from_s3(import_client.ImportFromS3Settings()) + request, stub, method, wrapper, settings, wrap_args = driver.calls[-1] + assert stub is ydb_import_v1_pb2_grpc.ImportServiceStub + assert method == import_client._ImportFromS3 + assert wrapper is import_client.ImportFromS3Operation + assert wrap_args == (driver,) + + client.get_import_from_s3_operation("imp-x") + request, _, method, wrapper, _, _ = driver.calls[-1] + assert request.id == "imp-x" + assert method == _apis.OperationService.GetOperation + assert wrapper is import_client.ImportFromS3Operation diff --git a/tests/unit/test_opentelemetry.py b/tests/unit/test_opentelemetry.py new file mode 100644 index 000000000..45bfdd35a --- /dev/null +++ b/tests/unit/test_opentelemetry.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8 -*- +import sys + +import pytest + +from ydb import opentelemetry as otel + + +def test_enable_tracing_dispatches_to_plugin(monkeypatch): + calls = [] + monkeypatch.setattr("ydb.opentelemetry.plugin._enable_tracing", lambda tracer: calls.append(tracer)) + otel.enable_tracing("my-tracer") + assert calls == ["my-tracer"] + + +def test_disable_tracing_dispatches_to_plugin(monkeypatch): + calls = [] + monkeypatch.setattr("ydb.opentelemetry.plugin._disable_tracing", lambda: calls.append(True)) + otel.disable_tracing() + assert calls == [True] + + +def test_enable_tracing_raises_when_plugin_missing(monkeypatch): + monkeypatch.setitem(sys.modules, "ydb.opentelemetry.plugin", None) + with pytest.raises(ImportError, match="OpenTelemetry packages are required"): + otel.enable_tracing() + + +def test_disable_tracing_silent_when_plugin_missing(monkeypatch): + monkeypatch.setitem(sys.modules, "ydb.opentelemetry.plugin", None) + assert otel.disable_tracing() is None diff --git a/tests/unit/test_operation.py b/tests/unit/test_operation.py new file mode 100644 index 000000000..39b65f228 --- /dev/null +++ b/tests/unit/test_operation.py @@ -0,0 +1,112 @@ +# -*- coding: utf-8 -*- +import pytest + +from ydb import operation, issues, _apis + + +class _FakeDriver: + """Records the arguments the client forwards to the driver.""" + + def __init__(self, result="RESULT"): + self._result = result + self.calls = [] + + def __call__(self, *args): + self.calls.append(args) + return self._result + + +def _operation_response(operation_id="op-1", status=issues.StatusCode.SUCCESS): + return _apis.ydb_operation.GetOperationResponse( + operation=_apis.ydb_operation.Operation(id=operation_id, status=status) + ) + + +def test_forget_operation_request(): + request = operation._forget_operation_request("abc") + assert request.id == "abc" + + +def test_cancel_operation_request(): + request = operation._cancel_operation_request("xyz") + assert request.id == "xyz" + + +def test_get_operation_request(): + op = operation.Operation(None, _operation_response("op-42")) + request = operation._get_operation_request(op) + assert request.id == "op-42" + + +def test_cancel_and_forget_response_success(): + response = _apis.ydb_operation.Operation(status=issues.StatusCode.SUCCESS) + assert operation._cancel_operation_response(None, response) is None + assert operation._forget_operation_response(None, response) is None + + +def test_cancel_response_raises_on_error(): + response = _apis.ydb_operation.Operation(status=issues.StatusCode.BAD_REQUEST) + with pytest.raises(issues.BadRequest): + operation._cancel_operation_response(None, response) + + +def test_operation_init_sets_id_and_repr(): + op = operation.Operation(None, _operation_response("id-1")) + assert op.id == "id-1" + assert repr(op) == "" + assert str(op) == "" + + +def test_operation_init_raises_on_error_status(): + response = _apis.ydb_operation.GetOperationResponse( + operation=_apis.ydb_operation.Operation(status=issues.StatusCode.BAD_REQUEST) + ) + with pytest.raises(issues.BadRequest): + operation.Operation(None, response) + + +def test_operation_without_driver_cannot_dispatch(): + op = operation.Operation(None, _operation_response()) + with pytest.raises(ValueError): + op.cancel() + with pytest.raises(ValueError): + op.forget() + with pytest.raises(ValueError): + op.get() + + +def test_operation_client_cancel_and_forget(): + driver = _FakeDriver() + client = operation.OperationClient(driver) + + assert client.cancel("op-1") == "RESULT" + request, stub, method, wrapper, settings = driver.calls[0] + assert request.id == "op-1" + assert stub is _apis.OperationService.Stub + assert method == _apis.OperationService.CancelOperation + assert wrapper is operation._cancel_operation_response + + assert client.forget("op-2") == "RESULT" + request, stub, method, wrapper, settings = driver.calls[1] + assert request.id == "op-2" + assert method == _apis.OperationService.ForgetOperation + assert wrapper is operation._forget_operation_response + + +def test_operation_methods_dispatch_with_driver(): + driver = _FakeDriver() + op = operation.Operation(None, _operation_response("op-9"), driver=driver) + + op.cancel() + assert driver.calls[-1][0].id == "op-9" + assert driver.calls[-1][2] == _apis.OperationService.CancelOperation + + op.forget() + assert driver.calls[-1][2] == _apis.OperationService.ForgetOperation + + op.get() + request, stub, method, wrapper, settings, wrap_args = driver.calls[-1] + assert request.id == "op-9" + assert method == _apis.OperationService.GetOperation + assert wrapper is operation.Operation + assert wrap_args == (driver,) diff --git a/tests/unit/test_reader_events.py b/tests/unit/test_reader_events.py new file mode 100644 index 000000000..5b1ade8ac --- /dev/null +++ b/tests/unit/test_reader_events.py @@ -0,0 +1,76 @@ +# -*- coding: utf-8 -*- +import pytest + +from ydb._topic_reader import events +from ydb.issues import ClientInternalError + + +def test_event_dataclasses(): + commit = events.OnCommit(topic="t", offset=5) + assert commit.topic == "t" + assert commit.offset == 5 + + request = events.OnPartitionGetStartOffsetRequest(topic="t", partition_id=3) + assert request.topic == "t" + assert request.partition_id == 3 + + response = events.OnPartitionGetStartOffsetResponse(start_offset=10) + assert response.start_offset == 10 + + assert isinstance(events.OnInitPartition(), events.OnInitPartition) + assert isinstance(events.OnShutdownPartition(), events.OnShutdownPartition) + + +def test_default_event_handler_returns(): + handler = events.EventHandler() + assert handler.on_commit(events.OnCommit("t", 1)) is None + + response = handler.on_partition_get_start_offset(events.OnPartitionGetStartOffsetRequest("t", 0)) + assert isinstance(response, events.OnPartitionGetStartOffsetResponse) + assert response.start_offset is None + + assert handler.on_init_partition(events.OnInitPartition()) is None + assert handler.on_shutdown_partition(events.OnShutdownPartition()) is None + + +async def test_dispatch_sync_handlers(): + handler = events.EventHandler() + assert await handler._dispatch(events.OnCommit("t", 1)) is None + + response = await handler._dispatch(events.OnPartitionGetStartOffsetRequest("t", 0)) + assert response.start_offset is None + + assert await handler._dispatch(events.OnInitPartition()) is None + assert await handler._dispatch(events.OnShutdownPartition()) is None + + +async def test_dispatch_async_handlers(): + class AsyncHandler(events.EventHandler): + async def on_commit(self, event): + return None + + async def on_partition_get_start_offset(self, event): + return events.OnPartitionGetStartOffsetResponse(start_offset=42) + + async def on_init_partition(self, event): + return None + + async def on_shutdown_partition(self, event): + return None + + handler = AsyncHandler() + assert await handler._dispatch(events.OnCommit("t", 1)) is None + + response = await handler._dispatch(events.OnPartitionGetStartOffsetRequest("t", 0)) + assert response.start_offset == 42 + + assert await handler._dispatch(events.OnInitPartition()) is None + assert await handler._dispatch(events.OnShutdownPartition()) is None + + +async def test_dispatch_unsupported_event_raises(): + class UnknownEvent(events.BaseReaderEvent): + pass + + with pytest.raises(ClientInternalError): + await events.EventHandler()._dispatch(UnknownEvent()) diff --git a/tests/unit/test_scripting.py b/tests/unit/test_scripting.py new file mode 100644 index 000000000..694439d7d --- /dev/null +++ b/tests/unit/test_scripting.py @@ -0,0 +1,108 @@ +# -*- coding: utf-8 -*- +import pytest + +from ydb import scripting, issues, types, _apis +from ydb._grpc.common.protos import ydb_scripting_pb2 +from ydb._grpc.common import ydb_scripting_v1_pb2_grpc + + +class _FakeDriver: + def __init__(self, result="RESULT"): + self._result = result + self.calls = [] + + def __call__(self, *args, **kwargs): + self.calls.append((args, kwargs)) + return self._result + + +def _execute_response(status=issues.StatusCode.SUCCESS, result=None): + op = _apis.ydb_operation.Operation(status=status) + op.result.Pack(result if result is not None else ydb_scripting_pb2.ExecuteYqlResult()) + return ydb_scripting_pb2.ExecuteYqlResponse(operation=op) + + +def _explain_response(status=issues.StatusCode.SUCCESS, plan=""): + op = _apis.ydb_operation.Operation(status=status) + op.result.Pack(ydb_scripting_pb2.ExplainYqlResult(plan=plan)) + return ydb_scripting_pb2.ExplainYqlResponse(operation=op) + + +def test_scripting_client_settings_builders(): + s = scripting.ScriptingClientSettings() + assert s._native_date_in_result_sets is False + assert s.with_native_date_in_result_sets(True) is s + assert s._native_date_in_result_sets is True + assert s.with_native_datetime_in_result_sets(True) is s + assert s._native_datetime_in_result_sets is True + + +def test_explain_settings_with_mode(): + s = scripting.ExplainYqlScriptSettings() + assert s.with_mode(scripting.ExplainYqlScriptSettings.MODE_EXPLAIN) is s + assert s.mode == scripting.ExplainYqlScriptSettings.MODE_EXPLAIN + + +def test_execute_request_factory_without_parameters(): + request = scripting._execute_yql_query_request_factory("SELECT 1") + assert request.script == "SELECT 1" + assert len(request.parameters) == 0 + + +def test_execute_request_factory_with_parameters(): + tp = scripting.TypedParameters( + {"$x": types.PrimitiveType.Int64}, + {"$x": 10}, + ) + request = scripting._execute_yql_query_request_factory("SELECT $x", tp) + assert request.script == "SELECT $x" + assert "$x" in request.parameters + + +def test_wrap_response_success_and_error(): + result = scripting._wrap_response(None, _execute_response(), None) + assert isinstance(result, scripting.YqlQueryResult) + + with pytest.raises(issues.PreconditionFailed): + scripting._wrap_response(None, _execute_response(status=issues.StatusCode.PRECONDITION_FAILED), None) + + +def test_wrap_explain_response_success_and_error(): + result = scripting._wrap_explain_response(None, _explain_response(plan="PLAN-TEXT")) + assert isinstance(result, scripting.YqlExplainResult) + assert result.plan == "PLAN-TEXT" + + with pytest.raises(issues.BadRequest): + scripting._wrap_explain_response(None, _explain_response(status=issues.StatusCode.BAD_REQUEST)) + + +def test_execute_yql_dispatch(): + driver = _FakeDriver() + client = scripting.ScriptingClient(driver) + assert client.execute_yql("SELECT 1") == "RESULT" + + args, kwargs = driver.calls[0] + assert args[0].script == "SELECT 1" + assert args[1] is ydb_scripting_v1_pb2_grpc.ScriptingServiceStub + assert args[2] == "ExecuteYql" + assert args[3] is scripting._wrap_response + assert kwargs["wrap_args"] == (client.scripting_client_settings,) + + +def test_explain_yql_dispatch(): + driver = _FakeDriver() + client = scripting.ScriptingClient(driver) + settings = scripting.ExplainYqlScriptSettings().with_mode(scripting.ExplainYqlScriptSettings.MODE_VALIDATE) + + assert client.explain_yql("SELECT 1", settings=settings) == "RESULT" + args, kwargs = driver.calls[0] + assert args[0].script == "SELECT 1" + assert args[0].mode == scripting.ExplainYqlScriptSettings.MODE_VALIDATE + assert args[2] == "ExplainYql" + assert args[3] is scripting._wrap_explain_response + + +def test_scripting_client_uses_provided_settings(): + settings = scripting.ScriptingClientSettings().with_native_date_in_result_sets(True) + client = scripting.ScriptingClient(_FakeDriver(), scripting_client_settings=settings) + assert client.scripting_client_settings is settings diff --git a/tests/unit/test_tracing.py b/tests/unit/test_tracing.py new file mode 100644 index 000000000..4da828041 --- /dev/null +++ b/tests/unit/test_tracing.py @@ -0,0 +1,169 @@ +# -*- coding: utf-8 -*- +import pytest + +from ydb import tracing +from ydb.tracing import Tracer, TraceLevel, _TracingCtx, with_trace + + +class _FakeSpan: + def __init__(self): + self.tags = {} + self._baggage = {} + + def set_tag(self, key, value): + self.tags[key] = value + + def set_baggage_item(self, key, value): + self._baggage[key] = value + + def get_baggage_item(self, key): + return self._baggage.get(key) + + +class _FakeScope: + def __init__(self, span): + self.span = span + self.closed = False + + def close(self): + self.closed = True + + +class _FakeScopeManager: + def __init__(self): + self.active = None + + +class _FakeOpenTracer: + """Minimal opentracing.Tracer stand-in for the interface tracing.py uses.""" + + def __init__(self): + self.scope_manager = _FakeScopeManager() + self.started = [] + + def start_active_span(self, name): + scope = _FakeScope(_FakeSpan()) + self.scope_manager.active = scope + self.started.append(name) + return scope + + +def test_disabled_tracer_is_noop(): + tracer = Tracer(None) + assert tracer.enabled is False + + ctx = tracer.trace("span") + assert isinstance(ctx, _TracingCtx) + with ctx as entered: + assert entered is ctx + assert entered.enabled is False + entered.trace({"k": "v"}) # no active span, must not raise + + +def test_default_tracer_builder_composition(): + ft = _FakeOpenTracer() + tracer = Tracer.default(ft) + assert tracer.enabled is True + assert tracer._pre_tags == {"started": True} + assert tracer._post_tags_ok == {"ok": True} + assert tracer._post_tags_err == {"ok": False} + assert tracer._verbose_level == TraceLevel.INFO + + ret = tracer.with_pre_tags({"a": 1}).with_post_tags({"b": 2}, {"c": 3}).with_verbose_level(TraceLevel.DEBUG) + assert ret is tracer + assert tracer._pre_tags == {"a": 1} + assert tracer._verbose_level == TraceLevel.DEBUG + + +def test_enabled_span_ok_path(): + ft = _FakeOpenTracer() + tracer = Tracer.default(ft) + + with tracer.trace("myspan") as ctx: + assert ctx.enabled is True + assert ft.started == ["myspan"] + scope = ft.scope_manager.active + assert scope.span.get_baggage_item("ctx") is ctx + assert scope.span.tags.get("started") is True # pre-tags applied on enter + ctx.trace({"custom": 1}) + assert scope.span.tags["custom"] == 1 + + assert scope.span.tags.get("ok") is True # post-ok tags applied on exit + assert scope.closed is True + + +def test_enabled_span_error_path_runs_callback(): + ft = _FakeOpenTracer() + tracer = Tracer.default(ft).with_verbose_level(TraceLevel.NONE) # let ERROR-level traces through + + with pytest.raises(RuntimeError): + with tracer.trace("s"): + scope = ft.scope_manager.active + raise RuntimeError("boom") + + assert scope.span.tags.get("ok") is False # post-err tags applied + assert scope.span.tags.get("error.type") == "RuntimeError" # from _default_on_error_callback + assert scope.closed is True + + +def test_trace_respects_verbose_level(): + ft = _FakeOpenTracer() + tracer = Tracer(ft).with_verbose_level(TraceLevel.INFO) + + with tracer.trace("s") as ctx: + scope = ft.scope_manager.active + ctx.trace({"skipped": 1}, trace_level=TraceLevel.ERROR) # ERROR > INFO threshold -> skipped + assert "skipped" not in scope.span.tags + ctx.trace({"kept": 1}, trace_level=TraceLevel.INFO) + assert scope.span.tags["kept"] == 1 + + +def test_trace_without_active_scope_is_noop(): + ft = _FakeOpenTracer() + ctx = Tracer.default(ft).trace("s") # created but not entered -> no scope + ctx.trace({"k": 1}) # enabled but scope is None -> early return, no raise + + +def test_module_trace_disabled_returns_none(): + assert tracing.trace(Tracer(None), {"k": 1}) is None + + +def test_module_trace_no_active_scope_returns_false(): + ft = _FakeOpenTracer() + assert tracing.trace(Tracer.default(ft), {"k": 1}) is False + + +def test_module_trace_active_scope_without_ctx_returns_false(): + ft = _FakeOpenTracer() + ft.scope_manager.active = _FakeScope(_FakeSpan()) # no "ctx" baggage + assert tracing.trace(Tracer.default(ft), {"k": 1}) is False + + +def test_module_trace_with_ctx_applies_tags(): + ft = _FakeOpenTracer() + tracer = Tracer.default(ft) + with tracer.trace("s"): + assert tracing.trace(tracer, {"applied": 1}) is None + assert ft.scope_manager.active.span.tags["applied"] == 1 + + +def test_with_trace_decorator_default_and_custom_name(): + ft = _FakeOpenTracer() + + class Service: + def __init__(self): + self.tracer = Tracer.default(ft) + + @with_trace() + def do(self, value): + return value * 2 + + @with_trace("custom.span") + def do_named(self): + return "ok" + + service = Service() + assert service.do(3) == 6 + assert ft.started[-1] == "Service.do" + assert service.do_named() == "ok" + assert ft.started[-1] == "custom.span" diff --git a/ydb/_grpc/grpcwrapper/ydb_scheme.py b/ydb/_grpc/grpcwrapper/ydb_scheme.py deleted file mode 100644 index b99220357..000000000 --- a/ydb/_grpc/grpcwrapper/ydb_scheme.py +++ /dev/null @@ -1,36 +0,0 @@ -import datetime -import enum -from dataclasses import dataclass -from typing import List - - -@dataclass -class Entry: - name: str - owner: str - type: "Entry.Type" - effective_permissions: "Permissions" - permissions: "Permissions" - size_bytes: int - created_at: datetime.datetime - - class Type(enum.IntEnum): - UNSPECIFIED = 0 - DIRECTORY = 1 - TABLE = 2 - PERS_QUEUE_GROUP = 3 - DATABASE = 4 - RTMR_VOLUME = 5 - BLOCK_STORE_VOLUME = 6 - COORDINATION_NODE = 7 - COLUMN_STORE = 12 - COLUMN_TABLE = 13 - SEQUENCE = 15 - REPLICATION = 16 - TOPIC = 17 - - -@dataclass -class Permissions: - subject: str - permission_names: List[str] diff --git a/ydb/draft/dynamic_config.py b/ydb/draft/dynamic_config.py index 2089fcb88..4648b29a7 100644 --- a/ydb/draft/dynamic_config.py +++ b/ydb/draft/dynamic_config.py @@ -71,9 +71,7 @@ def _get_node_labels_request_factory(node_id): def _wrap_dynamic_config(config_pb, dynamic_config_cls=None, *args, **kwargs): dynamic_config_cls = DynamicConfig if dynamic_config_cls is None else dynamic_config_cls - return dynamic_config_cls( - config_pb.identity[0].version, config_pb.identity[0].cluster, config_pb.config[0], *args, **kwargs - ) + return dynamic_config_cls(config_pb.identity.version, config_pb.identity.cluster, config_pb.config, *args, **kwargs) def _wrap_get_config_response(rpc_state, response):