[BACKEND] Windows support (#476)
This commit is contained in:
parent
a2808fa7fe
commit
798bbbc62c
29 changed files with 438 additions and 156 deletions
125
.github/workflows/ci-windows.yaml
vendored
Normal file
125
.github/workflows/ci-windows.yaml
vendored
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
name: ytld-sub CI (Windows)
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
jobs:
|
||||||
|
test-unit:
|
||||||
|
runs-on: windows-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v4
|
||||||
|
with:
|
||||||
|
python-version: "3.10"
|
||||||
|
|
||||||
|
- name: Run unit tests with coverage
|
||||||
|
run: |
|
||||||
|
curl.exe -L -o ffmpeg.zip https://github.com/yt-dlp/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-win64-gpl.zip
|
||||||
|
tar -xf ffmpeg.zip
|
||||||
|
move "ffmpeg-master-latest-win64-gpl\bin\ffmpeg.exe" "ffmpeg.exe"
|
||||||
|
move "ffmpeg-master-latest-win64-gpl\bin\ffprobe.exe" "ffprobe.exe"
|
||||||
|
|
||||||
|
python -m pip install -e .[test]
|
||||||
|
python -m pytest tests/unit
|
||||||
|
|
||||||
|
test-soundcloud:
|
||||||
|
runs-on: windows-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v4
|
||||||
|
with:
|
||||||
|
python-version: "3.10"
|
||||||
|
|
||||||
|
- name: Run e2e soundcloud tests with coverage
|
||||||
|
run: |
|
||||||
|
curl.exe -L -o ffmpeg.zip https://github.com/yt-dlp/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-win64-gpl.zip
|
||||||
|
tar -xf ffmpeg.zip
|
||||||
|
move "ffmpeg-master-latest-win64-gpl\bin\ffmpeg.exe" "ffmpeg.exe"
|
||||||
|
move "ffmpeg-master-latest-win64-gpl\bin\ffprobe.exe" "ffprobe.exe"
|
||||||
|
|
||||||
|
python -m pip install -e .[test]
|
||||||
|
python -m pytest tests/e2e/soundcloud
|
||||||
|
|
||||||
|
test-bandcamp:
|
||||||
|
runs-on: windows-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v4
|
||||||
|
with:
|
||||||
|
python-version: "3.10"
|
||||||
|
|
||||||
|
- name: Run e2e soundcloud tests with coverage
|
||||||
|
run: |
|
||||||
|
curl.exe -L -o ffmpeg.zip https://github.com/yt-dlp/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-win64-gpl.zip
|
||||||
|
tar -xf ffmpeg.zip
|
||||||
|
move "ffmpeg-master-latest-win64-gpl\bin\ffmpeg.exe" "ffmpeg.exe"
|
||||||
|
move "ffmpeg-master-latest-win64-gpl\bin\ffprobe.exe" "ffprobe.exe"
|
||||||
|
|
||||||
|
python -m pip install -e .[test]
|
||||||
|
python -m pytest tests/e2e/bandcamp
|
||||||
|
|
||||||
|
|
||||||
|
test-youtube:
|
||||||
|
runs-on: windows-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v4
|
||||||
|
with:
|
||||||
|
python-version: "3.10"
|
||||||
|
|
||||||
|
- name: Run e2e youtube tests with coverage
|
||||||
|
run: |
|
||||||
|
curl.exe -L -o ffmpeg.zip https://github.com/yt-dlp/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-win64-gpl.zip
|
||||||
|
tar -xf ffmpeg.zip
|
||||||
|
move "ffmpeg-master-latest-win64-gpl\bin\ffmpeg.exe" "ffmpeg.exe"
|
||||||
|
move "ffmpeg-master-latest-win64-gpl\bin\ffprobe.exe" "ffprobe.exe"
|
||||||
|
|
||||||
|
python -m pip install -e .[test]
|
||||||
|
python -m pytest tests/e2e/youtube
|
||||||
|
|
||||||
|
test-plugins:
|
||||||
|
runs-on: windows-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v4
|
||||||
|
with:
|
||||||
|
python-version: "3.10"
|
||||||
|
|
||||||
|
- name: Run e2e plugin tests with coverage
|
||||||
|
run: |
|
||||||
|
curl.exe -L -o ffmpeg.zip https://github.com/yt-dlp/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-win64-gpl.zip
|
||||||
|
tar -xf ffmpeg.zip
|
||||||
|
move "ffmpeg-master-latest-win64-gpl\bin\ffmpeg.exe" "ffmpeg.exe"
|
||||||
|
move "ffmpeg-master-latest-win64-gpl\bin\ffprobe.exe" "ffprobe.exe"
|
||||||
|
|
||||||
|
python -m pip install -e .[test]
|
||||||
|
python -m pytest tests/e2e/plugins
|
||||||
86
.github/workflows/ci.yaml
vendored
86
.github/workflows/ci.yaml
vendored
|
|
@ -1,4 +1,4 @@
|
||||||
name: ytld-sub CI
|
name: ytld-sub CI (Linux)
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
|
|
@ -8,70 +8,42 @@ on:
|
||||||
branches:
|
branches:
|
||||||
- master
|
- master
|
||||||
jobs:
|
jobs:
|
||||||
test-build:
|
|
||||||
runs-on: ubuntu-22.04
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
python-version: ["3.10"]
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v3
|
|
||||||
|
|
||||||
- name: Set up Python ${{ matrix.python-version }}
|
|
||||||
uses: actions/setup-python@v4
|
|
||||||
with:
|
|
||||||
python-version: ${{ matrix.python-version }}
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: |
|
|
||||||
python -m venv /opt/env
|
|
||||||
source /opt/env/bin/activate
|
|
||||||
pip install -e .[lint,test]
|
|
||||||
- name: Save Python build cache
|
|
||||||
uses: actions/cache@v3
|
|
||||||
with:
|
|
||||||
path: /opt/env
|
|
||||||
key: ${{github.sha}}-env
|
|
||||||
|
|
||||||
test-lint:
|
test-lint:
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
needs: test-build
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
- name: Restore Python build cache
|
- name: Set up Python
|
||||||
uses: actions/cache@v3
|
uses: actions/setup-python@v4
|
||||||
with:
|
with:
|
||||||
path: /opt/env
|
python-version: "3.10"
|
||||||
key: ${{github.sha}}-env
|
|
||||||
|
|
||||||
- name: Run linters
|
- name: Run linters
|
||||||
run: |
|
run: |
|
||||||
source /opt/env/bin/activate
|
pip install -e .[lint]
|
||||||
make check_lint
|
make check_lint
|
||||||
|
|
||||||
test-unit:
|
test-unit:
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
needs: test-build
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
- name: Restore Python build cache
|
- name: Set up Python
|
||||||
uses: actions/cache@v3
|
uses: actions/setup-python@v4
|
||||||
with:
|
with:
|
||||||
path: /opt/env
|
python-version: "3.10"
|
||||||
key: ${{github.sha}}-env
|
|
||||||
|
|
||||||
- name: Run unit tests with coverage
|
- name: Run unit tests with coverage
|
||||||
run: |
|
run: |
|
||||||
|
pip install -e .[test]
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get install -y ffmpeg
|
sudo apt-get install -y ffmpeg
|
||||||
source /opt/env/bin/activate
|
|
||||||
coverage run -m pytest tests/unit && coverage xml -o /opt/coverage/unit/coverage.xml
|
coverage run -m pytest tests/unit && coverage xml -o /opt/coverage/unit/coverage.xml
|
||||||
|
|
||||||
- name: Save coverage
|
- name: Save coverage
|
||||||
|
|
@ -82,46 +54,42 @@ jobs:
|
||||||
|
|
||||||
test-soundcloud:
|
test-soundcloud:
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
needs: test-build
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
- name: Restore Python build cache
|
- name: Set up Python
|
||||||
uses: actions/cache@v3
|
uses: actions/setup-python@v4
|
||||||
with:
|
with:
|
||||||
path: /opt/env
|
python-version: "3.10"
|
||||||
key: ${{github.sha}}-env
|
|
||||||
|
|
||||||
- name: Run e2e soundcloud tests with coverage
|
- name: Run e2e soundcloud tests with coverage
|
||||||
run: |
|
run: |
|
||||||
|
pip install -e .[test]
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get install -y ffmpeg
|
sudo apt-get install -y ffmpeg
|
||||||
source /opt/env/bin/activate
|
|
||||||
coverage run -m pytest tests/e2e/soundcloud && coverage xml -o /opt/coverage/soundcloud/coverage.xml
|
coverage run -m pytest tests/e2e/soundcloud && coverage xml -o /opt/coverage/soundcloud/coverage.xml
|
||||||
|
|
||||||
test-bandcamp:
|
test-bandcamp:
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
needs: test-build
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
- name: Restore Python build cache
|
- name: Set up Python
|
||||||
uses: actions/cache@v3
|
uses: actions/setup-python@v4
|
||||||
with:
|
with:
|
||||||
path: /opt/env
|
python-version: "3.10"
|
||||||
key: ${{github.sha}}-env
|
|
||||||
|
|
||||||
- name: Run e2e soundcloud tests with coverage
|
- name: Run e2e soundcloud tests with coverage
|
||||||
run: |
|
run: |
|
||||||
|
pip install -e .[test]
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get install -y ffmpeg
|
sudo apt-get install -y ffmpeg
|
||||||
source /opt/env/bin/activate
|
|
||||||
coverage run -m pytest tests/e2e/bandcamp && coverage xml -o /opt/coverage/bandcamp/coverage.xml
|
coverage run -m pytest tests/e2e/bandcamp && coverage xml -o /opt/coverage/bandcamp/coverage.xml
|
||||||
|
|
||||||
- name: Save coverage
|
- name: Save coverage
|
||||||
|
|
@ -132,24 +100,22 @@ jobs:
|
||||||
|
|
||||||
test-youtube:
|
test-youtube:
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
needs: test-build
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
- name: Restore Python build cache
|
- name: Set up Python
|
||||||
uses: actions/cache@v3
|
uses: actions/setup-python@v4
|
||||||
with:
|
with:
|
||||||
path: /opt/env
|
python-version: "3.10"
|
||||||
key: ${{github.sha}}-env
|
|
||||||
|
|
||||||
- name: Run e2e youtube tests with coverage
|
- name: Run e2e youtube tests with coverage
|
||||||
run: |
|
run: |
|
||||||
|
pip install -e .[test]
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get install -y ffmpeg
|
sudo apt-get install -y ffmpeg
|
||||||
source /opt/env/bin/activate
|
|
||||||
coverage run -m pytest tests/e2e/youtube && coverage xml -o /opt/coverage/youtube/coverage.xml
|
coverage run -m pytest tests/e2e/youtube && coverage xml -o /opt/coverage/youtube/coverage.xml
|
||||||
|
|
||||||
- name: Save coverage
|
- name: Save coverage
|
||||||
|
|
@ -160,24 +126,22 @@ jobs:
|
||||||
|
|
||||||
test-plugins:
|
test-plugins:
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
needs: test-build
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
- name: Restore Python build cache
|
- name: Set up Python
|
||||||
uses: actions/cache@v3
|
uses: actions/setup-python@v4
|
||||||
with:
|
with:
|
||||||
path: /opt/env
|
python-version: "3.10"
|
||||||
key: ${{github.sha}}-env
|
|
||||||
|
|
||||||
- name: Run e2e plugin tests with coverage
|
- name: Run e2e plugin tests with coverage
|
||||||
run: |
|
run: |
|
||||||
|
pip install -e .[test]
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get install -y ffmpeg
|
sudo apt-get install -y ffmpeg
|
||||||
source /opt/env/bin/activate
|
|
||||||
coverage run -m pytest tests/e2e/plugins && coverage xml -o /opt/coverage/plugins/coverage.xml
|
coverage run -m pytest tests/e2e/plugins && coverage xml -o /opt/coverage/plugins/coverage.xml
|
||||||
|
|
||||||
- name: Save coverage
|
- name: Save coverage
|
||||||
|
|
|
||||||
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -142,4 +142,7 @@ docker/*.whl
|
||||||
docker/root/*.whl
|
docker/root/*.whl
|
||||||
docker/root/defaults/examples
|
docker/root/defaults/examples
|
||||||
|
|
||||||
.local/
|
.local/
|
||||||
|
|
||||||
|
ffmpeg.exe
|
||||||
|
ffprobe.exe
|
||||||
|
|
@ -34,11 +34,11 @@ maximum flexibility while maintaining simplicity.
|
||||||

|

|
||||||
|
|
||||||
### SoundCloud Albums and Singles
|
### SoundCloud Albums and Singles
|
||||||
#### MusicBee (any file or tag-based music player)
|
#### MusicBee (any file or tag-based music players)
|
||||||

|

|
||||||
|
|
||||||
### Bandcamp Discography
|
### Bandcamp Discography
|
||||||
#### Navidrome (any file or tag-based music server)
|
#### Navidrome (any file or tag-based music servers)
|
||||||

|

|
||||||
|
|
||||||
|
|
||||||
|
|
@ -285,7 +285,7 @@ docker run -d \
|
||||||
Download and use our latest executable using the command below. For Windows users, use this method in
|
Download and use our latest executable using the command below. For Windows users, use this method in
|
||||||
[WSL](https://learn.microsoft.com/en-us/windows/wsl/). FFmpeg is a required dependency.
|
[WSL](https://learn.microsoft.com/en-us/windows/wsl/). FFmpeg is a required dependency.
|
||||||
```commandline
|
```commandline
|
||||||
curl -L https://github.com/jmbannon/ytdl-sub/releases/latest/download/ytdl-sub > ytdl-sub
|
curl -L -o ytdl-sub https://github.com/jmbannon/ytdl-sub/releases/latest/download/ytdl-sub
|
||||||
chmod +x ytdl-sub
|
chmod +x ytdl-sub
|
||||||
./ytdl-sub -h
|
./ytdl-sub -h
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,6 @@
|
||||||
import argparse
|
import argparse
|
||||||
import errno
|
|
||||||
import fcntl
|
|
||||||
import gc
|
import gc
|
||||||
import os
|
|
||||||
import sys
|
import sys
|
||||||
from contextlib import contextmanager
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import List
|
from typing import List
|
||||||
from typing import Tuple
|
from typing import Tuple
|
||||||
|
|
||||||
|
|
@ -13,8 +8,8 @@ from ytdl_sub.cli.download_args_parser import DownloadArgsParser
|
||||||
from ytdl_sub.cli.main_args_parser import parser
|
from ytdl_sub.cli.main_args_parser import parser
|
||||||
from ytdl_sub.config.config_file import ConfigFile
|
from ytdl_sub.config.config_file import ConfigFile
|
||||||
from ytdl_sub.subscriptions.subscription import Subscription
|
from ytdl_sub.subscriptions.subscription import Subscription
|
||||||
from ytdl_sub.utils.exceptions import ValidationException
|
|
||||||
from ytdl_sub.utils.file_handler import FileHandlerTransactionLog
|
from ytdl_sub.utils.file_handler import FileHandlerTransactionLog
|
||||||
|
from ytdl_sub.utils.file_lock import working_directory_lock
|
||||||
from ytdl_sub.utils.logger import Logger
|
from ytdl_sub.utils.logger import Logger
|
||||||
|
|
||||||
logger = Logger.get()
|
logger = Logger.get()
|
||||||
|
|
@ -119,45 +114,6 @@ def _view_url_from_cli(
|
||||||
return subscription, subscription.download(dry_run=True)
|
return subscription, subscription.download(dry_run=True)
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
|
||||||
def _working_directory_lock(config: ConfigFile):
|
|
||||||
"""
|
|
||||||
Create and try to lock the file /tmp/working_directory_name
|
|
||||||
|
|
||||||
Raises
|
|
||||||
------
|
|
||||||
ValidationException
|
|
||||||
Lock is acquired from another process running ytdl-sub in the same working directory
|
|
||||||
OSError
|
|
||||||
Other lock error occurred
|
|
||||||
"""
|
|
||||||
working_directory_path = Path(os.getcwd()) / config.config_options.working_directory
|
|
||||||
lock_file_path = (
|
|
||||||
Path(os.getcwd())
|
|
||||||
/ config.config_options.lock_directory
|
|
||||||
/ str(working_directory_path).replace("/", "_")
|
|
||||||
)
|
|
||||||
|
|
||||||
lock_file = open(lock_file_path, "w", encoding="utf-8")
|
|
||||||
|
|
||||||
try:
|
|
||||||
fcntl.lockf(lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
||||||
except OSError as exc:
|
|
||||||
if exc.errno in (errno.EACCES, errno.EAGAIN):
|
|
||||||
raise ValidationException(
|
|
||||||
"Cannot run two instances of ytdl-sub "
|
|
||||||
"with the same working directory at the same time"
|
|
||||||
) from exc
|
|
||||||
lock_file.close()
|
|
||||||
raise exc
|
|
||||||
|
|
||||||
try:
|
|
||||||
yield
|
|
||||||
finally:
|
|
||||||
fcntl.flock(lock_file, fcntl.LOCK_UN)
|
|
||||||
lock_file.close()
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> List[Tuple[Subscription, FileHandlerTransactionLog]]:
|
def main() -> List[Tuple[Subscription, FileHandlerTransactionLog]]:
|
||||||
"""
|
"""
|
||||||
Entrypoint for ytdl-sub, without the error handling
|
Entrypoint for ytdl-sub, without the error handling
|
||||||
|
|
@ -172,7 +128,7 @@ def main() -> List[Tuple[Subscription, FileHandlerTransactionLog]]:
|
||||||
config: ConfigFile = ConfigFile.from_file_path(args.config).initialize()
|
config: ConfigFile = ConfigFile.from_file_path(args.config).initialize()
|
||||||
transaction_logs: List[Tuple[Subscription, FileHandlerTransactionLog]] = []
|
transaction_logs: List[Tuple[Subscription, FileHandlerTransactionLog]] = []
|
||||||
|
|
||||||
with _working_directory_lock(config=config):
|
with working_directory_lock(config=config):
|
||||||
if args.subparser == "sub":
|
if args.subparser == "sub":
|
||||||
transaction_logs = _download_subscriptions_from_yaml_files(config=config, args=args)
|
transaction_logs = _download_subscriptions_from_yaml_files(config=config, args=args)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,14 +5,24 @@ from typing import Optional
|
||||||
from mergedeep import mergedeep
|
from mergedeep import mergedeep
|
||||||
|
|
||||||
from ytdl_sub.prebuilt_presets import PREBUILT_PRESETS
|
from ytdl_sub.prebuilt_presets import PREBUILT_PRESETS
|
||||||
|
from ytdl_sub.utils.system import IS_WINDOWS
|
||||||
from ytdl_sub.validators.strict_dict_validator import StrictDictValidator
|
from ytdl_sub.validators.strict_dict_validator import StrictDictValidator
|
||||||
from ytdl_sub.validators.validators import LiteralDictValidator
|
from ytdl_sub.validators.validators import LiteralDictValidator
|
||||||
from ytdl_sub.validators.validators import StringValidator
|
from ytdl_sub.validators.validators import StringValidator
|
||||||
|
|
||||||
|
if IS_WINDOWS:
|
||||||
|
_DEFAULT_LOCK_DIRECTORY = "" # Not supported in Windows
|
||||||
|
_DEFAULT_FFMPEG_PATH = ".\\ffmpeg.exe"
|
||||||
|
_DEFAULT_FFPROBE_PATH = ".\\ffprobe.exe"
|
||||||
|
else:
|
||||||
|
_DEFAULT_LOCK_DIRECTORY = "/tmp"
|
||||||
|
_DEFAULT_FFMPEG_PATH = "/usr/bin/ffmpeg"
|
||||||
|
_DEFAULT_FFPROBE_PATH = "/usr/bin/ffprobe"
|
||||||
|
|
||||||
|
|
||||||
class ConfigOptions(StrictDictValidator):
|
class ConfigOptions(StrictDictValidator):
|
||||||
_required_keys = {"working_directory"}
|
_required_keys = {"working_directory"}
|
||||||
_optional_keys = {"umask", "dl_aliases", "lock_directory"}
|
_optional_keys = {"umask", "dl_aliases", "lock_directory", "ffmpeg_path", "ffprobe_path"}
|
||||||
|
|
||||||
def __init__(self, name: str, value: Any):
|
def __init__(self, name: str, value: Any):
|
||||||
super().__init__(name, value)
|
super().__init__(name, value)
|
||||||
|
|
@ -27,7 +37,14 @@ class ConfigOptions(StrictDictValidator):
|
||||||
key="dl_aliases", validator=LiteralDictValidator
|
key="dl_aliases", validator=LiteralDictValidator
|
||||||
)
|
)
|
||||||
self._lock_directory = self._validate_key(
|
self._lock_directory = self._validate_key(
|
||||||
key="lock_directory", validator=StringValidator, default="/tmp"
|
key="lock_directory", validator=StringValidator, default=_DEFAULT_LOCK_DIRECTORY
|
||||||
|
)
|
||||||
|
# TODO: Validate these exist
|
||||||
|
self._ffmpeg_path = self._validate_key(
|
||||||
|
key="ffmpeg_path", validator=StringValidator, default=_DEFAULT_FFMPEG_PATH
|
||||||
|
)
|
||||||
|
self._ffprobe_path = self._validate_key(
|
||||||
|
key="ffprobe_path", validator=StringValidator, default=_DEFAULT_FFPROBE_PATH
|
||||||
)
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|
@ -82,6 +99,20 @@ class ConfigOptions(StrictDictValidator):
|
||||||
"""
|
"""
|
||||||
return self._lock_directory.value
|
return self._lock_directory.value
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ffmpeg_path(self) -> str:
|
||||||
|
"""
|
||||||
|
TODO: Fill out!
|
||||||
|
"""
|
||||||
|
return self._ffmpeg_path.value
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ffprobe_path(self) -> str:
|
||||||
|
"""
|
||||||
|
TODO: Fill out!
|
||||||
|
"""
|
||||||
|
return self._ffprobe_path.value
|
||||||
|
|
||||||
|
|
||||||
class ConfigValidator(StrictDictValidator):
|
class ConfigValidator(StrictDictValidator):
|
||||||
_required_keys = {"configuration", "presets"}
|
_required_keys = {"configuration", "presets"}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,10 @@ from typing import Optional
|
||||||
from yt_dlp.utils import sanitize_filename
|
from yt_dlp.utils import sanitize_filename
|
||||||
|
|
||||||
from ytdl_sub.entries.entry import Entry
|
from ytdl_sub.entries.entry import Entry
|
||||||
|
from ytdl_sub.validators.file_path_validators import (
|
||||||
|
OverridesStringFormatterValidatorFilePathValidator,
|
||||||
|
)
|
||||||
|
from ytdl_sub.validators.file_path_validators import StringFormatterFilePathValidator
|
||||||
from ytdl_sub.validators.strict_dict_validator import StrictDictValidator
|
from ytdl_sub.validators.strict_dict_validator import StrictDictValidator
|
||||||
from ytdl_sub.validators.string_datetime import StringDatetimeValidator
|
from ytdl_sub.validators.string_datetime import StringDatetimeValidator
|
||||||
from ytdl_sub.validators.string_formatter_validators import DictFormatterValidator
|
from ytdl_sub.validators.string_formatter_validators import DictFormatterValidator
|
||||||
|
|
@ -221,19 +225,19 @@ class OutputOptions(StrictDictValidator):
|
||||||
|
|
||||||
# Output directory should resolve without any entry variables.
|
# Output directory should resolve without any entry variables.
|
||||||
# This is to check the directory for any download-archives before any downloads begin
|
# This is to check the directory for any download-archives before any downloads begin
|
||||||
self._output_directory: OverridesStringFormatterValidator = self._validate_key(
|
self._output_directory = self._validate_key(
|
||||||
key="output_directory", validator=OverridesStringFormatterValidator
|
key="output_directory", validator=OverridesStringFormatterValidatorFilePathValidator
|
||||||
)
|
)
|
||||||
|
|
||||||
# file name and thumbnails however can use entry variables
|
# file name and thumbnails however can use entry variables
|
||||||
self._file_name: StringFormatterValidator = self._validate_key(
|
self._file_name = self._validate_key(
|
||||||
key="file_name", validator=StringFormatterValidator
|
key="file_name", validator=StringFormatterFilePathValidator
|
||||||
)
|
)
|
||||||
self._thumbnail_name = self._validate_key_if_present(
|
self._thumbnail_name = self._validate_key_if_present(
|
||||||
key="thumbnail_name", validator=StringFormatterValidator
|
key="thumbnail_name", validator=StringFormatterFilePathValidator
|
||||||
)
|
)
|
||||||
self._info_json_name = self._validate_key_if_present(
|
self._info_json_name = self._validate_key_if_present(
|
||||||
key="info_json_name", validator=StringFormatterValidator
|
key="info_json_name", validator=StringFormatterFilePathValidator
|
||||||
)
|
)
|
||||||
|
|
||||||
self._maintain_download_archive = self._validate_key_if_present(
|
self._maintain_download_archive = self._validate_key_if_present(
|
||||||
|
|
|
||||||
|
|
@ -439,7 +439,7 @@ class Downloader(DownloadArchiver, Generic[DownloaderOptionsT], ABC):
|
||||||
if path.endswith(".info.json")
|
if path.endswith(".info.json")
|
||||||
]
|
]
|
||||||
for info_json_file in info_json_files:
|
for info_json_file in info_json_files:
|
||||||
os.remove(info_json_file)
|
FileHandler.delete(info_json_file)
|
||||||
|
|
||||||
def _extract_entry_info_with_retry(self, entry: Entry) -> Entry:
|
def _extract_entry_info_with_retry(self, entry: Entry) -> Entry:
|
||||||
download_entry_dict = self.extract_info_with_retry(
|
download_entry_dict = self.extract_info_with_retry(
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ from ytdl_sub.utils.xml import XmlElement
|
||||||
from ytdl_sub.utils.xml import to_max_3_byte_utf8_dict
|
from ytdl_sub.utils.xml import to_max_3_byte_utf8_dict
|
||||||
from ytdl_sub.utils.xml import to_max_3_byte_utf8_string
|
from ytdl_sub.utils.xml import to_max_3_byte_utf8_string
|
||||||
from ytdl_sub.utils.xml import to_xml
|
from ytdl_sub.utils.xml import to_xml
|
||||||
|
from ytdl_sub.validators.file_path_validators import StringFormatterFilePathValidator
|
||||||
from ytdl_sub.validators.nfo_validators import NfoTagsValidator
|
from ytdl_sub.validators.nfo_validators import NfoTagsValidator
|
||||||
from ytdl_sub.validators.string_formatter_validators import DictFormatterValidator
|
from ytdl_sub.validators.string_formatter_validators import DictFormatterValidator
|
||||||
from ytdl_sub.validators.string_formatter_validators import StringFormatterValidator
|
from ytdl_sub.validators.string_formatter_validators import StringFormatterValidator
|
||||||
|
|
@ -46,7 +47,7 @@ class SharedNfoTagsOptions(PluginOptions):
|
||||||
super().__init__(name, value)
|
super().__init__(name, value)
|
||||||
|
|
||||||
self._nfo_name = self._validate_key_if_present(
|
self._nfo_name = self._validate_key_if_present(
|
||||||
key="nfo_name", validator=StringFormatterValidator
|
key="nfo_name", validator=StringFormatterFilePathValidator
|
||||||
)
|
)
|
||||||
self._nfo_root = self._validate_key_if_present(
|
self._nfo_root = self._validate_key_if_present(
|
||||||
key="nfo_root", validator=StringFormatterValidator
|
key="nfo_root", validator=StringFormatterValidator
|
||||||
|
|
@ -57,7 +58,7 @@ class SharedNfoTagsOptions(PluginOptions):
|
||||||
).value
|
).value
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def nfo_name(self) -> StringFormatterValidator:
|
def nfo_name(self) -> StringFormatterFilePathValidator:
|
||||||
"""
|
"""
|
||||||
The NFO file name.
|
The NFO file name.
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -168,7 +168,10 @@ class SplitByChaptersPlugin(Plugin[SplitByChaptersOptions]):
|
||||||
if self.is_dry_run:
|
if self.is_dry_run:
|
||||||
chapters = Chapters.from_entry_chapters(entry=entry)
|
chapters = Chapters.from_entry_chapters(entry=entry)
|
||||||
else:
|
else:
|
||||||
chapters = Chapters.from_embedded_chapters(file_path=entry.get_download_file_path())
|
chapters = Chapters.from_embedded_chapters(
|
||||||
|
ffprobe_path=FFMPEG.ffprobe_path(),
|
||||||
|
file_path=entry.get_download_file_path(),
|
||||||
|
)
|
||||||
|
|
||||||
# If no chapters, do not split anything
|
# If no chapters, do not split anything
|
||||||
if not chapters.contains_any_chapters():
|
if not chapters.contains_any_chapters():
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ from ytdl_sub.plugins.plugin import PluginOptions
|
||||||
from ytdl_sub.utils.file_handler import FileHandler
|
from ytdl_sub.utils.file_handler import FileHandler
|
||||||
from ytdl_sub.utils.file_handler import FileMetadata
|
from ytdl_sub.utils.file_handler import FileMetadata
|
||||||
from ytdl_sub.utils.logger import Logger
|
from ytdl_sub.utils.logger import Logger
|
||||||
|
from ytdl_sub.validators.file_path_validators import StringFormatterFilePathValidator
|
||||||
from ytdl_sub.validators.string_formatter_validators import StringFormatterValidator
|
from ytdl_sub.validators.string_formatter_validators import StringFormatterValidator
|
||||||
from ytdl_sub.validators.string_select_validator import StringSelectValidator
|
from ytdl_sub.validators.string_select_validator import StringSelectValidator
|
||||||
from ytdl_sub.validators.validators import BoolValidator
|
from ytdl_sub.validators.validators import BoolValidator
|
||||||
|
|
@ -58,7 +59,7 @@ class SubtitleOptions(PluginOptions):
|
||||||
def __init__(self, name, value):
|
def __init__(self, name, value):
|
||||||
super().__init__(name, value)
|
super().__init__(name, value)
|
||||||
self._subtitles_name = self._validate_key_if_present(
|
self._subtitles_name = self._validate_key_if_present(
|
||||||
key="subtitles_name", validator=StringFormatterValidator
|
key="subtitles_name", validator=StringFormatterFilePathValidator
|
||||||
)
|
)
|
||||||
self._subtitles_type = self._validate_key_if_present(
|
self._subtitles_type = self._validate_key_if_present(
|
||||||
key="subtitles_type", validator=SubtitlesTypeValidator, default="srt"
|
key="subtitles_type", validator=SubtitlesTypeValidator, default="srt"
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ from ytdl_sub.subscriptions.base_subscription import BaseSubscription
|
||||||
from ytdl_sub.subscriptions.subscription_ytdl_options import SubscriptionYTDLOptions
|
from ytdl_sub.subscriptions.subscription_ytdl_options import SubscriptionYTDLOptions
|
||||||
from ytdl_sub.utils.datetime import to_date_range
|
from ytdl_sub.utils.datetime import to_date_range
|
||||||
from ytdl_sub.utils.exceptions import ValidationException
|
from ytdl_sub.utils.exceptions import ValidationException
|
||||||
|
from ytdl_sub.utils.ffmpeg import FFMPEG
|
||||||
from ytdl_sub.utils.file_handler import FileHandler
|
from ytdl_sub.utils.file_handler import FileHandler
|
||||||
from ytdl_sub.utils.file_handler import FileHandlerTransactionLog
|
from ytdl_sub.utils.file_handler import FileHandlerTransactionLog
|
||||||
from ytdl_sub.utils.file_handler import FileMetadata
|
from ytdl_sub.utils.file_handler import FileMetadata
|
||||||
|
|
@ -265,6 +266,12 @@ class SubscriptionDownload(BaseSubscription, ABC):
|
||||||
If true, do not download any video/audio files or move anything to the output
|
If true, do not download any video/audio files or move anything to the output
|
||||||
directory.
|
directory.
|
||||||
"""
|
"""
|
||||||
|
# Set ffmpeg paths
|
||||||
|
FFMPEG.set_paths(
|
||||||
|
ffmpeg_path=self._config_options.ffmpeg_path,
|
||||||
|
ffprobe_path=self._config_options.ffprobe_path,
|
||||||
|
)
|
||||||
|
|
||||||
self._enhanced_download_archive.reinitialize(dry_run=dry_run)
|
self._enhanced_download_archive.reinitialize(dry_run=dry_run)
|
||||||
plugins = self._initialize_plugins()
|
plugins = self._initialize_plugins()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ from ytdl_sub.plugins.file_convert import FileConvertPlugin
|
||||||
from ytdl_sub.plugins.match_filters import MatchFiltersPlugin
|
from ytdl_sub.plugins.match_filters import MatchFiltersPlugin
|
||||||
from ytdl_sub.plugins.plugin import Plugin
|
from ytdl_sub.plugins.plugin import Plugin
|
||||||
from ytdl_sub.plugins.subtitles import SubtitlesPlugin
|
from ytdl_sub.plugins.subtitles import SubtitlesPlugin
|
||||||
|
from ytdl_sub.utils.ffmpeg import FFMPEG
|
||||||
from ytdl_sub.ytdl_additions.enhanced_download_archive import EnhancedDownloadArchive
|
from ytdl_sub.ytdl_additions.enhanced_download_archive import EnhancedDownloadArchive
|
||||||
|
|
||||||
PluginT = TypeVar("PluginT", bound=Plugin)
|
PluginT = TypeVar("PluginT", bound=Plugin)
|
||||||
|
|
@ -57,6 +58,7 @@ class SubscriptionYTDLOptions:
|
||||||
"outtmpl": str(Path(self._working_directory) / "%(id)s.%(ext)s"),
|
"outtmpl": str(Path(self._working_directory) / "%(id)s.%(ext)s"),
|
||||||
# Always write thumbnails
|
# Always write thumbnails
|
||||||
"writethumbnail": True,
|
"writethumbnail": True,
|
||||||
|
"ffmpeg_location": FFMPEG.ffmpeg_path(),
|
||||||
}
|
}
|
||||||
|
|
||||||
return ytdl_options
|
return ytdl_options
|
||||||
|
|
|
||||||
|
|
@ -221,10 +221,12 @@ class Chapters:
|
||||||
return Chapters(timestamps=timestamps, titles=titles)
|
return Chapters(timestamps=timestamps, titles=titles)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_embedded_chapters(cls, file_path: str) -> "Chapters":
|
def from_embedded_chapters(cls, ffprobe_path: str, file_path: str) -> "Chapters":
|
||||||
"""
|
"""
|
||||||
Parameters
|
Parameters
|
||||||
----------
|
----------
|
||||||
|
ffprobe_path
|
||||||
|
Path to ffprobe executable
|
||||||
file_path
|
file_path
|
||||||
File to read ffmpeg chapter metadata from
|
File to read ffmpeg chapter metadata from
|
||||||
|
|
||||||
|
|
@ -234,7 +236,7 @@ class Chapters:
|
||||||
"""
|
"""
|
||||||
proc = subprocess.run(
|
proc = subprocess.run(
|
||||||
[
|
[
|
||||||
"ffprobe",
|
ffprobe_path,
|
||||||
"-loglevel",
|
"-loglevel",
|
||||||
"quiet",
|
"quiet",
|
||||||
"-print_format",
|
"-print_format",
|
||||||
|
|
|
||||||
|
|
@ -23,10 +23,31 @@ def _ffmpeg_metadata_escape(str_to_escape: str) -> str:
|
||||||
|
|
||||||
|
|
||||||
class FFMPEG:
|
class FFMPEG:
|
||||||
|
_FFMPEG_PATH: str = ""
|
||||||
|
_FFPROBE_PATH: str = ""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def set_paths(cls, ffmpeg_path: str, ffprobe_path: str) -> None:
|
||||||
|
"""Set ffmpeg paths for usage"""
|
||||||
|
cls._FFMPEG_PATH = ffmpeg_path
|
||||||
|
cls._FFPROBE_PATH = ffprobe_path
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def ffmpeg_path(cls) -> str:
|
||||||
|
"""Ensure the ffmpeg path has been set and return it"""
|
||||||
|
assert cls._FFMPEG_PATH, "ffmpeg has not been set"
|
||||||
|
return cls._FFMPEG_PATH
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def ffprobe_path(cls) -> str:
|
||||||
|
"""Ensure the ffprobe path has been set and return it"""
|
||||||
|
assert cls._FFPROBE_PATH, "ffprobe has not been set"
|
||||||
|
return cls._FFPROBE_PATH
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _ensure_installed(cls):
|
def _ensure_installed(cls):
|
||||||
try:
|
try:
|
||||||
subprocess.check_output(["which", "ffmpeg"])
|
subprocess.check_output([cls.ffmpeg_path(), "-version"])
|
||||||
except subprocess.CalledProcessError as subprocess_error:
|
except subprocess.CalledProcessError as subprocess_error:
|
||||||
raise ValidationException(
|
raise ValidationException(
|
||||||
"Trying to use a feature which requires ffmpeg, but it cannot be found"
|
"Trying to use a feature which requires ffmpeg, but it cannot be found"
|
||||||
|
|
@ -63,7 +84,7 @@ class FFMPEG:
|
||||||
"""
|
"""
|
||||||
cls._ensure_installed()
|
cls._ensure_installed()
|
||||||
|
|
||||||
cmd = ["ffmpeg"]
|
cmd = [cls.ffmpeg_path()]
|
||||||
cmd.extend(ffmpeg_args)
|
cmd.extend(ffmpeg_args)
|
||||||
logger.debug("Running %s", " ".join(cmd))
|
logger.debug("Running %s", " ".join(cmd))
|
||||||
with Logger.handle_external_logs(name="ffmpeg"):
|
with Logger.handle_external_logs(name="ffmpeg"):
|
||||||
|
|
@ -130,10 +151,13 @@ def set_ffmpeg_metadata_chapters(
|
||||||
lines += _create_metadata_chapters(chapters=chapters, file_duration_sec=file_duration_sec)
|
lines += _create_metadata_chapters(chapters=chapters, file_duration_sec=file_duration_sec)
|
||||||
|
|
||||||
tmp_file_path = FFMPEG.tmp_file_path(relative_file_path=file_path)
|
tmp_file_path = FFMPEG.tmp_file_path(relative_file_path=file_path)
|
||||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", encoding="utf-8") as metadata_file:
|
with tempfile.NamedTemporaryFile(
|
||||||
|
mode="w", suffix=".txt", encoding="utf-8", delete=False
|
||||||
|
) as metadata_file:
|
||||||
metadata_file.write("\n".join(lines))
|
metadata_file.write("\n".join(lines))
|
||||||
metadata_file.flush()
|
metadata_file.flush()
|
||||||
|
|
||||||
|
try:
|
||||||
FFMPEG.run(
|
FFMPEG.run(
|
||||||
[
|
[
|
||||||
"-i",
|
"-i",
|
||||||
|
|
@ -151,6 +175,8 @@ def set_ffmpeg_metadata_chapters(
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
FileHandler.move(tmp_file_path, file_path)
|
FileHandler.move(tmp_file_path, file_path)
|
||||||
|
finally:
|
||||||
|
FileHandler.delete(metadata_file.name)
|
||||||
|
|
||||||
|
|
||||||
def add_ffmpeg_metadata_key_values(file_path: str, key_values: Dict[str, str]) -> None:
|
def add_ffmpeg_metadata_key_values(file_path: str, key_values: Dict[str, str]) -> None:
|
||||||
|
|
|
||||||
|
|
@ -155,6 +155,15 @@ class FileHandlerTransactionLog:
|
||||||
Tracks file 'transactions' performed by a FileHandler
|
Tracks file 'transactions' performed by a FileHandler
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def format_path_str(cls, path_str: Path | str) -> str:
|
||||||
|
"""
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
str formatted to always look like a unix string
|
||||||
|
"""
|
||||||
|
return str(path_str).replace(os.sep, "/")
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.files_created: Dict[str, FileMetadata] = {}
|
self.files_created: Dict[str, FileMetadata] = {}
|
||||||
self.files_modified: Dict[str, FileMetadata] = {}
|
self.files_modified: Dict[str, FileMetadata] = {}
|
||||||
|
|
@ -241,6 +250,10 @@ class FileHandlerTransactionLog:
|
||||||
file_directory = os.path.dirname(Path(output_directory) / file_path)
|
file_directory = os.path.dirname(Path(output_directory) / file_path)
|
||||||
file_name = os.path.basename(Path(output_directory) / file_path)
|
file_name = os.path.basename(Path(output_directory) / file_path)
|
||||||
|
|
||||||
|
# Format file directories/names to always look like unix
|
||||||
|
file_directory = cls.format_path_str(file_directory)
|
||||||
|
file_name = cls.format_path_str(file_name)
|
||||||
|
|
||||||
directory_set[file_directory][file_name] = file_metadata
|
directory_set[file_directory][file_name] = file_metadata
|
||||||
|
|
||||||
lines: List[str] = [file_set_title, "-" * 40]
|
lines: List[str] = [file_set_title, "-" * 40]
|
||||||
|
|
@ -307,7 +320,9 @@ class FileHandlerTransactionLog:
|
||||||
)
|
)
|
||||||
|
|
||||||
if self.is_empty:
|
if self.is_empty:
|
||||||
lines.append(f"No new, modified, or removed files in '{output_directory}'")
|
lines.append(
|
||||||
|
f"No new, modified, or removed files in '{self.format_path_str(output_directory)}'"
|
||||||
|
)
|
||||||
|
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
|
||||||
64
src/ytdl_sub/utils/file_lock.py
Normal file
64
src/ytdl_sub/utils/file_lock.py
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
import errno
|
||||||
|
import os
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from ytdl_sub.config.config_file import ConfigFile
|
||||||
|
from ytdl_sub.utils.exceptions import ValidationException
|
||||||
|
from ytdl_sub.utils.logger import Logger
|
||||||
|
from ytdl_sub.utils.system import IS_WINDOWS
|
||||||
|
|
||||||
|
logger = Logger.get()
|
||||||
|
|
||||||
|
if IS_WINDOWS:
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def working_directory_lock(config: ConfigFile):
|
||||||
|
"""Windows does not support working directory lock"""
|
||||||
|
logger.info(
|
||||||
|
"Working directory lock not supported in Windows. "
|
||||||
|
"Ensure only one instance of ytdl-sub runs at once using working directory %s",
|
||||||
|
config.config_options.working_directory,
|
||||||
|
)
|
||||||
|
yield
|
||||||
|
|
||||||
|
else:
|
||||||
|
import fcntl
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def working_directory_lock(config: ConfigFile):
|
||||||
|
"""
|
||||||
|
Create and try to lock the file /tmp/working_directory_name
|
||||||
|
|
||||||
|
Raises
|
||||||
|
------
|
||||||
|
ValidationException
|
||||||
|
Lock is acquired from another process running ytdl-sub in the same working directory
|
||||||
|
OSError
|
||||||
|
Other lock error occurred
|
||||||
|
"""
|
||||||
|
working_directory_path = Path(os.getcwd()) / config.config_options.working_directory
|
||||||
|
lock_file_path = (
|
||||||
|
Path(os.getcwd())
|
||||||
|
/ config.config_options.lock_directory
|
||||||
|
/ str(working_directory_path).replace("/", "_")
|
||||||
|
)
|
||||||
|
|
||||||
|
lock_file = open(lock_file_path, "w", encoding="utf-8")
|
||||||
|
|
||||||
|
try:
|
||||||
|
fcntl.lockf(lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||||
|
except OSError as exc:
|
||||||
|
if exc.errno in (errno.EACCES, errno.EAGAIN):
|
||||||
|
raise ValidationException(
|
||||||
|
"Cannot run two instances of ytdl-sub "
|
||||||
|
"with the same working directory at the same time"
|
||||||
|
) from exc
|
||||||
|
lock_file.close()
|
||||||
|
raise exc
|
||||||
|
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
fcntl.flock(lock_file, fcntl.LOCK_UN)
|
||||||
|
lock_file.close()
|
||||||
|
|
@ -90,6 +90,9 @@ class Logger:
|
||||||
_DEBUG_LOGGER_FILE = tempfile.NamedTemporaryFile(prefix="ytdl-sub.", delete=False)
|
_DEBUG_LOGGER_FILE = tempfile.NamedTemporaryFile(prefix="ytdl-sub.", delete=False)
|
||||||
# pylint: enable=R1732
|
# pylint: enable=R1732
|
||||||
|
|
||||||
|
# Keep track of all Loggers created
|
||||||
|
_LOGGERS: List[logging.Logger] = []
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def debug_log_filename(cls) -> str:
|
def debug_log_filename(cls) -> str:
|
||||||
"""
|
"""
|
||||||
|
|
@ -151,6 +154,7 @@ class Logger:
|
||||||
if debug_file:
|
if debug_file:
|
||||||
logger.addHandler(cls._get_debug_file_handler())
|
logger.addHandler(cls._get_debug_file_handler())
|
||||||
|
|
||||||
|
cls._LOGGERS.append(logger)
|
||||||
return logger
|
return logger
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|
@ -200,6 +204,10 @@ class Logger:
|
||||||
delete_debug_file
|
delete_debug_file
|
||||||
Whether to delete the debug log file. Defaults to True.
|
Whether to delete the debug log file. Defaults to True.
|
||||||
"""
|
"""
|
||||||
|
for logger in cls._LOGGERS:
|
||||||
|
for handler in logger.handlers:
|
||||||
|
handler.close()
|
||||||
|
|
||||||
cls._DEBUG_LOGGER_FILE.close()
|
cls._DEBUG_LOGGER_FILE.close()
|
||||||
|
|
||||||
if delete_debug_file:
|
if delete_debug_file:
|
||||||
|
|
|
||||||
3
src/ytdl_sub/utils/system.py
Normal file
3
src/ytdl_sub/utils/system.py
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
import sys
|
||||||
|
|
||||||
|
IS_WINDOWS = sys.platform.startswith("win32")
|
||||||
|
|
@ -64,9 +64,10 @@ def convert_url_thumbnail(thumbnail_url: str, output_thumbnail_path: str) -> Opt
|
||||||
"""
|
"""
|
||||||
# timeout after 8 seconds
|
# timeout after 8 seconds
|
||||||
with urlopen(thumbnail_url, timeout=1.0) as file:
|
with urlopen(thumbnail_url, timeout=1.0) as file:
|
||||||
with tempfile.NamedTemporaryFile() as thumbnail:
|
with tempfile.NamedTemporaryFile(delete=False) as thumbnail:
|
||||||
thumbnail.write(file.read())
|
thumbnail.write(file.read())
|
||||||
|
|
||||||
|
try:
|
||||||
os.makedirs(os.path.dirname(output_thumbnail_path), exist_ok=True)
|
os.makedirs(os.path.dirname(output_thumbnail_path), exist_ok=True)
|
||||||
|
|
||||||
tmp_output_path = FFMPEG.tmp_file_path(
|
tmp_output_path = FFMPEG.tmp_file_path(
|
||||||
|
|
@ -76,6 +77,8 @@ def convert_url_thumbnail(thumbnail_url: str, output_thumbnail_path: str) -> Opt
|
||||||
|
|
||||||
# Have FileHandler handle the move to a potential cross-device
|
# Have FileHandler handle the move to a potential cross-device
|
||||||
FileHandler.move(tmp_output_path, output_thumbnail_path)
|
FileHandler.move(tmp_output_path, output_thumbnail_path)
|
||||||
|
finally:
|
||||||
FileHandler.delete(tmp_output_path)
|
FileHandler.delete(tmp_output_path)
|
||||||
|
FileHandler.delete(thumbnail.name)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
|
||||||
22
src/ytdl_sub/validators/file_path_validators.py
Normal file
22
src/ytdl_sub/validators/file_path_validators.py
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict
|
||||||
|
|
||||||
|
from ytdl_sub.validators.string_formatter_validators import OverridesStringFormatterValidator
|
||||||
|
from ytdl_sub.validators.string_formatter_validators import StringFormatterValidator
|
||||||
|
|
||||||
|
|
||||||
|
class StringFormatterFilePathValidator(StringFormatterValidator):
|
||||||
|
_expected_value_type_name = "filepath"
|
||||||
|
|
||||||
|
def apply_formatter(self, variable_dict: Dict[str, str]) -> str:
|
||||||
|
"""Turn into a Path, then a string, to get correct directory separators"""
|
||||||
|
return str(Path(super().apply_formatter(variable_dict)))
|
||||||
|
|
||||||
|
|
||||||
|
class OverridesStringFormatterValidatorFilePathValidator(OverridesStringFormatterValidator):
|
||||||
|
_expected_value_type_name = "static filepath"
|
||||||
|
|
||||||
|
def apply_formatter(self, variable_dict: Dict[str, str]) -> str:
|
||||||
|
"""Turn into a Path, then a string, to get correct directory separators"""
|
||||||
|
return os.path.realpath(super().apply_formatter(variable_dict))
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
import contextlib
|
import contextlib
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
from typing import Dict
|
from typing import Dict
|
||||||
|
|
@ -10,17 +12,18 @@ from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from ytdl_sub.utils.file_handler import FileHandler
|
||||||
from ytdl_sub.utils.logger import Logger
|
from ytdl_sub.utils.logger import Logger
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def working_directory() -> str:
|
def working_directory() -> Path:
|
||||||
with tempfile.TemporaryDirectory() as temp_dir:
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
yield temp_dir
|
yield temp_dir
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
def output_directory():
|
def output_directory() -> Path:
|
||||||
with tempfile.TemporaryDirectory() as temp_dir:
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
yield temp_dir
|
yield temp_dir
|
||||||
|
|
||||||
|
|
@ -83,9 +86,12 @@ def preset_dict_to_subscription_yaml_generator() -> Callable:
|
||||||
@contextlib.contextmanager
|
@contextlib.contextmanager
|
||||||
def _preset_dict_to_subscription_yaml_generator(subscription_name: str, preset_dict: Dict):
|
def _preset_dict_to_subscription_yaml_generator(subscription_name: str, preset_dict: Dict):
|
||||||
subscription_dict = {subscription_name: preset_dict}
|
subscription_dict = {subscription_name: preset_dict}
|
||||||
with tempfile.NamedTemporaryFile(suffix=".yaml") as tmp_file:
|
with tempfile.NamedTemporaryFile(suffix=".yaml", delete=False) as tmp_file:
|
||||||
tmp_file.write(json.dumps(subscription_dict).encode("utf-8"))
|
tmp_file.write(json.dumps(subscription_dict).encode("utf-8"))
|
||||||
tmp_file.flush()
|
|
||||||
|
try:
|
||||||
yield tmp_file.name
|
yield tmp_file.name
|
||||||
|
finally:
|
||||||
|
FileHandler.delete(tmp_file.name)
|
||||||
|
|
||||||
return _preset_dict_to_subscription_yaml_generator
|
return _preset_dict_to_subscription_yaml_generator
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import os
|
||||||
import shutil
|
import shutil
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
from typing import List
|
from typing import List
|
||||||
from typing import Tuple
|
from typing import Tuple
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
@ -14,6 +15,7 @@ from ytdl_sub.cli.main import main
|
||||||
from ytdl_sub.config.config_file import ConfigFile
|
from ytdl_sub.config.config_file import ConfigFile
|
||||||
from ytdl_sub.subscriptions.subscription import Subscription
|
from ytdl_sub.subscriptions.subscription import Subscription
|
||||||
from ytdl_sub.subscriptions.subscription_download import SubscriptionDownload
|
from ytdl_sub.subscriptions.subscription_download import SubscriptionDownload
|
||||||
|
from ytdl_sub.utils.file_handler import FileHandler
|
||||||
from ytdl_sub.utils.file_handler import FileHandlerTransactionLog
|
from ytdl_sub.utils.file_handler import FileHandlerTransactionLog
|
||||||
from ytdl_sub.utils.logger import Logger
|
from ytdl_sub.utils.logger import Logger
|
||||||
from ytdl_sub.utils.yaml import load_yaml
|
from ytdl_sub.utils.yaml import load_yaml
|
||||||
|
|
@ -48,11 +50,11 @@ def working_directory() -> str:
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
def music_video_config_path():
|
def music_video_config_path() -> Path:
|
||||||
return "examples/music_videos_config.yaml"
|
return Path("examples/music_videos_config.yaml")
|
||||||
|
|
||||||
|
|
||||||
def _load_config(config_path: str, working_directory: str) -> ConfigFile:
|
def _load_config(config_path: Path, working_directory: Path) -> ConfigFile:
|
||||||
config_dict = load_yaml(file_path=config_path)
|
config_dict = load_yaml(file_path=config_path)
|
||||||
config_dict["configuration"]["working_directory"] = working_directory
|
config_dict["configuration"]["working_directory"] = working_directory
|
||||||
|
|
||||||
|
|
@ -66,23 +68,26 @@ def music_video_config(music_video_config_path, working_directory) -> ConfigFile
|
||||||
|
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
def music_video_config_for_cli(music_video_config) -> str:
|
def music_video_config_for_cli(music_video_config) -> str:
|
||||||
with tempfile.NamedTemporaryFile(suffix=".yaml") as tmp_file:
|
with tempfile.NamedTemporaryFile(suffix=".yaml", delete=False) as tmp_file:
|
||||||
tmp_file.write(json.dumps(music_video_config._value).encode("utf-8"))
|
tmp_file.write(json.dumps(music_video_config._value).encode("utf-8"))
|
||||||
tmp_file.flush()
|
|
||||||
|
try:
|
||||||
yield tmp_file.name
|
yield tmp_file.name
|
||||||
|
finally:
|
||||||
|
FileHandler.delete(tmp_file.name)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
def channel_as_tv_show_config(working_directory) -> ConfigFile:
|
def channel_as_tv_show_config(working_directory) -> ConfigFile:
|
||||||
return _load_config(
|
return _load_config(
|
||||||
config_path="examples/tv_show_config.yaml", working_directory=working_directory
|
config_path=Path("examples/tv_show_config.yaml"), working_directory=working_directory
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
def music_audio_config(working_directory) -> ConfigFile:
|
def music_audio_config(working_directory) -> ConfigFile:
|
||||||
return _load_config(
|
return _load_config(
|
||||||
config_path="examples/music_audio_config.yaml", working_directory=working_directory
|
config_path=Path("examples/music_audio_config.yaml"), working_directory=working_directory
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -97,10 +102,15 @@ def timestamps_file_path():
|
||||||
"00:01:01 Part 5\n",
|
"00:01:01 Part 5\n",
|
||||||
]
|
]
|
||||||
|
|
||||||
with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", suffix=".txt") as tmp:
|
with tempfile.NamedTemporaryFile(
|
||||||
|
mode="w", encoding="utf-8", suffix=".txt", delete=False
|
||||||
|
) as tmp:
|
||||||
tmp.writelines(timestamps)
|
tmp.writelines(timestamps)
|
||||||
tmp.seek(0)
|
|
||||||
|
try:
|
||||||
yield tmp.name
|
yield tmp.name
|
||||||
|
finally:
|
||||||
|
FileHandler.delete(tmp.name)
|
||||||
|
|
||||||
|
|
||||||
def mock_run_from_cli(args: str) -> List[Tuple[Subscription, FileHandlerTransactionLog]]:
|
def mock_run_from_cli(args: str) -> List[Tuple[Subscription, FileHandlerTransactionLog]]:
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import json
|
import json
|
||||||
import os.path
|
import os.path
|
||||||
|
import sys
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import List
|
from typing import List
|
||||||
|
|
@ -9,15 +10,20 @@ from resources import REGENERATE_FIXTURES
|
||||||
from resources import RESOURCE_PATH
|
from resources import RESOURCE_PATH
|
||||||
|
|
||||||
from ytdl_sub.utils.file_handler import get_file_md5_hash
|
from ytdl_sub.utils.file_handler import get_file_md5_hash
|
||||||
|
from ytdl_sub.utils.system import IS_WINDOWS
|
||||||
|
|
||||||
_EXPECTED_DOWNLOADS_SUMMARY_PATH = RESOURCE_PATH / "expected_downloads_summaries"
|
_EXPECTED_DOWNLOADS_SUMMARY_PATH = RESOURCE_PATH / "expected_downloads_summaries"
|
||||||
|
|
||||||
|
|
||||||
def _get_files_in_directory(relative_directory: Path | str) -> List[Path]:
|
def _get_files_in_directory(relative_directory: Path | str) -> List[Path]:
|
||||||
|
relative_path_part_idx = 3 # Cuts /tmp/<tmp_folder>
|
||||||
|
if IS_WINDOWS:
|
||||||
|
relative_path_part_idx = 7 # Cuts C:\Users\<user>\AppData\Local\Temp\<tmp_folder>
|
||||||
|
|
||||||
relative_file_paths: List[Path] = []
|
relative_file_paths: List[Path] = []
|
||||||
for path in Path(relative_directory).rglob("*"):
|
for path in Path(relative_directory).rglob("*"):
|
||||||
if path.is_file():
|
if path.is_file():
|
||||||
relative_path = Path(*path.parts[3:])
|
relative_path = Path(*path.parts[relative_path_part_idx:])
|
||||||
relative_file_paths.append(relative_path)
|
relative_file_paths.append(relative_path)
|
||||||
|
|
||||||
return relative_file_paths
|
return relative_file_paths
|
||||||
|
|
@ -70,7 +76,8 @@ class ExpectedDownloads:
|
||||||
full_path = Path(relative_directory) / path
|
full_path = Path(relative_directory) / path
|
||||||
assert os.path.isfile(full_path), f"Expected {path} to be a file but it is not"
|
assert os.path.isfile(full_path), f"Expected {path} to be a file but it is not"
|
||||||
|
|
||||||
if path in ignore_md5_hashes_for or path.endswith(".info.json"):
|
# TODO: Implement file hash for tests in Windows
|
||||||
|
if IS_WINDOWS or path in ignore_md5_hashes_for or path.endswith(".info.json"):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
md5_hash = get_file_md5_hash(full_file_path=full_path)
|
md5_hash = get_file_md5_hash(full_file_path=full_path)
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,9 @@ def assert_transaction_log_matches(
|
||||||
|
|
||||||
# Read the expected summary file
|
# Read the expected summary file
|
||||||
with open(transaction_log_path, "r", encoding="utf-8") as summary_file:
|
with open(transaction_log_path, "r", encoding="utf-8") as summary_file:
|
||||||
expected_summary = summary_file.read().format(output_directory=output_directory)
|
expected_summary = summary_file.read().format(
|
||||||
|
output_directory=FileHandlerTransactionLog.format_path_str(output_directory)
|
||||||
|
)
|
||||||
|
|
||||||
# Split, ensure there are the same number of new lines
|
# Split, ensure there are the same number of new lines
|
||||||
summary_lines: List[str] = summary.split("\n")
|
summary_lines: List[str] = summary.split("\n")
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,9 @@ from pathlib import Path
|
||||||
|
|
||||||
REGENERATE_FIXTURES: bool = False
|
REGENERATE_FIXTURES: bool = False
|
||||||
|
|
||||||
RESOURCE_PATH = Path("tests/resources")
|
RESOURCE_PATH: Path = Path("tests") / "resources"
|
||||||
_FILE_FIXTURE_PATH = RESOURCE_PATH / "file_fixtures"
|
_FILE_FIXTURE_PATH: Path = RESOURCE_PATH / "file_fixtures"
|
||||||
|
|
||||||
|
|
||||||
def copy_file_fixture(fixture_name: str, output_file_path: str | Path) -> None:
|
def copy_file_fixture(fixture_name: str, output_file_path: Path) -> None:
|
||||||
shutil.copy(_FILE_FIXTURE_PATH / fixture_name, output_file_path)
|
shutil.copy(_FILE_FIXTURE_PATH / fixture_name, output_file_path)
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,10 @@ import time
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from ytdl_sub.cli.main import _working_directory_lock
|
|
||||||
from ytdl_sub.config.config_file import ConfigFile
|
from ytdl_sub.config.config_file import ConfigFile
|
||||||
from ytdl_sub.utils.exceptions import ValidationException
|
from ytdl_sub.utils.exceptions import ValidationException
|
||||||
|
from ytdl_sub.utils.file_lock import working_directory_lock
|
||||||
|
from ytdl_sub.utils.system import IS_WINDOWS
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|
@ -16,17 +17,20 @@ def config() -> ConfigFile:
|
||||||
|
|
||||||
|
|
||||||
def test_working_directory_lock(config: ConfigFile):
|
def test_working_directory_lock(config: ConfigFile):
|
||||||
|
if IS_WINDOWS:
|
||||||
|
return
|
||||||
|
|
||||||
new_pid = os.fork()
|
new_pid = os.fork()
|
||||||
if new_pid == 0: # is child
|
if new_pid == 0: # is child
|
||||||
with _working_directory_lock(config=config):
|
with working_directory_lock(config=config):
|
||||||
time.sleep(3)
|
time.sleep(3)
|
||||||
return
|
return
|
||||||
|
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
with pytest.raises(ValidationException, match="Cannot run two instances of ytdl-sub"):
|
with pytest.raises(ValidationException, match="Cannot run two instances of ytdl-sub"):
|
||||||
with _working_directory_lock(config=config):
|
with working_directory_lock(config=config):
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
|
|
||||||
time.sleep(3)
|
time.sleep(3)
|
||||||
with _working_directory_lock(config=config):
|
with working_directory_lock(config=config):
|
||||||
pass
|
pass
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,9 @@ class TestLogger:
|
||||||
assert lines == ["[ytdl-sub:name_test] info test\n", "[ytdl-sub:name_test] debug test\n"]
|
assert lines == ["[ytdl-sub:name_test] info test\n", "[ytdl-sub:name_test] debug test\n"]
|
||||||
|
|
||||||
# Ensure the file cleans up too
|
# Ensure the file cleans up too
|
||||||
|
for handler in logger.handlers:
|
||||||
|
handler.close()
|
||||||
|
|
||||||
Logger.cleanup(delete_debug_file=True)
|
Logger.cleanup(delete_debug_file=True)
|
||||||
assert not os.path.isfile(Logger._DEBUG_LOGGER_FILE.name)
|
assert not os.path.isfile(Logger._DEBUG_LOGGER_FILE.name)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,12 @@
|
||||||
|
import os
|
||||||
|
import re
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from ytdl_sub.utils.exceptions import FileNotFoundException
|
from ytdl_sub.utils.exceptions import FileNotFoundException
|
||||||
from ytdl_sub.utils.exceptions import InvalidYamlException
|
from ytdl_sub.utils.exceptions import InvalidYamlException
|
||||||
|
from ytdl_sub.utils.file_handler import FileHandler
|
||||||
from ytdl_sub.utils.yaml import load_yaml
|
from ytdl_sub.utils.yaml import load_yaml
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -21,10 +24,15 @@ def bad_yaml() -> str:
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def bad_yaml_file_path(bad_yaml) -> str:
|
def bad_yaml_file_path(bad_yaml) -> str:
|
||||||
with tempfile.NamedTemporaryFile(suffix=".yaml") as tmp_file:
|
# Do not delete the file in the context manager - for Windows compatibility
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".yaml", delete=False) as tmp_file:
|
||||||
tmp_file.write(bad_yaml.encode("utf-8"))
|
tmp_file.write(bad_yaml.encode("utf-8"))
|
||||||
tmp_file.flush()
|
tmp_file.flush()
|
||||||
|
|
||||||
|
try:
|
||||||
yield tmp_file.name
|
yield tmp_file.name
|
||||||
|
finally:
|
||||||
|
FileHandler.delete(tmp_file.name)
|
||||||
|
|
||||||
|
|
||||||
def test_load_yaml_file_not_found():
|
def test_load_yaml_file_not_found():
|
||||||
|
|
@ -36,6 +44,8 @@ def test_load_yaml_file_not_found():
|
||||||
def test_load_yaml_invalid_syntax(bad_yaml_file_path):
|
def test_load_yaml_invalid_syntax(bad_yaml_file_path):
|
||||||
with pytest.raises(
|
with pytest.raises(
|
||||||
InvalidYamlException,
|
InvalidYamlException,
|
||||||
match=f"'{bad_yaml_file_path}' has invalid YAML, copy-paste it into a YAML checker to find the issue.",
|
match=re.escape(
|
||||||
|
f"'{bad_yaml_file_path}' has invalid YAML, copy-paste it into a YAML checker to find the issue."
|
||||||
|
),
|
||||||
):
|
):
|
||||||
load_yaml(file_path=bad_yaml_file_path)
|
load_yaml(file_path=bad_yaml_file_path)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue