Skip to content

Commit 0ca8250

Browse files
committed
Allow users to override the Swiss Ephemeris directory
Previously Stellium hard-coded ~/.stellium/ephe/ as the only location for Swiss Ephemeris data files. That works for the common case but breaks for portable installs (PythonPortable on D:\), read-only $HOME environments (Docker, Lambda, shared hosts), and users who already maintain a .se1 folder for another astrology tool and don't want a second copy. Add a small, layered override mechanism mirroring the convention used by pip, cargo, pyenv, HuggingFace, and friends: 1. SwissEphemerisEngine(ephe_path=...) — explicit argument, highest priority. 2. STELLIUM_EPHE_PATH environment variable — no code changes needed. 3. Default ~/.stellium/ephe/ — unchanged behavior for existing users. When a custom path is supplied Stellium uses it as-is: the directory is not created and the bundled essentials are not copied into it. Missing custom directories emit a stderr warning instead of crashing so misconfiguration fails loudly at the first calculation. Also: - Track the currently active directory (_active_ephe_dir) so get_ephe_dir() and has_ephe_file() report against the override rather than USER_EPHE_DIR. - Allow re-initialization against a different path; same path is a no-op. - Document all three mechanisms in README.md under a new "Ephemeris Data Location" section. - Add tests/test_ephemeris_paths.py covering precedence, expansion, no-copy semantics, has_ephe_file routing, env-var pass-through, and an end-to-end chart calculation against a populated custom path.
1 parent 10daea7 commit 0ca8250

4 files changed

Lines changed: 407 additions & 38 deletions

File tree

README.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -411,6 +411,44 @@ See `stellium --help` for full CLI documentation.
411411

412412
---
413413

414+
## Ephemeris Data Location
415+
416+
Stellium bundles enough Swiss Ephemeris data to cover **1800–2999 CE** and
417+
automatically copies it to `~/.stellium/ephe/` on first use, so most users
418+
never need to download anything. Use `stellium ephemeris download` if you
419+
need coverage outside that range or extra asteroid files.
420+
421+
### Using a custom ephemeris directory
422+
423+
You can point Stellium at any existing Swiss Ephemeris folder — handy for
424+
portable installs, read-only home directories (Docker, Lambda, shared
425+
hosts), or for reusing a folder you already maintain for another astrology
426+
tool. Two options, in order of precedence:
427+
428+
```python
429+
# 1. Explicit argument wins over everything else
430+
from stellium import ChartBuilder
431+
from stellium.engines.ephemeris import SwissEphemerisEngine
432+
433+
chart = (ChartBuilder.from_native(native)
434+
.with_ephemeris(SwissEphemerisEngine(ephe_path=r"D:\swisseph\ephe"))
435+
.calculate())
436+
```
437+
438+
```bash
439+
# 2. Environment variable — no code changes required
440+
export STELLIUM_EPHE_PATH=/opt/swisseph/ephe # macOS / Linux
441+
set STELLIUM_EPHE_PATH=D:\swisseph\ephe # Windows (cmd)
442+
$env:STELLIUM_EPHE_PATH = "D:\swisseph\ephe" # Windows (PowerShell)
443+
```
444+
445+
When you supply a custom path Stellium uses it **as-is**: the folder is not
446+
created, and the bundled ephemeris files are **not** copied into it.
447+
Make sure it already contains every `.se1` file you need for the objects
448+
and date range you plan to calculate.
449+
450+
---
451+
414452
## 🔍 Feature Highlights
415453

416454
### Zodiac Systems

src/stellium/data/paths.py

Lines changed: 98 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,12 @@
2727
USER_DATA_DIR = Path.home() / ".stellium"
2828
USER_EPHE_DIR = USER_DATA_DIR / "ephe"
2929

30+
# Environment variable that lets users override the ephemeris directory
31+
# without touching code — handy for portable installs, read-only $HOME
32+
# environments (Docker, Lambda, shared hosts), and for reusing an existing
33+
# Swiss Ephemeris folder from another astrology tool.
34+
ENV_EPHE_PATH = "STELLIUM_EPHE_PATH"
35+
3036
# Package data locations (using importlib.resources)
3137
PACKAGE_DATA_MODULE = "stellium.data"
3238

@@ -41,8 +47,12 @@
4147
"sefstars.txt", # Fixed stars catalog
4248
]
4349

44-
# Track whether ephemeris path has been initialized this session
50+
# Track whether the ephemeris path has been initialized this session, and
51+
# which directory is currently active. `_active_ephe_dir` is the source of
52+
# truth once initialization has happened — it may differ from USER_EPHE_DIR
53+
# when the user supplies a custom path.
4554
_ephe_initialized = False
55+
_active_ephe_dir: Path | None = None
4656

4757

4858
def get_user_data_dir() -> Path:
@@ -129,74 +139,132 @@ def _copy_bundled_ephe_files() -> int:
129139
return copied
130140

131141

132-
def initialize_ephemeris() -> Path:
142+
def _resolve_ephe_path(ephe_path: str | Path | None) -> tuple[Path, bool]:
133143
"""
134-
Initialize the ephemeris system.
135-
136-
This function:
137-
1. Ensures the user ephe directory exists
138-
2. Copies bundled ephemeris files to user directory (first run only)
139-
3. Sets the Swiss Ephemeris path
144+
Resolve which ephemeris directory to use, following the precedence:
140145
141-
Call this once at startup or before any ephemeris calculations.
146+
1. Explicit ``ephe_path`` argument (highest priority)
147+
2. ``STELLIUM_EPHE_PATH`` environment variable
148+
3. Default ``~/.stellium/ephe/``
142149
143150
Returns:
144-
Path to the ephemeris directory being used
151+
A tuple of (resolved_path, is_custom). ``is_custom`` is True when the
152+
path came from an override — in that case we do not create the
153+
directory, copy bundled files into it, or otherwise touch its
154+
contents; we assume the caller already manages it.
155+
"""
156+
if ephe_path is not None:
157+
return Path(ephe_path).expanduser(), True
158+
159+
env_value = os.environ.get(ENV_EPHE_PATH, "").strip()
160+
if env_value:
161+
return Path(env_value).expanduser(), True
162+
163+
return USER_EPHE_DIR, False
164+
165+
166+
def initialize_ephemeris(ephe_path: str | Path | None = None) -> Path:
145167
"""
146-
global _ephe_initialized
168+
Initialize the ephemeris system.
169+
170+
This function:
171+
1. Resolves which ephemeris directory to use (explicit arg >
172+
``STELLIUM_EPHE_PATH`` env var > default ``~/.stellium/ephe/``)
173+
2. For the default location: ensures the directory exists and copies
174+
bundled ephemeris files to it (first run only)
175+
3. Sets the Swiss Ephemeris path via ``swe.set_ephe_path``
147176
148-
if _ephe_initialized:
149-
return USER_EPHE_DIR
177+
When a custom path is supplied the directory is used as-is: Stellium
178+
will not create it or copy its bundled files into it. This makes it
179+
safe to point at an existing Swiss Ephemeris installation managed by
180+
another tool, or at a read-only folder.
150181
151-
# Ensure user directory exists
152-
ephe_dir = get_user_ephe_dir()
182+
If ``initialize_ephemeris`` is called a second time with a different
183+
path, the ephemeris is re-initialized against the new location.
153184
154-
# Copy bundled files if needed (idempotent - skips existing)
155-
copied = _copy_bundled_ephe_files()
156-
if copied > 0:
157-
print(f"Stellium: Initialized {copied} ephemeris files in {ephe_dir}")
185+
Args:
186+
ephe_path: Optional override for the ephemeris directory. Accepts a
187+
``str`` or ``pathlib.Path``. If omitted, falls back to the
188+
``STELLIUM_EPHE_PATH`` environment variable, then to
189+
``~/.stellium/ephe/``.
158190
159-
# Set Swiss Ephemeris path
160-
swe.set_ephe_path(str(ephe_dir) + os.sep)
191+
Returns:
192+
Path to the ephemeris directory that is now active.
193+
"""
194+
global _ephe_initialized, _active_ephe_dir
195+
196+
resolved, is_custom = _resolve_ephe_path(ephe_path)
197+
198+
# If already initialized against the same directory, nothing to do.
199+
if _ephe_initialized and _active_ephe_dir == resolved:
200+
return resolved
201+
202+
if is_custom:
203+
# Custom path: use as-is. Do not create the directory, do not copy
204+
# bundled files — the caller is responsible for what lives there.
205+
# We still warn (not raise) if it doesn't exist so that misconfigured
206+
# paths fail loudly at the first calculation rather than silently.
207+
if not resolved.exists():
208+
print(
209+
f"Stellium: warning — custom ephemeris path {resolved} does "
210+
"not exist. Swiss Ephemeris calculations will fail until "
211+
"the directory is created and populated.",
212+
file=sys.stderr,
213+
)
214+
else:
215+
# Default location: ensure the directory exists and populate it
216+
# with the essential bundled files on first run.
217+
resolved.mkdir(parents=True, exist_ok=True)
218+
copied = _copy_bundled_ephe_files()
219+
if copied > 0:
220+
print(f"Stellium: Initialized {copied} ephemeris files in {resolved}")
221+
222+
# Set Swiss Ephemeris path (trailing separator is required by the C lib).
223+
swe.set_ephe_path(str(resolved) + os.sep)
161224

162225
_ephe_initialized = True
163-
return ephe_dir
226+
_active_ephe_dir = resolved
227+
return resolved
164228

165229

166230
def get_ephe_dir() -> Path:
167231
"""
168232
Get the ephemeris directory, initializing if necessary.
169233
170234
This is the main function that should be used throughout the codebase
171-
to get the ephemeris path.
235+
to get the ephemeris path. Respects any override previously set via
236+
:func:`initialize_ephemeris` or the ``STELLIUM_EPHE_PATH`` env var.
172237
173238
Returns:
174-
Path to the ephemeris directory
239+
Path to the ephemeris directory currently in use.
175240
"""
176241
if not _ephe_initialized:
177242
initialize_ephemeris()
178-
return USER_EPHE_DIR
243+
assert _active_ephe_dir is not None # just initialized
244+
return _active_ephe_dir
179245

180246

181247
def reset_ephe_initialization() -> None:
182248
"""
183249
Reset the ephemeris initialization flag.
184250
185-
Useful for testing or if you need to reinitialize.
251+
Useful for testing or if you need to reinitialize against a different
252+
directory.
186253
"""
187-
global _ephe_initialized
254+
global _ephe_initialized, _active_ephe_dir
188255
_ephe_initialized = False
256+
_active_ephe_dir = None
189257

190258

191259
# Convenience function for checking if a specific ephemeris file exists
192260
def has_ephe_file(filename: str) -> bool:
193261
"""
194-
Check if a specific ephemeris file exists in the user directory.
262+
Check if a specific ephemeris file exists in the active directory.
195263
196264
Args:
197265
filename: Name of the ephemeris file (e.g., "se136199.se1")
198266
199267
Returns:
200-
True if the file exists
268+
True if the file exists in the directory currently being used.
201269
"""
202-
return (get_user_ephe_dir() / filename).exists()
270+
return (get_ephe_dir() / filename).exists()

src/stellium/engines/ephemeris.py

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
"""Ephemeris calculation engines."""
22

3+
from pathlib import Path
4+
35
import swisseph as swe
46

57
from stellium.core.ayanamsa import ZodiacType, get_ayanamsa
@@ -16,21 +18,26 @@
1618
from stellium.utils.cache import cached
1719

1820

19-
def _set_ephemeris_path() -> None:
21+
def _set_ephemeris_path(ephe_path: str | Path | None = None) -> None:
2022
"""
2123
Set the path to Swiss Ephemeris data files.
2224
2325
This function initializes the ephemeris system by:
24-
1. Ensuring the user ephe directory exists (~/.stellium/ephe/)
25-
2. Copying bundled ephemeris files from the package if needed
26+
1. Resolving the ephemeris directory (explicit arg >
27+
``STELLIUM_EPHE_PATH`` env var > default ``~/.stellium/ephe/``)
28+
2. For the default location, copying bundled ephemeris files if needed
2629
3. Setting the Swiss Ephemeris path
2730
28-
The ephemeris files are stored in the user's home directory so that:
31+
By default Stellium stores ephemeris files in the user's home directory:
2932
- Users can add their own asteroid ephemeris files
3033
- The package size stays small (only essential files bundled)
3134
- Updates don't overwrite user-downloaded files
35+
36+
Passing ``ephe_path`` (or setting ``STELLIUM_EPHE_PATH``) lets you point
37+
Stellium at an existing Swiss Ephemeris folder managed by another tool,
38+
at a portable-install directory, or at a read-only shared location.
3239
"""
33-
initialize_ephemeris()
40+
initialize_ephemeris(ephe_path)
3441

3542

3643
# Swiss Ephemeris object IDs
@@ -116,9 +123,19 @@ class SwissEphemerisEngine:
116123
# This prevents repeated warnings for the same object across multiple calculations
117124
_warned_missing_ephemeris: set[str] = set()
118125

119-
def __init__(self):
120-
"""Initialize Swiss Ephemeris."""
121-
_set_ephemeris_path()
126+
def __init__(self, ephe_path: str | Path | None = None) -> None:
127+
"""Initialize Swiss Ephemeris.
128+
129+
Args:
130+
ephe_path: Optional override for the Swiss Ephemeris data
131+
directory. If omitted, Stellium falls back to the
132+
``STELLIUM_EPHE_PATH`` environment variable and then to the
133+
default ``~/.stellium/ephe/`` location. Supplying a custom
134+
path makes Stellium use that directory as-is — no files are
135+
created or copied into it.
136+
"""
137+
_set_ephemeris_path(ephe_path)
138+
self._ephe_path = ephe_path
122139
self._object_ids = SWISS_EPHEMERIS_IDS.copy()
123140

124141
def _get_object_type(self, name: str) -> ObjectType:

0 commit comments

Comments
 (0)