gmdoc 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- gmdoc-0.1.0/.gitignore +13 -0
- gmdoc-0.1.0/LICENSE +21 -0
- gmdoc-0.1.0/PKG-INFO +108 -0
- gmdoc-0.1.0/README.md +82 -0
- gmdoc-0.1.0/gmdoc.py +652 -0
- gmdoc-0.1.0/pyproject.toml +51 -0
gmdoc-0.1.0/.gitignore
ADDED
gmdoc-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 boscoh
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
gmdoc-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: gmdoc
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Pull/push Google Docs as Markdown via the Drive API
|
|
5
|
+
Project-URL: Homepage, https://github.com/boscoh/gmdoc
|
|
6
|
+
Project-URL: Repository, https://github.com/boscoh/gmdoc
|
|
7
|
+
Project-URL: Issues, https://github.com/boscoh/gmdoc/issues
|
|
8
|
+
Author-email: boscoh <apposite@gmail.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: cli,docs,drive,gcloud,google-docs,markdown
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Operating System :: MacOS
|
|
17
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Text Processing :: Markup :: Markdown
|
|
22
|
+
Classifier: Typing :: Typed
|
|
23
|
+
Requires-Python: >=3.12
|
|
24
|
+
Requires-Dist: cyclopts>=4.10.0
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# gmdoc
|
|
28
|
+
|
|
29
|
+
Pull and push Google Docs as Markdown, using the Drive API's built-in
|
|
30
|
+
`text/markdown` conversion.
|
|
31
|
+
|
|
32
|
+
`gmdoc` borrows your access token from `gcloud`, so you don't need a Google Cloud
|
|
33
|
+
project, OAuth client, or config file. It talks to the Drive REST API with the
|
|
34
|
+
standard library, so its only dependency is `cyclopts`.
|
|
35
|
+
|
|
36
|
+
## Install
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
brew install --cask gcloud-cli
|
|
40
|
+
gcloud auth login --enable-gdrive-access # browser login, click Allow
|
|
41
|
+
|
|
42
|
+
uv tool install gmdoc
|
|
43
|
+
# or: pipx install gmdoc
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Running the script directly also works — `gmdoc.py` declares its own dependencies
|
|
47
|
+
in a PEP 723 header, so `uv run gmdoc.py ...` (or `./gmdoc.py ...`) works anywhere,
|
|
48
|
+
even if you copy just that one file.
|
|
49
|
+
|
|
50
|
+
## Usage
|
|
51
|
+
|
|
52
|
+
```sh
|
|
53
|
+
gmdoc ls [name] # list recent docs
|
|
54
|
+
gmdoc pull <doc-url|id> [out.md] # download (default name: <title>.md)
|
|
55
|
+
gmdoc pull notes.md # re-pull an already-linked file
|
|
56
|
+
gmdoc push notes.md # upload edits (replaces the doc body)
|
|
57
|
+
gmdoc new draft.md -t "My Doc" # create a new doc from markdown
|
|
58
|
+
gmdoc status [files...] # in sync / local / remote / CONFLICT
|
|
59
|
+
gmdoc account [email] [--login] # list/check/switch Google accounts
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Each pulled file gets front matter linking it to the doc:
|
|
63
|
+
|
|
64
|
+
```yaml
|
|
65
|
+
---
|
|
66
|
+
gmdoc_id: 1AbC...
|
|
67
|
+
gmdoc_title: My Doc
|
|
68
|
+
gmdoc_modified: 2026-01-01T12:00:00.000Z
|
|
69
|
+
gmdoc_hash: 3f2a...
|
|
70
|
+
gmdoc_account: me@gmail.com
|
|
71
|
+
---
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Multiple accounts
|
|
75
|
+
|
|
76
|
+
- `gmdoc account` lists your gcloud accounts, marks the active one with `*`, and
|
|
77
|
+
checks that each can reach Drive.
|
|
78
|
+
- `gmdoc account other@gmail.com` switches to that account, logging in first if
|
|
79
|
+
needed. Add `--login` to redo the login (fixes "no Drive access").
|
|
80
|
+
- Each file remembers the account it was synced with (`gmdoc_account`), and
|
|
81
|
+
`pull`, `push` and `status` use that account whichever one is active.
|
|
82
|
+
- New docs and `ls` use the active account. `--account/-a <email>` overrides it
|
|
83
|
+
on any command.
|
|
84
|
+
|
|
85
|
+
## Safety checks
|
|
86
|
+
|
|
87
|
+
- `push` refuses if the doc was edited in Google after your last sync. It detects
|
|
88
|
+
this by exporting the doc and comparing it to `gmdoc_hash`, the hash of the text
|
|
89
|
+
at last sync.
|
|
90
|
+
- `pull` refuses if the local file has unpushed edits.
|
|
91
|
+
- `--force` skips both checks.
|
|
92
|
+
|
|
93
|
+
## Troubleshooting
|
|
94
|
+
|
|
95
|
+
If Drive complains that the API isn't enabled for the project, set
|
|
96
|
+
`GMDOC_QUOTA_PROJECT=<your-project-id>` (a project with the Drive API enabled).
|
|
97
|
+
|
|
98
|
+
## Caveats
|
|
99
|
+
|
|
100
|
+
- A push **replaces the whole doc body**. Comments become detached, and
|
|
101
|
+
suggestions and some formatting (colors, complex tables, images) may be lost.
|
|
102
|
+
- After a push the file is re-exported, so it matches Google's version of the
|
|
103
|
+
markdown.
|
|
104
|
+
- The `drive` scope is used so it can edit any doc you have access to.
|
|
105
|
+
|
|
106
|
+
## License
|
|
107
|
+
|
|
108
|
+
MIT
|
gmdoc-0.1.0/README.md
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# gmdoc
|
|
2
|
+
|
|
3
|
+
Pull and push Google Docs as Markdown, using the Drive API's built-in
|
|
4
|
+
`text/markdown` conversion.
|
|
5
|
+
|
|
6
|
+
`gmdoc` borrows your access token from `gcloud`, so you don't need a Google Cloud
|
|
7
|
+
project, OAuth client, or config file. It talks to the Drive REST API with the
|
|
8
|
+
standard library, so its only dependency is `cyclopts`.
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```sh
|
|
13
|
+
brew install --cask gcloud-cli
|
|
14
|
+
gcloud auth login --enable-gdrive-access # browser login, click Allow
|
|
15
|
+
|
|
16
|
+
uv tool install gmdoc
|
|
17
|
+
# or: pipx install gmdoc
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Running the script directly also works — `gmdoc.py` declares its own dependencies
|
|
21
|
+
in a PEP 723 header, so `uv run gmdoc.py ...` (or `./gmdoc.py ...`) works anywhere,
|
|
22
|
+
even if you copy just that one file.
|
|
23
|
+
|
|
24
|
+
## Usage
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
gmdoc ls [name] # list recent docs
|
|
28
|
+
gmdoc pull <doc-url|id> [out.md] # download (default name: <title>.md)
|
|
29
|
+
gmdoc pull notes.md # re-pull an already-linked file
|
|
30
|
+
gmdoc push notes.md # upload edits (replaces the doc body)
|
|
31
|
+
gmdoc new draft.md -t "My Doc" # create a new doc from markdown
|
|
32
|
+
gmdoc status [files...] # in sync / local / remote / CONFLICT
|
|
33
|
+
gmdoc account [email] [--login] # list/check/switch Google accounts
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Each pulled file gets front matter linking it to the doc:
|
|
37
|
+
|
|
38
|
+
```yaml
|
|
39
|
+
---
|
|
40
|
+
gmdoc_id: 1AbC...
|
|
41
|
+
gmdoc_title: My Doc
|
|
42
|
+
gmdoc_modified: 2026-01-01T12:00:00.000Z
|
|
43
|
+
gmdoc_hash: 3f2a...
|
|
44
|
+
gmdoc_account: me@gmail.com
|
|
45
|
+
---
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Multiple accounts
|
|
49
|
+
|
|
50
|
+
- `gmdoc account` lists your gcloud accounts, marks the active one with `*`, and
|
|
51
|
+
checks that each can reach Drive.
|
|
52
|
+
- `gmdoc account other@gmail.com` switches to that account, logging in first if
|
|
53
|
+
needed. Add `--login` to redo the login (fixes "no Drive access").
|
|
54
|
+
- Each file remembers the account it was synced with (`gmdoc_account`), and
|
|
55
|
+
`pull`, `push` and `status` use that account whichever one is active.
|
|
56
|
+
- New docs and `ls` use the active account. `--account/-a <email>` overrides it
|
|
57
|
+
on any command.
|
|
58
|
+
|
|
59
|
+
## Safety checks
|
|
60
|
+
|
|
61
|
+
- `push` refuses if the doc was edited in Google after your last sync. It detects
|
|
62
|
+
this by exporting the doc and comparing it to `gmdoc_hash`, the hash of the text
|
|
63
|
+
at last sync.
|
|
64
|
+
- `pull` refuses if the local file has unpushed edits.
|
|
65
|
+
- `--force` skips both checks.
|
|
66
|
+
|
|
67
|
+
## Troubleshooting
|
|
68
|
+
|
|
69
|
+
If Drive complains that the API isn't enabled for the project, set
|
|
70
|
+
`GMDOC_QUOTA_PROJECT=<your-project-id>` (a project with the Drive API enabled).
|
|
71
|
+
|
|
72
|
+
## Caveats
|
|
73
|
+
|
|
74
|
+
- A push **replaces the whole doc body**. Comments become detached, and
|
|
75
|
+
suggestions and some formatting (colors, complex tables, images) may be lost.
|
|
76
|
+
- After a push the file is re-exported, so it matches Google's version of the
|
|
77
|
+
markdown.
|
|
78
|
+
- The `drive` scope is used so it can edit any doc you have access to.
|
|
79
|
+
|
|
80
|
+
## License
|
|
81
|
+
|
|
82
|
+
MIT
|
gmdoc-0.1.0/gmdoc.py
ADDED
|
@@ -0,0 +1,652 @@
|
|
|
1
|
+
#!/usr/bin/env -S uv run --script
|
|
2
|
+
# /// script
|
|
3
|
+
# requires-python = ">=3.12"
|
|
4
|
+
# dependencies = [
|
|
5
|
+
# "cyclopts>=4.10.0",
|
|
6
|
+
# ]
|
|
7
|
+
# ///
|
|
8
|
+
"""gmdoc: pull/push Google Docs as Markdown via the Drive API.
|
|
9
|
+
|
|
10
|
+
Pull and push Google Docs as Markdown, using the Drive API's built-in
|
|
11
|
+
`text/markdown` conversion.
|
|
12
|
+
|
|
13
|
+
## Setup (once)
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
brew install --cask gcloud-cli
|
|
17
|
+
gcloud auth login --enable-gdrive-access # browser login, click Allow
|
|
18
|
+
uv tool install gmdoc
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Or skip installing: `gmdoc.py` lists its own dependencies in the comment block at the
|
|
22
|
+
top, so `uv run gmdoc.py ...` or `./gmdoc.py ...` works anywhere, even if you copy just
|
|
23
|
+
this file.
|
|
24
|
+
|
|
25
|
+
`gmdoc` borrows gcloud's access token, so you don't need a Google Cloud project. It talks
|
|
26
|
+
to the Drive REST API with the standard library, so its only dependency is `cyclopts`.
|
|
27
|
+
|
|
28
|
+
If Drive complains that the API isn't enabled for the project, set
|
|
29
|
+
`GMDOC_QUOTA_PROJECT=<your-project-id>` (a project with the Drive API enabled).
|
|
30
|
+
|
|
31
|
+
## Usage
|
|
32
|
+
|
|
33
|
+
```sh
|
|
34
|
+
gmdoc ls [name] # list recent docs
|
|
35
|
+
gmdoc pull <doc-url|id> [out.md] # download (default name: <title>.md)
|
|
36
|
+
gmdoc pull notes.md # re-pull an already-linked file
|
|
37
|
+
gmdoc push notes.md # upload edits (replaces the doc body)
|
|
38
|
+
gmdoc new draft.md -t "My Doc" # create a new doc from markdown
|
|
39
|
+
gmdoc status [files...] # in sync / local / remote / CONFLICT
|
|
40
|
+
gmdoc account [email] [--login] # list/check/switch Google accounts
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Each pulled file gets front matter linking it to the doc:
|
|
44
|
+
|
|
45
|
+
```yaml
|
|
46
|
+
---
|
|
47
|
+
gmdoc_id: 1AbC...
|
|
48
|
+
gmdoc_title: My Doc
|
|
49
|
+
gmdoc_modified: 2026-01-01T12:00:00.000Z
|
|
50
|
+
gmdoc_hash: 3f2a...
|
|
51
|
+
gmdoc_account: me@gmail.com
|
|
52
|
+
---
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Multiple accounts
|
|
56
|
+
|
|
57
|
+
- `gmdoc account` lists your gcloud accounts, marks the active one with `*`, and checks
|
|
58
|
+
that each can reach Drive.
|
|
59
|
+
- `gmdoc account other@gmail.com` switches to that account, logging in first if needed.
|
|
60
|
+
Add `--login` to redo the login (fixes "no Drive access").
|
|
61
|
+
- Each file remembers the account it was synced with (`gmdoc_account`), and `pull`,
|
|
62
|
+
`push` and `status` use that account whichever one is active.
|
|
63
|
+
- New docs and `ls` use the active account. `--account/-a <email>` overrides it on
|
|
64
|
+
any command.
|
|
65
|
+
|
|
66
|
+
## Safety checks
|
|
67
|
+
|
|
68
|
+
- `push` refuses if the doc was edited in Google after your last sync. It detects this by
|
|
69
|
+
exporting the doc and comparing it to `gmdoc_hash`, the hash of the text at last sync.
|
|
70
|
+
- `pull` refuses if the local file has unpushed edits.
|
|
71
|
+
- `--force` skips both checks.
|
|
72
|
+
|
|
73
|
+
## Caveats
|
|
74
|
+
|
|
75
|
+
- A push **replaces the whole doc body**. Comments become detached, and suggestions and
|
|
76
|
+
some formatting (colors, complex tables, images) may be lost.
|
|
77
|
+
- After a push the file is re-exported, so it matches Google's version of the markdown.
|
|
78
|
+
- The `drive` scope is used so it can edit any doc you have access to.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
from __future__ import annotations
|
|
82
|
+
|
|
83
|
+
import functools
|
|
84
|
+
import hashlib
|
|
85
|
+
import json
|
|
86
|
+
import os
|
|
87
|
+
import re
|
|
88
|
+
import secrets
|
|
89
|
+
import shutil
|
|
90
|
+
import subprocess
|
|
91
|
+
import sys
|
|
92
|
+
import urllib.error
|
|
93
|
+
import urllib.request
|
|
94
|
+
from dataclasses import dataclass
|
|
95
|
+
from pathlib import Path
|
|
96
|
+
from typing import Annotated, NoReturn
|
|
97
|
+
from urllib.parse import urlencode
|
|
98
|
+
|
|
99
|
+
from cyclopts import App, Parameter
|
|
100
|
+
|
|
101
|
+
API = "https://www.googleapis.com/drive/v3"
|
|
102
|
+
UPLOAD = "https://www.googleapis.com/upload/drive/v3"
|
|
103
|
+
MD = "text/markdown"
|
|
104
|
+
GDOC = "application/vnd.google-apps.document"
|
|
105
|
+
FIELDS = "id,name,mimeType,modifiedTime"
|
|
106
|
+
|
|
107
|
+
# Name to show in hints: "gmdoc", or e.g. "b gmdoc" when run as a bhtool subcommand.
|
|
108
|
+
_argv0 = Path(sys.argv[0]).name
|
|
109
|
+
PROG = f"{_argv0} gmdoc" if _argv0 in ("b", "bhtool") else "gmdoc"
|
|
110
|
+
LOGIN_HINT = PROG + " account {} --login"
|
|
111
|
+
|
|
112
|
+
FM_RE = re.compile(r"\A---\n(.*?)\n---\n?", re.S)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# ---------------------------------------------------------------- auth / api
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class HttpError(Exception):
|
|
119
|
+
def __init__(self, status: int, message: str):
|
|
120
|
+
super().__init__(f"Drive API error {status}: {message}")
|
|
121
|
+
self.status = status
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@dataclass
|
|
125
|
+
class Drive:
|
|
126
|
+
token: str
|
|
127
|
+
account: str
|
|
128
|
+
|
|
129
|
+
def call(
|
|
130
|
+
self,
|
|
131
|
+
method: str,
|
|
132
|
+
url: str,
|
|
133
|
+
params: dict | None = None,
|
|
134
|
+
data: bytes | None = None,
|
|
135
|
+
content_type: str | None = None,
|
|
136
|
+
raise_errors: bool = False,
|
|
137
|
+
) -> bytes:
|
|
138
|
+
"""Make a Drive REST call. On HTTP errors, exit (or raise HttpError if asked)."""
|
|
139
|
+
headers = {"Authorization": f"Bearer {self.token}"}
|
|
140
|
+
if quota := os.environ.get("GMDOC_QUOTA_PROJECT"):
|
|
141
|
+
headers["x-goog-user-project"] = quota
|
|
142
|
+
if content_type:
|
|
143
|
+
headers["Content-Type"] = content_type
|
|
144
|
+
if params:
|
|
145
|
+
url += "?" + urlencode(params)
|
|
146
|
+
req = urllib.request.Request(url, data=data, method=method, headers=headers)
|
|
147
|
+
try:
|
|
148
|
+
with urllib.request.urlopen(req, timeout=60) as r:
|
|
149
|
+
return r.read()
|
|
150
|
+
except urllib.error.HTTPError as e:
|
|
151
|
+
raw = e.read().decode("utf-8", "replace")
|
|
152
|
+
try:
|
|
153
|
+
message = json.loads(raw)["error"]["message"]
|
|
154
|
+
except (ValueError, KeyError, TypeError):
|
|
155
|
+
message = raw.strip() or str(e.reason)
|
|
156
|
+
err = HttpError(e.code, message)
|
|
157
|
+
if raise_errors:
|
|
158
|
+
raise err from None
|
|
159
|
+
die(str(err))
|
|
160
|
+
except urllib.error.URLError as e:
|
|
161
|
+
die(f"can't reach Google: {e.reason}")
|
|
162
|
+
|
|
163
|
+
def call_json(self, method: str, url: str, **kw) -> dict:
|
|
164
|
+
return json.loads(self.call(method, url, **kw))
|
|
165
|
+
|
|
166
|
+
def meta(self, doc_id: str) -> dict:
|
|
167
|
+
try:
|
|
168
|
+
info = self.call_json(
|
|
169
|
+
"GET",
|
|
170
|
+
f"{API}/files/{doc_id}",
|
|
171
|
+
params={"fields": FIELDS, "supportsAllDrives": "true"},
|
|
172
|
+
raise_errors=True,
|
|
173
|
+
)
|
|
174
|
+
except HttpError as e:
|
|
175
|
+
if e.status == 404:
|
|
176
|
+
die(
|
|
177
|
+
f"doc {doc_id} not found, or not shared with {self.account}.",
|
|
178
|
+
"Try another account with --account <email>. To list your accounts:",
|
|
179
|
+
f" {PROG} account",
|
|
180
|
+
)
|
|
181
|
+
die(str(e))
|
|
182
|
+
if info["mimeType"] != GDOC:
|
|
183
|
+
die(f"{info['name']} is not a Google Doc ({info['mimeType']})")
|
|
184
|
+
return info
|
|
185
|
+
|
|
186
|
+
def export(self, doc_id: str) -> str:
|
|
187
|
+
data = self.call("GET", f"{API}/files/{doc_id}/export", params={"mimeType": MD})
|
|
188
|
+
return data.decode("utf-8")
|
|
189
|
+
|
|
190
|
+
def update(self, doc_id: str, text: str) -> dict:
|
|
191
|
+
"""Replace the doc body with markdown (Drive converts it)."""
|
|
192
|
+
return self.call_json(
|
|
193
|
+
"PATCH",
|
|
194
|
+
f"{UPLOAD}/files/{doc_id}",
|
|
195
|
+
params={"uploadType": "media", "fields": FIELDS, "supportsAllDrives": "true"},
|
|
196
|
+
data=text.encode("utf-8"),
|
|
197
|
+
content_type=MD,
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
def create(self, meta: dict, text: str) -> dict:
|
|
201
|
+
"""Create a file from metadata + markdown content (multipart upload)."""
|
|
202
|
+
boundary = "gmdoc-" + secrets.token_hex(16)
|
|
203
|
+
data = (
|
|
204
|
+
f"--{boundary}\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n"
|
|
205
|
+
f"{json.dumps(meta)}\r\n"
|
|
206
|
+
f"--{boundary}\r\nContent-Type: {MD}; charset=UTF-8\r\n\r\n"
|
|
207
|
+
f"{text}\r\n--{boundary}--\r\n"
|
|
208
|
+
).encode("utf-8")
|
|
209
|
+
return self.call_json(
|
|
210
|
+
"POST",
|
|
211
|
+
f"{UPLOAD}/files",
|
|
212
|
+
params={"uploadType": "multipart", "fields": FIELDS, "supportsAllDrives": "true"},
|
|
213
|
+
data=data,
|
|
214
|
+
content_type=f"multipart/related; boundary={boundary}",
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
def files(self, **params) -> list[dict]:
|
|
218
|
+
return self.call_json("GET", f"{API}/files", params=params).get("files", [])
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def connect(account: str | None = None) -> Drive:
|
|
222
|
+
"""Drive client for `account` (default: gcloud's active account)."""
|
|
223
|
+
account = account or active_account()
|
|
224
|
+
return Drive(gcloud_token(account), account)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
class Connections(dict):
|
|
228
|
+
"""One Drive client per account, created on demand."""
|
|
229
|
+
|
|
230
|
+
def __missing__(self, account: str | None) -> Drive: # None = active account
|
|
231
|
+
self[account] = connect(account)
|
|
232
|
+
return self[account]
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
GCLOUD_LOCATIONS = [ # common installs that may not be on PATH
|
|
236
|
+
"/opt/homebrew/share/google-cloud-sdk/bin/gcloud",
|
|
237
|
+
"/usr/local/share/google-cloud-sdk/bin/gcloud",
|
|
238
|
+
"~/google-cloud-sdk/bin/gcloud",
|
|
239
|
+
"/usr/lib/google-cloud-sdk/bin/gcloud",
|
|
240
|
+
]
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
@functools.cache
|
|
244
|
+
def gcloud_bin() -> str:
|
|
245
|
+
"""Path to gcloud, or exit with install instructions."""
|
|
246
|
+
found = shutil.which("gcloud")
|
|
247
|
+
if found:
|
|
248
|
+
return found
|
|
249
|
+
for loc in GCLOUD_LOCATIONS:
|
|
250
|
+
p = Path(loc).expanduser()
|
|
251
|
+
if p.is_file() and os.access(p, os.X_OK):
|
|
252
|
+
return str(p)
|
|
253
|
+
die(
|
|
254
|
+
"gcloud is not installed. gmdoc uses it to log in to Google.",
|
|
255
|
+
"Install it:",
|
|
256
|
+
" brew install --cask gcloud-cli",
|
|
257
|
+
" (other systems: https://cloud.google.com/sdk/docs/install)",
|
|
258
|
+
"Then log in:",
|
|
259
|
+
" gcloud auth login --enable-gdrive-access",
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def gcloud(*args: str, capture: bool = True) -> subprocess.CompletedProcess:
|
|
264
|
+
return subprocess.run([gcloud_bin(), *args], capture_output=capture, text=True)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def gcloud_accounts() -> list[dict]:
|
|
268
|
+
r = gcloud("auth", "list", "--format=json")
|
|
269
|
+
return json.loads(r.stdout or "[]") if r.returncode == 0 else []
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def active_account() -> str:
|
|
273
|
+
for a in gcloud_accounts():
|
|
274
|
+
if a.get("status") == "ACTIVE":
|
|
275
|
+
return a["account"]
|
|
276
|
+
die("no active gcloud account.", "Log in / pick one:", f" {PROG} account <email>")
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def gcloud_token(account: str) -> str:
|
|
280
|
+
"""Borrow an access token from `gcloud auth login --enable-gdrive-access`."""
|
|
281
|
+
r = gcloud("auth", "print-access-token", account)
|
|
282
|
+
if r.returncode != 0:
|
|
283
|
+
die(
|
|
284
|
+
f"{account} is not logged in to gcloud.",
|
|
285
|
+
"Log in:",
|
|
286
|
+
f" {LOGIN_HINT.format(account)}",
|
|
287
|
+
)
|
|
288
|
+
return r.stdout.strip()
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
# ------------------------------------------------------------ front matter
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def read(path: Path) -> tuple[dict, str]:
|
|
295
|
+
if not path.is_file():
|
|
296
|
+
die(f"no such file: {path}")
|
|
297
|
+
return parse(path.read_text(encoding="utf-8"))
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def parse(text: str) -> tuple[dict, str]:
|
|
301
|
+
m = FM_RE.match(text)
|
|
302
|
+
if not m:
|
|
303
|
+
return {}, text
|
|
304
|
+
fm = {}
|
|
305
|
+
for line in m.group(1).splitlines():
|
|
306
|
+
if ":" in line:
|
|
307
|
+
k, v = line.split(":", 1)
|
|
308
|
+
fm[k.strip()] = v.strip()
|
|
309
|
+
if "gmdoc_id" not in fm: # someone else's front matter; leave it in the body
|
|
310
|
+
return {}, text
|
|
311
|
+
return fm, text[m.end():]
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def render(fm: dict, body: str) -> str:
|
|
315
|
+
head = "\n".join(f"{k}: {v}" for k, v in fm.items())
|
|
316
|
+
return f"---\n{head}\n---\n\n{body.strip(chr(10))}\n"
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def digest(body: str) -> str:
|
|
320
|
+
return hashlib.sha256(body.strip().encode("utf-8")).hexdigest()[:16]
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def write_synced(path: Path, drive: Drive, info: dict, body: str) -> None:
|
|
324
|
+
fm = {
|
|
325
|
+
"gmdoc_id": info["id"],
|
|
326
|
+
"gmdoc_title": info["name"],
|
|
327
|
+
"gmdoc_modified": info["modifiedTime"],
|
|
328
|
+
"gmdoc_hash": digest(body),
|
|
329
|
+
}
|
|
330
|
+
if drive.account:
|
|
331
|
+
fm["gmdoc_account"] = drive.account
|
|
332
|
+
path.write_text(render(fm, body), encoding="utf-8")
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
# ------------------------------------------------------------------ helpers
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def die(msg: str, *hints: str) -> NoReturn:
|
|
339
|
+
"""Print an error and exit. Each hint goes on its own indented line after a blank line."""
|
|
340
|
+
text = f"{PROG}: {msg}"
|
|
341
|
+
if hints:
|
|
342
|
+
text += "\n\n" + "\n".join(f" {h}" if h else "" for h in hints)
|
|
343
|
+
print(text, file=sys.stderr)
|
|
344
|
+
sys.exit(1)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
ID_RE = re.compile(r"[A-Za-z0-9_-]{20,}")
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def doc_id_from(s: str) -> str:
|
|
351
|
+
"""Extract a Drive file id from an id or any Docs/Drive URL.
|
|
352
|
+
|
|
353
|
+
Handles .../d/<id>/edit?tab=t.0#heading=..., /u/1/d/<id>, /view, /copy,
|
|
354
|
+
open?id=<id>, uc?id=<id>&export=..., and ids with stray ?/#/quotes.
|
|
355
|
+
"""
|
|
356
|
+
from urllib.parse import parse_qs, urlsplit
|
|
357
|
+
|
|
358
|
+
s = s.strip().strip("'\"<>")
|
|
359
|
+
url = urlsplit(s)
|
|
360
|
+
parts = [p for p in url.path.split("/") if p]
|
|
361
|
+
if "d" in parts[:-1]: # .../d/<id>/...
|
|
362
|
+
cand = parts[parts.index("d") + 1]
|
|
363
|
+
else:
|
|
364
|
+
query = parse_qs(url.query) or parse_qs(url.fragment.lstrip("?"))
|
|
365
|
+
# ?id=<id>, else the last path segment (folders/<id>, or a bare id)
|
|
366
|
+
cand = (query.get("id") or [""])[0] or (parts[-1] if parts else "")
|
|
367
|
+
m = ID_RE.search(cand)
|
|
368
|
+
if not m:
|
|
369
|
+
die(f"can't find a doc id in {s!r}")
|
|
370
|
+
return m.group(0)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def slug(name: str) -> str:
|
|
374
|
+
return re.sub(r"[^A-Za-z0-9]+", "-", name).strip("-").lower() or "untitled"
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def looks_like_file(s: str) -> bool:
|
|
378
|
+
return s.endswith(".md") or Path(s).exists()
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def drive_ok(drive: Drive) -> str:
|
|
382
|
+
"""Return '' if the account can use Drive, else a short reason."""
|
|
383
|
+
try:
|
|
384
|
+
drive.call("GET", f"{API}/files", params={"pageSize": 1, "fields": "files(id)"}, raise_errors=True)
|
|
385
|
+
return ""
|
|
386
|
+
except HttpError as e:
|
|
387
|
+
return "no Drive access" if e.status in (401, 403) else f"error {e.status}"
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
# ----------------------------------------------------------------- commands
|
|
391
|
+
|
|
392
|
+
Force = Annotated[bool, Parameter(name=["--force", "-f"], negative="")]
|
|
393
|
+
Account = Annotated[str | None, Parameter(name=["--account", "-a"])]
|
|
394
|
+
|
|
395
|
+
app = App(name="gmdoc", help="Pull/push Google Docs as Markdown.", version_flags=[])
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
@app.command
|
|
399
|
+
def pull(
|
|
400
|
+
target: str, out: Path | None = None, *, account: Account = None, force: Force = False
|
|
401
|
+
) -> None:
|
|
402
|
+
"""Download a doc as markdown.
|
|
403
|
+
|
|
404
|
+
Parameters
|
|
405
|
+
----------
|
|
406
|
+
target
|
|
407
|
+
Doc id, doc URL, or an already-linked .md file.
|
|
408
|
+
out
|
|
409
|
+
Output file (default: the doc title, slugified, + .md).
|
|
410
|
+
account
|
|
411
|
+
Google account (default: the file's gmdoc_account, else gcloud's active account).
|
|
412
|
+
force
|
|
413
|
+
Overwrite local changes.
|
|
414
|
+
"""
|
|
415
|
+
path = None
|
|
416
|
+
if looks_like_file(target):
|
|
417
|
+
fm, _ = read(Path(target))
|
|
418
|
+
if "gmdoc_id" not in fm:
|
|
419
|
+
die(f"{target} has no gmdoc_id front matter.", "Pass a doc id or URL instead.")
|
|
420
|
+
doc_id = fm["gmdoc_id"]
|
|
421
|
+
account = account or fm.get("gmdoc_account")
|
|
422
|
+
path = out or Path(target)
|
|
423
|
+
else:
|
|
424
|
+
doc_id = doc_id_from(target)
|
|
425
|
+
|
|
426
|
+
drive = connect(account)
|
|
427
|
+
info = drive.meta(doc_id)
|
|
428
|
+
path = path or out or Path(f"{slug(info['name'])}.md")
|
|
429
|
+
|
|
430
|
+
if path.exists() and not force:
|
|
431
|
+
fm, body = read(path)
|
|
432
|
+
if fm.get("gmdoc_id") and fm["gmdoc_id"] != doc_id:
|
|
433
|
+
die(
|
|
434
|
+
f"{path} is linked to a different doc ({fm['gmdoc_id']}).",
|
|
435
|
+
"Use --force to overwrite it.",
|
|
436
|
+
)
|
|
437
|
+
if not fm or fm.get("gmdoc_hash") != digest(body):
|
|
438
|
+
die(
|
|
439
|
+
f"{path} has local changes that would be overwritten.",
|
|
440
|
+
"Push them first, or use --force to discard them.",
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
write_synced(path, drive, info, drive.export(doc_id))
|
|
444
|
+
print(f"pulled '{info['name']}' -> {path}")
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
@app.command
|
|
448
|
+
def push(
|
|
449
|
+
file: Path, doc: str | None = None, *, account: Account = None, force: Force = False
|
|
450
|
+
) -> None:
|
|
451
|
+
"""Replace a doc's content with a markdown file.
|
|
452
|
+
|
|
453
|
+
Parameters
|
|
454
|
+
----------
|
|
455
|
+
file
|
|
456
|
+
Linked .md file.
|
|
457
|
+
doc
|
|
458
|
+
Doc id/URL (default: from front matter).
|
|
459
|
+
account
|
|
460
|
+
Google account (default: the file's gmdoc_account, else gcloud's active account).
|
|
461
|
+
force
|
|
462
|
+
Overwrite remote changes.
|
|
463
|
+
"""
|
|
464
|
+
fm, body = read(file)
|
|
465
|
+
doc_id = doc_id_from(doc) if doc else fm.get("gmdoc_id")
|
|
466
|
+
if not doc_id:
|
|
467
|
+
die(
|
|
468
|
+
f"{file} has no gmdoc_id front matter.",
|
|
469
|
+
"Pass a doc id or URL, or create a new doc:",
|
|
470
|
+
f" {PROG} new {file}",
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
drive = connect(account or fm.get("gmdoc_account"))
|
|
474
|
+
info = drive.meta(doc_id)
|
|
475
|
+
if not force:
|
|
476
|
+
if fm.get("gmdoc_id") != doc_id:
|
|
477
|
+
die(
|
|
478
|
+
f"{file} was not pulled from this doc.",
|
|
479
|
+
"Pushing replaces the doc's whole body. Use --force to do it anyway.",
|
|
480
|
+
)
|
|
481
|
+
if fm.get("gmdoc_hash") == digest(body):
|
|
482
|
+
print("no local changes; nothing to push")
|
|
483
|
+
return
|
|
484
|
+
if digest(drive.export(doc_id)) != fm.get("gmdoc_hash"):
|
|
485
|
+
die(
|
|
486
|
+
f"'{info['name']}' was edited in Google Docs since your last sync.",
|
|
487
|
+
"Save your edits elsewhere and pull, or use --force to overwrite.",
|
|
488
|
+
)
|
|
489
|
+
|
|
490
|
+
info = drive.update(doc_id, body)
|
|
491
|
+
# Re-export so the local file matches Google's normalized markdown.
|
|
492
|
+
write_synced(file, drive, info, drive.export(doc_id))
|
|
493
|
+
print(f"pushed {file} -> '{info['name']}'")
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
@app.command
|
|
497
|
+
def new(
|
|
498
|
+
file: Path,
|
|
499
|
+
*,
|
|
500
|
+
title: Annotated[str | None, Parameter(name=["--title", "-t"])] = None,
|
|
501
|
+
folder: str | None = None,
|
|
502
|
+
account: Account = None,
|
|
503
|
+
force: Force = False,
|
|
504
|
+
) -> None:
|
|
505
|
+
"""Create a new doc from a markdown file.
|
|
506
|
+
|
|
507
|
+
Parameters
|
|
508
|
+
----------
|
|
509
|
+
file
|
|
510
|
+
Markdown file to upload.
|
|
511
|
+
title
|
|
512
|
+
Doc title (default: file name).
|
|
513
|
+
folder
|
|
514
|
+
Drive folder id/URL.
|
|
515
|
+
account
|
|
516
|
+
Google account that will own the doc (default: gcloud's active account).
|
|
517
|
+
force
|
|
518
|
+
Create even if the file is already linked to a doc.
|
|
519
|
+
"""
|
|
520
|
+
fm, body = read(file)
|
|
521
|
+
if fm.get("gmdoc_id") and not force:
|
|
522
|
+
die(
|
|
523
|
+
f"{file} is already linked to {fm['gmdoc_id']}.",
|
|
524
|
+
f"Use `{PROG} push`, or --force to create another doc.",
|
|
525
|
+
)
|
|
526
|
+
body_meta: dict[str, object] = {"name": title or file.stem, "mimeType": GDOC}
|
|
527
|
+
if folder:
|
|
528
|
+
body_meta["parents"] = [doc_id_from(folder)]
|
|
529
|
+
drive = connect(account)
|
|
530
|
+
info = drive.create(body_meta, body)
|
|
531
|
+
write_synced(file, drive, info, drive.export(info["id"]))
|
|
532
|
+
print(f"created '{info['name']}' https://docs.google.com/document/d/{info['id']}/edit")
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
@app.command
|
|
536
|
+
def status(*files: Path) -> None:
|
|
537
|
+
"""Show sync state (and account) of linked .md files.
|
|
538
|
+
|
|
539
|
+
Parameters
|
|
540
|
+
----------
|
|
541
|
+
files
|
|
542
|
+
Files to check (default: *.md in the current directory).
|
|
543
|
+
"""
|
|
544
|
+
drives = Connections()
|
|
545
|
+
for path in files or sorted(Path(".").glob("*.md")):
|
|
546
|
+
fm, body = read(path)
|
|
547
|
+
if "gmdoc_id" not in fm:
|
|
548
|
+
continue
|
|
549
|
+
drive = drives[fm.get("gmdoc_account")]
|
|
550
|
+
local = fm.get("gmdoc_hash") != digest(body)
|
|
551
|
+
remote = fm.get("gmdoc_hash") != digest(drive.export(fm["gmdoc_id"]))
|
|
552
|
+
state = {
|
|
553
|
+
(False, False): "in sync",
|
|
554
|
+
(True, False): "local changes (push)",
|
|
555
|
+
(False, True): "remote changes (pull)",
|
|
556
|
+
(True, True): "CONFLICT (both changed)",
|
|
557
|
+
}[(local, remote)]
|
|
558
|
+
who = f" [{drive.account}]" if drive.account else ""
|
|
559
|
+
print(f"{path}: {state}{who}")
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
@app.command
|
|
563
|
+
def ls(
|
|
564
|
+
query: str | None = None,
|
|
565
|
+
*,
|
|
566
|
+
limit: Annotated[int, Parameter(name=["--limit", "-n"])] = 20,
|
|
567
|
+
account: Account = None,
|
|
568
|
+
) -> None:
|
|
569
|
+
"""List recent Google Docs.
|
|
570
|
+
|
|
571
|
+
Parameters
|
|
572
|
+
----------
|
|
573
|
+
query
|
|
574
|
+
Filter by name.
|
|
575
|
+
limit
|
|
576
|
+
Max docs to show.
|
|
577
|
+
account
|
|
578
|
+
Google account (default: gcloud's active account).
|
|
579
|
+
"""
|
|
580
|
+
q = f"mimeType='{GDOC}' and trashed=false"
|
|
581
|
+
if query:
|
|
582
|
+
q += " and name contains '{}'".format(query.replace("'", "\\'"))
|
|
583
|
+
files = connect(account).files(
|
|
584
|
+
q=q,
|
|
585
|
+
orderBy="modifiedTime desc",
|
|
586
|
+
pageSize=limit,
|
|
587
|
+
fields="files(id,name,modifiedTime)",
|
|
588
|
+
includeItemsFromAllDrives="true",
|
|
589
|
+
supportsAllDrives="true",
|
|
590
|
+
)
|
|
591
|
+
for f in files:
|
|
592
|
+
print(f"{f['id']} {f['modifiedTime'][:10]} {f['name']}")
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
@app.command
|
|
596
|
+
def account(
|
|
597
|
+
email: str | None = None,
|
|
598
|
+
*,
|
|
599
|
+
login: Annotated[bool, Parameter(negative="")] = False,
|
|
600
|
+
) -> None:
|
|
601
|
+
"""List Google accounts and their Drive access, or switch the active one.
|
|
602
|
+
|
|
603
|
+
With EMAIL: switch gcloud's active account to it, logging in first
|
|
604
|
+
(with Drive access) if it isn't logged in yet.
|
|
605
|
+
|
|
606
|
+
Parameters
|
|
607
|
+
----------
|
|
608
|
+
email
|
|
609
|
+
Account to switch to.
|
|
610
|
+
login
|
|
611
|
+
Force a fresh browser login with Drive access (fixes "no Drive access").
|
|
612
|
+
"""
|
|
613
|
+
if email:
|
|
614
|
+
known = {a["account"] for a in gcloud_accounts()}
|
|
615
|
+
if login or email not in known:
|
|
616
|
+
# Interactive: opens a browser, so don't capture output.
|
|
617
|
+
r = gcloud("auth", "login", email, "--enable-gdrive-access", capture=False)
|
|
618
|
+
if r.returncode != 0:
|
|
619
|
+
die(f"login failed for {email}.")
|
|
620
|
+
r = gcloud("config", "set", "account", email)
|
|
621
|
+
if r.returncode != 0:
|
|
622
|
+
die(r.stderr.strip())
|
|
623
|
+
print(f"switched to {email}")
|
|
624
|
+
elif login:
|
|
625
|
+
die("--login needs an EMAIL")
|
|
626
|
+
|
|
627
|
+
accounts = gcloud_accounts()
|
|
628
|
+
if not accounts:
|
|
629
|
+
die("no gcloud accounts.", "Log in:", f" {PROG} account <email>")
|
|
630
|
+
width = max(len(a["account"]) for a in accounts)
|
|
631
|
+
broken = []
|
|
632
|
+
for a in accounts:
|
|
633
|
+
mark = "*" if a.get("status") == "ACTIVE" else " "
|
|
634
|
+
problem = drive_ok(connect(a["account"]))
|
|
635
|
+
if problem:
|
|
636
|
+
broken.append(a["account"])
|
|
637
|
+
print(f"{mark} {a['account']:<{width}} {problem or 'Drive ok'}")
|
|
638
|
+
if broken:
|
|
639
|
+
print("\nTo fix, log in again:")
|
|
640
|
+
for acct in broken:
|
|
641
|
+
print(f" {LOGIN_HINT.format(acct)}")
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def main() -> None:
|
|
645
|
+
try:
|
|
646
|
+
app()
|
|
647
|
+
except KeyboardInterrupt:
|
|
648
|
+
sys.exit(130)
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
if __name__ == "__main__":
|
|
652
|
+
main()
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "gmdoc"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Pull/push Google Docs as Markdown via the Drive API"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
authors = [
|
|
9
|
+
{ name = "boscoh", email = "apposite@gmail.com" }
|
|
10
|
+
]
|
|
11
|
+
requires-python = ">=3.12"
|
|
12
|
+
keywords = ["google-docs", "markdown", "drive", "cli", "docs", "gcloud"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 3 - Alpha",
|
|
15
|
+
"Environment :: Console",
|
|
16
|
+
"Intended Audience :: End Users/Desktop",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Operating System :: MacOS",
|
|
19
|
+
"Operating System :: POSIX :: Linux",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Programming Language :: Python :: 3.13",
|
|
23
|
+
"Topic :: Text Processing :: Markup :: Markdown",
|
|
24
|
+
"Typing :: Typed",
|
|
25
|
+
]
|
|
26
|
+
dependencies = [
|
|
27
|
+
"cyclopts>=4.10.0",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[project.urls]
|
|
31
|
+
Homepage = "https://github.com/boscoh/gmdoc"
|
|
32
|
+
Repository = "https://github.com/boscoh/gmdoc"
|
|
33
|
+
Issues = "https://github.com/boscoh/gmdoc/issues"
|
|
34
|
+
|
|
35
|
+
[project.scripts]
|
|
36
|
+
gmdoc = "gmdoc:main"
|
|
37
|
+
|
|
38
|
+
[build-system]
|
|
39
|
+
requires = ["hatchling"]
|
|
40
|
+
build-backend = "hatchling.build"
|
|
41
|
+
|
|
42
|
+
[tool.hatch.build.targets.wheel]
|
|
43
|
+
only-include = ["gmdoc.py"]
|
|
44
|
+
|
|
45
|
+
[tool.hatch.build.targets.sdist]
|
|
46
|
+
include = [
|
|
47
|
+
"/gmdoc.py",
|
|
48
|
+
"/pyproject.toml",
|
|
49
|
+
"/README.md",
|
|
50
|
+
"/LICENSE",
|
|
51
|
+
]
|