From 0e95c6af1f045e5413e4dd0db0c9404cdb25bb13 Mon Sep 17 00:00:00 2001 From: fraoustin Date: Tue, 19 Jan 2016 19:29:31 +0100 Subject: [PATCH] end dev upload and download --- AUTHORS.txt | 2 +- CHANGES.rst | 11 + README.rst | 59 ++++- REQUIREMENTS.txt | 1 + piwigotools/__init__.py | 224 +++++++++++++++++- piwigotools/interface.py | 107 +++++++++ piwigotools/main.py | 199 ++++++++++++++++ piwigotools/progressbar/__init__.py | 54 +++++ piwigotools/progressbar/compat.py | 45 ++++ piwigotools/progressbar/progressbar.py | 296 +++++++++++++++++++++++ piwigotools/progressbar/widgets.py | 311 +++++++++++++++++++++++++ setup.py | 10 +- tests/samplepiwigotools.jpg | Bin 0 -> 16247 bytes tests/test_basic.py | 83 ++++++- 14 files changed, 1396 insertions(+), 6 deletions(-) create mode 100644 piwigotools/interface.py create mode 100644 piwigotools/main.py create mode 100644 piwigotools/progressbar/__init__.py create mode 100644 piwigotools/progressbar/compat.py create mode 100644 piwigotools/progressbar/progressbar.py create mode 100644 piwigotools/progressbar/widgets.py create mode 100644 tests/samplepiwigotools.jpg diff --git a/AUTHORS.txt b/AUTHORS.txt index 0fe7016..a2bc2bc 100644 --- a/AUTHORS.txt +++ b/AUTHORS.txt @@ -1 +1 @@ -Name::name@mail.com +Frédéric Aoustin::fraoustin@gmail.com diff --git a/CHANGES.rst b/CHANGES.rst index e69de29..2a4ca7b 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -0,0 +1,11 @@ +0.0.2 +===== + +- add verb upload, download and ws +- integrate progressbar +- manage thread + +0.0.1 +===== + +init diff --git a/README.rst b/README.rst index bbab3ba..35e9f68 100644 --- a/README.rst +++ b/README.rst @@ -1 +1,58 @@ -piwigotools +piwigotools +=========== + +Piwigo is a famous open-source online photo gallery. + +Piwigotools is a module python for manage your piwigo gallery. +The module add command "piwigo" + + +Installation +------------ + +Warning +~~~~~~~ + +Piwigotools needs the progressbar module. + +But progressbar moduel is not compatible with python3 + +install for python2.7 + +:: + + pip install progressbar + +install for python 3 + +:: + + git clone https://github.com/coagulant/progressbar-python3.git + cd progressbar-python3 + python setup.py install + + +Install +~~~~~~~ + + +:: + + pip install piwigotools + +Or + +:: + + git clone https://github.com/fraoustin/piwigotools.git + cd piwigotools + python setup.py install + +Usage +----- + +:: + + piwigo verb --param1=value1 --param2=value2 ... + + diff --git a/REQUIREMENTS.txt b/REQUIREMENTS.txt index e69de29..65a6f3e 100644 --- a/REQUIREMENTS.txt +++ b/REQUIREMENTS.txt @@ -0,0 +1 @@ +piwigo diff --git a/piwigotools/__init__.py b/piwigotools/__init__.py index 2f6b102..6374622 100644 --- a/piwigotools/__init__.py +++ b/piwigotools/__init__.py @@ -5,5 +5,227 @@ Module piwigotools """ -__version_info__ = (0, 0, 1) +__version_info__ = (0, 0, 2) __version__ = '.'.join([str(val) for val in __version_info__]) + +import inspect + +import requests + +import piwigo + +class LoginException(Exception): + + def __str__(self): + return "You are not logged" + +class PiwigoException(Exception): + + def __init__(self, strerr): + self._strerr = strerr + + def __str__(self): + return self._strerr + +class PiwigoExistException(PiwigoException): + + def __init__(self, strerr): + PiwigoException.__init__(self, strerr) + + +class Piwigo(piwigo.Piwigo): + """ + describe piwigo gallery + """ + + def __init__(self, url): + piwigo.Piwigo.__init__(self, url) + self._login = False + + def login(self, username, password): + """ + login on piwigo gallery + """ + self.pwg.session.login(username=username, password=password) + self._login = True + return True + + def logout(self): + """ + logout on piwigo gallery + """ + self.pwg.session.logout() + self._login = False + return True + + @property + def plan(self): + #return { (("/%s" % i["name"].replace(" / ","/")).encode('utf-8')).decode('utf-8') : i["id"] for i in self.pwg.categories.getList(recursive=True, fullname=True)['categories'] } + return { "/%s" % i["name"].replace(" / ","/") : i["id"] for i in self.pwg.categories.getList(recursive=True, fullname=True)['categories'] } + + + def _checkarg(fn): + def checking(self, *args, **kw): + args = list(args) + # manage path + if inspect.getargspec(fn).args.count('path'): + pos = inspect.getargspec(fn).args.index('path') -1 + if args[pos][-1] == '/' : args[pos] = args[pos][:-1] + args = tuple(args) + return fn(self, *args, **kw) + return checking + + def _checklogin(fn): + def checking(self, *args, **kw): + if self._login: + return fn(self, *args, **kw) + raise LoginException() + return checking + + @property + @_checklogin + def token(self): + """ + return pwg_token + """ + return self.pwg.session.getStatus()["pwg_token"] + + def islogged(self): + try: + self.token + except LoginException: + return False + return True + + @_checkarg + def iscategory(self, path): + if path in self.plan: + return True + return False + + @_checkarg + def idcategory(self, path): + if not self.iscategory(path): + raise PiwigoExistException("category %s not exist" % path) + return self.plan[path] + + @_checkarg + def images(self, path, **kw): + """ + return list of file name image for path + """ + kw["cat_id"]= self.idcategory(path) + kw["per_page"] = 200 + kw["page"] = 0 + imgs = {} + loop = True + while loop: + req = self.pwg.categories.getImages(**kw) + for img in req["images"]: + imgs[img["file"]] = img + if req["paging"]["count"] < req["paging"]["per_page"]: + loop = False + return imgs + + @_checkarg + def sublevels(self, path, **kw): + """ + return list of category in for path + """ + kw["cat_id"]= self.idcategory(path) + return { i["name"] : i for i in self.pwg.categories.getList(**kw)['categories'] if i["id"] != kw["cat_id"] } + + + @_checkarg + def isimage(self, path): + img = path.split('/')[-1] + path = '/'.join(path.split('/')[:-1]) + if img in self.images(path): + return True + return False + + @_checkarg + def idimage(self, path): + if not self.isimage(path): + raise PiwigoExistException("image %s not exist" % path) + img = path.split('/')[-1] + path = '/'.join(path.split('/')[:-1]) + return self.images(path)[img]["id"] + + @_checkarg + @_checklogin + def mkdir(self, path, **kw): + """ + create a category named path + """ + kw['name'] = path.split('/')[-1] + parent = '/'.join(path.split('/')[:-1]) + if parent and not self.iscategory(parent): + raise PiwigoExistException("category %s not exist" % parent) + if parent : kw['parent'] = self.plan[parent] + self.pwg.categories.add(**kw) + return self.idcategory(path) + + @_checkarg + @_checklogin + def makedirs(self, path, **kw): + """ + recursive category create function + """ + pp = '/' + for p in path.split('/')[1:]: + pp = '%s%s' % (pp, p) + if not self.iscategory(pp): + self.mkdir(pp, **kw) + pp = '%s/' % pp + return self.idcategory(path) + + @_checkarg + @_checklogin + def removedirs(self, path, **kw): + """ + remove (delete) category + """ + self.pwg.categories.delete(category_id=self.idcategory(path), pwg_token=self.token, **kw) + return True + + @_checkarg + @_checklogin + def upload(self, image, path="", **kw): + """ + upload image in path + """ + kw["image"] = image + if len(path): + if not self.iscategory(path): + raise PiwigoExistException("category %s not exist" % parent) + kw['category'] = self.idcategory(path) + return self.pwg.images.addSimple(**kw)['image_id'] + + @_checkarg + @_checklogin + def download(self, path, dst, **kw): + """ + download image dst + """ + if not self.isimage(path): + raise PiwigoException("image %s not exist" % path) + img = path.split('/')[-1] + path = '/'.join(path.split('/')[:-1]) + url = self.images(path)[img]['element_url'] + with open(dst, 'wb') as img: + r = requests.get(url) + img.write(r.content) + r.connection.close() + return True + + + @_checklogin + def remove(self, path, **kw): + """ + remove (delete) image + """ + if not self.isimage(path): + raise PiwigoException("image %s not exist" % path) + self.pwg.images.delete(image_id= self.idimage(path), pwg_token=self.token) + return True diff --git a/piwigotools/interface.py b/piwigotools/interface.py new file mode 100644 index 0000000..67694a9 --- /dev/null +++ b/piwigotools/interface.py @@ -0,0 +1,107 @@ +# -*- coding: utf-8 -*- + +import sys +import os +import threading +try: + import Queue as queue +except: + import queue +import time + +import piwigotools.progressbar as progressbar + +class Step(threading.Thread): + + def __init__(self, qin, qout, qerr): + threading.Thread.__init__(self) + self.qin = qin + self.qout = qout + self.qerr = qerr + + def run(self): + while not self.qin.empty(): + try: + call, arg, kw = self.qin.get_nowait() + try: + call(*arg, **kw) + except Exception as e: + print(e) + self.qerr.put([call, arg, kw, e]) + self.qout.put([call, arg, kw]) + except queue.Empty: + pass + +class Run: + + def __init__(self, name, cnt=1): + self._name = name + self._qin = queue.Queue() + self._qout = queue.Queue() + self._qerr = queue.Queue() + self._threads = [ Step(self._qin, self._qout, self._qerr) for i in range(cnt)] + + def add(self, call, arg, kw): + self._qin.put([call, arg, kw]) + + def start(self): + self._qout.maxsize = self._qin.qsize() + pbar = progressbar.ProgressBar(widgets=['%s ' % self._name, + progressbar.Counter() , + ' on %s ' % self._qin.qsize(), + progressbar.Bar(), + ' ', + progressbar.Timer()], + maxval=self._qin.qsize()).start() + for thread in self._threads: + thread.start() + while not self._qout.full(): + time.sleep(0.1) # sleep 0.1s + pbar.update(self._qout.qsize()) + pbar.finish() + return self._qerr + +class StepAnalyse(threading.Thread): + + def __init__(self, pbar): + threading.Thread.__init__(self) + self._pbar = pbar + self._stopevent = threading.Event() + + def run(self): + self._pbar.start() + i = 0 + while not self._stopevent.isSet(): + try: + self._pbar.update(i) + i = i + 1 + except: + pass + time.sleep(0.1) + + def stop(self): + self._stopevent.set() + self._pbar.finish() + +class Analyse: + + def __init__(self, name): + self._name = name + pbar = progressbar.ProgressBar(widgets=['%s: ' % name, + progressbar.AnimatedMarker(), + ' | ', + progressbar.Timer()] + ) + self._thread = StepAnalyse(pbar) + + def start(self): + self._thread.start() + + def stop(self): + self._thread.stop() + +def purge_kw(kw, notkw): + return {k : kw[k] for k in kw if k not in notkw} + + + diff --git a/piwigotools/main.py b/piwigotools/main.py new file mode 100644 index 0000000..2fe42f1 --- /dev/null +++ b/piwigotools/main.py @@ -0,0 +1,199 @@ +# -*- coding: utf-8 -*- + +import sys +import os, os.path +import glob +import pprint + +try: + from myterm.parser import OptionParser +except: + from optparse import OptionParser + +from piwigotools import Piwigo, __version__ +from piwigo.ws import Ws +from piwigotools.interface import * + +DESCRIPTION = "tools for piwigo gallery" +USAGE = """piwigo verb --param1=value1 --param2=value2 +verb list +- upload +- download +- sync +- ws + +to get help: piwigo verb --help +""" +AUTHOR = "Frederic Aoustin" +PROG = "piwigo" +VERSION = __version__ + +VERBS = { + "upload": + { + "usage" : "usage for verb upload", + "description" : "upload file in piwigo gallery", + "arg" : + { + "category" : {"type":"string", "default":"/", "help":"destination category of piwigo gallery"}, + "source" : {"type":"string", "default":"*.jpg", "help":"path of upload picture"}, + "url" : {"type":"string", "default":"", "help":"url of piwigo gallery"}, + "user" : {"type":"string", "default":"", "help":"user of piwigo gallery"}, + "password" : {"type":"string", "default":"", "help":"password of piwigo gallery"}, + "thread" : {"type":"int", "default":"1", "help":"number of thread"}, + }, + }, + "download": + { + "usage" : "usage for verb download", + "description" : "download image from piwigo gallery", + "arg" : + { + "category" : {"type":"string", "default":"/", "help":"source category of piwigo gallery"}, + "dest" : {"type":"string", "default":".", "help":"path of destination"}, + "url" : {"type":"string", "default":"", "help":"url of piwigo gallery"}, + "user" : {"type":"string", "default":"", "help":"user of piwigo gallery"}, + "password" : {"type":"string", "default":"", "help":"password of piwigo gallery"}, + "thread" : {"type":"int", "default":"1", "help":"number of thread"}, + }, + }, + "sync": + { + "usage" : "usage for verb sync", + "description" : "synchronization between path and piwigo gallery", + "arg" : + { + "category" : {"type":"string", "default":"/", "help":"category of piwigo gallery"}, + "source" : {"type":"string", "default":".", "help":"path of picture"}, + "url" : {"type":"string", "default":"", "help":"url of piwigo gallery"}, + "user" : {"type":"string", "default":"", "help":"user of piwigo gallery"}, + "password" : {"type":"string", "default":"", "help":"password of piwigo gallery"}, + "thread" : {"type":"int", "default":"1", "help":"number of thread"}, + }, + }, + "ws": + { + "usage" : "usage for verb ws", + "description" : "use web service of piwigo gallery", + "arg" : + { + "method" : {"type":"string", "default":".", "help":"name of web service"}, + "url" : {"type":"string", "default":"", "help":"url of piwigo gallery"}, + }, + }, + + } + +def add_dynamic_option(parser): + + # add arg for verb + if not len(sys.argv) > 1: + parser.print_help() + sys.exit(1) + + if sys.argv[1] in ("--help", "-h"): + parser.print_help() + parser.print_version() + sys.exit(0) + + if sys.argv[1] in ("--version"): + parser.print_version() + sys.exit(0) + + + verb = sys.argv[1] + arg_know = ['--help'] + for arg in VERBS.get(verb, {'arg':{}})['arg']: + kw = VERBS[sys.argv[1]]['arg'][arg] + kw['dest'] = arg + parser.add_option("--%s" % arg, **kw) + arg_know.append("--%s" % arg) + # add arg in argv + for arg in sys.argv[2:]: + if arg[:2] == '--' and arg.split('=')[0] not in arg_know: + arg = arg[2:].split('=')[0] + parser.add_option("--%s" % arg , dest=arg, type="string") + arg_know.append("--%s" % arg) + + + #check verb + if verb not in VERBS: + parser.print_help() + parser.exit(status=2, msg='verb "%s" unknow\n' % verb) + sys.exit(0) + + parser.set_usage(VERBS[verb]["usage"]) + parser.description = VERBS[verb]["description"] + + if '--help' in sys.argv[1:]: + parser.print_help() + sys.exit(0) + + + +def main(): + usage = USAGE + parser = OptionParser(version="%s %s" % (PROG,VERSION), usage=usage) + parser.description= DESCRIPTION + parser.epilog = AUTHOR + try: + add_dynamic_option(parser) + (options, args) = parser.parse_args() + verb = args[0] + if verb == 'ws': + piwigo = Piwigo(url=options.url) + if 'user' and 'password' in options.__dict__: + piwigo.login(options.user, options.password) + kw = purge_kw(options.__dict__,('user','password','url')) + pp = pprint.PrettyPrinter(indent=4) + pp.pprint(Ws(piwigo, options.method)(**kw)) + if piwigo.islogged: + piwigo.logout() + if verb == "download": + ana = Analyse('Analyze') + ana.start() + piwigo = Piwigo(url=options.url) + piwigo.login(options.user, options.password) + # check + if not os.path.isdir(options.dest): + os.makedirs(options.dest) + options.dest = os.path.abspath(options.dest) + piwigo.iscategory(options.category) + if options.category[-1] == '/' : options.category = options.category[:-1] + # treatment + run = Run(verb, options.thread) + kw = purge_kw(options.__dict__,('user','password','url','dest','category','thread')) + for img in piwigo.images(options.category, **kw): + run.add(piwigo.download, + ["%s/%s" % (options.category, str(img)), "%s/%s" % (options.dest, str(img))], + kw) + ana.stop() + run.start() + piwigo.logout() + if verb == "upload": + ana = Analyse('Analyze') + ana.start() + piwigo = Piwigo(url=options.url) + piwigo.login(options.user, options.password) + # check + piwigo.makedirs(options.category) + # treatment + run = Run(verb, options.thread) + kw = purge_kw(options.__dict__,('user','password','url','source','category','thread')) + for img in glob.glob(options.source): + run.add(piwigo.upload, + [os.path.abspath(img), options.category], + kw) + ana.stop() + run.start() + piwigo.logout() + except Exception as e: + parser.error(e) + sys.exit(1) + +if __name__ == "__main__": + main() + +# TODO +# verb sync +# test python3: problem request return bytes and not str ... only str python2 or 3 and encoding? diff --git a/piwigotools/progressbar/__init__.py b/piwigotools/progressbar/__init__.py new file mode 100644 index 0000000..5680797 --- /dev/null +++ b/piwigotools/progressbar/__init__.py @@ -0,0 +1,54 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# progressbar - Text progress bar library for Python. +# Copyright (c) 2005 Nilton Volpato +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + + +# IMPORTANT +# copy of https://github.com/coagulant/progressbar-python3 + + +"""Text progress bar library for Python. + +A text progress bar is typically used to display the progress of a long +running operation, providing a visual cue that processing is underway. + +The ProgressBar class manages the current progress, and the format of the line +is given by a number of widgets. A widget is an object that may display +differently depending on the state of the progress bar. There are three types +of widgets: + - a string, which always shows itself + + - a ProgressBarWidget, which may return a different value every time its + update method is called + + - a ProgressBarWidgetHFill, which is like ProgressBarWidget, except it + expands to fill the remaining width of the line. + +The progressbar module is very easy to use, yet very powerful. It will also +automatically enable features like auto-resizing when the system supports it. +""" + +__author__ = 'Nilton Volpato' +__author_email__ = 'first-name dot last-name @ gmail.com' +__date__ = '2011-05-14' +__version__ = '2.3dev' + +from .compat import * +from .widgets import * +from .progressbar import * diff --git a/piwigotools/progressbar/compat.py b/piwigotools/progressbar/compat.py new file mode 100644 index 0000000..de99344 --- /dev/null +++ b/piwigotools/progressbar/compat.py @@ -0,0 +1,45 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# progressbar - Text progress bar library for Python. +# Copyright (c) 2005 Nilton Volpato +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +"""Compatibility methods and classes for the progressbar module.""" + + +# Python 3.x (and backports) use a modified iterator syntax +# This will allow 2.x to behave with 3.x iterators +try: + next +except NameError: + def next(iter): + try: + # Try new style iterators + return iter.__next__() + except AttributeError: + # Fallback in case of a "native" iterator + return iter.next() + + +# Python < 2.5 does not have "any" +try: + any +except NameError: + def any(iterator): + for item in iterator: + if item: return True + return False diff --git a/piwigotools/progressbar/progressbar.py b/piwigotools/progressbar/progressbar.py new file mode 100644 index 0000000..d7c8893 --- /dev/null +++ b/piwigotools/progressbar/progressbar.py @@ -0,0 +1,296 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# progressbar - Text progress bar library for Python. +# Copyright (c) 2005 Nilton Volpato +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +"""Main ProgressBar class.""" + +from __future__ import division + +import math +import os +import signal +import sys +import time + +try: + from fcntl import ioctl + from array import array + import termios +except ImportError: + pass + +from . import widgets + + +class UnknownLength: pass + + +class ProgressBar(object): + """The ProgressBar class which updates and prints the bar. + + A common way of using it is like: + >>> pbar = ProgressBar().start() + >>> for i in range(100): + ... # do something + ... pbar.update(i+1) + ... + >>> pbar.finish() + + You can also use a ProgressBar as an iterator: + >>> progress = ProgressBar() + >>> for i in progress(some_iterable): + ... # do something + ... + + Since the progress bar is incredibly customizable you can specify + different widgets of any type in any order. You can even write your own + widgets! However, since there are already a good number of widgets you + should probably play around with them before moving on to create your own + widgets. + + The term_width parameter represents the current terminal width. If the + parameter is set to an integer then the progress bar will use that, + otherwise it will attempt to determine the terminal width falling back to + 80 columns if the width cannot be determined. + + When implementing a widget's update method you are passed a reference to + the current progress bar. As a result, you have access to the + ProgressBar's methods and attributes. Although there is nothing preventing + you from changing the ProgressBar you should treat it as read only. + + Useful methods and attributes include (Public API): + - currval: current progress (0 <= currval <= maxval) + - maxval: maximum (and final) value + - finished: True if the bar has finished (reached 100%) + - start_time: the time when start() method of ProgressBar was called + - seconds_elapsed: seconds elapsed since start_time and last call to + update + - percentage(): progress in percent [0..100] + """ + + __slots__ = ('currval', 'fd', 'finished', 'last_update_time', + 'left_justify', 'maxval', 'next_update', 'num_intervals', + 'poll', 'seconds_elapsed', 'signal_set', 'start_time', + 'term_width', 'update_interval', 'widgets', '_time_sensitive', + '__iterable') + + _DEFAULT_MAXVAL = 100 + _DEFAULT_TERMSIZE = 80 + _DEFAULT_WIDGETS = [widgets.Percentage(), ' ', widgets.Bar()] + + def __init__(self, maxval=None, widgets=None, term_width=None, poll=1, + left_justify=True, fd=sys.stderr): + """Initializes a progress bar with sane defaults.""" + + # Don't share a reference with any other progress bars + if widgets is None: + widgets = list(self._DEFAULT_WIDGETS) + + self.maxval = maxval + self.widgets = widgets + self.fd = fd + self.left_justify = left_justify + + self.signal_set = False + if term_width is not None: + self.term_width = term_width + else: + try: + self._handle_resize() + signal.signal(signal.SIGWINCH, self._handle_resize) + self.signal_set = True + except (SystemExit, KeyboardInterrupt): raise + except: + self.term_width = self._env_size() + + self.__iterable = None + self._update_widgets() + self.currval = 0 + self.finished = False + self.last_update_time = None + self.poll = poll + self.seconds_elapsed = 0 + self.start_time = None + self.update_interval = 1 + + + def __call__(self, iterable): + """Use a ProgressBar to iterate through an iterable.""" + + try: + self.maxval = len(iterable) + except: + if self.maxval is None: + self.maxval = UnknownLength + + self.__iterable = iter(iterable) + return self + + + def __iter__(self): + return self + + + def __next__(self): + try: + value = next(self.__iterable) + if self.start_time is None: self.start() + else: self.update(self.currval + 1) + return value + except StopIteration: + self.finish() + raise + + + # Create an alias so that Python 2.x won't complain about not being + # an iterator. + next = __next__ + + + def _env_size(self): + """Tries to find the term_width from the environment.""" + + return int(os.environ.get('COLUMNS', self._DEFAULT_TERMSIZE)) - 1 + + + def _handle_resize(self, signum=None, frame=None): + """Tries to catch resize signals sent from the terminal.""" + + h, w = array('h', ioctl(self.fd, termios.TIOCGWINSZ, '\0' * 8))[:2] + self.term_width = w + + + def percentage(self): + """Returns the progress as a percentage.""" + return self.currval * 100.0 / self.maxval + + percent = property(percentage) + + + def _format_widgets(self): + result = [] + expanding = [] + width = self.term_width + + for index, widget in enumerate(self.widgets): + if isinstance(widget, widgets.WidgetHFill): + result.append(widget) + expanding.insert(0, index) + else: + widget = widgets.format_updatable(widget, self) + result.append(widget) + width -= len(widget) + + count = len(expanding) + while count: + portion = max(int(math.ceil(width * 1. / count)), 0) + index = expanding.pop() + count -= 1 + + widget = result[index].update(self, portion) + width -= len(widget) + result[index] = widget + + return result + + + def _format_line(self): + """Joins the widgets and justifies the line.""" + + widgets = ''.join(self._format_widgets()) + + if self.left_justify: return widgets.ljust(self.term_width) + else: return widgets.rjust(self.term_width) + + + def _need_update(self): + """Returns whether the ProgressBar should redraw the line.""" + if self.currval >= self.next_update or self.finished: return True + + delta = time.time() - self.last_update_time + return self._time_sensitive and delta > self.poll + + + def _update_widgets(self): + """Checks all widgets for the time sensitive bit.""" + + self._time_sensitive = any(getattr(w, 'TIME_SENSITIVE', False) + for w in self.widgets) + + + def update(self, value=None): + """Updates the ProgressBar to a new value.""" + + if value is not None and value is not UnknownLength: + if (self.maxval is not UnknownLength + and not 0 <= value <= self.maxval): + + raise ValueError('Value out of range') + + self.currval = value + + + if not self._need_update(): return + if self.start_time is None: + raise RuntimeError('You must call "start" before calling "update"') + + now = time.time() + self.seconds_elapsed = now - self.start_time + self.next_update = self.currval + self.update_interval + self.fd.write(self._format_line() + '\r') + self.last_update_time = now + + + def start(self): + """Starts measuring time, and prints the bar at 0%. + + It returns self so you can use it like this: + >>> pbar = ProgressBar().start() + >>> for i in range(100): + ... # do something + ... pbar.update(i+1) + ... + >>> pbar.finish() + """ + + if self.maxval is None: + self.maxval = self._DEFAULT_MAXVAL + + self.num_intervals = max(100, self.term_width) + self.next_update = 0 + + if self.maxval is not UnknownLength: + if self.maxval < 0: raise ValueError('Value out of range') + self.update_interval = self.maxval / self.num_intervals + + + self.start_time = self.last_update_time = time.time() + self.update(0) + + return self + + + def finish(self): + """Puts the ProgressBar bar in the finished state.""" + + self.finished = True + self.update(self.maxval) + self.fd.write('\n') + if self.signal_set: + signal.signal(signal.SIGWINCH, signal.SIG_DFL) diff --git a/piwigotools/progressbar/widgets.py b/piwigotools/progressbar/widgets.py new file mode 100644 index 0000000..62207ad --- /dev/null +++ b/piwigotools/progressbar/widgets.py @@ -0,0 +1,311 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# progressbar - Text progress bar library for Python. +# Copyright (c) 2005 Nilton Volpato +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +"""Default ProgressBar widgets.""" + +from __future__ import division + +import datetime +import math + +try: + from abc import ABCMeta, abstractmethod +except ImportError: + AbstractWidget = object + abstractmethod = lambda fn: fn +else: + AbstractWidget = ABCMeta('AbstractWidget', (object,), {}) + + +def format_updatable(updatable, pbar): + if hasattr(updatable, 'update'): return updatable.update(pbar) + else: return updatable + + +class Widget(AbstractWidget): + """The base class for all widgets. + + The ProgressBar will call the widget's update value when the widget should + be updated. The widget's size may change between calls, but the widget may + display incorrectly if the size changes drastically and repeatedly. + + The boolean TIME_SENSITIVE informs the ProgressBar that it should be + updated more often because it is time sensitive. + """ + + TIME_SENSITIVE = False + __slots__ = () + + @abstractmethod + def update(self, pbar): + """Updates the widget. + + pbar - a reference to the calling ProgressBar + """ + + +class WidgetHFill(Widget): + """The base class for all variable width widgets. + + This widget is much like the \\hfill command in TeX, it will expand to + fill the line. You can use more than one in the same line, and they will + all have the same width, and together will fill the line. + """ + + @abstractmethod + def update(self, pbar, width): + """Updates the widget providing the total width the widget must fill. + + pbar - a reference to the calling ProgressBar + width - The total width the widget must fill + """ + + +class Timer(Widget): + """Widget which displays the elapsed seconds.""" + + __slots__ = ('format_string',) + TIME_SENSITIVE = True + + def __init__(self, format='Elapsed Time: %s'): + self.format_string = format + + @staticmethod + def format_time(seconds): + """Formats time as the string "HH:MM:SS".""" + + return str(datetime.timedelta(seconds=int(seconds))) + + + def update(self, pbar): + """Updates the widget to show the elapsed time.""" + + return self.format_string % self.format_time(pbar.seconds_elapsed) + + +class ETA(Timer): + """Widget which attempts to estimate the time of arrival.""" + + TIME_SENSITIVE = True + + def update(self, pbar): + """Updates the widget to show the ETA or total time when finished.""" + + if pbar.currval == 0: + return 'ETA: --:--:--' + elif pbar.finished: + return 'Time: %s' % self.format_time(pbar.seconds_elapsed) + else: + elapsed = pbar.seconds_elapsed + eta = elapsed * pbar.maxval / pbar.currval - elapsed + return 'ETA: %s' % self.format_time(eta) + + +class FileTransferSpeed(Widget): + """Widget for showing the transfer speed (useful for file transfers).""" + + FORMAT = '%6.2f %s%s/s' + PREFIXES = ' kMGTPEZY' + __slots__ = ('unit',) + + def __init__(self, unit='B'): + self.unit = unit + + def update(self, pbar): + """Updates the widget with the current SI prefixed speed.""" + + if pbar.seconds_elapsed < 2e-6 or pbar.currval < 2e-6: # =~ 0 + scaled = power = 0 + else: + speed = pbar.currval / pbar.seconds_elapsed + power = int(math.log(speed, 1000)) + scaled = speed / 1000.**power + + return self.FORMAT % (scaled, self.PREFIXES[power], self.unit) + + +class AnimatedMarker(Widget): + """An animated marker for the progress bar which defaults to appear as if + it were rotating. + """ + + __slots__ = ('markers', 'curmark') + + def __init__(self, markers='|/-\\'): + self.markers = markers + self.curmark = -1 + + def update(self, pbar): + """Updates the widget to show the next marker or the first marker when + finished""" + + if pbar.finished: return self.markers[0] + + self.curmark = (self.curmark + 1) % len(self.markers) + return self.markers[self.curmark] + +# Alias for backwards compatibility +RotatingMarker = AnimatedMarker + + +class Counter(Widget): + """Displays the current count.""" + + __slots__ = ('format_string',) + + def __init__(self, format='%d'): + self.format_string = format + + def update(self, pbar): + return self.format_string % pbar.currval + + +class Percentage(Widget): + """Displays the current percentage as a number with a percent sign.""" + + def update(self, pbar): + return '%3d%%' % pbar.percentage() + + +class FormatLabel(Timer): + """Displays a formatted label.""" + + mapping = { + 'elapsed': ('seconds_elapsed', Timer.format_time), + 'finished': ('finished', None), + 'last_update': ('last_update_time', None), + 'max': ('maxval', None), + 'seconds': ('seconds_elapsed', None), + 'start': ('start_time', None), + 'value': ('currval', None) + } + + __slots__ = ('format_string',) + def __init__(self, format): + self.format_string = format + + def update(self, pbar): + context = {} + for name, (key, transform) in self.mapping.items(): + try: + value = getattr(pbar, key) + + if transform is None: + context[name] = value + else: + context[name] = transform(value) + except: pass + + return self.format_string % context + + +class SimpleProgress(Widget): + """Returns progress as a count of the total (e.g.: "5 of 47").""" + + __slots__ = ('sep',) + + def __init__(self, sep=' of '): + self.sep = sep + + def update(self, pbar): + return '%d%s%d' % (pbar.currval, self.sep, pbar.maxval) + + +class Bar(WidgetHFill): + """A progress bar which stretches to fill the line.""" + + __slots__ = ('marker', 'left', 'right', 'fill', 'fill_left') + + def __init__(self, marker='#', left='|', right='|', fill=' ', + fill_left=True): + """Creates a customizable progress bar. + + marker - string or updatable object to use as a marker + left - string or updatable object to use as a left border + right - string or updatable object to use as a right border + fill - character to use for the empty part of the progress bar + fill_left - whether to fill from the left or the right + """ + self.marker = marker + self.left = left + self.right = right + self.fill = fill + self.fill_left = fill_left + + + def update(self, pbar, width): + """Updates the progress bar and its subcomponents.""" + + left, marked, right = (format_updatable(i, pbar) for i in + (self.left, self.marker, self.right)) + + width -= len(left) + len(right) + # Marked must *always* have length of 1 + if pbar.maxval: + marked *= int(pbar.currval / pbar.maxval * width) + else: + marked = '' + + if self.fill_left: + return '%s%s%s' % (left, marked.ljust(width, self.fill), right) + else: + return '%s%s%s' % (left, marked.rjust(width, self.fill), right) + + +class ReverseBar(Bar): + """A bar which has a marker which bounces from side to side.""" + + def __init__(self, marker='#', left='|', right='|', fill=' ', + fill_left=False): + """Creates a customizable progress bar. + + marker - string or updatable object to use as a marker + left - string or updatable object to use as a left border + right - string or updatable object to use as a right border + fill - character to use for the empty part of the progress bar + fill_left - whether to fill from the left or the right + """ + self.marker = marker + self.left = left + self.right = right + self.fill = fill + self.fill_left = fill_left + + +class BouncingBar(Bar): + def update(self, pbar, width): + """Updates the progress bar and its subcomponents.""" + + left, marker, right = (format_updatable(i, pbar) for i in + (self.left, self.marker, self.right)) + + width -= len(left) + len(right) + + if pbar.finished: return '%s%s%s' % (left, width * marker, right) + + position = int(pbar.currval % (width * 2 - 1)) + if position > width: position = width * 2 - position + lpad = self.fill * (position - 1) + rpad = self.fill * (width - len(marker) - len(lpad)) + + # Swap if we want to bounce the other way + if not self.fill_left: rpad, lpad = lpad, rpad + + return '%s%s%s%s%s' % (left, lpad, marker, rpad, right) diff --git a/setup.py b/setup.py index f06e83f..2e8b68e 100644 --- a/setup.py +++ b/setup.py @@ -11,8 +11,9 @@ import piwigotools NAME = "piwigotools" VERSION = piwigotools.__version__ -DESC = "piwigotools description" -URLPKG = "https://url/of/piwigotools/website" +DESC = "mange your piwigo gallery by command piwigo" +URLPKG = "https://github.com/fraoustin/piwigotools" + HERE = os.path.abspath(os.path.dirname(__file__)) @@ -44,4 +45,9 @@ setup( install_requires=REQUIRED, url=URLPKG, classifiers=CLASSIFIED, + entry_points = { + 'console_scripts': [ + 'piwigo = piwigotools.main:main', + ], + }, ) diff --git a/tests/samplepiwigotools.jpg b/tests/samplepiwigotools.jpg new file mode 100644 index 0000000000000000000000000000000000000000..e4fca9ae16e8fa039bf2ab1d726f9be9bb398358 GIT binary patch literal 16247 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