data-manipulation 0.45__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 (45) hide show
  1. {data_manipulation-0.45/data_manipulation.egg-info → data_manipulation-0.48}/PKG-INFO +1 -1
  2. {data_manipulation-0.45 → data_manipulation-0.48}/data_manipulation/__init__.py +1 -2
  3. {data_manipulation-0.45 → data_manipulation-0.48}/data_manipulation/_version.py +3 -3
  4. data_manipulation-0.48/data_manipulation/base.py +450 -0
  5. data_manipulation-0.48/data_manipulation/beautifulsoup_.py +100 -0
  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/pandas_.py +732 -0
  14. data_manipulation-0.48/data_manipulation/postgres_.py +186 -0
  15. data_manipulation-0.48/data_manipulation/pyspark_.py +446 -0
  16. data_manipulation-0.48/data_manipulation/smtplib_.py +82 -0
  17. data_manipulation-0.48/data_manipulation/sqlalchemy_.py +183 -0
  18. {data_manipulation-0.45 → data_manipulation-0.48/data_manipulation.egg-info}/PKG-INFO +1 -1
  19. {data_manipulation-0.45 → data_manipulation-0.48}/data_manipulation.egg-info/SOURCES.txt +1 -2
  20. data_manipulation-0.45/data_manipulation/base.py +0 -475
  21. data_manipulation-0.45/data_manipulation/beautifulsoup_.py +0 -81
  22. data_manipulation-0.45/data_manipulation/boto3_.py +0 -130
  23. data_manipulation-0.45/data_manipulation/cryptography_.py +0 -91
  24. data_manipulation-0.45/data_manipulation/django_.py +0 -127
  25. data_manipulation-0.45/data_manipulation/geopandas_.py +0 -42
  26. data_manipulation-0.45/data_manipulation/kerberos_.py +0 -45
  27. data_manipulation-0.45/data_manipulation/mysql_connector_python_.py +0 -96
  28. data_manipulation-0.45/data_manipulation/openldap_.py +0 -26
  29. data_manipulation-0.45/data_manipulation/pandas_.py +0 -864
  30. data_manipulation-0.45/data_manipulation/psycopg2_.py +0 -111
  31. data_manipulation-0.45/data_manipulation/psycopg_.py +0 -116
  32. data_manipulation-0.45/data_manipulation/pyspark_.py +0 -414
  33. data_manipulation-0.45/data_manipulation/smtplib_.py +0 -72
  34. data_manipulation-0.45/data_manipulation/sqlalchemy_.py +0 -98
  35. {data_manipulation-0.45 → data_manipulation-0.48}/LICENSE +0 -0
  36. {data_manipulation-0.45 → data_manipulation-0.48}/MANIFEST.in +0 -0
  37. {data_manipulation-0.45 → data_manipulation-0.48}/README.md +0 -0
  38. {data_manipulation-0.45 → data_manipulation-0.48}/data_manipulation/flask_.py +0 -0
  39. {data_manipulation-0.45 → data_manipulation-0.48}/data_manipulation/prometheus_.py +0 -0
  40. {data_manipulation-0.45 → data_manipulation-0.48}/data_manipulation.egg-info/dependency_links.txt +0 -0
  41. {data_manipulation-0.45 → data_manipulation-0.48}/data_manipulation.egg-info/requires.txt +0 -0
  42. {data_manipulation-0.45 → data_manipulation-0.48}/data_manipulation.egg-info/top_level.txt +0 -0
  43. {data_manipulation-0.45 → data_manipulation-0.48}/setup.cfg +0 -0
  44. {data_manipulation-0.45 → data_manipulation-0.48}/setup.py +0 -0
  45. {data_manipulation-0.45 → 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.45
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-09-08T09:24:53+0800",
11
+ "date": "2024-12-02T10:43:01+0800",
12
12
  "dirty": false,
13
13
  "error": null,
14
- "full-revisionid": "60bea1e3117837fae0b5506910b8006d348b5b81",
15
- "version": "0.45"
14
+ "full-revisionid": "773fa65b8cf0c0897344e60183a793418eca4432",
15
+ "version": "0.48"
16
16
  }
17
17
  ''' # END VERSION_JSON
18
18
 
@@ -0,0 +1,450 @@
1
+ import itertools
2
+ import os
3
+ import re
4
+ import subprocess
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
9
+
10
+ from loguru import logger
11
+
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
+
26
+ # DATA STRUCTURE
27
+ def clean_string(
28
+ string: str,
29
+ remove_parenthesis: bool = False,
30
+ remove_brackets: bool = False,
31
+ ) -> str:
32
+ """Cleans and standardizes input string.
33
+
34
+ Args:
35
+ string (str): String to clean.
36
+ remove_parenthesis (bool): Whether to remove content within parentheses.
37
+ remove_brackets (bool): Whether to remove content within square brackets.
38
+
39
+ Returns:
40
+ str: Uppercase cleaned string with standardized spacing.
41
+
42
+ Raises:
43
+ ValueError: If input string is None or empty.
44
+
45
+ Examples:
46
+ >>> clean_string(" sHawn tesT ")
47
+ 'SHAWN TEST'
48
+ >>> clean_string("shawn ( te st )")
49
+ 'SHAWN ( TE ST )'
50
+ >>> clean_string("shawn ( te st )", remove_parenthesis=True)
51
+ 'SHAWN'
52
+ >>> clean_string("shawn [ te st ]", remove_brackets=True)
53
+ 'SHAWN'
54
+ """
55
+ if not string or not isinstance(string, str):
56
+ raise ValueError("Input must be a non-empty string")
57
+
58
+ if remove_parenthesis:
59
+ string = re.sub(r"\(.*\)", "", string)
60
+ if remove_brackets:
61
+ string = re.sub(r"\[.*\]", "", string)
62
+
63
+ return " ".join(string.strip().upper().split())
64
+
65
+
66
+ def get_string_case_combination(str_: str) -> List[str]:
67
+ """Generates all possible case combinations of a string.
68
+
69
+ Args:
70
+ str_ (str): Input string to generate combinations for.
71
+
72
+ Returns:
73
+ List[str]: List of all possible case combinations.
74
+
75
+ Examples:
76
+ >>> get_string_case_combination("abc")
77
+ ['ABC', 'ABc', 'AbC', 'Abc', 'aBC', 'aBc', 'abC', 'abc']
78
+ """
79
+ if not str_:
80
+ raise ValueError("Input string cannot be empty or None")
81
+
82
+ return list(map("".join, itertools.product(*zip(str_.upper(), str_.lower()))))
83
+
84
+
85
+ def get_none_variation() -> List[Union[None, str]]:
86
+ """Returns a list of common variations of None/null values.
87
+
88
+ Returns:
89
+ list: List containing None and various string representations of null values.
90
+
91
+ Examples:
92
+ >>> get_none_variation()
93
+ [None, 'NONE', 'NONe', 'NOnE', 'NOne', 'NoNE', 'NoNe', 'NonE', 'None', 'nONE', 'nONe', 'nOnE', 'nOne', 'noNE', 'noNe', 'nonE', 'none', 'NULL', 'NULl', 'NUlL', 'NUll', 'NuLL', 'NuLl', 'NulL', 'Null', 'nULL', 'nULl', 'nUlL', 'nUll', 'nuLL', 'nuLl', 'nulL', 'null', 'NA', 'Na', 'nA', 'na', 'N.A', 'N.a', 'N.A', 'N.a', 'n.A', 'n.a', 'n.A', 'n.a', 'N.A.', 'N.A.', 'N.a.', 'N.a.', 'N.A.', 'N.A.', 'N.a.', 'N.a.', 'n.A.', 'n.A.', 'n.a.', 'n.a.', 'n.A.', 'n.A.', 'n.a.', 'n.a.', 'NAN', 'NAn', 'NaN', 'Nan', 'nAN', 'nAn', 'naN', 'nan', 'NIL', 'NIl', 'NiL', 'Nil', 'nIL', 'nIl', 'niL', 'nil']
94
+ """
95
+ variations = (
96
+ [None]
97
+ + get_string_case_combination("none")
98
+ + get_string_case_combination("null")
99
+ + get_string_case_combination("na")
100
+ + get_string_case_combination("n.a")
101
+ + get_string_case_combination("n.a.")
102
+ + get_string_case_combination("nan")
103
+ + get_string_case_combination("nil")
104
+ )
105
+ return variations
106
+
107
+
108
+ def get_country_name_variation() -> dict:
109
+ """Returns a dictionary mapping country names to their codes.
110
+
111
+ Returns:
112
+ dict: Dictionary with country names as keys and country codes as values.
113
+ """
114
+ variations = {
115
+ "AFRICA": "BW",
116
+ "BOSNIA": "BA",
117
+ "CZECH REPUBLIC": "CZ",
118
+ "MACEDONIA": "MK",
119
+ "REPUBLIC OF CHINA": "CN",
120
+ "REPUBLIC OF KOREA": "KR",
121
+ "RUSSIAN FEDERATION": "RU",
122
+ "SLAVONIC": "SK",
123
+ "SLOVAK REPUBLIC": "SK",
124
+ "TURKEY": "TR",
125
+ "TURKIYE": "TR",
126
+ "UNITED STATES": "US",
127
+ }
128
+ return variations
129
+
130
+
131
+ def get_country_code_variation() -> dict:
132
+ """Returns a dictionary mapping country codes to their name variations.
133
+
134
+ Returns:
135
+ dict: Dictionary with country codes as keys and lists of country name variations as values.
136
+ """
137
+ variations = {
138
+ "BA": ["BOSNIA"],
139
+ "BW": ["AFRICA"],
140
+ "CN": ["REPUBLIC OF CHINA"],
141
+ "CZ": ["CZECH REPUBLIC"],
142
+ "KR": ["REPUBLIC OF KOREA"],
143
+ "MK": ["MACEDONIA"],
144
+ "RU": ["RUSSIAN FEDERATION"],
145
+ "SK": ["SLAVONIC", "SLOVAK REPUBLIC"],
146
+ "TR": ["TURKEY", "TURKIYE"],
147
+ "US": ["UNITED STATES"],
148
+ }
149
+ return variations
150
+
151
+
152
+ def list_tuple_without_none(list_tuple: Union[List, Tuple]) -> Union[List, Tuple]:
153
+ """Removes None variations from a list or tuple.
154
+
155
+ Args:
156
+ list_tuple: Input list or tuple to clean.
157
+
158
+ Returns:
159
+ Union[List, Tuple]: Cleaned list or tuple.
160
+
161
+ Raises:
162
+ TypeError: If input is not a list or tuple.
163
+
164
+ Examples:
165
+ >>> list_tuple_without_none(["a", "none"])
166
+ ['a']
167
+ >>> list_tuple_without_none(("a", "none"))
168
+ ('a',)
169
+ """
170
+ if not isinstance(list_tuple, (list, tuple)):
171
+ raise TypeError("Input must be a list or tuple")
172
+
173
+ none_variations = get_none_variation()
174
+
175
+ if isinstance(list_tuple, list):
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)
178
+
179
+
180
+ @deprecated
181
+ def string_boolean_to_int(boolean_str_rep: str) -> int:
182
+ """Converts string boolean representations to integers.
183
+
184
+ Args:
185
+ boolean_str_rep (str): String representation of boolean value.
186
+
187
+ Returns:
188
+ int: 1 for true values, 0 for false values.
189
+
190
+ Examples:
191
+ >>> string_boolean_to_int("true")
192
+ 1
193
+ >>> string_boolean_to_int("True")
194
+ 1
195
+ """
196
+ try:
197
+ from distutils.util import strtobool
198
+
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
204
+
205
+
206
+ def string_dlt_to_dlt(dlt_str_rep: str) -> Union[Dict, List, Tuple]:
207
+ """Converts string representation of data structures to actual Python objects.
208
+
209
+ Args:
210
+ dlt_str_rep (str): String representation of dictionary/list/tuple.
211
+
212
+ Returns:
213
+ Union[Dict, List, Tuple]: Converted Python data structure.
214
+
215
+ Examples:
216
+ >>> string_dlt_to_dlt("[1, 2, 3]")
217
+ [1, 2, 3]
218
+ >>> string_dlt_to_dlt("{'a': 1, 'b': 2}")
219
+ {'a': 1, 'b': 2}
220
+ >>> string_dlt_to_dlt("('1', '2', '3')")
221
+ ('1', '2', '3')
222
+ """
223
+ try:
224
+ from ast import literal_eval
225
+
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
229
+
230
+
231
+ def string_str_to_str(string_str_rep: str) -> str:
232
+ """Converts string representation to a clean string by removing quotes.
233
+
234
+ Args:
235
+ string_str_rep (str): String representation to clean.
236
+
237
+ Returns:
238
+ str: Cleaned string with outer quotes removed.
239
+
240
+ Examples:
241
+ >>> string_str_to_str("'test'")
242
+ 'test'
243
+ >>> string_str_to_str('"test"')
244
+ 'test'
245
+ """
246
+ if not string_str_rep:
247
+ raise ValueError("Input string cannot be empty or None")
248
+ return string_str_rep.strip("'\"")
249
+
250
+
251
+ def delete_list_indices(
252
+ list_: list,
253
+ indices: List[int],
254
+ ) -> None:
255
+ """Deletes multiple indices from a list in-place.
256
+
257
+ Args:
258
+ list_ (list): Original list to modify.
259
+ indices (list): List of indices to delete.
260
+
261
+ Examples:
262
+ >>> values = [0, 1, 2, 3, 4]
263
+ >>> delete_list_indices(values, [1, 3])
264
+ >>> values
265
+ [0, 2, 4]
266
+ """
267
+ for index in sorted(indices, reverse=True):
268
+ del list_[index]
269
+
270
+
271
+ # FILESYSTEM
272
+ def get_path_files(
273
+ path: Union[str, Path],
274
+ keywords: List[str],
275
+ ) -> List[str]:
276
+ """Returns sorted list of files from given path that contain specified keywords.
277
+
278
+ Args:
279
+ path (str): Directory path to search.
280
+ keywords (list): List of keywords to match in filenames.
281
+
282
+ Returns:
283
+ List[str]: Sorted list of matching filenames.
284
+
285
+ Examples:
286
+ >>> get_path_files("test_base_folder", ["py"])
287
+ ['test1.py', 'test2.py', 'test3.py', 'test4.py', 'test5.py']
288
+ """
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
+ )
298
+
299
+
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.
306
+
307
+ Args:
308
+ path (str): Directory path.
309
+ keyword (str): Keyword to match in filenames.
310
+ n (int, optional): Number of newest files to keep. Defaults to 2.
311
+
312
+ Examples:
313
+ >>> remove_path_file("test_base_folder", ".py")
314
+ """
315
+ if n < 0:
316
+ raise ValueError("n must be non-negative")
317
+
318
+ path = Path(path)
319
+ to_delete = get_path_files(path=path, keywords=[keyword])[:-n]
320
+
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:
335
+ """Writes list contents to a file.
336
+
337
+ Args:
338
+ filepath (str): Path to output file.
339
+ list_ (list): List of values to write.
340
+ newline (bool, optional): Whether to add newline after each item. Defaults to True.
341
+
342
+ Examples:
343
+ >>> list_to_file("test.txt", [1, 2, 3])
344
+ """
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
356
+
357
+
358
+ # URLLIB
359
+ def create_encode_url(
360
+ url: str,
361
+ query_params: Optional[Dict[str, Any]] = None,
362
+ ) -> str:
363
+ """Creates an encoded URL with query parameters.
364
+
365
+ Args:
366
+ url (str): Base URL.
367
+ query_params (dict, optional): Dictionary of URL query parameters. Defaults to {}.
368
+
369
+ Returns:
370
+ str: Encoded URL with query parameters.
371
+
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
395
+
396
+
397
+ # SYSTEM
398
+ def parse_ps_aux(ps_aux_commands: str) -> List[List[str]]:
399
+ """Parses Linux ps aux command output into a list of records.
400
+
401
+ Args:
402
+ ps_aux_commands (str): Linux ps aux command string.
403
+
404
+ Returns:
405
+ List[List[str]]: List of process records.
406
+
407
+ Examples:
408
+ >>> # parse_ps_aux("ps aux | egrep -i '%cpu|anaconda3' | head")
409
+ """
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
428
+
429
+
430
+ if __name__ == "__main__":
431
+ import doctest
432
+
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()
@@ -0,0 +1,100 @@
1
+ import re
2
+ from typing import TYPE_CHECKING, Optional
3
+
4
+ from loguru import logger
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
+
15
+
16
+ def preprocess(html: str) -> Optional[str]:
17
+ """Removes whitespaces and newline characters from HTML string.
18
+
19
+ Args:
20
+ html (str): HTML string to be cleaned.
21
+
22
+ Returns:
23
+ Optional[str]: Cleaned HTML string with normalized whitespace.
24
+
25
+ Examples:
26
+ >>> a = "<html> <p> Something </p> </html> "
27
+ >>> preprocess(a)
28
+ '<html><p>Something</p></html>'
29
+
30
+ Note:
31
+ Reference: https://stackoverflow.com/questions/23241641
32
+ """
33
+
34
+ # remove leading and trailing whitespaces
35
+ pattern = re.compile("(^[\s]+)|([\s]+$)", re.MULTILINE)
36
+ html = re.sub(pattern, "", html)
37
+ # convert newlines to spaces, this preserves newline delimiters
38
+ html = re.sub("\n", " ", html)
39
+ # remove whitespaces before opening tags
40
+ html = re.sub("[\s]+<", "<", html)
41
+ # remove whitespaces after closing tags
42
+ html = re.sub(">[\s]+", ">", html)
43
+ return html
44
+
45
+
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]":
53
+ """Creates a BeautifulSoup object from a given URL.
54
+
55
+ Args:
56
+ url (str): URL to fetch and parse.
57
+ features (str, optional): Parser to use. Defaults to "lxml".
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.
61
+
62
+ Returns:
63
+ Optional[BeautifulSoup]: Parsed BeautifulSoup object, or None if request fails.
64
+
65
+ Examples:
66
+ >>> a = build_soup("https://google.com")
67
+ >>> type(a)
68
+ <class 'bs4.BeautifulSoup'>
69
+
70
+ Note:
71
+ Requires requests and beautifulsoup4 packages.
72
+ """
73
+ import requests
74
+ from bs4 import BeautifulSoup
75
+
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
95
+
96
+
97
+ if __name__ == "__main__":
98
+ import doctest
99
+
100
+ doctest.testmod()