surety-api 0.0.1__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.
surety/api/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .caller import ApiCaller
2
+ from .mock.service import MockServer
3
+ from .method import ApiMethod
4
+ from .schema import HttpMethod
surety/api/caller.py ADDED
@@ -0,0 +1,85 @@
1
+ from surety.process import dictionary
2
+ from surety.sdk import Field
3
+ from surety.diff import compare
4
+
5
+ from surety.api.method import ApiMethod
6
+
7
+
8
+ class ApiCaller:
9
+ method: ApiMethod
10
+
11
+ def __init__(self, req_body=None, path_params=None, headers=None,
12
+ params=None, cookies=None, updates=None):
13
+ self.req_body = req_body
14
+ self.response = None
15
+ self.path_params = path_params
16
+ self.headers = headers
17
+ self.params = params
18
+ self.cookies = cookies
19
+ self.updates = updates
20
+
21
+ def request(self):
22
+ req_body = self.req_body
23
+ params = self.params
24
+
25
+ if req_body is None:
26
+ pass
27
+ elif isinstance(self.req_body, Field):
28
+ req_body = self.req_body.value
29
+
30
+ if self.updates:
31
+ req_body = dictionary.merge_with_updates(req_body, self.updates)
32
+
33
+ if params is None:
34
+ pass
35
+ elif isinstance(self.params, Field):
36
+ params = self.params.value
37
+
38
+ self.response = self.method.call(
39
+ req_body=req_body,
40
+ path_params=self.path_params,
41
+ headers=self.headers,
42
+ params=params,
43
+ cookies=self.cookies
44
+ )
45
+
46
+ return self
47
+
48
+ def verify_response(self, error_code=None, resp_body=None, normalize=False,
49
+ normalize_keys=None, parse_response=None, rules=None):
50
+ if error_code:
51
+ assert self.response.status_code == error_code, (
52
+ f'Unexpected code: {self.response.status_code}\n'
53
+ f'Response text: {self.response.text}'
54
+ )
55
+ else:
56
+ assert self.response.status_code == 200, (
57
+ f'Unexpected response code: {self.response.status_code}'
58
+ f'\nResponse text: {self.response.text}'
59
+ )
60
+
61
+ if resp_body is not None or isinstance(resp_body, Field):
62
+ content_type = self.response.headers.get('Content-Type')
63
+
64
+ if content_type == 'text/csv':
65
+ result_data = self.response.text
66
+
67
+ if parse_response and callable(parse_response):
68
+ result_data = parse_response(result_data)
69
+ else:
70
+ result_data = self.response.text and self.response.json()
71
+
72
+ if isinstance(resp_body, Field):
73
+ resp_body = resp_body.value
74
+
75
+ if normalize or normalize_keys:
76
+ resp_body = dictionary.normalize(resp_body, result_data, keys=normalize_keys)
77
+
78
+ compare(
79
+ expected=resp_body,
80
+ actual=result_data,
81
+ rules=rules,
82
+ target_name='api response'
83
+ )
84
+
85
+ return self
surety/api/method.py ADDED
@@ -0,0 +1,175 @@
1
+ import json
2
+
3
+ import requests
4
+
5
+ from surety.process import dictionary
6
+ from surety.sdk import Field
7
+ from surety.diff import compare
8
+
9
+ from surety.api.mock.data import JsonFilter, MatchType, StringFilter
10
+ from surety.api.schema import Schema, HttpMethod
11
+
12
+
13
+ class ApiMethod(Schema):
14
+ @classmethod
15
+ def call(cls, req_body=None, session=None, path_params=None, headers=None,
16
+ params=None, cookies=None):
17
+ session = session or requests.Session()
18
+
19
+ if params is None:
20
+ params = cls.params().value
21
+
22
+ if req_body is None:
23
+ req_body = cls.req_body().value
24
+
25
+ if req_body is not None:
26
+ req_body = json.dumps(req_body)
27
+
28
+ url = cls.get_request_url(path_params=path_params)
29
+ headers = headers or {}
30
+ headers = dictionary.merge_with_updates(headers, {
31
+ 'Content-Type': 'application/json',
32
+ })
33
+
34
+ return session.request(
35
+ str(cls.method),
36
+ url,
37
+ headers=headers,
38
+ params=params,
39
+ data=req_body,
40
+ cookies=cookies
41
+ )
42
+
43
+ @classmethod
44
+ def reply(cls, path_params=None, params=None, req_body=None, body=None,
45
+ status=200, reset=False, cookies=None, headers=None, times=1,
46
+ no_prefix=False, substr_filter=None, strict=False,
47
+ default_headers=False):
48
+ from surety.api.mock.service import MockServer # pylint: disable=cyclic-import
49
+ reply_body = body
50
+
51
+ if default_headers and headers is None:
52
+ headers = cls.fe_headers()
53
+
54
+ headers = headers or {'Content-Type': 'application/json',}
55
+
56
+ if reply_body is None:
57
+ reply_body = cls.resp_body and cls.resp_body() # pylint: disable=not-callable
58
+
59
+ if reply_body not in [None, {}, ''] and isinstance(reply_body, Field):
60
+ reply_body = reply_body.value
61
+ reply_body = json.dumps(reply_body)
62
+
63
+ if req_body:
64
+ req_body = JsonFilter().with_values({
65
+ JsonFilter.Json.name: req_body,
66
+ JsonFilter.MatchType.name:
67
+ MatchType.Strict if strict else MatchType.Partial
68
+ })
69
+ elif substr_filter:
70
+ req_body = StringFilter().with_values({
71
+ StringFilter.String.name: substr_filter
72
+ })
73
+
74
+ if no_prefix:
75
+ prefix = ''
76
+ else:
77
+ prefix = f'/mockserver/{cls.get_service_name()}'
78
+
79
+ return MockServer().reply(
80
+ method=cls.method,
81
+ url=f'{prefix}{cls.get_relative_url(path_params)}',
82
+ params=params,
83
+ req_body=req_body,
84
+ body=reply_body,
85
+ status=status,
86
+ reset=reset,
87
+ headers=headers or {},
88
+ cookies=cookies,
89
+ times=times
90
+ )
91
+
92
+ @classmethod
93
+ def reply_corse_options(cls, allow_headers, reset=False, no_prefix=False,
94
+ path_params=None, params=None, req_body=None):
95
+ from surety.api.mock.service import MockServer # pylint: disable=cyclic-import
96
+ headers = cls.fe_headers(
97
+ content_type='Access-Control-Allow-Headers',
98
+ )
99
+ headers['Access-Control-Allow-Headers'] = ','.join(allow_headers)
100
+
101
+ if no_prefix:
102
+ prefix = ''
103
+ else:
104
+ prefix = f'/mockserver/{cls.get_service_name()}'
105
+
106
+ return MockServer().reply(
107
+ method=HttpMethod.OPTIONS,
108
+ url=f'{prefix}{cls.get_relative_url(path_params)}',
109
+ params=params,
110
+ req_body=req_body,
111
+ body=None,
112
+ reset=reset,
113
+ headers=headers
114
+ )
115
+
116
+ @classmethod
117
+ def verify_called(cls, expected=None, normalize=False, normalize_keys=None,
118
+ path_params=None, timeout=None, body=None, headers=None,
119
+ latest=False, params=None, rules=None, header_rules=None):
120
+ from surety.api.mock.service import MockServer # pylint: disable=cyclic-import
121
+
122
+ expected_value = expected
123
+
124
+ if isinstance(expected_value, Field):
125
+ expected_value = expected_value.value
126
+ if isinstance(params, Field):
127
+ params = params.value
128
+
129
+ if body:
130
+ if isinstance(body, str):
131
+ body = StringFilter().with_values({
132
+ StringFilter.String.name: body
133
+ })
134
+
135
+ actual_value, actual_headers, actual_params = MockServer().catch(
136
+ method=cls.method,
137
+ url=(f'/mockserver/{cls.get_service_name()}'
138
+ f'{cls.get_relative_url(path_params=path_params)}'),
139
+ body=body,
140
+ timeout=timeout,
141
+ latest=latest
142
+ )
143
+
144
+ if normalize or normalize_keys:
145
+ actual_value = dictionary.normalize(
146
+ actual_value, expected_value, keys=normalize_keys
147
+ )
148
+ compare(
149
+ actual=actual_value,
150
+ expected=expected_value,
151
+ rules=rules or None,
152
+ target_name='api request'
153
+ )
154
+
155
+ if headers:
156
+ filtered_headers = {}
157
+
158
+ for key in headers:
159
+ filtered_headers[key] = actual_headers.get(key)
160
+
161
+ compare(
162
+ actual=filtered_headers,
163
+ expected=headers,
164
+ rules=header_rules or None,
165
+ target_name='api request headers'
166
+ )
167
+
168
+ #FIXME parse into object + verify all together
169
+
170
+ if params:
171
+ compare(
172
+ actual=actual_params,
173
+ expected=params,
174
+ target_name='api request params'
175
+ )
File without changes
@@ -0,0 +1,95 @@
1
+ from surety.sdk import Bool, Dictionary, Enum, Int, Raw, String
2
+
3
+ from surety.api.schema import HttpMethod
4
+
5
+
6
+ class JsonMatcher(Enum):
7
+ IGNORE_KEY = '${json-unit.ignore-element}'
8
+
9
+
10
+ class HttpRequest(Dictionary):
11
+ Method = String(name='method')
12
+ Path = String(name='path')
13
+ Params = Raw(name='queryStringParameters', required=False)
14
+ Body = Raw(name='body', required=False)
15
+
16
+
17
+ class HttpResponse(Dictionary):
18
+ StatusCode = Int(name='statusCode')
19
+ Headers = Raw(name='headers', required=False)
20
+ Cookies = Raw(name='cookies', required=False)
21
+ Body = Raw(name='body', required=False)
22
+
23
+
24
+ class Command(Enum):
25
+ Expectation = 'expectation'
26
+ Reset = 'reset'
27
+ Retrieve = 'retrieve'
28
+
29
+
30
+ class ObjectType(Enum):
31
+ Requests = 'REQUESTS'
32
+ ActiveExpectations = 'active_expectations'
33
+
34
+
35
+ class Params(Dictionary):
36
+ Type = String(name='type', required=False)
37
+
38
+
39
+ class PathParams(Dictionary):
40
+ Command = Command(name='command')
41
+
42
+
43
+ class Times(Dictionary):
44
+ RemainingTimes = Int(name='remainingTimes')
45
+ Unlimited = Bool(name='unlimited', required=False)
46
+
47
+
48
+ class ExpectationBody(Dictionary):
49
+ HttpRequest = HttpRequest(name='httpRequest')
50
+ HttpResponse = HttpResponse(name='httpResponse')
51
+ Times = Times(name='times')
52
+
53
+
54
+ class RetrieveHttpRequest(Dictionary):
55
+ Method = HttpMethod(name='method', required=False)
56
+ Body = Raw(name='body', required=False)
57
+ Path = String(name='path')
58
+
59
+
60
+ class RetrieveBody(Dictionary):
61
+ HttpRequest = RetrieveHttpRequest(name='httpRequest')
62
+
63
+
64
+ class DataResponse(Dictionary):
65
+ Data = Raw(name='data')
66
+
67
+
68
+ class MatchType(Enum):
69
+ Strict = 'STRICT'
70
+ Partial = 'ONLY_MATCHING_FIELDS'
71
+
72
+
73
+ class JsonFilter(Dictionary):
74
+ Type = String(name='type', default='JSON')
75
+ Json = Raw(name='json')
76
+ MatchType = MatchType(name='matchType', default=MatchType.Partial)
77
+
78
+
79
+ class StringFilter(Dictionary):
80
+ Type = String(name='type', default='STRING')
81
+ String = String(name='string')
82
+ SubString = Bool(name='subString', default=True)
83
+
84
+
85
+ class TimeToLive(Dictionary):
86
+ Unlimited = Bool(name='unlimited')
87
+
88
+
89
+ class UnaddressedRequest(Dictionary):
90
+ HttpRequest = HttpRequest(name='httpRequest')
91
+ HttpResponse = HttpResponse(name='httpResponse', required=False)
92
+ Id = String(name='id')
93
+ Priority = Int(name='priority')
94
+ TimeToLive = TimeToLive(name='timeToLive')
95
+ Times = Times(name='times', required=False)
@@ -0,0 +1,179 @@
1
+ import waiting
2
+
3
+ from waiting.exceptions import TimeoutExpired
4
+
5
+ from surety.sdk import Field
6
+ from surety.config import Cfg
7
+ from surety.api.caller import ApiCaller
8
+ from surety.api.method import ApiMethod
9
+ from surety.api.mock.data import (
10
+ Command, ExpectationBody, HttpRequest, HttpResponse, ObjectType, Params,
11
+ PathParams, RetrieveBody, RetrieveHttpRequest, Times, UnaddressedRequest,
12
+ )
13
+ from surety.api.schema import HttpMethod
14
+
15
+
16
+ def get_mockserver_url():
17
+ if 'MockServer' in Cfg:
18
+ return f'http://{Cfg.MockServer.host}:{Cfg.MockServer.port}'
19
+
20
+ return None
21
+
22
+
23
+ BASE_URL = get_mockserver_url()
24
+
25
+
26
+ class RunCommand(ApiMethod):
27
+ method = HttpMethod.PUT
28
+ url = 'mockserver/{command}'
29
+ path_params = PathParams
30
+
31
+
32
+ class MockClient(ApiCaller):
33
+ method = RunCommand
34
+
35
+ def __init__(self, command, params=None, headers=None, body=None):
36
+ super().__init__(
37
+ headers=headers,
38
+ path_params=PathParams().with_values({
39
+ PathParams.Command.name: command,
40
+ }),
41
+ params=params,
42
+ req_body=body
43
+ )
44
+
45
+
46
+ class MockServer:
47
+ @staticmethod
48
+ def _get_client(command, params=None, headers=None, body=None):
49
+ return MockClient(command, params, headers, body).request()
50
+
51
+ def reset(self):
52
+ self._get_client(command=Command.Reset, headers={})
53
+
54
+ def reply(self, method, url, body, status=200, params=None, req_body=None,
55
+ headers=None, cookies=None, times=1, reset=True):
56
+ if reset:
57
+ self.reset()
58
+
59
+ self._get_client(
60
+ command=Command.Expectation,
61
+ headers=headers,
62
+ body=ExpectationBody().with_values({
63
+ ExpectationBody.HttpRequest.name: {
64
+ HttpRequest.Method.name: method,
65
+ HttpRequest.Path.name: url,
66
+ HttpRequest.Params.name: params,
67
+ HttpRequest.Body.name: req_body,
68
+ },
69
+ ExpectationBody.HttpResponse.name: {
70
+ HttpResponse.StatusCode.name: status,
71
+ HttpResponse.Headers.name: headers,
72
+ HttpResponse.Cookies.name: cookies,
73
+ HttpResponse.Body.name: body
74
+ },
75
+ ExpectationBody.Times.name: {
76
+ Times.RemainingTimes.name: times,
77
+ Times.Unlimited.name: False,
78
+ }
79
+ })
80
+ )
81
+
82
+ def catch(self, url, body=None, method=None, timeout=None,
83
+ latest=False):
84
+ req_body = body
85
+
86
+ if isinstance(req_body, Field):
87
+ req_body = req_body.value
88
+
89
+ def catch_request():
90
+ return self._get_client(
91
+ command=Command.Retrieve,
92
+ params=Params().with_values({
93
+ Params.Type.name: ObjectType.Requests,
94
+ }),
95
+ body=RetrieveBody().with_values({
96
+ RetrieveBody.HttpRequest.name:
97
+ RetrieveHttpRequest().with_values({
98
+ RetrieveHttpRequest.Method.name: method,
99
+ RetrieveHttpRequest.Path.name: url,
100
+ RetrieveHttpRequest.Body.name: req_body,
101
+ }),
102
+ })
103
+ ).response.json()
104
+
105
+ if timeout is None:
106
+ caught = catch_request()
107
+ else:
108
+ caught = waiting.wait(
109
+ catch_request,
110
+ timeout_seconds=timeout,
111
+ sleep_seconds=0,
112
+ waiting_for='request'
113
+ )
114
+
115
+ assert caught, 'No requests received'
116
+
117
+ if latest:
118
+ incoming = caught[0]
119
+ else:
120
+ assert len(caught) == 1, 'More than one request are caught'
121
+ incoming = caught[0]
122
+
123
+ headers = incoming.get('headers')
124
+ body = incoming.get('body')
125
+ params = incoming.get('queryStringParameters')
126
+
127
+ if isinstance(body, dict) and 'json' in body:
128
+ body = body['json']
129
+
130
+ return body, headers, params
131
+
132
+ def get_unaddressed_requests(self):
133
+ response = self._get_client(
134
+ command=Command.Retrieve,
135
+ params=Params().with_values({
136
+ Params.Type.name: ObjectType.ActiveExpectations,
137
+ })
138
+ ).response.json()
139
+
140
+ return [UnaddressedRequest().with_values(resp) for resp in response]
141
+
142
+ def wait_for_mocks_to_be_called(self, timeout_seconds=1):
143
+ try:
144
+ return waiting.wait(
145
+ lambda: not self.get_unaddressed_requests(),
146
+ sleep_seconds=0,
147
+ timeout_seconds=timeout_seconds,
148
+ waiting_for='mocks to be called',
149
+ )
150
+ except TimeoutExpired:
151
+ return None
152
+
153
+ def verify_all_mocks_called(self, timeout_seconds=1): # pylint:disable=inconsistent-return-statements
154
+ try:
155
+ return waiting.wait(
156
+ lambda: not self.get_unaddressed_requests(),
157
+ sleep_seconds=0,
158
+ timeout_seconds=timeout_seconds,
159
+ waiting_for='mocks to be called',
160
+ )
161
+ except TimeoutExpired:
162
+ pass
163
+
164
+ requests = self.get_unaddressed_requests()
165
+ parsed_reqs = ''
166
+
167
+ for req in requests:
168
+ body = req.HttpRequest.Body.value
169
+
170
+ if body and 'json' in body:
171
+ body = body['json']
172
+
173
+ parsed_reqs += (
174
+ f'\n{req.HttpRequest.Method.value} '
175
+ f'{req.HttpRequest.Path.value} {body}'
176
+ f'\nTimes: {req.Times.RemainingTimes.value}'
177
+ )
178
+
179
+ assert not requests, f'There are unused mocks: {parsed_reqs}'
surety/api/schema.py ADDED
@@ -0,0 +1,79 @@
1
+ import inspect
2
+
3
+ from urllib.parse import urljoin
4
+
5
+ from surety.process.common import exclude_none_from_kwargs
6
+ from surety.sdk import Dictionary, Enum
7
+ from surety.config import Cfg
8
+
9
+
10
+ def get_default_app_url():
11
+ if 'App' in Cfg:
12
+ return Cfg.App.get('url')
13
+
14
+ return None
15
+
16
+
17
+ class HttpMethod(Enum):
18
+ POST = 'POST'
19
+ GET = 'GET'
20
+ HEAD = 'HEAD'
21
+ PATCH = 'PATCH'
22
+ DELETE = 'DELETE'
23
+ PUT = 'PUT'
24
+ TRACE = 'TRACE'
25
+ OPTIONS = 'OPTIONS'
26
+
27
+
28
+ class Schema:
29
+ """
30
+ Additional configuration on module level:
31
+ SERVICE: service name for mocking
32
+ BASE_URL: application base url if not Cfg.App.url (default)
33
+ API_URL: api base url
34
+ """
35
+
36
+ method: HttpMethod
37
+ url: str
38
+ path_params = Dictionary
39
+ params = Dictionary
40
+ req_body = Dictionary
41
+ resp_body = Dictionary
42
+
43
+ @classmethod
44
+ def get_relative_url(cls, path_params=None):
45
+ path_params = path_params or cls.path_params()
46
+ formatted_url = cls.url.format(**path_params.value)
47
+ return f'{cls.get_api_base_url()}{formatted_url}'
48
+
49
+ @classmethod
50
+ def get_service_name(cls):
51
+ parent_module = inspect.getmodule(cls)
52
+ return getattr(parent_module, 'SERVICE', None)
53
+
54
+ @classmethod
55
+ def get_base_url(cls):
56
+ parent_module = inspect.getmodule(cls)
57
+ return getattr(parent_module, 'BASE_URL', get_default_app_url())
58
+
59
+ @classmethod
60
+ def get_api_base_url(cls):
61
+ parent_module = inspect.getmodule(cls)
62
+ return getattr(parent_module, 'API_URL', '')
63
+
64
+ @classmethod
65
+ def get_request_url(cls, path_params=None):
66
+ return urljoin(
67
+ f'{cls.get_base_url()}',
68
+ f'{cls.get_relative_url(path_params=path_params)}'
69
+ )
70
+
71
+ @classmethod
72
+ def fe_headers(cls, app_url=None, content_type='application/json',
73
+ set_cookie=None):
74
+ return exclude_none_from_kwargs({
75
+ 'Access-Control-Allow-Origin': app_url or get_default_app_url(),
76
+ 'Access-Control-Allow-Credentials': 'true',
77
+ 'Content-Type': content_type,
78
+ 'Set-Cookie': set_cookie,
79
+ })
@@ -0,0 +1,37 @@
1
+ Metadata-Version: 2.4
2
+ Name: surety-api
3
+ Version: 0.0.1
4
+ Summary: Contract-aware API interaction layer for the Surety ecosystem.
5
+ Author-email: Elena Kulgavaya <elena.kulgavaya@gmail.com>
6
+ License: MIT
7
+ Keywords: api,contract-testing,automation,integration-testing,surety
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Operating System :: OS Independent
11
+ Requires-Python: >=3.8
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+ Requires-Dist: deepdiff==8.0.1
15
+ Requires-Dist: surety<1.0,>=0.0.4
16
+ Requires-Dist: surety-config>=0.0.3
17
+ Requires-Dist: surety-diff>=0.0.1
18
+ Requires-Dist: requests
19
+ Requires-Dist: pyyaml
20
+ Requires-Dist: waiting
21
+ Dynamic: license-file
22
+
23
+ # Surety API
24
+
25
+ Contract-aware API interaction layer for the Surety ecosystem.
26
+
27
+ `surety-api` enables structured API testing, mocking, and
28
+ interaction based on Surety contracts.
29
+
30
+ It bridges declarative contracts and real HTTP communication.
31
+
32
+ ---
33
+
34
+ ## Installation
35
+
36
+ ```bash
37
+ pip install surety-api
@@ -0,0 +1,12 @@
1
+ surety/api/__init__.py,sha256=L9CtiC2lk7fVnzAk5dpT7NsA9EqL_xccpNVIcitZZsY,128
2
+ surety/api/caller.py,sha256=o5ZEgrxIX_FYUgjmhH8O-z9m703isuIvbpxRJ9HTV9o,2701
3
+ surety/api/method.py,sha256=R5GReigCdpEnwepCQuNfvSPT3wP5F1nIMaiLRPrXm2c,5630
4
+ surety/api/schema.py,sha256=GLw2JtrTburkXs_4HtBfgleexyo-oGhCv0PE4FyYQG8,2114
5
+ surety/api/mock/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ surety/api/mock/data.py,sha256=01hqX3MKvDkx-LpYG0Gcp4dUblYzlC1EUemgDspT8OU,2397
7
+ surety/api/mock/service.py,sha256=06OzsPBSZMB-Up7czOWsz5jBiNAu1RMXFHJZWvzOxm4,5700
8
+ surety_api-0.0.1.dist-info/licenses/LICENSE,sha256=3TUbLvR5i9BMk3nE-KUtbyBFoL73ES3YioEMOvxselQ,1072
9
+ surety_api-0.0.1.dist-info/METADATA,sha256=xCTfy2b-jL2z0pAUsFkzh_EnuCP_wDz9-CaPIJ-h_oc,1016
10
+ surety_api-0.0.1.dist-info/WHEEL,sha256=YCfwYGOYMi5Jhw2fU4yNgwErybb2IX5PEwBKV4ZbdBo,91
11
+ surety_api-0.0.1.dist-info/top_level.txt,sha256=a0-FMXVmX6qb_bkdgaQl82FUEQ-xnlMOp3ibsLoxdgs,7
12
+ surety_api-0.0.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Elena Kulgavaya
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ surety