borescope 0.1.0b1__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.
borescope/__init__.py ADDED
@@ -0,0 +1,13 @@
1
+ # Copyright 2026 Tony Meyer
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """borescope - a natural shell for debugging Juju Kubernetes workload containers."""
5
+
6
+ from importlib.metadata import PackageNotFoundError, version
7
+
8
+ try:
9
+ __version__ = version('borescope')
10
+ except PackageNotFoundError: # pragma: no cover - running from an uninstalled tree
11
+ __version__ = '0.0.0+unknown'
12
+
13
+ __all__ = ['__version__']
borescope/__main__.py ADDED
@@ -0,0 +1,13 @@
1
+ # Copyright 2026 Tony Meyer
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """``python -m borescope`` entry point."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import sys
9
+
10
+ from .cli import main
11
+
12
+ if __name__ == '__main__':
13
+ sys.exit(main())
borescope/cli.py ADDED
@@ -0,0 +1,143 @@
1
+ # Copyright 2026 Tony Meyer
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """Command-line entry point for borescope."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ import sys
10
+
11
+ from . import __version__
12
+
13
+
14
+ def build_parser() -> argparse.ArgumentParser:
15
+ """Build the top-level ``argparse`` parser."""
16
+ parser = argparse.ArgumentParser(
17
+ prog='borescope',
18
+ description=('A natural shell for debugging Juju Kubernetes workload containers.'),
19
+ )
20
+ parser.add_argument('unit', nargs='?', help="unit reference, for example 'myapp/0'")
21
+ parser.add_argument('--container', help='workload container name (default: first declared)')
22
+ parser.add_argument('-m', '--model', help='Juju model (default: current)')
23
+ parser.add_argument(
24
+ '-c',
25
+ '--command',
26
+ help='run a single command and exit (no REPL)',
27
+ )
28
+ parser.add_argument(
29
+ '--snapshot',
30
+ action='store_true',
31
+ help='dump container state as JSON and exit',
32
+ )
33
+ parser.add_argument(
34
+ '--socket',
35
+ help='talk directly to a Pebble unix socket (skip juju)',
36
+ )
37
+ parser.add_argument(
38
+ '--here',
39
+ action='store_true',
40
+ help=(
41
+ "run inside the charm container: auto-detect a workload's mounted "
42
+ 'Pebble socket (use --container to pick when there are several)'
43
+ ),
44
+ )
45
+ parser.add_argument('--juju', default='juju', help='juju binary to invoke (default: juju)')
46
+ parser.add_argument(
47
+ '--via',
48
+ choices=('ssh', 'exec'),
49
+ default='ssh',
50
+ help=(
51
+ "Juju relay for Mode B: 'ssh' (default, streaming) or 'exec' "
52
+ '(request/response — for sites where ssh is disabled)'
53
+ ),
54
+ )
55
+ parser.add_argument('--version', action='version', version=f'borescope {__version__}')
56
+ return parser
57
+
58
+
59
+ def _build_target(args: argparse.Namespace):
60
+ from .discovery import Target, resolve_local_target, resolve_target, validate_container_name
61
+
62
+ if args.container is not None:
63
+ validate_container_name(args.container)
64
+ if args.here:
65
+ return resolve_local_target(container=args.container)
66
+ if args.socket:
67
+ unit = args.unit or 'local'
68
+ app = unit.split('/')[0]
69
+ return Target(
70
+ unit=unit,
71
+ app=app,
72
+ container=args.container,
73
+ model=args.model,
74
+ juju_binary=args.juju,
75
+ socket_path=args.socket,
76
+ )
77
+ return resolve_target(
78
+ args.unit,
79
+ container=args.container,
80
+ model=args.model,
81
+ juju_binary=args.juju,
82
+ via=args.via,
83
+ )
84
+
85
+
86
+ def main(argv: list[str] | None = None) -> int:
87
+ """Run borescope from the command line and return the exit code."""
88
+ args = build_parser().parse_args(argv)
89
+ if not args.unit and not args.socket and not args.here:
90
+ print(
91
+ "borescope: a unit reference is required (for example 'borescope myapp/0'), "
92
+ 'or use --here when running inside a charm container.',
93
+ file=sys.stderr,
94
+ )
95
+ return 2
96
+
97
+ # Heavy imports happen only past argument parsing, keeping --help/--version fast.
98
+ from .errors import BorescopeError
99
+ from .shell import ShellContext
100
+ from .transport import open_transport
101
+
102
+ try:
103
+ target = _build_target(args)
104
+ transport = open_transport(
105
+ unit=target.unit,
106
+ container=target.container,
107
+ model=target.model,
108
+ juju_binary=target.juju_binary,
109
+ socket_path=target.socket_path,
110
+ via=target.via,
111
+ )
112
+ if args.snapshot:
113
+ from .snapshot import snapshot_json
114
+
115
+ print(snapshot_json(transport, target))
116
+ return 0
117
+
118
+ from .discovery import sanity_check
119
+
120
+ sanity_check(transport, target)
121
+ except BorescopeError as exc:
122
+ print(f'borescope: {exc}', file=sys.stderr)
123
+ return 1
124
+
125
+ from .shell import Shell
126
+
127
+ shell = Shell(ShellContext(transport=transport, target=target))
128
+
129
+ if args.command is not None:
130
+ return shell.execute_and_emit(args.command)
131
+
132
+ if not sys.stdin.isatty():
133
+ code = 0
134
+ for line in sys.stdin:
135
+ if line.strip():
136
+ code = shell.execute_and_emit(line.rstrip('\n'))
137
+ return code
138
+
139
+ return shell.loop()
140
+
141
+
142
+ if __name__ == '__main__': # pragma: no cover
143
+ sys.exit(main())
borescope/discovery.py ADDED
@@ -0,0 +1,264 @@
1
+ # Copyright 2026 Tony Meyer
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """Discovery layer (B) — "find the right Pebble".
5
+
6
+ Turns a unit reference (``myapp/0``) plus optional ``--container`` / ``--model``
7
+ into a :class:`Target` describing exactly which workload container's Pebble to talk
8
+ to. Everything here uses only the user's Juju model access (``juju status``,
9
+ ``juju ssh`` to read the charm's ``metadata.yaml``) — never ``kubectl`` or
10
+ cluster-admin — so borescope inherits Juju's authority for free.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ import re
17
+ from dataclasses import dataclass
18
+ from typing import TYPE_CHECKING
19
+
20
+ import yaml
21
+
22
+ from . import juju
23
+ from .errors import DiscoveryError, JujuError
24
+
25
+ if TYPE_CHECKING:
26
+ from .transport import Transport
27
+
28
+ _UNIT_RE = re.compile(r'^(?P<app>[a-z0-9][a-z0-9-]*)/(?P<num>\d+)$')
29
+
30
+ # Juju container names are lowercase alphanumerics with internal hyphens. Validating
31
+ # matters because the name is interpolated into a remote path and an ``env
32
+ # PEBBLE_SOCKET=…`` argv that ``juju ssh`` ultimately hands to a shell.
33
+ _CONTAINER_RE = re.compile(r'^[a-z0-9][a-z0-9-]*$')
34
+
35
+ # Charm agent metadata lives at a deterministic path inside the *charm* container
36
+ # (which, unlike the workload rock, has a normal filesystem and `cat`).
37
+ _META_FILES = ('metadata.yaml', 'charmcraft.yaml')
38
+
39
+ # Inside a Juju k8s charm container, each declared workload's Pebble socket is
40
+ # mounted here, one subdirectory per container. This is what makes Mode A
41
+ # (``--here``) possible without any Juju round-trip.
42
+ _LOCAL_SOCKET_DIR = '/charm/containers'
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class Target:
47
+ """A fully-resolved Pebble target."""
48
+
49
+ unit: str
50
+ app: str
51
+ container: str | None
52
+ model: str | None
53
+ controller: str | None = None
54
+ juju_binary: str = 'juju'
55
+ socket_path: str | None = None
56
+ # CLI-relay variant for Mode B: "ssh" (default) or "exec". Ignored when
57
+ # socket_path is set (Mode A uses the socket directly).
58
+ via: str = 'ssh'
59
+
60
+ @property
61
+ def history_key(self) -> str:
62
+ """Stable per-controller/model/unit key for history files."""
63
+ parts = [self.controller or '_', self.model or '_', self.unit]
64
+ return '/'.join(parts).replace('/', '_')
65
+
66
+
67
+ def validate_container_name(name: str) -> str:
68
+ """Return *name* if it's a valid container name; raise :class:`DiscoveryError`."""
69
+ if not _CONTAINER_RE.match(name):
70
+ raise DiscoveryError(
71
+ f"'{name}' is not a valid container name "
72
+ '(lowercase letters, digits, and hyphens only).'
73
+ )
74
+ return name
75
+
76
+
77
+ def parse_unit_ref(ref: str) -> tuple[str, str]:
78
+ """Split ``app/n`` into ``(app, n)``; raise :class:`DiscoveryError` if malformed."""
79
+ match = _UNIT_RE.match(ref.strip())
80
+ if not match:
81
+ raise DiscoveryError(f"'{ref}' is not a valid unit reference (for example 'myapp/0').")
82
+ return match.group('app'), match.group('num')
83
+
84
+
85
+ def _agent_dir(app: str, num: str) -> str:
86
+ return f'unit-{app}-{num}'
87
+
88
+
89
+ def discover_containers(
90
+ unit: str, app: str, num: str, *, model: str | None, juju_binary: str
91
+ ) -> list[str]:
92
+ """Return the workload container names declared in the charm's metadata.
93
+
94
+ Reads the charm's ``metadata.yaml`` (falling back to ``charmcraft.yaml``) from
95
+ the charm container — ``juju status`` does not list workload container names.
96
+ """
97
+ base = f'/var/lib/juju/agents/{_agent_dir(app, num)}/charm'
98
+ last_error: JujuError | None = None
99
+ for name in _META_FILES:
100
+ try:
101
+ raw = juju.run_juju(
102
+ # No ``--`` separator: juju's k8s ssh leaks it into the remote sh.
103
+ ['ssh', unit, 'cat', f'{base}/{name}'],
104
+ model=model,
105
+ juju_binary=juju_binary,
106
+ )
107
+ except JujuError as exc:
108
+ last_error = exc
109
+ continue
110
+ try:
111
+ meta = yaml.safe_load(raw) or {}
112
+ except yaml.YAMLError:
113
+ continue
114
+ containers = meta.get('containers')
115
+ if isinstance(containers, dict):
116
+ return [str(name) for name in containers]
117
+ if last_error is not None:
118
+ raise DiscoveryError(
119
+ f'could not read charm metadata for {unit}: {last_error}'
120
+ ) from last_error
121
+ return []
122
+
123
+
124
+ def resolve_target(
125
+ unit_ref: str,
126
+ *,
127
+ container: str | None = None,
128
+ model: str | None = None,
129
+ juju_binary: str = 'juju',
130
+ via: str = 'ssh',
131
+ ) -> Target:
132
+ """Resolve *unit_ref* to a :class:`Target`, confirming it exists and is on k8s."""
133
+ app, num = parse_unit_ref(unit_ref)
134
+ controller, current_model = juju.current_controller_model(juju_binary)
135
+ effective_model = model or current_model
136
+
137
+ status = juju.status_json(model=effective_model, juju_binary=juju_binary)
138
+
139
+ model_type = (status.get('model') or {}).get('type')
140
+ if model_type == 'iaas':
141
+ raise DiscoveryError(
142
+ f'{unit_ref} is on a machine (IAAS) model. borescope only supports '
143
+ 'Kubernetes charms, which run Pebble; machine charms already have a '
144
+ 'real shell. See the project scope.'
145
+ )
146
+
147
+ apps = status.get('applications') or {}
148
+ if app not in apps:
149
+ raise DiscoveryError(
150
+ f"application '{app}' not found in model '{effective_model or '<current>'}'."
151
+ )
152
+ units = apps[app].get('units') or {}
153
+ if unit_ref not in units:
154
+ available = ', '.join(sorted(units)) or 'none'
155
+ raise DiscoveryError(f"unit '{unit_ref}' not found. Units of '{app}': {available}.")
156
+
157
+ chosen = container
158
+ if chosen is None:
159
+ containers = discover_containers(unit_ref, app, num, model=model, juju_binary=juju_binary)
160
+ if not containers:
161
+ raise DiscoveryError(
162
+ f"no workload containers declared by '{app}'. Is this a "
163
+ 'Kubernetes (sidecar) charm?'
164
+ )
165
+ # Default to the first declared workload container.
166
+ chosen = containers[0]
167
+
168
+ return Target(
169
+ unit=unit_ref,
170
+ app=app,
171
+ container=chosen,
172
+ model=effective_model,
173
+ controller=controller,
174
+ juju_binary=juju_binary,
175
+ via=via,
176
+ )
177
+
178
+
179
+ def discover_local_sockets(base: str = _LOCAL_SOCKET_DIR) -> dict[str, str]:
180
+ """Map workload container name → mounted Pebble socket path.
181
+
182
+ As seen from *inside the charm container*, where Juju mounts every workload's
183
+ socket at ``/charm/containers/<name>/pebble.socket``. Returns an empty mapping
184
+ if *base* isn't present (that is, we're not in a charm container).
185
+ """
186
+ sockets: dict[str, str] = {}
187
+ try:
188
+ names = os.listdir(base)
189
+ except OSError:
190
+ return sockets
191
+ for name in sorted(names):
192
+ path = f'{base}/{name}/pebble.socket'
193
+ if os.path.exists(path):
194
+ sockets[name] = path
195
+ return sockets
196
+
197
+
198
+ def resolve_local_target(*, container: str | None = None, base: str = _LOCAL_SOCKET_DIR) -> Target:
199
+ """Resolve a :class:`Target` for borescope running *inside the charm container*.
200
+
201
+ Talks directly to a workload's mounted Pebble socket (Mode A) — no Juju, no
202
+ unit reference. Picks the socket named by *container*, or the sole one if the
203
+ charm declares just a single workload.
204
+ """
205
+ sockets = discover_local_sockets(base)
206
+ if not sockets:
207
+ raise DiscoveryError(
208
+ f"no Pebble sockets found under {base}. '--here' only works from inside "
209
+ "a Juju Kubernetes charm container, which mounts each workload's socket "
210
+ "there. From your workstation, run 'borescope <unit>' instead."
211
+ )
212
+ if container is not None:
213
+ socket = sockets.get(container)
214
+ if socket is None:
215
+ available = ', '.join(sockets)
216
+ raise DiscoveryError(
217
+ f"no Pebble socket for container '{container}' under {base}. "
218
+ f'Available: {available}.'
219
+ )
220
+ chosen = container
221
+ elif len(sockets) == 1:
222
+ chosen, socket = next(iter(sockets.items()))
223
+ else:
224
+ available = ', '.join(sockets)
225
+ raise DiscoveryError(
226
+ f'this charm has multiple workload containers ({available}); '
227
+ 'choose one with --container.'
228
+ )
229
+ return Target(
230
+ unit='local',
231
+ app=chosen,
232
+ container=chosen,
233
+ model=None,
234
+ socket_path=socket,
235
+ )
236
+
237
+
238
+ def sanity_check(transport: Transport, target: Target) -> None:
239
+ """Confirm the container's Pebble answers, and is new enough, before prompting."""
240
+ try:
241
+ info = transport.get_system_info()
242
+ except Exception as exc:
243
+ raise DiscoveryError(
244
+ f"could not reach Pebble in {target.unit} container '{target.container}': {exc}"
245
+ ) from exc
246
+
247
+ # borescope v1 relies on Pebble's `--format json` output (via shimmer). Older
248
+ # Pebbles (for example, v1.26, still shipped by current stable charms) lack it. Probe
249
+ # one structured read so we fail fast with a clear message rather than letting
250
+ # every read command die with a cryptic "unknown flag `format'".
251
+ try:
252
+ transport.get_services()
253
+ except Exception as exc:
254
+ message = str(exc).lower()
255
+ if 'unknown flag' in message and 'format' in message:
256
+ version = getattr(info, 'version', 'unknown')
257
+ raise DiscoveryError(
258
+ f"the Pebble in {target.unit} container '{target.container}' "
259
+ f'(version {version}) is too old for borescope: it lacks the '
260
+ '`--format json` output borescope relies on. borescope v1 needs a '
261
+ 'newer Pebble (support for older Pebble may come later).'
262
+ ) from exc
263
+ # Any other failure here isn't necessarily fatal; reachability is already
264
+ # proven, so let the session start and let individual commands report.
borescope/errors.py ADDED
@@ -0,0 +1,23 @@
1
+ # Copyright 2026 Tony Meyer
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """borescope's error hierarchy."""
5
+
6
+ from __future__ import annotations
7
+
8
+
9
+ class BorescopeError(Exception):
10
+ """Base class for all borescope errors."""
11
+
12
+
13
+ class DiscoveryError(BorescopeError):
14
+ """Raised when a unit/model/container cannot be resolved or reached."""
15
+
16
+
17
+ class JujuError(BorescopeError):
18
+ """Raised when an underlying ``juju`` invocation fails."""
19
+
20
+ def __init__(self, message: str, *, returncode: int | None = None, stderr: str = ''):
21
+ super().__init__(message)
22
+ self.returncode = returncode
23
+ self.stderr = stderr
borescope/juju.py ADDED
@@ -0,0 +1,88 @@
1
+ # Copyright 2026 Tony Meyer
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """Thin wrappers around the ``juju`` CLI.
5
+
6
+ borescope never links Juju's Go/Python API libraries; it shells out to the user's
7
+ ``juju`` client. That keeps borescope inside the user's existing Juju authority — if
8
+ they can run ``juju ssh`` to a unit, borescope works; if they can't, it fails the
9
+ same way — and needs no ``kubectl`` / cluster-admin access.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import subprocess
16
+ from typing import Any
17
+
18
+ from .errors import JujuError
19
+
20
+ DEFAULT_TIMEOUT = 30.0
21
+
22
+
23
+ def run_juju(
24
+ args: list[str],
25
+ *,
26
+ model: str | None = None,
27
+ juju_binary: str = 'juju',
28
+ input: str | None = None,
29
+ timeout: float = DEFAULT_TIMEOUT,
30
+ ) -> str:
31
+ """Run ``juju <args>`` and return stdout, raising :class:`JujuError` on failure.
32
+
33
+ *model*, when given, is inserted as ``-m <model>`` immediately after the
34
+ subcommand (``args[0]``), matching how ``juju`` expects the flag.
35
+ """
36
+ cmd = [juju_binary, args[0]]
37
+ if model:
38
+ cmd += ['-m', model]
39
+ cmd += args[1:]
40
+ try:
41
+ result = subprocess.run(
42
+ cmd,
43
+ input=input,
44
+ # Detach stdin unless we're sending input: `juju ssh` forwards our stdin
45
+ # to the remote and would otherwise drain borescope's own piped-batch
46
+ # command stream during discovery, leaving the REPL loop nothing to read.
47
+ stdin=subprocess.DEVNULL if input is None else None,
48
+ capture_output=True,
49
+ text=True,
50
+ timeout=timeout,
51
+ check=True,
52
+ )
53
+ except FileNotFoundError as exc:
54
+ raise JujuError(f"'{juju_binary}' not found on PATH. Install Juju and try again.") from exc
55
+ except subprocess.TimeoutExpired as exc:
56
+ raise JujuError(f"'{' '.join(cmd)}' timed out after {timeout}s") from exc
57
+ except subprocess.CalledProcessError as exc:
58
+ stderr = (exc.stderr or '').strip()
59
+ raise JujuError(
60
+ f"'{' '.join(cmd)}' failed: {stderr or 'unknown error'}",
61
+ returncode=exc.returncode,
62
+ stderr=stderr,
63
+ ) from exc
64
+ return result.stdout
65
+
66
+
67
+ def current_controller_model(
68
+ juju_binary: str = 'juju',
69
+ ) -> tuple[str | None, str | None]:
70
+ """Return ``(controller, model)`` from ``juju switch`` (either may be None)."""
71
+ try:
72
+ out = run_juju(['switch'], juju_binary=juju_binary).strip()
73
+ except JujuError:
74
+ return None, None
75
+ if not out:
76
+ return None, None
77
+ # `juju switch` prints "<controller>:<user>/<model>" (or "<controller>:<model>").
78
+ controller, _, model = out.partition(':')
79
+ return controller or None, (model or None)
80
+
81
+
82
+ def status_json(*, model: str | None = None, juju_binary: str = 'juju') -> dict[str, Any]:
83
+ """Return parsed ``juju status --format=json`` for *model*."""
84
+ out = run_juju(['status', '--format=json'], model=model, juju_binary=juju_binary)
85
+ try:
86
+ return json.loads(out)
87
+ except json.JSONDecodeError as exc:
88
+ raise JujuError('could not parse `juju status --format=json` output') from exc
@@ -0,0 +1,11 @@
1
+ # Copyright 2026 Tony Meyer
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """Shell layer (C) — "drive the Pebble"."""
5
+
6
+ from __future__ import annotations
7
+
8
+ from .context import ShellContext
9
+ from .repl import Shell
10
+
11
+ __all__ = ['Shell', 'ShellContext']
@@ -0,0 +1,16 @@
1
+ # Copyright 2026 Tony Meyer
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """Built-in commands and the registry that discovers them."""
5
+
6
+ from __future__ import annotations
7
+
8
+ from .base import Command, ExitShell, Result, build_registry
9
+
10
+
11
+ def import_all() -> None:
12
+ """Import every command module so its ``Command`` subclasses register."""
13
+ from . import basic, execcmd, filesystem, pebble # noqa: F401
14
+
15
+
16
+ __all__ = ['Command', 'ExitShell', 'Result', 'build_registry', 'import_all']
@@ -0,0 +1,57 @@
1
+ # Copyright 2026 Tony Meyer
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """A tiny getopt-ish argument splitter shared by the built-in commands.
5
+
6
+ Not a full argparse — these are debug-shell commands, so we want forgiving,
7
+ familiar behaviour (``-la``, ``-n10``, ``-n 10``, ``--follow``) without per-command
8
+ boilerplate.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+
14
+ def parse_args(
15
+ args: list[str], valued: tuple[str, ...] = ()
16
+ ) -> tuple[set[str], dict[str, str], list[str]]:
17
+ """Split *args* into ``(flags, values, positionals)``.
18
+
19
+ *valued* names flags that take a value (for example, ``("n",)`` for ``-n N`` /
20
+ ``("name",)`` for ``--name X``). Everything after ``--`` is positional.
21
+ """
22
+ flags: set[str] = set()
23
+ values: dict[str, str] = {}
24
+ positionals: list[str] = []
25
+
26
+ i = 0
27
+ while i < len(args):
28
+ arg = args[i]
29
+ if arg == '--':
30
+ positionals.extend(args[i + 1 :])
31
+ break
32
+ if arg.startswith('--'):
33
+ name = arg[2:]
34
+ if name in valued:
35
+ i += 1
36
+ values[name] = args[i] if i < len(args) else ''
37
+ else:
38
+ flags.add(name)
39
+ elif len(arg) > 1 and arg[0] == '-' and not arg[1].isdigit():
40
+ body = arg[1:]
41
+ j = 0
42
+ while j < len(body):
43
+ ch = body[j]
44
+ if ch in valued:
45
+ rest = body[j + 1 :]
46
+ if rest:
47
+ values[ch] = rest
48
+ else:
49
+ i += 1
50
+ values[ch] = args[i] if i < len(args) else ''
51
+ break
52
+ flags.add(ch)
53
+ j += 1
54
+ else:
55
+ positionals.append(arg)
56
+ i += 1
57
+ return flags, values, positionals