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.
- kerykeion_cli/__init__.py +35 -0
- kerykeion_cli/__main__.py +6 -0
- kerykeion_cli/app.py +87 -0
- kerykeion_cli/commands/__init__.py +2 -0
- kerykeion_cli/commands/_shared.py +186 -0
- kerykeion_cli/commands/analysis.py +159 -0
- kerykeion_cli/commands/call.py +88 -0
- kerykeion_cli/commands/charts.py +332 -0
- kerykeion_cli/commands/info.py +100 -0
- kerykeion_cli/commands/series.py +174 -0
- kerykeion_cli/commands/sky.py +406 -0
- kerykeion_cli/commands/status.py +22 -0
- kerykeion_cli/commands/subject.py +140 -0
- kerykeion_cli/commands/technique.py +336 -0
- kerykeion_cli/diagnostics.py +265 -0
- kerykeion_cli/errors.py +155 -0
- kerykeion_cli/introspect.py +255 -0
- kerykeion_cli/options.py +388 -0
- kerykeion_cli/parser.py +115 -0
- kerykeion_cli/profiles.py +179 -0
- kerykeion_cli/registry.py +157 -0
- kerykeion_cli/render_options.py +168 -0
- kerykeion_cli/rendering.py +124 -0
- kerykeion_cli/subject_resolver.py +433 -0
- kerykeion_cli/warnings.py +143 -0
- kerykeion_cli-6.0.0.data/data/share/man/man1/kerykeion.1 +354 -0
- kerykeion_cli-6.0.0.dist-info/METADATA +100 -0
- kerykeion_cli-6.0.0.dist-info/RECORD +31 -0
- kerykeion_cli-6.0.0.dist-info/WHEEL +4 -0
- kerykeion_cli-6.0.0.dist-info/entry_points.txt +2 -0
- kerykeion_cli-6.0.0.dist-info/licenses/LICENSE +661 -0
|
@@ -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())
|
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,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)
|