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.
- {data_manipulation-0.46/data_manipulation.egg-info → data_manipulation-0.48}/PKG-INFO +1 -1
- {data_manipulation-0.46 → data_manipulation-0.48}/data_manipulation/__init__.py +1 -2
- {data_manipulation-0.46 → data_manipulation-0.48}/data_manipulation/_version.py +3 -3
- {data_manipulation-0.46 → data_manipulation-0.48}/data_manipulation/base.py +177 -99
- {data_manipulation-0.46 → data_manipulation-0.48}/data_manipulation/beautifulsoup_.py +38 -11
- data_manipulation-0.48/data_manipulation/boto3_.py +184 -0
- data_manipulation-0.48/data_manipulation/cryptography_.py +138 -0
- data_manipulation-0.48/data_manipulation/django_.py +165 -0
- data_manipulation-0.48/data_manipulation/geopandas_.py +78 -0
- data_manipulation-0.48/data_manipulation/kerberos_.py +89 -0
- data_manipulation-0.48/data_manipulation/mysql_connector_python_.py +220 -0
- data_manipulation-0.48/data_manipulation/openldap_.py +88 -0
- data_manipulation-0.48/data_manipulation/postgres_.py +186 -0
- data_manipulation-0.48/data_manipulation/smtplib_.py +82 -0
- {data_manipulation-0.46 → data_manipulation-0.48}/data_manipulation/sqlalchemy_.py +71 -10
- {data_manipulation-0.46 → data_manipulation-0.48/data_manipulation.egg-info}/PKG-INFO +1 -1
- {data_manipulation-0.46 → data_manipulation-0.48}/data_manipulation.egg-info/SOURCES.txt +1 -2
- data_manipulation-0.46/data_manipulation/boto3_.py +0 -123
- data_manipulation-0.46/data_manipulation/cryptography_.py +0 -97
- data_manipulation-0.46/data_manipulation/django_.py +0 -140
- data_manipulation-0.46/data_manipulation/geopandas_.py +0 -44
- data_manipulation-0.46/data_manipulation/kerberos_.py +0 -51
- data_manipulation-0.46/data_manipulation/mysql_connector_python_.py +0 -98
- data_manipulation-0.46/data_manipulation/openldap_.py +0 -56
- data_manipulation-0.46/data_manipulation/psycopg2_.py +0 -247
- data_manipulation-0.46/data_manipulation/psycopg_.py +0 -101
- data_manipulation-0.46/data_manipulation/smtplib_.py +0 -86
- {data_manipulation-0.46 → data_manipulation-0.48}/LICENSE +0 -0
- {data_manipulation-0.46 → data_manipulation-0.48}/MANIFEST.in +0 -0
- {data_manipulation-0.46 → data_manipulation-0.48}/README.md +0 -0
- {data_manipulation-0.46 → data_manipulation-0.48}/data_manipulation/flask_.py +0 -0
- {data_manipulation-0.46 → data_manipulation-0.48}/data_manipulation/pandas_.py +0 -0
- {data_manipulation-0.46 → data_manipulation-0.48}/data_manipulation/prometheus_.py +0 -0
- {data_manipulation-0.46 → data_manipulation-0.48}/data_manipulation/pyspark_.py +0 -0
- {data_manipulation-0.46 → data_manipulation-0.48}/data_manipulation.egg-info/dependency_links.txt +0 -0
- {data_manipulation-0.46 → data_manipulation-0.48}/data_manipulation.egg-info/requires.txt +0 -0
- {data_manipulation-0.46 → data_manipulation-0.48}/data_manipulation.egg-info/top_level.txt +0 -0
- {data_manipulation-0.46 → data_manipulation-0.48}/setup.cfg +0 -0
- {data_manipulation-0.46 → data_manipulation-0.48}/setup.py +0 -0
- {data_manipulation-0.46 → data_manipulation-0.48}/versioneer.py +0 -0
|
@@ -8,11 +8,11 @@ import json
|
|
|
8
8
|
|
|
9
9
|
version_json = '''
|
|
10
10
|
{
|
|
11
|
-
"date": "2024-
|
|
11
|
+
"date": "2024-12-02T10:43:01+0800",
|
|
12
12
|
"dirty": false,
|
|
13
13
|
"error": null,
|
|
14
|
-
"full-revisionid": "
|
|
15
|
-
"version": "0.
|
|
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
|
|
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,
|
|
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
|
|
19
|
-
remove_brackets (bool
|
|
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
|
-
|
|
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) ->
|
|
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
|
-
|
|
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() ->
|
|
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:
|
|
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
|
|
156
|
+
list_tuple: Input list or tuple to clean.
|
|
134
157
|
|
|
135
158
|
Returns:
|
|
136
|
-
|
|
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
|
-
|
|
150
|
-
|
|
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
|
-
|
|
196
|
+
try:
|
|
197
|
+
from distutils.util import strtobool
|
|
175
198
|
|
|
176
|
-
|
|
177
|
-
|
|
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) ->
|
|
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
|
-
|
|
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
|
-
|
|
223
|
+
try:
|
|
224
|
+
from ast import literal_eval
|
|
198
225
|
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
219
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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(
|
|
263
|
-
|
|
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
|
-
|
|
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
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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(
|
|
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
|
-
|
|
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[
|
|
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[
|
|
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
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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(
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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__":
|