tquality-py-core 0.1.4__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.
- tquality_core/__init__.py +27 -0
- tquality_core/cli.py +107 -0
- tquality_core/config.py +158 -0
- tquality_core/elements/__init__.py +4 -0
- tquality_core/elements/base_element.py +82 -0
- tquality_core/elements/locator.py +26 -0
- tquality_core/pages/__init__.py +3 -0
- tquality_core/pages/base_form.py +42 -0
- tquality_core/py.typed +0 -0
- tquality_core/schema.py +66 -0
- tquality_core/services/__init__.py +17 -0
- tquality_core/services/logger.py +280 -0
- tquality_core/utils/__init__.py +3 -0
- tquality_core/utils/string_utils.py +28 -0
- tquality_py_core-0.1.4.dist-info/METADATA +185 -0
- tquality_py_core-0.1.4.dist-info/RECORD +20 -0
- tquality_py_core-0.1.4.dist-info/WHEEL +4 -0
- tquality_py_core-0.1.4.dist-info/entry_points.txt +2 -0
- tquality_py_core-0.1.4.dist-info/licenses/LICENSE +201 -0
- tquality_py_core-0.1.4.dist-info/licenses/NOTICE +5 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from tquality_core.config import BaseConfig
|
|
2
|
+
from tquality_core.elements.base_element import BaseElement
|
|
3
|
+
from tquality_core.elements.locator import Locator
|
|
4
|
+
from tquality_core.pages.base_form import BaseForm
|
|
5
|
+
from tquality_core.services.logger import (
|
|
6
|
+
Logger,
|
|
7
|
+
LogLevel,
|
|
8
|
+
ScreencastProvider,
|
|
9
|
+
ScreenshotProvider,
|
|
10
|
+
set_logger_resolver,
|
|
11
|
+
step,
|
|
12
|
+
)
|
|
13
|
+
from tquality_core.utils.string_utils import StringUtils
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"BaseConfig",
|
|
17
|
+
"BaseElement",
|
|
18
|
+
"BaseForm",
|
|
19
|
+
"Locator",
|
|
20
|
+
"Logger",
|
|
21
|
+
"LogLevel",
|
|
22
|
+
"ScreencastProvider",
|
|
23
|
+
"ScreenshotProvider",
|
|
24
|
+
"StringUtils",
|
|
25
|
+
"set_logger_resolver",
|
|
26
|
+
"step",
|
|
27
|
+
]
|
tquality_core/cli.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""CLI-команды tquality-py-core.
|
|
2
|
+
|
|
3
|
+
Точка входа: `tquality-config`. Доступные подкоманды:
|
|
4
|
+
|
|
5
|
+
- `init` - сгенерировать config.json5 в корне проекта со значениями по умолчанию
|
|
6
|
+
- `schema` - сгенерировать/обновить schema/config.schema.json (для мейнтейнеров)
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import json
|
|
12
|
+
import sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from tquality_core.config import BaseConfig, CONFIG_FILENAME
|
|
17
|
+
from tquality_core.schema import SCHEMA_URL, write_schema_file
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _find_project_root() -> Path:
|
|
21
|
+
"""Найти корень проекта: поднимаемся до pyproject.toml."""
|
|
22
|
+
current = Path.cwd().resolve()
|
|
23
|
+
for parent in (current, *current.parents):
|
|
24
|
+
if (parent / "pyproject.toml").exists():
|
|
25
|
+
return parent
|
|
26
|
+
return current
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _default_config_dict() -> dict[str, Any]:
|
|
30
|
+
"""Словарь значений по умолчанию BaseConfig с ссылкой на схему."""
|
|
31
|
+
cfg = BaseConfig()
|
|
32
|
+
data: dict[str, Any] = {"$schema": SCHEMA_URL}
|
|
33
|
+
data.update(cfg.model_dump())
|
|
34
|
+
return data
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def cmd_init(args: argparse.Namespace) -> int:
|
|
38
|
+
"""Сгенерировать config.json5 со значениями по умолчанию."""
|
|
39
|
+
target_dir = Path(args.path).resolve() if args.path else _find_project_root()
|
|
40
|
+
target_file = target_dir / CONFIG_FILENAME
|
|
41
|
+
|
|
42
|
+
if target_file.exists() and not args.force:
|
|
43
|
+
print(
|
|
44
|
+
f"Файл уже существует: {target_file}. "
|
|
45
|
+
f"Используйте --force для перезаписи.",
|
|
46
|
+
file=sys.stderr,
|
|
47
|
+
)
|
|
48
|
+
return 1
|
|
49
|
+
|
|
50
|
+
config_data = _default_config_dict()
|
|
51
|
+
target_file.write_text(
|
|
52
|
+
json.dumps(config_data, indent=4, ensure_ascii=False) + "\n",
|
|
53
|
+
encoding="utf-8",
|
|
54
|
+
)
|
|
55
|
+
print(f"Создан {target_file}")
|
|
56
|
+
return 0
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def cmd_schema(args: argparse.Namespace) -> int:
|
|
60
|
+
"""Сгенерировать schema/config.schema.json."""
|
|
61
|
+
target_dir = Path(args.path).resolve() if args.path else _find_project_root()
|
|
62
|
+
target_file = target_dir / "schema" / "config.schema.json"
|
|
63
|
+
|
|
64
|
+
write_schema_file(target_file)
|
|
65
|
+
print(f"Схема записана в {target_file}")
|
|
66
|
+
return 0
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def main(argv: list[str] | None = None) -> int:
|
|
70
|
+
parser = argparse.ArgumentParser(
|
|
71
|
+
prog="tquality-config",
|
|
72
|
+
description="Утилиты работы с конфигурацией tquality-py-core",
|
|
73
|
+
)
|
|
74
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
75
|
+
|
|
76
|
+
p_init = subparsers.add_parser(
|
|
77
|
+
"init",
|
|
78
|
+
help="Сгенерировать config.json5 со значениями по умолчанию",
|
|
79
|
+
)
|
|
80
|
+
p_init.add_argument(
|
|
81
|
+
"--path",
|
|
82
|
+
help="Каталог, в котором создать config.json5 (по умолчанию - корень проекта)",
|
|
83
|
+
)
|
|
84
|
+
p_init.add_argument(
|
|
85
|
+
"--force",
|
|
86
|
+
action="store_true",
|
|
87
|
+
help="Перезаписать существующий config.json5",
|
|
88
|
+
)
|
|
89
|
+
p_init.set_defaults(func=cmd_init)
|
|
90
|
+
|
|
91
|
+
p_schema = subparsers.add_parser(
|
|
92
|
+
"schema",
|
|
93
|
+
help="Сгенерировать schema/config.schema.json (для мейнтейнеров)",
|
|
94
|
+
)
|
|
95
|
+
p_schema.add_argument(
|
|
96
|
+
"--path",
|
|
97
|
+
help="Корень репозитория (по умолчанию - текущий проект)",
|
|
98
|
+
)
|
|
99
|
+
p_schema.set_defaults(func=cmd_schema)
|
|
100
|
+
|
|
101
|
+
args = parser.parse_args(argv)
|
|
102
|
+
result: int = args.func(args)
|
|
103
|
+
return result
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
if __name__ == "__main__":
|
|
107
|
+
sys.exit(main())
|
tquality_core/config.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Базовая конфигурация для проектов автоматизации тестирования.
|
|
2
|
+
|
|
3
|
+
Расширяйте `BaseConfig` в своем проекте, чтобы добавить поля, специфичные
|
|
4
|
+
для драйвера (тип браузера, размер окна и т.д.). Ядро определяет только
|
|
5
|
+
поля, универсальные для всех драйверов.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import json5
|
|
13
|
+
from pydantic import Field
|
|
14
|
+
from pydantic_settings import (
|
|
15
|
+
BaseSettings,
|
|
16
|
+
JsonConfigSettingsSource,
|
|
17
|
+
PydanticBaseSettingsSource,
|
|
18
|
+
SettingsConfigDict,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class _JsoncConfigSettingsSource(JsonConfigSettingsSource):
|
|
23
|
+
"""Как `JsonConfigSettingsSource`, но парсит jsonc/json5.
|
|
24
|
+
|
|
25
|
+
Позволяет пользователю оставлять в `config.json` комментарии
|
|
26
|
+
(`//`, `/* */`) и висячие запятые - полезно, чтобы рядом с настройкой
|
|
27
|
+
описать, зачем она такая.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def _read_file(self, file_path: Path) -> dict[str, Any]:
|
|
31
|
+
with file_path.open(encoding=self.json_file_encoding) as f:
|
|
32
|
+
data = json5.load(f)
|
|
33
|
+
if not isinstance(data, dict):
|
|
34
|
+
raise ValueError(
|
|
35
|
+
f"{file_path}: ожидается JSON-объект на верхнем уровне",
|
|
36
|
+
)
|
|
37
|
+
return data
|
|
38
|
+
|
|
39
|
+
CONFIG_FILENAME = "config.json5"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _find_project_root() -> Path | None:
|
|
43
|
+
"""Найти корень workspace, поднимаясь по родительским директориям."""
|
|
44
|
+
current = Path.cwd().resolve()
|
|
45
|
+
for parent in (current, *current.parents):
|
|
46
|
+
pyproject = parent / "pyproject.toml"
|
|
47
|
+
if pyproject.exists() and "tool.uv.workspace" in pyproject.read_text():
|
|
48
|
+
return parent
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _collect_config_chain(start: Path, stop: Path | None) -> list[Path]:
|
|
53
|
+
"""Собрать `config.json` от start к stop (включительно).
|
|
54
|
+
|
|
55
|
+
Возвращает список путей, упорядоченный от самого специфичного (ближнего к
|
|
56
|
+
start) до самого общего (в stop). Пропускает директории без config.json.
|
|
57
|
+
Останавливается при достижении stop или корня файловой системы.
|
|
58
|
+
"""
|
|
59
|
+
found: list[Path] = []
|
|
60
|
+
current = start.resolve()
|
|
61
|
+
stop_resolved = stop.resolve() if stop is not None else None
|
|
62
|
+
|
|
63
|
+
while True:
|
|
64
|
+
candidate = current / CONFIG_FILENAME
|
|
65
|
+
if candidate.exists():
|
|
66
|
+
found.append(candidate)
|
|
67
|
+
if stop_resolved is not None and current == stop_resolved:
|
|
68
|
+
break
|
|
69
|
+
if current.parent == current:
|
|
70
|
+
break
|
|
71
|
+
current = current.parent
|
|
72
|
+
|
|
73
|
+
return found
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class BaseConfig(BaseSettings):
|
|
77
|
+
"""Драйвер-независимая конфигурация.
|
|
78
|
+
|
|
79
|
+
Наследуйтесь от этого класса для добавления полей, специфичных для
|
|
80
|
+
драйвера.
|
|
81
|
+
|
|
82
|
+
### Порядок разрешения настроек (от высшего приоритета к низшему)
|
|
83
|
+
|
|
84
|
+
1. Аргументы конструктора
|
|
85
|
+
2. Переменные окружения (префикс `TEST_`)
|
|
86
|
+
3. Файл `.env`
|
|
87
|
+
4. Цепочка `config.json` от текущей директории вверх до корня workspace.
|
|
88
|
+
Более специфичный (ближний к cwd) побеждает менее специфичный.
|
|
89
|
+
Например, при запуске из `tests/integration/critical/` приоритет:
|
|
90
|
+
`critical/config.json` > `integration/config.json` > `tests/config.json`
|
|
91
|
+
> `config.json` в корне workspace.
|
|
92
|
+
5. Значения по умолчанию.
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
model_config = SettingsConfigDict(
|
|
96
|
+
env_prefix="TEST_",
|
|
97
|
+
env_file=".env",
|
|
98
|
+
env_file_encoding="utf-8",
|
|
99
|
+
extra="ignore",
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
base_url: str = Field(
|
|
103
|
+
default="http://localhost",
|
|
104
|
+
description=(
|
|
105
|
+
"Базовый URL тестируемого приложения. Абсолютный, со схемой "
|
|
106
|
+
"http или https."
|
|
107
|
+
),
|
|
108
|
+
pattern=r"^https?://\S+$",
|
|
109
|
+
)
|
|
110
|
+
default_timeout: float = Field(
|
|
111
|
+
default=10.0,
|
|
112
|
+
description=(
|
|
113
|
+
"Таймаут по умолчанию для explicit wait операций с элементами "
|
|
114
|
+
"(сек). Должен быть положительным."
|
|
115
|
+
),
|
|
116
|
+
gt=0,
|
|
117
|
+
)
|
|
118
|
+
log_dir: str = Field(
|
|
119
|
+
default="logs",
|
|
120
|
+
description=(
|
|
121
|
+
"Директория для файлов логов тестов (относительно корня проекта "
|
|
122
|
+
"или абсолютный путь). Создается автоматически если отсутствует."
|
|
123
|
+
),
|
|
124
|
+
min_length=1,
|
|
125
|
+
)
|
|
126
|
+
highlight_elements: bool = Field(
|
|
127
|
+
default=False,
|
|
128
|
+
description=(
|
|
129
|
+
"Подсвечивать элемент красной рамкой на время взаимодействия. "
|
|
130
|
+
"Удобно при отладке и записи скринкастов."
|
|
131
|
+
),
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
@classmethod
|
|
135
|
+
def settings_customise_sources(
|
|
136
|
+
cls,
|
|
137
|
+
settings_cls: type[BaseSettings],
|
|
138
|
+
init_settings: PydanticBaseSettingsSource,
|
|
139
|
+
env_settings: PydanticBaseSettingsSource,
|
|
140
|
+
dotenv_settings: PydanticBaseSettingsSource,
|
|
141
|
+
file_secret_settings: PydanticBaseSettingsSource,
|
|
142
|
+
) -> tuple[PydanticBaseSettingsSource, ...]:
|
|
143
|
+
sources: list[PydanticBaseSettingsSource] = [
|
|
144
|
+
init_settings, env_settings, dotenv_settings,
|
|
145
|
+
]
|
|
146
|
+
|
|
147
|
+
project_root = _find_project_root()
|
|
148
|
+
config_chain = _collect_config_chain(Path.cwd(), project_root)
|
|
149
|
+
|
|
150
|
+
# Цепочка упорядочена от специфичного к общему.
|
|
151
|
+
# pydantic-settings отдает приоритет источникам, идущим раньше,
|
|
152
|
+
# поэтому порядок сохраняется как есть.
|
|
153
|
+
for config_path in config_chain:
|
|
154
|
+
sources.append(
|
|
155
|
+
_JsoncConfigSettingsSource(settings_cls, json_file=config_path)
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
return tuple(sources)
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Абстрактная база для всех реализаций элементов (Selenium, Appium, WinAppDriver).
|
|
2
|
+
|
|
3
|
+
Пакеты, специфичные для драйвера, наследуют `BaseElement` для реализации
|
|
4
|
+
логики поиска и ожидания. Ядро определяет интерфейс, на который опираются
|
|
5
|
+
page object классы.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from abc import ABC, abstractmethod
|
|
10
|
+
|
|
11
|
+
from tquality_core.elements.locator import Locator
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class BaseElement(ABC):
|
|
15
|
+
"""Интерфейс единичного UI-элемента, идентифицируемого локатором.
|
|
16
|
+
|
|
17
|
+
Подклассы должны реализовать методы поиска и ожидания через примитивы
|
|
18
|
+
своего драйвера.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self, locator: Locator, name: str = "") -> None:
|
|
22
|
+
self._locator = locator
|
|
23
|
+
self._name = name or f"{self.__class__.__name__}({locator})"
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def name(self) -> str:
|
|
27
|
+
return self._name
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def locator(self) -> Locator:
|
|
31
|
+
return self._locator
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
@abstractmethod
|
|
35
|
+
def text(self) -> str:
|
|
36
|
+
"""Вернуть видимый текст элемента."""
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
@abstractmethod
|
|
40
|
+
def is_displayed(self) -> bool:
|
|
41
|
+
"""Вернуть True, если элемент существует И видим."""
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
@abstractmethod
|
|
45
|
+
def is_present(self) -> bool:
|
|
46
|
+
"""Вернуть True, если элемент есть в DOM (может быть невидим)."""
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
@abstractmethod
|
|
50
|
+
def is_enabled(self) -> bool:
|
|
51
|
+
"""Вернуть True, если элемент активен и интерактивен."""
|
|
52
|
+
|
|
53
|
+
@abstractmethod
|
|
54
|
+
def get_attribute(self, attr: str) -> str | None:
|
|
55
|
+
"""Вернуть значение DOM-атрибута или None."""
|
|
56
|
+
|
|
57
|
+
@abstractmethod
|
|
58
|
+
def click(self) -> None:
|
|
59
|
+
"""Кликнуть по элементу (сначала дождется кликабельности)."""
|
|
60
|
+
|
|
61
|
+
@abstractmethod
|
|
62
|
+
def wait_for_displayed(self, timeout: float | None = None) -> BaseElement:
|
|
63
|
+
"""Ждать, пока элемент станет отображаемым. Возвращает self для чейнинга."""
|
|
64
|
+
|
|
65
|
+
@abstractmethod
|
|
66
|
+
def wait_until_visible(self, timeout: float | None = None) -> BaseElement:
|
|
67
|
+
"""Ждать, пока элемент станет видимым."""
|
|
68
|
+
|
|
69
|
+
@abstractmethod
|
|
70
|
+
def wait_until_clickable(self, timeout: float | None = None) -> BaseElement:
|
|
71
|
+
"""Ждать, пока элемент станет кликабельным."""
|
|
72
|
+
|
|
73
|
+
@abstractmethod
|
|
74
|
+
def wait_until_invisible(self, timeout: float | None = None) -> BaseElement:
|
|
75
|
+
"""Ждать, пока элемент станет невидимым."""
|
|
76
|
+
|
|
77
|
+
@abstractmethod
|
|
78
|
+
def wait_until_not_present(self, timeout: float | None = None) -> BaseElement:
|
|
79
|
+
"""Ждать, пока элемент исчезнет из DOM."""
|
|
80
|
+
|
|
81
|
+
def __repr__(self) -> str:
|
|
82
|
+
return self._name
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Драйвер-независимое представление локатора элемента.
|
|
2
|
+
|
|
3
|
+
Локатор - пара `(strategy, value)`, где strategy - это стратегия поиска
|
|
4
|
+
(CSS-селектор, XPath, ID и т.д. - зависит от драйвера), а value - сам
|
|
5
|
+
локатор (`.button`, `//div`, `my-id`).
|
|
6
|
+
|
|
7
|
+
Тип strategy остается `str`, потому что разные драйверы используют разные
|
|
8
|
+
значения: Selenium - строки из `By.*` (`"css selector"`, `"xpath"`...),
|
|
9
|
+
Appium добавляет mobile-специфичные (`"accessibility id"`, `"android uiautomator"`).
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import NamedTuple
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Locator(NamedTuple):
|
|
17
|
+
"""Пара (стратегия_поиска, локатор) для идентификации элемента."""
|
|
18
|
+
|
|
19
|
+
by: str
|
|
20
|
+
value: str
|
|
21
|
+
|
|
22
|
+
def __str__(self) -> str:
|
|
23
|
+
return f"{self.by}={self.value}"
|
|
24
|
+
|
|
25
|
+
def __repr__(self) -> str:
|
|
26
|
+
return f"Locator(by={self.by!r}, value={self.value!r})"
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Базовая форма / page object.
|
|
2
|
+
|
|
3
|
+
Page - частный случай Form (форма с полным контекстом). Наследуйте `BaseForm`
|
|
4
|
+
для любой адресуемой UI-области: главной страницы, модального окна, шапки,
|
|
5
|
+
боковой панели, попапа и т.д.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from tquality_core.elements.base_element import BaseElement
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class BaseForm:
|
|
16
|
+
"""Драйвер-независимая база для page object'ов и форм.
|
|
17
|
+
|
|
18
|
+
Подклассы создают элементы в `__init__` и передают `unique_element`
|
|
19
|
+
(действительно уникальный для этой формы) в `super().__init__()`. Тесты
|
|
20
|
+
никогда не обращаются к элементам напрямую - они вызывают методы формы,
|
|
21
|
+
описывающие бизнес-логику.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, unique_element: BaseElement, name: str = "") -> None:
|
|
25
|
+
self._unique_element = unique_element
|
|
26
|
+
self._name = name or self.__class__.__name__
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def name(self) -> str:
|
|
30
|
+
return self._name
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def unique_element(self) -> BaseElement:
|
|
34
|
+
return self._unique_element
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def is_displayed(self) -> bool:
|
|
38
|
+
return self._unique_element.is_displayed
|
|
39
|
+
|
|
40
|
+
def wait_for_displayed(self, timeout: float | None = None) -> BaseForm:
|
|
41
|
+
self._unique_element.wait_for_displayed(timeout)
|
|
42
|
+
return self
|
tquality_core/py.typed
ADDED
|
File without changes
|
tquality_core/schema.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Генерация JSON-схемы для конфигурации.
|
|
2
|
+
|
|
3
|
+
`SCHEMA_URL` вычисляется по установленной версии пакета:
|
|
4
|
+
|
|
5
|
+
- Релизная версия (например, `0.1.3`) → `@v0.1.3` - пин на тег.
|
|
6
|
+
- Dev/editable сборка (версия содержит `+g...` или `.dev`) → `@master`.
|
|
7
|
+
|
|
8
|
+
Это значит: `tquality-config init`, выполненный на релизной установке,
|
|
9
|
+
запекает в config.json5 ссылку на конкретный тег. В dev-окружении -
|
|
10
|
+
ссылка на master, чтобы отслеживать текущую разработку.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import importlib.metadata
|
|
15
|
+
import json
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from tquality_core.config import BaseConfig
|
|
20
|
+
|
|
21
|
+
_REPO_BASE = "https://cdn.jsdelivr.net/gh/Tquality-ru/tquality-py-core"
|
|
22
|
+
_SCHEMA_PATH = "schema/config.schema.json"
|
|
23
|
+
_PACKAGE_NAME = "tquality-py-core"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _resolve_ref() -> str:
|
|
27
|
+
"""Вернуть git-ref для URL схемы.
|
|
28
|
+
|
|
29
|
+
Чистый релиз ("0.1.3") → "v0.1.3". Dev ("0.1.3+g...", "0.0+g...",
|
|
30
|
+
"0.1.3.dev1") → "master". Пакет не установлен → "master".
|
|
31
|
+
"""
|
|
32
|
+
try:
|
|
33
|
+
version = importlib.metadata.version(_PACKAGE_NAME)
|
|
34
|
+
except importlib.metadata.PackageNotFoundError:
|
|
35
|
+
return "master"
|
|
36
|
+
if "+" in version or ".dev" in version:
|
|
37
|
+
return "master"
|
|
38
|
+
return f"v{version}"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
SCHEMA_URL = f"{_REPO_BASE}@{_resolve_ref()}/{_SCHEMA_PATH}"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def generate_schema(config_cls: type[BaseConfig] = BaseConfig) -> dict[str, Any]:
|
|
45
|
+
"""Вернуть JSON-схему для переданного класса конфигурации.
|
|
46
|
+
|
|
47
|
+
По умолчанию возвращает схему BaseConfig. Проекты могут передать свой
|
|
48
|
+
подкласс для генерации схемы с дополнительными полями.
|
|
49
|
+
"""
|
|
50
|
+
schema: dict[str, Any] = config_cls.model_json_schema()
|
|
51
|
+
schema["$schema"] = "http://json-schema.org/draft-07/schema#"
|
|
52
|
+
schema["$id"] = SCHEMA_URL
|
|
53
|
+
return schema
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def write_schema_file(
|
|
57
|
+
path: Path,
|
|
58
|
+
config_cls: type[BaseConfig] = BaseConfig,
|
|
59
|
+
) -> None:
|
|
60
|
+
"""Записать JSON-схему в файл с отступами и завершающей новой строкой."""
|
|
61
|
+
schema = generate_schema(config_cls)
|
|
62
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
63
|
+
path.write_text(
|
|
64
|
+
json.dumps(schema, indent=2, ensure_ascii=False) + "\n",
|
|
65
|
+
encoding="utf-8",
|
|
66
|
+
)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from tquality_core.services.logger import (
|
|
2
|
+
Logger,
|
|
3
|
+
LogLevel,
|
|
4
|
+
ScreencastProvider,
|
|
5
|
+
ScreenshotProvider,
|
|
6
|
+
set_logger_resolver,
|
|
7
|
+
step,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"Logger",
|
|
12
|
+
"LogLevel",
|
|
13
|
+
"ScreencastProvider",
|
|
14
|
+
"ScreenshotProvider",
|
|
15
|
+
"set_logger_resolver",
|
|
16
|
+
"step",
|
|
17
|
+
]
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
"""Логгер тестов с интеграцией в allure.
|
|
2
|
+
|
|
3
|
+
Каждый тест получает свой файл лога, именованный по pytest node ID. Декоратор
|
|
4
|
+
и контекстный менеджер `step` оборачивают действия в allure-шаги. Шаги уровня
|
|
5
|
+
CRITICAL делают скриншот в конце (успех или сбой) через подключаемый провайдер.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import enum
|
|
10
|
+
import functools
|
|
11
|
+
import hashlib
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
from collections.abc import Callable
|
|
16
|
+
from datetime import datetime
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from types import TracebackType
|
|
19
|
+
from typing import Any, Protocol, TYPE_CHECKING
|
|
20
|
+
|
|
21
|
+
import allure
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from tquality_core.config import BaseConfig
|
|
25
|
+
|
|
26
|
+
_LOG_FORMAT = "%(asctime)s [%(levelname)s] %(message)s"
|
|
27
|
+
_LOG_DATEFMT = "%H:%M:%S"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class LogLevel(enum.Enum):
|
|
31
|
+
"""Уровень важности шага.
|
|
32
|
+
|
|
33
|
+
- NORMAL: только лог + allure-шаг.
|
|
34
|
+
- CRITICAL: + скриншот в конце (успех или сбой).
|
|
35
|
+
- WITH_SCREENCAST: + запись экрана в виде GIF на время выполнения шага.
|
|
36
|
+
Требует зарегистрированного `ScreencastProvider` (обычно из selenium).
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
NORMAL = "normal"
|
|
40
|
+
CRITICAL = "critical"
|
|
41
|
+
WITH_SCREENCAST = "with-screencast"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ScreenshotProvider(Protocol):
|
|
45
|
+
"""Интерфейс драйвер-специфичного провайдера скриншотов.
|
|
46
|
+
|
|
47
|
+
Регистрируется как DI-сервис (`providers.Singleton(...)`) в контейнере
|
|
48
|
+
и инжектится в `Logger.__init__`. CRITICAL-шаги вызывают `capture()` в
|
|
49
|
+
конце (успех или сбой) и прикрепляют PNG к allure-отчету.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def is_available(self) -> bool:
|
|
53
|
+
"""Вернуть True, если сессия драйвера сейчас активна."""
|
|
54
|
+
...
|
|
55
|
+
|
|
56
|
+
def capture(self) -> bytes:
|
|
57
|
+
"""Вернуть текущий экран как PNG-байты."""
|
|
58
|
+
...
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class ScreencastProvider(Protocol):
|
|
62
|
+
"""Интерфейс провайдера screencast-записи.
|
|
63
|
+
|
|
64
|
+
Регистрируется как DI-сервис и инжектится в `Logger.__init__`.
|
|
65
|
+
Шаги уровня `WITH_SCREENCAST` вызывают `start()` на входе и `stop()`
|
|
66
|
+
на выходе; полученный бинарник (GIF/mp4) прикрепляется к allure-шагу.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
def is_available(self) -> bool:
|
|
70
|
+
"""Вернуть True, если сессия драйвера сейчас активна."""
|
|
71
|
+
...
|
|
72
|
+
|
|
73
|
+
def start(self) -> None:
|
|
74
|
+
"""Начать запись. Должен быть идемпотентным при повторном вызове."""
|
|
75
|
+
...
|
|
76
|
+
|
|
77
|
+
def stop(self) -> bytes | None:
|
|
78
|
+
"""Остановить запись и вернуть бинарник или None, если кадров нет."""
|
|
79
|
+
...
|
|
80
|
+
|
|
81
|
+
def mime_type(self) -> str:
|
|
82
|
+
"""MIME-тип результата `stop()` - например, 'image/gif' или 'video/mp4'."""
|
|
83
|
+
...
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class _Step:
|
|
87
|
+
"""Внутренняя реализация шага, используется как контекстный менеджер и декоратор."""
|
|
88
|
+
|
|
89
|
+
def __init__(
|
|
90
|
+
self, logger: Logger, title: str, level: LogLevel = LogLevel.NORMAL,
|
|
91
|
+
) -> None:
|
|
92
|
+
self._logger = logger
|
|
93
|
+
self._title = title
|
|
94
|
+
self._level = level
|
|
95
|
+
self._allure_step = allure.step(title)
|
|
96
|
+
|
|
97
|
+
def __enter__(self) -> _Step:
|
|
98
|
+
self._logger.info("Шаг: %s", self._title)
|
|
99
|
+
self._allure_step.__enter__() # type: ignore[no-untyped-call]
|
|
100
|
+
if self._level == LogLevel.WITH_SCREENCAST:
|
|
101
|
+
provider = self._logger.screencast_provider
|
|
102
|
+
if provider is None:
|
|
103
|
+
self._logger.warning(
|
|
104
|
+
"WITH_SCREENCAST: ScreencastProvider не зарегистрирован, пропускаю",
|
|
105
|
+
)
|
|
106
|
+
elif not provider.is_available():
|
|
107
|
+
self._logger.warning(
|
|
108
|
+
"WITH_SCREENCAST: сессия драйвера неактивна, пропускаю",
|
|
109
|
+
)
|
|
110
|
+
else:
|
|
111
|
+
try:
|
|
112
|
+
provider.start()
|
|
113
|
+
except Exception: # noqa: BLE001
|
|
114
|
+
self._logger.warning(
|
|
115
|
+
"Не удалось начать screencast: %s",
|
|
116
|
+
self._title,
|
|
117
|
+
)
|
|
118
|
+
return self
|
|
119
|
+
|
|
120
|
+
def __exit__(
|
|
121
|
+
self,
|
|
122
|
+
exc_type: type[BaseException] | None,
|
|
123
|
+
exc_val: BaseException | None,
|
|
124
|
+
exc_tb: TracebackType | None,
|
|
125
|
+
) -> None:
|
|
126
|
+
try:
|
|
127
|
+
if self._level == LogLevel.CRITICAL:
|
|
128
|
+
self._attach_screenshot(failed=exc_type is not None)
|
|
129
|
+
elif self._level == LogLevel.WITH_SCREENCAST:
|
|
130
|
+
self._attach_screencast()
|
|
131
|
+
finally:
|
|
132
|
+
self._allure_step.__exit__(exc_type, exc_val, exc_tb) # type: ignore[no-untyped-call]
|
|
133
|
+
status = "СБОЙ" if exc_type else "завершен"
|
|
134
|
+
self._logger.info("Шаг %s: %s", status, self._title)
|
|
135
|
+
|
|
136
|
+
def _attach_screenshot(self, *, failed: bool) -> None:
|
|
137
|
+
provider = self._logger.screenshot_provider
|
|
138
|
+
if provider is None:
|
|
139
|
+
self._logger.warning(
|
|
140
|
+
"CRITICAL: ScreenshotProvider не зарегистрирован, пропускаю",
|
|
141
|
+
)
|
|
142
|
+
return
|
|
143
|
+
if not provider.is_available():
|
|
144
|
+
self._logger.warning(
|
|
145
|
+
"CRITICAL: сессия драйвера неактивна, пропускаю скриншот",
|
|
146
|
+
)
|
|
147
|
+
return
|
|
148
|
+
label = (
|
|
149
|
+
f"Скриншот [СБОЙ]: {self._title}"
|
|
150
|
+
if failed else f"Скриншот: {self._title}"
|
|
151
|
+
)
|
|
152
|
+
try:
|
|
153
|
+
png = provider.capture()
|
|
154
|
+
except Exception: # noqa: BLE001
|
|
155
|
+
self._logger.warning("Не удалось снять скриншот: %s", self._title)
|
|
156
|
+
return
|
|
157
|
+
allure.attach(png, name=label, attachment_type=allure.attachment_type.PNG)
|
|
158
|
+
|
|
159
|
+
def _attach_screencast(self) -> None:
|
|
160
|
+
provider = self._logger.screencast_provider
|
|
161
|
+
if provider is None:
|
|
162
|
+
self._logger.warning(
|
|
163
|
+
"WITH_SCREENCAST: ScreencastProvider не зарегистрирован, "
|
|
164
|
+
"пропускаю прикрепление записи",
|
|
165
|
+
)
|
|
166
|
+
return
|
|
167
|
+
try:
|
|
168
|
+
payload = provider.stop()
|
|
169
|
+
except Exception: # noqa: BLE001
|
|
170
|
+
self._logger.warning(
|
|
171
|
+
"Не удалось остановить screencast: %s", self._title,
|
|
172
|
+
)
|
|
173
|
+
return
|
|
174
|
+
if not payload:
|
|
175
|
+
return
|
|
176
|
+
allure.attach(
|
|
177
|
+
payload,
|
|
178
|
+
name=f"Screencast: {self._title}",
|
|
179
|
+
extension=provider.mime_type().split("/")[-1],
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
def __call__(self, func: Callable[..., Any]) -> Callable[..., Any]:
|
|
183
|
+
@functools.wraps(func)
|
|
184
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
185
|
+
with self:
|
|
186
|
+
return func(*args, **kwargs)
|
|
187
|
+
return wrapper
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _get_test_node_id() -> str:
|
|
191
|
+
"""Сформировать безопасное для файловой системы имя из pytest node ID.
|
|
192
|
+
|
|
193
|
+
ASCII-only, с MD5-хэшем для уникальности при не-ASCII параметрах.
|
|
194
|
+
"""
|
|
195
|
+
current = os.environ.get("PYTEST_CURRENT_TEST", "")
|
|
196
|
+
if not current:
|
|
197
|
+
return "unknown"
|
|
198
|
+
node_id = re.sub(r"\s+\(.*\)$", "", current)
|
|
199
|
+
ascii_part = re.sub(r"[^a-zA-Z0-9_\-]", "_", node_id)
|
|
200
|
+
ascii_part = re.sub(r"_+", "_", ascii_part).strip("_")
|
|
201
|
+
node_hash = hashlib.md5(node_id.encode()).hexdigest()[:8]
|
|
202
|
+
return f"{ascii_part[:80]}_{node_hash}"
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
class Logger:
|
|
206
|
+
"""Логгер на один контекст теста с отдельным файловым обработчиком.
|
|
207
|
+
|
|
208
|
+
Получает `screenshot_provider` и `screencast_provider` через DI -
|
|
209
|
+
шаги уровня CRITICAL и WITH_SCREENCAST используют их для
|
|
210
|
+
прикрепления артефактов к allure-отчету.
|
|
211
|
+
"""
|
|
212
|
+
|
|
213
|
+
def __init__(
|
|
214
|
+
self,
|
|
215
|
+
config: BaseConfig,
|
|
216
|
+
screenshot_provider: ScreenshotProvider | None = None,
|
|
217
|
+
screencast_provider: ScreencastProvider | None = None,
|
|
218
|
+
) -> None:
|
|
219
|
+
self.screenshot_provider = screenshot_provider
|
|
220
|
+
self.screencast_provider = screencast_provider
|
|
221
|
+
|
|
222
|
+
self._started_at = datetime.now()
|
|
223
|
+
timestamp = self._started_at.strftime("%Y%m%d_%H%M%S")
|
|
224
|
+
node_id = _get_test_node_id()
|
|
225
|
+
|
|
226
|
+
log_dir = Path(config.log_dir)
|
|
227
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
228
|
+
log_file = log_dir / f"{timestamp}_{node_id}.log"
|
|
229
|
+
|
|
230
|
+
self._logger = logging.getLogger(f"tquality.{timestamp}_{node_id}")
|
|
231
|
+
self._logger.setLevel(logging.INFO)
|
|
232
|
+
|
|
233
|
+
formatter = logging.Formatter(_LOG_FORMAT, datefmt=_LOG_DATEFMT)
|
|
234
|
+
|
|
235
|
+
file_handler = logging.FileHandler(log_file, encoding="utf-8")
|
|
236
|
+
file_handler.setFormatter(formatter)
|
|
237
|
+
self._logger.addHandler(file_handler)
|
|
238
|
+
|
|
239
|
+
stream_handler = logging.StreamHandler()
|
|
240
|
+
stream_handler.setFormatter(formatter)
|
|
241
|
+
self._logger.addHandler(stream_handler)
|
|
242
|
+
|
|
243
|
+
self._logger.info("Лог запущен: %s", log_file)
|
|
244
|
+
|
|
245
|
+
def info(self, msg: str, *args: Any) -> None:
|
|
246
|
+
self._logger.info(msg, *args)
|
|
247
|
+
|
|
248
|
+
def warning(self, msg: str, *args: Any) -> None:
|
|
249
|
+
self._logger.warning(msg, *args)
|
|
250
|
+
|
|
251
|
+
def error(self, msg: str, *args: Any) -> None:
|
|
252
|
+
self._logger.error(msg, *args)
|
|
253
|
+
|
|
254
|
+
def debug(self, msg: str, *args: Any) -> None:
|
|
255
|
+
self._logger.debug(msg, *args)
|
|
256
|
+
|
|
257
|
+
def step(self, title: str, level: LogLevel = LogLevel.NORMAL) -> _Step:
|
|
258
|
+
return _Step(self, title, level=level)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
_logger_resolver: Callable[[], Logger] | None = None
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def set_logger_resolver(resolver: Callable[[], Logger] | None) -> None:
|
|
265
|
+
"""Зарегистрировать способ получения активного Logger из любого места.
|
|
266
|
+
|
|
267
|
+
Обычно связывается с провайдером DI-контейнера (например, `Container.logger`).
|
|
268
|
+
"""
|
|
269
|
+
global _logger_resolver
|
|
270
|
+
_logger_resolver = resolver
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def step(title: str, level: LogLevel = LogLevel.NORMAL) -> _Step:
|
|
274
|
+
"""Фабрика шагов уровня модуля, делегирующая зарегистрированному Logger."""
|
|
275
|
+
if _logger_resolver is None:
|
|
276
|
+
raise RuntimeError(
|
|
277
|
+
"Резолвер логгера не зарегистрирован. "
|
|
278
|
+
"Вызовите set_logger_resolver() при настройке."
|
|
279
|
+
)
|
|
280
|
+
return _logger_resolver().step(title, level=level)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Утилиты работы со строками."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class StringUtils:
|
|
8
|
+
"""Часто используемые операции парсинга строк."""
|
|
9
|
+
|
|
10
|
+
_DIGITS_PATTERN = re.compile(r"\d+")
|
|
11
|
+
_NON_DIGIT_PATTERN = re.compile(r"[^\d]")
|
|
12
|
+
|
|
13
|
+
@staticmethod
|
|
14
|
+
def get_digits(value: str) -> str:
|
|
15
|
+
"""Вернуть только цифры из строки. Пример: '5 000 ₽' -> '5000'."""
|
|
16
|
+
return re.sub(StringUtils._NON_DIGIT_PATTERN, "", value)
|
|
17
|
+
|
|
18
|
+
@staticmethod
|
|
19
|
+
def parse_int(value: str, default: int = 0) -> int:
|
|
20
|
+
"""Распарсить число из строки, возвращая default если цифр нет."""
|
|
21
|
+
digits = StringUtils.get_digits(value)
|
|
22
|
+
return int(digits) if digits else default
|
|
23
|
+
|
|
24
|
+
@staticmethod
|
|
25
|
+
def extract_first_number(value: str) -> int | None:
|
|
26
|
+
"""Извлечь первое число из строки. Пример: 'Chrome 146.0.1' -> 146."""
|
|
27
|
+
match = StringUtils._DIGITS_PATTERN.search(value)
|
|
28
|
+
return int(match.group()) if match else None
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tquality-py-core
|
|
3
|
+
Version: 0.1.4
|
|
4
|
+
Summary: Driver-agnostic core for tquality test automation (Selenium, Appium, WinAppDriver, etc.)
|
|
5
|
+
Project-URL: Homepage, https://github.com/Tquality-ru/tquality-py-core
|
|
6
|
+
Project-URL: Repository, https://github.com/Tquality-ru/tquality-py-core
|
|
7
|
+
Project-URL: Issues, https://github.com/Tquality-ru/tquality-py-core/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/Tquality-ru/tquality-py-core/blob/master/CHANGELOG.md
|
|
9
|
+
Author: ООО «Точка качества»
|
|
10
|
+
License-Expression: Apache-2.0
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
License-File: NOTICE
|
|
13
|
+
Keywords: allure,appium,page-object,qa,selenium,test-automation,testing,tquality,winappdriver
|
|
14
|
+
Classifier: Development Status :: 4 - Beta
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
22
|
+
Classifier: Topic :: Software Development :: Testing
|
|
23
|
+
Classifier: Typing :: Typed
|
|
24
|
+
Requires-Python: >=3.12
|
|
25
|
+
Requires-Dist: allure-python-commons>=2.13
|
|
26
|
+
Requires-Dist: dependency-injector>=4.41
|
|
27
|
+
Requires-Dist: json5>=0.9
|
|
28
|
+
Requires-Dist: pydantic-settings>=2.0
|
|
29
|
+
Requires-Dist: pydantic>=2.0
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
# tquality-py-core
|
|
33
|
+
|
|
34
|
+
Независимое от драйвера ядро для автоматизации тестирования tquality. Предоставляет
|
|
35
|
+
основу, на которой строятся пакеты, специфичные для драйверов (Selenium,
|
|
36
|
+
Appium, WinAppDriver).
|
|
37
|
+
|
|
38
|
+
## Компоненты
|
|
39
|
+
|
|
40
|
+
- **`BaseConfig`** - конфигурация на основе pydantic-settings с загрузкой из
|
|
41
|
+
`config.json5` (с поддержкой комментариев и висячих запятых через json5),
|
|
42
|
+
переменных окружения и dotenv. Для добавления полей, специфичных для
|
|
43
|
+
драйвера, используется наследование.
|
|
44
|
+
- **`Logger`, `LogLevel`, `step`** - журналирование в контексте одного теста
|
|
45
|
+
с интеграцией allure. Уровни шагов: `NORMAL`, `CRITICAL` (снимок экрана в
|
|
46
|
+
конце) и `WITH_SCREENCAST` (видеозапись шага через подключаемый поставщик).
|
|
47
|
+
- **`BaseForm`** - базовый класс для страниц и форм (страница - форма с полным
|
|
48
|
+
контекстом).
|
|
49
|
+
- **`BaseElement`** - абстрактный интерфейс, который реализуют элементы,
|
|
50
|
+
специфичные для драйвера.
|
|
51
|
+
- **`StringUtils`** - вспомогательные функции разбора строк.
|
|
52
|
+
|
|
53
|
+
## Не входит в ядро
|
|
54
|
+
|
|
55
|
+
- Конкретная интеграция с драйверами (Selenium, Appium, WinAppDriver) -
|
|
56
|
+
живёт в отдельных пакетах, зависящих от этого ядра.
|
|
57
|
+
- Типы элементов (`Button`, `Input`, `Label` и т. п.) - реализации,
|
|
58
|
+
специфичные для драйвера, живут рядом с интеграцией драйвера.
|
|
59
|
+
- Настройка контейнера внедрения зависимостей - каждый использующий пакет
|
|
60
|
+
собирает свой контейнер через `dependency-injector`, регистрируя службы
|
|
61
|
+
ядра и службы, специфичные для драйвера.
|
|
62
|
+
|
|
63
|
+
## Контракт интеграции
|
|
64
|
+
|
|
65
|
+
Использующие пакеты должны:
|
|
66
|
+
|
|
67
|
+
1. Наследовать `BaseConfig` с полями, специфичными для драйвера.
|
|
68
|
+
2. Зарегистрировать функцию получения `Logger` через
|
|
69
|
+
`set_logger_resolver(lambda: YourServices.logger())`, где `YourServices` -
|
|
70
|
+
контейнер использующего пакета. Это нужно, чтобы `step()` из ядра
|
|
71
|
+
находил активный `Logger` в любом модуле.
|
|
72
|
+
3. При необходимости реализовать `ScreenshotProvider` / `ScreencastProvider`
|
|
73
|
+
и внедрить их в `Logger` через контейнер, чтобы шаги уровня `CRITICAL`
|
|
74
|
+
прикрепляли снимки экрана, а `WITH_SCREENCAST` - видеозапись (конкретный
|
|
75
|
+
формат - на стороне поставщика, например webm в Selenium) к отчёту
|
|
76
|
+
allure. Без поставщиков шаги проходят с предупреждением в журнал.
|
|
77
|
+
4. Предоставить конкретные подклассы `BaseElement` с логикой поиска и
|
|
78
|
+
ожидания.
|
|
79
|
+
|
|
80
|
+
## Требования
|
|
81
|
+
|
|
82
|
+
- Python 3.12+
|
|
83
|
+
|
|
84
|
+
## Установка
|
|
85
|
+
|
|
86
|
+
Пакет публикуется в [публичный PyPI](https://pypi.org/project/tquality-py-core/).
|
|
87
|
+
Это рекомендуемый способ установки для всех потребителей:
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
pip install tquality-py-core
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
или с использованием [uv](https://docs.astral.sh/uv/):
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
uv add tquality-py-core
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
В `pyproject.toml` потребителя:
|
|
100
|
+
|
|
101
|
+
```toml
|
|
102
|
+
dependencies = [
|
|
103
|
+
"tquality-py-core>=0.1.3",
|
|
104
|
+
]
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### Альтернатива: установка из GitHub-зеркала
|
|
108
|
+
|
|
109
|
+
Если нужна сборка из исходников (например, для проверки коммита,
|
|
110
|
+
ещё не вышедшего в релиз), пакет также доступен из публичного
|
|
111
|
+
GitHub-зеркала по тегу:
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
uv pip install "tquality-py-core @ git+https://github.com/Tquality-ru/tquality-py-core.git@v0.1.3"
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
В этом случае hatch у потребителя требует явного разрешения
|
|
118
|
+
`direct-references`:
|
|
119
|
+
|
|
120
|
+
```toml
|
|
121
|
+
[tool.hatch.metadata]
|
|
122
|
+
allow-direct-references = true
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## CLI
|
|
126
|
+
|
|
127
|
+
После установки доступна команда `tquality-config`:
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
tquality-config init # сгенерировать config.json5 со значениями по умолчанию
|
|
131
|
+
tquality-config schema # сгенерировать schema/config.schema.json (для сопровождающих)
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Сгенерированный `config.json5` включает ссылку на JSON-схему, опубликованную
|
|
135
|
+
через jsDelivr. Адрес автоматически привязан к версии пакета: при установке
|
|
136
|
+
выпущенной версии (`0.1.3`) → `@v0.1.3`, при установке невыпущенной версии
|
|
137
|
+
(`+g...`, `.dev`) → `@master`:
|
|
138
|
+
|
|
139
|
+
```jsonc
|
|
140
|
+
{
|
|
141
|
+
"$schema": "https://cdn.jsdelivr.net/gh/Tquality-ru/tquality-py-core@v0.1.3/schema/config.schema.json",
|
|
142
|
+
// Комментарии поддерживаются - можно пояснить выбор значения.
|
|
143
|
+
"base_url": "http://localhost",
|
|
144
|
+
"default_timeout": 10.0,
|
|
145
|
+
"log_dir": "logs",
|
|
146
|
+
"highlight_elements": false,
|
|
147
|
+
}
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Редакторы с поддержкой JSON Schema (VS Code, JetBrains IDE) автоматически
|
|
151
|
+
подсказывают доступные поля и проверяют значения. Синтаксис jsonc/json5
|
|
152
|
+
позволяет оставлять комментарии `//` и `/* */` и висячие запятые.
|
|
153
|
+
|
|
154
|
+
## Разработка
|
|
155
|
+
|
|
156
|
+
См. [CONTRIBUTING.md](CONTRIBUTING.md) для инструкций по настройке окружения
|
|
157
|
+
разработчика, установке перехватчиков git и проверке типов mypy.
|
|
158
|
+
|
|
159
|
+
## CI/CD
|
|
160
|
+
|
|
161
|
+
GitLab CI запускает две проверки на каждом MR и на master:
|
|
162
|
+
|
|
163
|
+
- **`mypy`** - строгий режим проверки типов.
|
|
164
|
+
- **`tests`** - запуск pytest с отчётом JUnit.
|
|
165
|
+
|
|
166
|
+
При публикации тега git вида `vX.Y.Z`:
|
|
167
|
+
|
|
168
|
+
- **`publish-pypi`** - сборка (версия берётся из тега через `hatch-vcs`)
|
|
169
|
+
и загрузка пакета в публичный
|
|
170
|
+
[PyPI](https://pypi.org/project/tquality-py-core/). Требует переменную
|
|
171
|
+
`PYPI_TOKEN` в настройках CI/CD (protected, masked).
|
|
172
|
+
- **`publish`** - дублирующая публикация в GitLab Package Registry
|
|
173
|
+
(внутреннее зеркало).
|
|
174
|
+
- **`mirror-to-github`** - master и сам тег отправляются в
|
|
175
|
+
https://github.com/Tquality-ru/tquality-py-core (ветки `feature/*`
|
|
176
|
+
на зеркало не копируются).
|
|
177
|
+
|
|
178
|
+
История версий - в [CHANGELOG.md](CHANGELOG.md).
|
|
179
|
+
|
|
180
|
+
## Зачем это существует
|
|
181
|
+
|
|
182
|
+
Отделяет универсальные шаблоны (журналирование, объекты страниц, загрузка
|
|
183
|
+
конфигурации) от кода, специфичного для драйвера. Appium и WinAppDriver
|
|
184
|
+
повторно используют ту же модель объектов страниц, отчётность по шагам и
|
|
185
|
+
конвейер конфигурации без обязательной зависимости от Selenium.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
tquality_core/__init__.py,sha256=au22AYGbHGMkk-pXLrhnz-ViAviyMWLCze5SEHa2zk8,641
|
|
2
|
+
tquality_core/cli.py,sha256=Th30YcGrqt5-RYVxmOruHlgLbVY0XDD7NGOi-aP-mkI,3697
|
|
3
|
+
tquality_core/config.py,sha256=lSP6HB7JRi1R4ii_EerkBlQRKJBf1bSfIT8aMKIwcJA,6511
|
|
4
|
+
tquality_core/schema.py,sha256=JY7wmHi1PEqQO7ygs9oxQ0kkcfYAy7-FNOgFp1DC4o0,2601
|
|
5
|
+
tquality_core/elements/__init__.py,sha256=NEUGUhJtE78Hcbmx1ZMQaL_fPIWRYKI_8laYZRsRUxo,149
|
|
6
|
+
tquality_core/elements/base_element.py,sha256=07f4jsUzAlaf9WVpPMgqkP1sy4NfS18BOMVbQ45ZoxM,3192
|
|
7
|
+
tquality_core/elements/locator.py,sha256=orliYhx9ZuqzoGrCm21t9kb5eVNSjzmK3P-PEupm5oQ,1090
|
|
8
|
+
tquality_core/pages/__init__.py,sha256=12KtvXwMU44vVZXbzStyKlc37XlizgXofa1dHWxDpUU,75
|
|
9
|
+
tquality_core/pages/base_form.py,sha256=B6oVwcr7m2H8TmpJyttE2flfmmL6dIRTFMXoP1gRCI0,1635
|
|
10
|
+
tquality_core/services/__init__.py,sha256=kR7_pDTrvybybnWCrwKHDAEclB4_E8tY5kbqCaRP3lc,291
|
|
11
|
+
tquality_core/services/logger.py,sha256=tp_kVEnsxP0HjCYyUIeMqHAZvYtWn1NeIWICQJxvBjs,11045
|
|
12
|
+
tquality_core/utils/__init__.py,sha256=3cMPC8nrWcY15P9IfqGXXB3B9YHx8sx7z9WvrdDRIcs,84
|
|
13
|
+
tquality_core/utils/string_utils.py,sha256=2AJLh1CN1pthK84Q3Nqi1cWFRdBl7Q4XEbG1WzvOCkw,1123
|
|
14
|
+
tquality_core/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
15
|
+
tquality_py_core-0.1.4.dist-info/METADATA,sha256=eH_LpUQB--jV4ZpyqQwGJZ2CrEzotT7COlIHEREGRU4,9439
|
|
16
|
+
tquality_py_core-0.1.4.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
17
|
+
tquality_py_core-0.1.4.dist-info/entry_points.txt,sha256=QZeY6HAZtwGqz1aEFUNj4wi9H_sXiBk2CLjx8HQ3KsA,59
|
|
18
|
+
tquality_py_core-0.1.4.dist-info/licenses/LICENSE,sha256=Zh74Q42FkXdtes3fwG83wTYUHimBH3SpqzdtANsFGrI,11310
|
|
19
|
+
tquality_py_core-0.1.4.dist-info/licenses/NOTICE,sha256=CEB_oVvfMt9Ux1s1I-iLmqkjxfwXMjnNCgohoPat5LI,178
|
|
20
|
+
tquality_py_core-0.1.4.dist-info/RECORD,,
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
Apache License
|
|
2
|
+
Version 2.0, January 2004
|
|
3
|
+
http://www.apache.org/licenses/
|
|
4
|
+
|
|
5
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
6
|
+
|
|
7
|
+
1. Definitions.
|
|
8
|
+
|
|
9
|
+
"License" shall mean the terms and conditions for use, reproduction,
|
|
10
|
+
and distribution as defined by Sections 1 through 9 of this document.
|
|
11
|
+
|
|
12
|
+
"Licensor" shall mean the copyright owner or entity authorized by
|
|
13
|
+
the copyright owner that is granting the License.
|
|
14
|
+
|
|
15
|
+
"Legal Entity" shall mean the union of the acting entity and all
|
|
16
|
+
other entities that control, are controlled by, or are under common
|
|
17
|
+
control with that entity. For the purposes of this definition,
|
|
18
|
+
"control" means (i) the power, direct or indirect, to cause the
|
|
19
|
+
direction or management of such entity, whether by contract or
|
|
20
|
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
21
|
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
22
|
+
|
|
23
|
+
"You" (or "Your") shall mean an individual or Legal Entity
|
|
24
|
+
exercising permissions granted by this License.
|
|
25
|
+
|
|
26
|
+
"Source" form shall mean the preferred form for making modifications,
|
|
27
|
+
including but not limited to software source code, documentation
|
|
28
|
+
source, and configuration files.
|
|
29
|
+
|
|
30
|
+
"Object" form shall mean any form resulting from mechanical
|
|
31
|
+
transformation or translation of a Source form, including but
|
|
32
|
+
not limited to compiled object code, generated documentation,
|
|
33
|
+
and conversions to other media types.
|
|
34
|
+
|
|
35
|
+
"Work" shall mean the work of authorship, whether in Source or
|
|
36
|
+
Object form, made available under the License, as indicated by a
|
|
37
|
+
copyright notice that is included in or attached to the work
|
|
38
|
+
(an example is provided in the Appendix below).
|
|
39
|
+
|
|
40
|
+
"Derivative Works" shall mean any work, whether in Source or Object
|
|
41
|
+
form, that is based on (or derived from) the Work and for which the
|
|
42
|
+
editorial revisions, annotations, elaborations, or other modifications
|
|
43
|
+
represent, as a whole, an original work of authorship. For the purposes
|
|
44
|
+
of this License, Derivative Works shall not include works that remain
|
|
45
|
+
separable from, or merely link (or bind by name) to the interfaces of,
|
|
46
|
+
the Work and Derivative Works thereof.
|
|
47
|
+
|
|
48
|
+
"Contribution" shall mean any work of authorship, including
|
|
49
|
+
the original version of the Work and any modifications or additions
|
|
50
|
+
to that Work or Derivative Works thereof, that is intentionally
|
|
51
|
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
52
|
+
or by an individual or Legal Entity authorized to submit on behalf of
|
|
53
|
+
the copyright owner. For the purposes of this definition, "submitted"
|
|
54
|
+
means any form of electronic, verbal, or written communication sent
|
|
55
|
+
to the Licensor or its representatives, including but not limited to
|
|
56
|
+
communication on electronic mailing lists, source code control systems,
|
|
57
|
+
and issue tracking systems that are managed by, or on behalf of, the
|
|
58
|
+
Licensor for the purpose of discussing and improving the Work, but
|
|
59
|
+
excluding communication that is conspicuously marked or otherwise
|
|
60
|
+
designated in writing by the copyright owner as "Not a Contribution."
|
|
61
|
+
|
|
62
|
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
63
|
+
on behalf of whom a Contribution has been received by Licensor and
|
|
64
|
+
subsequently incorporated within the Work.
|
|
65
|
+
|
|
66
|
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
67
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
68
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
69
|
+
copyright license to reproduce, prepare Derivative Works of,
|
|
70
|
+
publicly display, publicly perform, sublicense, and distribute the
|
|
71
|
+
Work and such Derivative Works in Source or Object form.
|
|
72
|
+
|
|
73
|
+
3. Grant of Patent License. Subject to the terms and conditions of
|
|
74
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
75
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
76
|
+
(except as stated in this section) patent license to make, have made,
|
|
77
|
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
78
|
+
where such license applies only to those patent claims licensable
|
|
79
|
+
by such Contributor that are necessarily infringed by their
|
|
80
|
+
Contribution(s) alone or by combination of their Contribution(s)
|
|
81
|
+
with the Work to which such Contribution(s) was submitted. If You
|
|
82
|
+
institute patent litigation against any entity (including a
|
|
83
|
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
84
|
+
or a Contribution incorporated within the Work constitutes direct
|
|
85
|
+
or contributory patent infringement, then any patent licenses
|
|
86
|
+
granted to You under this License for that Work shall terminate
|
|
87
|
+
as of the date such litigation is filed.
|
|
88
|
+
|
|
89
|
+
4. Redistribution. You may reproduce and distribute copies of the
|
|
90
|
+
Work or Derivative Works thereof in any medium, with or without
|
|
91
|
+
modifications, and in Source or Object form, provided that You
|
|
92
|
+
meet the following conditions:
|
|
93
|
+
|
|
94
|
+
(a) You must give any other recipients of the Work or
|
|
95
|
+
Derivative Works a copy of this License; and
|
|
96
|
+
|
|
97
|
+
(b) You must cause any modified files to carry prominent notices
|
|
98
|
+
stating that You changed the files; and
|
|
99
|
+
|
|
100
|
+
(c) You must retain, in the Source form of any Derivative Works
|
|
101
|
+
that You distribute, all copyright, patent, trademark, and
|
|
102
|
+
attribution notices from the Source form of the Work,
|
|
103
|
+
excluding those notices that do not pertain to any part of
|
|
104
|
+
the Derivative Works; and
|
|
105
|
+
|
|
106
|
+
(d) If the Work includes a "NOTICE" text file as part of its
|
|
107
|
+
distribution, then any Derivative Works that You distribute must
|
|
108
|
+
include a readable copy of the attribution notices contained
|
|
109
|
+
within such NOTICE file, excluding those notices that do not
|
|
110
|
+
pertain to any part of the Derivative Works, in at least one
|
|
111
|
+
of the following places: within a NOTICE text file distributed
|
|
112
|
+
as part of the Derivative Works; within the Source form or
|
|
113
|
+
documentation, if provided along with the Derivative Works; or,
|
|
114
|
+
within a display generated by the Derivative Works, if and
|
|
115
|
+
wherever such third-party notices normally appear. The contents
|
|
116
|
+
of the NOTICE file are for informational purposes only and
|
|
117
|
+
do not modify the License. You may add Your own attribution
|
|
118
|
+
notices within Derivative Works that You distribute, alongside
|
|
119
|
+
or as an addendum to the NOTICE text from the Work, provided
|
|
120
|
+
that such additional attribution notices cannot be construed
|
|
121
|
+
as modifying the License.
|
|
122
|
+
|
|
123
|
+
You may add Your own copyright statement to Your modifications and
|
|
124
|
+
may provide additional or different license terms and conditions
|
|
125
|
+
for use, reproduction, or distribution of Your modifications, or
|
|
126
|
+
for any such Derivative Works as a whole, provided Your use,
|
|
127
|
+
reproduction, and distribution of the Work otherwise complies with
|
|
128
|
+
the conditions stated in this License.
|
|
129
|
+
|
|
130
|
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
131
|
+
any Contribution intentionally submitted for inclusion in the Work
|
|
132
|
+
by You to the Licensor shall be under the terms and conditions of
|
|
133
|
+
this License, without any additional terms or conditions.
|
|
134
|
+
Notwithstanding the above, nothing herein shall supersede or modify
|
|
135
|
+
the terms of any separate license agreement you may have executed
|
|
136
|
+
with Licensor regarding such Contributions.
|
|
137
|
+
|
|
138
|
+
6. Trademarks. This License does not grant permission to use the trade
|
|
139
|
+
names, trademarks, service marks, or product names of the Licensor,
|
|
140
|
+
except as required for describing the origin of the Work and
|
|
141
|
+
reproducing the content of the NOTICE file.
|
|
142
|
+
|
|
143
|
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
144
|
+
agreed to in writing, Licensor provides the Work (and each
|
|
145
|
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
146
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
147
|
+
implied, including, without limitation, any warranties or conditions
|
|
148
|
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
149
|
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
150
|
+
appropriateness of using or redistributing the Work and assume any
|
|
151
|
+
risks associated with Your exercise of permissions under this License.
|
|
152
|
+
|
|
153
|
+
8. Limitation of Liability. In no event and under no legal theory,
|
|
154
|
+
whether in tort (including negligence), contract, or otherwise,
|
|
155
|
+
unless required by applicable law (such as deliberate and grossly
|
|
156
|
+
negligent acts) or agreed to in writing, shall any Contributor be
|
|
157
|
+
liable to You for damages, including any direct, indirect, special,
|
|
158
|
+
incidental, or consequential damages of any character arising as a
|
|
159
|
+
result of this License or out of the use or inability to use the
|
|
160
|
+
Work (including but not limited to damages for loss of goodwill,
|
|
161
|
+
work stoppage, computer failure or malfunction, or any and all
|
|
162
|
+
other commercial damages or losses), even if such Contributor
|
|
163
|
+
has been advised of the possibility of such damages.
|
|
164
|
+
|
|
165
|
+
9. Accepting Warranty or Support. While redistributing the Work or
|
|
166
|
+
Derivative Works thereof, You may choose to offer, and charge a
|
|
167
|
+
fee for, acceptance of support, warranty, indemnity, or other
|
|
168
|
+
liability obligations and/or rights consistent with this License.
|
|
169
|
+
However, in accepting such obligations, You may act only on Your
|
|
170
|
+
own behalf and on Your sole responsibility, not on behalf of any
|
|
171
|
+
other Contributor, and only if You agree to indemnify, defend,
|
|
172
|
+
and hold each Contributor harmless for any liability incurred by,
|
|
173
|
+
or claims asserted against, such Contributor by reason of your
|
|
174
|
+
accepting any such warranty or support.
|
|
175
|
+
|
|
176
|
+
END OF TERMS AND CONDITIONS
|
|
177
|
+
|
|
178
|
+
APPENDIX: How to apply the Apache License to your work.
|
|
179
|
+
|
|
180
|
+
To apply the Apache License to your work, attach the following
|
|
181
|
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
182
|
+
replaced with your own identifying information. (Don't include
|
|
183
|
+
the brackets!) The text should be enclosed in the appropriate
|
|
184
|
+
comment syntax for the file format. We also recommend that a
|
|
185
|
+
file or class name and description of purpose be included on the
|
|
186
|
+
same "printed page" as the copyright notice for easier
|
|
187
|
+
identification within third-party archives.
|
|
188
|
+
|
|
189
|
+
Copyright 2026 ООО «Точка качества»
|
|
190
|
+
|
|
191
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
192
|
+
you may not use this file except in compliance with the License.
|
|
193
|
+
You may obtain a copy of the License at
|
|
194
|
+
|
|
195
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
196
|
+
|
|
197
|
+
Unless required by applicable law or agreed to in writing, software
|
|
198
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
199
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
200
|
+
See the License for the specific language governing permissions and
|
|
201
|
+
limitations under the License.
|