rabbit-brain 0.6.0__py3-none-win_amd64.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,33 @@
1
+ """Rabbit Brain: the record of the experiments your coding agents run, kept as plain files in your repo.
2
+
3
+ Experiments with variants and criteria, runs with receipts, and the datasets and checkpoints they used, recorded as YAML
4
+ in `.rb/` beside the code. Agents propose and record; rb checks sources and computes findings; you decide. CLI: `rb`.
5
+ Documentation: `rb docs` (AGENTS.md).
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ from typing import Any
11
+
12
+ __version__ = "0.6.0"
13
+ if os.environ.get("RB_TEST_VERSION"): # the tests pin the version, as RB_TEST_NOW pins the clock, so what they expect outlives a release
14
+ __version__ = os.environ["RB_TEST_VERSION"]
15
+
16
+ from .errors import RBError, RBWarning # noqa: E402
17
+
18
+ _LAZY = {
19
+ "open": ".sdk", "init": ".sdk", "clone": ".sdk", "State": ".sdk", "Experiment": ".sdk", "Criterion": ".sdk", "Run": ".sdk",
20
+ "Spec": ".sdk", "DryRun": ".sdk",
21
+ }
22
+
23
+ __all__ = ["__version__", "RBError", "RBWarning", *_LAZY]
24
+
25
+
26
+ def __getattr__(name: str) -> Any:
27
+ """Loaded on first use, so `import rabbit_brain` (and every `rb` command) does not pay for what it does not touch."""
28
+ if name in _LAZY:
29
+ import importlib
30
+ value = getattr(importlib.import_module(_LAZY[name], __name__), name)
31
+ globals()[name] = value
32
+ return value
33
+ raise AttributeError(f"module 'rabbit_brain' has no attribute {name!r}")
rabbit_brain/_rb.py ADDED
@@ -0,0 +1,180 @@
1
+ """Calling rb from its Python package. The SDK and the terminal UI hold no rules of their own: every read and every
2
+ write is asked of rb, through `rb bridge` (one JSON request, one JSON answer), so the Python surface and the command
3
+ line can never disagree about what the record says.
4
+
5
+ Which rb: RB_BIN when set, else the `rb` installed with this package (the scripts directory of this Python).
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ import subprocess
12
+ import sys
13
+ import sysconfig
14
+ import warnings
15
+ from dataclasses import dataclass
16
+ from typing import Any, Optional
17
+
18
+ from . import __version__
19
+ from .errors import EXIT_ENVIRONMENT, RBError, RBWarning
20
+
21
+ UNSET: Any = object()
22
+ # pinned: unpinned, pip installs the newest final release, which can be another version than this one
23
+ REINSTALL = f'pip install --force-reinstall "rabbit-brain=={__version__}"'
24
+
25
+
26
+ def _quiet() -> dict:
27
+ """Windows: a Python with no console window (an editor's, a notebook's) starts rb without one too, so no window
28
+ flashes up for each question. In a terminal rb shares the terminal, as everywhere else."""
29
+ if sys.platform != "win32":
30
+ return {}
31
+ try:
32
+ import ctypes
33
+ if ctypes.windll.kernel32.GetConsoleWindow():
34
+ return {}
35
+ except Exception: # noqa: BLE001 - no way to ask: start rb without a window, which is right in either case
36
+ pass
37
+ return {"creationflags": subprocess.CREATE_NO_WINDOW}
38
+
39
+
40
+ QUIET = _quiet()
41
+
42
+
43
+ def binary() -> Optional[str]:
44
+ """The rb binary this package calls, or None when there is none to find."""
45
+ given = os.environ.get("RB_BIN")
46
+ if given:
47
+ return given
48
+ exe = "rb" + (sysconfig.get_config_var("EXE") or "")
49
+ places = []
50
+ try:
51
+ places.append(sysconfig.get_path("scripts"))
52
+ places.append(sysconfig.get_path("scripts", scheme=sysconfig.get_preferred_scheme("user")))
53
+ except Exception: # noqa: BLE001 - an unusual Python layout only means fewer places to look
54
+ pass
55
+ places.append(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "bin")) # pip install --target
56
+ for d in places:
57
+ if d and os.path.isfile(os.path.join(d, exe)):
58
+ return os.path.join(d, exe)
59
+ return None
60
+
61
+
62
+ def found() -> str:
63
+ """The rb this package calls. When there is none, that is said once, with what to do."""
64
+ rb = binary()
65
+ if rb is None:
66
+ raise RBError("E_INTERNAL", message="This Python package found no rb beside it: the SDK and the screen ask rb for everything.",
67
+ fix=f"Install them together: {REINSTALL}. Or set RB_BIN to the rb binary.",
68
+ exit_code=EXIT_ENVIRONMENT)
69
+ return rb
70
+
71
+
72
+ def _jsonable(v: Any) -> Any:
73
+ """What json does not take as it is: a path, a numpy or torch scalar, a set."""
74
+ if isinstance(v, os.PathLike):
75
+ return os.fspath(v)
76
+ if hasattr(v, "item") and callable(v.item):
77
+ try:
78
+ return v.item()
79
+ except Exception: # noqa: BLE001 - an array is not a scalar: said below
80
+ pass
81
+ if isinstance(v, (set, frozenset)):
82
+ return sorted(v)
83
+ raise RBError("E_OBJECT_INVALID", message=f"rb records text, numbers, true and false, lists and mappings (got {type(v).__name__}).")
84
+
85
+
86
+ def command() -> list:
87
+ """The command line that runs rb, for what the screen runs as the user would type it."""
88
+ return [found()]
89
+
90
+
91
+ def environment() -> dict:
92
+ """The environment rb runs in: this one, plus what only this process knows about the Python doing the work."""
93
+ env = dict(os.environ)
94
+ if sys.executable:
95
+ env["RB_PYTHON"] = sys.executable # a run's receipt fingerprints the Python that made it
96
+ torch = sys.modules.get("torch")
97
+ cuda = getattr(getattr(torch, "version", None), "cuda", None) if torch is not None else None
98
+ if cuda:
99
+ env["RB_CUDA"] = str(cuda) # CUDA exists only on an imported torch: said by the process that has it
100
+ else:
101
+ env.pop("RB_CUDA", None)
102
+ return env
103
+
104
+
105
+ def _answer(line: str) -> dict:
106
+ rb = found()
107
+ try:
108
+ r = subprocess.run([rb, "bridge"], input=(line + "\n").encode("utf-8"), stdout=subprocess.PIPE, stderr=subprocess.PIPE,
109
+ env=environment(), **QUIET)
110
+ except OSError as exc:
111
+ raise RBError("E_INTERNAL", message=f"rb could not be started ({rb}): {exc}", exit_code=EXIT_ENVIRONMENT)
112
+ out = r.stdout.decode("utf-8", "replace").strip()
113
+ try:
114
+ answer = json.loads(out.splitlines()[-1]) if out else None
115
+ except ValueError:
116
+ answer = None
117
+ if not isinstance(answer, dict) or "ok" not in answer:
118
+ tail = (r.stderr.decode("utf-8", "replace").strip().splitlines() or [out[:200] or f"exit {r.returncode}"])[-1]
119
+ raise RBError("E_INTERNAL", message=f"rb did not answer ({rb}): {tail}", exit_code=EXIT_ENVIRONMENT,
120
+ fix=f"This Python package and the rb it found may be different versions: {REINSTALL}.")
121
+ return answer
122
+
123
+
124
+ @dataclass
125
+ class DryRun:
126
+ """A write asked with dry_run=True: rb made every check the write makes and wrote nothing. `value` is what the write
127
+ would give back; `would_write` what it would write, as `rb <command> --dry-run --json` gives it in data.would_write:
128
+ each record file with its content (`files`), each file it would remove (`removed`), each line it would add to the
129
+ log (`log`). Ids and times are drawn again when the write is made."""
130
+ value: Any
131
+ would_write: dict
132
+
133
+
134
+ def call(op: str, args: Optional[dict] = None, *, root: Any = None, via: str = "sdk", agent: Optional[str] = None,
135
+ words: Any = UNSET, dry_run: bool = False, here: Any = None, place: Optional[str] = None, at: Optional[str] = None) -> Any:
136
+ """Ask rb for one thing. Returns its value, or with `dry_run` a DryRun; raises RBError with what rb said when it
137
+ refuses. `here` is the checkout the caller works in (a linked worktree nested in the record's), as op open gave it.
138
+ `place` and `at` ask what a screen reads of another place or a past version, by the id and commit op places lists:
139
+ read there, never written."""
140
+ req: dict = {"op": op, "root": os.fspath(root) if root is not None else None, "via": via, "agent": agent, "args": args or {}}
141
+ if here is not None:
142
+ req["here"] = os.fspath(here)
143
+ if place is not None:
144
+ req["place"] = place
145
+ if at is not None:
146
+ req["at"] = at
147
+ if words is not UNSET:
148
+ req["words"] = words
149
+ if dry_run:
150
+ req["dry_run"] = True
151
+ answer = _answer(json.dumps(req, default=_jsonable))
152
+ if answer["ok"]:
153
+ _said(answer.get("warnings"))
154
+ if dry_run:
155
+ return DryRun(answer.get("value"), answer.get("would_write") or {})
156
+ return answer.get("value")
157
+ e = dict(answer.get("error") or {})
158
+ raise RBError(e.pop("code", "E_INTERNAL"), message=e.pop("message", None), fix=e.pop("fix", None),
159
+ exit_code=e.pop("exit_code", 2), problems=list(e.pop("problems", []) or []), extra=e)
160
+
161
+
162
+ def _said(texts: Any) -> None:
163
+ """What rb says after a write it made (`data.warnings` on the command line), as an RBWarning at the line of code
164
+ that asked for the write: the first one outside this package."""
165
+ if not texts:
166
+ return
167
+ here = os.path.dirname(os.path.abspath(__file__))
168
+ frame, level = sys._getframe(1), 2
169
+ while frame is not None and os.path.dirname(os.path.abspath(frame.f_code.co_filename)) == here:
170
+ frame, level = frame.f_back, level + 1
171
+ for text in texts:
172
+ warnings.warn(str(text), RBWarning, stacklevel=level)
173
+
174
+
175
+ def check_version(answered: Any) -> None:
176
+ """An opening answer says which rb gave it. A different version beside this package means a broken install."""
177
+ theirs = answered.get("rb") if isinstance(answered, dict) else None
178
+ if theirs is not None and theirs != __version__:
179
+ raise RBError("E_INTERNAL", message=f"rb {theirs} answered, and this Python package is rabbit-brain {__version__} ({binary()}).",
180
+ fix=f"Reinstall so the two match: {REINSTALL}.", exit_code=EXIT_ENVIRONMENT)
rabbit_brain/errors.py ADDED
@@ -0,0 +1,71 @@
1
+ """Error codes: one table feeds `errors[]` in the JSON envelope, `rb docs`, and the tests.
2
+
3
+ Exit codes: 0 ok · 1 a check failed (rb status --fail-on, rb spec verify, rb fmt --check, rb doctor) · 2 rb could not do what was asked, and says why
4
+ · 3 a fault in rb itself, or an install that cannot start it.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import dataclass, field
9
+
10
+ EXIT_OK = 0
11
+ EXIT_CHECK_FAILED = 1
12
+ EXIT_INVALID = 2
13
+ EXIT_ENVIRONMENT = 3
14
+
15
+ # code -> (meaning, what to do)
16
+ ERRORS: dict[str, tuple[str, str]] = {
17
+ "E_FILE_NOT_FOUND": ("A file given on the command line does not exist, or could not be read.", "Check the path; it is relative to the current directory."),
18
+ "E_NOT_AVAILABLE": ("This command, or the hosted service it needs, is not available in this version of rabbit-brain.", "The message says what to use instead. `rb --help` lists every command in this version."),
19
+ "E_WRITE_FAILED": ("A file could not be written.", "The message names the file and says why; check that its directory is one you can write to."),
20
+ "E_NO_PROJECT": ("No record here: no .rb/ in this directory or any directory above it.", "Run `rb init \"<the work's theme>\"` in the project root (it creates .rb/), or cd into a project that has one. In a project already under way, `rb docs --start` says how to record the work done."),
21
+ "E_PROJECT_EXISTS": ("A project already exists here or in a directory above, or in another worktree of this repository.", "Use it: `rb status`. One .rb/ per line of work; start another in a different directory."),
22
+ "E_YAML": ("The file is not Rabbit Brain YAML.", "The message says where and what to write instead. Rabbit Brain YAML is plain YAML without anchors, tags, dates or yes/no; `rb fmt <file>` rewrites a file that reads in the canonical form."),
23
+ "E_OBJECT_NOT_FOUND": ("No record with that id in this project.", "`rb status --json` lists every record; `rb show <experiment>` lists its variants, settings, criteria and runs; a setting is addressed as <experiment>/<name>."),
24
+ "E_OBJECT_INVALID": ("The values given do not make a valid record.", "Do what the message says; `rb <command> --help` lists the arguments and `rb schema <kind>` prints a record's shape (experiment, criterion, run, ...)."),
25
+ "E_FROZEN": ("The experiment is frozen, and this would change what it locked: a setting, a variant, what it varies, or a criterion.", "Run the same command with --amend --why \"<reason>\"; the change, the reason, who made it and the hash before and after are kept."),
26
+ "E_USAGE": ("The command line could not be parsed: an unknown option, a missing argument, or a value that is not allowed.", "`rb <command> --help` lists the arguments. A negative number is written as it is (--at-most -0.001); text that starts with '-' goes after --."),
27
+ "E_ACTOR_INVALID": ("RB_ACTOR is not in the form human:<name> or agent:<name>.", "Set it to human:<name> or agent:<name> (letters, digits and _.@+-), or unset it: rb then detects the agent runtime, or names the human from git."),
28
+ "E_SOURCE_UNRESOLVED": ("The source was read and does not state the value, or could not be read.", "The setting stays provisional. Check the path, key, line, quote or commit, or set the value the source actually states: `rb spec set <exp> <name> <value> --source <file#key>`."),
29
+ "E_SOURCE_OUTSIDE": ("The source is outside the project, so nobody else can check it.", "Copy it into the repository, commit it, and point --source at the copy."),
30
+ "E_SOURCE_UNVERIFIABLE": ("This source cannot be checked by reading a file: a url, a note, or a file source without a line or a quote.", "Point at a config key (file#key), a file line, a quote in a saved copy of the page, or a run's JSON (run:PATH#/pointer) that states the value; or someone vouches for it: `rb decide <exp>/<name> accept --why \"...\"`."),
31
+ "E_STATE_EDITED": ("A record under .rb/ was changed outside rb (its content edited, its file moved or deleted, or written by hand), and rb will not write over it. Comments and formatting are not changes.", "`rb doctor` lists every such file. `rb doctor --restore` puts back what rb last wrote; if the change is right, `rb doctor --adopt --why \"...\"` takes it as it stands, recorded as yours."),
32
+ "E_STATE_CORRUPT": ("A file under .rb/ is not a valid record.", "It was probably edited by hand or left mid-merge. `rb doctor` lists every such file and says where it breaks; `rb doctor --restore` puts back what rb last wrote."),
33
+ "E_STATE_NEWER": ("Part of this .rb/ was written by a newer rb than this one.", "Upgrade: `pip install --upgrade rabbit-brain`. Until then rb will not guess at state it cannot read, so gates fail closed with this reason."),
34
+ "E_BUNDLE_INVALID": ("The bundle is not a valid rb export, or its files do not match its manifest.", "Get a fresh copy from where it was published; every file must hash to what manifest.json records. rb export --with-record writes one."),
35
+ "E_HUB_REFUSED": ("The hub refused the request.", "The message carries the hub's reason and fix; the record on this machine is unchanged."),
36
+ "E_HUB_UNREACHABLE": ("The hub could not be reached, or what answered at its address is not the hub.", "Check the address (--hub, or RB_HUB_URL) and the network; nothing local was affected."),
37
+ "E_ADOPT_AUTHORITY": ("The edit changes who wrote or decided something, or through what, which an adoption must never launder into the record.", "`rb doctor --restore` puts the file back. If the authorship or decision is really wrong, make the change through rb as yourself, where it is logged as yours."),
38
+ "E_STALE_REVIEW": ("The files changed between the review and the adoption, so what was reviewed is not what would be adopted.", "Look again: `rb doctor` lists every file changed outside rb as it is now."),
39
+ "E_INTERNAL": ("A fault in rb itself, or an install that cannot run a part of it.", "Report it with the link its fix line gives (report_url under --json)."),
40
+ }
41
+
42
+
43
+ class RBWarning(UserWarning):
44
+ """What rb says after a write that went through, for the writer to act on: the SDK's `data.warnings`. A run's input
45
+ whose copy here is not the bytes it names gets one, and so do numbers that differ from the run already recorded with
46
+ the same output files, and an agent's new text that holds LaTeX."""
47
+
48
+
49
+ @dataclass
50
+ class RBError(Exception):
51
+ code: str
52
+ message: str | None = None
53
+ fix: str | None = None
54
+ exit_code: int = EXIT_INVALID
55
+ problems: list[str] = field(default_factory=list)
56
+ extra: dict = field(default_factory=dict) # structured detail for agents
57
+
58
+ def __post_init__(self) -> None:
59
+ meaning, default_fix = ERRORS.get(self.code, ("Unexpected failure.", ERRORS["E_INTERNAL"][1]))
60
+ if self.message is None:
61
+ self.message = meaning
62
+ if self.fix is None:
63
+ self.fix = default_fix
64
+ super().__init__(self.message)
65
+
66
+ def to_dict(self) -> dict:
67
+ d = {"code": self.code, "message": self.message, "fix": self.fix}
68
+ if self.problems:
69
+ d["problems"] = list(self.problems)
70
+ d.update(self.extra)
71
+ return d
rabbit_brain/models.py ADDED
@@ -0,0 +1,33 @@
1
+ """The envelope every command prints with --json, and the registry `rb schema` prints from."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Optional
5
+
6
+ from pydantic import BaseModel, ConfigDict, Field
7
+
8
+
9
+ class ErrorItem(BaseModel):
10
+ model_config = ConfigDict(extra="allow")
11
+ code: str
12
+ message: str
13
+ fix: Optional[str] = None
14
+
15
+
16
+ class Envelope(BaseModel):
17
+ """What every command prints with --json: exactly one object on stdout."""
18
+ model_config = ConfigDict(extra="forbid")
19
+ ok: bool
20
+ rb_version: str
21
+ command: str
22
+ data: dict = Field(default_factory=dict)
23
+ errors: list[ErrorItem] = Field(default_factory=list)
24
+ next: list[str] = Field(default_factory=list)
25
+ rb_delta: list[dict] = Field(default_factory=list) # in an agent session: what changed in the research since it last looked
26
+
27
+
28
+ def _schemas() -> dict[str, type[BaseModel]]:
29
+ from .record import OBJECT_SCHEMAS
30
+ return {**OBJECT_SCHEMAS, "envelope": Envelope}
31
+
32
+
33
+ SCHEMAS: dict[str, type[BaseModel]] = _schemas()