partest 0.1.8__tar.gz

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.

Potentially problematic release.


This version of partest might be problematic. Click here for more details.

partest-0.1.8/PKG-INFO ADDED
@@ -0,0 +1,157 @@
1
+ Metadata-Version: 2.1
2
+ Name: partest
3
+ Version: 0.1.8
4
+ Summary: This is a module for the rapid implementation of test cases with coverage tracking. This module contains a call counter for specific endpoints and their methods. As well as the function of determining the types of tests that need to be counted.
5
+ Home-page: https://github.com/Dec01/partest
6
+ Author: dec01
7
+ Author-email: parschin.ewg@yandex.ru
8
+ Project-URL: GitHub, https://github.com/Dec01/partest
9
+ Keywords: autotest partest test coverage
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Operating System :: OS Independent
13
+ Requires-Python: >=3.8
14
+ Description-Content-Type: text/markdown
15
+ Requires-Dist: httpx>=0.27.2
16
+ Requires-Dist: pyyaml>=6.0.2
17
+ Requires-Dist: matplotlib>=3.9.2
18
+ Requires-Dist: allure-pytest>=2.8.18
19
+ Requires-Dist: pytest-asyncio>=0.23.7
20
+ Requires-Dist: pytest==8.3.3
21
+
22
+ ## HI!
23
+
24
+ This is a framework for API autotests with coverage assessment. Detailed instructions in the process of writing. It is better to check with the author how to use it. Tools are used:
25
+
26
+ * pytest
27
+ * httpx
28
+ * allure
29
+
30
+ Files are required for specific work:
31
+
32
+ **conftest.py** - it must have a fixture's inside:
33
+
34
+ ```commandline
35
+ @pytest.fixture(scope="session")
36
+ def api_client(domain):
37
+ return ApiClient(domain=domain)
38
+ ```
39
+ ```commandline
40
+ @pytest.fixture(scope='session', autouse=True)
41
+ def clear_call_data():
42
+ """Фикстура для очистки данных перед запуском тестов."""
43
+ global call_count, call_type
44
+ api_call_storage.call_count.clear()
45
+ api_call_storage.call_type.clear()
46
+ yield
47
+ ```
48
+
49
+ **confpartest.py** - It must have variables inside:
50
+
51
+ ```
52
+ swagger_files = {
53
+ 'test1': ['local', '../public/swagger/app-openapi.yaml'],
54
+ 'test2': ['local', '../public/swagger/app-openapi2.yaml'],
55
+ 'test3': ['url', 'https://url.ru']
56
+ }
57
+
58
+ test_types_coverage = ['default', '405', 'param']
59
+ ```
60
+
61
+ The project must have a test that displays information about the coverage in allure. The name of it **test_zorro.py**:
62
+
63
+ ```commandline
64
+
65
+ async def test_display_final_call_counts(self):
66
+ report_lines = []
67
+ total_coverage_percentage = 0
68
+ total_endpoints = 0
69
+ total_calls_excluding_generation = 0
70
+
71
+ for (method, endpoint, description), count in call_count.items():
72
+ types = set(call_type[(method, endpoint, description)])
73
+ total_endpoints += 1
74
+
75
+ # Подсчет вызовов, исключая тип 'generation_data'
76
+ if 'generation_data' not in types:
77
+ total_calls_excluding_generation += count
78
+
79
+ # Проверка на наличие обязательных типов тестов
80
+ coverage_status = "Недостаточное покрытие ❌"
81
+ matched_types = set(types).intersection(types)
82
+ count_matched = len(matched_types)
83
+
84
+ # Логика для определения статуса покрытия и расчета процента
85
+ if count_matched == len(types): # Все типы присутствуют
86
+ coverage_status = "Покрытие выполнено ✅"
87
+ total_coverage_percentage += 100
88
+ elif count_matched == 2:
89
+ coverage_status = "Покрытие выполнено на 66% 🔔"
90
+ total_coverage_percentage += 66
91
+ elif count_matched == 1:
92
+ coverage_status = "Покрытие выполнено на 33% ❌"
93
+ total_coverage_percentage += 33
94
+ else:
95
+ coverage_status = "Недостаточное покрытие ❌"
96
+ total_coverage_percentage += 0
97
+
98
+ report_line = (
99
+ f"\n{description}\nЭндпоинт: {endpoint}\nМетод: {method} | "
100
+ f"Обращений: {count}, Типы тестов: {', '.join(types)}\n{coverage_status}\n"
101
+ )
102
+ report_lines.append(report_line)
103
+
104
+ # Вычисление общего процента покрытия
105
+ if total_endpoints > 0:
106
+ average_coverage_percentage = total_coverage_percentage / total_endpoints
107
+ else:
108
+ average_coverage_percentage = 0
109
+
110
+ border = "*" * 50
111
+ summary = f"{border}\nОбщий процент покрытия: {average_coverage_percentage:.2f}%\nОбщее количество вызовов (исключая 'generation_data'): {total_calls_excluding_generation}\n{border}\n"
112
+
113
+ # Добавляем сводку в начало отчета
114
+ report_lines.insert(0, summary)
115
+
116
+ create_chart(call_count)
117
+
118
+ with open('api_call_counts.png', 'rb') as f:
119
+ allure.attach(f.read(), name='Оценка покрытия', attachment_type=allure.attachment_type.PNG)
120
+
121
+ allure.attach("\n".join(report_lines), name='Отчет по вызовам API', attachment_type=allure.attachment_type.TEXT)
122
+
123
+ assert True
124
+
125
+ ```
126
+
127
+
128
+ What does the test look like:
129
+
130
+ ```commandline
131
+ async def test_get(self, api_client):
132
+ endpoint = 'https://ya.ru'
133
+ response = await api_client.make_request(
134
+ 'GET',
135
+ endpoint,
136
+ params='limit=1',
137
+ expected_status_code=200,
138
+ validate_model=Models.ValidateGet,
139
+ type=types.type_default
140
+ )
141
+ assert response is not None
142
+ assert isinstance(response, dict)
143
+ ```
144
+
145
+ All available data that the client can accept:
146
+ ```
147
+ method: str,
148
+ endpoint: str,
149
+ add_url1: Optional[str] = '',
150
+ add_url2: Optional[str] = '',
151
+ add_url3: Optional[str] = '',
152
+ params: Optional[Dict[str, Any]] = None,
153
+ headers: Optional[Dict[str, str]] = None,
154
+ data: Optional[Dict[str, Any]] = None,
155
+ expected_status_code: Optional[int] = None,
156
+ validate_model: Optional[Type[BaseModel]] = None,
157
+ type: Optional[str] = None```
@@ -0,0 +1,136 @@
1
+ ## HI!
2
+
3
+ This is a framework for API autotests with coverage assessment. Detailed instructions in the process of writing. It is better to check with the author how to use it. Tools are used:
4
+
5
+ * pytest
6
+ * httpx
7
+ * allure
8
+
9
+ Files are required for specific work:
10
+
11
+ **conftest.py** - it must have a fixture's inside:
12
+
13
+ ```commandline
14
+ @pytest.fixture(scope="session")
15
+ def api_client(domain):
16
+ return ApiClient(domain=domain)
17
+ ```
18
+ ```commandline
19
+ @pytest.fixture(scope='session', autouse=True)
20
+ def clear_call_data():
21
+ """Фикстура для очистки данных перед запуском тестов."""
22
+ global call_count, call_type
23
+ api_call_storage.call_count.clear()
24
+ api_call_storage.call_type.clear()
25
+ yield
26
+ ```
27
+
28
+ **confpartest.py** - It must have variables inside:
29
+
30
+ ```
31
+ swagger_files = {
32
+ 'test1': ['local', '../public/swagger/app-openapi.yaml'],
33
+ 'test2': ['local', '../public/swagger/app-openapi2.yaml'],
34
+ 'test3': ['url', 'https://url.ru']
35
+ }
36
+
37
+ test_types_coverage = ['default', '405', 'param']
38
+ ```
39
+
40
+ The project must have a test that displays information about the coverage in allure. The name of it **test_zorro.py**:
41
+
42
+ ```commandline
43
+
44
+ async def test_display_final_call_counts(self):
45
+ report_lines = []
46
+ total_coverage_percentage = 0
47
+ total_endpoints = 0
48
+ total_calls_excluding_generation = 0
49
+
50
+ for (method, endpoint, description), count in call_count.items():
51
+ types = set(call_type[(method, endpoint, description)])
52
+ total_endpoints += 1
53
+
54
+ # Подсчет вызовов, исключая тип 'generation_data'
55
+ if 'generation_data' not in types:
56
+ total_calls_excluding_generation += count
57
+
58
+ # Проверка на наличие обязательных типов тестов
59
+ coverage_status = "Недостаточное покрытие ❌"
60
+ matched_types = set(types).intersection(types)
61
+ count_matched = len(matched_types)
62
+
63
+ # Логика для определения статуса покрытия и расчета процента
64
+ if count_matched == len(types): # Все типы присутствуют
65
+ coverage_status = "Покрытие выполнено ✅"
66
+ total_coverage_percentage += 100
67
+ elif count_matched == 2:
68
+ coverage_status = "Покрытие выполнено на 66% 🔔"
69
+ total_coverage_percentage += 66
70
+ elif count_matched == 1:
71
+ coverage_status = "Покрытие выполнено на 33% ❌"
72
+ total_coverage_percentage += 33
73
+ else:
74
+ coverage_status = "Недостаточное покрытие ❌"
75
+ total_coverage_percentage += 0
76
+
77
+ report_line = (
78
+ f"\n{description}\nЭндпоинт: {endpoint}\nМетод: {method} | "
79
+ f"Обращений: {count}, Типы тестов: {', '.join(types)}\n{coverage_status}\n"
80
+ )
81
+ report_lines.append(report_line)
82
+
83
+ # Вычисление общего процента покрытия
84
+ if total_endpoints > 0:
85
+ average_coverage_percentage = total_coverage_percentage / total_endpoints
86
+ else:
87
+ average_coverage_percentage = 0
88
+
89
+ border = "*" * 50
90
+ summary = f"{border}\nОбщий процент покрытия: {average_coverage_percentage:.2f}%\nОбщее количество вызовов (исключая 'generation_data'): {total_calls_excluding_generation}\n{border}\n"
91
+
92
+ # Добавляем сводку в начало отчета
93
+ report_lines.insert(0, summary)
94
+
95
+ create_chart(call_count)
96
+
97
+ with open('api_call_counts.png', 'rb') as f:
98
+ allure.attach(f.read(), name='Оценка покрытия', attachment_type=allure.attachment_type.PNG)
99
+
100
+ allure.attach("\n".join(report_lines), name='Отчет по вызовам API', attachment_type=allure.attachment_type.TEXT)
101
+
102
+ assert True
103
+
104
+ ```
105
+
106
+
107
+ What does the test look like:
108
+
109
+ ```commandline
110
+ async def test_get(self, api_client):
111
+ endpoint = 'https://ya.ru'
112
+ response = await api_client.make_request(
113
+ 'GET',
114
+ endpoint,
115
+ params='limit=1',
116
+ expected_status_code=200,
117
+ validate_model=Models.ValidateGet,
118
+ type=types.type_default
119
+ )
120
+ assert response is not None
121
+ assert isinstance(response, dict)
122
+ ```
123
+
124
+ All available data that the client can accept:
125
+ ```
126
+ method: str,
127
+ endpoint: str,
128
+ add_url1: Optional[str] = '',
129
+ add_url2: Optional[str] = '',
130
+ add_url3: Optional[str] = '',
131
+ params: Optional[Dict[str, Any]] = None,
132
+ headers: Optional[Dict[str, str]] = None,
133
+ data: Optional[Dict[str, Any]] = None,
134
+ expected_status_code: Optional[int] = None,
135
+ validate_model: Optional[Type[BaseModel]] = None,
136
+ type: Optional[str] = None```
@@ -0,0 +1,7 @@
1
+ from .coverage import *
2
+ from .call_storage import *
3
+ from .client import *
4
+ from .test_types import *
5
+ from .allure_graph import *
6
+ from .methods import *
7
+ from .parparser import *
@@ -0,0 +1,20 @@
1
+ import matplotlib.pyplot as plt
2
+
3
+ def create_chart(call_count):
4
+ ''' Creating a graph that contains data on endpoint calls '''
5
+ methods = []
6
+ counts = []
7
+
8
+ for (method, endpoint, description), count in call_count.items():
9
+ methods.append(f"{description} ({method})")
10
+ counts.append(count)
11
+
12
+ plt.figure(figsize=(10, 6))
13
+ plt.barh(methods, counts, color='skyblue')
14
+ plt.xlabel('Количество вызовов')
15
+ plt.title('Количество вызовов API по методам и описаниям')
16
+ plt.tight_layout()
17
+
18
+ # Сохранение графика в файл
19
+ plt.savefig('api_call_counts.png')
20
+ plt.close()
@@ -0,0 +1,4 @@
1
+ ''' Data on the number of requests and types of tests are collected here. '''
2
+
3
+ call_count = {}
4
+ call_type = {}
@@ -0,0 +1,146 @@
1
+ import httpx
2
+ from typing import Optional, Dict, Any, Type
3
+
4
+ from pydantic import BaseModel, ValidationError
5
+ from partest import track_api_calls
6
+ from partest.utils import Logger, errordesc, StatusCode
7
+
8
+
9
+ class ApiClient:
10
+ """
11
+ ApiClient serves not only as a client for making requests for endpoints, but also for processing these requests.
12
+
13
+ Attributes
14
+ ----------
15
+ :param domain:
16
+ :param verify:
17
+ :param follow_redirects:
18
+
19
+ """
20
+
21
+
22
+ def __init__(self, domain, verify=False, follow_redirects=True):
23
+ self.domain = domain
24
+ self.verify = verify
25
+ self.follow_redirects = follow_redirects
26
+ self.logger = Logger()
27
+
28
+ @track_api_calls
29
+ async def make_request(
30
+ self,
31
+ method: str,
32
+ endpoint: str,
33
+ add_url1: Optional[str] = '',
34
+ add_url2: Optional[str] = '',
35
+ add_url3: Optional[str] = '',
36
+ params: Optional[Dict[str, Any]] = None,
37
+ headers: Optional[Dict[str, str]] = None,
38
+ data: Optional[Dict[str, Any]] = None,
39
+ expected_status_code: Optional[int] = None,
40
+ validate_model: Optional[Type[BaseModel]] = None,
41
+ type: Optional[str] = None
42
+
43
+ ) -> Optional[Dict[str, Any]]:
44
+
45
+ url = f"{self.domain}{endpoint}{add_url1}{add_url2}{add_url3}"
46
+ self.logger.log_request(method, url, params=params, headers=headers, data=data)
47
+
48
+ async with httpx.AsyncClient(verify=self.verify, follow_redirects=self.follow_redirects) as client:
49
+ try:
50
+ response = await self._perform_request(client, method, url, params, headers, data)
51
+
52
+ self.logger.log_response(response)
53
+
54
+ if expected_status_code is not None:
55
+ self._check_status_code(response.status_code, expected_status_code, response, data, validate_model)
56
+
57
+ return response.json()
58
+
59
+ except httpx.HTTPStatusError as err:
60
+ return self._handle_http_error(err, data)
61
+
62
+ except httpx.RequestError as e:
63
+ self.logger.error(f"An error occurred: {e}")
64
+ return None
65
+
66
+ async def _perform_request(self, client, method: str, url: str, params: Optional[Dict[str, Any]],
67
+ headers: Optional[Dict[str, str]], data: Optional[Dict[str, Any]]) -> httpx.Response:
68
+ if method == "GET":
69
+ return await client.get(url, params=params, headers=headers)
70
+ elif method == "POST":
71
+ return await client.post(url, json=data, params=params, headers=headers)
72
+ elif method == "PUT":
73
+ return await client.put(url, json=data, params=params, headers=headers)
74
+ elif method == "PATCH":
75
+ return await client.patch(url, json=data, params=params, headers=headers)
76
+ elif method == "DELETE":
77
+ return await client.delete(url, params=params, headers=headers)
78
+ else:
79
+ raise ValueError("Unsupported HTTP method")
80
+
81
+ def _check_status_code(self, actual_code: int, expected_code: int, response: httpx.Response,
82
+ request_data: Optional[Dict[str, Any]], validate_model: Optional[Type[BaseModel]]):
83
+ """ We check whether the actual status code corresponds to the expected status code. """
84
+ if actual_code != expected_code:
85
+ error_description = errordesc()
86
+ error_description.codeExpected = expected_code
87
+ error_description.codeActual = actual_code
88
+ error_description.responseBody = response
89
+ error_description.requestBody = request_data
90
+ self.logger.error(errordesc.status(
91
+ codeExpected=expected_code,
92
+ codeActual=error_description.codeActual,
93
+ responseBody=error_description.responseBody
94
+ ))
95
+ raise AssertionError(f"Expected status code {expected_code}, but got {actual_code}")
96
+
97
+ if validate_model:
98
+ try:
99
+ data = response.json()
100
+ assert validate_model.response_default(data)
101
+ except ValidationError as e:
102
+ self.logger.error(errordesc.validate(
103
+ validateModel=validate_model,
104
+ validateData=data,
105
+ error=str(e)
106
+ ))
107
+ raise AssertionError(f"Response data validation failed: {e}")
108
+
109
+ def _handle_http_error(self, err: httpx.HTTPStatusError, request_data: Optional[Dict[str, Any]]):
110
+ """ Exception handling. """
111
+ error_description = errordesc()
112
+ error_description.codeExpected = StatusCode.ok
113
+ error_description.codeActual = err.response.status_code
114
+ error_description.responseBody = err.response
115
+ error_description.requestBody = request_data
116
+ self.logger.error(errordesc.status(
117
+ codeExpected=StatusCode.ok,
118
+ codeActual=error_description.codeActual,
119
+ responseBody=error_description.responseBody
120
+ ))
121
+ return None
122
+
123
+ class Get(ApiClient):
124
+ async def get(self, endpoint, add_url1=None, add_url2=None, add_url3=None, params=None, headers=None, data=None, expected_status_code=None, validate_model=None):
125
+ return await self.make_request("GET", endpoint, add_url1=add_url1, add_url2=add_url2, add_url3=add_url3, params=params, data=data, headers=headers,
126
+ expected_status_code=expected_status_code, validate_model=validate_model)
127
+
128
+ class Post(ApiClient):
129
+ async def post(self, endpoint, add_url1=None, add_url2=None, add_url3=None, params=None, data=None, headers=None, expected_status_code=None, validate_model=None):
130
+ return await self.make_request("POST", endpoint, add_url1=add_url1, add_url2=add_url2, add_url3=add_url3, params=params, data=data, headers=headers,
131
+ expected_status_code=expected_status_code, validate_model=validate_model)
132
+
133
+ class Patch(ApiClient):
134
+ async def patch(self, endpoint, add_url1=None, add_url2=None, add_url3=None, params=None, data=None, headers=None, expected_status_code=None, validate_model=None):
135
+ return await self.make_request("PATCH", endpoint, add_url1=add_url1, add_url2=add_url2, add_url3=add_url3, params=params, data=data, headers=headers,
136
+ expected_status_code=expected_status_code, validate_model=validate_model)
137
+
138
+ class Put(ApiClient):
139
+ async def put(self, endpoint, add_url1=None, add_url2=None, add_url3=None, params=None, data=None, headers=None, expected_status_code=None, validate_model=None):
140
+ return await self.make_request("PUT", endpoint, add_url1=add_url1, add_url2=add_url2, add_url3=add_url3, params=params, data=data, headers=headers,
141
+ expected_status_code=expected_status_code, validate_model=validate_model)
142
+
143
+ class Delete(ApiClient):
144
+ async def delete(self, endpoint, add_url1=None, add_url2=None, add_url3=None, params=None, data=None, headers=None, expected_status_code=None, validate_model=None):
145
+ return await self.make_request("DELETE", endpoint, add_url1=add_url1, add_url2=add_url2, add_url3=add_url3, params=params, data=data, headers=headers,
146
+ expected_status_code=expected_status_code, validate_model=validate_model)
@@ -0,0 +1,90 @@
1
+ import re
2
+ from functools import wraps
3
+ from typing import Callable
4
+ from uuid import UUID
5
+
6
+ from confpartest import swagger_files
7
+ from partest.call_storage import call_count, call_type
8
+ from partest.parparser import SwaggerSettings
9
+
10
+ swagger_settings = SwaggerSettings(swagger_files)
11
+ paths_info = swagger_settings.collect_paths_info()
12
+
13
+ def track_api_calls(func: Callable) -> Callable:
14
+ """Decorator for tracking API calls."""
15
+
16
+ @wraps(func)
17
+ async def wrapper(*args, **kwargs):
18
+ method = args[1]
19
+ endpoint = args[2]
20
+ test_type = kwargs.get('type', 'unknown')
21
+
22
+ # Собираем параметры пути из paths_info
23
+ path_params = {}
24
+ for path in paths_info:
25
+ for param in path['parameters']:
26
+ if param.type == 'path':
27
+ if param.name not in path_params:
28
+ if param.schema is not None:
29
+ if 'enum' in param.schema:
30
+ path_params[param.name] = param.schema['enum']
31
+ else:
32
+ path_params[param.name] = []
33
+ else:
34
+ path_params[param.name] = []
35
+
36
+ # Processing the add_url parameters
37
+ for i in range(1, 4):
38
+ add_url = kwargs.get(f'add_url{i}')
39
+ if add_url:
40
+ new_param = re.sub(r'^.', '', add_url)
41
+ matched = False
42
+ remaining_param = None
43
+ for param_name, enum_values in path_params.items():
44
+ if new_param in enum_values:
45
+ endpoint += '/{' + f'{param_name}' + '}'
46
+ matched = True
47
+ break
48
+ else:
49
+ remaining_param = param_name
50
+
51
+ if not matched and remaining_param:
52
+ # If no match is found, add the remaining parameter
53
+ endpoint += '/{' + f'{remaining_param}' + '}'
54
+
55
+ # Check if the current method and endpoint match any of the paths_info
56
+ if method is not None and endpoint is not None:
57
+ matched_any = False # Flag for tracking whether a match has been found
58
+ for path in paths_info:
59
+ if path['method'] == method and path['path'] == endpoint:
60
+ key = (method, endpoint, path['description'])
61
+
62
+ if key not in call_count:
63
+ call_count[key] = 0
64
+ call_type[key] = []
65
+ call_count[key] += 1
66
+ call_type[key].append(test_type)
67
+ matched_any = True
68
+ break
69
+
70
+ # After checking all the paths, add the not found endpoints with 0 calls
71
+ for path in paths_info:
72
+ key = (path['method'], path['path'], path['description'])
73
+ if key not in call_count:
74
+ call_count[key] = 0
75
+ call_type[key] = []
76
+
77
+ response = await func(*args, **kwargs)
78
+
79
+ return response
80
+
81
+ return wrapper
82
+
83
+
84
+ def is_valid_uuid(uuid_to_test, version=4):
85
+ """Checks whether the string is a valid UUID."""
86
+ try:
87
+ uuid_obj = UUID(uuid_to_test, version=version)
88
+ except ValueError:
89
+ return False
90
+ return str(uuid_obj) == uuid_to_test
@@ -0,0 +1,2 @@
1
+ class MethodsList:
2
+ methods = {'GET': 'GET', 'POST': 'POST', 'PATCH': 'PATCH', 'PUT': 'PUT', 'DELETE': 'DELETE'}
@@ -0,0 +1,214 @@
1
+ import yaml
2
+ import requests
3
+
4
+ class Parameter:
5
+ """Class representing a parameter in an API path."""
6
+ def __init__(self, name, param_type, required=False, description='', schema=None):
7
+ self.name = name
8
+ self.type = param_type
9
+ self.required = required
10
+ self.description = description
11
+ self.schema = schema
12
+
13
+ def __repr__(self):
14
+ return f"Parameter(name={self.name}, type={self.type}, required={self.required}, description={self.description}, schema={self.schema})"
15
+
16
+
17
+ class RequestBody:
18
+ """Class representing the request body of an API path."""
19
+ def __init__(self, content):
20
+ self.content = content
21
+
22
+ @staticmethod
23
+ def resolve_schema(schema_ref, swagger_dict):
24
+ return OpenAPIParser.resolve_ref(schema_ref, swagger_dict)
25
+
26
+ def __repr__(self):
27
+ return f"RequestBody(content={self.content})"
28
+
29
+
30
+ class Response:
31
+ """Class representing a response from an API path."""
32
+ def __init__(self, status_code, content):
33
+ self.status_code = status_code
34
+ self.content = content
35
+
36
+ @staticmethod
37
+ def resolve_schema(schema_ref, swagger_dict):
38
+ return OpenAPIParser.resolve_ref(schema_ref, swagger_dict)
39
+
40
+ def __repr__(self):
41
+ return f"Response(status_code={self.status_code}, content={self.content})"
42
+
43
+
44
+ class Path:
45
+ """Class representing a path in an API."""
46
+ def __init__(self, path, method, description, parameters, request_body, responses):
47
+ self.path = path
48
+ self.method = method
49
+ self.description = description
50
+ self.parameters = parameters
51
+ self.request_body = request_body
52
+ self.responses = responses
53
+
54
+ def __repr__(self):
55
+ return f"Path(path={self.path}, method={self.method}, description={self.description}, parameters={self.parameters}, request_body={self.request_body}, responses={self.responses})"
56
+
57
+
58
+ class OpenAPIParser:
59
+ """Class for parsing OpenAPI specifications."""
60
+ def __init__(self, swagger_dict):
61
+ self.swagger_dict = swagger_dict
62
+
63
+ @staticmethod
64
+ def resolve_ref(ref, swagger_dict):
65
+ parts = ref.split('/')
66
+ resolved = swagger_dict
67
+ for part in parts[1:]:
68
+ resolved = resolved[part]
69
+ return resolved
70
+
71
+ @classmethod
72
+ def load_swagger_yaml(cls, source_type, file_path=None):
73
+ """Loads the Swagger YAML file from a local file or a URL."""
74
+ if source_type == 'local':
75
+ with open(file_path, 'r') as file:
76
+ swagger_dict = yaml.safe_load(file)
77
+ elif source_type == 'url':
78
+ response = requests.get(file_path)
79
+ response.raise_for_status()
80
+ swagger_dict = yaml.safe_load(response.text)
81
+ else:
82
+ raise ValueError("Invalid source type. Use 'local' or 'url'.")
83
+
84
+ return cls(swagger_dict)
85
+
86
+ def extract_paths_info(self):
87
+ """Extracts path information from the Swagger specification."""
88
+ paths = self.swagger_dict.get('paths', {})
89
+ result = []
90
+
91
+ for path, methods in paths.items():
92
+ for method, details in methods.items():
93
+ parameters = self.extract_parameters(details)
94
+ request_body = self.extract_request_body(details)
95
+ responses = self.extract_responses(details)
96
+ description = self.safe_get_description(details)
97
+ result.append(Path(
98
+ path=path,
99
+ method=method.upper(),
100
+ description=description,
101
+ parameters=parameters,
102
+ request_body=request_body,
103
+ responses=responses
104
+ ))
105
+
106
+ return result
107
+
108
+ def extract_parameters(self, details):
109
+ """Extracts parameters from the method details."""
110
+ parameters = []
111
+ if 'parameters' in details:
112
+ for param in details['parameters']:
113
+ resolved_param = self.resolve_param(param)
114
+ parameters.append(resolved_param)
115
+ return parameters
116
+
117
+ def extract_request_body(self, details):
118
+ """Extracts the request body from method details."""
119
+ if 'requestBody' in details:
120
+ content = details['requestBody'].get('content', {})
121
+ if 'application/json' in content:
122
+ if 'schema' in content['application/json'] and '$ref' in content['application/json']['schema']:
123
+ schema_ref = content['application/json']['schema']['$ref']
124
+ resolved_schema = RequestBody.resolve_schema(schema_ref, self.swagger_dict)
125
+ content['application/json']['schema'] = resolved_schema
126
+ return RequestBody(content['application/json'])
127
+ return None
128
+
129
+ def extract_responses(self, details):
130
+ """Extracts responses from method details."""
131
+ responses = {}
132
+ if 'responses' in details:
133
+ for code, response in details['responses'].items():
134
+ if 'content' in response and 'application/json' in response['content']:
135
+ content = response['content']['application/json']
136
+ if 'schema' in content and '$ref' in content['schema']:
137
+ schema_ref = content['schema']['$ref']
138
+ resolved_schema = Response.resolve_schema(schema_ref, self.swagger_dict)
139
+ content['schema'] = resolved_schema
140
+ responses[code] = Response(code, response)
141
+ return responses
142
+
143
+ def resolve_param(self, param):
144
+ """Resolves a parameter definition."""
145
+ if '$ref' in param:
146
+ resolved_param = self.resolve_ref(param['$ref'], self.swagger_dict)
147
+ schema_content = None
148
+ if 'schema' in resolved_param and '$ref' in resolved_param['schema']:
149
+ schema_ref = resolved_param['schema']['$ref']
150
+ schema_content = self.resolve_ref(schema_ref, self.swagger_dict)
151
+ return Parameter(
152
+ name=resolved_param['name'],
153
+ param_type=resolved_param['in'],
154
+ required=resolved_param.get('required', False),
155
+ description=resolved_param.get('description', ''),
156
+ schema=schema_content
157
+ )
158
+ else:
159
+ schema_content = None
160
+ if 'schema' in param and '$ref' in param['schema']:
161
+ schema_ref = param['schema']['$ref']
162
+ schema_content = self.resolve_ref(schema_ref, self.swagger_dict)
163
+
164
+ return Parameter(
165
+ name=param['name'],
166
+ param_type=param['in'],
167
+ required=param.get('required', False),
168
+ description=param.get('description', ''),
169
+ schema=schema_content
170
+ )
171
+
172
+ def safe_get_description(self, details):
173
+ """Safely retrieves the description from details."""
174
+ if isinstance(details, dict):
175
+ return details.get('description', '')
176
+ elif isinstance(details, list) and details:
177
+ return details[0].get('description', '') if isinstance(details[0], dict) else ''
178
+ return ''
179
+
180
+
181
+ class SwaggerSettings:
182
+ """Class for managing Swagger settings and loading Swagger files."""
183
+ def __init__(self, swagger_files):
184
+ self.local_files = []
185
+ self.swaggers = []
186
+ self.add_swagger(swagger_files)
187
+
188
+ def add_swagger(self, swagger_dict):
189
+ """Adds swaggger definitions from a dictionary to the swaggers list."""
190
+ for name, (source_type, path) in swagger_dict.items():
191
+ self.swaggers.append((source_type, path))
192
+
193
+ def load_swagger(self):
194
+ """Loads swaggger definitions and returns their data."""
195
+ all_extracted_data = []
196
+ for source_type, path in self.swaggers:
197
+ parser = OpenAPIParser.load_swagger_yaml(source_type, path)
198
+ extracted_data = parser.extract_paths_info()
199
+ all_extracted_data.extend(extracted_data)
200
+ return all_extracted_data
201
+
202
+ def collect_paths_info(self):
203
+ """Collects path information from all swagger definitions."""
204
+ extracted_data = self.load_swagger()
205
+ paths_info = []
206
+
207
+ for item in extracted_data:
208
+ paths_info.append({
209
+ 'description': item.description,
210
+ 'path': item.path,
211
+ 'method': item.method,
212
+ 'parameters': item.parameters
213
+ })
214
+ return paths_info
@@ -0,0 +1,8 @@
1
+
2
+ class TypesTestCases:
3
+ type_default = 'default'
4
+ type_405 = '405'
5
+ type_params = 'params'
6
+ type_elem = 'elem'
7
+ type_gen = 'generation_data'
8
+ type_health = 'health'
@@ -0,0 +1,4 @@
1
+ from .logger import *
2
+ from .date import *
3
+ from .ascii import *
4
+ from .checking import *
@@ -0,0 +1,14 @@
1
+ class BColors:
2
+ HEADER = '\033[95m'
3
+ OKBLUE = '\033[94m'
4
+ OKCYAN = '\033[96m'
5
+ OKGREEN = '\033[92m'
6
+ WARNING = '\033[93m'
7
+ FAIL = '\033[91m'
8
+ ENDC = '\033[0m'
9
+ BOLD = '\033[1m'
10
+ UNDERLINE = '\033[4m'
11
+
12
+
13
+ class MethodTypes:
14
+ type_list = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE']
@@ -0,0 +1,73 @@
1
+ import json
2
+
3
+
4
+ class errordesc:
5
+ def __init__(self):
6
+ self.codeExpected = None
7
+ self.codeActual = None
8
+ self.responseBody = None
9
+ self.responseHeader = None
10
+ self.requestHeader = None
11
+ self.requestBody = None
12
+ self.payloadElement = None
13
+ self.dataElement = None
14
+
15
+
16
+ @classmethod
17
+ def status(cls, codeExpected=200, codeActual=None, responseBody=None):
18
+ try:
19
+ body = f'Response body: \n{json.dumps(responseBody.json(), indent=4, ensure_ascii=False)}\n'
20
+ except:
21
+ body = f'Response (non-JSON): {responseBody.text}\n'
22
+ finally:
23
+ desc = (f"\n\n---->\nОшибка! Пришел некорректный статус код!\n"
24
+ f"Ожидали код: {codeExpected} <\> Получили код: {codeActual}\n"
25
+ f"{body}\n<----\n\n")
26
+ return desc
27
+
28
+ @classmethod
29
+ def validate(cls, validateData=None, validateModel=None, error=None):
30
+ try:
31
+ return "\n\n---->\nОшибка валидации, объекты сравнения:", error, validateModel, '\n', validateData, "\nПодробнее в принт-логах.\n<----\n\n"
32
+ except:
33
+ return "Нет тела или модели"
34
+
35
+ @classmethod
36
+ def _ifelse(cls):
37
+ try:
38
+ return "\n\n---->\nНе выполнены условия для выполнения теста, падение.\n<----\n\n"
39
+ except:
40
+ return "Чёт пошло не так"
41
+
42
+ @classmethod
43
+ def element(cls, payloadElement=None, dataElement=None, requestBody=None, responseBody=None ):
44
+ try:
45
+ if requestBody is not None and responseBody is not None:
46
+ _resp_body = f'Response body: \n{json.dumps(responseBody.json(), indent=4, ensure_ascii=False)}\n'
47
+ _req_body = f'Request body: \n{json.dumps(requestBody.json(), indent=4, ensure_ascii=False)}\n'
48
+ else:
49
+ _resp_body = ""
50
+ _req_body = ""
51
+ except:
52
+ if requestBody is not None and responseBody is not None:
53
+ _resp_body = f'Response (non-JSON): {responseBody.text}\n'
54
+ _req_body = f'Request (non-JSON): {requestBody.text}\n'
55
+ else:
56
+ _resp_body = ""
57
+ _req_body = ""
58
+ finally:
59
+ desc = (f"\n\n---->\nОшибка! Получили не то значение элемента что ожидали!\n"
60
+ f"Ожидали значение: {payloadElement} <\> Получили значение: {dataElement}\n"
61
+ f"{_resp_body}\n{_req_body}\n<----\n\n")
62
+ return desc
63
+
64
+ def __str__(self):
65
+ return "Ответ не валиден"
66
+
67
+ class StatusCode:
68
+ ok = 200
69
+ bad_request = 400
70
+ not_allowed = 405
71
+ forbidden = 403
72
+ not_found = 404
73
+ exception_400 = [200, 400]
@@ -0,0 +1,24 @@
1
+ from datetime import datetime, timedelta
2
+
3
+
4
+ class DateGen:
5
+ def __init__(self):
6
+ self._current_date = datetime.now()
7
+ self._formats = {
8
+ "default": "%Y-%m-%dT%H:%M:00+03:00",
9
+ "short": "%Y-%m-%d",
10
+ "long": "%Y-%m-%d %H:%M:%S",
11
+ }
12
+
13
+ @classmethod
14
+ def get_start_date(cls, days=0, format="default"):
15
+ start_date = cls()._current_date - timedelta(days=days)
16
+ return start_date.strftime(cls()._formats[format])
17
+
18
+ @classmethod
19
+ def get_end_date(cls, days=0, format="default"):
20
+ end_date = cls()._current_date + timedelta(days=days)
21
+ return end_date.strftime(cls()._formats[format])
22
+
23
+ def __str__(self):
24
+ return self._current_date.strftime("%Y-%m-%d")
@@ -0,0 +1,38 @@
1
+ import configparser
2
+ import json
3
+ import logging
4
+
5
+ from faker.providers.bank.en_PH import logger
6
+
7
+
8
+ class Logger:
9
+ def __init__(self):
10
+ self.logger = logging.getLogger(__name__)
11
+ self.setup_logger()
12
+
13
+ def setup_logger(self):
14
+ parser = configparser.ConfigParser()
15
+ parser.read('pytest.ini')
16
+
17
+ def get_log(self):
18
+ return self.logger
19
+
20
+ def log_request(self, method, url, params=None, headers=None, data=None):
21
+ self.logger.info(
22
+ f'{"=" * 14}REQUEST INFO{"=" * 14}\nRequest Method: {method} \nURL: {url} \nParams: {params} \nHeaders: {headers} \nData: {data}\n{"=" * 13}↓RESPONSE INFO↓{"=" * 13}')
23
+
24
+ def log_response(self, response):
25
+ try:
26
+ self.logger.info(
27
+ f'Response StatusCode: {response.status_code}\nCookies: {response.cookies}\nHeaders: {response.headers}, \nData: {json.dumps(response.json(), indent=4, ensure_ascii=False)}')
28
+ except json.JSONDecodeError:
29
+ self.logger.info(f'Response (non-JSON): {response.text}\n')
30
+
31
+ def error(self, message):
32
+ self.logger.error(message)
33
+
34
+ def log_str(self, str):
35
+ return self.logger.info(str)
36
+
37
+ def __str__(self):
38
+ return logger.info()
@@ -0,0 +1,66 @@
1
+ import allure
2
+
3
+ from partest.test_types import TypesTestCases
4
+ from partest.allure_graph import create_chart
5
+ from partest.call_storage import call_count, call_type
6
+ from confpartest import test_types_coverage, test_types_exception
7
+
8
+ types = TypesTestCases
9
+ required_types = test_types_coverage
10
+ exception_types = test_types_exception
11
+
12
+ def zorro():
13
+ """Function for displaying the total number of API calls and test types."""
14
+ report_lines = []
15
+ total_coverage_percentage = 0
16
+ total_endpoints = 0
17
+ total_calls_excluding_generation = 0
18
+
19
+ for (method, endpoint, description), count in call_count.items():
20
+ types = set(call_type[(method, endpoint, description)])
21
+ total_endpoints += 1
22
+
23
+ if 'generation_data' in types and len(types) == 1:
24
+ pass
25
+ else:
26
+ total_calls_excluding_generation += count
27
+
28
+ coverage_status = "Недостаточное покрытие ❌"
29
+ present_types = [test_type for test_type in required_types if test_type in types]
30
+ coverage_count = len(present_types)
31
+ required_count = len(required_types)
32
+
33
+ if any(exception_type in types for exception_type in exception_types):
34
+ coverage_percentage = 100
35
+ coverage_status = "Покрытие выполнено на 100% ✅ (исключение)"
36
+ elif coverage_count == required_count:
37
+ coverage_percentage = 100
38
+ coverage_status = "Покрытие выполнено ✅"
39
+ elif coverage_count > 0:
40
+ coverage_percentage = (coverage_count / required_count) * 100
41
+ coverage_status = f"Покрытие выполнено на {coverage_percentage:.2f}% 🔔"
42
+ else:
43
+ coverage_percentage = 0
44
+
45
+ total_coverage_percentage += coverage_percentage
46
+
47
+ report_line = (
48
+ f"\n{description}\nЭндпоинт: {endpoint}\nМетод: {method} | "
49
+ f"Обращений: {count}, Типы тестов: {', '.join(types)}\n{coverage_status}\n"
50
+ )
51
+ report_lines.append(report_line)
52
+
53
+ if total_endpoints > 0:
54
+ average_coverage_percentage = total_coverage_percentage / total_endpoints
55
+ else:
56
+ average_coverage_percentage = 0
57
+
58
+ border = "*" * 50
59
+ summary = f"{border}\nОбщий процент покрытия: {average_coverage_percentage:.2f}%\nОбщее количество вызовов (исключая 'generation_data'): {total_calls_excluding_generation}\n{border}\n"
60
+ report_lines.insert(0, summary)
61
+ create_chart(call_count)
62
+
63
+ with open('api_call_counts.png', 'rb') as f:
64
+ allure.attach(f.read(), name='Оценка покрытия', attachment_type=allure.attachment_type.PNG)
65
+
66
+ allure.attach("\n".join(report_lines), name='Отчет по вызовам API', attachment_type=allure.attachment_type.TEXT)
@@ -0,0 +1,157 @@
1
+ Metadata-Version: 2.1
2
+ Name: partest
3
+ Version: 0.1.8
4
+ Summary: This is a module for the rapid implementation of test cases with coverage tracking. This module contains a call counter for specific endpoints and their methods. As well as the function of determining the types of tests that need to be counted.
5
+ Home-page: https://github.com/Dec01/partest
6
+ Author: dec01
7
+ Author-email: parschin.ewg@yandex.ru
8
+ Project-URL: GitHub, https://github.com/Dec01/partest
9
+ Keywords: autotest partest test coverage
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Operating System :: OS Independent
13
+ Requires-Python: >=3.8
14
+ Description-Content-Type: text/markdown
15
+ Requires-Dist: httpx>=0.27.2
16
+ Requires-Dist: pyyaml>=6.0.2
17
+ Requires-Dist: matplotlib>=3.9.2
18
+ Requires-Dist: allure-pytest>=2.8.18
19
+ Requires-Dist: pytest-asyncio>=0.23.7
20
+ Requires-Dist: pytest==8.3.3
21
+
22
+ ## HI!
23
+
24
+ This is a framework for API autotests with coverage assessment. Detailed instructions in the process of writing. It is better to check with the author how to use it. Tools are used:
25
+
26
+ * pytest
27
+ * httpx
28
+ * allure
29
+
30
+ Files are required for specific work:
31
+
32
+ **conftest.py** - it must have a fixture's inside:
33
+
34
+ ```commandline
35
+ @pytest.fixture(scope="session")
36
+ def api_client(domain):
37
+ return ApiClient(domain=domain)
38
+ ```
39
+ ```commandline
40
+ @pytest.fixture(scope='session', autouse=True)
41
+ def clear_call_data():
42
+ """Фикстура для очистки данных перед запуском тестов."""
43
+ global call_count, call_type
44
+ api_call_storage.call_count.clear()
45
+ api_call_storage.call_type.clear()
46
+ yield
47
+ ```
48
+
49
+ **confpartest.py** - It must have variables inside:
50
+
51
+ ```
52
+ swagger_files = {
53
+ 'test1': ['local', '../public/swagger/app-openapi.yaml'],
54
+ 'test2': ['local', '../public/swagger/app-openapi2.yaml'],
55
+ 'test3': ['url', 'https://url.ru']
56
+ }
57
+
58
+ test_types_coverage = ['default', '405', 'param']
59
+ ```
60
+
61
+ The project must have a test that displays information about the coverage in allure. The name of it **test_zorro.py**:
62
+
63
+ ```commandline
64
+
65
+ async def test_display_final_call_counts(self):
66
+ report_lines = []
67
+ total_coverage_percentage = 0
68
+ total_endpoints = 0
69
+ total_calls_excluding_generation = 0
70
+
71
+ for (method, endpoint, description), count in call_count.items():
72
+ types = set(call_type[(method, endpoint, description)])
73
+ total_endpoints += 1
74
+
75
+ # Подсчет вызовов, исключая тип 'generation_data'
76
+ if 'generation_data' not in types:
77
+ total_calls_excluding_generation += count
78
+
79
+ # Проверка на наличие обязательных типов тестов
80
+ coverage_status = "Недостаточное покрытие ❌"
81
+ matched_types = set(types).intersection(types)
82
+ count_matched = len(matched_types)
83
+
84
+ # Логика для определения статуса покрытия и расчета процента
85
+ if count_matched == len(types): # Все типы присутствуют
86
+ coverage_status = "Покрытие выполнено ✅"
87
+ total_coverage_percentage += 100
88
+ elif count_matched == 2:
89
+ coverage_status = "Покрытие выполнено на 66% 🔔"
90
+ total_coverage_percentage += 66
91
+ elif count_matched == 1:
92
+ coverage_status = "Покрытие выполнено на 33% ❌"
93
+ total_coverage_percentage += 33
94
+ else:
95
+ coverage_status = "Недостаточное покрытие ❌"
96
+ total_coverage_percentage += 0
97
+
98
+ report_line = (
99
+ f"\n{description}\nЭндпоинт: {endpoint}\nМетод: {method} | "
100
+ f"Обращений: {count}, Типы тестов: {', '.join(types)}\n{coverage_status}\n"
101
+ )
102
+ report_lines.append(report_line)
103
+
104
+ # Вычисление общего процента покрытия
105
+ if total_endpoints > 0:
106
+ average_coverage_percentage = total_coverage_percentage / total_endpoints
107
+ else:
108
+ average_coverage_percentage = 0
109
+
110
+ border = "*" * 50
111
+ summary = f"{border}\nОбщий процент покрытия: {average_coverage_percentage:.2f}%\nОбщее количество вызовов (исключая 'generation_data'): {total_calls_excluding_generation}\n{border}\n"
112
+
113
+ # Добавляем сводку в начало отчета
114
+ report_lines.insert(0, summary)
115
+
116
+ create_chart(call_count)
117
+
118
+ with open('api_call_counts.png', 'rb') as f:
119
+ allure.attach(f.read(), name='Оценка покрытия', attachment_type=allure.attachment_type.PNG)
120
+
121
+ allure.attach("\n".join(report_lines), name='Отчет по вызовам API', attachment_type=allure.attachment_type.TEXT)
122
+
123
+ assert True
124
+
125
+ ```
126
+
127
+
128
+ What does the test look like:
129
+
130
+ ```commandline
131
+ async def test_get(self, api_client):
132
+ endpoint = 'https://ya.ru'
133
+ response = await api_client.make_request(
134
+ 'GET',
135
+ endpoint,
136
+ params='limit=1',
137
+ expected_status_code=200,
138
+ validate_model=Models.ValidateGet,
139
+ type=types.type_default
140
+ )
141
+ assert response is not None
142
+ assert isinstance(response, dict)
143
+ ```
144
+
145
+ All available data that the client can accept:
146
+ ```
147
+ method: str,
148
+ endpoint: str,
149
+ add_url1: Optional[str] = '',
150
+ add_url2: Optional[str] = '',
151
+ add_url3: Optional[str] = '',
152
+ params: Optional[Dict[str, Any]] = None,
153
+ headers: Optional[Dict[str, str]] = None,
154
+ data: Optional[Dict[str, Any]] = None,
155
+ expected_status_code: Optional[int] = None,
156
+ validate_model: Optional[Type[BaseModel]] = None,
157
+ type: Optional[str] = None```
@@ -0,0 +1,22 @@
1
+ README.md
2
+ setup.cfg
3
+ setup.py
4
+ partest/__init__.py
5
+ partest/allure_graph.py
6
+ partest/call_storage.py
7
+ partest/client.py
8
+ partest/coverage.py
9
+ partest/methods.py
10
+ partest/parparser.py
11
+ partest/test_types.py
12
+ partest/zorro_report.py
13
+ partest.egg-info/PKG-INFO
14
+ partest.egg-info/SOURCES.txt
15
+ partest.egg-info/dependency_links.txt
16
+ partest.egg-info/requires.txt
17
+ partest.egg-info/top_level.txt
18
+ partest/utils/__init__.py
19
+ partest/utils/ascii.py
20
+ partest/utils/checking.py
21
+ partest/utils/date.py
22
+ partest/utils/logger.py
@@ -0,0 +1,6 @@
1
+ httpx>=0.27.2
2
+ pyyaml>=6.0.2
3
+ matplotlib>=3.9.2
4
+ allure-pytest>=2.8.18
5
+ pytest-asyncio>=0.23.7
6
+ pytest==8.3.3
@@ -0,0 +1 @@
1
+ partest
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
partest-0.1.8/setup.py ADDED
@@ -0,0 +1,30 @@
1
+ from setuptools import setup, find_packages
2
+
3
+
4
+ def readme():
5
+ with open('README.md', 'r') as f:
6
+ return f.read()
7
+
8
+
9
+ setup(
10
+ name='partest',
11
+ version='0.1.8',
12
+ author='dec01',
13
+ author_email='parschin.ewg@yandex.ru',
14
+ description='This is a module for the rapid implementation of test cases with coverage tracking. This module contains a call counter for specific endpoints and their methods. As well as the function of determining the types of tests that need to be counted.',
15
+ long_description=readme(),
16
+ long_description_content_type='text/markdown',
17
+ url='https://github.com/Dec01/partest',
18
+ packages=find_packages(exclude=['src']),
19
+ install_requires=['httpx>=0.27.2', 'pyyaml>=6.0.2', 'matplotlib>=3.9.2', 'allure-pytest>=2.8.18', 'pytest-asyncio>=0.23.7', 'pytest==8.3.3'],
20
+ classifiers=[
21
+ 'Programming Language :: Python :: 3',
22
+ 'License :: OSI Approved :: MIT License',
23
+ 'Operating System :: OS Independent'
24
+ ],
25
+ keywords='autotest partest test coverage',
26
+ project_urls={
27
+ 'GitHub': 'https://github.com/Dec01/partest'
28
+ },
29
+ python_requires='>=3.8'
30
+ )