One of the smallest changes in the v0.4.0 release was one of the most intentional: save_typescript_client() now raises aValueError if you pass a relative path. The function used to silently join relative paths with os.getcwd(). That silent fallback was the source of a class of bugs that were hard to reproduce, harder to debug, and impossible to fully test.
The old behavior
Here’s what the function used to do:
def save_typescript_client(schemas, output_path=DEFAULT_OUTPUT):
content = generate_typescript_client(schemas)
if not os.path.isabs(output_path):
output_path = os.path.join(os.getcwd(), output_path) # silent CWD fallback
os.makedirs(os.path.dirname(output_path), exist_ok=True)
with open(output_path, "w", encoding="utf-8") as f:
f.write(content)At first glance, this seems helpful. The user passes a relative path, and the function resolves it against the current working directory. What could go wrong?
The bug: CWD is not stable
The os.getcwd() fallback assumes the caller’s CWD is the same as pyrpc’s CWD. This breaks in any of these scenarios:
- A library calls
save_typescript_client. The library’s CWD at import time is different from the caller’s CWD at call time. The relative path resolves to the wrong directory. - The caller changes CWD during execution. A common pattern with
os.chdirfor temporary directory work. The CWD at write time differs from the CWD at planning time. - Multiple threads or processes. Each thread can have a different CWD (on some platforms), or a parent process can change CWD before fork. The file ends up in an unpredictable location.
- Testing. Tests that mock
os.getcwdor run from a temporary directory get different file locations than production. The test passes, but the behavior is wrong.
The common thread: os.getcwd() is a global variable, not a parameter. A function that depends on a global for its core behavior is impure — its output changes based on invisible state. This makes it untestable, unpredictable, and inconsistent across call sites.
The new contract
The fix is to remove the default and reject relative paths:
def save_typescript_client(schemas, output_path):
if not os.path.isabs(output_path):
raise ValueError(
"save_typescript_client requires an absolute path"
)
content = generate_typescript_client(schemas)
os.makedirs(os.path.dirname(output_path), exist_ok=True)
with open(output_path, "w", encoding="utf-8") as f:
f.write(content)Three changes:
- No default value.
output_pathis a required argument. The oldDEFAULT_OUTPUTconstant exists only at the CLI level, not in the API. - Fail fast. The check happens before any file I/O. The error message is explicit: “requires an absolute path.”
- No CWD fallback. The caller is responsible for resolving paths before calling the function. This forces every call site to think about path resolution explicitly.
The CLI layer handles resolution
The codegen and dev commands now resolve paths before calling save_typescript_client:
# codegen command: DEFAULT_OUTPUT, save_typescript_client = _lazy_import_codegen() output = os.path.abspath(DEFAULT_OUTPUT) # ← explicit resolution at CLI level save_typescript_client(schemas, output) # dev command - regenerate(): _, save_typescript_client = _lazy_import_codegen() save_typescript_client(schemas, types_output) # ← types_output is already absolute
In the dev command, types_output is derived fromclient_root, which was resolved against the config file directory at startup. It’s always absolute by the time it reachessave_typescript_client. The resolution pipeline is:
client_root (from pyrpc.json, possibly relative) → _resolve_client_root(client_root, config_dir) → absolute → os.path.join(absolute_client_root, "node_modules/@pyrpc/types/src/index.ts") → types_output (absolute) → save_typescript_client(...)
Every path in this pipeline is absolute by the time it enters the function. There is no point where os.getcwd() is consulted. The config file directory is the universe of discourse.
What this means for API consumers
The save_typescript_client API is part of pyrpc-codegen, a separate package that can be installed independently. Users who call it directly (e.g., in CI scripts, build tools, or custom workflows) now have an explicit contract:
from pyrpc_codegen import save_typescript_client
import os
# This works:
save_typescript_client(schemas, "/tmp/types/index.ts")
# This also works (resolved explicitly by caller):
save_typescript_client(schemas, os.path.abspath("generated/types.ts"))
# This fails with a clear error:
save_typescript_client(schemas, "generated/types.ts")
# ValueError: save_typescript_client requires an absolute pathThe explicit error is better than a silent wrong-path bug. It tells the caller exactly what to fix. The error surfaces at development time, not in production when the file doesn’t exist where expected.
The pattern: fail fast on global state
os.getcwd() is not the only global that pyrpc eliminated.datetime.now() (for timestamping generated output) was also removed in favor of explicit parameters. The pattern is: a function should not depend on mutable global state for its core behavior. Configuration (like the output path) should be passed explicitly. Global state should only be used for display, logging, and non-functional concerns.
This is a deliberately strict design principle. It makes the API harder to use in the simplest cases (you have to type more characters for a relative path), but it makes all non-trivial cases correct by construction. The strictness is an investment in predictability — and for a tool whose output is a file that gets imported by TypeScript, predictability is the entire product.

pyRPC