pyxecm 1.6__py3-none-any.whl → 2.0.0__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.

Potentially problematic release.


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

Files changed (56) hide show
  1. pyxecm/__init__.py +6 -4
  2. pyxecm/avts.py +673 -246
  3. pyxecm/coreshare.py +686 -467
  4. pyxecm/customizer/__init__.py +16 -4
  5. pyxecm/customizer/__main__.py +58 -0
  6. pyxecm/customizer/api/__init__.py +5 -0
  7. pyxecm/customizer/api/__main__.py +6 -0
  8. pyxecm/customizer/api/app.py +914 -0
  9. pyxecm/customizer/api/auth.py +154 -0
  10. pyxecm/customizer/api/metrics.py +92 -0
  11. pyxecm/customizer/api/models.py +13 -0
  12. pyxecm/customizer/api/payload_list.py +865 -0
  13. pyxecm/customizer/api/settings.py +103 -0
  14. pyxecm/customizer/browser_automation.py +332 -139
  15. pyxecm/customizer/customizer.py +1007 -1130
  16. pyxecm/customizer/exceptions.py +35 -0
  17. pyxecm/customizer/guidewire.py +322 -0
  18. pyxecm/customizer/k8s.py +713 -378
  19. pyxecm/customizer/log.py +107 -0
  20. pyxecm/customizer/m365.py +2867 -909
  21. pyxecm/customizer/nhc.py +1169 -0
  22. pyxecm/customizer/openapi.py +258 -0
  23. pyxecm/customizer/payload.py +16817 -7467
  24. pyxecm/customizer/pht.py +699 -285
  25. pyxecm/customizer/salesforce.py +516 -342
  26. pyxecm/customizer/sap.py +58 -41
  27. pyxecm/customizer/servicenow.py +593 -371
  28. pyxecm/customizer/settings.py +442 -0
  29. pyxecm/customizer/successfactors.py +408 -346
  30. pyxecm/customizer/translate.py +83 -48
  31. pyxecm/helper/__init__.py +5 -2
  32. pyxecm/helper/assoc.py +83 -43
  33. pyxecm/helper/data.py +2406 -870
  34. pyxecm/helper/logadapter.py +27 -0
  35. pyxecm/helper/web.py +229 -101
  36. pyxecm/helper/xml.py +527 -171
  37. pyxecm/maintenance_page/__init__.py +5 -0
  38. pyxecm/maintenance_page/__main__.py +6 -0
  39. pyxecm/maintenance_page/app.py +51 -0
  40. pyxecm/maintenance_page/settings.py +28 -0
  41. pyxecm/maintenance_page/static/favicon.avif +0 -0
  42. pyxecm/maintenance_page/templates/maintenance.html +165 -0
  43. pyxecm/otac.py +234 -140
  44. pyxecm/otawp.py +1436 -557
  45. pyxecm/otcs.py +7716 -3161
  46. pyxecm/otds.py +2150 -919
  47. pyxecm/otiv.py +36 -21
  48. pyxecm/otmm.py +1272 -325
  49. pyxecm/otpd.py +231 -127
  50. pyxecm-2.0.0.dist-info/METADATA +145 -0
  51. pyxecm-2.0.0.dist-info/RECORD +54 -0
  52. {pyxecm-1.6.dist-info → pyxecm-2.0.0.dist-info}/WHEEL +1 -1
  53. pyxecm-1.6.dist-info/METADATA +0 -53
  54. pyxecm-1.6.dist-info/RECORD +0 -32
  55. {pyxecm-1.6.dist-info → pyxecm-2.0.0.dist-info/licenses}/LICENSE +0 -0
  56. {pyxecm-1.6.dist-info → pyxecm-2.0.0.dist-info}/top_level.txt +0 -0
@@ -1,84 +1,54 @@
1
- """
2
- ServiceNow Module to interact with the ServiceNow API
3
- See:
4
-
5
- Class: ServiceNow
6
- Methods:
7
-
8
- __init__ : class initializer
9
- thread_wrapper: Function to wrap around threads to catch exceptions during exection
10
- config : Returns the configuration dictionary
11
- get_data: Get the Data object that holds all processed Knowledge base Articles (Pandas Data Frame)
12
- request_header: Returns the request header for ServiceNow API calls
13
- parse_request_response: Parse the REST API responses and convert
14
- them to Python dict in a safe way
15
- exist_result_item: Check if an dict item is in the response
16
- of the ServiceNow API call
17
- get_result_value: Check if a defined value (based on a key) is in the ServiceNow API response
18
-
19
- authenticate : Authenticates at ServiceNow API
20
- get_oauth_token: Returns the OAuth access token.
21
-
22
- get_object: Get an ServiceNow object based on table name and ID
23
- get_summary: Get summary object for an article.
24
- get_table: Retrieve a specified ServiceNow table data (row or values)
25
- get_table_count: Get number of table rows (e.g. Knowledge Base Articles) matching the query
26
- (or if query = "" it should be the total number)
27
- get_knowledge_bases: Get the configured knowledge bases in ServiceNow
28
- get_knowledge_base_articles: Get selected / filtered Knowledge Base articles
29
- make_file_names_unique: Make file names unique if required. The mutable
30
- list is changed "in-place".
31
- download_attachments: Download the attachments of a Knowledge Base Article (KBA) in ServiceNow.
32
- load_articles: Main method to load ServiceNow articles in a Data Frame and
33
- download the attchments.
34
- load_articles_worker: Worker Method for multi-threading.
35
- load_article: Process a single KBA: download attachments (if any)
36
- and add the KBA to the Data Frame.
1
+ """ServiceNow Module to interact with the ServiceNow API.
2
+
3
+ See: https://developer.servicenow.com
37
4
  """
38
5
 
39
6
  __author__ = "Dr. Marc Diefenbruch"
40
- __copyright__ = "Copyright 2024, OpenText"
7
+ __copyright__ = "Copyright (C) 2024-2025, OpenText"
41
8
  __credits__ = ["Kai-Philip Gatzweiler"]
42
9
  __maintainer__ = "Dr. Marc Diefenbruch"
43
10
  __email__ = "mdiefenb@opentext.com"
44
11
 
45
- import os
46
12
  import json
47
13
  import logging
48
- import urllib.parse
14
+ import os
15
+ import tempfile
49
16
  import threading
50
- import traceback
51
- from functools import cache
52
17
  import time
18
+ import urllib.parse
19
+ from collections.abc import Callable
20
+ from functools import cache
21
+ from typing import Any
53
22
 
54
23
  import requests
55
24
  from requests.auth import HTTPBasicAuth
56
25
  from requests.exceptions import HTTPError, RequestException
57
- from pyxecm.helper.data import Data
58
26
 
59
- logger = logging.getLogger("pyxecm.customizer.servicenow")
27
+ from pyxecm.helper import Data
28
+
29
+ default_logger = logging.getLogger("pyxecm.customizer.servicenow")
60
30
 
61
31
  REQUEST_HEADERS = {"Accept": "application/json", "Content-Type": "application/json"}
62
32
 
63
33
  REQUEST_TIMEOUT = 60
64
34
 
65
- KNOWLEDGE_BASE_PATH = "/tmp/attachments"
35
+ KNOWLEDGE_BASE_PATH = os.path.join(tempfile.gettempdir(), "attachments")
66
36
 
67
37
  # ServiceNow database tables. Table names starting with "u_" are custom OpenText tables:
68
38
  SN_TABLE_CATEGORIES = "kb_category"
69
39
  SN_TABLE_KNOWLEDGE_BASES = "kb_knowledge_base"
70
40
  SN_TABLE_KNOWLEDGE_BASE_ARTICLES = "u_kb_template_technical_article_public"
71
- SN_TABLE_KNOWLEDGE_BASE_ARTICLES_PRODUCT = (
72
- "u_kb_template_product_documentation_standard"
73
- )
41
+ SN_TABLE_KNOWLEDGE_BASE_ARTICLES_PRODUCT = "u_kb_template_product_documentation_standard"
74
42
  SN_TABLE_RELATED_PRODUCTS = "cmdb_model"
75
43
  SN_TABLE_PRODUCT_LINES = "u_ot_product_model"
76
44
  SN_TABLE_PRODUCT_VERSIONS = "u_ot_product_model_version"
77
45
  SN_TABLE_ATTACHMENTS = "sys_attachment"
78
46
 
79
47
 
80
- class ServiceNow(object):
81
- """Used to retrieve and automate stettings in ServiceNow."""
48
+ class ServiceNow:
49
+ """Class used to retrieve and automate stettings in ServiceNow."""
50
+
51
+ logger: logging.Logger = default_logger
82
52
 
83
53
  _config: dict
84
54
  _access_token = None
@@ -86,6 +56,7 @@ class ServiceNow(object):
86
56
  _data: Data = None
87
57
  _thread_number = 3
88
58
  _download_dir = ""
59
+ _product_exclusions = None
89
60
 
90
61
  def __init__(
91
62
  self,
@@ -98,21 +69,42 @@ class ServiceNow(object):
98
69
  token_url: str = "",
99
70
  thread_number: int = 3,
100
71
  download_dir: str = KNOWLEDGE_BASE_PATH,
101
- ):
102
- """Initialize the Service Now object
72
+ product_exclusions: list | None = None,
73
+ logger: logging.Logger = default_logger,
74
+ ) -> None:
75
+ """Initialize the Service Now object.
103
76
 
104
77
  Args:
105
- base_url (str): base URL of the ServiceNow tenant
106
- auth_type (str): authorization type, either "oauth" or "basic"
107
- client_id (str): ServiceNow Client ID
108
- client_secret (str): ServiceNow Client Secret
109
- username (str): user name in Saleforce
110
- password (str): password of the user
111
- token_url (str, optional): Token URL for ServiceNow login via OAuth.
112
- thread_number (int, optional): number of threads for parallel processing. Default is 3.
113
- download_path (str): path to stored downloaded files from ServiceNow
78
+ base_url (str):
79
+ The base URL of the ServiceNow tenant.
80
+ auth_type (str):
81
+ Athe authorization type, either "oauth" or "basic".
82
+ client_id (str):
83
+ ServiceNow Client ID.
84
+ client_secret (str):
85
+ The ServiceNow client secret.
86
+ username (str):
87
+ The user name in ServiceNow.
88
+ password (str):
89
+ The password of the ServiceNow user.
90
+ token_url (str, optional):
91
+ Token URL for ServiceNow login via OAuth.
92
+ thread_number (int, optional):
93
+ The number of threads for parallel processing. Default is 3.
94
+ download_dir (str, optional):
95
+ The path to stored downloaded files from ServiceNow.
96
+ product_exclusions (list | None, optional):
97
+ List of products that should NOT be loaded from ServiceNow.
98
+ logger:
99
+ The logging object used for all log messages. Default is default_logger.
100
+
114
101
  """
115
102
 
103
+ if logger != default_logger:
104
+ self.logger = logger.getChild("servicenow")
105
+ for logfilter in logger.filters:
106
+ self.logger.addFilter(logfilter)
107
+
116
108
  servicenow_config = {}
117
109
 
118
110
  # Store the credentials and parameters in a config dictionary:
@@ -129,61 +121,68 @@ class ServiceNow(object):
129
121
 
130
122
  servicenow_config["restUrl"] = servicenow_config["baseUrl"] + "/api/now/"
131
123
  servicenow_config["tableUrl"] = servicenow_config["restUrl"] + "table"
132
- servicenow_config["knowledgeUrl"] = (
133
- servicenow_config["restUrl"] + "table/kb_knowledge"
134
- )
135
- servicenow_config["knowledgeBaseUrl"] = (
136
- servicenow_config["restUrl"] + "table/" + SN_TABLE_KNOWLEDGE_BASES
137
- )
138
- servicenow_config["attachmentsUrl"] = (
139
- servicenow_config["restUrl"] + "table/" + SN_TABLE_ATTACHMENTS
140
- )
141
- servicenow_config["attachmentDownloadUrl"] = (
142
- servicenow_config["restUrl"] + "attachment"
143
- )
124
+ servicenow_config["knowledgeUrl"] = servicenow_config["restUrl"] + "table/kb_knowledge"
125
+ servicenow_config["knowledgeBaseUrl"] = servicenow_config["restUrl"] + "table/" + SN_TABLE_KNOWLEDGE_BASES
126
+ servicenow_config["attachmentsUrl"] = servicenow_config["restUrl"] + "table/" + SN_TABLE_ATTACHMENTS
127
+ servicenow_config["attachmentDownloadUrl"] = servicenow_config["restUrl"] + "attachment"
144
128
  servicenow_config["statsUrl"] = servicenow_config["restUrl"] + "stats"
145
129
 
146
130
  self._config = servicenow_config
147
131
 
148
132
  self._session = requests.Session()
149
133
 
150
- self._data = Data()
134
+ self._data = Data(logger=self.logger)
151
135
 
152
136
  self._thread_number = thread_number
153
-
154
137
  self._download_dir = download_dir
138
+ self._product_exclusions = product_exclusions
155
139
 
156
140
  # end method definition
157
141
 
158
- def thread_wrapper(self, target, *args, **kwargs):
159
- """Function to wrap around threads to catch exceptions during exection"""
142
+ def thread_wrapper(self, target: Callable, *args: tuple, **kwargs: dict[str, Any]) -> None:
143
+ """Wrap around threads to catch exceptions during exection.
144
+
145
+ Args:
146
+ target (Callable):
147
+ The method (callable) the Thread should run.
148
+ args (tuple):
149
+ The arguments for the method.
150
+ kwargs (dict):
151
+ Keyword arguments for the method.
152
+
153
+ """
160
154
 
161
155
  try:
162
156
  target(*args, **kwargs)
163
- except Exception as e:
157
+ except Exception:
164
158
  thread_name = threading.current_thread().name
165
- logger.error(
166
- "Thread '%s': failed with exception -> %s", thread_name, str(e)
159
+ self.logger.error(
160
+ "Thread '%s': failed!",
161
+ thread_name,
167
162
  )
168
- logger.error(traceback.format_exc())
169
163
 
170
164
  # end method definition
171
165
 
172
166
  def config(self) -> dict:
173
- """Returns the configuration dictionary
167
+ """Return the configuration dictionary.
174
168
 
175
169
  Returns:
176
- dict: Configuration dictionary
170
+ dict:
171
+ The configuration dictionary.
172
+
177
173
  """
174
+
178
175
  return self._config
179
176
 
180
177
  # end method definition
181
178
 
182
179
  def get_data(self) -> Data:
183
- """Get the Data object that holds all processed Knowledge base Articles
180
+ """Get the Data object that holds all processed Knowledge base Articles.
184
181
 
185
182
  Returns:
186
- Data: Datastructure with all processed articles.
183
+ Data:
184
+ Data object (with embedded data frame) holding all processed articles.
185
+
187
186
  """
188
187
 
189
188
  return self._data
@@ -191,17 +190,22 @@ class ServiceNow(object):
191
190
  # end method definition
192
191
 
193
192
  def request_header(self, content_type: str = "") -> dict:
194
- """Returns the request header used for Application calls.
195
- Consists of Bearer access token and Content Type
193
+ """Return the request header used for Application calls.
194
+
195
+ Consists of Bearer access token and Content Type.
196
196
 
197
197
  Args:
198
- content_type (str, optional): custom content type for the request.
199
- Typical values:
200
- * application/json - Used for sending JSON-encoded data
201
- * application/x-www-form-urlencoded - The default for HTML forms. Data is sent as key-value pairs in the body of the request, similar to query parameters
202
- * multipart/form-data - Used for file uploads or when a form includes non-ASCII characters
198
+ content_type (str, optional):
199
+ Custom content type for the request.
200
+ Typical values:
201
+ * application/json - Used for sending JSON-encoded data
202
+ * application/x-www-form-urlencoded - The default for HTML forms.
203
+ Data is sent as key-value pairs in the body of the request, similar to query parameters.
204
+ * multipart/form-data - Used for file uploads or when a form includes non-ASCII characters
203
205
  Return:
204
- dict: request header values
206
+ dict:
207
+ The request header values.
208
+
205
209
  """
206
210
 
207
211
  request_header = {}
@@ -224,43 +228,48 @@ class ServiceNow(object):
224
228
  additional_error_message: str = "",
225
229
  show_error: bool = True,
226
230
  ) -> dict | None:
227
- """Converts the request response (JSon) to a Python dict in a safe way
228
- that also handles exceptions. It first tries to load the response.text
229
- via json.loads() that produces a dict output. Only if response.text is
230
- not set or is empty it just converts the response_object to a dict using
231
- the vars() built-in method.
231
+ """Convert the request response (JSon) to a Python dict in a safe way.
232
+
233
+ It handles exceptions and first tries to load the response.text
234
+ via json.loads() that produces a dict output. Only if response.text is
235
+ not set or is empty it just converts the response_object to a dict using
236
+ the vars() built-in method.
232
237
 
233
238
  Args:
234
- response_object (object): this is reponse object delivered by the request call
235
- additional_error_message (str, optional): use a more specific error message
236
- in case of an error
237
- show_error (bool): True: write an error to the log file
238
- False: write a warning to the log file
239
+ response_object (object):
240
+ This is reponse object delivered by the request call.
241
+ additional_error_message (str, optional):
242
+ If provided, use a more specific error message
243
+ in case of an error.
244
+ show_error (bool, optional):
245
+ True: write an error to the log file.
246
+ False: write a warning to the log file.
247
+
239
248
  Returns:
240
- dict: response information or None in case of an error
249
+ dict | None:
250
+ Response information or None in case of an error.
251
+
241
252
  """
242
253
 
243
254
  if not response_object:
244
255
  return None
245
256
 
246
257
  try:
247
- if response_object.text:
248
- dict_object = json.loads(response_object.text)
249
- else:
250
- dict_object = vars(response_object)
258
+ dict_object = json.loads(response_object.text) if response_object.text else vars(response_object)
251
259
  except json.JSONDecodeError as exception:
252
260
  if additional_error_message:
253
261
  message = "Cannot decode response as JSON. {}; error -> {}".format(
254
- additional_error_message, exception
262
+ additional_error_message,
263
+ exception,
255
264
  )
256
265
  else:
257
266
  message = "Cannot decode response as JSON; error -> {}".format(
258
- exception
267
+ exception,
259
268
  )
260
269
  if show_error:
261
- logger.error(message)
270
+ self.logger.error(message)
262
271
  else:
263
- logger.warning(message)
272
+ self.logger.warning(message)
264
273
  return None
265
274
  else:
266
275
  return dict_object
@@ -271,11 +280,17 @@ class ServiceNow(object):
271
280
  """Check existence of key / value pair in the response properties of an ServiceNow API call.
272
281
 
273
282
  Args:
274
- response (dict): REST response from an Salesforce API call
275
- key (str): property name (key)
276
- value (str): value to find in the item with the matching key
283
+ response (dict):
284
+ REST response from an ServiceNow API call.
285
+ key (str):
286
+ The property name (key) to check the value of.
287
+ value (str):
288
+ Value to find in the item with the matching key.
289
+
277
290
  Returns:
278
- bool: True if the value was found, False otherwise
291
+ bool:
292
+ True if the value was found, False otherwise.
293
+
279
294
  """
280
295
 
281
296
  if not response:
@@ -290,7 +305,7 @@ class ServiceNow(object):
290
305
  if value == record[key]:
291
306
  return True
292
307
  else:
293
- if not key in response:
308
+ if key not in response:
294
309
  return False
295
310
  if value == response[key]:
296
311
  return True
@@ -308,16 +323,22 @@ class ServiceNow(object):
308
323
  """Get value of a result property with a given key of an ServiceNow API call.
309
324
 
310
325
  Args:
311
- response (dict): REST response from an Salesforce REST Call
312
- key (str): property name (key)
313
- index (int, optional): Index to use (1st element has index 0).
314
- Defaults to 0.
326
+ response (dict):
327
+ REST response from an ServiceNow REST call.
328
+ key (str):
329
+ The property name (key) to get the value of.
330
+ index (int, optional):
331
+ Index to use (1st element has index 0).
332
+ Defaults to 0.
333
+
315
334
  Returns:
316
- str: value for the key, None otherwise
335
+ str:
336
+ The value for the key, None otherwise.
337
+
317
338
  """
318
339
 
319
340
  # ServiceNow responses should always have a "result":
320
- if not response or not "result" in response:
341
+ if not response or "result" not in response:
321
342
  return None
322
343
 
323
344
  values = response["result"]
@@ -331,7 +352,7 @@ class ServiceNow(object):
331
352
  elif isinstance(values, dict) and key in values:
332
353
  value = values[key]
333
354
  else:
334
- logger.error("Illegal data type in ServiceNow response!")
355
+ self.logger.error("Illegal data type in ServiceNow response!")
335
356
  return None
336
357
 
337
358
  return value
@@ -342,9 +363,12 @@ class ServiceNow(object):
342
363
  """Authenticate at ServiceNow with client ID and client secret or with basic authentication.
343
364
 
344
365
  Args:
345
- auth_type (str): this can be "basic" or "oauth"
366
+ auth_type (str):
367
+ The Authorization type. This can be "basic" or "oauth".
368
+
346
369
  Returns:
347
- str: session token or None in case of an error
370
+ str:
371
+ The session token or None in case of an error.
348
372
 
349
373
  """
350
374
 
@@ -363,16 +387,18 @@ class ServiceNow(object):
363
387
 
364
388
  return token
365
389
  else:
366
- logger.error("Unsupported authentication type")
390
+ self.logger.error("Unsupported authentication type")
367
391
  return None
368
392
 
369
393
  # end method definition
370
394
 
371
395
  def get_oauth_token(self) -> str:
372
- """Returns the OAuth access token.
396
+ """Return the OAuth access token.
373
397
 
374
398
  Returns:
375
- str: Access token
399
+ str:
400
+ The access token.
401
+
376
402
  """
377
403
 
378
404
  token_post_body = {
@@ -394,9 +420,9 @@ class ServiceNow(object):
394
420
  else:
395
421
  # Store authentication access_token:
396
422
  self._access_token = authenticate_dict["access_token"]
397
- logger.debug("Access Token -> %s", self._access_token)
423
+ self.logger.debug("Access Token -> %s", self._access_token)
398
424
  else:
399
- logger.error(
425
+ self.logger.error(
400
426
  "Failed to request an Service Now Access Token; error -> %s",
401
427
  response.text,
402
428
  )
@@ -408,57 +434,59 @@ class ServiceNow(object):
408
434
 
409
435
  @cache
410
436
  def get_object(self, table_name: str, sys_id: str) -> dict | None:
411
- """Get an ServiceNow object based on table name and ID
437
+ """Get an ServiceNow object based on table name and ID.
412
438
 
413
439
  Args:
414
- table_name (str): Name of the ServiceNow table.
415
- sys_id (str): ID of the data set to resolve.
440
+ table_name (str):
441
+ The name of the ServiceNow table.
442
+ sys_id (str):
443
+ The ID of the data set to resolve.
416
444
 
417
445
  Returns:
418
- dict | None: dictionary of fields of resulting table row or None
419
- in case an error occured.
446
+ dict | None:
447
+ The dictionary of fields of resulting table row or None
448
+ in case an error occured.
449
+
420
450
  """
421
451
 
422
452
  if not table_name:
423
- logger.error("Table name is missing!")
453
+ self.logger.error("Table name is missing!")
424
454
  return None
425
455
 
426
456
  if not sys_id:
427
- logger.error("System ID of item to lookup is missing!")
457
+ self.logger.error("System ID of item to lookup is missing!")
428
458
  return None
429
459
 
430
460
  request_header = self.request_header()
431
461
 
432
462
  request_url = self.config()["restUrl"] + "table/{}/{}".format(
433
- table_name, sys_id
463
+ table_name,
464
+ sys_id,
434
465
  )
435
466
 
436
467
  try:
437
468
  response = self._session.get(url=request_url, headers=request_header)
438
469
  data = self.parse_request_response(response)
439
-
440
- return data
441
- except HTTPError as http_err:
442
- logger.error(
443
- "HTTP error occurred while resolving -> %s in table -> '%s': %s",
470
+ except HTTPError:
471
+ self.logger.error(
472
+ "HTTP error occurred while resolving -> '%s' in table -> '%s'!",
444
473
  sys_id,
445
474
  table_name,
446
- str(http_err),
447
475
  )
448
- except RequestException as req_err:
449
- logger.error(
450
- "Request error occurred while resolving -> %s in table -> '%s': %s",
476
+ except RequestException:
477
+ self.logger.error(
478
+ "Request error occurred while resolving -> '%s' in table -> '%s'!",
451
479
  sys_id,
452
480
  table_name,
453
- str(req_err),
454
481
  )
455
- except Exception as err:
456
- logger.error(
457
- "An error occurred while resolving -> %s in table -> '%s': %s",
482
+ except Exception:
483
+ self.logger.error(
484
+ "An error occurred while resolving -> '%s' in table -> '%s'!",
458
485
  sys_id,
459
486
  table_name,
460
- str(err),
461
487
  )
488
+ else:
489
+ return data
462
490
 
463
491
  return None
464
492
 
@@ -468,10 +496,13 @@ class ServiceNow(object):
468
496
  """Get summary object for an article.
469
497
 
470
498
  Args:
471
- summary_sys_id (str): System ID of the article
499
+ summary_sys_id (str):
500
+ The system ID of the article.
472
501
 
473
502
  Returns:
474
- dict | None: dictionary with the summary
503
+ dict | None:
504
+ The dictionary with the summary.
505
+
475
506
  """
476
507
 
477
508
  return self.get_object(table_name="kb_knowledge_summary", sys_id=summary_sys_id)
@@ -490,17 +521,24 @@ class ServiceNow(object):
490
521
  """Retrieve a specified ServiceNow table data (row or values).
491
522
 
492
523
  Args:
493
- table_name (str): Name of the ServiceNow table
494
- query (str, optional): Query to filter the table rows (e.g. articles).
495
- fields (list, optional): Just return the fileds in this list.
496
- Defaults to None which means to deliver
497
- all fields.
498
- limit (int, optional): Number of results to return. None = unlimited.
499
- offset (int, optional): first item to return (for chunking)
500
- error_string (str, optional): custom error string
524
+ table_name (str):
525
+ The name of the ServiceNow table to retrieve.
526
+ query (str, optional):
527
+ Query to filter the table rows (e.g. articles).
528
+ fields (list, optional):
529
+ Just return the fileds in this list.
530
+ Defaults to None which means to deliver all fields.
531
+ limit (int, optional):
532
+ Number of results to return. None = unlimited.
533
+ offset (int, optional):
534
+ First item to return (for chunking).
535
+ error_string (str, optional):
536
+ A custom error string can be provided by this parameter.
501
537
 
502
538
  Returns:
503
- list | None: List or articles or None if the request fails.
539
+ list | None:
540
+ List or articles or None if the request fails.
541
+
504
542
  """
505
543
 
506
544
  request_header = self.request_header()
@@ -519,32 +557,34 @@ class ServiceNow(object):
519
557
  encoded_query = urllib.parse.urlencode(params, doseq=True)
520
558
 
521
559
  request_url = self.config()["tableUrl"] + "/{}?{}".format(
522
- table_name, encoded_query
560
+ table_name,
561
+ encoded_query,
523
562
  )
524
563
 
525
564
  try:
526
565
  while True:
527
566
  response = self._session.get(
528
- url=request_url, headers=request_header # , params=params
567
+ url=request_url,
568
+ headers=request_header, # , params=params
529
569
  )
530
570
  data = self.parse_request_response(response)
531
571
 
532
572
  if response.status_code == 200:
533
573
  return data.get("result", [])
534
574
  elif response.status_code == 202:
535
- logger.warning(
536
- "Service Now returned <202 Accepted> -> throtteling, retrying ..."
575
+ self.logger.warning(
576
+ "Service Now returned <202 Accepted> -> throtteling, retrying ...",
537
577
  )
538
578
  time.sleep(1000)
539
579
  else:
540
580
  return None
541
581
 
542
- except HTTPError as http_err:
543
- logger.error("%sHTTP error -> %s!", error_string, str(http_err))
544
- except RequestException as req_err:
545
- logger.error("%sRequest error -> %s!", error_string, str(req_err))
546
- except Exception as err:
547
- logger.error("%sError -> %s!", error_string, str(err))
582
+ except HTTPError:
583
+ self.logger.error("%sHTTP error!", error_string)
584
+ except RequestException:
585
+ self.logger.error("%sRequest error!", error_string)
586
+ except Exception:
587
+ self.logger.error("%s", error_string)
548
588
 
549
589
  return None
550
590
 
@@ -555,15 +595,20 @@ class ServiceNow(object):
555
595
  table_name: str,
556
596
  query: str | None = None,
557
597
  ) -> int:
558
- """Get number of table rows (e.g. Knowledge Base Articles) matching the query
559
- (or if query = "" it should be the total number)
598
+ """Get number of table rows (e.g. Knowledge Base Articles) matching the query.
599
+
600
+ (or if query = "" it should be the total number).
560
601
 
561
602
  Args:
562
- table_name (str): name of the ServiceNow table
563
- query (str, optional): Query string to filter the results. Defaults to "".
603
+ table_name (str):
604
+ The name of the ServiceNow table.
605
+ query (str, optional):
606
+ A query string to filter the results. Defaults to "".
564
607
 
565
608
  Returns:
566
- int: Number of table rows.
609
+ int:
610
+ Number of table rows.
611
+
567
612
  """
568
613
 
569
614
  request_header = self.request_header()
@@ -576,21 +621,24 @@ class ServiceNow(object):
576
621
  encoded_query = urllib.parse.urlencode(params, doseq=True)
577
622
 
578
623
  request_url = self.config()["statsUrl"] + "/{}?{}".format(
579
- table_name, encoded_query
624
+ table_name,
625
+ encoded_query,
580
626
  )
581
627
 
582
628
  try:
583
629
  response = self._session.get(
584
- url=request_url, headers=request_header, timeout=600
630
+ url=request_url,
631
+ headers=request_header,
632
+ timeout=600,
585
633
  )
586
634
  data = self.parse_request_response(response)
587
635
  return int(data["result"]["stats"]["count"])
588
- except HTTPError as http_err:
589
- logger.error("HTTP error occurred -> %s!", str(http_err))
590
- except RequestException as req_err:
591
- logger.error("Request error occurred -> %s!", str(req_err))
592
- except Exception as err:
593
- logger.error("An error occurred -> %s!", str(err))
636
+ except HTTPError:
637
+ self.logger.error("HTTP error occurred!")
638
+ except RequestException:
639
+ self.logger.error("Request error occurred!")
640
+ except Exception:
641
+ self.logger.error("An error occurred!")
594
642
 
595
643
  return None
596
644
 
@@ -600,9 +648,11 @@ class ServiceNow(object):
600
648
  """Get the configured knowledge base categories in ServiceNow.
601
649
 
602
650
  Returns:
603
- list | None: list of configured knowledge base categories or None in case of an error.
651
+ list | None:
652
+ A list of configured knowledge base categories
653
+ or None in case of an error.
604
654
 
605
- Example:
655
+ Example:
606
656
  [
607
657
  {
608
658
  'sys_mod_count': '2',
@@ -628,6 +678,7 @@ class ServiceNow(object):
628
678
  'sys_created_by': 'tiychowdhury@opentext.com'
629
679
  }
630
680
  ]
681
+
631
682
  """
632
683
 
633
684
  return self.get_table(
@@ -642,9 +693,10 @@ class ServiceNow(object):
642
693
  """Get the configured knowledge bases in ServiceNow.
643
694
 
644
695
  Returns:
645
- list | None: list of configured knowledge bases or None in case of an error.
696
+ list | None:
697
+ The list of configured knowledge bases or None in case of an error.
646
698
 
647
- Example:
699
+ Example:
648
700
  [
649
701
  {
650
702
  'mandatory_fields': '',
@@ -694,10 +746,11 @@ class ServiceNow(object):
694
746
  'card_color': '',
695
747
  'disable_rating': 'false',
696
748
  'create_translation_task': 'false',
697
- 'kb_managers': 'acab67001b6b811461a7a8e22a4bcbbe,7ab0b6801ba205d061a7a8e22a4bcbec,2a685f4c1be7811461a7a8e22a4bcbfd,6cc3c3d2db21781068cfd6c4e2961962,053429e31b5f0114fea2ec20604bcb95,5454eb441b6b0514fea2ec20604bcbfc,3a17970c1be7811461a7a8e22a4bcb23'
749
+ 'kb_managers': 'acab67001b6b811461a7a8e22a4bcbbe,7ab0b6801ba205d061a7a8e22a4bcbec'
698
750
  },
699
751
  ...
700
752
  ]
753
+
701
754
  """
702
755
 
703
756
  return self.get_table(
@@ -715,20 +768,26 @@ class ServiceNow(object):
715
768
  limit: int | None = 10,
716
769
  offset: int = 0,
717
770
  ) -> list | None:
718
- """Get selected / filtered Knowledge Base articles
771
+ """Get selected / filtered Knowledge Base articles.
719
772
 
720
773
  Args:
721
- query (str, optional): Query to filter the the articles.
722
- fields (list, optional): Just return the fileds in this list.
723
- Defaults to None which means to deliver
724
- all fields.
725
- limit (int, optional): Number of results to return. None = unlimited.
726
- offset (int, optional): first item to return (for chunking)
774
+ table_name (str, optional):
775
+ The name of the ServiceNow table.
776
+ query (str, optional):
777
+ Query to filter the articles.
778
+ fields (list, optional):
779
+ Just return the fields in this list.
780
+ Defaults to None which means to deliver all fields.
781
+ limit (int, optional):
782
+ Number of results to return. None = unlimited.
783
+ offset (int, optional):
784
+ The first item to return (for chunking).
727
785
 
728
786
  Returns:
729
- list | None: List or articles or None if the request fails.
787
+ list | None:
788
+ List or articles or None if the request fails.
730
789
 
731
- Example:
790
+ Example:
732
791
  [
733
792
  {
734
793
  'parent': '',
@@ -832,6 +891,7 @@ class ServiceNow(object):
832
891
  },
833
892
  ...
834
893
  ]
894
+
835
895
  """
836
896
 
837
897
  return self.get_table(
@@ -845,13 +905,16 @@ class ServiceNow(object):
845
905
 
846
906
  # end method definition
847
907
 
848
- def make_file_names_unique(self, file_list: list):
849
- """Make file names unique if required. The mutable
850
- list is changed "in-place".
908
+ def make_file_names_unique(self, file_list: list) -> None:
909
+ """Make file names unique if required.
910
+
911
+ The mutable list is changed "in-place".
851
912
 
852
913
  Args:
853
- file_list (list): list of attachments as dictionaries
854
- with "sys_id" and "file_name" keys.
914
+ file_list (list):
915
+ List of attachments as dictionaries
916
+ with "sys_id" and "file_name" keys.
917
+
855
918
  """
856
919
 
857
920
  # Dictionary to keep track of how many times each file name has been encountered
@@ -882,13 +945,16 @@ class ServiceNow(object):
882
945
  # end method definition
883
946
 
884
947
  def get_article_attachments(self, article: dict) -> list | None:
885
- """Get a list of attachments for an article
948
+ """Get a list of attachments for an article.
886
949
 
887
950
  Args:
888
- article (dict): Article information
951
+ article (dict):
952
+ Article information.
889
953
 
890
954
  Returns:
891
- list | None: list of attachments
955
+ list | None:
956
+ List of attachments for the article.
957
+
892
958
  """
893
959
 
894
960
  article_sys_id = article["sys_id"]
@@ -904,30 +970,32 @@ class ServiceNow(object):
904
970
 
905
971
  try:
906
972
  response = self._session.get(
907
- url=request_url, headers=request_header, params=params
973
+ url=request_url,
974
+ headers=request_header,
975
+ params=params,
908
976
  )
909
977
  data = self.parse_request_response(response)
910
978
  attachments = data.get("result", [])
911
979
  if not attachments:
912
- logger.debug(
980
+ self.logger.debug(
913
981
  "Knowledge base article -> %s does not have attachments!",
914
982
  article_number,
915
983
  )
916
984
  return []
917
985
  else:
918
- logger.info(
986
+ self.logger.debug(
919
987
  "Knowledge base article -> %s has %s attachments.",
920
988
  article_number,
921
989
  len(attachments),
922
990
  )
923
991
  return attachments
924
992
 
925
- except HTTPError as http_err:
926
- logger.error("HTTP error occurred -> %s!", str(http_err))
927
- except RequestException as req_err:
928
- logger.error("Request error occurred -> %s!", str(req_err))
929
- except Exception as err:
930
- logger.error("An error occurred -> %s!", str(err))
993
+ except HTTPError:
994
+ self.logger.error("HTTP error occurred!")
995
+ except RequestException:
996
+ self.logger.error("Request error occurred!")
997
+ except Exception:
998
+ self.logger.error("An error occurred!")
931
999
 
932
1000
  return None
933
1001
 
@@ -941,26 +1009,30 @@ class ServiceNow(object):
941
1009
  """Download the attachments of a Knowledge Base Article (KBA) in ServiceNow.
942
1010
 
943
1011
  Args:
944
- article (dict): dictionary holding the Service Now article data
945
- skip_existing (bool, optional): skip download if file has been downloaded before
1012
+ article (dict):
1013
+ The dictionary holding the ServiceNow article data.
1014
+ skip_existing (bool, optional):
1015
+ If True, skip download if file has been downloaded before.
946
1016
 
947
1017
  Returns:
948
- bool: True = success, False = failure
1018
+ bool:
1019
+ True = success, False = failure.
1020
+
949
1021
  """
950
1022
 
951
1023
  article_number = article["number"]
952
1024
 
953
- attachments = self.get_article_attachments(article)
1025
+ attachments = self.get_article_attachments(article=article)
954
1026
 
955
1027
  if not attachments:
956
- logger.debug(
1028
+ self.logger.debug(
957
1029
  "Knowledge base article -> %s does not have attachments to download!",
958
1030
  article_number,
959
1031
  )
960
1032
  article["has_attachments"] = False
961
1033
  return False
962
1034
  else:
963
- logger.info(
1035
+ self.logger.info(
964
1036
  "Knowledge base article -> %s has %s attachments to download...",
965
1037
  article_number,
966
1038
  len(attachments),
@@ -980,79 +1052,108 @@ class ServiceNow(object):
980
1052
  article["download_files_ids"] = []
981
1053
 
982
1054
  if not os.path.exists(base_dir):
983
- os.makedirs(base_dir)
1055
+ try:
1056
+ os.makedirs(base_dir)
1057
+ except FileExistsError:
1058
+ self.logger.error(
1059
+ "Directory -> '%s' already exists. Race condition occurred.",
1060
+ base_dir,
1061
+ )
1062
+ except PermissionError:
1063
+ self.logger.error("Permission error with directory -> %s", base_dir)
1064
+ return False
1065
+ except OSError:
1066
+ self.logger.error("OS error with directory -> %s", base_dir)
1067
+ return False
1068
+ except TypeError:
1069
+ self.logger.error("Invalid path type -> %s", base_dir)
1070
+ return False
984
1071
 
985
1072
  for attachment in attachments:
986
1073
  file_path = os.path.join(base_dir, attachment["file_name"])
987
1074
 
988
1075
  if os.path.exists(file_path) and skip_existing:
989
- logger.info(
990
- "File -> %s has been downloaded before. Skipping download...",
1076
+ self.logger.info(
1077
+ "File -> '%s' has been downloaded before. Skipping download...",
991
1078
  file_path,
992
1079
  )
993
1080
 
994
- # we need to add file_name and sys_id in the list of files and for later use in bulkDocument processing...
1081
+ # We need to add file_name and sys_id in the list of files and and file IDs
1082
+ # for later use in bulkDocument processing...
1083
+ # This creates two new columns "download_files" and "download_files_ids"
1084
+ # in the data frame:
995
1085
  article["download_files"].append(attachment["file_name"])
996
1086
  article["download_files_ids"].append(attachment["sys_id"])
997
1087
  continue
998
- attachment_download_url = (
999
- self.config()["attachmentDownloadUrl"]
1000
- + "/"
1001
- + attachment["sys_id"]
1002
- + "/file"
1003
- )
1088
+ attachment_download_url = self.config()["attachmentDownloadUrl"] + "/" + attachment["sys_id"] + "/file"
1004
1089
  try:
1005
- logger.info(
1006
- "Downloading attachment file -> '%s' for article -> %s from ServiceNow...",
1090
+ self.logger.info(
1091
+ "Downloading attachment file -> '%s' for article -> '%s' from ServiceNow...",
1007
1092
  file_path,
1008
1093
  article_number,
1009
1094
  )
1010
1095
 
1096
+ # Request the attachment as a stream from ServiceNow.
1097
+ # This initiates the download process...
1011
1098
  attachment_response = self._session.get(
1012
- attachment_download_url, stream=True
1099
+ attachment_download_url,
1100
+ stream=True,
1013
1101
  )
1014
1102
  attachment_response.raise_for_status()
1015
1103
 
1016
- with open(file_path, "wb") as file:
1104
+ # Read and write the attachment file in chunks:
1105
+ with open(file_path, "wb") as attachment_file:
1017
1106
  for chunk in attachment_response.iter_content(chunk_size=8192):
1018
- file.write(chunk)
1107
+ attachment_file.write(chunk)
1019
1108
 
1020
- # we build a list of filenames and ids.
1021
- # the ids we want to use as nicknames later on
1109
+ # We build a list of filenames and IDs.
1110
+ # The IDs we want to use as nicknames later on.
1022
1111
  article["download_files"].append(attachment["file_name"])
1023
1112
  article["download_files_ids"].append(attachment["sys_id"])
1024
1113
 
1025
- except HTTPError as e:
1026
- logger.error(
1027
- "Failed to download -> '%s' using url -> %s; error -> %s",
1114
+ except HTTPError:
1115
+ self.logger.error(
1116
+ "Failed to download -> '%s' using url -> %s",
1028
1117
  attachment["file_name"],
1029
1118
  attachment_download_url,
1030
- str(e),
1031
1119
  )
1032
1120
 
1033
1121
  return True
1034
1122
 
1035
1123
  # end method definition
1036
1124
 
1037
- def load_articles(self, table_name: str, query: str | None) -> bool:
1038
- """Main method to load ServiceNow articles in a Data Frame and
1039
- download the attchments.
1125
+ def load_articles(
1126
+ self,
1127
+ table_name: str,
1128
+ query: str | None,
1129
+ skip_existing_downloads: bool = True,
1130
+ ) -> bool:
1131
+ """Load ServiceNow articles in a data frame and download the attchments.
1040
1132
 
1041
1133
  Args:
1042
- query (str): Filter criteria for the articles.
1134
+ table_name (str):
1135
+ The name of the ServiceNow table.
1136
+ query (str | None):
1137
+ Filter criteria for the articles.
1138
+ skip_existing_downloads (bool, optional):
1139
+ If True, it tries to optimize the processing by reusing
1140
+ existing downloads of attachments in the file system.
1043
1141
 
1044
1142
  Returns:
1045
- bool: True = Success, False = Failure
1143
+ bool:
1144
+ True = Success, False = Failure.
1145
+
1046
1146
  """
1047
1147
 
1048
1148
  total_count = self.get_table_count(table_name=table_name, query=query)
1049
1149
 
1050
- logger.info(
1051
- "Total number of Knowledge Base Articles (KBA) -> %s", str(total_count)
1150
+ self.logger.info(
1151
+ "Total number of Knowledge Base Articles (KBA) -> %s",
1152
+ str(total_count),
1052
1153
  )
1053
1154
 
1054
1155
  if total_count == 0:
1055
- logger.info(
1156
+ self.logger.info(
1056
1157
  "Query does not return any value from ServiceNow table -> '%s'. Finishing.",
1057
1158
  table_name,
1058
1159
  )
@@ -1068,7 +1169,7 @@ class ServiceNow(object):
1068
1169
  remainder = 0
1069
1170
  number = 1
1070
1171
 
1071
- logger.info(
1172
+ self.logger.info(
1072
1173
  "Processing -> %s Knowledge Base Articles (KBA), table name -> '%s', thread number -> %s, partition size -> %s",
1073
1174
  str(total_count),
1074
1175
  table_name,
@@ -1082,7 +1183,7 @@ class ServiceNow(object):
1082
1183
  for i in range(number):
1083
1184
  current_partition_size = partition_size + (1 if i < remainder else 0)
1084
1185
  thread = threading.Thread(
1085
- name=f"load_articles_{i+1:02}",
1186
+ name=f"load_articles_{i + 1:02}",
1086
1187
  target=self.thread_wrapper,
1087
1188
  args=(
1088
1189
  self.load_articles_worker,
@@ -1090,6 +1191,7 @@ class ServiceNow(object):
1090
1191
  query,
1091
1192
  current_partition_size,
1092
1193
  current_offset,
1194
+ skip_existing_downloads,
1093
1195
  ),
1094
1196
  )
1095
1197
  thread.start()
@@ -1104,17 +1206,31 @@ class ServiceNow(object):
1104
1206
  # end method definition
1105
1207
 
1106
1208
  def load_articles_worker(
1107
- self, table_name: str, query: str, partition_size: int, partition_offset: int
1209
+ self,
1210
+ table_name: str,
1211
+ query: str,
1212
+ partition_size: int,
1213
+ partition_offset: int,
1214
+ skip_existing_downloads: bool = True,
1108
1215
  ) -> None:
1109
- """Worker Method for multi-threading.
1216
+ """Worker method for multi-threading.
1110
1217
 
1111
1218
  Args:
1112
- query (str): Query to select the relevant KBA.
1113
- partition_size (int): Total size of the partition assigned to this thread.
1114
- partition_offset (int): Starting offset for the KBAs this thread is processing.
1219
+ table_name (str):
1220
+ Name of the ServiceNow table.
1221
+ query (str):
1222
+ Query to select the relevant KBA.
1223
+ partition_size (int):
1224
+ Total size of the partition assigned to this thread.
1225
+ partition_offset (int):
1226
+ Starting offset for the KBAs this thread is processing.
1227
+ skip_existing_downloads (bool, optional):
1228
+ If True, it tries to optimize the processing by reusing
1229
+ existing downloads of attachments in the file system.
1230
+
1115
1231
  """
1116
1232
 
1117
- logger.info(
1233
+ self.logger.info(
1118
1234
  "Start processing KBAs in range from -> %s to -> %s from table -> '%s'...",
1119
1235
  partition_offset,
1120
1236
  partition_offset + partition_size,
@@ -1125,23 +1241,29 @@ class ServiceNow(object):
1125
1241
  # So we define "limit" as the maximum number of KBAs we want to retrieve for one REST call.
1126
1242
  # This should be a reasonable number to avoid timeouts. We also need to make sure
1127
1243
  # the limit is not bigger than the the partition size:
1128
- limit = 100 if partition_size > 100 else partition_size
1244
+ limit = min(partition_size, 100)
1129
1245
 
1130
1246
  for offset in range(partition_offset, partition_offset + partition_size, limit):
1131
1247
  articles = self.get_table(
1132
- table_name=table_name, query=query, limit=limit, offset=offset
1248
+ table_name=table_name,
1249
+ query=query,
1250
+ limit=limit,
1251
+ offset=offset,
1133
1252
  )
1134
- logger.info(
1253
+ self.logger.info(
1135
1254
  "Retrieved a list of %s KBAs starting at offset -> %s to process.",
1136
1255
  str(len(articles)),
1137
1256
  offset,
1138
1257
  )
1139
1258
  for article in articles:
1140
- logger.info("Processing KBA -> %s...", article["number"])
1259
+ self.logger.info("Processing KBA -> %s...", article["number"])
1141
1260
  article["source_table"] = table_name
1142
- self.load_article(article)
1261
+ self.load_article(
1262
+ article=article,
1263
+ skip_existing_downloads=skip_existing_downloads,
1264
+ )
1143
1265
 
1144
- logger.info(
1266
+ self.logger.info(
1145
1267
  "Finished processing KBAs in range from -> %s to -> %s from table -> '%s'.",
1146
1268
  partition_offset,
1147
1269
  partition_offset + partition_size,
@@ -1150,15 +1272,19 @@ class ServiceNow(object):
1150
1272
 
1151
1273
  # end method definition
1152
1274
 
1153
- def load_article(self, article: dict, skip_existing_downloads: bool = True):
1154
- """Process a single KBA: download attachments (if any), add additional
1155
- keys / values to the article from other ServiceNow tables,
1156
- and finally add the KBA to the Data Frame.
1275
+ def load_article(self, article: dict, skip_existing_downloads: bool = True) -> None:
1276
+ """Process a single KBA.
1277
+
1278
+ Download attachments (if any), add additional keys / values to the article from
1279
+ other ServiceNow tables, and finally add the KBA to the data frame.
1157
1280
 
1158
1281
  Args:
1159
- article (dict): Dictionary inclusing all fields of
1160
- a single KBA. This is a mutable variable
1161
- that gets modified by this method!
1282
+ article (dict):
1283
+ Dictionary inclusing all fields of a single KBA.
1284
+ This is a mutable variable that gets modified by this method!
1285
+ skip_existing_downloads (bool, optional):
1286
+ If True it tries to optimize the processing by reusing
1287
+ existing downloads of attachments.
1162
1288
 
1163
1289
  Side effect:
1164
1290
  The article dict is modified with by adding additional key / value
@@ -1177,209 +1303,272 @@ class ServiceNow(object):
1177
1303
 
1178
1304
  """
1179
1305
 
1306
+ #
1307
+ # Download the attachments of the KBA:
1308
+ #
1309
+
1180
1310
  _ = self.download_attachments(
1181
- article=article, skip_existing=skip_existing_downloads
1311
+ article=article,
1312
+ skip_existing=skip_existing_downloads,
1182
1313
  )
1183
1314
 
1184
1315
  #
1185
1316
  # Add additional columns from related ServiceNow tables:
1186
1317
  #
1187
1318
 
1188
- if "kb_category" in article and article["kb_category"]:
1319
+ if article.get("kb_category"):
1189
1320
  category_key = article.get("kb_category")["value"]
1190
1321
  category_table_name = SN_TABLE_CATEGORIES
1191
1322
  category = self.get_object(
1192
- table_name=category_table_name, sys_id=category_key
1323
+ table_name=category_table_name,
1324
+ sys_id=category_key,
1193
1325
  )
1194
1326
  if category:
1195
1327
  article["kb_category_name"] = self.get_result_value(
1196
- response=category, key="full_category"
1328
+ response=category,
1329
+ key="full_category",
1197
1330
  )
1198
1331
  else:
1199
- logger.warning(
1200
- "Article -> %s has no category value!", article["number"]
1332
+ self.logger.warning(
1333
+ "Article -> %s has no category value!",
1334
+ article["number"],
1201
1335
  )
1202
1336
  article["kb_category_name"] = ""
1203
1337
  else:
1204
- logger.warning(
1205
- "Article -> %s has no value for category!", article["number"]
1338
+ self.logger.warning(
1339
+ "Article -> %s has no value for category!",
1340
+ article["number"],
1206
1341
  )
1207
1342
  article["kb_category_name"] = ""
1208
1343
 
1209
1344
  knowledge_base_key = article.get("kb_knowledge_base")["value"]
1210
1345
  knowledge_base_table_name = SN_TABLE_KNOWLEDGE_BASES
1211
1346
  knowledge_base = self.get_object(
1212
- table_name=knowledge_base_table_name, sys_id=knowledge_base_key
1347
+ table_name=knowledge_base_table_name,
1348
+ sys_id=knowledge_base_key,
1213
1349
  )
1214
1350
  if knowledge_base:
1215
1351
  article["kb_knowledge_base_name"] = self.get_result_value(
1216
- response=knowledge_base, key="title"
1352
+ response=knowledge_base,
1353
+ key="title",
1217
1354
  )
1218
1355
  else:
1219
- logger.warning(
1220
- "Article -> %s has no value for Knowledge Base!",
1356
+ self.logger.warning(
1357
+ "Article -> %s has no value for knowledge base!",
1221
1358
  article["number"],
1222
1359
  )
1223
1360
  article["kb_knowledge_base_name"] = ""
1224
1361
 
1225
- related_product_names = []
1226
- if article.get("related_products", None):
1362
+ # We use a set to make sure the resulting related items are unique:
1363
+ related_product_names: set = set()
1364
+ if article.get("related_products"):
1227
1365
  related_product_keys = article.get("related_products").split(",")
1228
1366
  for related_product_key in related_product_keys:
1229
1367
  related_product = self.get_object(
1230
- table_name=SN_TABLE_RELATED_PRODUCTS, sys_id=related_product_key
1368
+ table_name=SN_TABLE_RELATED_PRODUCTS,
1369
+ sys_id=related_product_key,
1231
1370
  )
1232
1371
  if related_product:
1233
1372
  related_product_name = self.get_result_value(
1234
- response=related_product, key="name"
1373
+ response=related_product,
1374
+ key="name",
1235
1375
  )
1236
- logger.debug(
1237
- "Found related Product -> '%s' (%s)",
1376
+ # Remove leading or trailing spaces (simple cleansing effort):
1377
+ related_product_name = related_product_name.strip() if related_product_name else ""
1378
+ if self._product_exclusions and related_product_name in self._product_exclusions:
1379
+ self.logger.info(
1380
+ "Found related product -> '%s' (%s) but it is on the product exclusion list. Skipping...",
1381
+ related_product_name,
1382
+ related_product_key,
1383
+ )
1384
+ continue
1385
+ self.logger.debug(
1386
+ "Found related product -> '%s' (%s)",
1238
1387
  related_product_name,
1239
1388
  related_product_key,
1240
1389
  )
1241
- related_product_names.append(related_product_name)
1390
+ # Add the related item to the resulting set
1391
+ # (duplicates will not be added as it is a set):
1392
+ related_product_names.add(related_product_name)
1242
1393
  # Extended ECM can only handle a maxiumum of 50 line items:
1243
1394
  if len(related_product_names) == 49:
1244
- logger.info(
1245
- "Reached maximum of 50 multi-value items for related Products of article -> %s",
1395
+ self.logger.info(
1396
+ "Reached maximum of 50 multi-value items for related products of article -> %s",
1246
1397
  article["number"],
1247
1398
  )
1248
1399
  break
1249
1400
  else:
1250
- logger.warning(
1251
- "Article -> %s: Cannot lookup related Product name in table -> '%s' with ID -> %s",
1401
+ self.logger.warning(
1402
+ "Article -> %s: Cannot lookup related product name in table -> '%s' with key -> '%s'",
1252
1403
  article["number"],
1253
1404
  SN_TABLE_RELATED_PRODUCTS,
1254
1405
  related_product_key,
1255
1406
  )
1256
1407
  else:
1257
- logger.warning(
1258
- "Article -> %s has no value related Products!",
1408
+ self.logger.debug(
1409
+ "Article -> %s has no related products!",
1259
1410
  article["number"],
1260
1411
  )
1261
- article["related_product_names"] = related_product_names
1412
+ # This adds a column to the data frame with the name "related_product_names"
1413
+ # (we convert the set to a list):
1414
+ article["related_product_names"] = list(related_product_names)
1262
1415
 
1263
- product_line_names = []
1264
- if article.get("u_product_line", None):
1416
+ # We use a set to make sure the resulting related items are unique:
1417
+ product_line_names: set = set()
1418
+ if article.get("u_product_line"):
1265
1419
  product_line_keys = article.get("u_product_line").split(",")
1266
1420
  product_line_table = SN_TABLE_PRODUCT_LINES
1267
1421
  for product_line_key in product_line_keys:
1268
1422
  product_line = self.get_object(
1269
- table_name=product_line_table, sys_id=product_line_key
1423
+ table_name=product_line_table,
1424
+ sys_id=product_line_key,
1270
1425
  )
1271
1426
  if product_line:
1272
1427
  product_line_name = self.get_result_value(
1273
- response=product_line, key="name"
1428
+ response=product_line,
1429
+ key="name",
1274
1430
  )
1275
- logger.debug(
1276
- "Found related Product Line -> '%s' (%s)",
1431
+ # Remove leading or trailing spaces (simple cleansing effort):
1432
+ product_line_name = product_line_name.strip() if product_line_name else ""
1433
+ self.logger.debug(
1434
+ "Found related product line -> '%s' (%s)",
1277
1435
  product_line_name,
1278
1436
  product_line_key,
1279
1437
  )
1280
- product_line_names.append(product_line_name)
1438
+ # Add the related item to the resulting set
1439
+ # (duplicates will not be added as it is a set):
1440
+ product_line_names.add(product_line_name)
1281
1441
  # Extended ECM can only handle a maxiumum of 50 line items:
1282
1442
  if len(product_line_names) == 49:
1283
- logger.info(
1284
- "Reached maximum of 50 multi-value items for related Product Lines of article -> %s",
1443
+ self.logger.info(
1444
+ "Reached maximum of 50 multi-value items for related product lines of article -> %s",
1285
1445
  article["number"],
1286
1446
  )
1287
1447
  break
1448
+ # end if product_line:
1288
1449
  else:
1289
- logger.warning(
1290
- "Article -> %s: Cannot lookup related Product Line name in table -> '%s' with ID -> %s",
1450
+ self.logger.warning(
1451
+ "Article -> %s: Cannot lookup related product line name in table -> '%s' with key -> '%s'",
1291
1452
  article["number"],
1292
1453
  product_line_table,
1293
1454
  product_line_key,
1294
1455
  )
1295
1456
  else:
1296
- logger.warning(
1297
- "Article -> %s has no value for related Product Lines!",
1457
+ self.logger.debug(
1458
+ "Article -> %s has no related product lines!",
1298
1459
  article["number"],
1299
1460
  )
1300
- article["u_product_line_names"] = product_line_names
1461
+ # This adds a column to the data frame with the name "u_product_line_names"
1462
+ # (we convert the set to a list):
1463
+ article["u_product_line_names"] = list(product_line_names)
1301
1464
 
1302
- sub_product_line_names = []
1303
- if article.get("u_sub_product_line", None):
1465
+ # We use a set to make sure the resulting related items are unique:
1466
+ sub_product_line_names: set = set()
1467
+ if article.get("u_sub_product_line"):
1304
1468
  sub_product_line_keys = article.get("u_sub_product_line").split(",")
1305
1469
  sub_product_line_table = SN_TABLE_PRODUCT_LINES
1306
1470
  for sub_product_line_key in sub_product_line_keys:
1307
1471
  sub_product_line = self.get_object(
1308
- table_name=sub_product_line_table, sys_id=sub_product_line_key
1472
+ table_name=sub_product_line_table,
1473
+ sys_id=sub_product_line_key,
1309
1474
  )
1310
1475
  if sub_product_line:
1311
1476
  sub_product_line_name = self.get_result_value(
1312
- response=sub_product_line, key="name"
1477
+ response=sub_product_line,
1478
+ key="name",
1313
1479
  )
1314
- logger.debug(
1315
- "Found related Sub Product Line -> '%s' (%s)",
1480
+ # Remove leading or trailing spaces (simple cleansing effort):
1481
+ sub_product_line_name = sub_product_line_name.strip() if sub_product_line_name else ""
1482
+ self.logger.debug(
1483
+ "Found related sub product line -> '%s' (%s)",
1316
1484
  sub_product_line_name,
1317
1485
  sub_product_line_key,
1318
1486
  )
1319
- sub_product_line_names.append(sub_product_line_name)
1487
+ # Add the related item to the resulting set
1488
+ # (duplicates will not be added as it is a set):
1489
+ sub_product_line_names.add(sub_product_line_name)
1320
1490
  # Extended ECM can only handle a maxiumum of 50 line items:
1321
1491
  if len(sub_product_line_names) == 49:
1322
- logger.info(
1323
- "Reached maximum of 50 multi-value items for related Sub Product Lines of article -> %s",
1492
+ self.logger.info(
1493
+ "Reached maximum of 50 multi-value items for related sub product lines of article -> %s",
1324
1494
  article["number"],
1325
1495
  )
1326
1496
  break
1327
1497
  else:
1328
- logger.warning(
1329
- "Article -> %s: Cannot lookup related Sub Product Line name in table -> '%s' with ID -> %s",
1498
+ self.logger.warning(
1499
+ "Article -> %s: Cannot lookup related sub product line name in table -> '%s' with key -> '%s'",
1330
1500
  article["number"],
1331
1501
  sub_product_line_table,
1332
1502
  sub_product_line_key,
1333
1503
  )
1334
1504
  else:
1335
- logger.warning(
1336
- "Article -> %s has no value for related Sub Product Lines!",
1505
+ self.logger.debug(
1506
+ "Article -> %s has no related sub product lines!",
1337
1507
  article["number"],
1338
1508
  )
1339
- article["u_sub_product_line_names"] = sub_product_line_names
1509
+ # This adds a column to the data frame with the name "u_sub_product_line_names"
1510
+ # (we convert the set to a list):
1511
+ article["u_sub_product_line_names"] = list(sub_product_line_names)
1340
1512
 
1341
- application_names = []
1342
- if article.get("u_application", None):
1513
+ # We use a set to make sure the resulting related items are unique:
1514
+ application_names: set = set()
1515
+ if article.get("u_application"):
1343
1516
  application_keys = article.get("u_application").split(",")
1344
1517
  application_table_name = SN_TABLE_PRODUCT_LINES
1345
1518
  for application_key in application_keys:
1346
1519
  application = self.get_object(
1347
- table_name=application_table_name, sys_id=application_key
1520
+ table_name=application_table_name,
1521
+ sys_id=application_key,
1348
1522
  )
1349
1523
  if application:
1350
1524
  application_name = self.get_result_value(
1351
- response=application, key="name"
1525
+ response=application,
1526
+ key="name",
1352
1527
  )
1353
- logger.debug(
1354
- "Found related Application -> '%s' (%s)",
1528
+ # Remove leading or trailing spaces (simple cleansing effort):
1529
+ application_name = application_name.strip() if application_name else ""
1530
+ if self._product_exclusions and application_name in self._product_exclusions:
1531
+ self.logger.info(
1532
+ "Found related application -> '%s' (%s) but it is on the product exclusion list. Skipping...",
1533
+ application_name,
1534
+ application_key,
1535
+ )
1536
+ continue
1537
+ self.logger.debug(
1538
+ "Found related application -> '%s' (%s)",
1355
1539
  application_name,
1356
1540
  application_key,
1357
1541
  )
1358
- application_names.append(application_name)
1542
+ # Add the related item to the resulting set
1543
+ # (duplicates will not be added as it is a set):
1544
+ application_names.add(application_name)
1359
1545
  # Extended ECM can only handle a maxiumum of 50 line items:
1360
1546
  if len(application_names) == 49:
1361
- logger.info(
1362
- "Reached maximum of 50 multi-value items for related Applications of article -> %s",
1547
+ self.logger.info(
1548
+ "Reached maximum of 50 multi-value items for related applications of article -> %s",
1363
1549
  article["number"],
1364
1550
  )
1365
1551
  break
1552
+ # end if application
1366
1553
  else:
1367
- logger.warning(
1368
- "Article -> %s: Cannot lookup related Application name in table -> '%s' with ID -> %s",
1554
+ self.logger.warning(
1555
+ "Article -> %s: Cannot lookup related application name in table -> '%s' with key -> %s",
1369
1556
  article["number"],
1370
1557
  application_table_name,
1371
1558
  application_key,
1372
1559
  )
1373
1560
  else:
1374
- logger.warning(
1375
- "Article -> %s has no value for related Applications!",
1561
+ self.logger.debug(
1562
+ "Article -> %s has no related applications!",
1376
1563
  article["number"],
1377
1564
  )
1378
- article["u_application_names"] = application_names
1565
+ # This adds a column to the data frame with the name "u_application_names"
1566
+ # (we convert the set to a list):
1567
+ article["u_application_names"] = list(application_names)
1379
1568
 
1380
- application_versions = []
1569
+ application_versions: set = set()
1381
1570
  application_version_sets = []
1382
- if article.get("u_application_version", None):
1571
+ if article.get("u_application_version"):
1383
1572
  application_version_keys = article.get("u_application_version").split(",")
1384
1573
  for application_version_key in application_version_keys:
1385
1574
  # Get the version object from ServiceNow. It includes both,
@@ -1390,74 +1579,107 @@ class ServiceNow(object):
1390
1579
  )
1391
1580
  if application_version:
1392
1581
  application_version_name = self.get_result_value(
1393
- response=application_version, key="u_version_name"
1582
+ response=application_version,
1583
+ key="u_version_name",
1394
1584
  )
1395
- logger.debug(
1396
- "Found related Application Version -> '%s' (%s)",
1585
+ self.logger.debug(
1586
+ "Found related application version -> '%s' in table -> '%s' with key -> '%s'",
1587
+ application_version_name,
1397
1588
  SN_TABLE_PRODUCT_LINES,
1398
1589
  application_version_key,
1399
1590
  )
1400
1591
 
1401
- application_versions.append(application_version_name)
1402
-
1403
- # Lookup application name of version and fill the set
1592
+ # Add the related version to the resulting set
1593
+ # (duplicates will not be added as it is a set):
1594
+ application_versions.add(application_version_name)
1404
1595
 
1596
+ # Use the application key to lookup application name
1597
+ # for the version and fill a set
1405
1598
  application_key = self.get_result_value(
1406
- response=application_version, key="u_product_model"
1599
+ response=application_version,
1600
+ key="u_product_model",
1407
1601
  )
1408
1602
 
1409
1603
  if application_key:
1410
- # u_applicatio_model has a substructure like this:
1411
- # {
1412
- # 'link': 'https://support.opentext.com/api/now/table/u_ot_product_model/9b2dcea747f6d910ab0a9ed7536d4364',
1413
- # 'value': '9b2dcea747f6d910ab0a9ed7536d4364'
1414
- # }
1415
- # We want the value:
1604
+ """
1605
+ u_application_model has a substructure like this:
1606
+ {
1607
+ 'link': 'https://support.opentext.com/api/now/table/u_ot_product_model/9b2dcea747f6d910ab0a9ed7536d4364',
1608
+ 'value': '9b2dcea747f6d910ab0a9ed7536d4364'
1609
+ }
1610
+ """
1611
+ # We want the value which represents the key to lookup the application name:
1416
1612
  application_key = application_key.get("value")
1417
1613
 
1418
1614
  if application_key:
1615
+ # Retrieve the application with the application key from ServiceNBow:
1419
1616
  application = self.get_object(
1420
1617
  table_name=SN_TABLE_PRODUCT_LINES,
1421
1618
  sys_id=application_key,
1422
1619
  )
1423
-
1424
1620
  application_name = self.get_result_value(
1425
- response=application, key="name"
1621
+ response=application,
1622
+ key="name",
1623
+ )
1624
+ # Remove leading or trailing spaces (simple cleansing effort):
1625
+ application_name = application_name.strip() if application_name else ""
1626
+
1627
+ # We check if the application name is in the product exclusions list.
1628
+ # If this is the case we skip it from being added to the Application Version Set
1629
+ # as we don't want to create a workspace relationship.
1630
+ if (
1631
+ self._product_exclusions
1632
+ and application_name
1633
+ and application_name in self._product_exclusions
1634
+ ):
1635
+ self.logger.info(
1636
+ "Found related application -> '%s' (%s) but it is on the product exclusion list. Skipping...",
1637
+ application_name,
1638
+ application_key,
1639
+ )
1640
+ continue
1641
+ self.logger.debug(
1642
+ "Found related application -> '%s' for version -> '%s' in table -> '%s' with key -> '%s'",
1643
+ application_name,
1644
+ application_version_name,
1645
+ SN_TABLE_PRODUCT_LINES,
1646
+ application_key,
1426
1647
  )
1427
1648
 
1428
1649
  if application_name:
1429
1650
  application_version_sets.append(
1430
1651
  {
1431
- # "Application": application_name,
1432
- # "Version": application_version_name,
1433
1652
  "u_product_model": application_name,
1434
1653
  "u_version_name": application_version_name,
1435
- }
1654
+ },
1436
1655
  )
1656
+ # end if application_key
1437
1657
 
1438
1658
  # Extended ECM can only handle a maxiumum of 50 line items:
1439
- if len(application_versions) == 49:
1440
- logger.info(
1441
- "Reached maximum of 50 multi-value items for related Application Version of article -> %s",
1659
+ if len(application_version_sets) == 49:
1660
+ self.logger.info(
1661
+ "Reached maximum of 50 multi-value items for related application versions of article -> %s",
1442
1662
  article["number"],
1443
1663
  )
1444
1664
  break
1665
+ # end if application_version
1445
1666
  else:
1446
- logger.warning(
1447
- "Article -> %s: Cannot lookup related Application Version in table -> '%s' with ID -> %s",
1667
+ self.logger.warning(
1668
+ "Article -> %s: Cannot lookup related application version in table -> '%s' with key -> '%s'",
1448
1669
  article["number"],
1449
1670
  SN_TABLE_PRODUCT_VERSIONS,
1450
1671
  application_version_key,
1451
1672
  )
1452
1673
  else:
1453
- logger.warning(
1454
- "Article -> %s has no value for related Application Version!",
1674
+ self.logger.debug(
1675
+ "Article -> %s has no related application version!",
1455
1676
  article["number"],
1456
1677
  )
1457
- # Convert to list and set to remove duplicates:
1458
- article["u_application_versions"] = list(set(application_versions))
1678
+ # This adds a column to the data frame with the name "u_application_versions"
1679
+ # (we convert the set to a list):
1680
+ article["u_application_versions"] = list(application_versions)
1459
1681
 
1460
- # This set maps the applications and the versions (table-like structure)
1682
+ # This list of dictionaries maps the applications and the versions (table-like structure)
1461
1683
  article["u_application_version_sets"] = application_version_sets
1462
1684
 
1463
1685
  # Now we add the article to the Pandas Data Frame in the Data class: