python

Пишем скрипт для синхронизации папок с Google Drive, плюс учимся использовать Google Drive API

  • среда, 10 мая 2017 г. в 03:14:47
https://habrahabr.ru/post/328248/
  • Python
  • Google API
  • API



В этой статье мы рассмотрим основные инструменты работы с Google Drive REST API, осуществим "прямую" и "обратную" синхронизацию папки на компьютере с папкой в облаке Гугл Диска, а заодно выясним какие сложности могут возникнуть при работе с Google Docs через API Диска и как правильно их импортировать и экспортировать чтобы (почти) никто не пострадал.


Лирическое вступление


Однажды, проснувшись ранним утром, я внезапно обнаружил недостаток продуктивности (и автоматизированности) в повседневной жизни. Первой "под нож оптимизации" попала рабочая папка с полезными книжками, проектами, документами и смешными картинками котов. Обычно вся моя бурная деятельность происходит за домашним компьютером, однако иногда требуется действовать вне пределов уютной комнаты, и хочется чтобы все привычное было под рукой, в том числе и текущие проекты. В таких случаях на помощь приходят на помощь флешки, но это прошлый век и не стильно, хочется чтобы все было надежно доступно в любой момент из облака (при наличии интернета конечно). Как пример такого облака в данном случае будет Гугл Диск.


Но поскольку каждый день происходят изменения, что-то добавляется, что-то улетает в корзину, и вручную следить за этим нет никакого желания, должна быть настроена синхронизация. На Windows и MacOS есть специальный софт примерно для таких целей. Я большую часть времени провожу за Ubuntu, и официального клиента под линукс нет и не предвидится. Да и вообще это не кажется очень удобным. Хочется иметь универсальный метод решения данной задачи, желательно кроссплатформенный, без перекидывания всех файлов в специальную папку. Поиск готовых решений ни к чему не привел, ни один вариант меня полностью не устроил. Я решил что лучше всего будет самостоятельно написать скрипт на Python и повесить его к cron (или запускать самостоятельно и вручную). К тому же на хабре при беглом поиске не нашлось туториалов по этой теме, так что может в дальнейшем кому-то и пригодится.




Получаем необходимые данные ("включаем" Drive API и получаем необходимые ключи)


Выполнив несколько простых шагов, уже через 5 минут у Вас в руках уже будет тестовое приложение с доступом к Drive API.


Перед началом захватывающего путешествия, убедитесь что взяли с собой:


  • Python версии 2.6 или выше (в моем случае это был Python 3 чего и Вам советую).
  • Пакетный менеджер pip.
  • Доступ к интернету и веб-браузер.
  • Аккаунт Google и рабочий Google Drive.

К этому нужно отнестись с полной серьезностью, потому что если хотя бы одно из этих условий будет нарушено, дальше ничего не получится.


Порядок действий примерно такой:


1) Переходим по ссылке для того чтобы создать наш проект и автоматически включить Drive API. Жмем Continue, затем Go to credentials
2) На странице Add cridentials to your page жмём кнопку Cancel.
3) На странице выбираем вкладку OAuth consent screen. Выбираем нужный Email и вписываем Product name, жмем Save.
4) Во вкладе Credentials выбираем Create credentials, затем Oauth client ID.
5) Выбираем тип Other, вписываем звучное название и жмем Create.
6) В поле нашего "проекта" жмем по кнопке справа для скачивания json файла. Сохраняем его в папку с будущим скриптом, для удобства переименуем в client_secret.json


Весь процесс в картинках для наглядности













Далее выполняем действия уже на компьютере


1 — Устанавливаем зависимости
Установим Google Client Library библиотеку


sudo pip install --upgrade google-api-python-client

Или посмотрите по ссылке если возникли вопросы.


2 — Создадим наш начальный скрипт
Создаем python скрипт рядом с сорханённым .json файлом и сохраняем пример кода для теста:


Исходный код
from __future__ import print_function
import httplib2
import os

from apiclient import discovery
from oauth2client import client
from oauth2client import tools
from oauth2client.file import Storage

try:
    import argparse
    flags = argparse.ArgumentParser(parents=[tools.argparser]).parse_args()
except ImportError:
    flags = None

# If modifying these scopes, delete your previously saved credentials
# at ~/.credentials/drive-python-quickstart.json
SCOPES = 'https://www.googleapis.com/auth/drive.metadata.readonly'
CLIENT_SECRET_FILE = 'client_secret.json'
APPLICATION_NAME = 'Drive API Python Quickstart'

def get_credentials():
    home_dir = os.path.expanduser('~')
    credential_dir = os.path.join(home_dir, '.credentials')
    if not os.path.exists(credential_dir):
        os.makedirs(credential_dir)
    credential_path = os.path.join(credential_dir,
                                   'drive-python-quickstart.json')

    store = Storage(credential_path)
    credentials = store.get()
    if not credentials or credentials.invalid:
        flow = client.flow_from_clientsecrets(CLIENT_SECRET_FILE, SCOPES)
        flow.user_agent = APPLICATION_NAME
        if flags:
            credentials = tools.run_flow(flow, store, flags)
        else: # Needed only for compatibility with Python 2.6
            credentials = tools.run(flow, store)
        print('Storing credentials to ' + credential_path)
    return credentials

def main():
    credentials = get_credentials()
    http = credentials.authorize(httplib2.Http())
    service = discovery.build('drive', 'v3', http=http)

    results = service.files().list(
        pageSize=10,fields="nextPageToken, files(id, name)").execute()
    items = results.get('files', [])
    if not items:
        print('No files found.')
    else:
        print('Files:')
        for item in items:
            print('{0} ({1})'.format(item['name'], item['id']))

if __name__ == '__main__':
    main()

При первом запуске он попробует открыть вкладку в браузере и попросит залогиниться в гугл аккаунте и согласиться с выдаваемыми правами.


В целом там все просто и понятно, но если вкратце пробежаться


  • функция get_credentials создает/достает .json файл полномочий для авторизации через API.
  • scopes это наши права доступа (в дальнейшем поставим самые большие ведь себе доверяем)
  • service создает нам доступ к аккаунту Google Drive
  • дальше обращение к апи, берем список файлов (ограничиваемся 10 штуками) и выводим данные.

Можно даже запустить и посмотреть на то как он выведет имена и id первых десяти файлов и папок в корневой директории.


Вообще, это почти полный coypaste из официального quickstart-туториала, так что если возникнут проблемы посмотрите там.




Немного про Google Drive API



Основная документация, если вдруг что.


Собственно давайте знакомиться с API ближе.


Структура Drive REST API представляет из себя набор "ресурсов" (ориг. resources types), у каждого из которых есть свои методы.


Всего из интересных это "ресурсы" about(), files() и возможно comments(). Все остальные можно посмотреть, опять же, в документации, и если они вам нужны то работа c ними будет аналогичной (скорее всего). Мы же рассмотрим действительно полезные вещи.


В первую очередь для затравки посмотрим что мы сможем узнать о нашем Диске через методы about() (что логично). Благо здесь все просто и имеется только один метод get().


Пример запроса


    about_example = drive_service.about(fields='user, storageQuota, exportFormats, importFormats').get('')

Параметров здесь много, расскажу о тех которые мне показались важными или интересными.


Параметры importFormats и exportFormats содержат информацию, в какие форматы можно перевести файл при его загрузке и скачивании соответственно. Это информация может быть важной в случае работы с файлами формата Google Docs, об этом чуть ниже.


StorageQuota может поведать, что логично, о количестве использованного и оставшегося свободного пространства в облаке.


user содержит несколько полей, например displayName, photoLink и emailAddress (значения понятны из названия)


На выходе получим JSON структуру с этими данными.


Пример ответа, включая exportFormats и importFormats

{
"user": {
"kind": "drive#user",
"displayName": "Имя Фамилия",
"photoLink": "http://i.imgur.com/hgXLjzr.png",
"me": true,
"permissionId": "id number",
"emailAddress": "youremail@gmail.com"
},
"storageQuota": {
"limit": "16106127360",
"usage": "2920407530",
"usageInDrive": "1590570002",
"usageInDriveTrash": "54316729"
},
"importFormats": {
"application/x-vnd.oasis.opendocument.presentation": [
"application/vnd.google-apps.presentation"
],
"text/tab-separated-values": [
"application/vnd.google-apps.spreadsheet"
],
"image/jpeg": [
"application/vnd.google-apps.document"
],
"image/bmp": [
"application/vnd.google-apps.document"
],
"image/gif": [
"application/vnd.google-apps.document"
],
"application/vnd.ms-excel.sheet.macroenabled.12": [
"application/vnd.google-apps.spreadsheet"
],
"application/vnd.openxmlformats-officedocument.wordprocessingml.template": [
"application/vnd.google-apps.document"
],
"application/vnd.ms-powerpoint.presentation.macroenabled.12": [
"application/vnd.google-apps.presentation"
],
"application/vnd.ms-word.template.macroenabled.12": [
"application/vnd.google-apps.document"
],
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": [
"application/vnd.google-apps.document"
],
"image/pjpeg": [
"application/vnd.google-apps.document"
],
"application/vnd.google-apps.script+text/plain": [
"application/vnd.google-apps.script"
],
"application/vnd.ms-excel": [
"application/vnd.google-apps.spreadsheet"
],
"application/vnd.sun.xml.writer": [
"application/vnd.google-apps.document"
],
"application/vnd.ms-word.document.macroenabled.12": [
"application/vnd.google-apps.document"
],
"application/vnd.ms-powerpoint.slideshow.macroenabled.12": [
"application/vnd.google-apps.presentation"
],
"text/rtf": [
"application/vnd.google-apps.document"
],
"text/plain": [
"application/vnd.google-apps.document"
],
"application/vnd.oasis.opendocument.spreadsheet": [
"application/vnd.google-apps.spreadsheet"
],
"application/x-vnd.oasis.opendocument.spreadsheet": [
"application/vnd.google-apps.spreadsheet"
],
"image/png": [
"application/vnd.google-apps.document"
],
"application/x-vnd.oasis.opendocument.text": [
"application/vnd.google-apps.document"
],
"application/msword": [
"application/vnd.google-apps.document"
],
"application/pdf": [
"application/vnd.google-apps.document"
],
"application/json": [
"application/vnd.google-apps.script"
],
"application/x-msmetafile": [
"application/vnd.google-apps.drawing"
],
"application/vnd.openxmlformats-officedocument.spreadsheetml.template": [
"application/vnd.google-apps.spreadsheet"
],
"application/vnd.ms-powerpoint": [
"application/vnd.google-apps.presentation"
],
"application/vnd.ms-excel.template.macroenabled.12": [
"application/vnd.google-apps.spreadsheet"
],
"image/x-bmp": [
"application/vnd.google-apps.document"
],
"application/rtf": [
"application/vnd.google-apps.document"
],
"application/vnd.openxmlformats-officedocument.presentationml.template": [
"application/vnd.google-apps.presentation"
],
"image/x-png": [
"application/vnd.google-apps.document"
],
"text/html": [
"application/vnd.google-apps.document"
],
"application/vnd.oasis.opendocument.text": [
"application/vnd.google-apps.document"
],
"application/vnd.openxmlformats-officedocument.presentationml.presentation": [
"application/vnd.google-apps.presentation"
],
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": [
"application/vnd.google-apps.spreadsheet"
],
"application/vnd.google-apps.script+json": [
"application/vnd.google-apps.script"
],
"application/vnd.openxmlformats-officedocument.presentationml.slideshow": [
"application/vnd.google-apps.presentation"
],
"application/vnd.ms-powerpoint.template.macroenabled.12": [
"application/vnd.google-apps.presentation"
],
"text/csv": [
"application/vnd.google-apps.spreadsheet"
],
"application/vnd.oasis.opendocument.presentation": [
"application/vnd.google-apps.presentation"
],
"image/jpg": [
"application/vnd.google-apps.document"
],
"text/richtext": [
"application/vnd.google-apps.document"
]
},
"exportFormats": {
"application/vnd.google-apps.form": [
"application/zip"
],
"application/vnd.google-apps.document": [
"application/rtf",
"application/vnd.oasis.opendocument.text",
"text/html",
"application/pdf",
"application/epub+zip",
"application/zip",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"text/plain"
],
"application/vnd.google-apps.drawing": [
"image/svg+xml",
"image/png",
"application/pdf",
"image/jpeg"
],
"application/vnd.google-apps.spreadsheet": [
"application/x-vnd.oasis.opendocument.spreadsheet",
"text/tab-separated-values",
"application/pdf",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"text/csv",
"application/zip",
"application/vnd.oasis.opendocument.spreadsheet"
],
"application/vnd.google-apps.script": [
"application/vnd.google-apps.script+json"
],
"application/vnd.google-apps.presentation": [
"application/vnd.oasis.opendocument.presentation",
"application/pdf",
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
"text/plain"
]
}
}


У "ресурса" .files (которым мы в основном и будем пользоваться) также есть большое количество методов, рассмотрим основные из них.


С методом files().list() уже немного знакомы, но давайте подробнее.


Метод .list() позволяет находить файлы и папки (используя определённые запросы).


Пример запроса


    list_example = drive_service.files().list(q='fullText contains "important" and trashed = true', fileds='files(id, name)')

Здесь мы воспользовались таким важным параметром как q — особый вид запроса, позволяющий выбрать из всего потока только нужную нам информацию.


Работает на логическом полуестественном языке, можно рассматривать различные параметры, выглядят запросы примерно так


    q = 'name = "Habrahabr"'
    q = 'fullText contains "some text"'
    q = '"idexample123" in parents'

Подробнее о том как этим орудовать здесь.


Пункт fileds регулирует какую именно информацию о файлах мы хотим получить (чтобы запросы выполнялись быстрее). Касается это любого запроса.


Можно также посмотреть базовые примеры работы — загрузка файлов и скачивание файлов. Не стал рассматривать их отдельно, потому что дальше будет их применение "в боевых условиях", что более полезно.




Исходная загрузка файлов


Впрочем, хватит теории, перейдем уже к веселой части (ради этого здесь и собрались).


Для того чтобы синхронизировать папку на компьютере с папкой в облаке, сначала нужно чтобы было что синхронизировать и там и там. В принципе, можно загрузить папку вручную через браузер, но в таком случае можно вручную обновлять файлы по отдельности, вообще сюда не приходить. Так что будем загружать папку с помощью пары строчек кода, магии и костылей.


Если вы все же загружаете папку вручную/уже имеете такую на диске и не хотите ничего менять, то просто переходите к следующему пункту (только примечание — в нашем конкретном случае папка создается прямо в корневой директории гугл диска, так что имейте это ввиду, возможно потребуется a little bit of tinkering).

Напишем функцию, которая будет загружать нашу папку на диск


Код функции
def folder_upload(service):
    parents_id = {}

    for root, _, files in os.walk(FULL_PATH, topdown=True):
        last_dir = root.split('/')[-1]
        pre_last_dir = root.split('/')[-2]
        if pre_last_dir not in parents_id.keys():
            pre_last_dir = []
        else:
            pre_last_dir = parents_id[pre_last_dir]

        folder_metadata = {'name': last_dir,
                           'parents': [pre_last_dir],
                           'mimeType': 'application/vnd.google-apps.folder'}
        create_folder = service.files().create(body=folder_metadata,
                                               fields='id').execute()
        folder_id = create_folder.get('id', [])

        for name in files:
            file_metadata = {'name': name, 'parents': [folder_id]}
            media = MediaFileUpload(
                os.path.join(root, name),
                mimetype=mimetypes.MimeTypes().guess_type(name)[0])
            service.files().create(body=file_metadata,
                                   media_body=media,
                                   fields='id').execute()

        parents_id[last_dir] = folder_id

    return parents_id

Разберем по порядку, хотя здесь все пока довольно просто.


FULL_PATH — глобальная переменная, содержащая полный путь к требуемой папке.


Мы "пробегаем" нашу папку спускаясь "сверху вниз". Для каждой новой папки создаем ее брата близнеца на гугл диске, ее id сохраняем в словарь (ключ — имя, значение — id), а все встречающиеся файлы загружаем в соответствующую папку. В конце возвращаем этот словарь.


Правда, как в анекдоте, есть один нюанс. Для загрузки файла на диск требуется указать его mimeType (спецификация для кодирования информации, не спрашивайте, я не знаю). Для упрощения работы с этим делом воспользуемся готовым решением (по сути оно просто предполагает соответствующий mimeType по расширению, если я правильно понял из документации).


Так что если у Вас есть какие-то хитрые файлы или файлы без расширения, перед применением программы проконсультируйтесь со специалистом (будьте осторожнее).




Синхронизируем изменения с компьютера в облако



Вот загрузили мы папку, поделали каких то изменений, что-то удалили, что-то добавили, что-то просто подредактировали, надо бы теперь сделать так чтобы эти изменения отразились на нашей облачной копии.


Очевидно удалять и загружать заново просто так не очень хочется, поэтому "мы пойдем другим путем".


Создадим для этого отдельный python — скрипт, подгружаем необходимые библиотеки. Для анализа изменений файлов нужно получить дерево файлов и папок на Диске и локальном хранилище, чтобы затем их сравнить и эти изменения перенести туда или обратно.


В достаточно богатом на возможности апи нет встроенного способа получить всё дерево папок и файлов (ну или я не нашел и тогда зря велосипед собрал).


А раз нет, так напишем сами!


Код функции загрузки файлов на Google Диск
def get_tree(folder_name, tree_list, root, parents_id, service):

    folder_id = parents_id[folder_name]

    results = service.files().list(
        pageSize=100,
        q=("%r in parents and \
        mimeType = 'application/vnd.google-apps.folder'and \
        trashed != True" % folder_id)).execute()

    items = results.get('files', [])
    root += folder_name + os.path.sep

    for item in items:
        parents_id[item['name']] = item['id']
        tree_list.append(root + item['name'])
        folder_id = [i['id'] for i in items
                     if i['name'] == item['name']][0]
        folder_name = item['name']
        get_tree(folder_name, tree_list,
                 root, parents_id, service)

Разберем подробнее.
На вход подается куча параметров
folder_name — исходное название нашей папки
folder_id — исходное id папки
tree_list — список наших путей, изначально пустой []
root — промежуточный путь директории… которые потом сохраняются в tree_list
parents_id — словарь, содержащий пары {имя папки — ее значение}


Делаем исходный запрос на содержимое папки с id=folder_id (и еще те что не в корзине*), выдираем те из них которые папки (потому что сейчас интересуемся только путями).


Сохраняем имена и id этих папок в словарь parents_id, в строке root отмечаем тот факт что спускаемся вниз по иерархии папок, и для каждой новой встреченной папки рекурсивно запускаем get_tree с параметрами уже этой папки.


Таким образом рекурсивно спускаемся по всем папкам и получаем их пути.


//Примечание1: У гугл Диска какая-то особая файловая система (что наверное логично), которая обладает своими законами и правилами. Например рядом в одном каталоге могут лежать две папки или два файла с одинаковыми названиями, потому что уникальными для файлов являются их идентификаторы — id. К тому же один и тот же файл может быть одновременно в двух разных местах на диске (если я все правильно понял). Наверное эти знания можно было использовать для более оптимизированной синхронизации, построить базу данных рядом со скриптом и проверять по ней, но это уже overfitting и совсем лишнее. Просто на заметку.


// Примечание2: все мои примеры, и даже итоговый скрипт сделаны с одним халатным допущением, считается что во папке находится не больше 1000 файлов (и так сойдет!). В моем случае это всегда верно, себе я доверяю. Поэтому так грубо и непредусмотрительно написано. Проблема исправляется легко и в пару строчек с использованием nextPageToken, как показано в пункте выше. Думаю Вам не составит труда это сделать самим (ну или у меня когда-нибудь дойдут руки).


Пример кода с использованием nextPageToken также есть в специальной инструкции.


Отлично, получили папки которые имеются на диске, теперь проделаем то же самое
с папкой на компьютере (это делается уже проще)


    os_tree_list = []
    root_len = len(full_path.split(os.path.sep)[0:-2])
    for root, dirs, files in os.walk(full_path, topdown=True):
        for name in dirs:
            var_path = '/'.join(root.split('/')[root_len+1:])
            os_tree_list.append(os.path.join(var_path, name))

Здесь по сути только одна хитрость — это превращение полного пути в относительный. Остальное довольно просто.


Теперь у нас имеются tree_list и os_tree_list, которые мы намереваемся сравнить.


Примеры того, как выглядят эти списки


    os_tree_list = [Example/Example1, Example/Example1/Example2, Example/Example1/Example3]
    tree_list = [Example/Example1/, Example/Example1/Example4, Example/Example1/Example4/Example5]

Для дальнейших действий разобьем папки на три группы


    remove_folders = list(set(tree_list).difference(set(os_tree_list)))
    upload_folders = list(set(os_tree_list).difference(set(tree_list))) 
    exact_folders = list(set(os_tree_list).intersection(set(tree_list)))

Как очевидно следует из названия переменных, remove_folders это те папки, которые старые либо окзались здесь случайно и их требуется удалить, upload_folders — новые папки которые нужно загрузить, ну и exact_folders — это папки которые имеются и на компьютере и в облаке.


Начнем с простого — загрузки папок и содержащихся в них папок, благо с этим уже немного знакомы.


Загрузка папок на Диск
upload_folders = sorted(upload_folders, key=by_lines)

for folder_dir in upload_folders:
        var = os.path.join(full_path.split(os.path.sep)[0:-1]) + os.path.sep
        variable = var + folder_dir
        last_dir = folder_dir.split(os.path.sep)[-1]
        pre_last_dir = folder_dir.split(os.path.sep)[-2]

        files = [f for f in os.listdir(variable)
                 if os.path.isfile(os.path.join(variable, f))]

        folder_metadata = {'name': last_dir,
                           'parents': [parents_id[pre_last_dir]],
                           'mimeType': 'application/vnd.google-apps.folder'}
        create_folder = service.files().create(
            body=folder_metadata, fields='id').execute()
        folder_id = create_folder.get('id', [])
        parents_id[last_dir] = folder_id

        for os_file in files:
            some_metadata = {'name': os_file, 'parents': [folder_id]}
            os_file_mimetype = mimetypes.MimeTypes().guess_type(
                os.path.join(variable, os_file))[0]
            media = MediaFileUpload(os.path.join(variable, os_file),
                                    mimetype=os_file_mimetype)
            upload_this = service.files().create(body=some_metadata,
                                                 media_body=media,
                                                 fields='id').execute()
            upload_this = upload_this.get('id', [])

Перед началом действа мы отсортировали наш список с "адресами" папок для удобства по числу поддиректорий (или проще наклонных черточек '\'), чтобы загружать папки и их содержимое по мере погружения вглубь иерархического дерева (избегая таким образом возможных опасностей типа "а этой папки нет и непонятно куда она вообще его выкинет").


Создаем эту папку на диске в соответствующем для нее месте, с указанием ее "родителя" (для этого и нужен pre_last_dir).


По аналогии с папками, смотрим файлы в директории и проверяем их наличие в это же папке на диске.


Берем все файлы из этой папки и по очереди загружаем по известному сценарию.


Дальше чуть интереснее, будем пробегаться по файлам и проверять их на измену изменения.


Теперь предстояло сравнить файлы присутствующие по обе стороны баррикад, и в случае необходимости обновить их содержимое на Диске.


Для этого у файлов есть специальный метод есть .update(), однако не все так просто.


Изначальный вариант предполагал не обновление, а удаление — загрузку новой версии файла (просто с первой попытки метод update() не завёлся, я запаниковал и решил что он слишком хитрый и заменил его на привычный и родной upload()).


Хорошо что решил ещё раз протестировать, и все заработало, стоило только правильно указать mimeType, в очередной раз напоминаю — будьте с ним внимательнее.


Кстати выигрыш по скорости на глаз не был заметен, даже наоборот, но никаких даже приблизительных замеров не производилось, так что это не точно.


Затем стоило разобраться все-таки, имеет ли вообще смысл обновлять файл, иначе если каждый раз все эти файлы загружать (полностью или частично) будет не комильфо.


Первая же пришедшая в голову идея была такая — проверка по дате последней модификации, благо для файла такой параметр вынуть не составляет труда (параметр modifiedTime).


И вроде все ничего, на стандартных проверках все работало как часы. Но пытливые умы уже наверное поняли в чем здесь фатальное упущение. Да, естественно, если мы просто закинем файл из другой папки с таким же названием, но другим содержимым, то дата изменения будет старой, а файл другим совершенно (один из вариантов, видимо таких ситуаций еще много можно придумать). Значит нужно было придумать новый способ проверки (не менее гениальный, но более действенный). В итоге абсолютно самостоятельно, без чьей либо помощи, был придуман вариант проверять не только дату изменения, а хеши файлов (ну кто вообще мог подумать что у файлов на диске есть параметр md5Checksum). В итоге логика изменилась не сильно, а результат радует.


Код
for folder_dir in exact_folders:

        var = (os.path.sep).join(full_path.split(
            os.path.sep)[0:-1]) + os.path.sep

        variable = var + folder_dir
        last_dir = folder_dir.split(os.path.sep)[-1]
        # print(last_dir, folder_dir)
        os_files = [f for f in os.listdir(variable)
                    if os.path.isfile(os.path.join(variable, f))]
        results = service.files().list(
            pageSize=1000, q=('%r in parents and \
            mimeType!="application/vnd.google-apps.folder" and \
            trashed != True' % parents_id[last_dir]),
            fields="files(id, name, mimeType, \
            modifiedTime, md5Checksum)").execute()

        items = results.get('files', [])

        refresh_files = [f for f in items if f['name'] in os_files]
        remove_files = [f for f in items if f['name'] not in os_files]
        upload_files = [f for f in os_files
                        if f not in [j['name']for j in items]]

        # Check files that exist both on Drive and on PC
        for drive_file in refresh_files:
            file_dir = os.path.join(variable, drive_file['name'])
            file_time = os.path.getmtime(file_dir)
            mtime = [f['modifiedTime']
                     for f in items if f['name'] == drive_file['name']][0]
            mtime = datetime.datetime.strptime(
                mtime[:-2], "%Y-%m-%dT%H:%M:%S.%f")
            drive_time = time.mktime(mtime.timetuple())
            # print(drive_file['name'])
            # if file['mimeType'] in GOOGLE_MIME_TYPES.keys():
            # print(file['name'], file['mimeType'])
            # print()
            os_file_md5 = hashlib.md5(open(file_dir, 'rb').read()).hexdigest()
            if 'md5Checksum' in drive_file.keys():
                # print(1, file['md5Checksum'])
                drive_md5 = drive_file['md5Checksum']
                # print(2, os_file_md5)
            else:
                # print('No hash')
                drive_md5 = None
                # print(drive_md5 != os_file_md5)

            if (file_time > drive_time) or (drive_md5 != os_file_md5):
                file_id = [f['id'] for f in items
                           if f['name'] == drive_file['name']][0]
                file_mime = [f['mimeType'] for f in items
                             if f['name'] == drive_file['name']][0]

                # File's new content.
                # file_mime = mimetypes.MimeTypes().guess_type(file_dir)[0]
                file_metadata = {'name': drive_file['name'],
                                 'parents': [parents_id[last_dir]]}
                # media_body = MediaFileUpload(file_dir, mimetype=filemime)
                media_body = MediaFileUpload(file_dir, mimetype=file_mime)
                # print('I am HERE, ', )
                service.files().update(fileId=file_id,
                                       media_body=media_body,
                                       fields='id').execute()

Те файлы, которые отсутствуют в облаке, загружаем, те что там "лишние" (есть только там, но не на компьютере) — удаляем.



Ну и последнее (а также самое простое) — удаление старых папок


    for folder_dir in remove_folders:
        var = (os.path.sep).join(full_path.split(os.path.sep)[0:-1]) + os.path.sep
        variable = var + folder_dir
        last_dir = folder_dir.split('/')[-1]
        folder_id = parents_id[last_dir]
        service.files().delete(fileId=folder_id).execute()

Здесь все достаточно просто и лаконично.


Всем радость и счастье. Почти.


Примечание: действия загрузка -> обновление -> удаление папок стоит в таком порядке не случайно. Это сделано на всякий случай, чтобы избежать коллизии вроде "папка была" — "папки внутри нет" (или как-то так). Да, это потенциально может добавить лишней и ненужной работы, но зато безопаснее. К тому же, программа писалась в основном под себя, себе я доверяю и скорее всего сильно много чудить и менять папки не буду (хотя могу, но в таком случае скорее просто все снесу и загружу заново, "такой уж я парень")




Пункт 3 — обратная синхронизация



Теперь займемся синхронизацией Google Диск -> компьютер. Допустим имеется у нас папка на ноутбуке, поработав день на свежем воздухе и отразив все эти изменения на диске, хотим чтобы чудесным образом все так же появилось (или исчезло) на домашнем ПК.


В принципе логика здесь такая же, как в предыдущем пункте, только наоборот (каламбур, но думаю суть поняли).


Образец кода для скачивания файла примерно такой:


file_id = 'idexample'
request = drive_service.files().get_media(fileId=file_id) 
fh = io.BytesIO()
downloader = MediaIoBaseDownload(fh, request)
done = False
while done is False:
    status, done = downloader.next_chunk()
    print "Download %d%%." % int(status.progress() * 100)

И здесь появляется главная трудность. Данный способ работает только с так называемыми "бинарными файлами" (технически не совсем так, ведь текстовые файлы — не бинарные, бла бла бла, не придирайтесь). На практике это значит что все будет хорошо с любыми файлами кроме файлов формата Google Docs. К ним нужен особый подход.


Во-первых, для них существуют отдельный немного свой тип запроса:


file_id = 'idexample'
request = drive_service.files().export_media(fileId=file_id, mimeType='application/pdf')
fh = io.BytesIO()
downloader = MediaIoBaseDownload(fh, request)
done = False
while done is False:
    status, done = downloader.next_chunk()
    print "Download %d%%." % int(status.progress() * 100)

Во-вторых, сохранять их нужно с использованием строго определенных типов mimeType. То есть нужно каждому формату Google Docs файла нужно поставить в соответствие четко определенный поддерживаемый формат (здесь могут пригодиться знания полученные из метода about().get(fields='exportFormats') чуть выше, нельзя просто так Google Таблицы сохранить в .doc файле).


Основные моменты не будут сильно от отличаться по своей сути от предыдущего пункта, поэтому не буду приодить весь код целиком, можете посмотреть в (репозитории)[].


C самого начала знакомство с гугл доками у меня не заладилось, постоянно возникали проблемы. Нормально и почти без лишних проблем работало только их сохранение в pdf файл, что полноценно устраивать не может (так так редактировать их будет нельзя).


То есть обратной совместимости (пока что) добиться не удалось, жаль. Не знаю как именно (не смотрел), но видимо гугловский софт по синхронизачии работает довольно хитро, поэтому возможно на linux gui решения и нет (да и не нужно оно).


Настолько длинный и тернистый путь был связан с Google Docs, что я решил выделить "наболевшее" в отдельную главу.


Примечание: вместо io.BytesIO() лично я использую FileIO(path_to_file, 'wb'), иначе ничего просто не работало, на заметку.




Эпопея с Google Docs



Как же решить проблему обратной совместимости?


Спойлер — наверное её не надо решать. Я так полностью не решил.


Ситуация здесь такая — есть все "обычные" файлы, а есть файлы формата Google Docs. И вот на компьютер просто так сохранить эти чудесные Гугл Доки никак нельзя, нужно переводить в соответствующий известный формат (например Microsoft, OpenOffice или pdf в случае документов).


После того когда вся программная часть была списана и работала сносно, большая часть времени ушла на выход из этого неловкого положения.


Вообще, писал то я скрипт изначально под свои нужды, и в моей рабочей папке никаких Гугл Доков быть не должно. Но желание здесь все более универсально оказалось сильнее, и пара ночей была потрачена только на решение проблемы совместимости.


Ход эволюции мысли здесь был примерно такой:


1) Сначала было решено ничего не делать с этими веб файлами, просто при проверке папкой либо их игнорировать, либо скачивать PDF и этого будет достаточно. Но это уж слишком ситуативно, можно лучше.


2) А почему бы не перегонять их в соответствующий Microsoft формат (для презентаций и т.п., благо такой вариант возможно) а потом обратно? Здесь явно нужно было уже работать с обновлением файла, простого удаления — загрузки не хватит (собственно в этот момент я снова решил попробовать метод .update()), ибо вдруг этот файл расшарен, при загрузке у него сменится id и старая ссылка работать не будет. Не то чтобы это мне было нужно, но раз уж взялся за дело, отступать было поздно.


И вот на этом моменте и возникла самая большая куча подводных камней. Итоговый код вот (на самом деле не очень сложный)



    #f - список, состоящий из имений файла, его id, и других полехные вещей вроде mimeType и др.
    if f['mimeType'] in GoogleMimeTypes.keys():
        print('File APPLICATION!!! ', f['name'], f['mimeType'])

        # request = service.files().get_media(fileId=file_id)

        request = service.files().export_media(fileId=file_id, mimeType = GoogleMimeTypes[f['mimeType']])
        fh = io.FileIO(os.path.join(variable, f['name']), 'wb')
        downloader = MediaIoBaseDownload(fh, request)
        done = False
        while done is False:
            status, done = downloader.next_chunk()
            print("Download %d%%." % int(status.progress() * 100), GoogleMimeTypes[f['mimeType']])
    else:
        print('Good File ', f['name'], f['mimeType'])

        request = service.files().get_media(fileId=file_id)
        fh = io.FileIO(os.path.join(variable, f['name']), 'wb')
        downloader = MediaIoBaseDownload(fh, request)
        done = False
        while done is False:
            status, done = downloader.next_chunk()
            print("Download %d%%." % int(status.progress() * 100))

А вот некоторые проблемы с которым пришлось бороться / мириться (может умные люди выйдут из положения более элегантно)


1) Несмотря на заявленную карту соответствий, не в каждый формат удалось перевести конкретные файлы (например почему-то таблицы лично у меня очень плохо сохраняются в pdf).
2) Проблемы с mimeType и расширениями. У Google Файлов (поскольку это веб формат) расширений нет, соответственно и при сохранении у файла не будет. Но Майкрософт файлы просто так без расширения не работают (без расширения это просто xml архив, который так просто в ворде не запустить), да и другие файлы тоже без расширений могут вести себя непредсказуемо.
3) Вообще молчу про файлы не входящие в офис-пакет. Мало кто знает, но Google Docs также имеет в своем арсенале Drawings, Forms и кучу всего другого, что вообще никак нормально не переводится. Яркий пример — Google Drawings, которые вообще нормально нельзя перевести "туда-обратно-туда" (можно посмотреть это в exportFormats и importFormats). В итоге на эти вещи было решено конкретно забить в надежде что в жизни они никогда и никому не встретятся.


Поэтому был реализован дикий костыль: ко всем Google Docs файлам в название приписывается .ext расширение, соответствующее аналогу в мире Microsoft. И при сохранении (экспорте) в таком случае файл на компьютере будет соответствовать всем ожиданиям и будет нормально работать и запускаться. Соответственно пришлось немного переписать логику работы по синхронизации компьютер -> Диск, этот файл будет обновлять веб-версию Google Doc файла, а не просто стирать ее и заменять на Office документ (соответственно название файла все равно один раз пострадает, что может повлиять на его доступность при расшаривании, возможно).


Поверхностная проверка проблем не выявила.




Пункт про аналоги


Да, стоит наверное уточнить момент связанный с "аналогов не нашел или они все хуже". Положа руку на сердце, это не совсем так. Изначально поиски были под убунту, и на глаза попалась пара интересных примеров (не говоря уже об известной программе grive), но лично меня они не устраивают, где-то синхронизация только в одну сторону, где-то все работает неудобно и неестественно через создание какой-то лишней искусственной папки, где-то еще что-то неудобно. Оправдался в общем за создание очередного велосипеда. Вот так вот.




Заключениe


Пора уже подводить итоги.


Что-то еще осталось "допилить" и что-то не идеально. Но основная цель была достигнута. В планах еще немного это все подшлифовать, возможно добавить таблицы или маленькую БД и хранить там данные о Google Docs файлах, чтобы не менять название, а просто ставить в соответствие файлу на компьютере файл в облаке. Но это сложно,"из пушки по воробьям", вероятно поэтому пока и откладываю. На сегодня меня текущее решение устраивает.


Также в планах дописать немного, чтобы можно было использовать относительные пути, просто положив скрипт рядом с синхронизируемой папкой.


Жду комментариев, критики и советов, как это дело можно элегантно подправить.


Исходники можно посмотреть в репозитории.


Полезные ссылки, еще раз:


1) Документация, список API, там же рядом основные примеры
2) Официальный quickstart-туториал
3) Исходный код в репозитории