Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35f47cb012 | ||
|
|
00ea1dbd17 | ||
|
|
b0c4810344 | ||
|
|
6e083f39e8 | ||
|
|
825338a2a3 | ||
|
|
a2f88923ab |
@@ -10,10 +10,11 @@ A command-line tool for processing and cleaning scanned musical score PDFs. This
|
|||||||
- **PNG Optimization**: Optimize PNG files using optipng (if installed)
|
- **PNG Optimization**: Optimize PNG files using optipng (if installed)
|
||||||
- **Modular Processing**: Process your files step by step or all at once
|
- **Modular Processing**: Process your files step by step or all at once
|
||||||
- **High Quality Output**: Preserve image quality throughout the process
|
- **High Quality Output**: Preserve image quality throughout the process
|
||||||
|
- **Professional PDF Output**: Generate A4-sized PDFs with proper borders and centered content
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
1. Ensure you have Python 3.8+ installed
|
1. Ensure you have Python 3.12+ installed
|
||||||
2. Install uv (recommended) or pip
|
2. Install uv (recommended) or pip
|
||||||
3. Clone this repository:
|
3. Clone this repository:
|
||||||
```bash
|
```bash
|
||||||
@@ -55,15 +56,20 @@ Automatically detects and corrects page rotation by analyzing staff lines.
|
|||||||
|
|
||||||
### Optimize Pages
|
### Optimize Pages
|
||||||
```bash
|
```bash
|
||||||
./pdf_cleaner.py optimize
|
./pdf_cleaner.py optimize [--level {1,2,3}]
|
||||||
```
|
```
|
||||||
Trims excess white space and optionally runs PNG optimization (requires optipng).
|
Processes pages with different optimization levels:
|
||||||
|
- Level 1: Only trims excess white space
|
||||||
|
- Level 2: Trims white space and converts to 1-bit monochrome
|
||||||
|
- Level 3: All optimizations + PNG optimization (requires optipng)
|
||||||
|
|
||||||
|
Default level is 1 if not specified.
|
||||||
|
|
||||||
### Create Final PDF
|
### Create Final PDF
|
||||||
```bash
|
```bash
|
||||||
./pdf_cleaner.py finalize output.pdf
|
./pdf_cleaner.py finalize output.pdf
|
||||||
```
|
```
|
||||||
Combines all processed pages into a final PDF and cleans up temporary files.
|
Combines all processed pages into a final PDF with proper A4 sizing, borders, and centered content.
|
||||||
|
|
||||||
### Typical Workflow
|
### Typical Workflow
|
||||||
```bash
|
```bash
|
||||||
@@ -83,7 +89,10 @@ Combines all processed pages into a final PDF and cleans up temporary files.
|
|||||||
3. **Optimization**:
|
3. **Optimization**:
|
||||||
- Detects content boundaries and removes excess white space
|
- Detects content boundaries and removes excess white space
|
||||||
- Optionally runs optipng for additional file size reduction
|
- Optionally runs optipng for additional file size reduction
|
||||||
4. **Finalization**: Combines processed images back into a PDF using img2pdf
|
4. **Finalization**:
|
||||||
|
- Combines processed images into a professional A4-sized PDF
|
||||||
|
- Adds configurable borders around content
|
||||||
|
- Centers content on each page while maintaining aspect ratio
|
||||||
|
|
||||||
## Dependencies
|
## Dependencies
|
||||||
|
|
||||||
@@ -100,4 +109,19 @@ Contributions are welcome! Please feel free to submit a Pull Request.
|
|||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
[Insert chosen license here]
|
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
||||||
|
|
||||||
|
## Version History
|
||||||
|
|
||||||
|
- v0.9rc3: Third release candidate
|
||||||
|
- Switched to parallel processing in optimization
|
||||||
|
- Removed unused imports and variables
|
||||||
|
- Added `ruff` as a development dependency
|
||||||
|
- v0.9rc2: Second release candidate
|
||||||
|
- Improved documentation
|
||||||
|
- Added optimization level descriptions
|
||||||
|
- Fixed aspect ratio in PDF output
|
||||||
|
- v0.9rc1: First release candidate with full functionality
|
||||||
|
- Professional PDF output with A4 sizing and borders
|
||||||
|
- Complete image processing pipeline
|
||||||
|
- Configurable settings
|
||||||
+47
-41
@@ -1,11 +1,7 @@
|
|||||||
#!/usr/bin/env -S uv run
|
#!/usr/bin/env -S uv run
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import shutil
|
|
||||||
import subprocess
|
import subprocess
|
||||||
from pathlib import Path
|
|
||||||
from typing import List, Tuple
|
|
||||||
|
|
||||||
import click
|
import click
|
||||||
import cv2
|
import cv2
|
||||||
import img2pdf
|
import img2pdf
|
||||||
@@ -13,8 +9,8 @@ import numpy as np
|
|||||||
from pdf2image import convert_from_path
|
from pdf2image import convert_from_path
|
||||||
|
|
||||||
import settings
|
import settings
|
||||||
from settings import OptimizationLevel
|
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
import concurrent.futures
|
||||||
|
|
||||||
class Settings:
|
class Settings:
|
||||||
TRIM_PADDING_PIXELS = 20
|
TRIM_PADDING_PIXELS = 20
|
||||||
@@ -22,8 +18,6 @@ class Settings:
|
|||||||
OPTIPNG_OPTIMIZATION_LEVEL = 7
|
OPTIPNG_OPTIMIZATION_LEVEL = 7
|
||||||
PDF_BORDER_SIZE = 50
|
PDF_BORDER_SIZE = 50
|
||||||
|
|
||||||
settings = Settings()
|
|
||||||
|
|
||||||
def ensure_temp_dir():
|
def ensure_temp_dir():
|
||||||
"""Ensure temporary directory exists and return its path."""
|
"""Ensure temporary directory exists and return its path."""
|
||||||
temp_dir = "temp_processed_images"
|
temp_dir = "temp_processed_images"
|
||||||
@@ -281,7 +275,7 @@ def run_optipng(file_path: str) -> bool:
|
|||||||
bool: True if successful, False otherwise
|
bool: True if successful, False otherwise
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
subprocess.run(
|
||||||
['optipng', f'-o{settings.OPTIPNG_OPTIMIZATION_LEVEL}', file_path],
|
['optipng', f'-o{settings.OPTIPNG_OPTIMIZATION_LEVEL}', file_path],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
@@ -306,13 +300,12 @@ def optimize(level):
|
|||||||
2: Level 1 + convert to 1-bit monochrome
|
2: Level 1 + convert to 1-bit monochrome
|
||||||
3: Level 2 + optipng optimization
|
3: Level 2 + optipng optimization
|
||||||
"""
|
"""
|
||||||
temp_dir = ensure_temp_dir()
|
|
||||||
temp_files = get_temp_files()
|
temp_files = get_temp_files()
|
||||||
if not temp_files:
|
if not temp_files:
|
||||||
print("No pages found in temporary directory. Run 'extract' first.")
|
print("No pages found in temporary directory. Run 'extract' first.")
|
||||||
return
|
return
|
||||||
|
|
||||||
opt_level = OptimizationLevel(level)
|
opt_level = settings.OptimizationLevel(level)
|
||||||
|
|
||||||
total_files = len(temp_files)
|
total_files = len(temp_files)
|
||||||
successful = {
|
successful = {
|
||||||
@@ -323,49 +316,62 @@ def optimize(level):
|
|||||||
|
|
||||||
print(f"Processing {total_files} images at optimization level {level}...")
|
print(f"Processing {total_files} images at optimization level {level}...")
|
||||||
|
|
||||||
# Step 1: Always trim whitespace
|
# Step 1: Convert to monochrome if level >= 2
|
||||||
print("\nTrimming whitespace from images...")
|
if int(opt_level) >= int(settings.OptimizationLevel.MONOCHROME):
|
||||||
for i, file_path in enumerate(temp_files, 1):
|
|
||||||
print(f"[{i}/{total_files}] Processing {os.path.basename(file_path)}...", end='', flush=True)
|
|
||||||
# Only optimize if this is the final step (level 1)
|
|
||||||
if trim_whitespace(file_path, is_final=(opt_level == OptimizationLevel.TRIM)):
|
|
||||||
successful['trim'] += 1
|
|
||||||
print(" ")
|
|
||||||
else:
|
|
||||||
print(" ")
|
|
||||||
|
|
||||||
# Step 2: Convert to monochrome if level >= 2
|
|
||||||
if int(opt_level) >= int(OptimizationLevel.MONOCHROME):
|
|
||||||
print("\nConverting to monochrome...")
|
print("\nConverting to monochrome...")
|
||||||
for i, file_path in enumerate(temp_files, 1):
|
with concurrent.futures.ThreadPoolExecutor() as executor:
|
||||||
print(f"[{i}/{total_files}] Converting {os.path.basename(file_path)}...", end='', flush=True)
|
futures = {executor.submit(convert_to_monochrome, file_path, (opt_level == settings.OptimizationLevel.MONOCHROME)): file_path for file_path in temp_files}
|
||||||
# Only optimize if this is the final step (level 2)
|
for future in concurrent.futures.as_completed(futures):
|
||||||
if convert_to_monochrome(file_path, is_final=(opt_level == OptimizationLevel.MONOCHROME)):
|
file_path = futures[future]
|
||||||
successful['monochrome'] += 1
|
try:
|
||||||
print(" ")
|
if future.result():
|
||||||
else:
|
successful['monochrome'] += 1
|
||||||
print(" ")
|
print(f"Converted {os.path.basename(file_path)}")
|
||||||
|
else:
|
||||||
|
print(f"Failed to convert {os.path.basename(file_path)}")
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"{os.path.basename(file_path)} generated an exception: {exc}")
|
||||||
|
|
||||||
|
# Step 2: Always trim whitespace
|
||||||
|
print("\nTrimming whitespace from images...")
|
||||||
|
with concurrent.futures.ThreadPoolExecutor() as executor:
|
||||||
|
futures = {executor.submit(trim_whitespace, file_path, (opt_level == settings.OptimizationLevel.TRIM)): file_path for file_path in temp_files}
|
||||||
|
for future in concurrent.futures.as_completed(futures):
|
||||||
|
file_path = futures[future]
|
||||||
|
try:
|
||||||
|
if future.result():
|
||||||
|
successful['trim'] += 1
|
||||||
|
print(f"Trimmed {os.path.basename(file_path)}")
|
||||||
|
else:
|
||||||
|
print(f"Failed to trim {os.path.basename(file_path)}")
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"{os.path.basename(file_path)} generated an exception: {exc}")
|
||||||
|
|
||||||
# Step 3: Run optipng if level = 3
|
# Step 3: Run optipng if level = 3
|
||||||
if opt_level == OptimizationLevel.FULL:
|
if opt_level == settings.OptimizationLevel.FULL:
|
||||||
if check_optipng_installed():
|
if check_optipng_installed():
|
||||||
print("\nOptimizing PNG files with optipng...")
|
print("\nOptimizing PNG files with optipng...")
|
||||||
for i, file_path in enumerate(temp_files, 1):
|
with concurrent.futures.ThreadPoolExecutor() as executor:
|
||||||
print(f"[{i}/{total_files}] Optimizing {os.path.basename(file_path)}...", end='', flush=True)
|
futures = {executor.submit(run_optipng, file_path): file_path for file_path in temp_files}
|
||||||
if run_optipng(file_path):
|
for future in concurrent.futures.as_completed(futures):
|
||||||
successful['optipng'] += 1
|
file_path = futures[future]
|
||||||
print(" ")
|
try:
|
||||||
else:
|
if future.result():
|
||||||
print(" ")
|
successful['optipng'] += 1
|
||||||
|
print(f"Optimized {os.path.basename(file_path)}")
|
||||||
|
else:
|
||||||
|
print(f"Failed to optimize {os.path.basename(file_path)}")
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"{os.path.basename(file_path)} generated an exception: {exc}")
|
||||||
else:
|
else:
|
||||||
print("\nNote: optipng not found. Skipping PNG optimization.")
|
print("\nNote: optipng not found. Skipping PNG optimization.")
|
||||||
|
|
||||||
# Print summary
|
# Print summary
|
||||||
print("\nOptimization complete!")
|
print("\nOptimization complete!")
|
||||||
print(f"Successfully trimmed: {successful['trim']}/{total_files} images")
|
print(f"Successfully trimmed: {successful['trim']}/{total_files} images")
|
||||||
if int(opt_level) >= int(OptimizationLevel.MONOCHROME):
|
if int(opt_level) >= int(settings.OptimizationLevel.MONOCHROME):
|
||||||
print(f"Successfully converted to monochrome: {successful['monochrome']}/{total_files} images")
|
print(f"Successfully converted to monochrome: {successful['monochrome']}/{total_files} images")
|
||||||
if opt_level == OptimizationLevel.FULL and check_optipng_installed():
|
if opt_level == settings.OptimizationLevel.FULL and check_optipng_installed():
|
||||||
print(f"Successfully optimized with optipng: {successful['optipng']}/{total_files} images")
|
print(f"Successfully optimized with optipng: {successful['optipng']}/{total_files} images")
|
||||||
|
|
||||||
@cli.command()
|
@cli.command()
|
||||||
|
|||||||
+6
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "notes-cleaner"
|
name = "notes-cleaner"
|
||||||
version = "0.9rc1"
|
version = "0.9rc3"
|
||||||
description = "Add your description here"
|
description = "Add your description here"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
@@ -12,3 +12,8 @@ dependencies = [
|
|||||||
"click>=8.1.7",
|
"click>=8.1.7",
|
||||||
"pydantic>=2.10.3",
|
"pydantic>=2.10.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[dependency-groups]
|
||||||
|
dev = [
|
||||||
|
"ruff>=0.8.1",
|
||||||
|
]
|
||||||
|
|||||||
@@ -33,19 +33,27 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "notes-cleaner"
|
name = "notes-cleaner"
|
||||||
version = "0.1.0"
|
version = "0.9rc2"
|
||||||
source = { virtual = "." }
|
source = { virtual = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "click" },
|
{ name = "click" },
|
||||||
{ name = "pydantic" },
|
{ name = "pydantic" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[package.dev-dependencies]
|
||||||
|
dev = [
|
||||||
|
{ name = "ruff" },
|
||||||
|
]
|
||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "click", specifier = ">=8.1.7" },
|
{ name = "click", specifier = ">=8.1.7" },
|
||||||
{ name = "pydantic", specifier = ">=2.10.3" },
|
{ name = "pydantic", specifier = ">=2.10.3" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[package.metadata.requires-dev]
|
||||||
|
dev = [{ name = "ruff", specifier = ">=0.8.1" }]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pydantic"
|
name = "pydantic"
|
||||||
version = "2.10.3"
|
version = "2.10.3"
|
||||||
@@ -99,6 +107,31 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/df/c3/b15fb833926d91d982fde29c0624c9f225da743c7af801dace0d4e187e71/pydantic_core-2.27.1-cp313-none-win_arm64.whl", hash = "sha256:45cf8588c066860b623cd11c4ba687f8d7175d5f7ef65f7129df8a394c502de5", size = 1882983 },
|
{ url = "https://files.pythonhosted.org/packages/df/c3/b15fb833926d91d982fde29c0624c9f225da743c7af801dace0d4e187e71/pydantic_core-2.27.1-cp313-none-win_arm64.whl", hash = "sha256:45cf8588c066860b623cd11c4ba687f8d7175d5f7ef65f7129df8a394c502de5", size = 1882983 },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ruff"
|
||||||
|
version = "0.8.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/95/d0/8ff5b189d125f4260f2255d143bf2fa413b69c2610c405ace7a0a8ec81ec/ruff-0.8.1.tar.gz", hash = "sha256:3583db9a6450364ed5ca3f3b4225958b24f78178908d5c4bc0f46251ccca898f", size = 3313222 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a2/d6/1a6314e568db88acdbb5121ed53e2c52cebf3720d3437a76f82f923bf171/ruff-0.8.1-py3-none-linux_armv6l.whl", hash = "sha256:fae0805bd514066f20309f6742f6ee7904a773eb9e6c17c45d6b1600ca65c9b5", size = 10532605 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/89/a8/a957a8812e31facffb6a26a30be0b5b4af000a6e30c7d43a22a5232a3398/ruff-0.8.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b8a4f7385c2285c30f34b200ca5511fcc865f17578383db154e098150ce0a087", size = 10278243 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a8/23/9db40fa19c453fabf94f7a35c61c58f20e8200b4734a20839515a19da790/ruff-0.8.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:cd054486da0c53e41e0086e1730eb77d1f698154f910e0cd9e0d64274979a209", size = 9917739 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e2/a0/6ee2d949835d5701d832fc5acd05c0bfdad5e89cfdd074a171411f5ccad5/ruff-0.8.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2029b8c22da147c50ae577e621a5bfbc5d1fed75d86af53643d7a7aee1d23871", size = 10779153 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7a/25/9c11dca9404ef1eb24833f780146236131a3c7941de394bc356912ef1041/ruff-0.8.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2666520828dee7dfc7e47ee4ea0d928f40de72056d929a7c5292d95071d881d1", size = 10304387 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c8/b9/84c323780db1b06feae603a707d82dbbd85955c8c917738571c65d7d5aff/ruff-0.8.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:333c57013ef8c97a53892aa56042831c372e0bb1785ab7026187b7abd0135ad5", size = 11360351 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6b/e1/9d4bbb2ace7aad14ded20e4674a48cda5b902aed7a1b14e6b028067060c4/ruff-0.8.1-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:288326162804f34088ac007139488dcb43de590a5ccfec3166396530b58fb89d", size = 12022879 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/75/28/752ff6120c0e7f9981bc4bc275d540c7f36db1379ba9db9142f69c88db21/ruff-0.8.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b12c39b9448632284561cbf4191aa1b005882acbc81900ffa9f9f471c8ff7e26", size = 11610354 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ba/8c/967b61c2cc8ebd1df877607fbe462bc1e1220b4a30ae3352648aec8c24bd/ruff-0.8.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:364e6674450cbac8e998f7b30639040c99d81dfb5bbc6dfad69bc7a8f916b3d1", size = 12813976 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7f/29/e059f945d6bd2d90213387b8c360187f2fefc989ddcee6bbf3c241329b92/ruff-0.8.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b22346f845fec132aa39cd29acb94451d030c10874408dbf776af3aaeb53284c", size = 11154564 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/55/47/cbd05e5a62f3fb4c072bc65c1e8fd709924cad1c7ec60a1000d1e4ee8307/ruff-0.8.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:b2f2f7a7e7648a2bfe6ead4e0a16745db956da0e3a231ad443d2a66a105c04fa", size = 10760604 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bb/ee/4c3981c47147c72647a198a94202633130cfda0fc95cd863a553b6f65c6a/ruff-0.8.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:adf314fc458374c25c5c4a4a9270c3e8a6a807b1bec018cfa2813d6546215540", size = 10391071 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6b/e6/083eb61300214590b188616a8ac6ae1ef5730a0974240fb4bec9c17de78b/ruff-0.8.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a885d68342a231b5ba4d30b8c6e1b1ee3a65cf37e3d29b3c74069cdf1ee1e3c9", size = 10896657 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/77/bd/aacdb8285d10f1b943dbeb818968efca35459afc29f66ae3bd4596fbf954/ruff-0.8.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:d2c16e3508c8cc73e96aa5127d0df8913d2290098f776416a4b157657bee44c5", size = 11228362 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/39/72/fcb7ad41947f38b4eaa702aca0a361af0e9c2bf671d7fd964480670c297e/ruff-0.8.1-py3-none-win32.whl", hash = "sha256:93335cd7c0eaedb44882d75a7acb7df4b77cd7cd0d2255c93b28791716e81790", size = 8803476 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e4/ea/cae9aeb0f4822c44651c8407baacdb2e5b4dcd7b31a84e1c5df33aa2cc20/ruff-0.8.1-py3-none-win_amd64.whl", hash = "sha256:2954cdbe8dfd8ab359d4a30cd971b589d335a44d444b6ca2cb3d1da21b75e4b6", size = 9614463 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/eb/76/fbb4bd23dfb48fa7758d35b744413b650a9fd2ddd93bca77e30376864414/ruff-0.8.1-py3-none-win_arm64.whl", hash = "sha256:55873cc1a473e5ac129d15eccb3c008c096b94809d693fc7053f588b67822737", size = 8959621 },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "typing-extensions"
|
name = "typing-extensions"
|
||||||
version = "4.12.2"
|
version = "4.12.2"
|
||||||
|
|||||||
Reference in New Issue
Block a user