Skip to content

Commit 3094931

Browse files
authored
Release 2.3.1 (#92)
1 parent 30ce118 commit 3094931

File tree

10 files changed

+157
-49
lines changed

10 files changed

+157
-49
lines changed

.readthedocs.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ version: 2
99
build:
1010
os: ubuntu-22.04
1111
tools:
12-
python: "3.10"
12+
python: "3.12"
1313

1414
# Build documentation in the docs/ directory with Sphinx (this is the default documentation type)
1515
sphinx:

CHANGELOG.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,25 @@
11
# Changelog / Release notes
22

33

4+
## [2.3.1](https://github.com/mikeqfu/pyhelpers/releases/tag/2.3.1)
5+
6+
(*27 November 2025*)
7+
8+
### Notable [changes](https://github.com/mikeqfu/pyhelpers/compare/2.3.0...2.3.1) since [2.3.0](https://pypi.org/project/pyhelpers/2.3.0/):
9+
10+
- **Bug fixes:**
11+
* Resolved a dependency deprecation issue by migrating `Expr.shrink_dtype()` to `Series.shrink_dtype()` within `downcast_numeric_columns()` (#86).
12+
* Fixed an issue in `download_file_from_url()` where streaming compressed files (Gzip/Deflate) resulted in corrupted output (#88). The fix introduces the **`stream_download`** parameter for controlled, memory-efficient decompression.
13+
- **New features:**
14+
* Added a new function: `get_project_structure()`, a utility for visualizing or generating project file/directory structure.
15+
- **Maintenance & documentation:**
16+
* **Bumped minimum required Python version to 3.12** from 3.10.**.
17+
* Updated build configuration (`.readthedocs.yml`) to use **Python 3.12** for documentation builds, resolving dependency conflicts (e.g. `pyproj`).
18+
* Updated `requirements.txt` to reflect current dependencies.
19+
20+
**For more information and detailed specifications, check out the [PyHelpers 2.3.1 documentation](https://pyhelpers.readthedocs.io/en/2.3.1/).**
21+
22+
423
## [2.3.0](https://github.com/mikeqfu/pyhelpers/releases/tag/2.3.0)
524

625
(*12 July 2025*)

docs/source/requirements.txt

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
furo==2024.8.6
2-
pandas==2.3.1
3-
psycopg2==2.9.10
4-
pyproj==3.7.1
5-
shapely==2.1.1
1+
furo==2025.9.25
2+
pandas==2.3.3
3+
pandas-stubs==2.3.2.250926
4+
psycopg2==2.9.11
5+
pyproj==3.7.2
6+
shapely==2.1.2
67
sphinx-copybutton==0.5.2
7-
sphinx-new-tab-link==0.8.0
8+
sphinx-new-tab-link==0.8.1
89
sphinx-toggleprompt==0.6.0
9-
sqlalchemy==2.0.41
10+
sqlalchemy==2.0.44

pyhelpers/data/.metadata

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
"Author": "Qian Fu",
66
"Affiliation": "School of Engineering, University of Birmingham",
77
"Email": "q.fu@bham.ac.uk",
8-
"Version": "2.3.0",
8+
"Version": "2.3.1",
99
"License": "MIT",
1010
"First release": "September 2019"
1111
}

pyhelpers/ops/downloads.py

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,7 @@ def _download_file_from_url(response, path_to_file, chunk_multiplier=1, desc=Non
168168
def download_file_from_url(url, path_to_file, if_exists='replace', max_retries=5,
169169
requests_session_args=None, verbose=False, print_wrap_limit=None,
170170
chunk_multiplier=1, desc=None, bar_format=None, colour=None,
171-
validate=True, **kwargs):
171+
validate=True, stream_download=False, **kwargs):
172172
# noinspection PyShadowingNames
173173
"""
174174
Downloads a file from a valid URL.
@@ -212,6 +212,11 @@ def download_file_from_url(url, path_to_file, if_exists='replace', max_retries=5
212212
:param validate: Whether to validate if the downloaded file size matches the expected content
213213
length; defaults to ``True``.
214214
:type validate: bool
215+
:param stream_download: When `stream_download=True`, use streaming download
216+
(memory-efficient, perferred for large files);
217+
When `stream_download=False`, not streaming (simpler/faster for small files);
218+
defaults to ``False``.
219+
:type stream_download: bool
215220
:param kwargs: [Optional] Additional parameters passed to the method `tqdm.tqdm()`_.
216221
217222
.. _`tqdm.tqdm()`: https://tqdm.github.io/docs/tqdm/
@@ -272,8 +277,10 @@ def download_file_from_url(url, path_to_file, if_exists='replace', max_retries=5
272277

273278
os.makedirs(os.path.dirname(path_to_file_), exist_ok=True) # Ensure the directory exists
274279

275-
# Streaming, so we can iterate over the response
276-
with session.get(url=url, stream=True, headers=fake_headers) as response:
280+
# If streaming, we can iterate over the response
281+
stream_download_ = verbose or stream_download
282+
283+
with session.get(url=url, stream=stream_download_, headers=fake_headers) as response:
277284
if response.status_code != 200:
278285
print(f"Failed to retrieve file. HTTP Status Code: {response.status_code}.")
279286
return None
@@ -292,8 +299,17 @@ def download_file_from_url(url, path_to_file, if_exists='replace', max_retries=5
292299
)
293300

294301
else:
295-
with open(path_to_file_, mode='wb') as f: # Open the file in binary write mode
296-
shutil.copyfileobj(fsrc=response.raw, fdst=f) # type: ignore
302+
if stream_download_:
303+
encoding = response.headers.get('Content-Encoding', '')
304+
if 'gzip' in encoding or 'deflate' in encoding:
305+
response.raw.decode_content = True
306+
307+
with open(path_to_file_, mode='wb') as f: # Open the file in binary write mode
308+
shutil.copyfileobj(fsrc=response.raw, fdst=f) # type: ignore
309+
310+
else:
311+
with open(path_to_file_, mode='wb') as f:
312+
f.write(response.content)
297313

298314
# Validate download if necessary
299315
if validate and os.stat(path_to_file).st_size == 0:

pyhelpers/ops/general.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,3 +345,71 @@ def get_ansi_colour_code(colours, show_valid_colours=False):
345345
"""
346346

347347
return _get_ansi_colour_code(colours=colours, show_valid_colours=show_valid_colours)
348+
349+
350+
def get_project_structure(start_path, ignore_dirs=None, out_file=None, encoding='utf-8',
351+
print_in_console=True):
352+
"""
353+
Prints and/or writes the directory and file structure of a given project folder
354+
starting from ``start_path``.
355+
356+
The output shows a tree-like hierarchy with branch symbols for better readability.
357+
358+
:param start_path: Path to the root directory whose structure to visualize;
359+
can be absolute or relative to the current working directory.
360+
:type start_path: str | pathlib.Path
361+
:param ignore_dirs: Optional set of directory names to ignore during traversal;
362+
defaults to ``{'__pycache__'}``.
363+
:type ignore_dirs: None | typying.Iterable
364+
:param out_file: Optional file path to write the structure output. If ``None`` (default),
365+
no file will be written.
366+
If specified, the structure will be saved to the specified file path.
367+
:type out_file: str | None
368+
:param encoding: The encoding to use when writing to the output file; defaults to ``'utf-8'``.
369+
:type encoding: str
370+
:param print_in_console: Whether to print the structure to the console; defaults to ``True``.
371+
:type print_in_console: bool
372+
373+
**Examples**::
374+
375+
>>> from pyhelpers.ops import get_project_structure
376+
>>> get_project_structure(start_path="pyhelpers")
377+
>>> get_project_structure("my_project", print_in_console=False, out_file="structure.txt")
378+
"""
379+
380+
if ignore_dirs is None:
381+
ignore_dirs = {'__pycache__'}
382+
383+
def _print(text, file_handle=None):
384+
if print_in_console:
385+
try:
386+
print(text)
387+
except UnicodeEncodeError:
388+
# Fallback for consoles that can't handle Unicode
389+
ascii_text = text.replace('├', '+').replace('│', '|').replace('──', '--')
390+
print(ascii_text)
391+
392+
if file_handle is not None:
393+
file_handle.write(text + '\n')
394+
395+
f = None
396+
if out_file is not None:
397+
f = open(out_file, 'w', encoding=encoding)
398+
399+
try:
400+
for root, dirs, files in os.walk(start_path):
401+
dirs[:] = [d for d in dirs if d not in ignore_dirs]
402+
403+
level = root.replace(start_path, '').count(os.sep)
404+
indent = '│ ' * level + '├── '
405+
line = f"{indent}{os.path.basename(root)}/"
406+
_print(line, f)
407+
408+
sub_indent = '│ ' * (level + 1)
409+
for file in sorted(files):
410+
file_line = f"{sub_indent}├── {file}"
411+
_print(file_line, f)
412+
413+
finally:
414+
if f is not None:
415+
f.close()

pyhelpers/ops/manipulation.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -812,7 +812,7 @@ def downcast_numeric_columns(*dataframes):
812812
df_[int_cols] = df_[int_cols].apply(pd.to_numeric, downcast='integer')
813813

814814
# Process all float columns
815-
float_cols = df_.select_dtypes(include=['floating']).columns
815+
float_cols = df_.select_dtypes(include=['floating']).columns # noqa
816816
if not float_cols.empty:
817817
df_[float_cols] = df_[float_cols].apply(pd.to_numeric, downcast='float')
818818

@@ -822,14 +822,14 @@ def downcast_numeric_columns(*dataframes):
822822
elif isinstance(df, pl.DataFrame): # noqa
823823
df_ = df.clone()
824824
# Process integer columns
825-
int_cols = [col for col in df.columns if df[col].dtype.is_integer()]
825+
int_cols = [col for col in df_.columns if df_[col].dtype.is_integer()]
826826
if int_cols:
827-
df_ = df_.with_columns([pl.col(col).shrink_dtype() for col in int_cols]) # noqa
827+
df_ = df_.with_columns([df_[col].shrink_dtype() for col in int_cols]) # noqa
828828

829829
# Process float columns
830-
float_cols = [col for col in df.columns if df[col].dtype.is_float()]
830+
float_cols = [col for col in df_.columns if df_[col].dtype.is_float()]
831831
if float_cols:
832-
df_ = df_.with_columns([pl.col(col).shrink_dtype() for col in float_cols]) # noqa
832+
df_ = df_.with_columns([df_[col].shrink_dtype() for col in float_cols]) # noqa
833833

834834
results.append(df_)
835835

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ keywords = [
2121
"Python utils",
2222
"Python utility"
2323
]
24-
requires-python = ">=3.10"
24+
requires-python = ">=3.12"
2525
dependencies = [
2626
"numpy",
2727
"pandas",

requirements.txt

Lines changed: 26 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,43 @@
1-
build==1.2.2.post1
1+
build==1.3.0
22
folium==0.20.0
33
fqdn==1.5.1
4-
furo==2024.8.6
5-
gdal==3.11.1
4+
furo==2025.9.25
5+
gdal==3.11.4
66
isoduration==20.11.0
77
jaraco.collections==5.1.0
88
jsonpointer==3.0.0
9-
matplotlib==3.10.3
10-
networkx==3.5
11-
nltk==3.9.1
12-
notebook==7.4.4
9+
matplotlib==3.10.7
10+
networkx==3.6
11+
nltk==3.9.2
12+
notebook==7.5.0
1313
odfpy==1.4.1
1414
openpyxl==3.1.5
15-
orjson==3.10.18
16-
pandas==2.3.1
15+
orjson==3.11.4
16+
pandas==2.3.3
17+
pandas-stubs==2.3.2.250926
1718
pdfkit==1.0.0
1819
pip-chill==1.0.3
1920
pkginfo==1.12.1.2
20-
polars==1.31.0
21-
psycopg2==2.9.10
22-
pyarrow==20.0.0
23-
pyodbc==5.2.0
24-
pypandoc==1.15
25-
pyproj==3.7.1
26-
pytest-cov==6.2.1
27-
python-rapidjson==1.20
28-
rapidfuzz==3.13.0
29-
scikit-learn==1.7.0
30-
shapely==2.1.1
21+
polars==1.35.2
22+
psycopg2==2.9.11
23+
pyarrow==22.0.0
24+
pyodbc==5.3.0
25+
pypandoc==1.16.2
26+
pyproj==3.7.2
27+
pytest-cov==7.0.0
28+
python-rapidjson==1.22
29+
rapidfuzz==3.14.3
30+
scikit-learn==1.7.2
31+
shapely==2.1.2
3132
sphinx-copybutton==0.5.2
32-
sphinx-new-tab-link==0.8.0
33+
sphinx-new-tab-link==0.8.1
3334
sphinx-toggleprompt==0.6.0
34-
sqlalchemy==2.0.41
35+
sqlalchemy==2.0.44
3536
tinycss2==1.4.0
3637
tomli==2.0.1
37-
twine==6.1.0
38-
ujson==5.10.0
38+
twine==6.2.0
39+
ujson==5.11.0
3940
uri-template==1.3.0
4041
webcolors==24.11.1
4142
xlsx2csv==0.8.4
42-
xlsxwriter==3.2.5
43+
xlsxwriter==3.2.9

tests/test_ops/test_downloads.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,17 @@ def test_is_downloadable():
1919

2020

2121
@pytest.mark.parametrize('verbose', [True, False])
22-
def test_download_file_from_url(capfd, verbose):
22+
@pytest.mark.parametrize('stream_download', [True, False])
23+
def test_download_file_from_url(capfd, verbose, stream_download):
2324
logo_url = 'https://www.python.org/static/community_logos/python-logo-master-v3-TM.png'
2425

2526
path_to_img = "ops-download_file_from_url-demo.png"
26-
download_file_from_url(logo_url, path_to_img, verbose=verbose, colour='green')
27+
download_file_from_url(
28+
logo_url, path_to_img, verbose=verbose, colour='green', stream_download=stream_download)
2729
assert os.path.isfile(path_to_img)
2830

29-
download_file_from_url(logo_url, path_to_img, if_exists='pass', verbose=verbose)
31+
download_file_from_url(
32+
logo_url, path_to_img, if_exists='pass', verbose=verbose, stream_download=stream_download)
3033
out, _ = capfd.readouterr()
3134
if verbose:
3235
assert "Aborting download." in out
@@ -35,7 +38,7 @@ def test_download_file_from_url(capfd, verbose):
3538

3639
path_to_img_ = tempfile.NamedTemporaryFile()
3740
path_to_img = path_to_img_.name + ".png"
38-
download_file_from_url(logo_url, path_to_img, verbose=verbose)
41+
download_file_from_url(logo_url, path_to_img, verbose=verbose, stream_download=stream_download)
3942
assert os.path.isfile(path_to_img)
4043

4144
os.remove(path_to_img_.name)

0 commit comments

Comments
 (0)