pytorch

Форк
0
/
test_diagnostics.py 
651 строка · 24.3 Кб
1
# Owner(s): ["module: onnx"]
2
from __future__ import annotations
3

4
import contextlib
5
import dataclasses
6
import io
7
import logging
8
import typing
9
import unittest
10
from typing import AbstractSet, Protocol, Tuple
11

12
import torch
13
from torch.onnx import errors
14
from torch.onnx._internal import diagnostics
15
from torch.onnx._internal.diagnostics import infra
16
from torch.onnx._internal.diagnostics.infra import formatter, sarif
17
from torch.onnx._internal.fx import diagnostics as fx_diagnostics
18
from torch.testing._internal import common_utils, logging_utils
19

20

21
class _SarifLogBuilder(Protocol):
22
    def sarif_log(self) -> sarif.SarifLog:
23
        ...
24

25

26
def _assert_has_diagnostics(
27
    sarif_log_builder: _SarifLogBuilder,
28
    rule_level_pairs: AbstractSet[Tuple[infra.Rule, infra.Level]],
29
):
30
    sarif_log = sarif_log_builder.sarif_log()
31
    unseen_pairs = {(rule.id, level.name.lower()) for rule, level in rule_level_pairs}
32
    actual_results = []
33
    for run in sarif_log.runs:
34
        if run.results is None:
35
            continue
36
        for result in run.results:
37
            id_level_pair = (result.rule_id, result.level)
38
            unseen_pairs.discard(id_level_pair)
39
            actual_results.append(id_level_pair)
40

41
    if unseen_pairs:
42
        raise AssertionError(
43
            f"Expected diagnostic results of rule id and level pair {unseen_pairs} not found. "
44
            f"Actual diagnostic results: {actual_results}"
45
        )
46

47

48
@dataclasses.dataclass
49
class _RuleCollectionForTest(infra.RuleCollection):
50
    rule_without_message_args: infra.Rule = dataclasses.field(
51
        default=infra.Rule(
52
            "1",
53
            "rule-without-message-args",
54
            message_default_template="rule message",
55
        )
56
    )
57

58

59
@contextlib.contextmanager
60
def assert_all_diagnostics(
61
    test_suite: unittest.TestCase,
62
    sarif_log_builder: _SarifLogBuilder,
63
    rule_level_pairs: AbstractSet[Tuple[infra.Rule, infra.Level]],
64
):
65
    """Context manager to assert that all diagnostics are emitted.
66

67
    Usage:
68
        with assert_all_diagnostics(
69
            self,
70
            diagnostics.engine,
71
            {(rule, infra.Level.Error)},
72
        ):
73
            torch.onnx.export(...)
74

75
    Args:
76
        test_suite: The test suite instance.
77
        sarif_log_builder: The SARIF log builder.
78
        rule_level_pairs: A set of rule and level pairs to assert.
79

80
    Returns:
81
        A context manager.
82

83
    Raises:
84
        AssertionError: If not all diagnostics are emitted.
85
    """
86

87
    try:
88
        yield
89
    except errors.OnnxExporterError:
90
        test_suite.assertIn(infra.Level.ERROR, {level for _, level in rule_level_pairs})
91
    finally:
92
        _assert_has_diagnostics(sarif_log_builder, rule_level_pairs)
93

94

95
def assert_diagnostic(
96
    test_suite: unittest.TestCase,
97
    sarif_log_builder: _SarifLogBuilder,
98
    rule: infra.Rule,
99
    level: infra.Level,
100
):
101
    """Context manager to assert that a diagnostic is emitted.
102

103
    Usage:
104
        with assert_diagnostic(
105
            self,
106
            diagnostics.engine,
107
            rule,
108
            infra.Level.Error,
109
        ):
110
            torch.onnx.export(...)
111

112
    Args:
113
        test_suite: The test suite instance.
114
        sarif_log_builder: The SARIF log builder.
115
        rule: The rule to assert.
116
        level: The level to assert.
117

118
    Returns:
119
        A context manager.
120

121
    Raises:
122
        AssertionError: If the diagnostic is not emitted.
123
    """
124

125
    return assert_all_diagnostics(test_suite, sarif_log_builder, {(rule, level)})
126

127

128
class TestDynamoOnnxDiagnostics(common_utils.TestCase):
129
    """Test cases for diagnostics emitted by the Dynamo ONNX export code."""
130

131
    def setUp(self):
132
        self.diagnostic_context = fx_diagnostics.DiagnosticContext("dynamo_export", "")
133
        self.rules = _RuleCollectionForTest()
134
        return super().setUp()
135

136
    def test_log_is_recorded_in_sarif_additional_messages_according_to_diagnostic_options_verbosity_level(
137
        self,
138
    ):
139
        logging_levels = [
140
            logging.DEBUG,
141
            logging.INFO,
142
            logging.WARNING,
143
            logging.ERROR,
144
        ]
145
        for verbosity_level in logging_levels:
146
            self.diagnostic_context.options.verbosity_level = verbosity_level
147
            with self.diagnostic_context:
148
                diagnostic = fx_diagnostics.Diagnostic(
149
                    self.rules.rule_without_message_args, infra.Level.NONE
150
                )
151
                additional_messages_count = len(diagnostic.additional_messages)
152
                for log_level in logging_levels:
153
                    diagnostic.log(level=log_level, message="log message")
154
                    if log_level >= verbosity_level:
155
                        self.assertGreater(
156
                            len(diagnostic.additional_messages),
157
                            additional_messages_count,
158
                            f"Additional message should be recorded when log level is {log_level} "
159
                            f"and verbosity level is {verbosity_level}",
160
                        )
161
                    else:
162
                        self.assertEqual(
163
                            len(diagnostic.additional_messages),
164
                            additional_messages_count,
165
                            f"Additional message should not be recorded when log level is "
166
                            f"{log_level} and verbosity level is {verbosity_level}",
167
                        )
168

169
    def test_torch_logs_environment_variable_precedes_diagnostic_options_verbosity_level(
170
        self,
171
    ):
172
        self.diagnostic_context.options.verbosity_level = logging.ERROR
173
        with logging_utils.log_settings("onnx_diagnostics"), self.diagnostic_context:
174
            diagnostic = fx_diagnostics.Diagnostic(
175
                self.rules.rule_without_message_args, infra.Level.NONE
176
            )
177
            additional_messages_count = len(diagnostic.additional_messages)
178
            diagnostic.debug("message")
179
            self.assertGreater(
180
                len(diagnostic.additional_messages), additional_messages_count
181
            )
182

183
    def test_log_is_not_emitted_to_terminal_when_log_artifact_is_not_enabled(self):
184
        self.diagnostic_context.options.verbosity_level = logging.INFO
185
        with self.diagnostic_context:
186
            diagnostic = fx_diagnostics.Diagnostic(
187
                self.rules.rule_without_message_args, infra.Level.NONE
188
            )
189

190
            with self.assertLogs(
191
                diagnostic.logger, level=logging.INFO
192
            ) as assert_log_context:
193
                diagnostic.info("message")
194
                # NOTE: self.assertNoLogs only exist >= Python 3.10
195
                # Add this dummy log such that we can pass self.assertLogs, and inspect
196
                # assert_log_context.records to check if the log we don't want is not emitted.
197
                diagnostic.logger.log(logging.ERROR, "dummy message")
198

199
            self.assertEqual(len(assert_log_context.records), 1)
200

201
    def test_log_is_emitted_to_terminal_when_log_artifact_is_enabled(self):
202
        self.diagnostic_context.options.verbosity_level = logging.INFO
203

204
        with logging_utils.log_settings("onnx_diagnostics"), self.diagnostic_context:
205
            diagnostic = fx_diagnostics.Diagnostic(
206
                self.rules.rule_without_message_args, infra.Level.NONE
207
            )
208

209
            with self.assertLogs(diagnostic.logger, level=logging.INFO):
210
                diagnostic.info("message")
211

212
    def test_diagnostic_log_emit_correctly_formatted_string(self):
213
        verbosity_level = logging.INFO
214
        self.diagnostic_context.options.verbosity_level = verbosity_level
215
        with self.diagnostic_context:
216
            diagnostic = fx_diagnostics.Diagnostic(
217
                self.rules.rule_without_message_args, infra.Level.NOTE
218
            )
219
            diagnostic.log(
220
                logging.INFO,
221
                "%s",
222
                formatter.LazyString(lambda x, y: f"{x} {y}", "hello", "world"),
223
            )
224
            self.assertIn("hello world", diagnostic.additional_messages)
225

226
    def test_log_diagnostic_to_diagnostic_context_raises_when_diagnostic_type_is_wrong(
227
        self,
228
    ):
229
        with self.diagnostic_context:
230
            # Dynamo onnx exporter diagnostic context expects fx_diagnostics.Diagnostic
231
            # instead of base infra.Diagnostic.
232
            diagnostic = infra.Diagnostic(
233
                self.rules.rule_without_message_args, infra.Level.NOTE
234
            )
235
            with self.assertRaises(TypeError):
236
                self.diagnostic_context.log(diagnostic)
237

238

239
class TestTorchScriptOnnxDiagnostics(common_utils.TestCase):
240
    """Test cases for diagnostics emitted by the TorchScript ONNX export code."""
241

242
    def setUp(self):
243
        engine = diagnostics.engine
244
        engine.clear()
245
        self._sample_rule = diagnostics.rules.missing_custom_symbolic_function
246
        super().setUp()
247

248
    def _trigger_node_missing_onnx_shape_inference_warning_diagnostic_from_cpp(
249
        self,
250
    ) -> diagnostics.TorchScriptOnnxExportDiagnostic:
251
        class CustomAdd(torch.autograd.Function):
252
            @staticmethod
253
            def forward(ctx, x, y):
254
                return x + y
255

256
            @staticmethod
257
            def symbolic(g, x, y):
258
                return g.op("custom::CustomAdd", x, y)
259

260
        class M(torch.nn.Module):
261
            def forward(self, x):
262
                return CustomAdd.apply(x, x)
263

264
        # trigger warning for missing shape inference.
265
        rule = diagnostics.rules.node_missing_onnx_shape_inference
266
        torch.onnx.export(M(), torch.randn(3, 4), io.BytesIO())
267

268
        context = diagnostics.engine.contexts[-1]
269
        for diagnostic in context.diagnostics:
270
            if (
271
                diagnostic.rule == rule
272
                and diagnostic.level == diagnostics.levels.WARNING
273
            ):
274
                return typing.cast(
275
                    diagnostics.TorchScriptOnnxExportDiagnostic, diagnostic
276
                )
277
        raise AssertionError("No diagnostic found.")
278

279
    def test_assert_diagnostic_raises_when_diagnostic_not_found(self):
280
        with self.assertRaises(AssertionError):
281
            with assert_diagnostic(
282
                self,
283
                diagnostics.engine,
284
                diagnostics.rules.node_missing_onnx_shape_inference,
285
                diagnostics.levels.WARNING,
286
            ):
287
                pass
288

289
    def test_cpp_diagnose_emits_warning(self):
290
        with assert_diagnostic(
291
            self,
292
            diagnostics.engine,
293
            diagnostics.rules.node_missing_onnx_shape_inference,
294
            diagnostics.levels.WARNING,
295
        ):
296
            # trigger warning for missing shape inference.
297
            self._trigger_node_missing_onnx_shape_inference_warning_diagnostic_from_cpp()
298

299
    def test_py_diagnose_emits_error(self):
300
        class M(torch.nn.Module):
301
            def forward(self, x):
302
                return torch.diagonal(x)
303

304
        with assert_diagnostic(
305
            self,
306
            diagnostics.engine,
307
            diagnostics.rules.operator_supported_in_newer_opset_version,
308
            diagnostics.levels.ERROR,
309
        ):
310
            # trigger error for operator unsupported until newer opset version.
311
            torch.onnx.export(
312
                M(),
313
                torch.randn(3, 4),
314
                io.BytesIO(),
315
                opset_version=9,
316
            )
317

318
    def test_diagnostics_engine_records_diagnosis_reported_outside_of_export(
319
        self,
320
    ):
321
        sample_level = diagnostics.levels.ERROR
322
        with assert_diagnostic(
323
            self,
324
            diagnostics.engine,
325
            self._sample_rule,
326
            sample_level,
327
        ):
328
            diagnostic = infra.Diagnostic(self._sample_rule, sample_level)
329
            diagnostics.export_context().log(diagnostic)
330

331
    def test_diagnostics_records_python_call_stack(self):
332
        diagnostic = diagnostics.TorchScriptOnnxExportDiagnostic(self._sample_rule, diagnostics.levels.NOTE)  # fmt: skip
333
        # Do not break the above line, otherwise it will not work with Python-3.8+
334
        stack = diagnostic.python_call_stack
335
        assert stack is not None  # for mypy
336
        self.assertGreater(len(stack.frames), 0)
337
        frame = stack.frames[0]
338
        assert frame.location.snippet is not None  # for mypy
339
        self.assertIn("self._sample_rule", frame.location.snippet)
340
        assert frame.location.uri is not None  # for mypy
341
        self.assertIn("test_diagnostics.py", frame.location.uri)
342

343
    def test_diagnostics_records_cpp_call_stack(self):
344
        diagnostic = (
345
            self._trigger_node_missing_onnx_shape_inference_warning_diagnostic_from_cpp()
346
        )
347
        stack = diagnostic.cpp_call_stack
348
        assert stack is not None  # for mypy
349
        self.assertGreater(len(stack.frames), 0)
350
        frame_messages = [frame.location.message for frame in stack.frames]
351
        # node missing onnx shape inference warning only comes from ToONNX (_jit_pass_onnx)
352
        # after node-level shape type inference and processed symbolic_fn output type
353
        self.assertTrue(
354
            any(
355
                isinstance(message, str) and "torch::jit::NodeToONNX" in message
356
                for message in frame_messages
357
            )
358
        )
359

360

361
@common_utils.instantiate_parametrized_tests
362
class TestDiagnosticsInfra(common_utils.TestCase):
363
    """Test cases for diagnostics infra."""
364

365
    def setUp(self):
366
        self.rules = _RuleCollectionForTest()
367
        with contextlib.ExitStack() as stack:
368
            self.context: infra.DiagnosticContext[
369
                infra.Diagnostic
370
            ] = stack.enter_context(infra.DiagnosticContext("test", "1.0.0"))
371
            self.addCleanup(stack.pop_all().close)
372
        return super().setUp()
373

374
    def test_diagnostics_engine_records_diagnosis_with_custom_rules(self):
375
        custom_rules = infra.RuleCollection.custom_collection_from_list(
376
            "CustomRuleCollection",
377
            [
378
                infra.Rule(
379
                    "1",
380
                    "custom-rule",
381
                    message_default_template="custom rule message",
382
                ),
383
                infra.Rule(
384
                    "2",
385
                    "custom-rule-2",
386
                    message_default_template="custom rule message 2",
387
                ),
388
            ],
389
        )
390

391
        with assert_all_diagnostics(
392
            self,
393
            self.context,
394
            {
395
                (custom_rules.custom_rule, infra.Level.WARNING),  # type: ignore[attr-defined]
396
                (custom_rules.custom_rule_2, infra.Level.ERROR),  # type: ignore[attr-defined]
397
            },
398
        ):
399
            diagnostic1 = infra.Diagnostic(
400
                custom_rules.custom_rule, infra.Level.WARNING  # type: ignore[attr-defined]
401
            )
402
            self.context.log(diagnostic1)
403

404
            diagnostic2 = infra.Diagnostic(
405
                custom_rules.custom_rule_2, infra.Level.ERROR  # type: ignore[attr-defined]
406
            )
407
            self.context.log(diagnostic2)
408

409
    def test_diagnostic_log_is_not_emitted_when_level_less_than_diagnostic_options_verbosity_level(
410
        self,
411
    ):
412
        verbosity_level = logging.INFO
413
        self.context.options.verbosity_level = verbosity_level
414
        with self.context:
415
            diagnostic = infra.Diagnostic(
416
                self.rules.rule_without_message_args, infra.Level.NOTE
417
            )
418

419
            with self.assertLogs(
420
                diagnostic.logger, level=verbosity_level
421
            ) as assert_log_context:
422
                diagnostic.log(logging.DEBUG, "debug message")
423
                # NOTE: self.assertNoLogs only exist >= Python 3.10
424
                # Add this dummy log such that we can pass self.assertLogs, and inspect
425
                # assert_log_context.records to check if the log level is correct.
426
                diagnostic.log(logging.INFO, "info message")
427

428
        for record in assert_log_context.records:
429
            self.assertGreaterEqual(record.levelno, logging.INFO)
430
        self.assertFalse(
431
            any(
432
                message.find("debug message") >= 0
433
                for message in diagnostic.additional_messages
434
            )
435
        )
436

437
    def test_diagnostic_log_is_emitted_when_level_not_less_than_diagnostic_options_verbosity_level(
438
        self,
439
    ):
440
        verbosity_level = logging.INFO
441
        self.context.options.verbosity_level = verbosity_level
442
        with self.context:
443
            diagnostic = infra.Diagnostic(
444
                self.rules.rule_without_message_args, infra.Level.NOTE
445
            )
446

447
            level_message_pairs = [
448
                (logging.INFO, "info message"),
449
                (logging.WARNING, "warning message"),
450
                (logging.ERROR, "error message"),
451
            ]
452

453
            for level, message in level_message_pairs:
454
                with self.assertLogs(diagnostic.logger, level=verbosity_level):
455
                    diagnostic.log(level, message)
456

457
            self.assertTrue(
458
                any(
459
                    message.find(message) >= 0
460
                    for message in diagnostic.additional_messages
461
                )
462
            )
463

464
    @common_utils.parametrize(
465
        "log_api, log_level",
466
        [
467
            ("debug", logging.DEBUG),
468
            ("info", logging.INFO),
469
            ("warning", logging.WARNING),
470
            ("error", logging.ERROR),
471
        ],
472
    )
473
    def test_diagnostic_log_is_emitted_according_to_api_level_and_diagnostic_options_verbosity_level(
474
        self, log_api: str, log_level: int
475
    ):
476
        verbosity_level = logging.INFO
477
        self.context.options.verbosity_level = verbosity_level
478
        with self.context:
479
            diagnostic = infra.Diagnostic(
480
                self.rules.rule_without_message_args, infra.Level.NOTE
481
            )
482

483
            message = "log message"
484
            with self.assertLogs(
485
                diagnostic.logger, level=verbosity_level
486
            ) as assert_log_context:
487
                getattr(diagnostic, log_api)(message)
488
                # NOTE: self.assertNoLogs only exist >= Python 3.10
489
                # Add this dummy log such that we can pass self.assertLogs, and inspect
490
                # assert_log_context.records to check if the log level is correct.
491
                diagnostic.log(logging.ERROR, "dummy message")
492

493
            for record in assert_log_context.records:
494
                self.assertGreaterEqual(record.levelno, logging.INFO)
495

496
            if log_level >= verbosity_level:
497
                self.assertIn(message, diagnostic.additional_messages)
498
            else:
499
                self.assertNotIn(message, diagnostic.additional_messages)
500

501
    def test_diagnostic_log_lazy_string_is_not_evaluated_when_level_less_than_diagnostic_options_verbosity_level(
502
        self,
503
    ):
504
        verbosity_level = logging.INFO
505
        self.context.options.verbosity_level = verbosity_level
506
        with self.context:
507
            diagnostic = infra.Diagnostic(
508
                self.rules.rule_without_message_args, infra.Level.NOTE
509
            )
510

511
            reference_val = 0
512

513
            def expensive_formatting_function() -> str:
514
                # Modify the reference_val to reflect this function is evaluated
515
                nonlocal reference_val
516
                reference_val += 1
517
                return f"expensive formatting {reference_val}"
518

519
            # `expensive_formatting_function` should NOT be evaluated.
520
            diagnostic.debug("%s", formatter.LazyString(expensive_formatting_function))
521
            self.assertEqual(
522
                reference_val,
523
                0,
524
                "expensive_formatting_function should not be evaluated after being wrapped under LazyString",
525
            )
526

527
    def test_diagnostic_log_lazy_string_is_evaluated_once_when_level_not_less_than_diagnostic_options_verbosity_level(
528
        self,
529
    ):
530
        verbosity_level = logging.INFO
531
        self.context.options.verbosity_level = verbosity_level
532
        with self.context:
533
            diagnostic = infra.Diagnostic(
534
                self.rules.rule_without_message_args, infra.Level.NOTE
535
            )
536

537
            reference_val = 0
538

539
            def expensive_formatting_function() -> str:
540
                # Modify the reference_val to reflect this function is evaluated
541
                nonlocal reference_val
542
                reference_val += 1
543
                return f"expensive formatting {reference_val}"
544

545
            # `expensive_formatting_function` should NOT be evaluated.
546
            diagnostic.info("%s", formatter.LazyString(expensive_formatting_function))
547
            self.assertEqual(
548
                reference_val,
549
                1,
550
                "expensive_formatting_function should only be evaluated once after being wrapped under LazyString",
551
            )
552

553
    def test_diagnostic_log_emit_correctly_formatted_string(self):
554
        verbosity_level = logging.INFO
555
        self.context.options.verbosity_level = verbosity_level
556
        with self.context:
557
            diagnostic = infra.Diagnostic(
558
                self.rules.rule_without_message_args, infra.Level.NOTE
559
            )
560
            diagnostic.log(
561
                logging.INFO,
562
                "%s",
563
                formatter.LazyString(lambda x, y: f"{x} {y}", "hello", "world"),
564
            )
565
            self.assertIn("hello world", diagnostic.additional_messages)
566

567
    def test_diagnostic_nested_log_section_emits_messages_with_correct_section_title_indentation(
568
        self,
569
    ):
570
        verbosity_level = logging.INFO
571
        self.context.options.verbosity_level = verbosity_level
572
        with self.context:
573
            diagnostic = infra.Diagnostic(
574
                self.rules.rule_without_message_args, infra.Level.NOTE
575
            )
576

577
            with diagnostic.log_section(logging.INFO, "My Section"):
578
                diagnostic.log(logging.INFO, "My Message")
579
                with diagnostic.log_section(logging.INFO, "My Subsection"):
580
                    diagnostic.log(logging.INFO, "My Submessage")
581

582
            with diagnostic.log_section(logging.INFO, "My Section 2"):
583
                diagnostic.log(logging.INFO, "My Message 2")
584

585
            self.assertIn("## My Section", diagnostic.additional_messages)
586
            self.assertIn("### My Subsection", diagnostic.additional_messages)
587
            self.assertIn("## My Section 2", diagnostic.additional_messages)
588

589
    def test_diagnostic_log_source_exception_emits_exception_traceback_and_error_message(
590
        self,
591
    ):
592
        verbosity_level = logging.INFO
593
        self.context.options.verbosity_level = verbosity_level
594
        with self.context:
595
            try:
596
                raise ValueError("original exception")
597
            except ValueError as e:
598
                diagnostic = infra.Diagnostic(
599
                    self.rules.rule_without_message_args, infra.Level.NOTE
600
                )
601
                diagnostic.log_source_exception(logging.ERROR, e)
602

603
            diagnostic_message = "\n".join(diagnostic.additional_messages)
604

605
            self.assertIn("ValueError: original exception", diagnostic_message)
606
            self.assertIn("Traceback (most recent call last):", diagnostic_message)
607

608
    def test_log_diagnostic_to_diagnostic_context_raises_when_diagnostic_type_is_wrong(
609
        self,
610
    ):
611
        with self.context:
612
            with self.assertRaises(TypeError):
613
                # The method expects 'Diagnostic' or its subclasses as arguments.
614
                # Passing any other type will trigger a TypeError.
615
                self.context.log("This is a str message.")
616

617
    def test_diagnostic_context_raises_if_diagnostic_is_error(self):
618
        with self.assertRaises(infra.RuntimeErrorWithDiagnostic):
619
            self.context.log_and_raise_if_error(
620
                infra.Diagnostic(
621
                    self.rules.rule_without_message_args, infra.Level.ERROR
622
                )
623
            )
624

625
    def test_diagnostic_context_raises_original_exception_from_diagnostic_created_from_it(
626
        self,
627
    ):
628
        with self.assertRaises(ValueError):
629
            try:
630
                raise ValueError("original exception")
631
            except ValueError as e:
632
                diagnostic = infra.Diagnostic(
633
                    self.rules.rule_without_message_args, infra.Level.ERROR
634
                )
635
                diagnostic.log_source_exception(logging.ERROR, e)
636
                self.context.log_and_raise_if_error(diagnostic)
637

638
    def test_diagnostic_context_raises_if_diagnostic_is_warning_and_warnings_as_errors_is_true(
639
        self,
640
    ):
641
        with self.assertRaises(infra.RuntimeErrorWithDiagnostic):
642
            self.context.options.warnings_as_errors = True
643
            self.context.log_and_raise_if_error(
644
                infra.Diagnostic(
645
                    self.rules.rule_without_message_args, infra.Level.WARNING
646
                )
647
            )
648

649

650
if __name__ == "__main__":
651
    common_utils.run_tests()
652

Использование cookies

Мы используем файлы cookie в соответствии с Политикой конфиденциальности и Политикой использования cookies.

Нажимая кнопку «Принимаю», Вы даете АО «СберТех» согласие на обработку Ваших персональных данных в целях совершенствования нашего веб-сайта и Сервиса GitVerse, а также повышения удобства их использования.

Запретить использование cookies Вы можете самостоятельно в настройках Вашего браузера.