xsync-cli 0.1.0__py3-none-any.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.
xsync_cli/cli.py ADDED
@@ -0,0 +1,321 @@
1
+ """The xsync command line."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import os
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ from xsync_cli.adapters.codex import load_rules, render_catalog
12
+ from xsync_cli.adapters.codex_config import (
13
+ STATE_FILENAME,
14
+ ConfigError,
15
+ State,
16
+ catalog_path_for,
17
+ check_provider_match,
18
+ clear_state,
19
+ default_codex_home,
20
+ read_config,
21
+ read_state,
22
+ write_state,
23
+ )
24
+ from xsync_cli.adapters.codex_wiring import init_config, known_removals, reset_config
25
+ from xsync_cli.core.atomic import write_json_atomic
26
+ from xsync_cli.core.diff import diff_catalogs
27
+ from xsync_cli.core.filters import apply_filters
28
+ from xsync_cli.core.profiles import (
29
+ WIRE_APIS,
30
+ Profile,
31
+ ProfileError,
32
+ ProfileStore,
33
+ default_store_path,
34
+ )
35
+ from xsync_cli.sources.openai_compat import (
36
+ EndpointUnreachable,
37
+ SourceError,
38
+ fetch_models,
39
+ )
40
+
41
+ EXIT_OK = 0
42
+ EXIT_ERROR = 1
43
+ EXIT_UNREACHABLE = 2
44
+
45
+
46
+ def store_path() -> Path:
47
+ """The profile file. The variable XSYNC_PROFILES wins."""
48
+ override = os.environ.get("XSYNC_PROFILES")
49
+ return Path(override) if override else default_store_path()
50
+
51
+
52
+ def open_store() -> ProfileStore:
53
+ store = ProfileStore(store_path())
54
+ store.load()
55
+ return store
56
+
57
+
58
+ def _ask(prompt: str, default: str = "") -> str:
59
+ suffix = f" [{default}]" if default else ""
60
+ answer = input(f"{prompt}{suffix}: ").strip()
61
+ return answer or default
62
+
63
+
64
+ def _ask_list(prompt: str) -> list[str]:
65
+ answer = _ask(prompt)
66
+ return [part.strip() for part in answer.split(",") if part.strip()]
67
+
68
+
69
+ def cmd_setup(args: argparse.Namespace) -> int:
70
+ """Make a profile."""
71
+ store = open_store()
72
+
73
+ name = _ask("profile name")
74
+ if not name:
75
+ print("the profile name cannot be empty.", file=sys.stderr)
76
+ return EXIT_ERROR
77
+
78
+ base_url = _ask("base URL (for example http://127.0.0.1:20128/v1)")
79
+ if not base_url:
80
+ print("the base URL cannot be empty.", file=sys.stderr)
81
+ return EXIT_ERROR
82
+
83
+ api_key = _ask("API key (leave empty when the endpoint needs none)")
84
+ include = _ask_list("include globs, separated by a comma (optional)")
85
+ exclude = _ask_list("exclude globs, separated by a comma (optional)")
86
+
87
+ print(f"testing {base_url}/models …")
88
+ try:
89
+ models = fetch_models(base_url, api_key or None)
90
+ except EndpointUnreachable as error:
91
+ print(f"error: {error}", file=sys.stderr)
92
+ return EXIT_UNREACHABLE
93
+ except SourceError as error:
94
+ print(f"error: {error}", file=sys.stderr)
95
+ return EXIT_ERROR
96
+
97
+ print(f"\n{len(models)} models:")
98
+ for model in models:
99
+ window = f"{model.context_window:,}" if model.context_window else "unknown"
100
+ flags = ",".join(
101
+ flag
102
+ for flag, on in (
103
+ ("tools", model.tools),
104
+ ("reasoning", model.reasoning),
105
+ ("vision", model.vision),
106
+ ("search", model.search),
107
+ )
108
+ if on
109
+ )
110
+ print(f" {model.slug:<48} {window:>12} {flags}")
111
+
112
+ print()
113
+ wire_api = _ask(f"wire API ({' or '.join(WIRE_APIS)})", "chat")
114
+
115
+ try:
116
+ profile = Profile(
117
+ name=name,
118
+ base_url=base_url,
119
+ api_key=api_key or None,
120
+ api_key_env=None,
121
+ wire_api=wire_api,
122
+ include=include,
123
+ exclude=exclude,
124
+ )
125
+ except ProfileError as error:
126
+ print(f"error: {error}", file=sys.stderr)
127
+ return EXIT_ERROR
128
+
129
+ store.add(profile)
130
+ store.save()
131
+ print(f"\nsaved profile {name!r} to {store.path}")
132
+ if store.active == name:
133
+ print(f"profile {name!r} is now active")
134
+ return EXIT_OK
135
+
136
+
137
+ def cmd_list(args: argparse.Namespace) -> int:
138
+ """Show the profiles."""
139
+ store = open_store()
140
+ if not store.names():
141
+ print("no profile exists. Run `xsync setup` first.")
142
+ return EXIT_OK
143
+ for name in store.names():
144
+ mark = "*" if name == store.active else " "
145
+ profile = store.get(name)
146
+ print(f"{mark} {name:<20} {profile.base_url:<40} {profile.wire_api}")
147
+ return EXIT_OK
148
+
149
+
150
+ def cmd_use(args: argparse.Namespace) -> int:
151
+ """Set the active profile."""
152
+ store = open_store()
153
+ try:
154
+ store.set_active(args.name)
155
+ except ProfileError as error:
156
+ print(f"error: {error}", file=sys.stderr)
157
+ return EXIT_ERROR
158
+ store.save()
159
+ print(f"active profile: {args.name}")
160
+ return EXIT_OK
161
+
162
+
163
+ def cmd_remove(args: argparse.Namespace) -> int:
164
+ """Delete a profile."""
165
+ store = open_store()
166
+ try:
167
+ store.remove(args.name)
168
+ except ProfileError as error:
169
+ print(f"error: {error}", file=sys.stderr)
170
+ return EXIT_ERROR
171
+ store.save()
172
+ print(f"removed profile {args.name!r}")
173
+ return EXIT_OK
174
+
175
+
176
+ def _load_catalog(path: Path) -> dict | None:
177
+ """The current catalog, or None when it is absent or damaged."""
178
+ if not path.exists():
179
+ return None
180
+ try:
181
+ return json.loads(path.read_text(encoding="utf-8"))
182
+ except json.JSONDecodeError:
183
+ return None
184
+
185
+
186
+ def _do_reset(codex_home: Path, profile_name: str, force: bool) -> int:
187
+ """Remove everything that xSync wrote."""
188
+ state_path = codex_home / STATE_FILENAME
189
+ state = read_state(state_path)
190
+
191
+ if state is None:
192
+ planned = known_removals(profile_name)
193
+ if not force:
194
+ print("no state file exists. xSync would remove:")
195
+ for key in planned.keys_written:
196
+ print(f" key {key}")
197
+ for block in planned.blocks_written:
198
+ print(f" block [{block}]")
199
+ print("\nadd --force to continue.", file=sys.stderr)
200
+ return EXIT_ERROR
201
+ state = State(
202
+ profile=profile_name,
203
+ keys_written=planned.keys_written,
204
+ blocks_written=planned.blocks_written,
205
+ files_written=[str(codex_home / f"{profile_name}-models.json")],
206
+ config_sha256="",
207
+ written_at="",
208
+ )
209
+
210
+ removed = reset_config(codex_home / "config.toml", state)
211
+ clear_state(state_path)
212
+ for name in removed:
213
+ print(f"removed {name}")
214
+ print("Codex is back at its own defaults.")
215
+ return EXIT_OK
216
+
217
+
218
+ def cmd_codex(args: argparse.Namespace) -> int:
219
+ """Sync a profile into the Codex catalog."""
220
+ store = open_store()
221
+ codex_home = default_codex_home()
222
+ config_path = codex_home / "config.toml"
223
+
224
+ try:
225
+ profile = store.get(args.profile) if args.profile else store.active_profile()
226
+ except ProfileError as error:
227
+ print(f"error: {error}", file=sys.stderr)
228
+ return EXIT_ERROR
229
+
230
+ if args.reset:
231
+ try:
232
+ return _do_reset(codex_home, profile.name, args.force)
233
+ except ConfigError as error:
234
+ print(f"error: {error}", file=sys.stderr)
235
+ return EXIT_ERROR
236
+
237
+ catalog_path = catalog_path_for(profile, codex_home)
238
+
239
+ if args.init:
240
+ try:
241
+ api_key = profile.resolve_key(os.environ)
242
+ state = init_config(config_path, profile, catalog_path, api_key)
243
+ except (ConfigError, ProfileError) as error:
244
+ print(f"error: {error}", file=sys.stderr)
245
+ return EXIT_ERROR
246
+ write_state(codex_home / STATE_FILENAME, state)
247
+ print(f"Codex now routes to {profile.name!r} ({profile.base_url})")
248
+
249
+ try:
250
+ api_key = profile.resolve_key(os.environ)
251
+ models = fetch_models(profile.base_url, api_key)
252
+ except EndpointUnreachable as error:
253
+ print(f"error: {error}", file=sys.stderr)
254
+ return EXIT_UNREACHABLE
255
+ except (SourceError, ProfileError) as error:
256
+ print(f"error: {error}", file=sys.stderr)
257
+ return EXIT_ERROR
258
+
259
+ models = apply_filters(models, profile.include, profile.exclude)
260
+
261
+ config = read_config(config_path)
262
+ message = check_provider_match(config, profile)
263
+ if message:
264
+ if args.dry_run:
265
+ print(f"warning:\n{message}\n")
266
+ else:
267
+ print(f"error:\n{message}", file=sys.stderr)
268
+ return EXIT_ERROR
269
+
270
+ catalog = render_catalog(models, load_rules())
271
+ report = diff_catalogs(_load_catalog(catalog_path), catalog)
272
+ print(report.render())
273
+
274
+ if args.dry_run:
275
+ print("no files written (--dry-run)")
276
+ return EXIT_OK
277
+
278
+ write_json_atomic(catalog_path, catalog)
279
+ print(f"wrote {len(catalog['models'])} models to {catalog_path}")
280
+ return EXIT_OK
281
+
282
+
283
+ def build_parser() -> argparse.ArgumentParser:
284
+ parser = argparse.ArgumentParser(
285
+ prog="xsync",
286
+ description="Sync the model list of an OpenAI-compatible endpoint into a harness.",
287
+ )
288
+ sub = parser.add_subparsers(dest="command", required=True)
289
+
290
+ sub.add_parser("setup", help="make a profile").set_defaults(func=cmd_setup)
291
+ sub.add_parser("list", help="show the profiles").set_defaults(func=cmd_list)
292
+
293
+ use = sub.add_parser("use", help="set the active profile")
294
+ use.add_argument("name")
295
+ use.set_defaults(func=cmd_use)
296
+
297
+ remove = sub.add_parser("remove", help="delete a profile")
298
+ remove.add_argument("name")
299
+ remove.set_defaults(func=cmd_remove)
300
+
301
+ codex = sub.add_parser("codex", help="sync a profile into the Codex catalog")
302
+ codex.add_argument("--profile", help="use this profile for one run")
303
+ codex.add_argument("--dry-run", action="store_true", help="show the difference only")
304
+ codex.add_argument("--init", action="store_true", help="wire Codex to the endpoint")
305
+ codex.add_argument(
306
+ "--reset", action="store_true", help="remove everything xSync wrote"
307
+ )
308
+ codex.add_argument("--force", action="store_true", help="reset without a state file")
309
+ codex.set_defaults(func=cmd_codex)
310
+
311
+ return parser
312
+
313
+
314
+ def main(argv: list[str] | None = None) -> int:
315
+ parser = build_parser()
316
+ args = parser.parse_args(argv)
317
+ try:
318
+ return args.func(args)
319
+ except (ProfileError, ConfigError) as error:
320
+ print(f"error: {error}", file=sys.stderr)
321
+ return EXIT_ERROR
File without changes
@@ -0,0 +1,28 @@
1
+ """The safe file write."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+
11
+ def write_json_atomic(path: Path, payload: dict[str, Any], keep_backup: bool = True) -> None:
12
+ """Write JSON without a partial file.
13
+
14
+ The function writes a temporary file in the same directory, confirms
15
+ that the file holds valid JSON, and then replaces the target. It
16
+ keeps one backup with the suffix `.bak`.
17
+ """
18
+ path.parent.mkdir(parents=True, exist_ok=True)
19
+ temporary = path.with_name(path.name + ".tmp")
20
+
21
+ text = json.dumps(payload, indent=2, ensure_ascii=False)
22
+ json.loads(text)
23
+
24
+ temporary.write_text(text, encoding="utf-8")
25
+ if keep_backup and path.exists():
26
+ backup = path.with_name(path.name + ".bak")
27
+ backup.write_bytes(path.read_bytes())
28
+ os.replace(temporary, path)
xsync_cli/core/diff.py ADDED
@@ -0,0 +1,81 @@
1
+ """The difference between two catalogs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any
7
+
8
+
9
+ @dataclass(frozen=True, slots=True)
10
+ class CatalogDiff:
11
+ """The report of one sync."""
12
+
13
+ added: list[str]
14
+ removed: list[str]
15
+ changed: list[tuple[str, list[str]]]
16
+ unchanged: list[str]
17
+
18
+ @property
19
+ def is_empty(self) -> bool:
20
+ """True when the catalog does not change."""
21
+ return not (self.added or self.removed or self.changed)
22
+
23
+ def render(self, limit: int = 8) -> str:
24
+ """The report as text."""
25
+ lines: list[str] = []
26
+ if self.added:
27
+ lines.append(f"+ {len(self.added)} added {self._sample(self.added, limit)}")
28
+ if self.removed:
29
+ lines.append(
30
+ f"- {len(self.removed)} removed {self._sample(self.removed, limit)}"
31
+ )
32
+ if self.changed:
33
+ names = [
34
+ f"{slug} ({self._fields(fields)})" for slug, fields in self.changed
35
+ ]
36
+ lines.append(f"~ {len(self.changed)} changed {self._sample(names, limit)}")
37
+ lines.append(f" {len(self.unchanged)} unchanged")
38
+ return "\n".join(lines)
39
+
40
+ @staticmethod
41
+ def _fields(fields: list[str], limit: int = 3) -> str:
42
+ """The field names of one changed model, cut short."""
43
+ head = ", ".join(fields[:limit])
44
+ return head if len(fields) <= limit else f"{head}, +{len(fields) - limit} more"
45
+
46
+ @staticmethod
47
+ def _sample(items: list[str], limit: int) -> str:
48
+ head = ", ".join(items[:limit])
49
+ return head if len(items) <= limit else f"{head}, … (+{len(items) - limit})"
50
+
51
+
52
+ def _by_slug(catalog: dict[str, Any] | None) -> dict[str, dict[str, Any]]:
53
+ if not catalog:
54
+ return {}
55
+ return {entry["slug"]: entry for entry in catalog.get("models", []) if "slug" in entry}
56
+
57
+
58
+ def diff_catalogs(old: dict[str, Any] | None, new: dict[str, Any]) -> CatalogDiff:
59
+ """Compare two catalogs by slug."""
60
+ old_map = _by_slug(old)
61
+ new_map = _by_slug(new)
62
+
63
+ added = [slug for slug in new_map if slug not in old_map]
64
+ removed = [slug for slug in old_map if slug not in new_map]
65
+ changed: list[tuple[str, list[str]]] = []
66
+ unchanged: list[str] = []
67
+
68
+ for slug, entry in new_map.items():
69
+ if slug not in old_map:
70
+ continue
71
+ fields = sorted(
72
+ key
73
+ for key in set(entry) | set(old_map[slug])
74
+ if entry.get(key) != old_map[slug].get(key)
75
+ )
76
+ if fields:
77
+ changed.append((slug, fields))
78
+ else:
79
+ unchanged.append(slug)
80
+
81
+ return CatalogDiff(added=added, removed=removed, changed=changed, unchanged=unchanged)
@@ -0,0 +1,33 @@
1
+ """The include and exclude glob rules."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+ from fnmatch import fnmatch
7
+
8
+ from xsync_cli.core.model import Model
9
+
10
+
11
+ def _matches_any(slug: str, patterns: Sequence[str]) -> bool:
12
+ lowered = slug.lower()
13
+ return any(fnmatch(lowered, pattern.lower()) for pattern in patterns)
14
+
15
+
16
+ def apply_filters(
17
+ models: Sequence[Model],
18
+ include: Sequence[str],
19
+ exclude: Sequence[str],
20
+ ) -> list[Model]:
21
+ """Keep the models that the rules allow.
22
+
23
+ An empty include list keeps every model. The exclude list always
24
+ wins over the include list. The match ignores the letter case.
25
+ """
26
+ kept: list[Model] = []
27
+ for candidate in models:
28
+ if include and not _matches_any(candidate.slug, include):
29
+ continue
30
+ if exclude and _matches_any(candidate.slug, exclude):
31
+ continue
32
+ kept.append(candidate)
33
+ return kept
@@ -0,0 +1,35 @@
1
+ """The provider-neutral model record."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+
8
+ @dataclass(frozen=True, slots=True)
9
+ class Model:
10
+ """One model, as an endpoint reports it.
11
+
12
+ The fields hold facts about the model. They do not hold harness
13
+ settings. An endpoint that reports no capabilities gives None or
14
+ False. The adapter rules then supply a value.
15
+ """
16
+
17
+ slug: str
18
+ context_window: int | None
19
+ max_output: int | None
20
+ vision: bool
21
+ reasoning: bool
22
+ tools: bool
23
+ search: bool
24
+ owned_by: str | None
25
+
26
+ @property
27
+ def prefix(self) -> str:
28
+ """The first slug segment, or an empty string for a flat slug."""
29
+ head, sep, _ = self.slug.partition("/")
30
+ return head if sep else ""
31
+
32
+ @property
33
+ def leaf_name(self) -> str:
34
+ """The last slug segment."""
35
+ return self.slug.rsplit("/", 1)[-1]
@@ -0,0 +1,166 @@
1
+ """The profile store.
2
+
3
+ A profile holds the connection data for one OpenAI-compatible endpoint.
4
+ The store keeps the profiles in a TOML file with mode 0600.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import tomllib
11
+ from collections.abc import Mapping
12
+ from dataclasses import dataclass, field
13
+ from pathlib import Path
14
+
15
+ import tomlkit
16
+
17
+ WIRE_APIS = ("chat", "responses")
18
+
19
+
20
+ class ProfileError(Exception):
21
+ """The profile data is wrong, or the profile does not exist."""
22
+
23
+
24
+ def default_store_path() -> Path:
25
+ """The default path of the profile file."""
26
+ return Path.home() / ".config" / "xsync" / "profiles.toml"
27
+
28
+
29
+ @dataclass(frozen=True, slots=True)
30
+ class Profile:
31
+ """One endpoint, with its filters."""
32
+
33
+ name: str
34
+ base_url: str
35
+ api_key: str | None
36
+ api_key_env: str | None
37
+ wire_api: str
38
+ include: list[str] = field(default_factory=list)
39
+ exclude: list[str] = field(default_factory=list)
40
+
41
+ def __post_init__(self) -> None:
42
+ if self.api_key and self.api_key_env:
43
+ raise ProfileError(
44
+ f"profile {self.name!r} sets api_key and api_key_env. "
45
+ "Set one, not both."
46
+ )
47
+ if self.wire_api not in WIRE_APIS:
48
+ raise ProfileError(
49
+ f"profile {self.name!r} has wire_api {self.wire_api!r}. "
50
+ f"Use one of {', '.join(WIRE_APIS)}."
51
+ )
52
+ object.__setattr__(self, "base_url", self.base_url.rstrip("/"))
53
+
54
+ def resolve_key(self, env: Mapping[str, str]) -> str | None:
55
+ """The API key, or None when the profile has no key."""
56
+ if self.api_key:
57
+ return self.api_key
58
+ if self.api_key_env:
59
+ value = env.get(self.api_key_env)
60
+ if not value:
61
+ raise ProfileError(
62
+ f"profile {self.name!r} needs the variable "
63
+ f"{self.api_key_env}. The variable is empty or absent."
64
+ )
65
+ return value
66
+ return None
67
+
68
+ def to_table(self) -> dict[str, object]:
69
+ """The profile as plain data, without the name."""
70
+ table: dict[str, object] = {"base_url": self.base_url, "wire_api": self.wire_api}
71
+ if self.api_key:
72
+ table["api_key"] = self.api_key
73
+ if self.api_key_env:
74
+ table["api_key_env"] = self.api_key_env
75
+ if self.include:
76
+ table["include"] = list(self.include)
77
+ if self.exclude:
78
+ table["exclude"] = list(self.exclude)
79
+ return table
80
+
81
+
82
+ class ProfileStore:
83
+ """The reader and the writer of the profile file."""
84
+
85
+ def __init__(self, path: Path) -> None:
86
+ self.path = path
87
+ self.active: str | None = None
88
+ self._profiles: dict[str, Profile] = {}
89
+
90
+ def load(self) -> None:
91
+ """Read the file. An absent file gives an empty store."""
92
+ if not self.path.exists():
93
+ self.active = None
94
+ self._profiles = {}
95
+ return
96
+ data = tomllib.loads(self.path.read_text(encoding="utf-8"))
97
+ self.active = data.get("active")
98
+ self._profiles = {}
99
+ for name, table in (data.get("profiles") or {}).items():
100
+ self._profiles[name] = Profile(
101
+ name=name,
102
+ base_url=str(table.get("base_url", "")),
103
+ api_key=table.get("api_key"),
104
+ api_key_env=table.get("api_key_env"),
105
+ wire_api=str(table.get("wire_api", "chat")),
106
+ include=list(table.get("include", [])),
107
+ exclude=list(table.get("exclude", [])),
108
+ )
109
+ if self.active not in self._profiles:
110
+ self.active = next(iter(self._profiles), None)
111
+
112
+ def save(self) -> None:
113
+ """Write the file with mode 0600."""
114
+ document = tomlkit.document()
115
+ if self.active:
116
+ document["active"] = self.active
117
+ profiles = tomlkit.table(is_super_table=True)
118
+ for name, profile in self._profiles.items():
119
+ entry = tomlkit.table()
120
+ for key, value in profile.to_table().items():
121
+ entry[key] = value
122
+ profiles[name] = entry
123
+ document["profiles"] = profiles
124
+
125
+ self.path.parent.mkdir(parents=True, exist_ok=True)
126
+ temporary = self.path.with_suffix(".tmp")
127
+ temporary.write_text(tomlkit.dumps(document), encoding="utf-8")
128
+ os.chmod(temporary, 0o600)
129
+ os.replace(temporary, self.path)
130
+
131
+ def add(self, profile: Profile) -> None:
132
+ """Add a profile. The first profile becomes the active profile."""
133
+ self._profiles[profile.name] = profile
134
+ if self.active is None:
135
+ self.active = profile.name
136
+
137
+ def get(self, name: str) -> Profile:
138
+ """One profile by name."""
139
+ try:
140
+ return self._profiles[name]
141
+ except KeyError:
142
+ raise ProfileError(f"unknown profile {name!r}") from None
143
+
144
+ def remove(self, name: str) -> None:
145
+ """Delete a profile. Move the active name when necessary."""
146
+ if name not in self._profiles:
147
+ raise ProfileError(f"unknown profile {name!r}")
148
+ del self._profiles[name]
149
+ if self.active == name:
150
+ self.active = next(iter(self._profiles), None)
151
+
152
+ def names(self) -> list[str]:
153
+ """The profile names, in insertion order."""
154
+ return list(self._profiles)
155
+
156
+ def set_active(self, name: str) -> None:
157
+ """Make one profile the active profile."""
158
+ if name not in self._profiles:
159
+ raise ProfileError(f"unknown profile {name!r}")
160
+ self.active = name
161
+
162
+ def active_profile(self) -> Profile:
163
+ """The active profile."""
164
+ if not self.active:
165
+ raise ProfileError("no profile exists. Run `xsync setup` first.")
166
+ return self.get(self.active)
File without changes