Mit Hazel & Dropbox Python SDK einen einzelnen Ordner zur Dropbox kopieren

Bei uns in der Firma wurde nun der Einsatz von Dropbox [Link] verboten. Irgendeine EU Datenschutzrichtlinie, die Firmen mächtig hohe Strafen androht, sofern solche Dienste unkontrolliert verwendet werden. Also habe ich ganz brav den Dropbox-Client bei mir deinstalliert und es gleich bereut. Schließlich nutzte ich Dropbox, um ein wichtige Dateien mit meiner Frau zu teilen. Also was tun?

Während der Client verboten ist, ist die Webseite verfügbar. Ich könnte also einfach meine zu teilenden Dateien über die Webseite hochladen. Leider ist das keine gute Lösung, denn dafür bin ich zu faul und deutlich zu vergesslich.

Dann kam mir jedoch eine Idee. Monosnap [AppStore] kann immer noch Dateien auf die Dropbox hochladen und teilen. Wie das? Ganz einfach – Monosnap nutzt die Dropbox API  und ist so nicht vom Client abhängig.

Wenn das geht, dann könnte ich mir doch einen kleines Script schreiben, dass nur einen Ordner auf die Dropbox kopiert?

Die kurze Antwort:

Ja, das geht und ist sogar sehr leicht. Neben dem Dropbox SDK für Python braucht es nur noch Hazel (oder ein gutes Automator-Script). Ich ziehe Hazel vor, da es schon auf meinem Rechner installiert ist. Was Du hier siehst könntest Du aber auch verwenden, um Dateien von einem Raspberry Pi oder einem anderen Server zur Dropbox zu kopieren.

Und hier die lange Antwort, samt Schritt-für-Schritt-Anleitung:

Dropbox SDK für Python installieren:

Das SDK gibt es direkt bei GitHub [Link] und kann ohne große Aufwand über den Terminal installiert werden. Dazu führst Du in macOS folgende Schritte durch:

  1. git clone git://github.com/dropbox/dropbox-sdk-python.git
  2. cd dropbox-sdk-python
  3. sudo python setup.py install

Eine DropBox App erstellen

In der Dropbox App Console [Link] kannst Du eine App anlegen. Da Du diese nur für Dich selbst benutzt, musst Du dafür noch nicht mal alle Felder ausfüllen. Im ersten Schritt wählst Du:

  1. Create App
  2. Bei Choose an API wählst Du Dropbox API
  3. Bei Choose the type of access you need wählst Du App folder– Access to a single folder created specifically for your app.
  4. Dann gibst Du der App einen Namen

Im nächsten Bildschirm musst Du eigentlich nur in den Abschnitt OAuth2 gehen und dort unter Generated access token auf Generate klicken. Der hier generierte Token wird für den nächsten Schritt gebraucht. Er ist „geheim“ und sollte nicht weitergereicht werden. Da die App jedoch nur auf einen einzelnen Unterordner im Ordner Apps beschränkt ist, kann man nicht viel echten Schaden damit anrichten.

Lade updown.py auf Deinen Rechner

Die netten Leute bei Dropbox haben ein sehr nützliches Demo-Python-Script geschrieben, dass wir nun ein Kleinwenig anpassen, damit es nicht ganz so viele Rückfragen stellt und weiß, was wir synchronisieren wollen. Lade Dir die Datei updown.py [Link] vom GitHub auf Deinen Rechner und speichere Sie an einem leicht zu erreichenden Ort. Ich selbst hab sie gleich in mein Home Directory gelegt. Da ist sie zwar nicht wirklich gut aufgehoben, aber leicht zu erreichen. Für den Moment kann sie da bleiben.

In meinem Home Directory habe ich mir dann auch noch einen leeren Ordner angelegt. Bei mir heißt er „FamilyShare“.

Öffne die updown.py Datei und mach folgendes:

  1. Füge ganz am Anfang (Zeile 1) folgends ein #!/usr/bin/python – somit ist die Ursprüngliche Zeile 1 in Zeile 2 gerutscht. Alle weiteren Zeilenangaben gehen davon aus, dass das Script in Zeile 2 mit from __future__ import print_function weitergeht
  2. In Zeile 20 fügst Du den Token ein, den Du in der App Console erstellt hast.
  3. In Zeile 23 fügst Du Deinen Ordner „FamilyShare“ als Ordner in der Dropbox ein, also parser.add_argument(‚folder‘, nargs=‘?‘, default=’FamilyShare‘,
  4. In Zeile 25 fügst Du den Ordner auf deiner Festplatte hinzu, also parser.add_argument(‚rootdir‘, nargs=‘?‘, default=’~/FamilyShare‘,
  5. In Zeile 99 entfernst Du diesen Quelltext und sorgst dann dafür, dass die nächste Zeile einen Einzug nach links verschoben wird.
    Also das entfernen: if yesno(‚Refresh %s‘ % name, False, args):
    Und dies upload(dbx, fullname, folder, subfolder, name, overwrite=True)  muss bündig dem print(…) statement der Zeile 98 anfangen
  6. In Zeile 233 gibt es den Aufruf der Funktion main() , bei mir war dieser ganz an den Anfang der Zeile gerutscht. Da dieser Aufruf jedoch Teil eines IF-Statements ist, muss dieser einen Tab eingerückt werden. Python nutzt die Tabs für die Struktur des Quelltextes und somit sind diese sehr viel wichtiger, als in anderen Sprachen

Wenn Du diese Änderungen durchgeführt hast, bist Du fast schon bereit zum Testen. Speichere Deine Datei und verändere mit ⌘+i die Berechtigungen so, dass alle Nutzer sowohl Lese-, als auch Schreibrechte bekommen.

In Deinen Ordner „FamilyShare“ legst Du nun eine einzige Datei, z.B. eine Textdatei. Hast Du diese abgelegt machst Du folgendes, um die App im Terminal zu testen:

  1. Mit cd ~ wechselst Du ins Home Directory
  2. Dort gibst Du dann folgendes ein python updown.py

Da Du zum ersten Mal einen Sync anstösst fragt Dich die App, ob Du das auch willst. Diese Frage müsstest Du mit Y beantworten. Mein Ziel war und ist es nur eine einzige Datei mit meiner Frau zu teilen, daher ist das für mich ok, dass ich beim ersten Mal die Datei manuell bestätigen muss. Wenn Du das als störend empfindest, müsstest Du in Zeile 100 noch ein paar Änderungen vornehmen, damit hier kein elif steht sondern ein else: …

Hast Du alles richtig gemacht, belohnt Dich die App nun mit einem positiven Ergebnis und in Deiner Dropbox im Ordner Apps findet sich ein Ordner FamilyShare, der Deine Textdatei enthält.

Hazel einrichten…

…damit Änderungen im Ordner FamilyShare über das Python Script synchronisiert werden.

In Hazel fügst Du erst einmal eine Regel für Deinen Ordner hinzu. Damit Dir das leichter fällt, habe ich hier mal einen Screenshot von meiner Regel eingebunden. Einfach draufklicken, damit Du sie vollständig siehst:

Kurz zusammengefasst:

If Date Last Modified did Change Do the following to the matched file or folder: Run shell script embedded script

Das Script selbst könnte kürzer nicht sein:

python ~/updown.py

Ich hatte mein Script, wie ihr aus dem Screenshot sehen könnt, umbenannt, also darauf achten, den richtigen Dateinamen zu hinterlegen, fall ihr das auch vorhabt.

Tja, und mehr ist nicht zu tun. Das nächste Mal, wenn das Date Last Modified sich ändert, wird der Ordner synchronisiert. So einfach ist das ;)

Hier noch mal das gesamte Script. Bitte beachtet, dass die Datei NICHT von mir ist, sie enthält nur so wenige Änderungen vom Original. Das ganze Kudos geht an die guten Leute bei Dropbox, die ein so nützliches SDK und so gute Beispieldateien bereitstellen, dass man auch als ungeübter Nutzer schnell Ergebnisse erzielen kann. Die ursprüngliche Datei findest Du bei GitHub [Link]

#!/usr/bin/python
from __future__ import print_function

import argparse
import contextlib
import datetime
import os
import six
import sys
import time
import unicodedata

if sys.version.startswith('2'):
    input = raw_input

import dropbox
from dropbox.files import FileMetadata, FolderMetadata

# OAuth2 access token.  TODO: login etc.
TOKEN = 'DEIN_OAUTH2_TOKEN_AUS_DER_APP_CONSOLE'

parser = argparse.ArgumentParser(description='Sync ~/FamilyShare to Dropbox')
parser.add_argument('folder', nargs='?', default='FamilyShare',
                    help='Folder name in your Dropbox')
parser.add_argument('rootdir', nargs='?', default='~/FamilyShare',
                    help='Local directory to upload')
parser.add_argument('--token', default=TOKEN,
                    help='Access token '
                    '(see https://www.dropbox.com/developers/apps)')
parser.add_argument('--yes', '-y', action='store_true',
                    help='Answer yes to all questions')
parser.add_argument('--no', '-n', action='store_true',
                    help='Answer no to all questions')
parser.add_argument('--default', '-d', action='store_true',
                    help='Take default answer on all questions')

def main():
    """Main program.
    Parse command line, then iterate over files and directories under
    rootdir and upload all files.  Skips some temporary files and
    directories, and avoids duplicate uploads by comparing size and
    mtime with the server.
    """
    args = parser.parse_args()
    if sum([bool(b) for b in (args.yes, args.no, args.default)]) > 1:
        print('At most one of --yes, --no, --default is allowed')
        sys.exit(2)
    if not args.token:
        print('--token is mandatory')
        sys.exit(2)

    folder = args.folder
    rootdir = os.path.expanduser(args.rootdir)
    print('Dropbox folder name:', folder)
    print('Local directory:', rootdir)
    if not os.path.exists(rootdir):
        print(rootdir, 'does not exist on your filesystem')
        sys.exit(1)
    elif not os.path.isdir(rootdir):
        print(rootdir, 'is not a foldder on your filesystem')
        sys.exit(1)

    dbx = dropbox.Dropbox(args.token)

    for dn, dirs, files in os.walk(rootdir):
        subfolder = dn[len(rootdir):].strip(os.path.sep)
        listing = list_folder(dbx, folder, subfolder)
        print('Descending into', subfolder, '...')

        # First do all the files.
        for name in files:
            fullname = os.path.join(dn, name)
            if not isinstance(name, six.text_type):
                name = name.decode('utf-8')
            nname = unicodedata.normalize('NFC', name)
            if name.startswith('.'):
                print('Skipping dot file:', name)
            elif name.startswith('@') or name.endswith('~'):
                print('Skipping temporary file:', name)
            elif name.endswith('.pyc') or name.endswith('.pyo'):
                print('Skipping generated file:', name)
            elif nname in listing:
                md = listing[nname]
                mtime = os.path.getmtime(fullname)
                mtime_dt = datetime.datetime(*time.gmtime(mtime)[:6])
                size = os.path.getsize(fullname)
                if (isinstance(md, dropbox.files.FileMetadata) and
                    mtime_dt == md.client_modified and size == md.size):
                    print(name, 'is already synced [stats match]')
                else:
                    print(name, 'exists with different stats, downloading')
                    res = download(dbx, folder, subfolder, name)
                    with open(fullname) as f:
                        data = f.read()
                    if res == data:
                        print(name, 'is already synced [content match]')
                    else:
                        print(name, 'has changed since last sync')
                        upload(dbx, fullname, folder, subfolder, name, overwrite=True)
            elif yesno('Upload %s' % name, True, args):
                upload(dbx, fullname, folder, subfolder, name)

        # Then choose which subdirectories to traverse.
        keep = []
        for name in dirs:
            if name.startswith('.'):
                print('Skipping dot directory:', name)
            elif name.startswith('@') or name.endswith('~'):
                print('Skipping temporary directory:', name)
            elif name == '__pycache__':
                print('Skipping generated directory:', name)
            elif yesno('Descend into %s' % name, True, args):
                print('Keeping directory:', name)
                keep.append(name)
            else:
                print('OK, skipping directory:', name)
        dirs[:] = keep

def list_folder(dbx, folder, subfolder):
    """List a folder.
    Return a dict mapping unicode filenames to
    FileMetadata|FolderMetadata entries.
    """
    path = '/%s/%s' % (folder, subfolder.replace(os.path.sep, '/'))
    while '//' in path:
        path = path.replace('//', '/')
    path = path.rstrip('/')
    try:
        with stopwatch('list_folder'):
            res = dbx.files_list_folder(path)
    except dropbox.exceptions.ApiError as err:
        print('Folder listing failed for', path, '-- assumped empty:', err)
        return {}
    else:
        rv = {}
        for entry in res.entries:
            rv[entry.name] = entry
        return rv

def download(dbx, folder, subfolder, name):
    """Download a file.
    Return the bytes of the file, or None if it doesn't exist.
    """
    path = '/%s/%s/%s' % (folder, subfolder.replace(os.path.sep, '/'), name)
    while '//' in path:
        path = path.replace('//', '/')
    with stopwatch('download'):
        try:
            md, res = dbx.files_download(path)
        except dropbox.exceptions.HttpError as err:
            print('*** HTTP error', err)
            return None
    data = res.content
    #print(len(data), 'bytes; md:', md)
    return data

def upload(dbx, fullname, folder, subfolder, name, overwrite=False):
    """Upload a file.
    Return the request response, or None in case of error.
    """
    path = '/%s/%s/%s' % (folder, subfolder.replace(os.path.sep, '/'), name)
    while '//' in path:
        path = path.replace('//', '/')
    mode = (dropbox.files.WriteMode.overwrite
            if overwrite
            else dropbox.files.WriteMode.add)
    mtime = os.path.getmtime(fullname)
    with open(fullname, 'rb') as f:
        data = f.read()
    with stopwatch('upload %d bytes' % len(data)):
        try:
            res = dbx.files_upload(
                data, path, mode,
                client_modified=datetime.datetime(*time.gmtime(mtime)[:6]),
                mute=True)
        except dropbox.exceptions.ApiError as err:
            print('*** API error', err)
            return None
    print('uploaded as', res.name.encode('utf8'))
    return res

def yesno(message, default, args):
    """Handy helper function to ask a yes/no question.
    Command line arguments --yes or --no force the answer;
    --default to force the default answer.
    Otherwise a blank line returns the default, and answering
    y/yes or n/no returns True or False.
    Retry on unrecognized answer.
    Special answers:
    - q or quit exits the program
    - p or pdb invokes the debugger
    """
    if args.default:
        print(message + '? [auto]', 'Y' if default else 'N')
        return default
    if args.yes:
        print(message + '? [auto] YES')
        return True
    if args.no:
        print(message + '? [auto] NO')
        return False
    if default:
        message += '? [Y/n] '
    else:
        message += '? [N/y] '
    while True:
        answer = input(message).strip().lower()
        if not answer:
            return default
        if answer in ('y', 'yes'):
            return True
        if answer in ('n', 'no'):
            return False
        if answer in ('q', 'quit'):
            print('Exit')
            raise SystemExit(0)
        if answer in ('p', 'pdb'):
            import pdb
            pdb.set_trace()
        print('Please answer YES or NO.')

@contextlib.contextmanager
def stopwatch(message):
    """Context manager to print how long a block of code took."""
    t0 = time.time()
    try:
        yield
    finally:
        t1 = time.time()
        print('Total elapsed time for %s: %.3f' % (message, t1 - t0))

if __name__ == '__main__':
	main()

 

Claus Wolf

Seit 1994 im Netz unterwegs und seit 2004 eingefleischter Mac-Nutzer. 21.5" iMac - 2.9GHz Intel Core i5, 16GB RAM, 1TB Fusion Drive HDD / 128GB iPhone 7 / 128GB iPad 9,7" (2017) / 15" MacBook Pro (Mitte 2014) in der Firma...

Das könnte Dich auch interessieren …

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert.

Diese Website verwendet Akismet, um Spam zu reduzieren. Erfahre mehr darüber, wie deine Kommentardaten verarbeitet werden.