One of the most deceptively hard problems in pyrpc’s design was path resolution. The question sounds simple: “When a user setsclient_root: "../frontend" in their config file, where exactly is that path relative to?” The answer determines whetherpyrpc dev works from a project subdirectory, whether monorepo setups are ergonomic, and whether the generated types end up in the right place.
The default trap
The naive approach is to resolve relative to os.getcwd(). This is what most Python tools do. It works fine when the user runs the tool from the project root. But pyrpc users often work from subdirectories:
project/
server/
main.py
pyrpc.json # client_root: "../frontend"
frontend/
src/
client.ts
node_modules/
@pyrpc/types/src/index.ts
# User runs from server/:
cd project/server
pyrpc dev
# With os.getcwd() resolution, "../frontend" → project/server/../frontend
# → project/frontend ✓ (works by accident in this case)
# With config-relative resolution, "../frontend" → project/server/../frontend
# → project/frontend ✓ (same result)In this case both approaches work. The difference appears when the config file is in a different location than expected, or when the user runs pyrpc from outside the project.
Why os.getcwd() is wrong
Consider a monorepo with pyrpc.json at the root, and the user runs cd server && pyrpc dev:
monorepo/
pyrpc.json # client_root: "frontend"
server/
main.py
frontend/
node_modules/
@pyrpc/types/src/index.ts
# User runs from server/:
cd monorepo/server
pyrpc dev
# With os.getcwd() resolution: "frontend" → monorepo/server/frontend âœ-
# (nowhere near the actual frontend project)
# With config-relative resolution: "frontend" → monorepo/frontend ✓
# (from pyrpc.json's directory, which is monorepo/)The _find_pyrpc_json() function walks up from CWD to find the config file, so it finds monorepo/pyrpc.json. But the path resolution still depends on whether we use the config file’s directory or the CWD. Using the config file’s directory is the only correct answer — it makes the config file a self-contained unit whose meaning doesn’t change based on where the user happens to be standing.
The resolution pipeline
pyrpc’s path resolution happens in a strict pipeline, once, at config load time:
1. Find pyrpc.json (walk up from CWD)
2. Determine config_dir = os.path.dirname(pyrpc.json)
3. For each path in config:
if os.path.isabs(path): use as-is
else: os.path.join(config_dir, path)
4. Normalize (os.path.normpath)
5. Pass absolute paths everywhere downstreamThis means step 5 is critical: no downstream code ever receives a relative path. Once config is loaded, every path is absolute. Thetypes_output path is derived from client_root at load time and stored as an absolute path:
new_client_root = _resolve_client_root(
client_root_raw, config_dir
)
new_types_output = os.path.join(
new_client_root,
"node_modules/@pyrpc/types/src/index.ts"
)
# Both are absolute before any function sees themThe save_typescript_client() enforcement
To make sure no caller accidentally passes a relative path, the codegen API itself rejects relative paths at the boundary:
def save_typescript_client(schemas, output_path):
if not os.path.isabs(output_path):
raise ValueError(
"save_typescript_client requires an absolute path"
)
# ... write fileThis is a “fail fast” contract. The old code silently joined relative paths with os.getcwd(), which was a time bomb — it worked by accident depending on CWD, then broke when called from a different context. The explicit error makes the contract visible and forces every caller to think about path resolution.
The types output convention
The types output path isn’t stored in pyrpc.json. It’s derived from client_root by convention:
{client_root}/node_modules/@pyrpc/types/src/index.tsWhy hardcode this path instead of making it configurable? Two reasons:
- The monorepo convention. This is where npm installs the
@pyrpc/typespackage. The types file lives inside the installed package, which meansimport type { Types } from "@pyrpc/types"works without any path configuration in TypeScript’stsconfig.jsonpaths or import aliases. - One fewer config knob. Every configurable path is a decision the user has to make. By making the convention rigid, we eliminate a class of “where did my types go?” bugs. Users who need a custom location can use
pyrpc codegen --outputfor one-off generation.
Two things we got right from the start
First, os.path.normpath is applied to every resolved path. This eliminates .. segments and normalizes separators — critical for cross-platform correctness and for comparing paths in the migration logic."../frontend" and "../frontend/" and"../frontend/./" all normalize to the same string.
Second, absolute paths in the config file pass through unchanged. If a user sets "client_root": "C:/Projects/frontend", it’s used as-is. The config-relative resolution only applies to relative paths. This means users in CI environments can hardcode absolute paths without worrying about the config file location.
The principle
Path resolution follows one rule: a config file is a self-contained unit. All paths in it are meaningful relative to the file itself, not to the user’s terminal location. This is the same rule that Docker follows (docker-compose.yml paths are relative to the compose file), that Webpack follows (context defaults to the config directory), and that Terraform follows (module paths are relative to the.tf file). It’s a well-established convention in tools that have config files — and it’s the convention pyrpc now follows.

pyRPC