Chapter 5 of 8. Prerequisite: Chapter 4 — you should have a refresh token in Keychain and a
.envwith the OAuth client ID + secret. This chapter assumes both.
The two-script trap
The first version of every Google-API automation I have written looked like this:
project/
├── pull_gsc.py
└── pull_ga4.py
Two scripts. Each one duplicates the credential-loading code at the top, then does its one thing. This works until the third script. By the fourth script you have copy-pasted the auth wrapper four times, the cron-runner script does not know which python pull_X.py invocation it is supposed to call this morning, and any change to the OAuth scopes means editing every script.
The shape I am going to walk through replaces all of that with a single entrypoint that dispatches to subcommands. The subcommands share an authenticated session that gets minted once per CLI invocation, regardless of which subcommand you call. The cron line stays stable across new subcommands.
The shape
project/
├── telemetry/
│ ├── __init__.py
│ ├── __main__.py # entrypoint — argparse + dispatch
│ ├── auth.py # the shared credential wrapper
│ ├── gsc.py # GSC subcommands
│ └── ga4.py # GA4 subcommands
├── .env
└── requirements.txt
You run it as python -m telemetry <subcommand> [args]. The __main__.py is the single dispatch point. Each domain (GSC, GA4) gets its own module with its own subcommands registered against the shared parser.
The shared credential wrapper
telemetry/auth.py is the only place credentials ever get constructed. Everything else takes a Credentials instance as input. This is the single most important rule in the shape — it is what makes adding the fifth subcommand free instead of a credential-handling refactor.
# telemetry/auth.py
import os
import subprocess
import google.auth.transport.requests
from google.oauth2.credentials import Credentials
SCOPES = [
"https://www.googleapis.com/auth/webmasters.readonly",
"https://www.googleapis.com/auth/analytics.readonly",
]
KEYCHAIN_SERVICE = "gsc-telemetry:captain_random:refresh_token"
def _read_refresh_token() -> str:
"""Shell out to macOS `security` to read the refresh token from Keychain.
See [chapter 4](/learning/gsc-ga4-automation/oauth-refresh-tokens-keychain/)
for the storage rationale. Uses Python's `subprocess.run` with the
`args` list form so the service name is never shell-interpreted."""
result = subprocess.run(
[
"security",
"find-generic-password",
"-s", KEYCHAIN_SERVICE,
"-a", os.environ["USER"],
"-w",
],
capture_output=True,
text=True,
check=True,
)
return result.stdout.strip()
def build_credentials() -> Credentials:
"""Construct a Credentials object the google-api-python-client can use.
Per the google-auth docs, the Credentials object refreshes its own
access token transparently when the current one expires — provided
refresh_token, client_id, and client_secret are all set."""
creds = Credentials(
token=None,
refresh_token=_read_refresh_token(),
token_uri="https://oauth2.googleapis.com/token",
client_id=os.environ["GOOGLE_OAUTH_CLIENT_ID"],
client_secret=os.environ["GOOGLE_OAUTH_CLIENT_SECRET"],
scopes=SCOPES,
)
creds.refresh(google.auth.transport.requests.Request())
return creds
Two things worth flagging here:
- The
Credentialsconstructor takestoken=Nonebecause we are starting fresh — we have a refresh token but not yet a live access token. The.refresh()call mints the access token. Per the google-auth docs, this exchange usesclient_id+client_secretplus the refresh token, which is why all four fields are required. - The
SCOPESlist must match exactly what was authorised at consent time. Adding a scope here without re-running consent will cause every subsequent API call to fail with a scope error. If you need a new scope, run the--reauthsubcommand (we will define one in a moment) before retrying.
The entrypoint
telemetry/__main__.py is the file python -m telemetry runs. It loads the environment, configures argparse, and dispatches to subcommand modules. Keep it small — the moment it grows logic of its own, you start needing tests for it.
# telemetry/__main__.py
import argparse
import sys
from dotenv import load_dotenv
from . import auth, ga4, gsc
load_dotenv()
def build_parser() -> argparse.ArgumentParser:
"""Top-level parser; each subcommand module registers its own."""
parser = argparse.ArgumentParser(
prog="telemetry",
description="GSC + GA4 telemetry CLI",
)
subparsers = parser.add_subparsers(dest="cmd", required=True)
gsc.register(subparsers)
ga4.register(subparsers)
# Reauth is owned by the auth module.
reauth = subparsers.add_parser("reauth", help="Re-run OAuth consent flow")
reauth.set_defaults(func=_handle_reauth)
return parser
def _handle_reauth(_args: argparse.Namespace) -> int:
"""Wipe Keychain entry + walk consent again. Detail in chapter 4."""
return auth.run_consent_flow()
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
if args.cmd in ("reauth",):
return args.func(args)
# Every data subcommand gets the same authenticated credentials.
creds = auth.build_credentials()
return args.func(args, creds)
if __name__ == "__main__":
sys.exit(main())
The pattern that does the work is args.func(args, creds). Each subcommand registers itself with set_defaults(func=...), then receives the args namespace and the credentials object. The entrypoint never has to know what the subcommands do — it just dispatches.
Per Python's argparse docs, the set_defaults(func=...) pattern is documented and stable, and the subcommand modules can grow independently.
A subcommand module
Here is the shape of telemetry/gsc.py. The GSC-specific query implementations come in chapter 6 — for now, see how the registration plugs into the parser.
# telemetry/gsc.py
import argparse
from google.oauth2.credentials import Credentials
from googleapiclient.discovery import build
def register(subparsers: argparse._SubParsersAction) -> None:
"""Hook GSC subcommands onto the top-level parser."""
p = subparsers.add_parser("gsc", help="Google Search Console queries")
inner = p.add_subparsers(dest="gsc_cmd", required=True)
index = inner.add_parser("indexation", help="Pull index-coverage buckets")
index.add_argument("--property", required=True, help="GSC property URL")
index.set_defaults(func=cmd_indexation)
queries = inner.add_parser("queries", help="Pull top search queries")
queries.add_argument("--property", required=True)
queries.add_argument("--days", type=int, default=28)
queries.set_defaults(func=cmd_queries)
def cmd_indexation(args: argparse.Namespace, creds: Credentials) -> int:
"""Detail in chapter 6."""
service = build("searchconsole", "v1", credentials=creds)
... # see chapter 6
return 0
def cmd_queries(args: argparse.Namespace, creds: Credentials) -> int:
"""Detail in chapter 6."""
service = build("searchconsole", "v1", credentials=creds)
... # see chapter 6
return 0
The build("searchconsole", "v1", credentials=creds) call comes from the google-api-python-client library and returns a service object whose methods mirror the API surface. Same shape for GA4 in telemetry/ga4.py — different service name (analyticsdata, v1beta) but identical structure.
Why argparse and not click / typer
You could absolutely use click or typer for this. Both are excellent. The reason I default to argparse for telemetry CLIs is that it ships in the Python standard library, which means cron environments and quick-deploy contexts (a fresh laptop, a CI runner, a colleague trying the same thing) do not need a pip install step before the CLI works.
For a CLI that you are going to publish for other people to install, click is friendlier. For a CLI that lives in your ~/.claude/skills or ~/bin directory and runs from a cron, argparse is fine.
Running it
With the shape in place:
# Daily indexation pull
python -m telemetry gsc indexation --property "https://example.com/"
# 28-day top queries
python -m telemetry gsc queries --property "https://example.com/"
# GA4 events for the last 7 days
python -m telemetry ga4 events --property 123456789 --days 7
Chapter 8 covers wrapping each of those into a daily cron and sending the output through a Telegram bot. Chapters 6 and 7 cover the body of cmd_indexation, cmd_queries, and the GA4 equivalents.
What's next
Chapter 6 fills in cmd_indexation and cmd_queries — the actual GSC API calls. Chapter 7 does the same for GA4. By the end of chapter 7 you will have a working CLI; chapter 8 is about wiring it into a daily cron and surfacing the output through Telegram so that you read the daily digest instead of asking "what changed yesterday?" by clicking around the UI.
Refactor your own scripts into the dispatch shape
Find two or more standalone scripts in one of your own projects that duplicate setup at the top, credential loading, an API client or a database handle, and restructure them into a single python -m package: one entrypoint, one shared wrapper constructed once per run, and one module per domain registering its own subcommands.
Expected behaviour
- A package runnable as python -m yourpackage with argparse subcommands and required dispatch
- Shared setup constructed in exactly one module, with every subcommand receiving it as an argument
- Each domain module owning a register function that hooks its subcommands onto the top-level parser
- The old duplicated setup blocks deleted from every former script
- Adding a stub subcommand touches only its domain module, with no logic added to the entrypoint
PROVE IT Add a new stub subcommand on a branch and show the git diff: the change lives inside one domain module while the entrypoint stays untouched.
Why does the chapter default to argparse rather than click or typer for this CLI?
Which argparse method attaches a handler function to a subcommand so the entrypoint can dispatch through args.func?
Why does the chapter call the rule about auth.py the single most important rule in the shape?
Show answer
auth.py is the only place credentials are ever constructed; everything else takes a Credentials instance as input. That is what makes adding the fifth subcommand free instead of a credential-handling refactor, and it means a scope or storage change is an edit to one file rather than to every script.
↺ re-read: “The shared credential wrapper”