data-manipulation 0.46__tar.gz → 0.48__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.
Files changed (40) hide show
  1. {data_manipulation-0.46/data_manipulation.egg-info → data_manipulation-0.48}/PKG-INFO +1 -1
  2. {data_manipulation-0.46 → data_manipulation-0.48}/data_manipulation/__init__.py +1 -2
  3. {data_manipulation-0.46 → data_manipulation-0.48}/data_manipulation/_version.py +3 -3
  4. {data_manipulation-0.46 → data_manipulation-0.48}/data_manipulation/base.py +177 -99
  5. {data_manipulation-0.46 → data_manipulation-0.48}/data_manipulation/beautifulsoup_.py +38 -11
  6. data_manipulation-0.48/data_manipulation/boto3_.py +184 -0
  7. data_manipulation-0.48/data_manipulation/cryptography_.py +138 -0
  8. data_manipulation-0.48/data_manipulation/django_.py +165 -0
  9. data_manipulation-0.48/data_manipulation/geopandas_.py +78 -0
  10. data_manipulation-0.48/data_manipulation/kerberos_.py +89 -0
  11. data_manipulation-0.48/data_manipulation/mysql_connector_python_.py +220 -0
  12. data_manipulation-0.48/data_manipulation/openldap_.py +88 -0
  13. data_manipulation-0.48/data_manipulation/postgres_.py +186 -0
  14. data_manipulation-0.48/data_manipulation/smtplib_.py +82 -0
  15. {data_manipulation-0.46 → data_manipulation-0.48}/data_manipulation/sqlalchemy_.py +71 -10
  16. {data_manipulation-0.46 → data_manipulation-0.48/data_manipulation.egg-info}/PKG-INFO +1 -1
  17. {data_manipulation-0.46 → data_manipulation-0.48}/data_manipulation.egg-info/SOURCES.txt +1 -2
  18. data_manipulation-0.46/data_manipulation/boto3_.py +0 -123
  19. data_manipulation-0.46/data_manipulation/cryptography_.py +0 -97
  20. data_manipulation-0.46/data_manipulation/django_.py +0 -140
  21. data_manipulation-0.46/data_manipulation/geopandas_.py +0 -44
  22. data_manipulation-0.46/data_manipulation/kerberos_.py +0 -51
  23. data_manipulation-0.46/data_manipulation/mysql_connector_python_.py +0 -98
  24. data_manipulation-0.46/data_manipulation/openldap_.py +0 -56
  25. data_manipulation-0.46/data_manipulation/psycopg2_.py +0 -247
  26. data_manipulation-0.46/data_manipulation/psycopg_.py +0 -101
  27. data_manipulation-0.46/data_manipulation/smtplib_.py +0 -86
  28. {data_manipulation-0.46 → data_manipulation-0.48}/LICENSE +0 -0
  29. {data_manipulation-0.46 → data_manipulation-0.48}/MANIFEST.in +0 -0
  30. {data_manipulation-0.46 → data_manipulation-0.48}/README.md +0 -0
  31. {data_manipulation-0.46 → data_manipulation-0.48}/data_manipulation/flask_.py +0 -0
  32. {data_manipulation-0.46 → data_manipulation-0.48}/data_manipulation/pandas_.py +0 -0
  33. {data_manipulation-0.46 → data_manipulation-0.48}/data_manipulation/prometheus_.py +0 -0
  34. {data_manipulation-0.46 → data_manipulation-0.48}/data_manipulation/pyspark_.py +0 -0
  35. {data_manipulation-0.46 → data_manipulation-0.48}/data_manipulation.egg-info/dependency_links.txt +0 -0
  36. {data_manipulation-0.46 → data_manipulation-0.48}/data_manipulation.egg-info/requires.txt +0 -0
  37. {data_manipulation-0.46 → data_manipulation-0.48}/data_manipulation.egg-info/top_level.txt +0 -0
  38. {data_manipulation-0.46 → data_manipulation-0.48}/setup.cfg +0 -0
  39. {data_manipulation-0.46 → data_manipulation-0.48}/setup.py +0 -0
  40. {data_manipulation-0.46 → data_manipulation-0.48}/versioneer.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: data_manipulation
3
- Version: 0.46
3
+ Version: 0.48
4
4
  Summary: Powerful data manipulation
5
5
  Home-page: https://github.com/shawnngtq/data-manipulation
6
6
  Author: Shawn Ng
@@ -10,9 +10,8 @@ from data_manipulation import (
10
10
  mysql_connector_python_,
11
11
  openldap_,
12
12
  pandas_,
13
+ postgres_,
13
14
  prometheus_,
14
- psycopg2_,
15
- psycopg_,
16
15
  pyspark_,
17
16
  smtplib_,
18
17
  sqlalchemy_,
@@ -8,11 +8,11 @@ import json
8
8
 
9
9
  version_json = '''
10
10
  {
11
- "date": "2024-11-24T20:58:50+0800",
11
+ "date": "2024-12-02T10:43:01+0800",
12
12
  "dirty": false,
13
13
  "error": null,
14
- "full-revisionid": "8d4f86d6f48df8cc57789006ba9a8b8ee47ab4c2",
15
- "version": "0.46"
14
+ "full-revisionid": "773fa65b8cf0c0897344e60183a793418eca4432",
15
+ "version": "0.48"
16
16
  }
17
17
  ''' # END VERSION_JSON
18
18
 
@@ -2,25 +2,46 @@ import itertools
2
2
  import os
3
3
  import re
4
4
  import subprocess
5
- from typing import List
5
+ from functools import wraps
6
+ from pathlib import Path
7
+ from typing import Any, Dict, List, Optional, Tuple, Union
8
+ from urllib.parse import urlencode, urlparse, urlunparse
6
9
 
7
10
  from loguru import logger
8
11
 
9
12
 
13
+ def deprecated(func):
14
+ """Decorator to mark functions as deprecated."""
15
+
16
+ @wraps(func)
17
+ def wrapper(*args, **kwargs):
18
+ logger.warning(
19
+ f"Function {func.__name__} is deprecated and will be removed in future versions"
20
+ )
21
+ return func(*args, **kwargs)
22
+
23
+ return wrapper
24
+
25
+
10
26
  # DATA STRUCTURE
11
27
  def clean_string(
12
- string: str, remove_parenthesis: bool = False, remove_brackets: bool = False
28
+ string: str,
29
+ remove_parenthesis: bool = False,
30
+ remove_brackets: bool = False,
13
31
  ) -> str:
14
32
  """Cleans and standardizes input string.
15
33
 
16
34
  Args:
17
35
  string (str): String to clean.
18
- remove_parenthesis (bool, optional): Whether to remove content within parentheses. Defaults to False.
19
- remove_brackets (bool, optional): Whether to remove content within square brackets. Defaults to False.
36
+ remove_parenthesis (bool): Whether to remove content within parentheses.
37
+ remove_brackets (bool): Whether to remove content within square brackets.
20
38
 
21
39
  Returns:
22
40
  str: Uppercase cleaned string with standardized spacing.
23
41
 
42
+ Raises:
43
+ ValueError: If input string is None or empty.
44
+
24
45
  Examples:
25
46
  >>> clean_string(" sHawn tesT ")
26
47
  'SHAWN TEST'
@@ -31,35 +52,37 @@ def clean_string(
31
52
  >>> clean_string("shawn [ te st ]", remove_brackets=True)
32
53
  'SHAWN'
33
54
  """
55
+ if not string or not isinstance(string, str):
56
+ raise ValueError("Input must be a non-empty string")
34
57
 
35
58
  if remove_parenthesis:
36
59
  string = re.sub(r"\(.*\)", "", string)
37
60
  if remove_brackets:
38
61
  string = re.sub(r"\[.*\]", "", string)
39
62
 
40
- string = string.strip().upper()
41
- string = " ".join(string.split())
42
- return string
63
+ return " ".join(string.strip().upper().split())
43
64
 
44
65
 
45
- def get_string_case_combination(str_: str) -> list:
66
+ def get_string_case_combination(str_: str) -> List[str]:
46
67
  """Generates all possible case combinations of a string.
47
68
 
48
69
  Args:
49
70
  str_ (str): Input string to generate combinations for.
50
71
 
51
72
  Returns:
52
- list: List of all possible case combinations.
73
+ List[str]: List of all possible case combinations.
53
74
 
54
75
  Examples:
55
76
  >>> get_string_case_combination("abc")
56
77
  ['ABC', 'ABc', 'AbC', 'Abc', 'aBC', 'aBc', 'abC', 'abc']
57
78
  """
79
+ if not str_:
80
+ raise ValueError("Input string cannot be empty or None")
58
81
 
59
82
  return list(map("".join, itertools.product(*zip(str_.upper(), str_.lower()))))
60
83
 
61
84
 
62
- def get_none_variation() -> list:
85
+ def get_none_variation() -> List[Union[None, str]]:
63
86
  """Returns a list of common variations of None/null values.
64
87
 
65
88
  Returns:
@@ -126,14 +149,14 @@ def get_country_code_variation() -> dict:
126
149
  return variations
127
150
 
128
151
 
129
- def list_tuple_without_none(list_tuple: list | tuple) -> list | tuple:
152
+ def list_tuple_without_none(list_tuple: Union[List, Tuple]) -> Union[List, Tuple]:
130
153
  """Removes None variations from a list or tuple.
131
154
 
132
155
  Args:
133
- list_tuple (list | tuple): Input list or tuple to clean.
156
+ list_tuple: Input list or tuple to clean.
134
157
 
135
158
  Returns:
136
- list | tuple: List or tuple with None variations removed.
159
+ Union[List, Tuple]: Cleaned list or tuple.
137
160
 
138
161
  Raises:
139
162
  TypeError: If input is not a list or tuple.
@@ -144,21 +167,20 @@ def list_tuple_without_none(list_tuple: list | tuple) -> list | tuple:
144
167
  >>> list_tuple_without_none(("a", "none"))
145
168
  ('a',)
146
169
  """
170
+ if not isinstance(list_tuple, (list, tuple)):
171
+ raise TypeError("Input must be a list or tuple")
172
+
147
173
  none_variations = get_none_variation()
174
+
148
175
  if isinstance(list_tuple, list):
149
- lt = [item for item in list_tuple if item and item not in none_variations]
150
- elif isinstance(list_tuple, tuple):
151
- lt = tuple(item for item in list_tuple if item and item not in none_variations)
152
- else:
153
- raise TypeError("Wrong datatype(s)")
154
- return lt
176
+ return [item for item in list_tuple if item and item not in none_variations]
177
+ return tuple(item for item in list_tuple if item and item not in none_variations)
155
178
 
156
179
 
180
+ @deprecated
157
181
  def string_boolean_to_int(boolean_str_rep: str) -> int:
158
182
  """Converts string boolean representations to integers.
159
183
 
160
- Deprecated: Will be removed in Python 3.12+
161
-
162
184
  Args:
163
185
  boolean_str_rep (str): String representation of boolean value.
164
186
 
@@ -171,20 +193,24 @@ def string_boolean_to_int(boolean_str_rep: str) -> int:
171
193
  >>> string_boolean_to_int("True")
172
194
  1
173
195
  """
174
- from distutils.util import strtobool
196
+ try:
197
+ from distutils.util import strtobool
175
198
 
176
- int_ = strtobool(string_str_to_str(boolean_str_rep))
177
- return int_
199
+ return strtobool(string_str_to_str(boolean_str_rep))
200
+ except ValueError as e:
201
+ raise ValueError(
202
+ f"Invalid boolean string representation: {boolean_str_rep}"
203
+ ) from e
178
204
 
179
205
 
180
- def string_dlt_to_dlt(dlt_str_rep: str) -> dict | list | tuple:
206
+ def string_dlt_to_dlt(dlt_str_rep: str) -> Union[Dict, List, Tuple]:
181
207
  """Converts string representation of data structures to actual Python objects.
182
208
 
183
209
  Args:
184
210
  dlt_str_rep (str): String representation of dictionary/list/tuple.
185
211
 
186
212
  Returns:
187
- dict | list | tuple: Converted Python data structure.
213
+ Union[Dict, List, Tuple]: Converted Python data structure.
188
214
 
189
215
  Examples:
190
216
  >>> string_dlt_to_dlt("[1, 2, 3]")
@@ -194,10 +220,12 @@ def string_dlt_to_dlt(dlt_str_rep: str) -> dict | list | tuple:
194
220
  >>> string_dlt_to_dlt("('1', '2', '3')")
195
221
  ('1', '2', '3')
196
222
  """
197
- from ast import literal_eval
223
+ try:
224
+ from ast import literal_eval
198
225
 
199
- dlt = literal_eval(dlt_str_rep)
200
- return dlt
226
+ return literal_eval(dlt_str_rep)
227
+ except (ValueError, SyntaxError) as e:
228
+ raise ValueError(f"Invalid data structure string: {dlt_str_rep}") from e
201
229
 
202
230
 
203
231
  def string_str_to_str(string_str_rep: str) -> str:
@@ -215,11 +243,15 @@ def string_str_to_str(string_str_rep: str) -> str:
215
243
  >>> string_str_to_str('"test"')
216
244
  'test'
217
245
  """
218
- str_ = string_str_rep.strip("'\"")
219
- return str_
246
+ if not string_str_rep:
247
+ raise ValueError("Input string cannot be empty or None")
248
+ return string_str_rep.strip("'\"")
220
249
 
221
250
 
222
- def delete_list_indices(list_: list, indices: list) -> None:
251
+ def delete_list_indices(
252
+ list_: list,
253
+ indices: List[int],
254
+ ) -> None:
223
255
  """Deletes multiple indices from a list in-place.
224
256
 
225
257
  Args:
@@ -237,7 +269,10 @@ def delete_list_indices(list_: list, indices: list) -> None:
237
269
 
238
270
 
239
271
  # FILESYSTEM
240
- def get_path_files(path: str, keywords: list) -> list:
272
+ def get_path_files(
273
+ path: Union[str, Path],
274
+ keywords: List[str],
275
+ ) -> List[str]:
241
276
  """Returns sorted list of files from given path that contain specified keywords.
242
277
 
243
278
  Args:
@@ -245,22 +280,29 @@ def get_path_files(path: str, keywords: list) -> list:
245
280
  keywords (list): List of keywords to match in filenames.
246
281
 
247
282
  Returns:
248
- list: Sorted list of matching filenames.
283
+ List[str]: Sorted list of matching filenames.
249
284
 
250
285
  Examples:
251
286
  >>> get_path_files("test_base_folder", ["py"])
252
287
  ['test1.py', 'test2.py', 'test3.py', 'test4.py', 'test5.py']
253
288
  """
254
-
255
- list_ = []
256
- for file in sorted(os.listdir(path)):
257
- if any(word in file for word in keywords):
258
- list_.append(file)
259
- return list_
289
+ path = Path(path)
290
+ if not path.exists():
291
+ raise FileNotFoundError(f"Path does not exist: {path}")
292
+
293
+ return sorted(
294
+ f.name
295
+ for f in path.iterdir()
296
+ if f.is_file() and any(word in f.name for word in keywords)
297
+ )
260
298
 
261
299
 
262
- def remove_path_file(path: str, keyword: str, n: int = 2) -> None:
263
- """Removes all but the n newest files matching the keyword from the specified path.
300
+ def remove_path_file(
301
+ path: Union[str, Path],
302
+ keyword: str,
303
+ n: int = 2,
304
+ ) -> None:
305
+ """Removes all but the n newest files matching the keyword.
264
306
 
265
307
  Args:
266
308
  path (str): Directory path.
@@ -270,14 +312,26 @@ def remove_path_file(path: str, keyword: str, n: int = 2) -> None:
270
312
  Examples:
271
313
  >>> remove_path_file("test_base_folder", ".py")
272
314
  """
315
+ if n < 0:
316
+ raise ValueError("n must be non-negative")
273
317
 
318
+ path = Path(path)
274
319
  to_delete = get_path_files(path=path, keywords=[keyword])[:-n]
275
- for file in to_delete:
276
- os.remove(f"{path}/{file}")
277
- logger.info(f"{path}/{file} deleted ...")
278
320
 
279
-
280
- def list_to_file(filepath: str, list_: list, newline: bool = True) -> None:
321
+ for file in to_delete:
322
+ try:
323
+ file_path = path / file
324
+ file_path.unlink()
325
+ logger.info(f"Deleted file: {file_path}")
326
+ except OSError as e:
327
+ logger.error(f"Failed to delete {file_path}: {e}")
328
+
329
+
330
+ def list_to_file(
331
+ filepath: Union[str, Path],
332
+ list_: List,
333
+ newline: bool = True,
334
+ ) -> None:
281
335
  """Writes list contents to a file.
282
336
 
283
337
  Args:
@@ -288,16 +342,24 @@ def list_to_file(filepath: str, list_: list, newline: bool = True) -> None:
288
342
  Examples:
289
343
  >>> list_to_file("test.txt", [1, 2, 3])
290
344
  """
291
- f = open(filepath, "w")
292
- for line in list_:
293
- f.write(str(line))
294
- if newline:
295
- f.write("\n")
296
- f.close()
345
+ filepath = Path(filepath)
346
+ try:
347
+ with filepath.open("w") as f:
348
+ for line in list_:
349
+ f.write(str(line))
350
+ if newline:
351
+ f.write("\n")
352
+ logger.info(f"Successfully wrote to file: {filepath}")
353
+ except IOError as e:
354
+ logger.error(f"Failed to write to file {filepath}: {e}")
355
+ raise
297
356
 
298
357
 
299
358
  # URLLIB
300
- def create_encode_url(url: str, query_params: dict = {}) -> str:
359
+ def create_encode_url(
360
+ url: str,
361
+ query_params: Optional[Dict[str, Any]] = None,
362
+ ) -> str:
301
363
  """Creates an encoded URL with query parameters.
302
364
 
303
365
  Args:
@@ -306,67 +368,83 @@ def create_encode_url(url: str, query_params: dict = {}) -> str:
306
368
 
307
369
  Returns:
308
370
  str: Encoded URL with query parameters.
309
- """
310
- from urllib.parse import urlencode
311
371
 
312
- return f"{url}{urlencode(query_params)}"
372
+ Raises:
373
+ ValueError: If URL is invalid.
374
+ """
375
+ query_params = query_params or {}
376
+
377
+ try:
378
+ parsed = urlparse(url)
379
+ if not parsed.scheme or not parsed.netloc:
380
+ raise ValueError("Invalid URL format")
381
+
382
+ return urlunparse(
383
+ (
384
+ parsed.scheme,
385
+ parsed.netloc,
386
+ parsed.path,
387
+ parsed.params,
388
+ urlencode(query_params),
389
+ parsed.fragment,
390
+ )
391
+ )
392
+ except Exception as e:
393
+ logger.error(f"Failed to encode URL {url}: {e}")
394
+ raise
313
395
 
314
396
 
315
397
  # SYSTEM
316
- def parse_ps_aux(ps_aux_commands: str) -> List[list]:
398
+ def parse_ps_aux(ps_aux_commands: str) -> List[List[str]]:
317
399
  """Parses Linux ps aux command output into a list of records.
318
400
 
319
401
  Args:
320
402
  ps_aux_commands (str): Linux ps aux command string.
321
403
 
322
404
  Returns:
323
- List[list]: List of lists, where each inner list represents a process record.
405
+ List[List[str]]: List of process records.
324
406
 
325
407
  Examples:
326
408
  >>> # parse_ps_aux("ps aux | egrep -i '%cpu|anaconda3' | head")
327
409
  """
328
- output = subprocess.run(
329
- ps_aux_commands,
330
- capture_output=True,
331
- text=True,
332
- shell=True,
333
- executable="/bin/bash",
334
- )
335
- lines = output.stdout.split("\n")
336
- n_columns = len(lines[0].split()) - 1
337
- rows = [line.split(None, n_columns) for line in lines if line]
338
- return rows
410
+ try:
411
+ output = subprocess.run(
412
+ ps_aux_commands,
413
+ capture_output=True,
414
+ text=True,
415
+ shell=True,
416
+ executable="/bin/bash",
417
+ check=True,
418
+ )
419
+ lines = output.stdout.strip().split("\n")
420
+ if not lines:
421
+ return []
422
+
423
+ n_columns = len(lines[0].split()) - 1
424
+ return [line.split(None, n_columns) for line in lines if line]
425
+ except subprocess.SubprocessError as e:
426
+ logger.error(f"Failed to execute command: {e}")
427
+ raise
339
428
 
340
429
 
341
430
  if __name__ == "__main__":
342
431
  import doctest
343
432
 
344
- subprocess.run(
345
- "mkdir -p test_base_folder",
346
- capture_output=True,
347
- shell=True,
348
- text=True,
349
- executable="/bin/bash",
350
- )
351
- subprocess.run(
352
- "touch test_base_folder/test{1..5}.py",
353
- capture_output=True,
354
- shell=True,
355
- text=True,
356
- executable="/bin/bash",
357
- )
358
- doctest.testmod()
359
- subprocess.run(
360
- "rm -rf test_base_folder",
361
- capture_output=True,
362
- shell=True,
363
- text=True,
364
- executable="/bin/bash",
365
- )
366
- subprocess.run(
367
- "rm test.txt",
368
- capture_output=True,
369
- shell=True,
370
- text=True,
371
- executable="/bin/bash",
372
- )
433
+ # Setup test environment
434
+ test_folder = Path("test_base_folder")
435
+ try:
436
+ test_folder.mkdir(exist_ok=True)
437
+ for i in range(1, 6):
438
+ (test_folder / f"test{i}.py").touch()
439
+
440
+ doctest.testmod()
441
+ finally:
442
+ # Cleanup
443
+ if test_folder.exists():
444
+ for file in test_folder.iterdir():
445
+ file.unlink()
446
+ test_folder.rmdir()
447
+
448
+ test_file = Path("test.txt")
449
+ if test_file.exists():
450
+ test_file.unlink()
@@ -1,8 +1,17 @@
1
1
  import re
2
- from typing import Optional
2
+ from typing import TYPE_CHECKING, Optional
3
3
 
4
4
  from loguru import logger
5
5
 
6
+ if TYPE_CHECKING:
7
+ from bs4 import BeautifulSoup
8
+
9
+ # Constants
10
+ DEFAULT_TIMEOUT = 10
11
+ DEFAULT_HEADERS = {
12
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"
13
+ }
14
+
6
15
 
7
16
  def preprocess(html: str) -> Optional[str]:
8
17
  """Removes whitespaces and newline characters from HTML string.
@@ -34,13 +43,21 @@ def preprocess(html: str) -> Optional[str]:
34
43
  return html
35
44
 
36
45
 
37
- def build_soup(url: str, features: str = "lxml", to_preprocess: bool = True):
46
+ def build_soup(
47
+ url: str,
48
+ features: str = "lxml",
49
+ to_preprocess: bool = True,
50
+ timeout: int = DEFAULT_TIMEOUT,
51
+ headers: Optional[dict] = None,
52
+ ) -> "Optional[BeautifulSoup]":
38
53
  """Creates a BeautifulSoup object from a given URL.
39
54
 
40
55
  Args:
41
56
  url (str): URL to fetch and parse.
42
57
  features (str, optional): Parser to use. Defaults to "lxml".
43
58
  to_preprocess (bool, optional): Whether to preprocess the HTML. Defaults to True.
59
+ timeout (int, optional): Request timeout in seconds. Defaults to 10.
60
+ headers (Optional[dict], optional): Custom headers for the request. Defaults to None.
44
61
 
45
62
  Returns:
46
63
  Optional[BeautifulSoup]: Parsed BeautifulSoup object, or None if request fails.
@@ -56,15 +73,25 @@ def build_soup(url: str, features: str = "lxml", to_preprocess: bool = True):
56
73
  import requests
57
74
  from bs4 import BeautifulSoup
58
75
 
59
- response = requests.get(url)
60
- if response.status_code == 200:
61
- if to_preprocess:
62
- soup = BeautifulSoup(preprocess(response.text), features=features)
63
- else:
64
- soup = BeautifulSoup(response.text, features=features)
65
- return soup
66
- else:
67
- logger.error(f"response status code: {response.status_code}")
76
+ if not url or not url.strip():
77
+ raise ValueError("URL cannot be empty")
78
+
79
+ if not url.startswith(("http://", "https://")):
80
+ raise ValueError("URL must start with http:// or https://")
81
+
82
+ request_headers = headers or DEFAULT_HEADERS
83
+
84
+ try:
85
+ with requests.Session() as session:
86
+ response = session.get(url, headers=request_headers, timeout=timeout)
87
+ response.raise_for_status()
88
+
89
+ html_content = preprocess(response.text) if to_preprocess else response.text
90
+ return BeautifulSoup(html_content, features=features)
91
+
92
+ except requests.RequestException as e:
93
+ logger.error(f"Failed to fetch URL {url}: {str(e)}")
94
+ return None
68
95
 
69
96
 
70
97
  if __name__ == "__main__":