brynq-sdk-elastic 1.0.0__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.
- brynq_sdk_elastic-1.0.0/PKG-INFO +10 -0
- brynq_sdk_elastic-1.0.0/brynq_sdk/elastic/__init__.py +1 -0
- brynq_sdk_elastic-1.0.0/brynq_sdk/elastic/elastic.py +328 -0
- brynq_sdk_elastic-1.0.0/brynq_sdk_elastic.egg-info/PKG-INFO +10 -0
- brynq_sdk_elastic-1.0.0/brynq_sdk_elastic.egg-info/SOURCES.txt +9 -0
- brynq_sdk_elastic-1.0.0/brynq_sdk_elastic.egg-info/dependency_links.txt +1 -0
- brynq_sdk_elastic-1.0.0/brynq_sdk_elastic.egg-info/not-zip-safe +1 -0
- brynq_sdk_elastic-1.0.0/brynq_sdk_elastic.egg-info/requires.txt +2 -0
- brynq_sdk_elastic-1.0.0/brynq_sdk_elastic.egg-info/top_level.txt +1 -0
- brynq_sdk_elastic-1.0.0/setup.cfg +4 -0
- brynq_sdk_elastic-1.0.0/setup.py +18 -0
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Metadata-Version: 1.0
|
|
2
|
+
Name: brynq_sdk_elastic
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: elastic wrapper from BrynQ
|
|
5
|
+
Home-page: UNKNOWN
|
|
6
|
+
Author: BrynQ
|
|
7
|
+
Author-email: support@brynq.com
|
|
8
|
+
License: BrynQ License
|
|
9
|
+
Description: elastic wrapper from BrynQ
|
|
10
|
+
Platform: UNKNOWN
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from brynq_sdk.elastic.elastic import Elastic
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
import requests
|
|
2
|
+
import json
|
|
3
|
+
import datetime
|
|
4
|
+
import string
|
|
5
|
+
import random
|
|
6
|
+
import pandas as pd
|
|
7
|
+
import os
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Elastic:
|
|
11
|
+
def __init__(self, api_key: str = None, customer_name: str = None, space_name: str = None):
|
|
12
|
+
"""
|
|
13
|
+
A package to create indexes, users, roles, getting data, etc.
|
|
14
|
+
:param api_key: The api key to connect to elasticsearch if not provided in the .env file
|
|
15
|
+
"""
|
|
16
|
+
try:
|
|
17
|
+
self.verify = False
|
|
18
|
+
if os.getenv('BRYNQ_ENVIRONMENT') == 'prod':
|
|
19
|
+
self.elasticsearch_host = f'https://{os.getenv("ELASTIC_HOST_LIVE", "localhost")}:{os.getenv("ELASTIC_PORT_LIVE", "9200")}'
|
|
20
|
+
self.kibana_host = f'http://{os.getenv("ELASTIC_HOST_LIVE", "localhost")}:{os.getenv("KIBANA_PORT_LIVE", "5601")}'
|
|
21
|
+
self.elastic_token = os.getenv('ELASTIC_API_KEY_LIVE', api_key)
|
|
22
|
+
else:
|
|
23
|
+
self.elasticsearch_host = f'https://{os.getenv("ELASTIC_HOST_STAGING", "localhost")}:{os.getenv("ELASTIC_PORT_STAGING", "9200")}'
|
|
24
|
+
self.kibana_host = f'http://{os.getenv("ELASTIC_HOST_STAGING", "localhost")}:{os.getenv("KIBANA_PORT_STAGING", "5601")}'
|
|
25
|
+
self.elastic_token = os.getenv('ELASTIC_API_KEY_STAGING', api_key)
|
|
26
|
+
|
|
27
|
+
self.client_user = os.getenv('BRYNQ_CUSTOMER_NAME', 'default').lower().replace(' ', '_') if customer_name is None else customer_name.lower().replace(' ', '_')
|
|
28
|
+
self.space_name = os.getenv('ELASTIC_SPACE', 'default') if space_name is None else space_name
|
|
29
|
+
self.timestamp = int(datetime.datetime.now().timestamp())
|
|
30
|
+
self.elastic_headers = {
|
|
31
|
+
'Content-Type': 'application/json',
|
|
32
|
+
'Authorization': f'ApiKey {self.elastic_token}'
|
|
33
|
+
}
|
|
34
|
+
self.kibana_headers = {
|
|
35
|
+
'Content-Type': 'application/json',
|
|
36
|
+
'Authorization': f'ApiKey {self.elastic_token}',
|
|
37
|
+
'kbn-xsrf': 'true'
|
|
38
|
+
}
|
|
39
|
+
self.get_health()
|
|
40
|
+
self.create_space(space_name=self.space_name)
|
|
41
|
+
except Exception as e:
|
|
42
|
+
raise ConnectionError('Could not establish a connection: {}'.format(str(e)))
|
|
43
|
+
|
|
44
|
+
def get_health(self) -> str:
|
|
45
|
+
"""
|
|
46
|
+
Check if a there is a connection with elasticsearch
|
|
47
|
+
:return: if the connection is established or not
|
|
48
|
+
"""
|
|
49
|
+
# Get the health of the database connection
|
|
50
|
+
health = requests.get(url=f'{self.elasticsearch_host}/_cat/health?', headers=self.elastic_headers, verify=self.verify).status_code
|
|
51
|
+
if health != 200:
|
|
52
|
+
raise ConnectionError('Elasticsearch cluster health check failed with status code: {}'.format(health))
|
|
53
|
+
else:
|
|
54
|
+
return 'Healthy connection established with elasticsearch!'
|
|
55
|
+
|
|
56
|
+
def initialize_customer(self):
|
|
57
|
+
# Creates the index for the user if it does not exist yet
|
|
58
|
+
self.create_index(index_name=f'task_execution_log_{self.client_user}')
|
|
59
|
+
|
|
60
|
+
# creates the data view for the space and index if it does not exist yet
|
|
61
|
+
self.create_data_view(space_name=self.space_name, view_name=f'task_execution_log_{self.client_user}', name=f'Task execution log {self.client_user}', time_field='started_at')
|
|
62
|
+
|
|
63
|
+
def create_space(self, space_name: str) -> str:
|
|
64
|
+
"""
|
|
65
|
+
This function creates a space in elasticsearch for the current customer
|
|
66
|
+
:param space_name: The name of the space
|
|
67
|
+
:return: The status of the creation of the space
|
|
68
|
+
"""
|
|
69
|
+
url = f'{self.kibana_host}/api/spaces/space'
|
|
70
|
+
data = {
|
|
71
|
+
"id": space_name,
|
|
72
|
+
"name": space_name,
|
|
73
|
+
"description": f"This is the space for {space_name}",
|
|
74
|
+
"color": "#aabbcc",
|
|
75
|
+
"initials": space_name[0:2].upper(),
|
|
76
|
+
"disabledFeatures": [],
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
response = requests.head(url=url + fr'/{space_name}', headers=self.kibana_headers, verify=self.verify)
|
|
80
|
+
|
|
81
|
+
if response.status_code == 200:
|
|
82
|
+
return f'Index \'{space_name}\' already exists'
|
|
83
|
+
else:
|
|
84
|
+
response = requests.post(url=url, headers=self.kibana_headers, data=json.dumps(data), verify=self.verify)
|
|
85
|
+
if response.status_code == 200:
|
|
86
|
+
return f'space {space_name} created'
|
|
87
|
+
else:
|
|
88
|
+
raise ConnectionError(f'Could not create space {space_name} with status code: {response.status_code}. Response: {response.text}')
|
|
89
|
+
|
|
90
|
+
def create_data_view(self, space_name: str, view_name: str, name: str, time_field: str) -> str:
|
|
91
|
+
"""
|
|
92
|
+
This function creates a data view in elasticsearch for the current customer
|
|
93
|
+
:param space_name: The name of the space
|
|
94
|
+
:param view_name: The name of the data view
|
|
95
|
+
:param time_field: The name of the time field
|
|
96
|
+
:return: The status of the creation of the data view
|
|
97
|
+
"""
|
|
98
|
+
url = f'{self.kibana_host}/s/{space_name}/api/data_views/data_view'
|
|
99
|
+
data = {
|
|
100
|
+
"data_view": {
|
|
101
|
+
"title": f'{view_name}*',
|
|
102
|
+
"id": f'{view_name}',
|
|
103
|
+
"name": f'{name}',
|
|
104
|
+
"timeFieldName": time_field
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
response = requests.head(url=url + fr'/{view_name}', headers=self.kibana_headers, verify=self.verify)
|
|
109
|
+
|
|
110
|
+
if response.status_code == 200:
|
|
111
|
+
return f'Data view \'{view_name}\' already exists'
|
|
112
|
+
else:
|
|
113
|
+
response = requests.post(url=url, headers=self.kibana_headers, data=json.dumps(data), verify=self.verify)
|
|
114
|
+
if response.status_code == 200:
|
|
115
|
+
return f'data view {view_name} created'
|
|
116
|
+
else:
|
|
117
|
+
raise ConnectionError(f'Could not create data view {view_name} with status code: {response.status_code}. Response: {response.text}')
|
|
118
|
+
|
|
119
|
+
def get_all_docs_from_index(self, index: str) -> pd.DataFrame:
|
|
120
|
+
"""
|
|
121
|
+
Get all the documents from a certain index
|
|
122
|
+
:param index: the name of the index
|
|
123
|
+
:return: The response of the request to elasticsearch
|
|
124
|
+
"""
|
|
125
|
+
size = 10000
|
|
126
|
+
|
|
127
|
+
# Get all indices with the given index from the function parameter. For each day a new index.
|
|
128
|
+
indices = requests.get(url=self.elasticsearch_host + '/' + index + '*/_settings', headers=self.elastic_headers, verify=self.verify).json()
|
|
129
|
+
index_list = {}
|
|
130
|
+
|
|
131
|
+
for index in indices:
|
|
132
|
+
index_date = datetime.date(2023, 4, 3)
|
|
133
|
+
index_list[str(index_date)] = index
|
|
134
|
+
|
|
135
|
+
url = f'{self.elasticsearch_host}/{index}/_search'
|
|
136
|
+
|
|
137
|
+
# initial request
|
|
138
|
+
params = {"size": size, "scroll": "10m"}
|
|
139
|
+
response = requests.get(url=url, headers=self.elastic_headers, params=params, verify=self.verify).json()
|
|
140
|
+
|
|
141
|
+
# next requests until finished
|
|
142
|
+
scroll_id = response['_scroll_id']
|
|
143
|
+
total = response['hits']['total']['value']
|
|
144
|
+
response = pd.json_normalize(response['hits']['hits'])
|
|
145
|
+
response.drop(['_id', '_index', '_score'], axis=1, inplace=True)
|
|
146
|
+
|
|
147
|
+
# start all the request to elastic based on the scroll_id and add to the initial response
|
|
148
|
+
loop_boolean = True
|
|
149
|
+
body = json.dumps({"scroll": "10m", "scroll_id": scroll_id})
|
|
150
|
+
url = f'{self.elasticsearch_host}/_search/scroll'
|
|
151
|
+
|
|
152
|
+
while loop_boolean and total > size:
|
|
153
|
+
next_response = pd.json_normalize(requests.post(url=url, data=body, headers=self.elastic_headers, verify=self.verify).json()["hits"]["hits"])
|
|
154
|
+
next_response.drop(['_id', '_index', '_score'], axis=1, inplace=True)
|
|
155
|
+
response = pd.concat([response, next_response], ignore_index=True)
|
|
156
|
+
print(f'Received {len(next_response)} documents from index {index}')
|
|
157
|
+
if len(next_response) != size:
|
|
158
|
+
loop_boolean = False
|
|
159
|
+
return response
|
|
160
|
+
|
|
161
|
+
def delete_index(self, index_name) -> str:
|
|
162
|
+
"""
|
|
163
|
+
Deletes an existing index if it exists. Documentation: https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-delete-index.html
|
|
164
|
+
:param index_name: The index you want to delete
|
|
165
|
+
:return: The response of the request to elasticsearch
|
|
166
|
+
"""
|
|
167
|
+
# Check if index exists
|
|
168
|
+
url = f'{self.elasticsearch_host}/{index_name}'
|
|
169
|
+
response = requests.head(url=url, headers=self.elastic_headers, verify=self.verify)
|
|
170
|
+
|
|
171
|
+
# Delete index if it exists
|
|
172
|
+
if response.status_code == 404:
|
|
173
|
+
return f'Index \'{index_name}\' does not exist'
|
|
174
|
+
else:
|
|
175
|
+
response = requests.delete(url=url, headers=self.elastic_headers, verify=self.verify)
|
|
176
|
+
if response.status_code == 200:
|
|
177
|
+
return f'Index \'{index_name}\' deleted'
|
|
178
|
+
else:
|
|
179
|
+
raise ConnectionError(f'Could not delete index {index_name} with status code: {response.status_code}. Response: {response.text}')
|
|
180
|
+
|
|
181
|
+
def create_index(self, index_name: str) -> str:
|
|
182
|
+
"""
|
|
183
|
+
Creates a new index in the elasticsearch instance. Documentation: https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-create-index.html
|
|
184
|
+
:param index_name: The name of the desired index
|
|
185
|
+
:return: The response of the request to elasticsearch
|
|
186
|
+
"""
|
|
187
|
+
url = f'{self.elasticsearch_host}/{index_name}'
|
|
188
|
+
response = requests.head(url=url, headers=self.elastic_headers, verify=self.verify)
|
|
189
|
+
|
|
190
|
+
if response.status_code == 200:
|
|
191
|
+
return f'Index \'{index_name}\' already exists'
|
|
192
|
+
else:
|
|
193
|
+
response = requests.put(url=url, headers=self.elastic_headers, verify=self.verify)
|
|
194
|
+
if response.status_code == 200:
|
|
195
|
+
return f'Index {index_name} created'
|
|
196
|
+
else:
|
|
197
|
+
raise ConnectionError(f'Could not create index {index_name} with status code: {response.status_code}. Response: {response.text}')
|
|
198
|
+
|
|
199
|
+
def create_or_update_role(self, role_name: str, index: str) -> str:
|
|
200
|
+
"""
|
|
201
|
+
Creates or updates a role. All the indexes which start with the same constraint as the role_name, are added to the role
|
|
202
|
+
:param role_name: The name of the desired role. Most often the username which also is used for the mysql database user (sc_customer)
|
|
203
|
+
:param index: one or more index names in a list.
|
|
204
|
+
:return: The response of the request to elasticsearch
|
|
205
|
+
"""
|
|
206
|
+
url = f'{self.kibana_host}/api/security/role/{role_name}'
|
|
207
|
+
# Set the body
|
|
208
|
+
body = {
|
|
209
|
+
'elasticsearch': {
|
|
210
|
+
'cluster': ['transport_client'],
|
|
211
|
+
'indices': [
|
|
212
|
+
{
|
|
213
|
+
'names': [index],
|
|
214
|
+
'privileges': ['read', 'write', 'read_cross_cluster', 'view_index_metadata', 'index']
|
|
215
|
+
}
|
|
216
|
+
]
|
|
217
|
+
},
|
|
218
|
+
'kibana': [{
|
|
219
|
+
'feature': {
|
|
220
|
+
'dashboard': ['read'],
|
|
221
|
+
'discover': ['read']
|
|
222
|
+
},
|
|
223
|
+
'spaces': [role_name],
|
|
224
|
+
}],
|
|
225
|
+
'metadata': {
|
|
226
|
+
'version': 1
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
body = json.dumps(body)
|
|
230
|
+
|
|
231
|
+
response = requests.head(url=url, headers=self.kibana_headers, verify=self.verify)
|
|
232
|
+
|
|
233
|
+
if response.status_code == 200:
|
|
234
|
+
return f'Role \'{role_name}\' already exists'
|
|
235
|
+
else:
|
|
236
|
+
response = requests.put(url=url, data=body, headers=self.kibana_headers, verify=self.verify)
|
|
237
|
+
if response.status_code == 204:
|
|
238
|
+
return f'Role {role_name} created'
|
|
239
|
+
else:
|
|
240
|
+
raise ConnectionError(f'Could not create role {role_name} with status code: {response.status_code}. Response: {response.text}')
|
|
241
|
+
|
|
242
|
+
def get_indices(self) -> dict:
|
|
243
|
+
"""
|
|
244
|
+
Get all the indices in the elasticsearch instance
|
|
245
|
+
:return: A dictionary with all the indices
|
|
246
|
+
"""
|
|
247
|
+
indices = requests.get(url=f'{self.elasticsearch_host}/_cat/indices?format=json', headers=self.elastic_headers, verify=self.verify).json()
|
|
248
|
+
return indices
|
|
249
|
+
|
|
250
|
+
def create_user(self, user_name: str, password: str, user_description: str, roles: list) -> str:
|
|
251
|
+
"""
|
|
252
|
+
Creates a user if it doesn't exist.
|
|
253
|
+
:param user_name: The username. Most often the username which also is used for the mysql database user (sc_customer)
|
|
254
|
+
:param password: Choose a safe password. At least 8 characters long
|
|
255
|
+
:param user_description: A readable description. Often the customer name
|
|
256
|
+
:param roles: Give the roles to which the user belongs in a list. Most often the same role_name as the user_name
|
|
257
|
+
:return: The response of the request to elasticsearch
|
|
258
|
+
"""
|
|
259
|
+
url = f'{self.elasticsearch_host}/_security/user/{user_name}'
|
|
260
|
+
body = {
|
|
261
|
+
'password': f'{password}',
|
|
262
|
+
'roles': roles,
|
|
263
|
+
'full_name': f'{user_description}'
|
|
264
|
+
}
|
|
265
|
+
body = json.dumps(body)
|
|
266
|
+
|
|
267
|
+
response = requests.head(url=url, headers=self.elastic_headers, verify=self.verify)
|
|
268
|
+
|
|
269
|
+
if response.status_code == 200:
|
|
270
|
+
return f'user {user_name} already exists'
|
|
271
|
+
else:
|
|
272
|
+
response = requests.put(url=url, data=body, headers=self.elastic_headers, verify=self.verify)
|
|
273
|
+
if response.status_code == 200:
|
|
274
|
+
return f'user {user_name}, with password: {password} has been created'
|
|
275
|
+
else:
|
|
276
|
+
raise ConnectionError(f'Could not create user {user_name} with status code: {response.status_code}. Response: {response.text}')
|
|
277
|
+
|
|
278
|
+
def post_document(self, index_name: str, document: dict) -> requests.Response:
|
|
279
|
+
"""
|
|
280
|
+
Posts a document to the specified index. Documentation: https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-index_.html
|
|
281
|
+
:param index_name: The name of the index to which the document should be posted
|
|
282
|
+
:param document: The document to be posted
|
|
283
|
+
:return: The response of the request to elasticsearch
|
|
284
|
+
"""
|
|
285
|
+
url = f'{self.elasticsearch_host}/{index_name}/_doc/'
|
|
286
|
+
body = json.dumps(document)
|
|
287
|
+
response = requests.post(url=url, data=body, headers=self.elastic_headers, verify=self.verify)
|
|
288
|
+
return response
|
|
289
|
+
|
|
290
|
+
def get_document(self, index_name: str, document_id: str) -> requests.Response:
|
|
291
|
+
"""
|
|
292
|
+
Gets a document from the specified index. Documentation: https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-get.html
|
|
293
|
+
:param index_name: The name of the index from which the document should be retrieved
|
|
294
|
+
:param document_id: The id of the document to be retrieved
|
|
295
|
+
:return: The response of the request to elasticsearch
|
|
296
|
+
"""
|
|
297
|
+
url = f'{self.elasticsearch_host}/{index_name}/_doc/{document_id}'
|
|
298
|
+
response = requests.get(url=url, headers=self.elastic_headers, verify=self.verify)
|
|
299
|
+
return response
|
|
300
|
+
|
|
301
|
+
def delete_document(self, index_name: str, document_id: str) -> requests.Response:
|
|
302
|
+
"""
|
|
303
|
+
Deletes a document from the specified index. Documentation: https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-delete.html
|
|
304
|
+
:param index_name: The name of the index from which the document should be deleted
|
|
305
|
+
:param document_id: The id of the document to be deleted
|
|
306
|
+
:return: The response of the request to elasticsearch
|
|
307
|
+
"""
|
|
308
|
+
url = f'{self.elasticsearch_host}/{index_name}/_doc/{document_id}'
|
|
309
|
+
response = requests.delete(url=url, headers=self.elastic_headers, verify=self.verify)
|
|
310
|
+
return response
|
|
311
|
+
|
|
312
|
+
def task_execution_log(self, information: dict) -> requests.Response:
|
|
313
|
+
"""
|
|
314
|
+
Write a document to the elasticsearch database
|
|
315
|
+
:param information: the information to be inserted into the database.
|
|
316
|
+
:return: the response of the post request
|
|
317
|
+
"""
|
|
318
|
+
# Add new document
|
|
319
|
+
url = f'{self.elasticsearch_host}/task_execution_log_{self.client_user}/_doc/'
|
|
320
|
+
body = json.dumps(information)
|
|
321
|
+
response = requests.post(url=url, data=body, headers=self.elastic_headers, verify=self.verify)
|
|
322
|
+
return response
|
|
323
|
+
|
|
324
|
+
@staticmethod
|
|
325
|
+
def generate_password(length=20):
|
|
326
|
+
characters = string.ascii_letters + string.digits + string.punctuation
|
|
327
|
+
password = ''.join(random.choice(characters) for _ in range(length))
|
|
328
|
+
return password
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Metadata-Version: 1.0
|
|
2
|
+
Name: brynq-sdk-elastic
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: elastic wrapper from BrynQ
|
|
5
|
+
Home-page: UNKNOWN
|
|
6
|
+
Author: BrynQ
|
|
7
|
+
Author-email: support@brynq.com
|
|
8
|
+
License: BrynQ License
|
|
9
|
+
Description: elastic wrapper from BrynQ
|
|
10
|
+
Platform: UNKNOWN
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
setup.py
|
|
2
|
+
brynq_sdk/elastic/__init__.py
|
|
3
|
+
brynq_sdk/elastic/elastic.py
|
|
4
|
+
brynq_sdk_elastic.egg-info/PKG-INFO
|
|
5
|
+
brynq_sdk_elastic.egg-info/SOURCES.txt
|
|
6
|
+
brynq_sdk_elastic.egg-info/dependency_links.txt
|
|
7
|
+
brynq_sdk_elastic.egg-info/not-zip-safe
|
|
8
|
+
brynq_sdk_elastic.egg-info/requires.txt
|
|
9
|
+
brynq_sdk_elastic.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
brynq_sdk
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from setuptools import setup
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
setup(
|
|
5
|
+
name='brynq_sdk_elastic',
|
|
6
|
+
version='1.0.0',
|
|
7
|
+
description='elastic wrapper from BrynQ',
|
|
8
|
+
long_description='elastic wrapper from BrynQ',
|
|
9
|
+
author='BrynQ',
|
|
10
|
+
author_email='support@brynq.com',
|
|
11
|
+
packages=["brynq_sdk.elastic"],
|
|
12
|
+
license='BrynQ License',
|
|
13
|
+
install_requires=[
|
|
14
|
+
'requests>=2,<=3',
|
|
15
|
+
'paramiko>=2,<=3'
|
|
16
|
+
],
|
|
17
|
+
zip_safe=False,
|
|
18
|
+
)
|