flaplint is a static analyser for Juju charms. It reads your charm's source code and flags every place a value that has no stable byte-order — a set, a glob, a uuid4(), … — reaches a churn-sensitive write: a relation databag, an on-disk file, a pebble plan, or a content-hash change-detector.
Charms talk through relation databags. Juju has one rule that makes ordering matter:
Juju notifies the other side only when the text of a databag value changes.
It compares bytes, not meaning. So if your charm serialises a set (or a dict/list built from a set, a glob, os.listdir, set algebra, …) without sorting, the bytes come out in a different order each hook:
["alice", "bob"] (reconcile 1)
["bob", "alice"] (reconcile 2)Juju sees a "change", wakes the remote charm with relation-changed, it rewrites its databag in a new order, wakes you back… and two charms ping-pong forever. The fix is almost always one word:
json.dumps(sorted(datasources, key=lambda d: d["uid"])) # ← sorted()The same problem appears everywhere a charm writes something that's compared byte-for-byte to detect change: a rendered config file, a pebble plan, a content hash. All four are sinks flaplint checks.
flowchart LR
A[value built from a set / glob /<br/>relation.units / set algebra] --> B{sorted<br/>before writing?}
B -- no --> C[bytes differ reconcile-to-reconcile]
C --> D[Juju sees a 'change']
D --> E[fires relation-changed]
E --> F[reconcile ping-pong]
B -- yes --> G[stable bytes ✓]
The bug is a latent property of the code: a value's order is unstable whether or not a test ever exercises that relation. Reading the source means every code path is in scope, including the branches and error handlers a test suite never drives.
The trade-off is honesty about uncertainty. When where a value really came from can't be traced, the tool says so: it ranks findings by confidence and leaves the final call to you.
flaplint groups instability into four families. Each needs a different fix.
config = {u.name: u.app.name for u in relation.units} # dict — key order not fixed
databag["config"] = json.dumps(config) # key order varies each reconcileFix: json.dumps(config, sort_keys=True) — or yaml.dump(config).
A key-sorting serializer works here because the entire disorder is in the dict's key order. Locking keys alphabetically makes the output identical every reconcile.
The critical difference from #1: once you convert a set to a list, the disorder moves from key order into element positions — and key-sorting is blind to element positions.
addrs = list(self.endpoints) # set → list: element order baked in
databag["addrs"] = json.dumps(addrs) # even sort_keys=True can't fix list orderFix: list(sorted(self.endpoints)) — sort before materialising the list.
This is the most easily missed case. json.dumps(addrs, sort_keys=True) sorts the dict keys but does nothing to the order of items inside a list. yaml.dump has the same blind spot. So a list built from an unordered source keeps flapping even through a key-sorting serializer.
The same pattern appears as a join: ",".join(some_set) bakes the set's order into a string, which no serializer can then reorder.
databag["nonce"] = str(uuid4()) # different on every reconcile — sorting can't helpFix: Derive it deterministically, or persist it once and reuse it.
def publish(self, relation, items): # items: no type hint
relation.data[self.app]["peers"] = json.dumps(items) # writes items unsortedflaplint flags this as a helper that trusts its caller to pass ordered data. If you call publish(relation, set_of_peers) elsewhere, it emits a higher-confidence finding at the call site.
flaplint isn't on PyPI — run it straight from the repo with uvx, which
fetches, builds and caches it in one step (no clone, no venv to manage):
# latest on the default branch
uvx --from git+https://github.com/michaeldmitry/flaplint flaplint /path/to/my-charm/src
# pin a released version for a reproducible CI gate
uvx --from git+https://github.com/michaeldmitry/flaplint@v1.0.0 flaplint /path/to/my-charm/srcOr, working inside a clone, install it editable so a flaplint command lands on
your PATH:
uv pip install -e . # or: pip install -e .# the common case: scan a charm's src/ (its sibling lib/ is auto-included).
# Dependencies are resolved automatically: flaplint finds the charm's own
# environment (a sibling .venv's bin/python, else its site-packages) and traces
# the deps that write relation data — no flags needed.
flaplint /path/to/my-charm/src
# scan a checked-out library source tree as well, and report on it
flaplint my-charm/src --dep ../cos-lib/src
# fast, own-code-only run (skip dependency resolution) — e.g. a CI gate
flaplint my-charm/src --no-deps
# point at a specific environment instead of the auto-picked sibling .venv
flaplint my-charm/src --python /path/to/other/.venv/bin/python
# trace dependencies but don't report findings inside them (resolution only)
flaplint my-charm/src --no-report-deps
# CI gate: only surface the confident findings, machine-readable
flaplint my-charm/src --min-confidence high --json
# list writes flaplint could see but couldn't fully trace (a worklist for manual review)
flaplint my-charm/src --explain-gapsAlso importable as a library:
from flaplint import analyze_paths
findings = analyze_paths(["my-charm/src"], min_confidence="high")
for f in findings:
print(f.format())Exit code is 1 when any finding survives the confidence threshold, 0 when clean.
| Flag | Meaning |
|---|---|
paths… |
charm source files or directories to scan and report on |
--dep PATH |
extra source root to analyse and report on (e.g. a vendored lib) |
--venv PATH |
virtualenv / site-packages to trace into for call resolution only |
--python PATH |
resolve the charm's deps through a specific interpreter's import system (e.g. a uv sync .venv's bin/python); namespace-package-aware, installs nothing. A sibling .venv's bin/python is auto-picked by default — pass this only to override |
--no-deps |
disable automatic dependency resolution; scan only the charm's own src/ and sibling lib/ (fast, own-code-only — e.g. a CI gate, or when no venv is present) |
--no-report-deps |
trace installed dependencies for call resolution but do not report findings inside them (default: dependency findings are shown, as non-blocking warnings; a charm's own vendored lib/ is always reported) |
--min-confidence {low,medium,high} |
reporting threshold (default medium) |
--sort {criticality,location} |
finding order: most important first (yours before dependencies, higher confidence first), or by file location (default criticality) |
--format {pretty,concise,json} |
output style: grouped colour report (pretty, default), one-line-per-finding for editors/grep (concise), or machine json |
--json |
alias for --format json |
--explain-gaps |
also list blind spots: writes whose content flaplint couldn't fully trace (an unresolved library call, a value-object field, an untraced parameter). Not findings, never fail the run — a worklist of where a missed flap could hide |
Colour in
prettymode is emitted only to a terminal; it auto-disables when piped or in CI, and honours theNO_COLOR/FORCE_COLORconventions.
For how automatic resolution,
--venvand--pythondiscover vendored vs. installed libraries, see Resolving charm dependencies.
If a list's order is genuinely meaningful, annotate it:
relation.data[self.app]["priorities"] = json.dumps(items) # databag-order: ignoreflaplint marks values where they're created as unstable, follows them forward through assignments and function calls, and reports a problem when they reach one of four write targets while still unstable.
flowchart LR
S["starts as set · glob · uuid4 · …"] --> P["followed through assignments,<br/>calls, returns"]
P --> K{still unstable<br/>when written?}
K -- yes --> F["finding"]
K -- "no (sorted / laundered)" --> OK["stable ✓"]
The four write targets:
| sink | why it matters |
|---|---|
| databag | relation.data[app][key] = … — byte differences trigger spurious relation-changed |
| file | container.push(path, …) — a reshuffled config forces unnecessary rerenders/restarts |
| pebble plan | container.add_layer(…) — a changed plan triggers replan() and a workload restart |
| hash | sha256(content) — a different hash fires the change-gate every reconcile |
For the full story — every pattern it catches, how each serializer treats them, what gets followed through function calls, and where the analysis has blind spots — see docs/.
By default flaplint prints a grouped, colourised report:
flaplint
src/charm.py
✖ 142:9 unordered collection · peers → databag · high confidence
`peers` is an unordered collection written to the relation databag.
Fix at the source: the instability is created upstream in `_collect()`
(src/utils.py:20).
✖ 51:13 nondeterministic value · uuid4 → databag · high confidence
`uuid4` is freshly generated each time this code runs, so the value written
differs from last time (sorting cannot fix it).
lib/charms/grafana_k8s/v0/grafana_dashboard.py
▲ 1359:9 nondeterministic value · stored_data → databag · high confidence
`stored_data` is freshly generated each time this code runs …
────────────────────────────────────────────────────────
✖ 3 flap risk(s) 2 yours 1 in dependencies · 11 file(s) scanned
Every finding carries two independent axes, spelled out so neither is mistaken for the other:
| Axis | What it answers | How it shows |
|---|---|---|
| ownership | whose job is the fix? | the ✖ yours / ▲ in a dependency mark, the footer legend, and the total (2 yours) tally |
| confidence | how sure is flaplint it's a real flap? | high / medium / low, in the header tail |
They're orthogonal: a ▲ finding can be high confidence (a real flap, but in a library you don't own) and a ✖ can be medium. Each finding's header line reads mark line:col <failure mode> · <variable> → <sink> · <confidence>; then the indented plain-English why and fix, with a Fix at the source … trail when the value is born elsewhere.
line:col— where to fix it (forunordered-pickthis is the pick itself, not the blameless serialiser)· <variable>— the affected identifier to look at (peers,scheduler_addrs,uuid4)→ <sink>— where the value lands:databag,file,plan,hash, orrender
For editors, grep, or scripts, --format concise emits one flat line per finding (and --json emits the structured records). The two axes are separate greppable fields — owner=yours|dependency and confidence=high|medium|low:
src/charm.py:142:9: owner=yours type=unordered-collection confidence=high sink=databag var=peers
src/coordinator.py:229:42: owner=yours type=unordered-pick confidence=high sink=file var=scheduler_addrs
src/charm.py:51:13: owner=yours type=nondeterministic confidence=high sink=databag var=uuid4
lib/charms/grafana_k8s/v0/grafana_dashboard.py:1359:9: owner=dependency type=nondeterministic confidence=high sink=databag var=stored_data
type= |
what went wrong | the fix |
|---|---|---|
unordered-collection |
a whole set/dict serialised unsorted |
sorted(...) / sort_keys=True |
unordered-pick |
one item chosen by position (addrs[0]) |
sorted(addrs)[0] |
unordered-iteration |
a list built from something unordered (list(some_set) / [… for … in …]) |
sorted(...) before the list |
nondeterministic |
a different-every-run value (uuid4()/time()) |
make it stable/persistent |
kind=caller means the bug is right here in your code. kind=sink means a helper function that writes a parameter unsorted — it trusts its caller to pass ordered data. See sinks-and-findings.md for the full grading rules.
The blocking axis is about responsibility, not severity (that's what confidence is for). A ✖ yours finding is code the charm owns — its src/ or its own lib/charms/<charm-name>/ namespace; these fail the run. A ▲ in a dependency finding lives in a library the charm only uses: shown for awareness, but never fails the run — you can't fix someone else's library here. A dependency finding can still be high confidence; it just isn't yours to fix.