Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,17 @@ jobs:
tests:
uses: djachenko/repokit/.github/workflows/python-tests.yml@0.9
secrets: inherit

style:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v6

- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.x"

- name: No ternaries
run: python scripts/no_ternary.py src tests
7 changes: 7 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,11 @@ goodoc/
├── CLAUDE.md
├── install.sh # полная установка: pipx + workflow + shell.sh
├── pyproject.toml
├── docs/
│ └── oauth-client.md # как получить credentials.json (с нуля и для существующего клиента)
├── scripts/
│ └── no_ternary.py # AST-проверка на тернарники, job style в CI
├── tests/ # pytest: conftest с фикстурами, файл на модуль + test_integration
├── workflow/ # шаблон Automator Quick Action (копируется install.sh)
│ └── Contents/
│ ├── document.wflow
Expand Down Expand Up @@ -101,6 +106,8 @@ Scope: `https://www.googleapis.com/auth/drive.file` — доступ тольк

Если Drive API не включён в Cloud Console проекте — включить в APIs & Services → Library.

Пошаговый гайд по созданию клиента — [docs/oauth-client.md](docs/oauth-client.md). Отдельно разобран случай существующего клиента: секрет повторно не скачивается, нужен Client secrets → Add secret.

---

## Automator Quick Action
Expand Down
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,11 +50,18 @@ pipx install git+https://github.com/djachenko/goodoc.git

## First run

goodoc talks to Google Drive through your own Google Cloud project. On the first run, a setup wizard starts automatically:
goodoc talks to Google Drive through your own Google Cloud project.

Google gates Drive access behind an OAuth client, and shipping a single shared client for everybody requires app verification — a domain, a privacy policy, a review. Until that happens, each user creates their own client. It's a one-time step.

On the first run, a setup wizard starts automatically:

1. **Google Cloud credentials** — opens the browser, walks you through creating an OAuth client, prompts for the downloaded JSON file
— step by step, including the case of a client that already exists: [docs/oauth-client.md](docs/oauth-client.md)
2. **Authorization** — opens the browser for Google sign-in, saves the token

Google shows an "unverified app" warning during authorization. It refers to the client you just created — proceed past it.

After that, every run is silent.

There is also a shared client maintained by the author, with limited capacity. If you have an access key, skip the wizard entirely:
Expand Down
61 changes: 61 additions & 0 deletions docs/oauth-client.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# Getting `credentials.json`

goodoc needs an OAuth client of type **Desktop app** from a Google Cloud project you
control. The wizard asks for the JSON file Google gives you for that client.

---

## From scratch

1. [Create a project](https://console.cloud.google.com/projectcreate) — any name, no
organization needed
2. Enable the [Google Drive API](https://console.cloud.google.com/apis/library/drive.googleapis.com)
→ **Enable**
3. Fill in [Branding](https://console.cloud.google.com/auth/branding):
app name, your email as support and developer contact
4. Set [Audience](https://console.cloud.google.com/auth/audience) to **External**,
add your own account under **Test users**
5. Press **Publish app** on the same page — in testing mode Google expires the refresh
token after 7 days
6. Create the client in [Credentials](https://console.cloud.google.com/apis/credentials):
**+ Create Credentials → OAuth client ID**, application type **Desktop app**
7. Download the JSON from the dialog shown right after creation — it lands in
`~/Downloads/client_secret_*.json`

Check the project picker in the top bar before each step — the console likes to switch
projects on you.

**Publishing is not verification.** It needs no review, no approval, no domain, no privacy
policy — the switch takes effect immediately. Google only requires verification for
sensitive and restricted scopes; goodoc asks for `drive.file`, which is neither. It grants
access to files the app itself created, and nothing else in your Drive.

---

## Existing client

Google no longer lets you view or download the secret of a client after creation, so the
[Credentials](https://console.cloud.google.com/apis/credentials) list has no download
button — only ✏️ and 🗑. Add a second secret instead: the client ID stays the same, so
tokens already issued keep working.

1. Open the client from [Clients](https://console.cloud.google.com/auth/clients) —
click its name; type must be **Desktop**
2. **Client secrets → Add secret**
3. Download the JSON from that dialog — the secret is shown once
4. Check that the [Google Drive API](https://console.cloud.google.com/apis/library/drive.googleapis.com)
is enabled in this project
5. Check [Audience](https://console.cloud.google.com/auth/audience) — if it still says
Testing, press **Publish app**
6. Run goodoc; once it works, disable and delete the old secret in
[Clients](https://console.cloud.google.com/auth/clients)

Creating a fresh Desktop client (steps 6–7 above) is equally fine — old tokens are then
invalid, clear them with `goodoc logout`.

---

## Where it goes

The wizard copies the file to `~/.config/goodoc/credentials.json`. The token it obtains
afterwards is stored next to it as `token.json`.
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ goodoc = "goodoc.main:app"
[tool.mypy]
ignore_missing_imports = true

[tool.ruff.lint.isort]
known-first-party = ["conftest", "goodoc"]

[tool.ruff.lint.per-file-ignores]
# B008: typer.Argument/Option in defaults is the intended Typer pattern
"src/goodoc/main.py" = ["B008"]
Expand Down
41 changes: 41 additions & 0 deletions scripts/no_ternary.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
#!/usr/bin/env python3
"""Fail the build on ternary conditional expressions."""

import ast
import sys
from pathlib import Path

DEFAULT_ROOTS = ["src", "tests"]


def find_ternaries(path: Path) -> list[ast.IfExp]:
tree = ast.parse(path.read_text(), filename=str(path))

return [node for node in ast.walk(tree) if isinstance(node, ast.IfExp)]


def main() -> int:
roots = sys.argv[1:]

if not roots:
roots = DEFAULT_ROOTS

found = 0

for root in roots:
for path in sorted(Path(root).rglob("*.py")):
for node in find_ternaries(path):
print(f"{path}:{node.lineno}:{node.col_offset + 1}: ternary conditional expression")

found += 1

if not found:
return 0

print(f"\n{found} ternary expression(s) found — use an if/else block instead.", file=sys.stderr)

return 1


if __name__ == "__main__":
sys.exit(main())
50 changes: 35 additions & 15 deletions src/goodoc/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,35 +53,55 @@ def _acquire_credentials(self) -> None:
typer.echo(" 4. Create OAuth credentials:")
typer.echo(" Credentials → + Create Credentials → OAuth client ID")
typer.echo(" Application type: Desktop app")
typer.echo(" 5. Download the JSON file")
typer.echo(" 5. Download the JSON file from the dialog shown right after creation")
typer.echo()
typer.echo("Client already exists? Its secret can no longer be downloaded —")
typer.echo("open the client and use Client secrets -> Add secret, then download that JSON.")
typer.echo("Details: https://github.com/djachenko/goodoc/blob/master/docs/oauth-client.md")
typer.echo()

typer.echo("Opening Google Cloud Console in browser...")
webbrowser.open("https://console.cloud.google.com/apis/credentials")
typer.echo()

downloads = Path.home() / "Downloads"
suggestions = sorted(downloads.glob("client_secret_*.json"), key=lambda p: p.stat().st_mtime, reverse=True)
while True:
if suggestion := self._latest_download():
hint = f" [{suggestion}]"
else:
hint = ""

hint = f" [{suggestions[0]}]" if suggestions else ""
raw = typer.prompt(f"Path to downloaded credentials JSON{hint}", default="", show_default=False).strip()

raw = typer.prompt(f"Path to downloaded credentials JSON{hint}").strip()
src: Path | None

if raw:
src = Path(raw).expanduser()
elif suggestions:
src = suggestions[0]
else:
typer.echo("No path provided.", err=True)
if raw:
src = Path(raw).expanduser()
else:
src = suggestion

raise typer.Exit(1)
if src is None:
continue

if not src.exists():
typer.echo(f"File not found: {src}", err=True)
if not src.exists():
typer.echo(f"File not found: {src}", err=True)

raise typer.Exit(1)
continue

break

self._config.goodoc_dir.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, self._config.credentials_path)

typer.echo(f"Saved to {self._config.credentials_path}")

def _latest_download(self) -> Path | None:
downloads = sorted(
(Path.home() / "Downloads").glob("client_secret_*.json"),
key=lambda p: p.stat().st_mtime,
reverse=True,
)

if not downloads:
return None

return downloads[0]
30 changes: 20 additions & 10 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,64 +1,74 @@
from collections.abc import Callable
from pathlib import Path
from unittest.mock import MagicMock

import pytest
from pytest import MonkeyPatch
from typer.testing import CliRunner

FileTree = dict[str, "FileTree | str | None"]
CreateFiles = Callable[[Path, FileTree], None]


@pytest.fixture
def create_files():
def _create(root: Path, structure: dict):
def create_files() -> CreateFiles:
def _create(root: Path, structure: FileTree) -> None:
for key, value in structure.items():
path = root / key

if value is None:
path.touch()
elif isinstance(value, str):
path.write_text(value)
elif isinstance(value, dict):
path.mkdir(parents=True, exist_ok=True)

_create(path, value)

return _create


@pytest.fixture
def runner():
def runner() -> CliRunner:
return CliRunner()


@pytest.fixture
def mock_creds():
def mock_creds() -> MagicMock:
return MagicMock()


@pytest.fixture
def docx_file(tmp_path, create_files):
create_files(tmp_path, {"doc.docx": None})
def docx_file(tmp_path: Path, create_files: CreateFiles) -> Path:
create_files(tmp_path, {
"doc.docx": None,
})
return tmp_path / "doc.docx"


@pytest.fixture
def mock_get_credentials(monkeypatch):
def mock_get_credentials(monkeypatch: MonkeyPatch) -> MagicMock:
mock = MagicMock(return_value=MagicMock())
monkeypatch.setattr("goodoc.auth.Auth.get_credentials", mock)
return mock


@pytest.fixture
def mock_upload(monkeypatch):
def mock_upload(monkeypatch: MonkeyPatch) -> MagicMock:
mock = MagicMock(return_value="https://docs.google.com/doc")
monkeypatch.setattr("goodoc.drive.Drive.upload", mock)
return mock


@pytest.fixture
def mock_browser(monkeypatch):
def mock_browser(monkeypatch: MonkeyPatch) -> MagicMock:
mock = MagicMock()
monkeypatch.setattr("goodoc.app.webbrowser.open", mock)
return mock


@pytest.fixture
def mock_drive_build(monkeypatch):
def mock_drive_build(monkeypatch: MonkeyPatch) -> MagicMock:
mock_service = MagicMock()
mock_service.files.return_value.create.return_value.execute.return_value = {
"webViewLink": "https://docs.google.com/doc"
Expand Down
Loading
Loading