Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1e53178676 | |||
| e0d3af2a4f |
@@ -0,0 +1 @@
|
|||||||
|
MUSIC_BASE_PATH=/path/to/your/music
|
||||||
@@ -23,6 +23,9 @@ dependencies = [
|
|||||||
music = "music.__main__:cli"
|
music = "music.__main__:cli"
|
||||||
stream = "music.__main__:stream"
|
stream = "music.__main__:stream"
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = ["pytest>=8.0"]
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["hatchling"]
|
requires = ["hatchling"]
|
||||||
build-backend = "hatchling.build"
|
build-backend = "hatchling.build"
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from pydantic import Field
|
from pydantic import Field
|
||||||
from pydantic_settings import BaseSettings, CliApp, CliSubCommand, get_subcommand
|
from pydantic_settings import BaseSettings, CliApp, CliSubCommand, get_subcommand
|
||||||
|
|
||||||
@@ -11,6 +13,11 @@ class Cli(BaseSettings):
|
|||||||
"cli_use_class_docs_for_groups": True,
|
"cli_use_class_docs_for_groups": True,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
music_base_path: Path = Field(
|
||||||
|
default_factory=lambda: Path.home() / "Music",
|
||||||
|
description="base music directory ($MUSIC_BASE_PATH)",
|
||||||
|
)
|
||||||
|
|
||||||
download: CliSubCommand[YtDownload] = Field(alias="yt")
|
download: CliSubCommand[YtDownload] = Field(alias="yt")
|
||||||
stream: CliSubCommand[Stream] = Field(alias="stream")
|
stream: CliSubCommand[Stream] = Field(alias="stream")
|
||||||
random: CliSubCommand[Random] = Field(alias="random")
|
random: CliSubCommand[Random] = Field(alias="random")
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
import glob
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import ClassVar
|
|
||||||
|
|
||||||
from pydantic_settings import CliImplicitFlag, CliPositionalArg
|
from pydantic_settings import CliImplicitFlag, CliPositionalArg
|
||||||
import structlog
|
import structlog
|
||||||
import yt_dlp
|
import yt_dlp
|
||||||
|
import yt_dlp.cookies
|
||||||
from mutagen.easyid3 import EasyID3
|
from mutagen.easyid3 import EasyID3
|
||||||
from pydantic import Field, computed_field
|
from pydantic import Field, computed_field
|
||||||
from shazamio import Shazam
|
from shazamio import Shazam
|
||||||
@@ -68,9 +69,16 @@ def _shazam_lookup(filepath: str) -> tuple[str | None, str | None]:
|
|||||||
return _clean(track_info.get("subtitle")), _clean(track_info.get("title"))
|
return _clean(track_info.get("subtitle")), _clean(track_info.get("title"))
|
||||||
|
|
||||||
|
|
||||||
def current_quarter() -> Path:
|
def _cookiesfrombrowser() -> tuple | None:
|
||||||
date = DateParse()
|
"""First available cookies source: firefox dev edition, firefox, then chrome."""
|
||||||
return Path().home() / "Music" / f"{date.quarter}_{date.year}"
|
for root in yt_dlp.cookies._firefox_browser_dirs():
|
||||||
|
# dev edition lives in a randomly-prefixed dir, so pass an absolute path
|
||||||
|
matches = glob.glob(os.path.join(root, "*.dev-edition-default"))
|
||||||
|
if matches:
|
||||||
|
return ("firefox", matches[0], None, None)
|
||||||
|
if any(yt_dlp.cookies._firefox_cookie_dbs(yt_dlp.cookies._firefox_browser_dirs())):
|
||||||
|
return ("firefox", None, None, None)
|
||||||
|
return ("chrome", None, None, None)
|
||||||
|
|
||||||
|
|
||||||
class YtDownload(Command):
|
class YtDownload(Command):
|
||||||
@@ -79,16 +87,6 @@ class YtDownload(Command):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
url: CliPositionalArg[str] = Field(description="youtube url to download")
|
url: CliPositionalArg[str] = Field(description="youtube url to download")
|
||||||
ydl_opts: ClassVar = {
|
|
||||||
"format": "mp3/bestaudio/best",
|
|
||||||
"postprocessors": [
|
|
||||||
{
|
|
||||||
"key": "FFmpegExtractAudio",
|
|
||||||
"preferredcodec": "mp3",
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"remote_components": ["ejs:github"],
|
|
||||||
}
|
|
||||||
parents: CliImplicitFlag[bool] = Field(
|
parents: CliImplicitFlag[bool] = Field(
|
||||||
default=True, description="create the parent dirs if not exists"
|
default=True, description="create the parent dirs if not exists"
|
||||||
)
|
)
|
||||||
@@ -97,7 +95,25 @@ class YtDownload(Command):
|
|||||||
@property
|
@property
|
||||||
def quarter_dir(self) -> Path:
|
def quarter_dir(self) -> Path:
|
||||||
date = DateParse()
|
date = DateParse()
|
||||||
return Path().home() / "Music" / f"{date.quarter}_{date.year}"
|
return self.music_base_path / f"{date.quarter}_{date.year}"
|
||||||
|
|
||||||
|
@computed_field()
|
||||||
|
@property
|
||||||
|
def ydl_opts(self) -> dict:
|
||||||
|
opts = {
|
||||||
|
"format": "mp3/bestaudio/best",
|
||||||
|
"postprocessors": [
|
||||||
|
{
|
||||||
|
"key": "FFmpegExtractAudio",
|
||||||
|
"preferredcodec": "mp3",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"remote_components": ["ejs:github"],
|
||||||
|
}
|
||||||
|
cookies = _cookiesfrombrowser()
|
||||||
|
if cookies is not None:
|
||||||
|
opts["cookiesfrombrowser"] = cookies
|
||||||
|
return opts
|
||||||
|
|
||||||
def run(self) -> None:
|
def run(self) -> None:
|
||||||
os.chdir(Path.home() / "Downloads")
|
os.chdir(Path.home() / "Downloads")
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ class Playlist(Command):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def run(self) -> None:
|
def run(self) -> None:
|
||||||
for d in sorted(seasons()):
|
for d in sorted(seasons(self.music_base_path)):
|
||||||
songs = sorted(f for f in d.iterdir() if f.suffix in EXTENSIONS)
|
songs = sorted(f for f in d.iterdir() if f.suffix in EXTENSIONS)
|
||||||
if not songs:
|
if not songs:
|
||||||
log.info("skipping empty dir", dir=d.name)
|
log.info("skipping empty dir", dir=d.name)
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ class Random(Command):
|
|||||||
def run(self):
|
def run(self):
|
||||||
"""play random local songs."""
|
"""play random local songs."""
|
||||||
songs = []
|
songs = []
|
||||||
for d in seasons():
|
for d in seasons(self.music_base_path):
|
||||||
for f in d.iterdir():
|
for f in d.iterdir():
|
||||||
if not f.suffix == ".mp3":
|
if not f.suffix == ".mp3":
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from pathlib import Path
|
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
from pydantic import Field
|
from pydantic import Field
|
||||||
import requests
|
import requests
|
||||||
import structlog
|
import structlog
|
||||||
|
|
||||||
from music.dates import DateParse
|
|
||||||
from music.models import Command
|
from music.models import Command
|
||||||
|
|
||||||
log = structlog.get_logger()
|
log = structlog.get_logger()
|
||||||
@@ -38,11 +36,6 @@ class Show(str, Enum):
|
|||||||
# el sonito
|
# el sonito
|
||||||
|
|
||||||
|
|
||||||
def current_quarter() -> Path:
|
|
||||||
date = DateParse()
|
|
||||||
return Path().home() / "Music" / f"{date.quarter}_{date.year}"
|
|
||||||
|
|
||||||
|
|
||||||
def url_for_time(t) -> str:
|
def url_for_time(t) -> str:
|
||||||
default = "https://kexp-mp3-128.streamguys1.com/kexp128.mp3"
|
default = "https://kexp-mp3-128.streamguys1.com/kexp128.mp3"
|
||||||
tz = t.strftime("%Y-%m-%dT%H:%M:%SZ")
|
tz = t.strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from pydantic import Field
|
||||||
from pydantic_settings import BaseSettings
|
from pydantic_settings import BaseSettings
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseSettings, ABC):
|
class Command(BaseSettings, ABC):
|
||||||
|
music_base_path: Path = Field(
|
||||||
|
default_factory=lambda: Path.home() / "Music",
|
||||||
|
description="base music directory",
|
||||||
|
)
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def run(self) -> None:
|
def run(self) -> None:
|
||||||
pass
|
pass
|
||||||
|
|||||||
+4
-5
@@ -4,10 +4,9 @@ from typing import Generator
|
|||||||
from music.dates import quarter_year
|
from music.dates import quarter_year
|
||||||
|
|
||||||
|
|
||||||
def seasons() -> Generator[Path, None, None]:
|
def seasons(music_base_path: Path) -> Generator[Path, None, None]:
|
||||||
prefix = ("fall_", "winter_", "spring_", "summer_")
|
prefix = ("fall_", "winter_", "spring_", "summer_")
|
||||||
music_dir = Path().home() / "Music"
|
for d in music_base_path.iterdir():
|
||||||
for d in music_dir.iterdir():
|
|
||||||
if not d.is_dir():
|
if not d.is_dir():
|
||||||
continue
|
continue
|
||||||
if not d.name.startswith(prefix):
|
if not d.name.startswith(prefix):
|
||||||
@@ -16,6 +15,6 @@ def seasons() -> Generator[Path, None, None]:
|
|||||||
yield from []
|
yield from []
|
||||||
|
|
||||||
|
|
||||||
def current_quarter() -> Path:
|
def current_quarter(music_base_path: Path) -> Path:
|
||||||
quarter, year = quarter_year()
|
quarter, year = quarter_year()
|
||||||
return Path().home() / "Music" / f"{quarter}_{year}"
|
return music_base_path / f"{quarter}_{year}"
|
||||||
|
|||||||
+5
-2
@@ -1,5 +1,6 @@
|
|||||||
import subprocess
|
import subprocess
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
from pathlib import Path
|
||||||
from .paths import seasons
|
from .paths import seasons
|
||||||
import random
|
import random
|
||||||
import requests
|
import requests
|
||||||
@@ -11,6 +12,8 @@ import os
|
|||||||
|
|
||||||
log = structlog.get_logger()
|
log = structlog.get_logger()
|
||||||
|
|
||||||
|
_DEFAULT_MUSIC_BASE_PATH = Path.home() / "Music"
|
||||||
|
|
||||||
|
|
||||||
class Stream(str, Enum):
|
class Stream(str, Enum):
|
||||||
KEXP = "kexp"
|
KEXP = "kexp"
|
||||||
@@ -54,10 +57,10 @@ def play(stream, t=None):
|
|||||||
subprocess.run(["mpv", url])
|
subprocess.run(["mpv", url])
|
||||||
|
|
||||||
|
|
||||||
def local():
|
def local(music_base_path: Path = _DEFAULT_MUSIC_BASE_PATH):
|
||||||
"""play random local songs."""
|
"""play random local songs."""
|
||||||
songs = []
|
songs = []
|
||||||
for d in seasons():
|
for d in seasons(music_base_path):
|
||||||
for f in d.iterdir():
|
for f in d.iterdir():
|
||||||
if not f.suffix == ".mp3":
|
if not f.suffix == ".mp3":
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import pytest
|
||||||
|
from io import StringIO
|
||||||
|
from contextlib import redirect_stdout
|
||||||
|
|
||||||
|
from pydantic_settings import CliApp
|
||||||
|
|
||||||
|
from music.cli import Cli
|
||||||
|
|
||||||
|
|
||||||
|
def test_help_shows_music_dir():
|
||||||
|
buf = StringIO()
|
||||||
|
with pytest.raises(SystemExit):
|
||||||
|
with redirect_stdout(buf):
|
||||||
|
CliApp.run(Cli, cli_args=["--help"])
|
||||||
|
output = buf.getvalue()
|
||||||
|
assert "--music-base-path" in output, f"--music-base-path not found in help output:\n{output}"
|
||||||
@@ -459,6 +459,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", size = 68629, upload-time = "2026-04-22T16:42:40.909Z" },
|
{ url = "https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", size = 68629, upload-time = "2026-04-22T16:42:40.909Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "iniconfig"
|
||||||
|
version = "2.3.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mpv"
|
name = "mpv"
|
||||||
version = "1.0.8"
|
version = "1.0.8"
|
||||||
@@ -585,6 +594,11 @@ dependencies = [
|
|||||||
{ name = "yt-dlp" },
|
{ name = "yt-dlp" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[package.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
{ name = "pytest" },
|
||||||
|
]
|
||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "audioop-lts", marker = "python_full_version >= '3.13'", specifier = ">=0.2.2" },
|
{ name = "audioop-lts", marker = "python_full_version >= '3.13'", specifier = ">=0.2.2" },
|
||||||
@@ -594,11 +608,13 @@ requires-dist = [
|
|||||||
{ name = "mutagen", specifier = ">=1.47.0" },
|
{ name = "mutagen", specifier = ">=1.47.0" },
|
||||||
{ name = "pydantic", specifier = ">=2.0" },
|
{ name = "pydantic", specifier = ">=2.0" },
|
||||||
{ name = "pydantic-settings", specifier = ">=2.12.0" },
|
{ name = "pydantic-settings", specifier = ">=2.12.0" },
|
||||||
|
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" },
|
||||||
{ name = "requests", specifier = ">=2.32.3" },
|
{ name = "requests", specifier = ">=2.32.3" },
|
||||||
{ name = "shazamio", specifier = ">=0.8.1" },
|
{ name = "shazamio", specifier = ">=0.8.1" },
|
||||||
{ name = "structlog", specifier = ">=25.5.0" },
|
{ name = "structlog", specifier = ">=25.5.0" },
|
||||||
{ name = "yt-dlp", specifier = ">=2025.1.15" },
|
{ name = "yt-dlp", specifier = ">=2025.1.15" },
|
||||||
]
|
]
|
||||||
|
provides-extras = ["dev"]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mutagen"
|
name = "mutagen"
|
||||||
@@ -670,6 +686,24 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/58/78/548fb8e07b1a341746bfbecb32f2c268470f45fa028aacdbd10d9bc73aab/numpy-2.4.4-cp314-cp314t-win_arm64.whl", hash = "sha256:ba203255017337d39f89bdd58417f03c4426f12beed0440cfd933cb15f8669c7", size = 10566643, upload-time = "2026-03-29T13:21:34.339Z" },
|
{ url = "https://files.pythonhosted.org/packages/58/78/548fb8e07b1a341746bfbecb32f2c268470f45fa028aacdbd10d9bc73aab/numpy-2.4.4-cp314-cp314t-win_arm64.whl", hash = "sha256:ba203255017337d39f89bdd58417f03c4426f12beed0440cfd933cb15f8669c7", size = 10566643, upload-time = "2026-03-29T13:21:34.339Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "packaging"
|
||||||
|
version = "26.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pluggy"
|
||||||
|
version = "1.6.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "propcache"
|
name = "propcache"
|
||||||
version = "0.5.2"
|
version = "0.5.2"
|
||||||
@@ -877,6 +911,31 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/a6/53/d78dc063216e62fc55f6b2eebb447f6a4b0a59f55c8406376f76bf959b08/pydub-0.25.1-py2.py3-none-any.whl", hash = "sha256:65617e33033874b59d87db603aa1ed450633288aefead953b30bded59cb599a6", size = 32327, upload-time = "2021-03-10T02:09:53.503Z" },
|
{ url = "https://files.pythonhosted.org/packages/a6/53/d78dc063216e62fc55f6b2eebb447f6a4b0a59f55c8406376f76bf959b08/pydub-0.25.1-py2.py3-none-any.whl", hash = "sha256:65617e33033874b59d87db603aa1ed450633288aefead953b30bded59cb599a6", size = 32327, upload-time = "2021-03-10T02:09:53.503Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pygments"
|
||||||
|
version = "2.20.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pytest"
|
||||||
|
version = "9.1.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
|
{ name = "iniconfig" },
|
||||||
|
{ name = "packaging" },
|
||||||
|
{ name = "pluggy" },
|
||||||
|
{ name = "pygments" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "python-dotenv"
|
name = "python-dotenv"
|
||||||
version = "1.2.2"
|
version = "1.2.2"
|
||||||
|
|||||||
Reference in New Issue
Block a user