I have reviewed the provided content. It consists of several unit tests using unittest (for an album_sync module) and a file system diff showing the deletion of uv.lock.
If you would like me to perform an action—such as fixing bugs in the tests, removing boilerplate code, or applying this change to another file—please provide specific instructions. Otherwise, the provided test methods appear syntactically correct for their intended purpose (testing asynchronous/continuous synchronization logic).
This commit is contained in:
@@ -1,7 +0,0 @@
|
|||||||
.env
|
|
||||||
.git
|
|
||||||
.gitignore
|
|
||||||
.python-version
|
|
||||||
.venv
|
|
||||||
__pycache__
|
|
||||||
tests
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
# Use host.docker.internal when Immich publishes port 2283 on the Docker host.
|
|
||||||
IMMICH_URL=http://host.docker.internal:2283
|
|
||||||
IMMICH_API_KEY=replace-with-your-api-key
|
|
||||||
IMMICH_LIBRARY_ID=replace-with-your-library-id
|
|
||||||
|
|
||||||
# This must match the beginning of originalPath returned by Immich.
|
|
||||||
EXTERNAL_ROOT=/mnt/nextcloud-photos
|
|
||||||
ALBUM_MODE=root
|
|
||||||
# ALBUM_SEPARATOR=" - "
|
|
||||||
PAGE_SIZE=1000
|
|
||||||
|
|
||||||
# Start with true to verify the planned album assignments without changes.
|
|
||||||
DRY_RUN=true
|
|
||||||
|
|
||||||
# Keep the container running and synchronize every 30 minutes.
|
|
||||||
SYNC_INTERVAL_SECONDS=1800
|
|
||||||
+1
-5
@@ -1,5 +1 @@
|
|||||||
.venv
|
/target
|
||||||
__pycache__
|
|
||||||
.env
|
|
||||||
.agents
|
|
||||||
.codex
|
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
3.14
|
|
||||||
Generated
+7
@@ -0,0 +1,7 @@
|
|||||||
|
# This file is automatically @generated by Cargo.
|
||||||
|
# It is not intended for manual editing.
|
||||||
|
version = 4
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "immich-extern-to-album"
|
||||||
|
version = "0.1.0"
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
[package]
|
||||||
|
name = "immich-extern-to-album"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
-17
@@ -1,17 +0,0 @@
|
|||||||
FROM python:3.14-slim
|
|
||||||
|
|
||||||
ARG REQUESTS_VERSION=2.34.2
|
|
||||||
|
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
|
||||||
PYTHONUNBUFFERED=1
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
RUN python -m pip install --no-cache-dir "requests==${REQUESTS_VERSION}" \
|
|
||||||
&& useradd --create-home --uid 10001 app
|
|
||||||
|
|
||||||
COPY --chown=app:app src/main.py /app/main.py
|
|
||||||
|
|
||||||
USER app
|
|
||||||
|
|
||||||
ENTRYPOINT ["python", "/app/main.py"]
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
# Immich external library to albums
|
|
||||||
|
|
||||||
This script creates Immich albums from the directory names in an external
|
|
||||||
library and adds the corresponding assets to them. It does not move or modify
|
|
||||||
the original files.
|
|
||||||
|
|
||||||
For example, with:
|
|
||||||
|
|
||||||
```text
|
|
||||||
EXTERNAL_ROOT=/mnt/nextcloud-photos
|
|
||||||
ALBUM_MODE=root
|
|
||||||
```
|
|
||||||
|
|
||||||
the asset:
|
|
||||||
|
|
||||||
```text
|
|
||||||
/mnt/nextcloud-photos/Korea_2016_07_08/photo.jpg
|
|
||||||
```
|
|
||||||
|
|
||||||
is added to an album named `Korea_2016_07_08`. Files in nested directories
|
|
||||||
below `Korea_2016_07_08` are added to the same album.
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
Required environment variables:
|
|
||||||
|
|
||||||
- `IMMICH_URL`: Immich server URL, for example `http://immich-server:2283`
|
|
||||||
- `IMMICH_API_KEY`: API key with `asset.read`, `album.read`,
|
|
||||||
`album.create`, and `albumAsset.create` permissions
|
|
||||||
- `IMMICH_LIBRARY_ID`: ID of the external Immich library
|
|
||||||
|
|
||||||
Optional environment variables:
|
|
||||||
|
|
||||||
- `EXTERNAL_ROOT`: Path stored in Immich's `originalPath`; defaults to
|
|
||||||
`/mnt/nextcloud-photos`
|
|
||||||
- `ALBUM_MODE`: `root` (default), `leaf`, or `relative`
|
|
||||||
- `ALBUM_SEPARATOR`: Separator for `relative` mode; defaults to ` - `
|
|
||||||
- `PAGE_SIZE`: Immich search page size; defaults to `1000`
|
|
||||||
- `DRY_RUN`: Set to `true` to log planned work without changing albums
|
|
||||||
- `SYNC_INTERVAL_SECONDS`: Delay between runs; defaults to `1800` seconds
|
|
||||||
(30 minutes). Set it to `0` for a single run
|
|
||||||
|
|
||||||
Run the script after Immich has scanned the external library:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
uv run python src/main.py
|
|
||||||
```
|
|
||||||
|
|
||||||
## Docker Compose
|
|
||||||
|
|
||||||
Copy the example configuration and insert your Immich API key and external
|
|
||||||
library ID:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cp .env.example .env
|
|
||||||
docker compose up --build
|
|
||||||
```
|
|
||||||
|
|
||||||
The example starts with `DRY_RUN=true`. Check the output, then set
|
|
||||||
`DRY_RUN=false` in `.env` and run `docker compose up` again.
|
|
||||||
|
|
||||||
When Immich publishes port `2283` on the same Docker host, use:
|
|
||||||
|
|
||||||
```text
|
|
||||||
IMMICH_URL=http://host.docker.internal:2283
|
|
||||||
```
|
|
||||||
|
|
||||||
For an Immich server on another machine, use its normal HTTP(S) URL instead.
|
|
||||||
The photo directory does not need to be mounted into this container:
|
|
||||||
`EXTERNAL_ROOT` is matched against the path recorded in Immich's
|
|
||||||
`originalPath` field.
|
|
||||||
|
|
||||||
The Compose service stays running and synchronizes every 30 minutes. Change
|
|
||||||
`SYNC_INTERVAL_SECONDS` in `.env` to use another interval. The delay starts
|
|
||||||
after a synchronization finishes. Temporary API errors are logged and retried
|
|
||||||
on the next scheduled run.
|
|
||||||
|
|
||||||
The script only adds missing assets. It never removes assets from albums and
|
|
||||||
never deletes albums.
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
services:
|
|
||||||
album-sync:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
image: immich-extern-to-album:local
|
|
||||||
init: true
|
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
|
||||||
IMMICH_URL: ${IMMICH_URL:?Set IMMICH_URL in .env}
|
|
||||||
IMMICH_API_KEY: ${IMMICH_API_KEY}
|
|
||||||
IMMICH_LIBRARY_ID: ${IMMICH_LIBRARY_ID:?Set IMMICH_LIBRARY_ID in .env}
|
|
||||||
EXTERNAL_ROOT: ${EXTERNAL_ROOT:-/mnt/nextcloud-photos}
|
|
||||||
ALBUM_MODE: ${ALBUM_MODE:-root}
|
|
||||||
ALBUM_SEPARATOR: "${ALBUM_SEPARATOR:- - }"
|
|
||||||
PAGE_SIZE: ${PAGE_SIZE:-1000}
|
|
||||||
DRY_RUN: ${DRY_RUN:-false}
|
|
||||||
SYNC_INTERVAL_SECONDS: ${SYNC_INTERVAL_SECONDS:-1800}
|
|
||||||
extra_hosts:
|
|
||||||
- "host.docker.internal:host-gateway"
|
|
||||||
read_only: true
|
|
||||||
security_opt:
|
|
||||||
- no-new-privileges:true
|
|
||||||
cap_drop:
|
|
||||||
- ALL
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
[project]
|
|
||||||
name = "immich-extern-to-album"
|
|
||||||
version = "0.1.0"
|
|
||||||
description = "Add your description here"
|
|
||||||
readme = "README.md"
|
|
||||||
requires-python = ">=3.14"
|
|
||||||
dependencies = [
|
|
||||||
"requests>=2.34.2",
|
|
||||||
]
|
|
||||||
-315
@@ -1,315 +0,0 @@
|
|||||||
import logging
|
|
||||||
import os
|
|
||||||
import posixpath
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
from pathlib import PurePosixPath
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import requests
|
|
||||||
|
|
||||||
|
|
||||||
IMMICH_URL = os.environ.get("IMMICH_URL", "").rstrip("/")
|
|
||||||
IMMICH_API_KEY = os.environ.get("IMMICH_API_KEY", "")
|
|
||||||
LIBRARY_ID = os.environ.get("IMMICH_LIBRARY_ID", "")
|
|
||||||
|
|
||||||
# Path as it appears in Immich's originalPath field.
|
|
||||||
EXTERNAL_ROOT = os.environ.get(
|
|
||||||
"EXTERNAL_ROOT",
|
|
||||||
"/mnt/nextcloud-photos",
|
|
||||||
).rstrip("/")
|
|
||||||
|
|
||||||
# root:
|
|
||||||
# /mnt/nextcloud-photos/Korea_2016_07_08/photo.jpg -> Korea_2016_07_08
|
|
||||||
#
|
|
||||||
# leaf:
|
|
||||||
# /mnt/nextcloud-photos/2026/Vacation/photo.jpg -> Vacation
|
|
||||||
#
|
|
||||||
# relative:
|
|
||||||
# /mnt/nextcloud-photos/2026/Vacation/photo.jpg -> 2026 - Vacation
|
|
||||||
ALBUM_MODE = os.environ.get("ALBUM_MODE", "root").lower()
|
|
||||||
|
|
||||||
ALBUM_SEPARATOR = os.environ.get("ALBUM_SEPARATOR", " - ")
|
|
||||||
PAGE_SIZE = int(os.environ.get("PAGE_SIZE", "1000"))
|
|
||||||
DRY_RUN = os.environ.get("DRY_RUN", "false").lower() == "true"
|
|
||||||
SYNC_INTERVAL_SECONDS = int(os.environ.get("SYNC_INTERVAL_SECONDS", "1800"))
|
|
||||||
|
|
||||||
logging.basicConfig(
|
|
||||||
level=logging.INFO,
|
|
||||||
format="%(asctime)s %(levelname)s %(message)s",
|
|
||||||
)
|
|
||||||
|
|
||||||
session = requests.Session()
|
|
||||||
session.headers.update(
|
|
||||||
{
|
|
||||||
"x-api-key": IMMICH_API_KEY,
|
|
||||||
"Accept": "application/json",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def check_configuration() -> None:
|
|
||||||
missing = []
|
|
||||||
|
|
||||||
if not IMMICH_URL:
|
|
||||||
missing.append("IMMICH_URL")
|
|
||||||
|
|
||||||
if not IMMICH_API_KEY:
|
|
||||||
missing.append("IMMICH_API_KEY")
|
|
||||||
|
|
||||||
if not LIBRARY_ID:
|
|
||||||
missing.append("IMMICH_LIBRARY_ID")
|
|
||||||
|
|
||||||
if missing:
|
|
||||||
raise RuntimeError(
|
|
||||||
"The following environment variables are missing: " + ", ".join(missing)
|
|
||||||
)
|
|
||||||
|
|
||||||
if ALBUM_MODE not in {"root", "leaf", "relative"}:
|
|
||||||
raise RuntimeError("ALBUM_MODE must be root, leaf, or relative")
|
|
||||||
|
|
||||||
if not EXTERNAL_ROOT.startswith("/"):
|
|
||||||
raise RuntimeError("EXTERNAL_ROOT must be an absolute path")
|
|
||||||
|
|
||||||
if SYNC_INTERVAL_SECONDS < 0:
|
|
||||||
raise RuntimeError("SYNC_INTERVAL_SECONDS must be zero or greater")
|
|
||||||
|
|
||||||
|
|
||||||
def api_request(
|
|
||||||
method: str,
|
|
||||||
path: str,
|
|
||||||
*,
|
|
||||||
json: dict[str, Any] | None = None,
|
|
||||||
) -> Any:
|
|
||||||
url = f"{IMMICH_URL}/api{path}"
|
|
||||||
|
|
||||||
response = session.request(
|
|
||||||
method,
|
|
||||||
url,
|
|
||||||
json=json,
|
|
||||||
timeout=120,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not response.ok:
|
|
||||||
raise RuntimeError(
|
|
||||||
f"{method} {url} failed: {response.status_code} {response.text}"
|
|
||||||
)
|
|
||||||
|
|
||||||
if response.status_code == 204 or not response.content:
|
|
||||||
return None
|
|
||||||
|
|
||||||
return response.json()
|
|
||||||
|
|
||||||
|
|
||||||
def get_albums() -> dict[str, dict[str, Any]]:
|
|
||||||
albums = api_request("GET", "/albums?isOwned=true")
|
|
||||||
|
|
||||||
return {album["albumName"]: album for album in albums}
|
|
||||||
|
|
||||||
|
|
||||||
def create_album(album_name: str) -> dict[str, Any]:
|
|
||||||
logging.info("Creating album: %s", album_name)
|
|
||||||
|
|
||||||
if DRY_RUN:
|
|
||||||
return {
|
|
||||||
"id": f"dry-run:{album_name}",
|
|
||||||
"albumName": album_name,
|
|
||||||
"assets": [],
|
|
||||||
}
|
|
||||||
|
|
||||||
return api_request(
|
|
||||||
"POST",
|
|
||||||
"/albums",
|
|
||||||
json={
|
|
||||||
"albumName": album_name,
|
|
||||||
"assetIds": [],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def search_assets(**filters: Any) -> list[dict[str, Any]]:
|
|
||||||
assets: list[dict[str, Any]] = []
|
|
||||||
page = 1
|
|
||||||
|
|
||||||
while True:
|
|
||||||
logging.info("Reading asset page %d", page)
|
|
||||||
|
|
||||||
result = api_request(
|
|
||||||
"POST",
|
|
||||||
"/search/metadata",
|
|
||||||
json={
|
|
||||||
**filters,
|
|
||||||
"page": page,
|
|
||||||
"size": PAGE_SIZE,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
asset_page = result.get("assets", {})
|
|
||||||
items = asset_page.get("items", [])
|
|
||||||
assets.extend(items)
|
|
||||||
|
|
||||||
next_page = asset_page.get("nextPage")
|
|
||||||
if not items or next_page is None:
|
|
||||||
break
|
|
||||||
|
|
||||||
page = int(next_page)
|
|
||||||
|
|
||||||
return assets
|
|
||||||
|
|
||||||
|
|
||||||
def get_album_asset_ids(album_id: str) -> set[str]:
|
|
||||||
assets = search_assets(albumIds=[album_id])
|
|
||||||
return {asset["id"] for asset in assets if asset.get("id")}
|
|
||||||
|
|
||||||
|
|
||||||
def add_assets_to_album(
|
|
||||||
album_id: str,
|
|
||||||
asset_ids: list[str],
|
|
||||||
) -> None:
|
|
||||||
if not asset_ids:
|
|
||||||
return
|
|
||||||
|
|
||||||
logging.info(
|
|
||||||
"Adding %d asset(s) to the album",
|
|
||||||
len(asset_ids),
|
|
||||||
)
|
|
||||||
|
|
||||||
if DRY_RUN:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Send smaller batches to keep the request body at a reasonable size.
|
|
||||||
batch_size = 500
|
|
||||||
|
|
||||||
for start in range(0, len(asset_ids), batch_size):
|
|
||||||
batch = asset_ids[start : start + batch_size]
|
|
||||||
|
|
||||||
api_request(
|
|
||||||
"PUT",
|
|
||||||
f"/albums/{album_id}/assets",
|
|
||||||
json={
|
|
||||||
"ids": batch,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def get_album_name(original_path: str) -> str | None:
|
|
||||||
normalized_root = posixpath.normpath(EXTERNAL_ROOT)
|
|
||||||
normalized_path = posixpath.normpath(original_path.replace("\\", "/"))
|
|
||||||
|
|
||||||
prefix = normalized_root + "/"
|
|
||||||
|
|
||||||
if not normalized_path.startswith(prefix):
|
|
||||||
return None
|
|
||||||
|
|
||||||
relative_path = normalized_path[len(prefix) :]
|
|
||||||
relative = PurePosixPath(relative_path)
|
|
||||||
|
|
||||||
# A file directly in EXTERNAL_ROOT has no album directory.
|
|
||||||
if len(relative.parts) < 2:
|
|
||||||
return None
|
|
||||||
|
|
||||||
directory_parts = relative.parts[:-1]
|
|
||||||
|
|
||||||
if ALBUM_MODE == "root":
|
|
||||||
return directory_parts[0]
|
|
||||||
|
|
||||||
if ALBUM_MODE == "leaf":
|
|
||||||
return directory_parts[-1]
|
|
||||||
|
|
||||||
return ALBUM_SEPARATOR.join(directory_parts)
|
|
||||||
|
|
||||||
|
|
||||||
def get_external_assets() -> list[dict[str, Any]]:
|
|
||||||
assets = search_assets(libraryId=LIBRARY_ID)
|
|
||||||
logging.info("%d external assets found", len(assets))
|
|
||||||
return assets
|
|
||||||
|
|
||||||
|
|
||||||
def synchronize() -> None:
|
|
||||||
existing_albums = get_albums()
|
|
||||||
assets = get_external_assets()
|
|
||||||
|
|
||||||
desired_albums: dict[str, list[str]] = {}
|
|
||||||
|
|
||||||
for asset in assets:
|
|
||||||
asset_id = asset.get("id")
|
|
||||||
original_path = asset.get("originalPath")
|
|
||||||
|
|
||||||
if not asset_id or not original_path:
|
|
||||||
continue
|
|
||||||
|
|
||||||
album_name = get_album_name(original_path)
|
|
||||||
|
|
||||||
if not album_name:
|
|
||||||
continue
|
|
||||||
|
|
||||||
desired_albums.setdefault(album_name, []).append(asset_id)
|
|
||||||
|
|
||||||
logging.info(
|
|
||||||
"%d album directories derived from asset paths",
|
|
||||||
len(desired_albums),
|
|
||||||
)
|
|
||||||
|
|
||||||
for album_name, desired_asset_ids in sorted(desired_albums.items()):
|
|
||||||
album = existing_albums.get(album_name)
|
|
||||||
|
|
||||||
if album is None:
|
|
||||||
album = create_album(album_name)
|
|
||||||
existing_albums[album_name] = album
|
|
||||||
|
|
||||||
album_id = album["id"]
|
|
||||||
|
|
||||||
if DRY_RUN:
|
|
||||||
existing_asset_ids: set[str] = set()
|
|
||||||
else:
|
|
||||||
# Immich v3 album responses no longer contain the asset list.
|
|
||||||
existing_asset_ids = get_album_asset_ids(album_id)
|
|
||||||
|
|
||||||
missing_asset_ids = sorted(set(desired_asset_ids) - existing_asset_ids)
|
|
||||||
|
|
||||||
if missing_asset_ids:
|
|
||||||
logging.info(
|
|
||||||
"Album '%s': %d new asset(s)",
|
|
||||||
album_name,
|
|
||||||
len(missing_asset_ids),
|
|
||||||
)
|
|
||||||
add_assets_to_album(album_id, missing_asset_ids)
|
|
||||||
else:
|
|
||||||
logging.info(
|
|
||||||
"Album '%s' is up to date",
|
|
||||||
album_name,
|
|
||||||
)
|
|
||||||
|
|
||||||
logging.info("Synchronization completed")
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
check_configuration()
|
|
||||||
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
synchronize()
|
|
||||||
except Exception:
|
|
||||||
if SYNC_INTERVAL_SECONDS == 0:
|
|
||||||
raise
|
|
||||||
logging.exception("Synchronization failed; the next run will retry")
|
|
||||||
|
|
||||||
if SYNC_INTERVAL_SECONDS == 0:
|
|
||||||
break
|
|
||||||
|
|
||||||
logging.info(
|
|
||||||
"Next synchronization in %d seconds",
|
|
||||||
SYNC_INTERVAL_SECONDS,
|
|
||||||
)
|
|
||||||
time.sleep(SYNC_INTERVAL_SECONDS)
|
|
||||||
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
try:
|
|
||||||
sys.exit(main())
|
|
||||||
except Exception:
|
|
||||||
logging.exception("Synchronization failed")
|
|
||||||
sys.exit(1)
|
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
println!("Hello, world!");
|
||||||
|
}
|
||||||
@@ -1,227 +0,0 @@
|
|||||||
import importlib.util
|
|
||||||
import unittest
|
|
||||||
from pathlib import Path
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
|
|
||||||
MODULE_PATH = Path(__file__).parents[1] / "src" / "main.py"
|
|
||||||
SPEC = importlib.util.spec_from_file_location("album_sync", MODULE_PATH)
|
|
||||||
assert SPEC and SPEC.loader
|
|
||||||
album_sync = importlib.util.module_from_spec(SPEC)
|
|
||||||
SPEC.loader.exec_module(album_sync)
|
|
||||||
|
|
||||||
|
|
||||||
class AlbumNameTests(unittest.TestCase):
|
|
||||||
def test_root_directory_becomes_album_name(self) -> None:
|
|
||||||
with patch.object(
|
|
||||||
album_sync,
|
|
||||||
"EXTERNAL_ROOT",
|
|
||||||
"/mnt/nextcloud-photos",
|
|
||||||
), patch.object(album_sync, "ALBUM_MODE", "root"):
|
|
||||||
self.assertEqual(
|
|
||||||
album_sync.get_album_name(
|
|
||||||
"/mnt/nextcloud-photos/Korea_2016_07_08/photo.jpg"
|
|
||||||
),
|
|
||||||
"Korea_2016_07_08",
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_nested_files_stay_in_root_album(self) -> None:
|
|
||||||
with patch.object(
|
|
||||||
album_sync,
|
|
||||||
"EXTERNAL_ROOT",
|
|
||||||
"/mnt/nextcloud-photos",
|
|
||||||
), patch.object(album_sync, "ALBUM_MODE", "root"):
|
|
||||||
self.assertEqual(
|
|
||||||
album_sync.get_album_name(
|
|
||||||
"/mnt/nextcloud-photos/Korea_2016_07_08/day-1/photo.jpg"
|
|
||||||
),
|
|
||||||
"Korea_2016_07_08",
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_file_directly_in_external_root_is_skipped(self) -> None:
|
|
||||||
with patch.object(
|
|
||||||
album_sync,
|
|
||||||
"EXTERNAL_ROOT",
|
|
||||||
"/mnt/nextcloud-photos",
|
|
||||||
):
|
|
||||||
self.assertIsNone(
|
|
||||||
album_sync.get_album_name(
|
|
||||||
"/mnt/nextcloud-photos/photo.jpg"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_similar_root_prefix_is_skipped(self) -> None:
|
|
||||||
with patch.object(
|
|
||||||
album_sync,
|
|
||||||
"EXTERNAL_ROOT",
|
|
||||||
"/mnt/nextcloud-photos",
|
|
||||||
):
|
|
||||||
self.assertIsNone(
|
|
||||||
album_sync.get_album_name(
|
|
||||||
"/mnt/nextcloud-photos-old/Korea/photo.jpg"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_parent_traversal_cannot_escape_external_root(self) -> None:
|
|
||||||
with patch.object(
|
|
||||||
album_sync,
|
|
||||||
"EXTERNAL_ROOT",
|
|
||||||
"/mnt/nextcloud-photos",
|
|
||||||
):
|
|
||||||
self.assertIsNone(
|
|
||||||
album_sync.get_album_name(
|
|
||||||
"/mnt/nextcloud-photos/../other/Korea/photo.jpg"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class SearchTests(unittest.TestCase):
|
|
||||||
def test_search_assets_follows_pagination(self) -> None:
|
|
||||||
responses = [
|
|
||||||
{
|
|
||||||
"assets": {
|
|
||||||
"items": [{"id": "one"}],
|
|
||||||
"nextPage": "2",
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"assets": {
|
|
||||||
"items": [{"id": "two"}],
|
|
||||||
"nextPage": None,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
with patch.object(
|
|
||||||
album_sync,
|
|
||||||
"api_request",
|
|
||||||
side_effect=responses,
|
|
||||||
) as request:
|
|
||||||
assets = album_sync.search_assets(libraryId="library-id")
|
|
||||||
|
|
||||||
self.assertEqual(assets, [{"id": "one"}, {"id": "two"}])
|
|
||||||
self.assertEqual(request.call_count, 2)
|
|
||||||
self.assertEqual(
|
|
||||||
request.call_args_list[0].kwargs["json"],
|
|
||||||
{
|
|
||||||
"libraryId": "library-id",
|
|
||||||
"page": 1,
|
|
||||||
"size": album_sync.PAGE_SIZE,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class SynchronizationTests(unittest.TestCase):
|
|
||||||
def test_main_creates_folder_album_with_all_assets(self) -> None:
|
|
||||||
assets = [
|
|
||||||
{
|
|
||||||
"id": "asset-one",
|
|
||||||
"originalPath": (
|
|
||||||
"/mnt/nextcloud-photos/"
|
|
||||||
"Korea_2016_07_08/photo-1.jpg"
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "asset-two",
|
|
||||||
"originalPath": (
|
|
||||||
"/mnt/nextcloud-photos/"
|
|
||||||
"Korea_2016_07_08/day-2/photo-2.jpg"
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
created_album = {
|
|
||||||
"id": "album-id",
|
|
||||||
"albumName": "Korea_2016_07_08",
|
|
||||||
}
|
|
||||||
|
|
||||||
with patch.object(
|
|
||||||
album_sync,
|
|
||||||
"EXTERNAL_ROOT",
|
|
||||||
"/mnt/nextcloud-photos",
|
|
||||||
), patch.object(
|
|
||||||
album_sync,
|
|
||||||
"ALBUM_MODE",
|
|
||||||
"root",
|
|
||||||
), patch.object(
|
|
||||||
album_sync,
|
|
||||||
"SYNC_INTERVAL_SECONDS",
|
|
||||||
0,
|
|
||||||
), patch.object(
|
|
||||||
album_sync,
|
|
||||||
"check_configuration",
|
|
||||||
), patch.object(
|
|
||||||
album_sync,
|
|
||||||
"get_albums",
|
|
||||||
return_value={},
|
|
||||||
), patch.object(
|
|
||||||
album_sync,
|
|
||||||
"get_external_assets",
|
|
||||||
return_value=assets,
|
|
||||||
), patch.object(
|
|
||||||
album_sync,
|
|
||||||
"create_album",
|
|
||||||
return_value=created_album,
|
|
||||||
) as create_album, patch.object(
|
|
||||||
album_sync,
|
|
||||||
"get_album_asset_ids",
|
|
||||||
return_value=set(),
|
|
||||||
), patch.object(
|
|
||||||
album_sync,
|
|
||||||
"add_assets_to_album",
|
|
||||||
) as add_assets:
|
|
||||||
result = album_sync.main()
|
|
||||||
|
|
||||||
self.assertEqual(result, 0)
|
|
||||||
create_album.assert_called_once_with("Korea_2016_07_08")
|
|
||||||
add_assets.assert_called_once_with(
|
|
||||||
"album-id",
|
|
||||||
["asset-one", "asset-two"],
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_continuous_mode_waits_and_runs_again(self) -> None:
|
|
||||||
with patch.object(
|
|
||||||
album_sync,
|
|
||||||
"SYNC_INTERVAL_SECONDS",
|
|
||||||
1800,
|
|
||||||
), patch.object(
|
|
||||||
album_sync,
|
|
||||||
"check_configuration",
|
|
||||||
), patch.object(
|
|
||||||
album_sync,
|
|
||||||
"synchronize",
|
|
||||||
side_effect=[None, KeyboardInterrupt],
|
|
||||||
) as synchronize, patch.object(
|
|
||||||
album_sync.time,
|
|
||||||
"sleep",
|
|
||||||
) as sleep:
|
|
||||||
with self.assertRaises(KeyboardInterrupt):
|
|
||||||
album_sync.main()
|
|
||||||
|
|
||||||
self.assertEqual(synchronize.call_count, 2)
|
|
||||||
sleep.assert_called_once_with(1800)
|
|
||||||
|
|
||||||
def test_continuous_mode_retries_after_sync_error(self) -> None:
|
|
||||||
with patch.object(
|
|
||||||
album_sync,
|
|
||||||
"SYNC_INTERVAL_SECONDS",
|
|
||||||
1800,
|
|
||||||
), patch.object(
|
|
||||||
album_sync,
|
|
||||||
"check_configuration",
|
|
||||||
), patch.object(
|
|
||||||
album_sync,
|
|
||||||
"synchronize",
|
|
||||||
side_effect=[RuntimeError("temporary"), KeyboardInterrupt],
|
|
||||||
) as synchronize, patch.object(
|
|
||||||
album_sync.time,
|
|
||||||
"sleep",
|
|
||||||
) as sleep:
|
|
||||||
with self.assertRaises(KeyboardInterrupt):
|
|
||||||
album_sync.main()
|
|
||||||
|
|
||||||
self.assertEqual(synchronize.call_count, 2)
|
|
||||||
sleep.assert_called_once_with(1800)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
version = 1
|
|
||||||
revision = 3
|
|
||||||
requires-python = ">=3.14"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "certifi"
|
|
||||||
version = "2026.7.22"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "charset-normalizer"
|
|
||||||
version = "3.4.9"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/bd/2a/23f34ec9d04624958e137efdc394888716353190e75f25dd22c7a2c7a8aa/charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b", size = 152439, upload-time = "2026-07-07T14:34:58.454Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/7e/8d/496817fa0944239ecae662dd57ea765cfeaec6a735f9f025d4b7b72e7143/charset_normalizer-3.4.9-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380", size = 317253, upload-time = "2026-07-07T14:33:54.994Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/2b/f9/ef4a69ea338ad3c0deceea0f5f7d2380ae8b52132b06d652cb0d2cd86706/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9", size = 215898, upload-time = "2026-07-07T14:33:56.334Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/8c/e7/5ddfd76fc061eb52de219658a4aa431cbacadf0a0219c8854f00da50d289/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4", size = 236718, upload-time = "2026-07-07T14:33:57.9Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/49/ba/768fa3f36048d81c477a0ce61f813bc1454d80917ccfe550abd9f44f5e24/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a", size = 232519, upload-time = "2026-07-07T14:33:59.811Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f4/c4/b3e049d2aa3766180c78507110543d9d50894cc97f57de543f1be521dcdc/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046", size = 223143, upload-time = "2026-07-07T14:34:01.517Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/19/79/55c32d06d76ae4feafe053f061f3e3ab70bcf19f4007797ce8c3efda7830/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81", size = 206742, upload-time = "2026-07-07T14:34:03.04Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/10/e0/47c079dd82d217c807479cd59ffd30af56307ea31c108b75758970459ad3/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917", size = 219191, upload-time = "2026-07-07T14:34:04.657Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/42/ab/b9bc2e77d6b44a7e46ef62ec5cac1c9a6ba7b9135a5d560f002696ec9995/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41", size = 218328, upload-time = "2026-07-07T14:34:06.115Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f1/78/c9c71d599f5aa2d42bcdd35cbbd46d7f535351a57e40ff7d8e5a7e219401/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1", size = 207406, upload-time = "2026-07-07T14:34:07.554Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f6/39/c914445c321a845097ce4f6ac7de9a18228a77b766272125a1ce00d851eb/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf", size = 225157, upload-time = "2026-07-07T14:34:09.061Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/9b/f2/c0d4b8508565a36bc5c624e88ed297f5b0b1095011034d7f5b83a69908b5/charset_normalizer-3.4.9-cp314-cp314-win32.whl", hash = "sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48", size = 151095, upload-time = "2026-07-07T14:34:10.901Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/49/fd/a1d26144398c67486422a72bf5812cda22cb4ccfcd95a290fb41ceb4b8e2/charset_normalizer-3.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b", size = 162796, upload-time = "2026-07-07T14:34:12.47Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/20/95/d75e82f8ce9fd323ebf059c16c9aadefb22a1ecde13b7840b35835e4886c/charset_normalizer-3.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519", size = 153334, upload-time = "2026-07-07T14:34:14.044Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/00/5e/17398df3a139985ba9d11ed072531986f408c8fca952835ef1ab1820c02b/charset_normalizer-3.4.9-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198", size = 338848, upload-time = "2026-07-07T14:34:15.688Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/cd/91/7253a32e86b7e1d1239b1b36ba6dd0f021a21107ab33054b53119cc083b9/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32", size = 223022, upload-time = "2026-07-07T14:34:17.248Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/cb/32/2e64bd2be10e89c61e57ebe6a93fd98ae88eb7ebe414b5121f22c96c69eb/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632", size = 241590, upload-time = "2026-07-07T14:34:18.813Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/3d/ef/d96ec496cfea0c21db43b0ad03891308b02388d054cc902cf0e5a1ad6a88/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf", size = 239584, upload-time = "2026-07-07T14:34:20.52Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d4/ce/9af95f7876194bd7a14e3dfe4a4de2e0bff02666a3910d72beafd06cc297/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990", size = 230224, upload-time = "2026-07-07T14:34:22.189Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/52/94/af74dde74a3996bd959c350709bfe50e297823d70a8c1cbd54b838880863/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d", size = 212667, upload-time = "2026-07-07T14:34:23.857Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ee/f0/f1c4fe746c395922961b5916ed1d7d6e7d4c84851d19ed43cc89980ec953/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e", size = 227179, upload-time = "2026-07-07T14:34:25.586Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/e4/56/6c745619ac397e8871e2bcd3cea1eec86b877488f33888b3aef5c3ed506e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c", size = 225372, upload-time = "2026-07-07T14:34:27.212Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/78/ad/98aae8630ac71f16711968e38a5acfecce41b778bf2f0312851020f565a8/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2", size = 215222, upload-time = "2026-07-07T14:34:28.774Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f7/40/9593d54209765207a7f11073c06494c1721e4ca4a0a426c597679bf7f91e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534", size = 231958, upload-time = "2026-07-07T14:34:30.345Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/b1/27/693ee5e8a18191eb38647360c51cd505013e2bd3b366aa43fd5344c21e3c/charset_normalizer-3.4.9-cp314-cp314t-win32.whl", hash = "sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226", size = 155580, upload-time = "2026-07-07T14:34:31.884Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/80/3f/bd97d3d9c613013d07cb7733d299385b41df37f0471310f5a73dc359f0b8/charset_normalizer-3.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177", size = 167620, upload-time = "2026-07-07T14:34:33.438Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/3d/c6/eee9dca4439b1061f76373f06ea855678cc4a64c1c3c90b50e479edbb8eb/charset_normalizer-3.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501", size = 158037, upload-time = "2026-07-07T14:34:35.018Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/98/2b/f97f1c193fb855c345d678f5077d6926034db0722df74c8f057020e05a25/charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5", size = 64538, upload-time = "2026-07-07T14:34:56.993Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "idna"
|
|
||||||
version = "3.18"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "immich-extern-to-album"
|
|
||||||
version = "0.1.0"
|
|
||||||
source = { virtual = "." }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "requests" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[package.metadata]
|
|
||||||
requires-dist = [{ name = "requests", specifier = ">=2.34.2" }]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "requests"
|
|
||||||
version = "2.34.2"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "certifi" },
|
|
||||||
{ name = "charset-normalizer" },
|
|
||||||
{ name = "idna" },
|
|
||||||
{ name = "urllib3" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "urllib3"
|
|
||||||
version = "2.7.0"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
|
|
||||||
]
|
|
||||||
Reference in New Issue
Block a user