Compare commits

..
13 Commits
Author SHA1 Message Date
Esa Kataja a45407384e chore: release v1.0.0 2025-04-17 13:58:54 +03:00
Esa Kataja 708c7dc4cd Add future features section with Streamlit UI preview plan 2025-04-17 13:51:27 +03:00
Esa Kataja 21383a391a Update README 2025-04-17 13:49:25 +03:00
Esa Kataja 97e40731b2 Refactor app.py: extract SOLID helper functions and simplify main command
Moved ffprobe call into run_ffprobe
Introduced prepare_video_file, apply_stream_settings, and confirm_and_write_script helpers
Streamlined main() to delegate to new helpers
2025-04-17 13:49:14 +03:00
Esa Kataja 65777071f4 Add error handling for ffprobe 2025-04-17 13:19:22 +03:00
Esa Kataja 404b61aa00 Add IMDb ID validation with regex pattern matching for tt followed by 7-8 digits 2025-04-17 13:16:22 +03:00
Esa Kataja cf4b7fa106 Add confirmation prompt before overwriting existing encode.sh script 2025-04-17 13:00:40 +03:00
Esa Kataja 0c71d3c58b Update original_media_type to use enum and set it on VideoFile instance 2025-04-17 12:58:19 +03:00
Esa Kataja 5ec816c163 Optimizations to stream classes 2025-04-17 12:58:07 +03:00
Esa Kataja c7a381c4b9 Add film grain parameter to video encoding with configurable amount 2025-04-17 12:42:44 +03:00
Esa Kataja a30791a036 Replace Click with Typer for improved CLI argument handling 2025-04-17 12:36:31 +03:00
Esa Kataja 55cd3e7ec0 Add original_media_type field to Stream model for media type tracking 2025-04-17 12:31:53 +03:00
Esa Kataja 5d5371c991 Update README with details about metadata handling and anamorphic conversion 2025-04-17 11:51:57 +03:00
6 changed files with 280 additions and 98 deletions
+29
View File
@@ -0,0 +1,29 @@
# Changelog
All notable changes to this project will be documented in this file.
## [1.0.0] - 2025-04-17
### Added
- Initial release of **videnc-reeffed** CLI for encoding videos.
- Support for:
- Specifying input video file and IMDb ID validation.
- Removing subtitles with `--nosubtitles`.
- Encoding presets (`--preset`) and CRF settings (`--crf`).
- Selecting original media type (`--original-media-type`).
- Adjusting film grain amount (`--film-grain`).
- Implemented core functions:
- `run_ffprobe` for extracting stream metadata.
- `parse_streams` to model video, audio, and subtitle streams.
- `apply_stream_settings` to apply encoding parameters.
- `confirm_and_write_script` to generate and confirm overwrite of `encode.sh` script.
- Integrated modules and helpers:
- `models/streams` data models: `VideoFile`, `VideoStream`, `AudioStream`, `SubtitleStream`, `OriginalMediaType`.
- `movie_details.get_movie_details` for enriching metadata.
- `lib/fs.write_shell_script` for file system operations.
### Changed
- Adopted `src` directory layout according to standard Python packaging.
### Fixed
- None (initial release).
+83 -20
View File
@@ -1,39 +1,102 @@
# VidEnc # VidEnc: Video Encoding with OMDB Metadata
This is a command-line application that analyzes video files using `ffprobe` to extract stream information (video, audio, subtitles). It then uses the OMDb API to fetch additional movie details such as title and release date, given an IMDB ID. VidEnc streamlines complex video transcoding workflows into a single CLI command by generating a portable FFmpeg-based shell script that:
- Converts video streams to AV1 for maximum compression efficiency
- Encodes audio tracks to Opus for reduced file size without sacrificing quality
- Adjusts anamorphic sources to a correct 1:1 sample aspect ratio automatically
- Retrieves and embeds movie title and release date from OMDB using your IMDb ID
- Strips all other metadata to produce a clean, optimized output
## Workflow Designed for media archivists, home theater enthusiasts, and content creators, VidEnc offers customizable presets (CRF, film grain strength, speed), original media type tagging, optional subtitle handling, and shell completion support for a seamless CLI experience.
1. **Input:** The application takes a video file path as input, along with an optional IMDB ID and a flag to disable subtitle processing. ---
2. **FFprobe Analysis:** It runs `ffprobe` on the input video file to extract stream information in JSON format. ## 🚀 Features
3. **Stream Parsing:** The JSON output is parsed to identify video, audio, and subtitle streams. - Encode video streams to AV1 and audio streams to Opus
- Convert anamorphic video to 1:1 pixel aspect ratio
- Fetch and inject movie title and release date from OMDB
- Strip all other metadata to produce a clean output
- Configurable presets, CRF, film grain, media type, and subtitle handling
- Automatic generation of a reusable `encode.sh` script
- Shell completion support
4. **OMDb API Lookup (Optional):** If an IMDB ID is provided, the application queries the OMDb API to retrieve movie title and release date. ## 🔮 Future Features
5. **Output:** Finally, it prints a command string based on the extracted information. This command string is likely intended for further video processing or encoding. - Streamlit UI for interactive configuration and metadata preview.
- Every audio stream that is side loaded, must be converted to 5.1 channel layout with `channelmap=channel_layout=5.1`
## Usage ## 🔧 Prerequisites
- **Python** >= 3.12
- **FFmpeg** (including `ffprobe`) installed and available in your `PATH`
- **OMDB API key**: Sign up at https://www.omdbapi.com/ and set `API_KEY` as an environment variable or in a `.env` file
## ⚙️ Installation
1. Clone the repository (or navigate to your project folder):
```bash
git clone https://github.com/Kessinen/VidEnc.git
cd VidEnc
```
2. Install Python dependencies using **uv**:
```bash
uv install
```
3. Create a `.env` file with your OMDB API key:
```bash
echo "API_KEY=your_omdb_api_key" > .env
```
## 📝 Usage
### Generate the encoding script
```bash ```bash
python src/app.py <video_file> [-i <imdb_id>] [-ns] # via uv:
uv run src/app.py path/to/video.mp4 --imdb tt1234567
# or directly:
python src/app.py path/to/video.mp4 -i tt1234567
``` ```
* `<video_file>`: Path to the video file. This will probe the input, fetch metadata, and write an executable `encode.sh` next to your video.
* `-i <imdb_id>`: (Optional) IMDB ID of the movie.
* `-ns`: (Optional) Flag to disable subtitle processing.
### Execute the encoding script
## Example
```bash ```bash
python src/app.py my_movie.mp4 -i tt1234567 bash encode.sh
``` ```
This would analyze `my_movie.mp4`, fetch details from OMDb using IMDB ID `tt1234567`, and print a command string. ## ⚙️ CLI Options
## Unit Tests | Option | Description | Default |
|-------------------------------|----------------------------------------------------------------------|-------------|
| -ns, --nosubtitles | Disable subtitle processing | false |
| -p, --preset INT | Encoding preset (110 scale; higher = faster, lower quality) | 2 |
| -c, --crf INT | Constant Rate Factor for video quality | 30 |
| -o, --original-media-type | Source media type (DVD, BluRay, TVRip, WebRip, Other) | DVD |
| -fg, --film-grain INT | Film grain strength | 20 |
| --install-completion | Install shell completion for your current shell | — |
| --show-completion | Display shell completion script for customization | — |
There are no unit tests included in this project. ```bash
# Install bash completion (example):
python src/app.py --install-completion
```
## 🔍 How It Works
1. **Probe** the input file with `ffprobe` to collect stream data.
2. **Parse** video, audio, and subtitle streams into a structured model.
3. **Fetch** title and release date from OMDB using the provided IMDb ID.
4. **Configure** each video stream with presets, CRF, film grain, and media type.
5. **Generate** a single FFmpeg command to re-encode streams, convert SAR, embed metadata, and strip all other metadata.
6. **Write** the command into `encode.sh`, prompting before overwrite if it exists.
## 💡 Contributing
Contributions, issues, and feature requests are welcome! Feel free to open a PR or issue in the [GitHub repository](https://github.com/Kessinen/VidEnc).
## 📄 License
This project is licensed under the MIT License. See [LICENSE](LICENSE) for details.
+2 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "videnc-reeffed" name = "videnc-reeffed"
version = "0.1.0" version = "1.0.0"
description = "Add your description here" description = "Add your description here"
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
@@ -10,4 +10,5 @@ dependencies = [
"python-dotenv>=1.0.1", "python-dotenv>=1.0.1",
"requests>=2.32.3", "requests>=2.32.3",
"rich>=13.9.4", "rich>=13.9.4",
"typer>=0.15.2",
] ]
+73 -31
View File
@@ -1,13 +1,15 @@
import json import json
import subprocess import subprocess
import click import typer
from pathlib import Path from pathlib import Path
# from rich import print import re
import click
from models.streams import VideoFile, VideoStream, AudioStream, SubtitleStream from models.streams import VideoFile, VideoStream, AudioStream, SubtitleStream, OriginalMediaType
from movie_details import get_movie_details from movie_details import get_movie_details
from lib.fs import write_shell_script from lib.fs import write_shell_script
app = typer.Typer()
def parse_streams(stream_data: dict, VideoFile: VideoFile) -> VideoFile: def parse_streams(stream_data: dict, VideoFile: VideoFile) -> VideoFile:
for stream in stream_data["streams"]: for stream in stream_data["streams"]:
@@ -23,43 +25,83 @@ def parse_streams(stream_data: dict, VideoFile: VideoFile) -> VideoFile:
return VideoFile return VideoFile
@click.command() def validate_imdb(ctx: click.Context, param: click.Parameter, value: str) -> str:
@click.argument("filename", type=click.Path(exists=True)) """Validate that an IMDb ID matches 'tt' followed by 7-8 digits."""
@click.option("-i", "--imdb", type=str, help="IMDB ID") if value is None:
@click.option("-ns", "--nosubtitles", is_flag=True, help="No subtitles") return value
@click.option("-p", "--preset", type=int, default=2, help="Encoding preset (default: 2)") if not re.match(r"^tt\d{7,8}$", value):
@click.option("-c", "--crf", type=int, default=30, help="CRF value (default: 30)") raise click.BadParameter("IMDb ID must be 'tt' followed by 7-8 digits (e.g. tt1234567)")
@click.option('-o', '--original-media-type', default='DVD', help='Set the ORIGINAL_MEDIA_TYPE metadata') return value
def main(filename: Path, imdb: str, nosubtitles: bool, preset: int, crf: int, original_media_type: str):
"""Run ffprobe on a file and print the output in JSON format."""
video_details = VideoFile(
imdb=imdb, video_path=filename, allow_subtitle=not nosubtitles
)
video_details = get_movie_details(video_details)
# Helper functions for SOLID refactoring
def run_ffprobe(filename: Path) -> dict:
"""Execute ffprobe and return parsed JSON data."""
cmd = [ cmd = [
"ffprobe", "ffprobe", "-v", "quiet",
"-v", "-print_format", "json",
"quiet", "-show_format", "-show_streams",
"-print_format",
"json",
"-show_format",
"-show_streams",
filename, filename,
] ]
try:
result = subprocess.run(cmd, capture_output=True, check=True) result = subprocess.run(cmd, capture_output=True, check=True)
data = parse_streams(json.loads(result.stdout), video_details) except FileNotFoundError:
for stream in data.video_streams: typer.secho("Error: ffprobe not found. Please install FFmpeg.", fg="red", err=True)
raise typer.Exit(code=1)
except subprocess.CalledProcessError as e:
typer.secho("Error: ffprobe failed to probe the file.", fg="red", err=True)
error_output = e.stderr.decode() if e.stderr else str(e)
typer.secho(error_output, fg="red", err=True)
raise typer.Exit(code=1)
return json.loads(result.stdout)
def prepare_video_file(filename: Path, imdb: str, nosubtitles: bool) -> VideoFile:
"""Initialize VideoFile with metadata."""
return VideoFile(imdb=imdb, video_path=filename, allow_subtitle=not nosubtitles)
def apply_stream_settings(video_details: VideoFile, preset: int, crf: int, original_media_type: OriginalMediaType, film_grain: int) -> VideoFile:
"""Apply encoding settings to video streams."""
video_details.original_media_type = original_media_type
for stream in video_details.video_streams:
stream.preset = preset stream.preset = preset
stream.crf = crf stream.crf = crf
stream.original_media_type = original_media_type stream.original_media_type = original_media_type
export_data = " ".join(data.cmd()) stream.film_grain = film_grain
export_path = Path(filename).parent / "encode.sh" return video_details
def confirm_and_write_script(export_data: str, export_path: Path):
"""Confirm overwrite and write shell script."""
if export_path.exists():
if not typer.confirm(f"'{export_path}' already exists. Overwrite? "):
typer.echo("Aborting.")
raise typer.Exit()
export_path.unlink()
write_shell_script(export_data, export_path) write_shell_script(export_data, export_path)
print(Path(filename).parent)
print(" ".join(data.cmd())) @app.command()
# json.dump(data, sys.stdout, indent=4) def main(
filename: Path = typer.Argument(..., exists=True, help="Input video file"),
imdb: str = typer.Option(None, "-i", "--imdb", callback=validate_imdb, help="IMDB ID"),
nosubtitles: bool = typer.Option(False, "-ns", "--nosubtitles", help="Remove subtitles"),
preset: int = typer.Option(2, "-p", "--preset", help="Encoding preset"),
crf: int = typer.Option(30, "-c", "--crf", help="CRF value"),
original_media_type: OriginalMediaType = typer.Option(OriginalMediaType.DVD, "-o", "--original-media-type", case_sensitive=False, help="Source of the original media."),
film_grain: int = typer.Option(20, "-fg", "--film-grain", help="Film grain amount (default: 20)")
):
video_details = prepare_video_file(filename, imdb, nosubtitles)
video_details = get_movie_details(video_details)
stream_data = run_ffprobe(filename)
video_details = parse_streams(stream_data, video_details)
video_details = apply_stream_settings(video_details, preset, crf, original_media_type, film_grain)
export_data = " ".join(video_details.cmd())
export_path = filename.parent / "encode.sh"
confirm_and_write_script(export_data, export_path)
typer.echo(str(filename.parent))
typer.echo(export_data)
if __name__ == "__main__": if __name__ == "__main__":
main() app()
+64 -44
View File
@@ -1,15 +1,22 @@
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from typing import Optional from enum import Enum
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from functools import cached_property from functools import cached_property
from lib.fs import sanitize_filename from lib.fs import sanitize_filename
class OriginalMediaType(str, Enum):
DVD = "DVD"
BluRay = "BluRay"
TVRip = "TVRip"
WebRip = "WebRip"
Other = "Other"
class Tags(BaseModel): class Tags(BaseModel):
title: Optional[str] = Field(None) title: str | None = Field(None)
language: Optional[str] = Field(None) language: str | None = Field(None)
class Disposition(BaseModel): class Disposition(BaseModel):
@@ -18,22 +25,31 @@ class Disposition(BaseModel):
class VideoStream(BaseModel): class VideoStream(BaseModel):
index: int = Field(None) index: int | None = Field(None)
width: int = Field(None) width: int | None = Field(None)
height: int = Field(None) height: int | None = Field(None)
avg_frame_rate: str = Field(None, alias="avg_frame_rate") avg_frame_rate: str | None = Field(None, alias="avg_frame_rate")
codec_name: str = Field(None) codec_name: str | None = Field(None)
disposition: Disposition disposition: Disposition
tags: Tags | None = Field(None) tags: Tags | None = Field(None)
preset: int = Field(2) preset: int = Field(2)
crf: int = Field(30) crf: int = Field(30)
original_media_type: str | None = Field(None)
film_grain: int = Field(20)
@cached_property @cached_property
def fps(self) -> float: def fps(self) -> float:
frame_numbers = [int(x) for x in self.avg_frame_rate.split("/")] """Calculate frames per second from avg_frame_rate string."""
return frame_numbers[0] / frame_numbers[1] try:
if not self.avg_frame_rate:
return 0.0
num, denom = (int(x) for x in self.avg_frame_rate.split("/"))
return num / denom if denom else 0.0
except Exception:
return 0.0
def cmd_flags(self) -> list[str]: def cmd_flags(self) -> list[str]:
"""Generate ffmpeg command flags for this video stream."""
return [ return [
"-map", "-map",
f"0:{self.index}", f"0:{self.index}",
@@ -44,11 +60,11 @@ class VideoStream(BaseModel):
"-preset", "-preset",
str(self.preset), str(self.preset),
"-svtav1-params", "-svtav1-params",
"tune=0:film-grain=20:scd=1", f"tune=0:film-grain={self.film_grain}:scd=1",
"-vf", "-vf",
"'scale=iw*sar:ih,setsar=1,scale=-2:ih:lanczos'", "'scale=iw*sar:ih,setsar=1,scale=-2:ih:lanczos'",
"-g", "-g",
f"{round(self.fps) * 5}", str(round(self.fps) * 5),
] ]
@@ -61,20 +77,24 @@ class AudioStream(BaseModel):
tags: Tags | None = Field(None) tags: Tags | None = Field(None)
def cmd_flags(self, index: int) -> list[str]: def cmd_flags(self, index: int) -> list[str]:
"""Generate ffmpeg command flags for this audio stream."""
side_loaded = [] side_loaded = []
if "side" in self.channel_layout:
side_loaded = [f"-filter:a:{index}", "'channelmap=channel_layout=5.1'"]
default_disposition = ( default_disposition = (
[f"-disposition:a:{index}", "default"] if self.disposition.default else [] [f"-disposition:a:{index}", "default"] if self.disposition.default else []
) )
if "side" in self.channel_layout: language = getattr(self.tags, 'language', None) if self.tags else None
side_loaded = [f"-filter:a:{index}", "'channelmap=channel_layout=5.1'"] title = getattr(self.tags, 'title', None) if self.tags else None
meta_flags = []
if language:
meta_flags += [f"-metadata:s:a:{index}", f"language={language}"]
if title:
meta_flags += [f"-metadata:s:a:{index}", f"title='{title}'"]
return ( return (
["-map", f"0:{self.index}", f"-c:a:{index}", "libopus"] ["-map", f"0:{self.index}", f"-c:a:{index}", "libopus"]
+ side_loaded + side_loaded
+ [f"-metadata:s:a:{index}", f"language={self.tags.language}"] + meta_flags
+ [
f"-metadata:s:a:{index}",
f"title='{self.tags.title}'",
]
+ default_disposition + default_disposition
) )
@@ -86,23 +106,27 @@ class SubtitleStream(BaseModel):
tags: Tags | None = Field(None) tags: Tags | None = Field(None)
def cmd_flags(self, index: int) -> list[str]: def cmd_flags(self, index: int) -> list[str]:
return ["-map", f"0:s:{index}", f"-c:s:{index}", "copy"] + [ """Generate ffmpeg command flags for this subtitle stream."""
f"-metadata:s:s:{index}", language = getattr(self.tags, 'language', None) if self.tags else None
f"language={self.tags.language}", meta_flags = []
] if language:
meta_flags += [f"-metadata:s:s:{index}", f"language={language}"]
return ["-map", f"0:s:{index}", f"-c:s:{index}", "copy"] + meta_flags
class VideoFile(BaseModel): class VideoFile(BaseModel):
video_path: Path video_path: Path
title: str = Field(None) title: str | None = Field(None)
date_released: Optional[datetime] = Field(None) date_released: datetime | None = Field(None)
imdb: Optional[str] = Field(None) imdb: str | None = Field(None)
video_streams: list[VideoStream] = Field([]) video_streams: list[VideoStream] = Field(default_factory=list)
audio_streams: list[AudioStream] = Field([]) audio_streams: list[AudioStream] = Field(default_factory=list)
subtitle_streams: list[SubtitleStream] = Field([]) subtitle_streams: list[SubtitleStream] = Field(default_factory=list)
allow_subtitle: bool = Field(True) allow_subtitle: bool = Field(True)
original_media_type: OriginalMediaType = Field(OriginalMediaType.DVD)
def cmd(self) -> list[str]: def cmd(self) -> list[str]:
"""Generate the full ffmpeg command for this video file."""
cmd = [ cmd = [
"ffmpeg", "ffmpeg",
"-hide_banner", "-hide_banner",
@@ -112,17 +136,15 @@ class VideoFile(BaseModel):
f"'{str(self.video_path.absolute())}'", f"'{str(self.video_path.absolute())}'",
"-map_metadata", "-map_metadata",
"-1", "-1",
"-metadata",
f"title='{self.title}'",
"-metadata",
f"date_released={self.date_released.strftime('%Y-%m-%d')}",
"-metadata",
f"imdb={self.imdb}",
"-metadata",
"original_media_type=DVD",
"-pix_fmt",
"yuv420p10le",
] ]
if self.title:
cmd += ["-metadata", f"title='{self.title}'"]
if self.date_released:
cmd += ["-metadata", f"date_released={self.date_released.strftime('%Y-%m-%d')}"]
if self.imdb:
cmd += ["-metadata", f"imdb={self.imdb}"]
cmd += ["-metadata", f"original_media_type={self.original_media_type.value}"]
cmd += ["-pix_fmt", "yuv420p10le"]
for stream in self.video_streams: for stream in self.video_streams:
cmd.extend(stream.cmd_flags()) cmd.extend(stream.cmd_flags())
for i, stream in enumerate(self.audio_streams): for i, stream in enumerate(self.audio_streams):
@@ -130,12 +152,10 @@ class VideoFile(BaseModel):
if self.allow_subtitle: if self.allow_subtitle:
for i, stream in enumerate(self.subtitle_streams): for i, stream in enumerate(self.subtitle_streams):
cmd.extend(stream.cmd_flags(i)) cmd.extend(stream.cmd_flags(i))
year = self.date_released.year if self.date_released else "unknown"
output_file = ( output_file = (
self.video_path.absolute().parent self.video_path.absolute().parent
/ f"{sanitize_filename(self.title)}.{self.date_released.year}.mkv" / f"{sanitize_filename(self.title or 'untitled')}.{year}.mkv"
) )
cmd.append(f"'{str(output_file)}'")
cmd.extend([f"'{str(output_file)}'"])
return cmd return cmd
Generated
+28 -1
View File
@@ -1,4 +1,5 @@
version = 1 version = 1
revision = 1
requires-python = ">=3.12" requires-python = ">=3.12"
resolution-markers = [ resolution-markers = [
"python_full_version < '3.13'", "python_full_version < '3.13'",
@@ -67,7 +68,7 @@ name = "click"
version = "8.1.7" version = "8.1.7"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "colorama", marker = "platform_system == 'Windows'" }, { name = "colorama", marker = "sys_platform == 'win32'" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/96/d3/f04c7bfcf5c1862a2a5b845c6b2b360488cf47af55dfa79c98f6a6bf98b5/click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de", size = 336121 } sdist = { url = "https://files.pythonhosted.org/packages/96/d3/f04c7bfcf5c1862a2a5b845c6b2b360488cf47af55dfa79c98f6a6bf98b5/click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de", size = 336121 }
wheels = [ wheels = [
@@ -208,6 +209,30 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/19/71/39c7c0d87f8d4e6c020a393182060eaefeeae6c01dab6a84ec346f2567df/rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90", size = 242424 }, { url = "https://files.pythonhosted.org/packages/19/71/39c7c0d87f8d4e6c020a393182060eaefeeae6c01dab6a84ec346f2567df/rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90", size = 242424 },
] ]
[[package]]
name = "shellingham"
version = "1.5.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755 },
]
[[package]]
name = "typer"
version = "0.15.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "rich" },
{ name = "shellingham" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8b/6f/3991f0f1c7fcb2df31aef28e0594d8d54b05393a0e4e34c65e475c2a5d41/typer-0.15.2.tar.gz", hash = "sha256:ab2fab47533a813c49fe1f16b1a370fd5819099c00b119e0633df65f22144ba5", size = 100711 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7f/fc/5b29fea8cee020515ca82cc68e3b8e1e34bb19a3535ad854cac9257b414c/typer-0.15.2-py3-none-any.whl", hash = "sha256:46a499c6107d645a9c13f7ee46c5d5096cae6f5fc57dd11eccbbb9ae3e44ddfc", size = 45061 },
]
[[package]] [[package]]
name = "typing-extensions" name = "typing-extensions"
version = "4.12.2" version = "4.12.2"
@@ -236,6 +261,7 @@ dependencies = [
{ name = "python-dotenv" }, { name = "python-dotenv" },
{ name = "requests" }, { name = "requests" },
{ name = "rich" }, { name = "rich" },
{ name = "typer" },
] ]
[package.metadata] [package.metadata]
@@ -245,4 +271,5 @@ requires-dist = [
{ name = "python-dotenv", specifier = ">=1.0.1" }, { name = "python-dotenv", specifier = ">=1.0.1" },
{ name = "requests", specifier = ">=2.32.3" }, { name = "requests", specifier = ">=2.32.3" },
{ name = "rich", specifier = ">=13.9.4" }, { name = "rich", specifier = ">=13.9.4" },
{ name = "typer", specifier = ">=0.15.2" },
] ]