Every Python web tool faces the same question eventually: where does the configuration live? The obvious answer — pyproject.toml, under [tool.pyrpc] — was pyrpc’s first answer. It lasted through three minor versions before we pulled it out into a dedicatedpyrpc.json file. This post explains why.
The original design: [tool.pyrpc]
When pyrpc needed its first project-level configuration (the entry point module and framework choice), pyproject.toml was the natural home. It’s the standard place for Python tool configuration. Black uses it. Ruff uses it. Pytest uses it. The pattern is well-understood.
# pyproject.toml [tool.pyrpc] framework = "fastapi" entry = "app.main:app"
The implementation was straightforward: use tomllib to parse the file, look under tool.pyrpc, and extract the values. Writing was more involved — parsing the TOML, finding the [tool.pyrpc]section, replacing it in-place, and serializing back to TOML format without a library (since Python’s tomllib is read-only).
This worked. But as pyrpc grew, three problems emerged that couldn’t be fixed within the pyproject.toml paradigm.
Problem 1: The config writer was fragile
Writing [tool.pyrpc] back to pyproject.toml required a hand-written TOML serializer. Python’s standard library hastomllib (parse only) and tomlkit (round-trip), but we didn’t want to add a dependency just for config writing. The custom serializer worked for simple string values, but it didn’t handle multiline strings, inline tables, or array values. Every edge case was a potential bug.
# The hand-written serializer:
config_lines = ["[tool.pyrpc]\n"]
for key, value in config.items():
if isinstance(value, str):
config_lines.append(f'{key} = "{value}"\n')
elif isinstance(value, bool):
config_lines.append(f"{key} = {'true' if value else 'false'}\n")
# Find [tool.pyrpc] in the file, replace section
start_idx = None
for i, line in enumerate(lines):
if line.strip().startswith("[tool.pyrpc]"):
start_idx = i
break
# ... fragile in-place replacementThis was the kind of code that works until it doesn’t. A user with comments in their [tool.pyrpc] section, or with non-standard formatting, would get corrupted config. We were one bug report away from a data-loss issue.
Problem 2: No clear file ownership
pyproject.toml belongs to the project build system. Other tools read from it. When pyrpc writes its config back, it touches every line of the file — re-serializing the entire [tool.pyrpc] section and potentially altering whitespace around it. A user running pyrpc dev --reconfigurecould inadvertently trigger a reformat of their pyproject.toml, causing a confusing diff in unrelated sections.
More subtly: if another tool also wrote to pyproject.toml between pyrpc’s read and write, the hand-written serializer would clobber those changes. There was no merge logic, no atomic write, no backup.
Problem 3: Path semantics were ambiguous
The biggest problem wasn’t technical — it was semantic. pyrpc needed a client_root field: a path to the TypeScript client project. Where is that path relative to? The config file? The current working directory? The project root?
In pyproject.toml, there’s no strong convention. Some tools resolve relative to pyproject.toml’s directory. Others useos.getcwd(). The ambiguity meant every path-using function had to document its own resolution strategy, and users had to guess which one applied.
The solution: pyrpc.json
A dedicated config file fixes all three problems:
{
"version": 1,
"framework": "fastapi",
"entrypoint": "app.main",
"client_root": "../frontend"
}Writing is trivial. json.dump(config, f, indent=2)is a single line. No hand-written serializer, no line-by-line parsing, no edge cases. JSON is lossless for the types pyrpc needs (strings, numbers, booleans).
File ownership is clear. pyrpc owns pyrpc.jsonentirely. No other tool reads it. No other tool writes to it. There’s zero risk of cross-tool clobbering. If the file is missing, pyrpc creates it. If it’s corrupted, pyrpc shows an error and prompts for re-setup.
Path semantics are unambiguous. All paths inpyrpc.json are resolved relative to the config file’s directory. Resolution happens once, at config load time, and produces absolute paths. Downstream code never sees a relative path. This is documented and enforced:
def _resolve_client_root(client_root: str, config_dir: str) -> str:
p = os.path.join(config_dir, client_root) \
if not os.path.isabs(client_root) \
else client_root
return os.path.normpath(p)Why JSON and not TOML or YAML?
JSON was chosen for three reasons:
- Zero dependencies.
jsonis in the Python standard library. TOML writing would requiretomlkitor a custom serializer (which is how we got into this mess). YAML would requirePyYAML. - Idempotent writes.
json.dumpwithindent=2produces deterministic output. Runningpyrpc dev --reconfigurewith the same values produces the same file. TOML writing libraries can reorder keys or reformat comments. - Universal readability. Every language can parse JSON without a special library. If we ever build the client-side
npx pyrpcCLI, it can readpyrpc.jsonwithout needing a TOML parser.
The version field
You might wonder why there’s a "version": 1 in the file. It’s future-proofing. If we ever change the config schema in a breaking way, the version field lets us detect old configs and (in theory) migrate them. For now, we always write version 1 and never read a file without it. It’s a header, not a feature — but it’s the kind of header that saves you from a painful migration two years from now.
What we lost
One thing: pyproject.toml is a well-known file. Developers know to look there for tool configuration. pyrpc.json is new and requires discovery. We mitigate this with the pyrpc dev setup wizard, which creates the file automatically on first run — most users never need to know the file exists.
The _find_pyrpc_json() function walks up the directory tree (like ESLint’s config resolution), so — likepyproject.toml — it works from any subdirectory:
def _find_pyrpc_json() -> Path | None:
path = Path.cwd()
for parent in [path] + list(path.parents):
candidate = parent / CONFIG_FILE
if candidate.is_file():
return candidate
return NoneThe bottom line
Config files are infrastructure, not product. They should be boring. A single-file JSON config for a single-purpose tool is the most boring, most reliable choice. pyproject.toml is the right answer for tools that integrate deeply into the Python build system. pyrpc is not one of those tools — it’s a cross-language RPC framework whose configuration concerns (framework, entrypoint, client path) have nothing to do with package building. A dedicated file is the honest architecture.

pyRPC