kerykeion-cli 6.0.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.
@@ -0,0 +1,35 @@
1
+ # -*- coding: utf-8 -*-
2
+ """The ``kerykeion`` command: the console script and ``python -m kerykeion_cli`` both resolve to :func:`main`.
3
+
4
+ Shipped as the ``kerykeion-cli`` distribution, which the ``kerykeion[cli]``
5
+ extra installs — the library's own wheel carries no command. Built on the
6
+ standard library alone (argparse), so kerykeion is the whole dependency, and
7
+ ``import kerykeion`` never imports this package.
8
+
9
+ (It is not *fast*: importing a submodule imports ``kerykeion`` first — about a
10
+ second of backend selection. Making that lazy is a change to ``kerykeion/__init__.py``.)
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import sys
16
+
17
+ __all__ = ["main"]
18
+
19
+
20
+ def main(argv: list[str] | None = None) -> int:
21
+ """Console-script entry point: parse, dispatch, and turn anything that escapes into a classified exit."""
22
+ from kerykeion_cli.app import run
23
+ from kerykeion_cli.errors import handle_uncaught
24
+
25
+ args = sys.argv[1:] if argv is None else list(argv)
26
+ try:
27
+ return run(args)
28
+ except SystemExit:
29
+ raise
30
+ except BaseException as exc: # noqa: BLE001 — the whole point is to catch all
31
+ handle_uncaught(exc)
32
+
33
+
34
+ if __name__ == "__main__":
35
+ sys.exit(main())
@@ -0,0 +1,6 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Enable ``python -m kerykeion_cli``."""
3
+
4
+ from kerykeion_cli import main
5
+
6
+ raise SystemExit(main())
kerykeion_cli/app.py ADDED
@@ -0,0 +1,87 @@
1
+ # -*- coding: utf-8 -*-
2
+ """The command tree and its dispatch.
3
+
4
+ :func:`build_parser` mounts every command and group on the root parser;
5
+ :func:`run` parses one command line and calls the chosen command with its
6
+ flags as keyword arguments. No kerykeion symbol is imported at module level,
7
+ which keeps the cold-import gate green.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ from importlib.metadata import PackageNotFoundError, version as _pkg_version
14
+ from typing import Any, Callable
15
+
16
+ from kerykeion_cli import errors, warnings
17
+ from kerykeion_cli.parser import add_command, add_group
18
+
19
+ try:
20
+ __version__ = _pkg_version("kerykeion-cli")
21
+ except PackageNotFoundError: # a source tree without installation
22
+ __version__ = "0.0.0"
23
+
24
+ DESCRIPTION = (
25
+ "Astrology from the terminal. Save a subject once (kerykeion subject save ada --date 1990-07-15 "
26
+ "--time 10:30 --lat 41.9 --lng 12.5 --tz Europe/Rome), then pass -s ada to any command. "
27
+ "A terminal gets a text report, a pipe gets JSON; -f text|json|xml|svg and -o FILE choose explicitly."
28
+ )
29
+
30
+ # Namespace entries that are the parser's, not a command's.
31
+ _INTERNAL = frozenset({"handler", "menu", "traceback", "warnings_as_errors", "envelope"})
32
+
33
+
34
+ def build_parser() -> argparse.ArgumentParser:
35
+ """The root parser with every command mounted: charts, analyses, techniques and events, subjects and setup."""
36
+ from kerykeion_cli.commands import analysis, call, charts, info, series, sky, status, subject, technique
37
+
38
+ root = argparse.ArgumentParser(prog="kerykeion", description=DESCRIPTION)
39
+ root.add_argument("-V", "--version", action="version", version=__version__, help="Print the kerykeion-cli version and exit.")
40
+ root.add_argument("--traceback", action="store_true", help="Show a full traceback on error (default: a one-line message).")
41
+ root.add_argument("--warnings-as-errors", action="store_true", help="Exit 9 when any ephemeris warning or house fallback occurs.")
42
+ root.set_defaults(menu=root)
43
+ subparsers = root.add_subparsers(metavar="<command>")
44
+ charts_and_analyses: list[tuple[str, Callable[..., Any]]] = [
45
+ ("natal", charts.natal),
46
+ ("now", charts.now),
47
+ ("synastry", charts.synastry),
48
+ ("transit", charts.transit), # the single-moment dual wheel
49
+ ("composite", charts.composite),
50
+ ("return", charts.return_chart), # `return` is a keyword, hence the callable's name
51
+ ("progression", charts.progression),
52
+ ("aspects", analysis.aspects),
53
+ ("dominants", analysis.dominants),
54
+ ("moon", analysis.moon),
55
+ ("relationship-score", analysis.relationship_score),
56
+ ]
57
+ for name, func in charts_and_analyses:
58
+ add_command(subparsers, name, func)
59
+ add_group(subparsers, "technique", "Analytical techniques on a stored subject.", technique.COMMANDS)
60
+ add_group(subparsers, "sky", "Sun, Moon and planet events, at a moment or over a range.", sky.COMMANDS)
61
+ series_commands: list[tuple[str, Callable[..., Any]]] = [("ephemeris", series.ephemeris), ("transits", series.transits)]
62
+ for name, func in series_commands: # the time series
63
+ add_command(subparsers, name, func)
64
+ add_group(subparsers, "subject", "Save and inspect subjects; -s <name> reuses them everywhere.", subject.COMMANDS)
65
+ add_group(subparsers, "info", "What the flags accept: literals, point sets, fixed stars, methods.", info.COMMANDS)
66
+ add_command(subparsers, "status", status.status)
67
+ add_command(subparsers, "call", call.call)
68
+ return root
69
+
70
+
71
+ def run(argv: list[str]) -> int:
72
+ """Parse *argv* and run the chosen command; a group or a bare ``kerykeion`` prints its help."""
73
+ args = build_parser().parse_args(argv)
74
+ errors.set_traceback_enabled(args.traceback)
75
+ errors.set_warnings_as_errors(args.warnings_as_errors)
76
+ warnings.set_envelope(bool(getattr(args, "envelope", False)))
77
+ handler = getattr(args, "handler", None)
78
+ if handler is None:
79
+ args.menu.print_help()
80
+ return 0
81
+ kwargs = {name: value for name, value in vars(args).items() if name not in _INTERNAL}
82
+ if getattr(handler, "render_flags", False):
83
+ from kerykeion_cli.commands._shared import _RENDER_FLAGS, _render_options
84
+
85
+ kwargs["opts"] = _render_options({flag: kwargs.pop(flag, None) for flag in _RENDER_FLAGS})
86
+ handler(**kwargs)
87
+ return 0
@@ -0,0 +1,2 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Command modules, mounted on the parser by :mod:`kerykeion_cli.app`."""
@@ -0,0 +1,186 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Helpers shared across the command modules — everything a command would otherwise repeat.
3
+
4
+ A leaf module: it imports the CLI ``options``/``warnings``/``rendering`` helpers
5
+ and the stdlib, never another command module, so importing it cannot cycle.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from datetime import datetime
11
+ from typing import Any, Callable, Optional, TypeVar
12
+
13
+ from kerykeion_cli import options, warnings
14
+ from kerykeion_cli import rendering
15
+
16
+ _C = TypeVar("_C", bound=Callable[..., Any])
17
+
18
+
19
+ def _emit(model: object, fmt: Optional[str], output: Optional[str], opts: object = None) -> None:
20
+ """Resolve the format and route the payload through the warnings funnel."""
21
+ warnings.output_with_warnings(model, rendering.resolve_format(fmt, output), output, opts=opts)
22
+
23
+
24
+ def _given(**flags: Any) -> dict[str, Any]:
25
+ """The flags actually given, keyed by the library parameter they feed; ``None`` lets the library default decide."""
26
+ return {name: value for name, value in flags.items() if value is not None}
27
+
28
+
29
+ def _stored_subject(spec: Optional[str], cmd: str, flag: str = "-s", **flags: Any):
30
+ """The stored subject a command names with ``-s`` (or ``-S``), or a usable error."""
31
+ if not spec:
32
+ raise ValueError(f"{cmd} needs {flag} <profile>")
33
+ from kerykeion_cli import subject_resolver
34
+
35
+ return subject_resolver.resolve_subject(subject_resolver.SubjectFlags(**flags), spec)
36
+
37
+
38
+ def _split_csv(values: Optional[list[str]]) -> Optional[list[str]]:
39
+ """Flatten a repeatable option that may also carry comma-separated tokens."""
40
+ if values is None:
41
+ return None
42
+ out = [part.strip() for item in values for part in item.split(",") if part.strip()]
43
+ return out or None
44
+
45
+
46
+ def _parse_dt(value: str) -> datetime:
47
+ """Parse an ISO date or datetime, with a usable error otherwise."""
48
+ try:
49
+ return datetime.fromisoformat(value)
50
+ except ValueError as exc:
51
+ raise ValueError(f"expected an ISO date or datetime (YYYY-MM-DD or YYYY-MM-DDThh:mm), got {value!r}") from exc
52
+
53
+
54
+ def _choose(value: object, allowed: tuple[str, ...], label: str) -> object:
55
+ """Validate an enum-style flag case-insensitively, returning the canonical form."""
56
+ if value is None:
57
+ return None
58
+ canonical = {choice.lower(): choice for choice in allowed}
59
+ if str(value).strip().lower() not in canonical:
60
+ raise ValueError(f"--{label} must be {' or '.join(allowed)}, got {value!r}")
61
+ return canonical[str(value).strip().lower()]
62
+
63
+
64
+ # ── --aspects: one syntax for the two shapes the library asks for ────────────
65
+ # A plain list of names (mundane, solar arc, primary directions) or a list of
66
+ # {name, orb} records (AspectsFactory). The optional ``:orb`` suffix is what the
67
+ # second shape needs and the first cannot use, so _aspect_names refuses it by name.
68
+
69
+
70
+ def _parse_aspects(values: Optional[list[str]]) -> Optional[list[tuple[str, Optional[float]]]]:
71
+ """``--aspects`` tokens of ``name`` or ``name:orb`` → ``[(name, orb-or-None), …]``."""
72
+ tokens = _split_csv(values)
73
+ if tokens is None:
74
+ return None
75
+ parsed: list[tuple[str, Optional[float]]] = []
76
+ for token in tokens:
77
+ name, sep, raw_orb = token.partition(":")
78
+ if not name.strip():
79
+ raise ValueError(f"--aspects: empty aspect name in {token!r}")
80
+ if not sep:
81
+ parsed.append((name.strip(), None))
82
+ continue
83
+ try:
84
+ parsed.append((name.strip(), float(raw_orb)))
85
+ except ValueError:
86
+ raise ValueError(
87
+ f"--aspects: {raw_orb.strip()!r} is not a number for the orb of {name.strip()!r} (use e.g. 'trine:6')"
88
+ ) from None
89
+ return parsed or None
90
+
91
+
92
+ def _aspect_names(parsed: Optional[list[tuple[str, Optional[float]]]], context: str) -> Optional[list[str]]:
93
+ """Aspect names only, for the factories that take no per-aspect orb."""
94
+ if parsed is None:
95
+ return None
96
+ with_orb = [name for name, orb in parsed if orb is not None]
97
+ if with_orb:
98
+ raise ValueError(
99
+ f"--aspects: {context} takes aspect names without an orb; drop the ':orb' from {with_orb[0]!r}."
100
+ )
101
+ return [name for name, _ in parsed]
102
+
103
+
104
+ def _active_aspects(parsed: Optional[list[tuple[str, Optional[float]]]]) -> Optional[list[dict[str, object]]]:
105
+ """``ActiveAspect`` records; an omitted orb takes the library's own default."""
106
+ if parsed is None:
107
+ return None
108
+ from kerykeion.settings import config_constants as cc
109
+
110
+ defaults = {str(entry["name"]): float(entry["orb"]) for entry in cc.ALL_ACTIVE_ASPECTS}
111
+ out: list[dict[str, object]] = []
112
+ for name, orb in parsed:
113
+ if orb is None and name not in defaults:
114
+ raise ValueError(
115
+ f"--aspects: unknown aspect {name!r}; choose from {', '.join(sorted(defaults))} "
116
+ "(or give an explicit orb, 'name:6')."
117
+ )
118
+ out.append({"name": name, "orb": defaults[name] if orb is None else orb})
119
+ return out
120
+
121
+
122
+ # ── The shared flag sets are declared once, here ──────────────────────────────
123
+ # A command is built from its signature, so the commands must spell the flags;
124
+ # these keep the *set* in one place and turn a dropped flag into a loud failure
125
+ # instead of one that quietly does nothing.
126
+
127
+ _SUBJECT_FLAGS = (
128
+ "name", "date", "time", "seconds", "iso_utc", "lat", "lng", "tz", "city", "nation", "online", "offline",
129
+ "altitude", "zodiac", "sidereal_mode", "houses", "perspective", "points", "fixed_stars", "with_flags",
130
+ "without_flags", "set_flags",
131
+ ) # fmt: skip
132
+
133
+
134
+ def _subject_from(scope: dict, **overrides: object):
135
+ """``SubjectFlags`` from a command's ``locals()``; *overrides* replace flags the command does not expose."""
136
+ from kerykeion_cli import subject_resolver
137
+
138
+ missing = [name for name in _SUBJECT_FLAGS if name not in scope and name not in overrides]
139
+ if missing:
140
+ raise AssertionError(f"subject flags absent from the command signature: {', '.join(missing)}")
141
+ given: dict[str, Any] = {**{name: scope.get(name) for name in _SUBJECT_FLAGS}, **overrides}
142
+ for name in ("with_flags", "without_flags", "set_flags"): # repeatable options arrive as None
143
+ given[name] = given[name] or []
144
+ return subject_resolver.SubjectFlags(**given)
145
+
146
+
147
+ # CLI flag → (option alias, library parameter). Flag and parameter differ where
148
+ # the flag reads better short and where the paired --x/--no-x form needs the
149
+ # bare stem; ``no_aspects`` (the negative face of ``include_aspects``) is
150
+ # translated in _render_options instead.
151
+ _RENDER_FLAGS: dict[str, tuple[Any, Optional[str]]] = {
152
+ "no_aspects": (options.NoAspectsFlag, None),
153
+ "max_aspects": (options.MaxAspectsOpt, "max_aspects"),
154
+ "theme": (options.ThemeOpt, "theme"),
155
+ "chart_language": (options.ChartLanguageOpt, "chart_language"),
156
+ "style": (options.ChartStyleOpt, "style"),
157
+ "custom_title": (options.CustomTitleOpt, "custom_title"),
158
+ "padding": (options.PaddingOpt, "padding"),
159
+ "external_view": (options.ExternalViewFlag, "external_view"),
160
+ "transparent_background": (options.TransparentBackgroundFlag, "transparent_background"),
161
+ "cusp_position_comparison": (options.CuspComparisonFlag, "show_cusp_position_comparison"),
162
+ "auto_size": (options.AutoSizeFlag, "auto_size"),
163
+ "degree_indicators": (options.DegreeIndicatorsFlag, "show_degree_indicators"),
164
+ "aspect_icons": (options.AspectIconsFlag, "show_aspect_icons"),
165
+ "zodiac_ring": (options.ZodiacRingFlag, "show_zodiac_background_ring"),
166
+ "diurnality": (options.DiurnalityFlag, "show_diurnality"),
167
+ "house_position_comparison": (options.HousePositionComparisonFlag, "show_house_position_comparison"),
168
+ "aspect_grid_type": (options.AspectGridTypeOpt, "double_chart_aspect_grid_type"),
169
+ "svg_variant": (options.SvgVariantOpt, "svg_variant"),
170
+ "chart_settings": (options.ChartSettingsOpt, "chart_settings"),
171
+ }
172
+
173
+
174
+ def _render_options(given: dict[str, Any]) -> object:
175
+ """The render flags → ``RenderOptions`` (``None`` if none were given)."""
176
+ from kerykeion_cli import render_options
177
+
178
+ kwargs = {param: given[flag] for flag, (_, param) in _RENDER_FLAGS.items() if param}
179
+ kwargs["include_aspects"] = False if given["no_aspects"] else None # not passing it stays "not given"
180
+ return render_options.build(**kwargs)
181
+
182
+
183
+ def with_render_flags(command: _C) -> _C:
184
+ """Mark *command* as taking the shared report/chart flags: the parser declares them, the dispatcher hands them over as ``opts``."""
185
+ command.render_flags = True # type: ignore[attr-defined]
186
+ return command
@@ -0,0 +1,159 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Analysis commands: ``aspects``, ``dominants``, ``moon``, ``relationship-score``.
3
+
4
+ The four most common questions asked of a chart, reported rather than drawn.
5
+ All of them are reachable through ``kerykeion call`` too; these give each a
6
+ real ``--help``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ from typing import Any
13
+
14
+ from kerykeion_cli.commands._shared import _active_aspects, _emit, _given, _parse_aspects, _split_csv, _stored_subject
15
+ from kerykeion_cli.options import (
16
+ AccidentalDignitiesFlag,
17
+ AspectsOpt,
18
+ AxisOrbLimitOpt,
19
+ CustomWeightsOpt,
20
+ DeclinationOrbOpt,
21
+ DeclinationsFlag,
22
+ DistributionMethodOpt,
23
+ DominantMethodOpt,
24
+ FormatOpt,
25
+ LocationPrecisionOpt,
26
+ AllAspectsFlag,
27
+ OutputOpt,
28
+ PlanetsOpt,
29
+ ScoreBreakdownFlag,
30
+ Subject2Profile,
31
+ SubjectProfile,
32
+ UsingDefaultLocationFlag,
33
+ )
34
+
35
+
36
+ def aspects(
37
+ profile: SubjectProfile = None,
38
+ subject2: Subject2Profile = None,
39
+ declinations: DeclinationsFlag = None,
40
+ planets: PlanetsOpt = None,
41
+ aspect_list: AspectsOpt = None,
42
+ axis_orb_limit: AxisOrbLimitOpt = None,
43
+ orb: DeclinationOrbOpt = None,
44
+ fmt: FormatOpt = None,
45
+ output: OutputOpt = None,
46
+ ) -> None:
47
+ """Aspects within a chart, or between two (-S).
48
+
49
+ Declination aspects take a single ``--orb`` and have no per-aspect table
50
+ or axis rule, so ``--aspects`` and ``--axis-orb-limit`` are rejected there.
51
+ """
52
+ from kerykeion import AspectsFactory
53
+
54
+ first = _stored_subject(profile, "aspects")
55
+ second = _stored_subject(subject2, "aspects", "-S") if subject2 else None
56
+ kwargs: dict[str, Any] = _given(active_points=_split_csv(planets))
57
+ model: object
58
+ if declinations:
59
+ rejected = [f for f, v in (("--aspects", aspect_list), ("--axis-orb-limit", axis_orb_limit)) if v is not None]
60
+ if rejected:
61
+ raise ValueError(
62
+ f"{rejected[0]} does not apply to declination aspects; they use a single --orb and have no per-aspect table."
63
+ )
64
+ kwargs.update(_given(orb=orb))
65
+ model = (
66
+ AspectsFactory.dual_chart_declination_aspects(first, second, **kwargs) # type: ignore[arg-type]
67
+ if second is not None
68
+ else AspectsFactory.single_chart_declination_aspects(first, **kwargs) # type: ignore[arg-type]
69
+ )
70
+ else:
71
+ if orb is not None:
72
+ raise ValueError(
73
+ "--orb applies to --declinations; for ecliptic aspects give the orb per aspect, e.g. --aspects trine:6."
74
+ )
75
+ if axis_orb_limit is not None and axis_orb_limit <= 0: # invalid input (4), not a library error (5)
76
+ raise ValueError("--axis-orb-limit must be a positive number.")
77
+ kwargs.update(
78
+ _given(active_aspects=_active_aspects(_parse_aspects(aspect_list)), axis_orb_limit=axis_orb_limit)
79
+ )
80
+ model = (
81
+ AspectsFactory.dual_chart_aspects(first, second, **kwargs) # type: ignore[arg-type]
82
+ if second is not None
83
+ else AspectsFactory.single_chart_aspects(first, **kwargs) # type: ignore[arg-type]
84
+ )
85
+ _emit(model, fmt, output)
86
+
87
+
88
+ def dominants(
89
+ profile: SubjectProfile = None,
90
+ method: DominantMethodOpt = None,
91
+ planets: PlanetsOpt = None,
92
+ distribution_method: DistributionMethodOpt = None,
93
+ custom_weights: CustomWeightsOpt = None,
94
+ accidental_dignities: AccidentalDignitiesFlag = None,
95
+ score_breakdown: ScoreBreakdownFlag = None,
96
+ fmt: FormatOpt = None,
97
+ output: OutputOpt = None,
98
+ ) -> None:
99
+ """Dominant signs, elements, qualities and planets."""
100
+ from kerykeion import DominantsFactory
101
+
102
+ subject = _stored_subject(profile, "dominants")
103
+ kwargs: dict[str, Any] = _given(
104
+ active_points=_split_csv(planets),
105
+ distribution_method=distribution_method,
106
+ include_accidental_dignities=accidental_dignities,
107
+ include_score_breakdown=score_breakdown,
108
+ )
109
+ if method is not None: # validated against what the library reports, so a new strategy works the day it ships
110
+ available = list(DominantsFactory.available_methods())
111
+ chosen = {name.lower(): name for name in available}.get(method.strip().lower())
112
+ if chosen is None:
113
+ raise ValueError(f"--method must be one of {', '.join(available)}, got {method!r}")
114
+ kwargs["strategy"] = chosen
115
+ if custom_weights is not None:
116
+ try:
117
+ weights = json.loads(custom_weights)
118
+ except json.JSONDecodeError as exc:
119
+ raise ValueError(
120
+ f"--custom-weights is not valid JSON ({exc.msg}); expected an object like '{{\"Sun\": 1.5}}'."
121
+ ) from None
122
+ if not isinstance(weights, dict):
123
+ raise ValueError("--custom-weights must be a JSON object, e.g. '{\"Sun\": 1.5}'.")
124
+ kwargs["custom_weights"] = weights
125
+ _emit(DominantsFactory.from_subject(subject, **kwargs), fmt, output) # type: ignore[arg-type]
126
+
127
+
128
+ def moon(
129
+ profile: SubjectProfile = None,
130
+ using_default_location: UsingDefaultLocationFlag = None,
131
+ location_precision: LocationPrecisionOpt = None,
132
+ fmt: FormatOpt = None,
133
+ output: OutputOpt = None,
134
+ ) -> None:
135
+ """Moon phase at the subject's moment and place."""
136
+ from kerykeion import MoonPhaseDetailsFactory
137
+
138
+ subject = _stored_subject(profile, "moon")
139
+ kwargs = _given(using_default_location=using_default_location, location_precision=location_precision)
140
+ _emit(MoonPhaseDetailsFactory.from_subject(subject, **kwargs), fmt, output) # type: ignore[arg-type]
141
+
142
+
143
+ def relationship_score(
144
+ profile: SubjectProfile = None,
145
+ subject2: Subject2Profile = None,
146
+ all_aspects: AllAspectsFlag = None,
147
+ axis_orb_limit: AxisOrbLimitOpt = None,
148
+ fmt: FormatOpt = None,
149
+ output: OutputOpt = None,
150
+ ) -> None:
151
+ """Discepolo relationship score of two stored subjects."""
152
+ from kerykeion import RelationshipScoreFactory
153
+
154
+ first = _stored_subject(profile, "relationship-score")
155
+ second = _stored_subject(subject2, "relationship-score", "-S")
156
+ kwargs = _given(
157
+ use_only_major_aspects=None if all_aspects is None else not all_aspects, axis_orb_limit=axis_orb_limit
158
+ )
159
+ _emit(RelationshipScoreFactory(first, second, **kwargs).get_relationship_score(), fmt, output) # type: ignore[arg-type]
@@ -0,0 +1,88 @@
1
+ # -*- coding: utf-8 -*-
2
+ """``kerykeion call`` — a guarded dispatcher over the public API.
3
+
4
+ kerykeion call ProfectionsFactory.from_subject -s ada --param target_date=2025-06-01
5
+ kerykeion call --list
6
+ kerykeion call DominantsFactory.from_subject --explain
7
+
8
+ Safety is :mod:`kerykeion_cli.registry`'s job (only ``__all__`` names, no
9
+ private members, no models/exceptions). Subject parameters are bound from
10
+ ``-s``/``-S`` profiles; everything else comes as ``--param key=value`` with
11
+ type coercion from :mod:`kerykeion_cli.introspect`.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import Annotated, Any, Optional
17
+
18
+ from kerykeion_cli import introspect, registry, subject_resolver
19
+ from kerykeion_cli.commands._shared import _emit
20
+ from kerykeion_cli.options import (
21
+ CallSubject2Opt,
22
+ ExplainFlag,
23
+ FormatOpt,
24
+ ListFlag,
25
+ OutputOpt,
26
+ ParamOpt,
27
+ SubjectProfile,
28
+ )
29
+ from kerykeion_cli.parser import Arg
30
+
31
+
32
+ def _bind_subjects(
33
+ target: registry.ResolvedTarget, kwargs: dict[str, Any], first: Optional[str], second: Optional[str]
34
+ ) -> None:
35
+ """Assign the -s/-S profiles to the target's subject parameters, in order."""
36
+ slots = [p.name for p in introspect.explain(target) if p.classification == introspect.SUBJECT]
37
+ for flag, spec, index in (("-s", first, 0), ("-S", second, 1)):
38
+ if spec is None:
39
+ continue
40
+ if len(slots) <= index:
41
+ raise ValueError(
42
+ f"{target.spec} has {'no' if index == 0 else 'fewer than two'} subject parameter{'' if index == 0 else 's'}; {flag} is not used here."
43
+ )
44
+ kwargs[slots[index]] = subject_resolver.resolve_subject(subject_resolver.SubjectFlags(), spec)
45
+
46
+
47
+ def call(
48
+ target_arg: Annotated[Optional[str], Arg(help="Call target: Factory.method or a bare function.")] = None,
49
+ list_flag: ListFlag = None,
50
+ explain_flag: ExplainFlag = None,
51
+ profile: SubjectProfile = None,
52
+ subject2: CallSubject2Opt = None,
53
+ param: ParamOpt = None,
54
+ fmt: FormatOpt = None,
55
+ output: OutputOpt = None,
56
+ ) -> None:
57
+ """Any public factory method, for what has no command of its own."""
58
+ if list_flag:
59
+ _emit(registry.list_targets(), fmt, output)
60
+ return
61
+ if target_arg is None:
62
+ raise ValueError("call needs a target (Factory.method) or --list.")
63
+ target = registry.resolve_target(target_arg)
64
+ if explain_flag:
65
+ _emit([p.as_dict() for p in introspect.explain(target)], fmt, output)
66
+ return
67
+
68
+ known = {**target.init_params, **target.method_params}
69
+ kwargs: dict[str, Any] = {}
70
+ for item in param or []:
71
+ if "=" not in item:
72
+ raise ValueError(f"--param expects key=value, got {item!r}")
73
+ key, raw_value = item.split("=", 1)
74
+ key = key.strip()
75
+ if key not in known: # a typo must not run the library with defaults and no error
76
+ raise ValueError(f"--param {key!r} is not a parameter of {target.spec} (known: {', '.join(sorted(known))})")
77
+ kwargs[key] = introspect.coerce_value(known[key].annotation, raw_value)
78
+ _bind_subjects(target, kwargs, profile, subject2)
79
+
80
+ if target.needs_instance:
81
+ owner = registry.public_names()[target.owner_name]
82
+ instance = owner(**{k: v for k, v in kwargs.items() if k in target.init_params})
83
+ result = getattr(instance, target.member_name)(**{k: v for k, v in kwargs.items() if k in target.method_params}) # type: ignore[arg-type]
84
+ elif target.member_name: # static or classmethod: through the owner so Python binds ``cls``
85
+ result = getattr(registry.public_names()[target.owner_name], target.member_name)(**kwargs)
86
+ else:
87
+ result = target.callable_fn(**kwargs)
88
+ _emit(result, fmt, output)