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
9 changes: 5 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,12 +51,13 @@ goodoc/
└── src/
└── goodoc/
├── __init__.py
├── main.py # CLI: upload (по умолчанию) / login / logout
├── main.py # CLI: тонкий слой Typer, делегирует в App
├── app.py # App — точка сборки, бизнес-логика команд
├── config.py # пути и scopes
├── auth.py # Auth — получение и обновление токена
├── auth.py # Auth(config, setup) — токен, OAuth flow
├── client.py # встроенный OAuth-клиент автора + хеш ключа доступа
├── drive.py # загрузка в Drive
└── setup.py # визард первого запуска (свой Cloud-проект)
├── drive.py # Drive(auth) — загрузка в Google Drive
└── setup.py # Setup(config) — визард первого запуска
```

---
Expand Down
19 changes: 16 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ description = "Upload office files to Google Drive with auto-conversion to Docs/
readme = "README.md"
license = "MIT"
license-files = ["LICENSE"]
authors = [{ name = "Igor Djachenko" }]
authors = [
{ name = "Igor Djachenko" }
]
requires-python = ">=3.11"
classifiers = [
"Development Status :: 3 - Alpha",
Expand All @@ -30,8 +32,15 @@ dependencies = [
]

[project.optional-dependencies]
test = ["pytest", "ruff==0.15.16", "mypy"]
release = ["build", "python-semantic-release"]
test = [
"pytest",
"ruff >= 0.16, < 0.17",
"mypy"
]
release = [
"build",
"python-semantic-release"
]

[tool.setuptools.packages.find]
where = ["src"]
Expand All @@ -47,6 +56,10 @@ goodoc = "goodoc.main:app"
[tool.mypy]
ignore_missing_imports = true

[tool.ruff.lint.per-file-ignores]
# B008: typer.Argument/Option in defaults is the intended Typer pattern
"src/goodoc/main.py" = ["B008"]

[tool.semantic_release]
version_toml = ["pyproject.toml:project.version"]
branch = "master"
Expand Down
64 changes: 64 additions & 0 deletions src/goodoc/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import webbrowser
from pathlib import Path

import typer

from goodoc.auth import Auth
from goodoc.config import Config
from goodoc.drive import MIME_MAP, Drive


class App:
def __init__(self, config: Config, auth: Auth, drive: Drive) -> None:
self._config = config
self._auth = auth
self._drive = drive

def upload(self, files: list[Path], open_browser: bool) -> None:
for file in files:
if error_message := self.validate(file):
typer.echo(error_message, err=True)

raise typer.Exit(1)

for file in files:
typer.echo(f"Uploading {file.name}...")

url = self._drive.upload(file)
typer.echo(url)

if open_browser:
webbrowser.open(url)

def login(self, key: str | None = None) -> None:
if key is None:
self._auth.get_credentials()
else:
try:
self._auth.login_shared(key)
except ValueError as error:
typer.echo(str(error), err=True)

raise typer.Exit(1)

typer.echo("Logged in.")

def logout(self) -> None:
if self._config.token_path.exists():
self._config.token_path.unlink()

typer.echo("Logged out.")
else:
typer.echo("Not logged in.")

@staticmethod
def validate(file: Path) -> str | None:
if not file.exists():
return f"File not found: {file}"

if file.suffix.lower() not in MIME_MAP:
supported = ", ".join(MIME_MAP)

return f"Unsupported format: {file.suffix}. Supported: {supported}"

return None
40 changes: 20 additions & 20 deletions src/goodoc/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,49 +6,49 @@

from goodoc.client import ACCESS_KEY_HASH
from goodoc.config import Config
from goodoc.setup import authorize_shared, first_run_wizard
from goodoc.setup import Setup


class Auth:
@staticmethod
def get_credentials(config: Config) -> Credentials:
def __init__(self, config: Config, setup: Setup) -> None:
self._config = config
self._setup = setup

def get_credentials(self) -> Credentials:
creds = None

if config.token_path.exists():
creds = Credentials.from_authorized_user_file(str(config.token_path), config.scopes)
if self._config.token_path.exists():
creds = Credentials.from_authorized_user_file(str(self._config.token_path), self._config.scopes)

if not creds or not creds.valid:
if creds and creds.expired and creds.refresh_token:
creds.refresh(Request())
else:
creds = Auth._authorize(config)
creds = self._authorize()

Auth._save(config, creds)
self._save(creds)

return creds

@staticmethod
def login_shared(config: Config, access_key: str) -> Credentials:
def login_shared(self, access_key: str) -> Credentials:
if hashlib.sha256(access_key.encode()).hexdigest() != ACCESS_KEY_HASH:
raise ValueError("Invalid access key.")

creds = authorize_shared(config, access_key)
Auth._save(config, creds)
creds = self._setup.authorize_shared(access_key)
self._save(creds)

return creds

@staticmethod
def _authorize(config: Config) -> Credentials:
if config.credentials_path.exists():
flow = InstalledAppFlow.from_client_secrets_file(str(config.credentials_path), config.scopes)
def _authorize(self) -> Credentials:
if self._config.credentials_path.exists():
flow = InstalledAppFlow.from_client_secrets_file(str(self._config.credentials_path), self._config.scopes)

return flow.run_local_server(port=0)

return first_run_wizard(config)
return self._setup.first_run_wizard()

@staticmethod
def _save(config: Config, creds: Credentials) -> None:
config.token_path.parent.mkdir(parents=True, exist_ok=True)
def _save(self, creds: Credentials) -> None:
self._config.token_path.parent.mkdir(parents=True, exist_ok=True)

with config.token_path.open("w") as f:
with self._config.token_path.open("w") as f:
f.write(creds.to_json())
2 changes: 1 addition & 1 deletion src/goodoc/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from dataclasses import dataclass
from functools import cached_property
from pathlib import Path
from typing import Self, ClassVar
from typing import ClassVar, Self


@dataclass(frozen=True)
Expand Down
50 changes: 28 additions & 22 deletions src/goodoc/drive.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
from pathlib import Path

import typer
from google.oauth2.credentials import Credentials
from googleapiclient.discovery import build
from googleapiclient.http import MediaFileUpload

from goodoc.auth import Auth

MIME_MAP = {
".doc": (
"application/msword",
Expand Down Expand Up @@ -37,31 +38,36 @@
}


def upload(path: Path, creds: Credentials) -> str:
suffix = path.suffix.lower()
class Drive:
def __init__(self, auth: Auth) -> None:
self._auth = auth

def upload(self, path: Path) -> str:
suffix = path.suffix.lower()

if suffix not in MIME_MAP:
supported = ", ".join(MIME_MAP)
typer.echo(f"Unsupported format: {suffix}. Supported: {supported}", err=True)
if suffix not in MIME_MAP:
supported = ", ".join(MIME_MAP)
typer.echo(f"Unsupported format: {suffix}. Supported: {supported}", err=True)

raise typer.Exit(1)
raise typer.Exit(1)

source_mime, target_mime = MIME_MAP[suffix]
source_mime, target_mime = MIME_MAP[suffix]
creds = self._auth.get_credentials()

service = build("drive", "v3", credentials=creds)
media = MediaFileUpload(str(path), mimetype=source_mime, resumable=False)
service = build("drive", "v3", credentials=creds)
media = MediaFileUpload(str(path), mimetype=source_mime, resumable=False)

result = (
service.files()
.create(
body={
"name": path.stem,
"mimeType": target_mime,
},
media_body=media,
fields="id,webViewLink",
result = (
service.files()
.create(
body={
"name": path.stem,
"mimeType": target_mime,
},
media_body=media,
fields="id,webViewLink",
)
.execute()
)
.execute()
)

return result["webViewLink"]
return result["webViewLink"]
66 changes: 16 additions & 50 deletions src/goodoc/main.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import webbrowser
from pathlib import Path
from typing import Any

import typer
from typer.core import TyperGroup

from goodoc.app import App
from goodoc.auth import Auth
from goodoc.config import Config
from goodoc.drive import MIME_MAP, upload
from goodoc.drive import MIME_MAP, Drive
from goodoc.setup import Setup

DEFAULT_COMMAND = "upload"

Expand All @@ -20,76 +21,41 @@ def parse_args(self, ctx: Any, args: list[str]) -> list[str]:
return super().parse_args(ctx, args)


app = typer.Typer(cls=DefaultCommandGroup, no_args_is_help=True)

def _create_app() -> App:
config = Config.default()
setup = Setup(config)
auth = Auth(config, setup)
drive = Drive(auth)
local_app = App(config, auth, drive)

def validate_file(file: Path) -> str | None:
if not file.exists():
return f"File not found: {file}"
return local_app

if file.suffix.lower() not in MIME_MAP:
supported = ", ".join(MIME_MAP)
return f"Unsupported format: {file.suffix}. Supported: {supported}"

return None
app = typer.Typer(cls=DefaultCommandGroup, no_args_is_help=True)
_app = _create_app()


@app.command(DEFAULT_COMMAND)
def upload_files(
def upload(
files: list[Path] = typer.Argument(..., help=f"Paths to files ({' / '.join(MIME_MAP)})"),
no_open: bool = typer.Option(False, "--no-open", help="Do not open in browser"),
) -> None:
"""Upload office files to Google Drive and open them in the browser."""
config = Config.default()

for file in files:
if error := validate_file(file):
typer.echo(error, err=True)

raise typer.Exit(1)

creds = Auth.get_credentials(config)

for file in files:
typer.echo(f"Uploading {file.name}...")

url = upload(file, creds)
typer.echo(url)

if not no_open:
webbrowser.open(url)
_app.upload(files, open_browser=not no_open)


@app.command()
def login(
key: str | None = typer.Option(None, "--key", help="Access key for the author's shared client"),
) -> None:
"""Authenticate with Google (without uploading a file)."""
config = Config.default()

if key is None:
Auth.get_credentials(config)
else:
try:
Auth.login_shared(config, key)
except ValueError as error:
typer.echo(str(error), err=True)

raise typer.Exit(1)

typer.echo("Logged in.")
_app.login(key)


@app.command()
def logout() -> None:
"""Remove stored token (re-authentication will be required on next run)."""
config = Config.default()

if config.token_path.exists():
config.token_path.unlink()
typer.echo("Logged out.")
else:
typer.echo("Not logged in.")
_app.logout()


if __name__ == "__main__":
Expand Down
Loading
Loading