Skip to content
3 changes: 0 additions & 3 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,2 @@
{
"python.linting.pylintEnabled": true,
"python.linting.pydocstyleEnabled": true,
"python.linting.enabled": true
}
5 changes: 4 additions & 1 deletion pychee/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,8 @@
"""
# pychee: Client for Lychee, written in Python.

For additonal information, visit: [Lychee](https://github.com/LycheeOrg/Lychee).
For additional information, visit:
[Lychee](https://github.com/LycheeOrg/Lychee).
"""

from pychee.pychee import __version__
110 changes: 72 additions & 38 deletions pychee/pychee.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,27 @@
For additional information, visit: https://github.com/LycheeOrg/Lychee.
"""
from posixpath import join
from typing import List
from typing import List, Dict, Optional
from urllib.parse import unquote
from datetime import datetime

from requests import Session
from requests.exceptions import JSONDecodeError

__version__ = '0.2.2'
__version__ = '0.2.4'

class LycheeForbidden(Exception):
class LycheeError(Exception):
"""Raised for general Lychee errors."""

class LycheeNotAuthenticated(LycheeError):
"""Raised when a call results in an unauthenticated error."""

class LycheeForbidden(LycheeError):
"""Raised when the Lychee request is unauthorized."""

class LycheeNotFound(Exception):
class LycheeNotFound(LycheeError):
"""Raised when the requested resource was not found."""

class LycheeError(Exception):
"""Raised for general Lychee errors."""

#FIXME add error code handling
#FIXME adjust to API sending JSON because we changed Accept
#FIXME fix type hints...
Expand All @@ -38,6 +43,10 @@ class LycheeAPISession(Session):
'"Error: validation failed"'
]

NOT_AUTHENTICATED_MESSAGES = [
'User is not authenticated',
]

NOT_FOUND_MESSAGES = [
'"Error: no pictures found!"'
]
Expand Down Expand Up @@ -67,10 +76,18 @@ def request(self, method, url, *args, **kwargs):
# Update CSRF header if changed
if response.text in self.FORBID_MESSAGES:
raise LycheeForbidden(response.text)
if response.text in self.NOT_FOUND_MESSAGES:
elif response.text in self.NOT_FOUND_MESSAGES:
raise LycheeNotFound(response.text)
if response.text == 'false' or response.text is None:
elif response.text == 'false' or response.text is None:
raise LycheeError('Could be unauthorized, wrong args, who knows?')
else:
try:
json = response.json()
if json.get('message') in self.NOT_AUTHENTICATED_MESSAGES:
raise LycheeNotAuthenticated(response.text)
except JSONDecodeError:
pass # Do Nothing
response.raise_for_status()
return response

def _set_csrf_header(self) -> None:
Expand Down Expand Up @@ -102,13 +119,17 @@ class LycheeClient:
def __init__(self, url: str):
"""Initialize a new Lychee session for given URL."""
self._session = LycheeAPISession(url)
self._session.post('Session::init', json={})
self._session.request('post', 'Session::init', json={})

def login(self, username: str, password: str) -> None:
"""Log in to Lychee server."""
auth = {'username': username, 'password': password}
# Session takes care of setting cookies
login_response = self._session.post('Session::login', json=auth)
login_response = self._session.request(
'post',
'Session::login',
json=auth
)

def logout(self):
"""Log out from Lychee server."""
Expand All @@ -121,7 +142,7 @@ def get_albums(self) -> dict:

Returns an array of albums or false on failure.
"""
return self._session.post('Albums::get', json={}).json()
return self._session.request('post', 'Albums::get', json={}).json()

def get_albums_tree(self):
"""
Expand All @@ -130,7 +151,7 @@ def get_albums_tree(self):
Returns a list of albums dictionaries or an informative message on
failure.
"""
return self._session.post('Albums::tree', json={}).json()
return self._session.request('post', 'Albums::tree', json={}).json()

def get_albums_position_data(self) -> dict:
"""
Expand Down Expand Up @@ -158,7 +179,7 @@ def get_public_album(self, album_id: str, password: str = 'rand'):
password.
"""
data = {'albumID': album_id, 'password': password}
self._session.post('Album::getPublic', json=data)
return self._session.post('Album::getPublic', json=data).json()

def add_album(self, title: str, parent_id: str = None) -> str:
"""
Expand All @@ -169,28 +190,27 @@ def add_album(self, title: str, parent_id: str = None) -> str:
Return the ID of the new image.
"""
data = {'title': title, 'parent_id': parent_id}
return self._session.post('Album::add', json=data).json()
return self._session.request('post', 'Album::add', json=data).json()

def set_albums_title(self, album_ids: List[str], title: str):
"""Change the title of the albums."""
data = {'albumIDs': ','.join(album_ids), 'title': title}
self._session.post('Album::setTitle', json=data)
self._session.request('post', 'Album::setTitle', json=data)

def set_album_description(self, album_id: str, description: str):
"""Change the description of the album."""
data = {'albumID': album_id, 'description': description}
self._session.post('Album::setDescription', json=data)
self._session.request('post', 'Album::setDescription', json=data)

def set_album_public(
self,
album_id: str,
public: int,
visible: int,
nsfw: int,
downloadable: int,
share_button_visible: int,
full_photo: int,
password: str = ""
public: bool,
link_required: bool,
nsfw: bool,
downloadable: bool,
full_photo_access: int,
password: Optional[str] = None
):
"""
Change the sharing properties of the album.
Expand All @@ -199,20 +219,19 @@ def set_album_public(
"""
data = {
'albumID': album_id,
'public': public,
'visible': visible,
'nsfw': nsfw,
'downloadable': downloadable,
'share_button_visible': share_button_visible,
'full_photo': full_photo,
'grants_download': downloadable,
'grants_full_photo_access': full_photo_access,
'is_link_required': link_required,
'is_nsfw': nsfw,
'is_public': public,
'password': password
}
self._session.post('Album::setPublic', json=data)
self._session.request('post', 'Album::setProtectionPolicy', json=data)

def delete_album(self, album_id: List[str]):
"""Delete the albums and all pictures in the album."""
data = {'albumIDs': album_id}
self._session.post('Album::delete', json=data)
self._session.request('post', 'Album::delete', json=data)

def merge_albums(self, dest_id: str, source_ids: List[str]):
"""
Expand All @@ -222,12 +241,12 @@ def merge_albums(self, dest_id: str, source_ids: List[str]):
it will be deleted. Don't do this.
"""
data = {'albumIDs': dest_id + ',' + ','.join(source_ids)}
self._session.post('Album::merge', json=data)
self._session.request('post', 'Album::merge', json=data)

def move_albums(self, dest_id: str, source_ids: List[str]):
"""Move albums into another one, which becomes their parent."""
data = {'albumIDs': dest_id + ',' + ','.join(source_ids)}
self._session.post('Album::move', json=data)
self._session.request('post', 'Album::move', json=data)

def set_album_license(self, album_id: str, license: str):
"""
Expand All @@ -239,7 +258,7 @@ def set_album_license(self, album_id: str, license: str):
Returns false if license name is unrecognized.
"""
data = {'albumID': album_id, 'license': license}
self._session.post('Album::setLicense', json=data)
self._session.request('post', 'Album::setLicense', json=data)

def get_albums_archive(self, album_ids: List[str]) -> bytes:
"""
Expand All @@ -251,7 +270,11 @@ def get_albums_archive(self, album_ids: List[str]) -> bytes:
data = {'albumIDs': ','.join(album_ids)}
# For large archives, maybe we would use
# stream=True and iterate over chunks of answer.
return self._session.get('Album::getArchive', params=data).content
return self._session.request(
'post',
'Album::getArchive',
params=data
).content

def get_frame_settings(self) -> dict:
"""
Expand Down Expand Up @@ -308,15 +331,26 @@ def set_photos_tags(self, photo_ids: List[str], tags: List[str]):
data = {'photoIDs': ','.join(photo_ids), 'tags': ','.join(tags)}
self._session.post('Photo::setTags', json=data)

def add_photo(self, photo: bytes, photo_name: str, album_id: str) -> str:
def add_photo(
self,
photo: bytes,
photo_name: str,
album_id: str,
file_last_modified_time: Optional[int] = None
) -> Dict:
"""
Upload a photo into an album.

photo should be open('/your/photo', 'rb').read().

Return the ID of the uploaded image.
"""
data = {'albumID': album_id}
if file_last_modified_time is None:
file_last_modified_time = datetime.now().microsecond
data = {
'albumID': album_id,
'fileLastModifiedTime': file_last_modified_time
}
# Lychee expects a multipart/form-data with a field called name and being `file`,
# which contradicts with API doc for now
# See syntax there : https://stackoverflow.com/a/12385661
Expand Down