From bd585912ff5c34267fa7b80db4c6b79f4301848b Mon Sep 17 00:00:00 2001 From: Jivraj Singh Shekhawat Date: Tue, 28 Jul 2026 01:02:23 +0530 Subject: [PATCH 1/2] Fix headless/programmatic ChartTabbed use (NameError + None guards) Two bugs prevented driving ChartTabbed without the interactive GUI: 1. compute_horoscope() calls config.initialize_runtime(), but `config` was only imported deep in the module under a __main__ guard, so at runtime it raised `NameError: name 'config' is not defined`. The exception was caught and logged, leaving self._horo unset and producing an empty PDF. Import `config` at module scope. 2. During construction, combo-box currentIndexChanged signals fire _kundali_chart_selection_changed -> _update_tab_chart_information -> _fill_panchangam_info, which dereference self._horo before the horoscope is computed (self._horo is None). Under PyQt6 an unhandled exception in a slot aborts the process. Guard both methods to return early when self._horo is None. Co-Authored-By: Claude Opus 4.8 --- src/jhora/ui/horo_chart_tabs.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/jhora/ui/horo_chart_tabs.py b/src/jhora/ui/horo_chart_tabs.py index bbcae7dbc..4aed33168 100644 --- a/src/jhora/ui/horo_chart_tabs.py +++ b/src/jhora/ui/horo_chart_tabs.py @@ -40,7 +40,7 @@ import img2pdf from PIL import Image import numpy as np -from jhora import const, utils +from jhora import const, utils, config from jhora.panchanga import drik, pancha_paksha, vratha from jhora.horoscope import info from jhora.horoscope.prediction import general @@ -3922,6 +3922,7 @@ def _recreate_chart_tab_widgets(self): self.tabWidget.setCurrentIndex(current_tab) self._kundali_chart_combo.setCurrentIndex(self._current_kundali_chart_index) def _fill_panchangam_info(self, info_str,format_str): + if self._horo is None: return # horoscope not computed yet (e.g. signals firing during construction) jd = self._horo.julian_day place = drik.Place(self._place_name,float(self._latitude),float(self._longitude),float(self._time_zone)) bt=self._horo.birth_time @@ -4655,6 +4656,7 @@ def _update_bhava_tab_information(self,chart_index=None,chart_method=None,divisi def _update_tab_chart_information(self,chart_index=None,chart_method=None,divisional_chart_factor=None, base_rasi=None,count_from_end_of_sign=None, chart_index_1=None,chart_method_1=None,chart_index_2=None,chart_method_2=None): + if self._horo is None: return # horoscope not computed yet (e.g. signals firing during construction) info_str = '' format_str = _KEY_VALUE_FORMAT_ self._fill_panchangam_info(info_str, format_str) From a495f61c44fb9250ae0f479930519a2e96360578 Mon Sep 17 00:00:00 2001 From: Jivraj Singh Shekhawat Date: Wed, 29 Jul 2026 13:48:34 +0530 Subject: [PATCH 2/2] Add pyjhora_batch: headless batch horoscope reports (PDF/TXT/JHD) A batch layer over PyJHora's engine: a CSV/Excel of birth details in, per-person reports out. Reimplements no calculations. The report is extracted once as structured data (report_data.build_report) and then rendered, so no output path needs Qt on screen: * text_writer -> wrapped plain-text report * pdf_writer -> vector PDF via reportlab, ~150 KB and searchable, versus ~10 MB of 130-DPI JPEG for the Qt widget capture. That capture is still available as --pdf-mode screenshot, since it alone includes the drawn chart diagrams. * jhd_writer -> Jagannatha Hora .jhd, importable into the app. Every field packs D.MMSS, longitude is East-negative, lines are CRLF; the layout was derived byte-for-byte from a real export. The Yoga and Raja Yoga tables report the yoga and the condition that matched, and nothing else. The language resources carry a third "prediction" field, but it is fixed boilerplate keyed on the yoga name -- identical for every chart the yoga fires in -- and several yogas match very loosely, e.g. _matrunasa_yoga_198_calculation is true whenever the Moon is hemmed by, associated with, or aspected by any natural malefic, which covers most charts. Printed next to a person's name and birth time, "The person's mother will have a very early death" reads as an individual prognosis rather than as the classical rule text it is. CLI: --no-pdf / --no-jhd / --no-txt, --pdf-mode {vector,screenshot}, repeatable --dhasa (default vimsottari, 'all' for every system), -w N workers. reportlab is required for the vector PDF; openpyxl (Excel input), pytest and pypdf (tests) stay optional. Fixtures use a synthetic reference chart, and the panchanga and D1 expectations are captured from ChartTabbed so they cross-check the headless path rather than restating its own output. 169 passed, 1 skipped. Co-Authored-By: Claude Opus 5 --- .gitignore | 9 + generate_one.py | 28 + pyjhora_batch/README.md | 372 ++++++++++ pyjhora_batch/__init__.py | 37 + pyjhora_batch/__main__.py | 8 + pyjhora_batch/cli.py | 127 ++++ pyjhora_batch/engine.py | 336 +++++++++ pyjhora_batch/examples/sample.csv | 4 + pyjhora_batch/examples/sample.xlsx | Bin 0 -> 5173 bytes pyjhora_batch/jhd_writer.py | 169 +++++ pyjhora_batch/pdf_writer.py | 429 ++++++++++++ pyjhora_batch/readers/__init__.py | 7 + pyjhora_batch/readers/csv_reader.py | 60 ++ pyjhora_batch/readers/excel_reader.py | 83 +++ pyjhora_batch/report_data.py | 865 ++++++++++++++++++++++++ pyjhora_batch/requirements.txt | 7 + pyjhora_batch/tests/__init__.py | 0 pyjhora_batch/tests/conftest.py | 22 + pyjhora_batch/tests/test_birthrecord.py | 98 +++ pyjhora_batch/tests/test_cli.py | 88 +++ pyjhora_batch/tests/test_engine.py | 135 ++++ pyjhora_batch/tests/test_jhd_writer.py | 102 +++ pyjhora_batch/tests/test_pdf_writer.py | 157 +++++ pyjhora_batch/tests/test_readers.py | 92 +++ pyjhora_batch/tests/test_report_data.py | 507 ++++++++++++++ pyjhora_batch/tests/test_text_writer.py | 134 ++++ pyjhora_batch/text_writer.py | 197 ++++++ pyjhora_batch/wrapper.py | 326 +++++++++ 28 files changed, 4399 insertions(+) create mode 100644 generate_one.py create mode 100644 pyjhora_batch/README.md create mode 100644 pyjhora_batch/__init__.py create mode 100644 pyjhora_batch/__main__.py create mode 100644 pyjhora_batch/cli.py create mode 100644 pyjhora_batch/engine.py create mode 100644 pyjhora_batch/examples/sample.csv create mode 100644 pyjhora_batch/examples/sample.xlsx create mode 100644 pyjhora_batch/jhd_writer.py create mode 100644 pyjhora_batch/pdf_writer.py create mode 100644 pyjhora_batch/readers/__init__.py create mode 100644 pyjhora_batch/readers/csv_reader.py create mode 100644 pyjhora_batch/readers/excel_reader.py create mode 100644 pyjhora_batch/report_data.py create mode 100644 pyjhora_batch/requirements.txt create mode 100644 pyjhora_batch/tests/__init__.py create mode 100644 pyjhora_batch/tests/conftest.py create mode 100644 pyjhora_batch/tests/test_birthrecord.py create mode 100644 pyjhora_batch/tests/test_cli.py create mode 100644 pyjhora_batch/tests/test_engine.py create mode 100644 pyjhora_batch/tests/test_jhd_writer.py create mode 100644 pyjhora_batch/tests/test_pdf_writer.py create mode 100644 pyjhora_batch/tests/test_readers.py create mode 100644 pyjhora_batch/tests/test_report_data.py create mode 100644 pyjhora_batch/tests/test_text_writer.py create mode 100644 pyjhora_batch/text_writer.py create mode 100644 pyjhora_batch/wrapper.py diff --git a/.gitignore b/.gitignore index 544d5e26c..7a678549f 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,12 @@ src/.settings/org.eclipse.core.resources.prefs src/.settings/org.eclipse.core.resources.prefs src/jhora/data/geonames_places_5k.csv src/jhora/data/geonames_places_5k_IN.csv + +# pyjhora_batch: local build output, and batch input/output holding real +# birth details — never commit personal data. +.DS_Store +__pycache__/ +/build +/reports +/partners.csv +/partners.csv.orig diff --git a/generate_one.py b/generate_one.py new file mode 100644 index 000000000..1acb5a8c3 --- /dev/null +++ b/generate_one.py @@ -0,0 +1,28 @@ +"""Single-record smoke test for the batch wrapper. + +Demonstrates pyjhora_batch.generate_pdf: one record in, one PDF out, headless. +Unlike driving ChartTabbed's constructor directly, the birth details below are +actually honored (the constructor kwargs are not — see wrapper.py). +""" + +from pyjhora_batch import generate_pdf + +RECORD = { + "name": "Test Person", + "date_of_birth": "1985,6,15", # yyyy,m,d + "time_of_birth": "10:30:00", # hh:mm:ss (24h) + "place_name": "Ujjain", + "latitude": 23.5, + "longitude": 75.75, + "timezone": 5.5, # hours from UTC + "gender": "male", +} + + +def main(): + out = generate_pdf(RECORD, "reports/test.pdf") + print(f"Done. Wrote {out}") + + +if __name__ == "__main__": + main() diff --git a/pyjhora_batch/README.md b/pyjhora_batch/README.md new file mode 100644 index 000000000..2c6b70e0e --- /dev/null +++ b/pyjhora_batch/README.md @@ -0,0 +1,372 @@ +# pyjhora_batch + +Batch-generate Vedic horoscope reports from a CSV or Excel list of birth details — +a thin layer on top of [PyJHora](../src/jhora). Each person yields three files: a +**vector PDF**, a plain-text **report**, and an importable **Jagannatha Hora `.jhd`**. +It reuses PyJHora's astrology engine and never reimplements calculations. + +## Runbook — reproducing `reports/` from scratch + +Everything below is copy-paste. No other tooling is involved. + +**1. One-time setup** (from the repo root, `PyJHora/`): + +```bash +python3.12 -m venv ../.venv312 # any Python 3.10+ works +source ../.venv312/bin/activate # Windows: ..\.venv312\Scripts\activate + +pip install -r requirements.txt # PyJHora's own deps (PyQt6, swisseph, ...) +pip install -e . # PyJHora itself, editable — this is what + # puts src/ on sys.path so `import jhora` works +pip install -r pyjhora_batch/requirements.txt # reportlab + optional extras +``` + +Verify the environment before going further — this must print a path inside +`src/jhora`, not an error: + +```bash +python -c "import jhora; print(jhora.__file__)" +``` + +**2. Put your people in a CSV** — `partners.csv` at the repo root. Note the +quotes around `date_of_birth`: it contains commas. + +```csv +name,date_of_birth,time_of_birth,place,latitude,longitude,timezone,gender +Ravi Kumar,"1985,6,15",10:30:00,"Ujjain, Madhya Pradesh",23.5,75.75,5.5,male +Asha Menon,"2001,11,3",07:15:00,"Chennai, Tamil Nadu",13.0827,80.2707,5.5,female +``` + +**3. Generate.** This is the exact command that produced the current `reports/`: + +```bash +python -m pyjhora_batch partners.csv -o reports/ --dhasa all +``` + +Takes ~8s per person and writes three files each: + +| File | What it is | +|------|-----------| +| `_.pdf` | ~218-page vector report, ~585 KB, fully searchable | +| `_.txt` | the same content as plain text, ~750 KB | +| `_.jhd` | birth-data seed file, opens in Jagannatha Hora | + +Plus `batch.log` (full log with tracebacks) and, if any row fails, +`failures.csv` (the bad rows with an `_error` column — fix and re-run just that +file). + +**Drop `--dhasa all` for a much shorter report.** It is the only thing standing +between a ~50-page and a ~230-page PDF: with it you get all 60 dhasa systems +(~8,500 period rows), without it just Vimsottari. + +```bash +python -m pyjhora_batch partners.csv -o reports/ # ~50 pages, ~155 KB +python -m pyjhora_batch partners.csv -o reports/ --dhasa all --workers auto +``` + +**4. Check it worked.** The last page of the PDF lists anything that could not +be generated; on a healthy run it is absent entirely. All 60 dhasa systems +should build. + +To confirm the whole toolchain, run the tests (150 should pass): + +```bash +python -m pytest pyjhora_batch/tests -q +``` + +## Quick start (CLI) + +```bash +# from the repo root +python -m pyjhora_batch people.csv -o reports/ +python -m pyjhora_batch people.xlsx -o reports/ --workers auto +python -m pyjhora_batch people.csv -o reports/ --no-pdf # just .jhd + .txt (fast) +python -m pyjhora_batch people.csv -o reports/ --dhasa all # every dhasa system +python -m pyjhora_batch people.csv -o reports/ --pdf-mode screenshot # old Qt capture +``` + +| Flag | Meaning | +|------|---------| +| `-o, --out-dir DIR` | Output directory (default `reports`). | +| `-w, --workers N` | Worker processes: integer or `auto` (cpus−1). Default `1`. | +| `--sheet S` | Excel worksheet name or 0-based index. | +| `--no-pdf` / `--no-jhd` / `--no-txt` | Skip that output type. | +| `--pdf-mode MODE` | `vector` (default) or `screenshot` — see [Outputs](#outputs). | +| `--dhasa NAME` | Dhasa system to include; repeatable. `--dhasa all` for every system. Default `vimsottari`. | +| `--expand-all-tabs` | Expand every chart tab (screenshot mode only). | +| `-q, --quiet` | Only warnings/errors + the final summary. | +| `--allow-failures` | Exit `0` even if some records fail. | + +Exit codes: `0` success · `1` at least one record failed · `2` bad usage / missing input. + +## Input format + +One row per person. Column names are case-insensitive and accept common aliases +(spaces/hyphens are treated as underscores): + +| Field | Required | Format / aliases | +|-------|----------|------------------| +| `date_of_birth` | ✅ | `YYYY,M,D` (e.g. `1985,6,15`) — or a real Excel date cell. Aliases: `dob`, `date`. | +| `time_of_birth` | ✅ | `HH:MM:SS` 24h — or a real Excel time cell. Aliases: `tob`, `time`. | +| `place` / `place_name` | ✅ | Any label. Aliases: `location`, `city`. | +| `latitude` | ✅ | Decimal degrees, N positive. Alias: `lat`. | +| `longitude` | ✅ | Decimal degrees, E positive. Aliases: `long`, `lon`, `lng`. | +| `timezone` | ✅ | Hours from UTC, e.g. `5.5`. Aliases: `tz`, `utc_offset`. | +| `name` | | Person's name (used in output filenames). | +| `gender` | | `0`=Female `1`=Male `2`=Transgender `3`=None, or a label (`male`/`female`/…). Default `3`. | +| `chart_type`| | `south_indian` (default), `north_indian`, `east_indian`, `western`, `sudarsana_chakra`. | +| `language` | | `English` (default), `Hindi`, `Tamil`, `Telugu`, `Kannada`. | +| `output` / `filename` | | Override the output base filename for that row. | + +### Spouse columns (marriage compatibility) + +Compatibility is scored from **four numbers only**: each partner's nakshatra and +pada. Everything below exists to obtain the spouse's pair precisely — the +native's is already computed from their own birth data. + +There are two ways to supply it. **Prefer the first.** + +**A. The spouse's birth details** — exact, and the recommended route: + +| Column | Required | Notes | +|--------|----------|-------| +| `spouse_date_of_birth` | ✅ | `YYYY,M,D`. Aliases: `spouse_dob`, `partner_dob`. | +| `spouse_time_of_birth` | ✅ | `HH:MM:SS` 24h. Aliases: `spouse_tob`, `partner_tob`. | +| `spouse_latitude` | ✅ | Decimal degrees, N positive. Alias: `spouse_lat`. | +| `spouse_longitude` | ✅ | Decimal degrees, E positive. Aliases: `spouse_long`, `spouse_lon`. | +| `spouse_timezone` | ✅ | Hours from UTC. Alias: `spouse_tz`. | +| `spouse_place` | | Label only; not used in the calculation. | + +All five ✅ columns must be present together, or this route is skipped. + +**B. The spouse's star directly** — when the birth time is unknown: + +| Column | Required | Notes | +|--------|----------|-------| +| `spouse_nakshatra` | ✅ | `1`–`27`, or a name (`Swaathi`). Aliases: `spouse_star`, `spouse_nakshathra`. | +| `spouse_pada` | ✅ | `1`–`4`. Aliases: `spouse_paadham`, `spouse_quarter`. | + +⚠️ **A guessed birth time makes route B unreliable.** A pada is 3°20′ of Moon +travel ≈ **6 hours**. Get the time wrong by half a day and the pada — and with +it varna, vasiya, rajju and sthree-dheerga — can all change. + +**Applies to both routes:** + +| Column | Required | Notes | +|--------|----------|-------| +| `spouse_name` | | Shown in the report; not used in the calculation. | +| `spouse_gender` | | Which partner is the "boy" changes several kootas, so it is not arbitrary. Defaults to the opposite of `gender`; set this only for a same-sex pair or when `gender` is blank. | +| `compatibility_method` | | `north` (Ashtakoota, out of 36) or `south` (10 poruthams). Defaults from `chart_type`: `south_indian` → south, otherwise north. | + +Omit every spouse column and the section is simply absent — it is never an error. + +Example: + +```csv +name,date_of_birth,time_of_birth,place,latitude,longitude,timezone,gender,spouse_name,spouse_dob,spouse_tob,spouse_place,spouse_lat,spouse_long,spouse_tz +Ravi Kumar,"1985,6,15",10:30:00,Ujjain,23.5,75.75,5.5,male,Latha Menon,"1988,2,9",18:20:00,Jaipur,26.9124,75.7873,5.5 +``` + +The report then carries a **Marriage Compatibility** section showing both stars, +which one was treated as the boy, how the spouse's star was obtained, the +per-koota (or per-porutham) breakdown and the total. + +Example (`people.csv`): + +```csv +name,date_of_birth,time_of_birth,place,latitude,longitude,timezone,gender +Test Person,"1985,6,15",10:30:00,Ujjain,23.5,75.75,5.5,male +Asha Rao,"1985,11,2",08:15:00,Chennai,13.0878,80.2785,5.5,female +``` + +See `pyjhora_batch/examples/` for sample `.csv` and `.xlsx`. + +## Outputs + +For each valid row (filename derived from name + DOB, de-duplicated): + +- `_.pdf` — the full horoscope report. +- `_.txt` — the same content as plain text, for reading/grepping/pasting. +- `_.jhd` — importable into Jagannatha Hora. + +### PDF modes + +`--pdf-mode vector` (default) extracts the report data from PyJHora's engine and +typesets it with ReportLab. `--pdf-mode screenshot` is PyJHora's original path: +`ChartTabbed.save_as_pdf` grabs each Qt tab as a bitmap and pastes two per A4 page. + +| | vector | screenshot | +|---|---|---| +| Text | real, selectable, searchable | pixels (16–45 chars/page, all of it tab titles) | +| Resolution | vector — sharp at any zoom | ~975×770 JPEG ≈ **130 DPI**, upscaled 1.12× | +| Size (default settings) | **~155 KB / ~50 pages** | ~10 MB / 38 pages | +| Speed | ~2 s/person | ~60 s/person | +| Needs Qt | no | yes (offscreen `QApplication`) | +| Chart diagrams | drawn as vector lines | drawn as bitmaps | + +`vector` is better on every axis; `screenshot` exists only as a fallback. + +### Chart diagrams + +All 23 divisional charts plus the bhava chart are drawn as real diagrams — in +the PDF as vector lines, in the text report as an ASCII grid. The style follows +each row's `chart_type` column: + +- **`south_indian`** (default) — the 4×4 ring with an open centre, signs in + fixed cells (Pisces top-left, running clockwise), lagna marked with a + diagonal stroke in its corner. +- **`north_indian`** — the square with both diagonals and the diamond on the + edge midpoints; houses are fixed and signs rotate so house 1 (marked *Lagna*) + is always the top kite. + +Cell layouts are taken from `SouthIndianChart._zodiac_symbols` and +`NorthIndianChart._north_label_positions` in `jhora/ui/chart_styles.py`, so they +match the conventions the GUI draws. Other styles (`east_indian`, `western`) +fall back to the South Indian layout and say so in a note on the section. + +The text report always uses the South Indian ASCII grid — it is the one layout +that survives in fixed-width text — and annotates every cell with its house +number, so North Indian readers still get the house sequence: + +``` ++----------------------+----------------------+----------------------+----------------------+ +| Pi H8 | Ar H9 | Ta H10 | Ge H11 | +| | Sun☉ | | Jupiter♃ | +| | Mercury☿ | | | ++----------------------+----------------------+----------------------+----------------------+ +| Aq H7 | | Cn H12 | +| Mars♂ | | Kethu☋ | +| Venus♀ | | | ++----------------------+ +----------------------+ +| Cp H6 | | Le H1 < | +| Moon☾ | | Ascendantℒ | +``` + +### Report contents + +Both the text and vector-PDF renderers read the same structured `Report` +(`report_data.py`), ~85 sections in all: + +- birth details and panchanga at birth +- raja yogas, yogas, doshas, general predictions +- ashtakavarga (bhinna + sarva) +- sphuta (14 special points), shad bala, bhava bala, harsha/pancha/dwadhasa + vargeeya bala, vimsopaka bala, vaiseshikamsa bala +- the bhava (house) chart, drawn, plus a cusp table with begin/middle/end degrees +- dhasa-bhukthi periods — all 60 systems with `--dhasa all` (see below) +- planetary positions and arudha padas per chart, and drawn diagrams for all + 23 divisional charts + +- marriage compatibility, when the [spouse columns](#spouse-columns-marriage-compatibility) + are supplied + +Not included: **pancha pakshi**, which the GUI computes for *today* rather than +the birth moment. +A section that fails is recorded under "Sections that could not be generated" +rather than aborting the report. + +Per run, in the output directory: + +- `batch.log` — full log incl. stack traces for failed rows. +- `failures.csv` — every invalid/failed row with `_row/_line/_status/_error` plus its + original columns, so you can fix and re-run just that file. + +## Python API + +```python +from pyjhora_batch import build_report, render_pdf, write_text, run_csv, BirthRecord + +rec = BirthRecord.from_dict({ + "name": "Test Person", "date_of_birth": "1985,6,15", "time_of_birth": "10:30:00", + "place_name": "Ujjain", "latitude": 23.5, "longitude": 75.75, "timezone": 5.5, +}) + +# extract once, render twice +report = build_report(rec) # ~0.6s, no Qt +write_text(rec, "reports/test.txt", report=report) +render_pdf(report, "reports/test.pdf") + +for section in report.sections: # walk the structured data yourself + print(section.title, len(section.pairs), len(section.rows)) +print(report.warnings) # sections that could not be built +``` + +The legacy screenshot PDF is still one call: + +```python +from pyjhora_batch import generate_pdf + +generate_pdf({ + "name": "Test Person", "date_of_birth": "1985,6,15", "time_of_birth": "10:30:00", + "place_name": "Ujjain", "latitude": 23.5, "longitude": 75.75, "timezone": 5.5, + "gender": "male", +}, "reports/screenshot.pdf") +``` + +Batch: + +```python +summary = run_csv("people.csv", "reports/", workers=4) +print(summary) # Batch summary: N/M succeeded, K failed +for r in summary.failures: + print(r.row_number, r.error) +``` + +## The `.jhd` format (important caveat) + +PyJHora has no native `.jhd` export; `jhd_writer.py` is a fresh serializer, +calibrated against a real file exported by the Jagannatha Hora Windows app. Its +encoding is deliberately inconsistent, and matching it matters: + +- **Line 5 (timezone) is packed `H.MM`** — `+5:30` → `-5.300000`. +- **Lines 9/10 repeat that timezone as plain decimal** — `-5.500000`. The same + value appearing both ways in one file is what proves the format is not packed + throughout. +- **Longitude/latitude are decimal degrees**, not `D.MMSS`. +- **Time is decimal hours with 15 decimals** — `00:00` → `0.000000000000000`. +- Signs are inverted from the usual: timezone and longitude **negative for East**, + latitude positive for North. + +Still unconfirmed: line 12 is `105` in the reference export and `0` here (purpose +unknown; harmless since lat/long/tz are explicit), and CRLF vs LF line endings. + +## Performance + +In the default `vector` mode a person costs ~2s for all three files (~0.6s to +compute the report, ~1.5s to typeset the PDF); the `.jhd` write is instant. +Measured: 2 people × 3 outputs in **4.6s** end to end, including interpreter and +ephemeris startup. + +`--pdf-mode screenshot` is far slower — ~25–40s per PDF, dominated by the Qt save +step. `--workers N` spreads records across `N` processes (the pool uses `spawn`); +workers only create an offscreen `QApplication` when screenshot mode needs one. + +## Known PyJHora caveats + +- **Headless fix required.** Driving `ChartTabbed` without the GUI needs two fixes + in `src/jhora/ui/horo_chart_tabs.py` (a `config` import and two `self._horo is None` + guards). These are committed on branch `fix/headless-compute-horoscope`. +- **Constructor ignores birth-detail kwargs.** `ChartTabbed(date_of_birth=…, …)` does + not apply those values (it defaults to *now* + IP location). The wrapper instead + injects them via the public setters with networking disabled — so batch output is + deterministic and uses each row's own coordinates. Note that some screenshot-mode + tabs (e.g. Pancha Pakshi) still render *today* at the IP-guessed location; the + vector report is built from the birth data only and has no such leak. +- **`jhora.horoscope.main` is stale — use `jhora.horoscope.info`.** `main.Horoscope` + raises on construction (`get_calendar_information` calls `drik.vaara(jd)` against a + two-argument signature). `info.Horoscope` is what the GUI actually imports. +- **Set the ayanamsa before any headless engine call.** `ChartTabbed.__init__` runs + `drik.set_ayanamsa_mode(const._DEFAULT_AYANAMSA_MODE)`; skip it and swisseph keeps + its own default sidereal mode, so every longitude — and therefore tithi, nakshatra, + yoga and all divisional charts — silently disagrees with the GUI. `build_report` + does this for you. +- **Engine globals are order-sensitive.** Calling predictions → dosha → yoga → + raja_yoga in one process makes `raja_yoga` raise `IndexError`, while any shorter + prefix works. `report_data` orders the calls defensively and isolates each section. + +## Tests + +```bash +python -m pytest pyjhora_batch/tests -q # fast: no PDF render +PYJHORA_TEST_PARALLEL=1 python -m pytest pyjhora_batch/tests # also the spawn-pool test +``` diff --git a/pyjhora_batch/__init__.py b/pyjhora_batch/__init__.py new file mode 100644 index 000000000..b5079ce5e --- /dev/null +++ b/pyjhora_batch/__init__.py @@ -0,0 +1,37 @@ +"""pyjhora_batch — a thin batch layer on top of PyJHora. + +Reuses PyJHora's astrology engine; never reimplements calculations. Each record +can produce three artifacts: + +* ``report_data.build_report`` extracts the whole horoscope as structured data + (no Qt), which ``text_writer`` and ``pdf_writer`` render as a ``.txt`` and a + vector ``.pdf``. +* ``jhd_writer.write_jhd`` writes the Jagannatha Hora ``.jhd`` seed file. +* ``wrapper.generate_pdf`` is the legacy screenshot PDF (drives the Qt GUI). + +``engine.run_csv`` / ``engine.run_batch`` drive all of it over many records. +""" + +from .wrapper import BirthRecord, RecordError, ensure_app, generate_pdf +from .jhd_writer import build_jhd, write_jhd +from .report_data import Report, Section, build_report +from .text_writer import render_text, write_text +from .engine import (PDF_MODES, BatchSummary, RecordResult, run_batch, run_csv, + run_excel, default_worker_count) + +__all__ = [ + "BirthRecord", "RecordError", "ensure_app", "generate_pdf", + "build_jhd", "write_jhd", + "Report", "Section", "build_report", + "render_text", "write_text", + "PDF_MODES", "BatchSummary", "RecordResult", "run_batch", "run_csv", + "run_excel", "default_worker_count", +] + + +def __getattr__(name): + # pdf_writer imports reportlab; keep that optional for .txt/.jhd-only users. + if name in ("render_pdf", "write_pdf_report"): + from . import pdf_writer + return getattr(pdf_writer, name) + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/pyjhora_batch/__main__.py b/pyjhora_batch/__main__.py new file mode 100644 index 000000000..c57715080 --- /dev/null +++ b/pyjhora_batch/__main__.py @@ -0,0 +1,8 @@ +"""Enable `python -m pyjhora_batch ...`.""" + +import sys + +from .cli import main + +if __name__ == "__main__": + sys.exit(main()) diff --git a/pyjhora_batch/cli.py b/pyjhora_batch/cli.py new file mode 100644 index 000000000..b18a0de39 --- /dev/null +++ b/pyjhora_batch/cli.py @@ -0,0 +1,127 @@ +"""Command-line interface for the batch layer. + + python -m pyjhora_batch input.csv -o reports/ --workers auto + +Auto-detects CSV vs Excel by file extension and dispatches to the engine. +Running as a module puts the repo root on sys.path, which the 'spawn' worker +processes need to re-import the package. +""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +from .engine import PDF_MODES, default_worker_count, run_csv, run_excel + +_EXCEL_SUFFIXES = {".xlsx", ".xlsm", ".xltx", ".xltm"} +_CSV_SUFFIXES = {".csv", ".tsv", ".txt"} + + +def _parse_workers(value: str) -> int: + if value == "auto": + return default_worker_count() + try: + n = int(value) + except ValueError: + raise argparse.ArgumentTypeError(f"--workers must be an integer or 'auto' (got {value!r})") + if n < 1: + raise argparse.ArgumentTypeError("--workers must be >= 1") + return n + + +def build_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser( + prog="pyjhora_batch", + description="Batch-generate horoscope PDFs (and importable .jhd files) " + "from a CSV or Excel file of birth details.", + ) + p.add_argument("input", type=Path, + help="Input file (.csv/.tsv or .xlsx). Columns: name, " + "date_of_birth (YYYY,M,D), time_of_birth (HH:MM:SS), place, " + "latitude, longitude, timezone, gender (aliases accepted).") + p.add_argument("-o", "--out-dir", type=Path, default=Path("reports"), + help="Output directory (default: reports).") + p.add_argument("-w", "--workers", type=_parse_workers, default=1, metavar="N", + help="Worker processes: an integer, or 'auto' (cpus-1). Default: 1.") + p.add_argument("--sheet", default=None, + help="Excel worksheet name or 0-based index (default: active sheet).") + p.add_argument("--no-pdf", action="store_true", help="Skip PDF generation.") + p.add_argument("--no-jhd", action="store_true", help="Skip .jhd generation.") + p.add_argument("--no-txt", action="store_true", + help="Skip the plain-text report.") + p.add_argument("--pdf-mode", choices=PDF_MODES, default="vector", + help="vector: typeset the data as real text — sharp, searchable, " + "~150 KB (default). screenshot: PyJHora's Qt widget capture — " + "includes the drawn chart diagrams but is ~130 DPI JPEG, ~10 MB.") + p.add_argument("--dhasa", action="append", metavar="NAME", dest="dhasas", + help="Dhasa system to include (repeatable). Default: vimsottari. " + "Use '--dhasa all' for all 60 systems PyJHora exposes " + "(adds ~8500 rows). " + "e.g. --dhasa vimsottari --dhasa ashtottari") + p.add_argument("--expand-all-tabs", action="store_true", + help="Expand all chart tabs in the PDF (screenshot mode only).") + p.add_argument("-q", "--quiet", action="store_true", + help="Only print warnings/errors and the final summary.") + p.add_argument("--allow-failures", action="store_true", + help="Exit 0 even if some records fail (default: exit 1 on any failure).") + return p + + +def _resolve_sheet(sheet): + if sheet is None: + return None + try: + return int(sheet) + except (TypeError, ValueError): + return sheet + + +def main(argv=None) -> int: + args = build_parser().parse_args(argv) + + if not args.input.is_file(): + print(f"error: input file not found: {args.input}", file=sys.stderr) + return 2 + if args.no_pdf and args.no_jhd and args.no_txt: + print("error: --no-pdf, --no-jhd and --no-txt together produce no output.", + file=sys.stderr) + return 2 + + suffix = args.input.suffix.lower() + common = dict( + verbose=not args.quiet, + workers=args.workers, + write_pdf=not args.no_pdf, + write_jhd_file=not args.no_jhd, + write_txt=not args.no_txt, + pdf_mode=args.pdf_mode, + dhasas=tuple(args.dhasas) if args.dhasas else None, + expand_all_tabs=True if args.expand_all_tabs else None, + ) + + if suffix in _EXCEL_SUFFIXES: + summary = run_excel(args.input, args.out_dir, sheet=_resolve_sheet(args.sheet), **common) + elif suffix in _CSV_SUFFIXES: + summary = run_csv(args.input, args.out_dir, **common) + else: + print(f"error: unsupported input type {suffix!r}; use a .csv or .xlsx file.", + file=sys.stderr) + return 2 + + print(f"\n{summary}") + print(f"Output: {args.out_dir}") + if summary.failures: + print(f"Failures: {len(summary.failures)} (see {args.out_dir / 'failures.csv'} " + f"and {args.out_dir / 'batch.log'})") + for r in summary.failures: + print(f" row {r.row_number} [{r.status}] {r.name or ''}: {r.error}") + + if summary.failed and not args.allow_failures: + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/pyjhora_batch/engine.py b/pyjhora_batch/engine.py new file mode 100644 index 000000000..af7ee0370 --- /dev/null +++ b/pyjhora_batch/engine.py @@ -0,0 +1,336 @@ +"""Batch engine: turn a stream of raw rows into one PDF (+ .jhd) each, resiliently. + +Responsibilities: +* Validate each record and assign deterministic, collision-free output paths in + the main process (fast, pure) so naming never races across workers. +* Generate outputs per record, isolating failures so one bad row never stops the + batch. +* Run either single-process (default) or across a configurable pool of worker + processes — each worker owns its own offscreen QApplication (Qt objects cannot + cross process boundaries), reused across the records it handles. +* Log successes/failures (console + a log file in the output dir) and emit a + summary plus a retry CSV of the failed rows. +""" + +from __future__ import annotations + +import csv +import logging +import multiprocessing as mp +import os +import re +import traceback +from dataclasses import dataclass, field +from functools import partial +from pathlib import Path +from typing import Iterable, List, Optional, Tuple + +from .readers import RawRow, read_csv +from .jhd_writer import write_jhd +from .wrapper import BirthRecord, RecordError, ensure_app, generate_pdf + +#: How the PDF is produced. "vector" typesets the extracted data with ReportLab +#: (sharp, searchable, ~150 KB); "screenshot" is PyJHora's original Qt widget +#: capture (raster JPEG, ~10 MB) and is kept for visual chart diagrams. +PDF_MODES = ("vector", "screenshot") + +_LOGGER_NAME = "pyjhora_batch" + + +@dataclass +class RecordResult: + row_number: int + line_number: int + name: str + status: str # "ok" | "invalid" | "error" + output: Optional[str] = None # PDF path + jhd: Optional[str] = None # JHD path (written even if the PDF fails) + txt: Optional[str] = None # plain-text report path + error: Optional[str] = None + data: dict = field(default_factory=dict) + tb: Optional[str] = None # full traceback for logging (not serialized) + + +@dataclass +class _Job: + """A validated record with its resolved output paths, ready to render.""" + row_number: int + line_number: int + rec: BirthRecord + pdf_path: Path + jhd_path: Path + txt_path: Path + data: dict + + +@dataclass +class BatchSummary: + total: int = 0 + succeeded: int = 0 + failed: int = 0 + results: list = field(default_factory=list) + + @property + def failures(self): + return [r for r in self.results if r.status != "ok"] + + def __str__(self): + return (f"Batch summary: {self.succeeded}/{self.total} succeeded, " + f"{self.failed} failed") + + +def _slugify(text: str) -> str: + text = re.sub(r"[^\w\-]+", "_", (text or "").strip()) + return text.strip("_") or "chart" + + +def _output_name(rec: BirthRecord, raw: dict) -> str: + """Deterministic base filename (no extension) for a record.""" + explicit = raw.get("output") or raw.get("filename") + if explicit: + return _slugify(Path(str(explicit)).stem) + dob = rec.date_of_birth.replace(",", "-") + base = rec.name or rec.place_name + return f"{_slugify(base)}_{dob}" + + +def _configure_logger(out_dir: Path, verbose: bool) -> logging.Logger: + logger = logging.getLogger(_LOGGER_NAME) + logger.setLevel(logging.DEBUG) + logger.handlers.clear() # avoid duplicate handlers across runs + logger.propagate = False + + fmt = logging.Formatter("%(asctime)s %(levelname)s %(message)s") + + console = logging.StreamHandler() + console.setLevel(logging.INFO if verbose else logging.WARNING) + console.setFormatter(fmt) + logger.addHandler(console) + + file_handler = logging.FileHandler(out_dir / "batch.log", encoding="utf-8") + file_handler.setLevel(logging.DEBUG) + file_handler.setFormatter(fmt) + logger.addHandler(file_handler) + return logger + + +def _prepare(rows: Iterable[RawRow], out_dir: Path) -> Tuple[List[_Job], List[RecordResult]]: + """Validate rows and assign collision-free output paths. + + Returns (jobs, invalid_results). Validation happens up front in the main + process so bad rows never reach a worker and filenames are globally unique. + """ + jobs: List[_Job] = [] + invalids: List[RecordResult] = [] + used_names: set[str] = set() + + for raw in rows: + try: + rec = BirthRecord.from_dict(raw.data) + except RecordError as exc: + invalids.append(RecordResult(raw.row_number, raw.line_number, + str(raw.data.get("name", "")), "invalid", + error=str(exc), data=raw.data)) + continue + base = _output_name(rec, raw.data) + name = base + n = 2 + while name in used_names: + name = f"{base}_{n}" + n += 1 + used_names.add(name) + jobs.append(_Job(raw.row_number, raw.line_number, rec, + out_dir / f"{name}.pdf", out_dir / f"{name}.jhd", + out_dir / f"{name}.txt", raw.data)) + return jobs, invalids + + +def _process_job(job: _Job, *, write_pdf: bool, write_jhd_file: bool, + expand_all_tabs, write_txt: bool = True, + pdf_mode: str = "vector", dhasas=None) -> RecordResult: + """Render one job's outputs. Never raises — encodes failure in the result. + + Outputs are produced cheapest-first so a later failure never costs an earlier + artifact: the pure JHD, then the structured report (shared by the text and + vector-PDF renderers so the engine runs once, not twice), then the PDF. + + In "screenshot" mode the PDF still comes from PyJHora's Qt widget capture via + the process-local QApplication, which is why generate_pdf/ensure_app are only + touched on that path. + """ + jhd_written = None + try: + if write_jhd_file: + write_jhd(job.rec, job.jhd_path) + jhd_written = str(job.jhd_path) + except Exception as exc: # noqa: BLE001 + return RecordResult(job.row_number, job.line_number, job.rec.name, "error", + error=f"JHD write failed: {exc}", data=job.data, + tb=traceback.format_exc()) + + txt_written = None + try: + needs_report = write_txt or (write_pdf and pdf_mode == "vector") + report = None + if needs_report: + from .report_data import DEFAULT_DHASAS, build_report + report = build_report(job.rec, dhasas=dhasas or DEFAULT_DHASAS) + + if write_txt: + from .text_writer import write_text + write_text(job.rec, job.txt_path, report=report) + txt_written = str(job.txt_path) + + if write_pdf: + if pdf_mode == "vector": + from .pdf_writer import render_pdf + render_pdf(report, job.pdf_path) + else: + generate_pdf(job.rec, job.pdf_path, expand_all_tabs=expand_all_tabs) + + return RecordResult(job.row_number, job.line_number, job.rec.name, "ok", + output=str(job.pdf_path) if write_pdf else None, + jhd=jhd_written, txt=txt_written, data=job.data) + except Exception as exc: # noqa: BLE001 - per-record isolation is the point + return RecordResult(job.row_number, job.line_number, job.rec.name, "error", + jhd=jhd_written, txt=txt_written, error=str(exc), + data=job.data, tb=traceback.format_exc()) + + +def _log_result(logger, result: RecordResult) -> None: + if result.status == "ok": + target = result.output or result.txt or result.jhd + logger.info("Row %s -> %s", result.row_number, + Path(target).name if target else "(no output)") + elif result.status == "invalid": + logger.error("Row %s (line %s) invalid: %s", result.row_number, + result.line_number, result.error) + else: + logger.error("Row %s (line %s) failed: %s\n%s", result.row_number, + result.line_number, result.error, result.tb or "") + + +# --- worker plumbing (top-level so it is picklable under the 'spawn' start method) --- + +def _pool_init(needs_qt: bool = True) -> None: + """Runs once per worker process: create its own offscreen QApplication. + + Only the screenshot PDF path needs Qt; the vector/text path is pure Python, + so workers skip the QApplication entirely when it is not required. + """ + if needs_qt: + ensure_app(headless=True) + + +def _worker_task(job: _Job, **kwargs) -> RecordResult: + return _process_job(job, **kwargs) + + +def run_batch(rows: Iterable[RawRow], out_dir, *, app=None, verbose: bool = True, + expand_all_tabs=None, write_failures: bool = True, + write_pdf: bool = True, write_jhd_file: bool = True, + write_txt: bool = True, pdf_mode: str = "vector", dhasas=None, + workers: int = 1, max_tasks_per_child: int = 25) -> BatchSummary: + """Generate outputs for each row in ``rows``, isolating per-record failures. + + Each record yields up to three files: a ``.pdf``, a ``.jhd`` and a ``.txt``. + ``pdf_mode`` picks the PDF pipeline — "vector" (default) typesets the + extracted data, "screenshot" uses PyJHora's Qt capture. + + ``workers`` selects the number of worker processes: 1 (default) runs + in-process; >1 spreads records across a process pool. Returns a + :class:`BatchSummary`; never raises for a bad record. + """ + if pdf_mode not in PDF_MODES: + raise ValueError(f"pdf_mode must be one of {PDF_MODES}, got {pdf_mode!r}") + out_dir = Path(out_dir) + out_dir.mkdir(parents=True, exist_ok=True) + logger = _configure_logger(out_dir, verbose) + + jobs, invalids = _prepare(rows, out_dir) + + summary = BatchSummary() + summary.results.extend(invalids) + for inv in invalids: + _log_result(logger, inv) + + job_kwargs = dict(write_pdf=write_pdf, write_jhd_file=write_jhd_file, + write_txt=write_txt, pdf_mode=pdf_mode, dhasas=dhasas, + expand_all_tabs=expand_all_tabs) + needs_qt = write_pdf and pdf_mode == "screenshot" + + if workers and workers > 1 and len(jobs) > 1: + results = _run_parallel(jobs, logger, workers=workers, + max_tasks_per_child=max_tasks_per_child, + needs_qt=needs_qt, **job_kwargs) + else: + if workers and workers > 1: + logger.info("Only %d job(s); running single-process.", len(jobs)) + if needs_qt: + app = app or ensure_app() + results = [] + for job in jobs: + result = _process_job(job, **job_kwargs) + _log_result(logger, result) + results.append(result) + + summary.results.extend(results) + summary.results.sort(key=lambda r: r.row_number) + summary.total = len(summary.results) + summary.succeeded = sum(1 for r in summary.results if r.status == "ok") + summary.failed = summary.total - summary.succeeded + + logger.warning(str(summary)) + if write_failures and summary.failures: + _write_failures_csv(summary, out_dir / "failures.csv", logger) + return summary + + +def _run_parallel(jobs: List[_Job], logger, *, workers: int, max_tasks_per_child: int, + needs_qt: bool = True, **job_kwargs) -> List[RecordResult]: + n_workers = min(workers, len(jobs)) + logger.warning("Running %d job(s) across %d worker process(es).", len(jobs), n_workers) + # 'spawn' avoids forking a process that has touched Qt/Cocoa (unsafe on macOS). + ctx = mp.get_context("spawn") + task = partial(_worker_task, **job_kwargs) + results: List[RecordResult] = [] + with ctx.Pool(processes=n_workers, initializer=_pool_init, + initargs=(needs_qt,), + maxtasksperchild=max_tasks_per_child) as pool: + for result in pool.imap_unordered(task, jobs): + _log_result(logger, result) + results.append(result) + return results + + +def _write_failures_csv(summary: BatchSummary, path: Path, logger) -> None: + # Union of all original columns across failed rows, plus diagnostics. + cols: list[str] = [] + for r in summary.failures: + for k in r.data: + if k not in cols: + cols.append(k) + fieldnames = ["_row", "_line", "_status", "_error"] + cols + with path.open("w", newline="", encoding="utf-8") as fh: + writer = csv.DictWriter(fh, fieldnames=fieldnames) + writer.writeheader() + for r in summary.failures: + writer.writerow({"_row": r.row_number, "_line": r.line_number, + "_status": r.status, "_error": r.error, **r.data}) + logger.warning("Wrote %d failed row(s) for retry -> %s", len(summary.failures), path) + + +def run_csv(csv_path, out_dir, **kwargs) -> BatchSummary: + """Convenience: read a CSV and run the batch over it.""" + return run_batch(read_csv(csv_path), out_dir, **kwargs) + + +def run_excel(xlsx_path, out_dir, *, sheet=None, **kwargs) -> BatchSummary: + """Convenience: read an .xlsx worksheet and run the batch over it.""" + from .readers.excel_reader import read_excel # lazy: openpyxl optional + return run_batch(read_excel(xlsx_path, sheet=sheet), out_dir, **kwargs) + + +def default_worker_count() -> int: + """A sensible default worker count (leaves one core free).""" + return max(1, (os.cpu_count() or 2) - 1) diff --git a/pyjhora_batch/examples/sample.csv b/pyjhora_batch/examples/sample.csv new file mode 100644 index 000000000..c5524e233 --- /dev/null +++ b/pyjhora_batch/examples/sample.csv @@ -0,0 +1,4 @@ +name,date_of_birth,time_of_birth,place,latitude,longitude,timezone,gender +Test Person,"1985,6,15",10:30:00,Ujjain,23.5,75.75,5.5,male +Asha Rao,"1985,11,2",08:15:00,Chennai,13.0878,80.2785,5.5,female +Bad Row Missing Coords,"2000,1,1",06:00:00,Nowhere,,,5.5,male diff --git a/pyjhora_batch/examples/sample.xlsx b/pyjhora_batch/examples/sample.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..00e48ff98df2f3704c39c13f6f1f6d68ba07ff5b GIT binary patch literal 5173 zcmZ`-1yodP*B(-0=#n8NRd9%*J7nkq6a*;|7-6WPlmSFSx}-r;T0~MpLb?%#25AXt z0qOJ~uj{+s%m01ntaHvfYn^AGcR%ad@osH(99(Ju06+lvKzwct?bK9>$Gr7nE^^Fe ziL}smMmoCiJ$7^iqwMW8(VE09{6PHq#(U4IUxza1klvLJO|M6Tp9WOjviCpT-oll2 zaCh%w$O4D3aaAQL?Shx=A%dMGa%1eLGQB;Sp_xmb#R4hpO&Kls&t^i`wZu)Im z#Y8hT>}S>k_xuI#Ivc2*Zx~DmH-Na2s?MnXn*fUWfAh;1d^>7&Ds_ zekJ9VRWbLy?_l%Qz~HzaD6=noQ)6}2e6#cVf*cg`{Pg)HJ55Bn8m5(o2tcctS2q=9 z>$G1OfjWx1cg%R+2*k%ewq7wc^S~crmw8>;(#*@0*II5sSH}?fh_zyw1#(Q&s~Z8& z#-h@f=c);=_;F&_em*%X14pk(FbVoP@G}^&diNvqmtcm$Uv*pBnry6OAJ}#d4ypL~ zb$i9()v6w557Q6`l1NIV8T3DUe%Q8^5#BTDDr66uU))8BRrL;qIWpqT>(r&Ur0v@^ z=ExIPWFrPFCk`izqx0TH+}9dD@|xR7u}&q5Fwz>fb(rU+w zg;=F3i6E6ML0VvvjW#284xvCoX5eu}$9sx-DzEAWg08uU;G;rhvb27tf$F6HF*y{p zkm~ZyWzg>IS}t_(P_KSEJJEm!EAjfA&^uw?_Ayj^({53!6cZI?UmP*Md|)C67iEF? z3r!WOOP<`x%hX}Hoj5*@Q?=+DMgH_DCXP#uqcW~=0GvEV)OJ=ZFgY#+zGTs()n*^ZcX_GjEzRz{x%RR| z2m8l8Ju?BL4ji7StmhixN~?}5iN=o>2V)ke^5B&YCAZw@?b+^$w;+k7}EqSfdi8iZbzf{o=Py zC)oXul+UWjumx-C#0xy&0#lp@q!xRyJv&WwYc~6F;n=NA zwk)~wXx~J!_{HJO;;BQdq>4tWdpiY|s}eSQJR?3Wo#2x8_n0VrDy+V^?z=nKewKNR zK1rva89QX?M7PPMMBKSI|jA_p)ElGk*wjN%8pD zqV)c!!5H(2;GDSQl*BBK={G7~#he$-UZJ5r6ouvuwIy07oT-u(p1Z!RDd%HTIfpaz zz~>`{hPjqJ6te2qp0mW@TZ9|!3q5*8ZC;dDR@5;m0Ce{;*aK!h5t|EAN(Mnj!`_QW z5FzD9<(6fqMo6p=NrV`q@k|9?SBYdV!HA@$FSkibTiPPOZiO{s{kworb5xPpVrpyn zH2~oH?*ih6bhdS|hQnQ5`2KqQRYd~yrXnZ#$uHt>q6??_d=zr9@!r|%2X85@r_gbQ zRKijv@N2yrpZN%7ilii>I#$B1`j2B$Z+hAj0UXZnsE%s%`R#d_PxTf1Pzuu)Wl}=u z;hYOCmNhdYz<~zN__jkv?s9d19*CylTwpGtw4)!-1b?=89H%)F!n!XcY_hjLu~0uh z^f+HN!Wo5snLW$@_=`!LG&s|SzX*u`W#oJbHkR2|?UQjGrirxcQZv2j72lC~G2&{e zAZOMI$K`mToP={jFiK?+&6)S*&aIG(UE$O6B9oCHexRiLKZv*81%#FJ%7TDQ1am{f zAn?Jtx}&d{8WmIvKlW)}H;i;?5<7^e01dHfdDX5U<)y&MAwox}GBsnRyzIu#BMSYdL_@J6XS^4=MT^zgpT;uvwYPjp=&P(@ z<2On+qVn%EMoyrlf@ccXW6gxO@ac@*X(J0nL!mHpmNQN9?0V*&vgrvzX!MkqVvj;% zKkyx6G>+t3kD*7+2k)IWp{=F8GHj7Wj*Qc+54`SL2ef{v1Q%%-GvIzF*m6@iOC~qs z@PDCTQ1BsgDKdMf`GC1z`{{L8UUbIC{x&Dw6}e9LoI32nyGNdW5k+O&9-_%dSWcGi zI-8Q?+j(0&KZ{Rmr)36YRx8L{$H|44@G|eo93z9y2F3P~RfXUk>|n!6Ge^t!tI%LL z|MXkYIGv?aEQLx**fE366ww`G|*{7Y7Fuyo7b>@rIR&erz3kDC83HDN>713?Ax#CY0k=HYEqH?-K3iYB@Tl6M% zZQ>*mK}&Cx;Tx{fbul}~8fhO;8C>$kqj95a?Iv#bW_6OEXvS-TG)EDYao9mr95I|) zcSjKFxN{7o?;};f;O0+Qo$|!?lp8i?a?jGgTKSAW##`2uh~cq+zDeFJ%ZdeVOKsvJ ziOgFMsr>vl%?K6g*5DV@{i1#!{4)DS;=wmX56PP(8V9Bo9oGp#axHz5e+of9{K0k+C^qnGmt%d_8P%PQQEgNC*<0iM@R4QQ0Wiqx`TRIivV+*hv43 zIDEb~i7X+kod#|NokAcYJ&|1fjyn0o1boqHjSB=Fga(XQz_6EaA17?$uszw)4@w(^ z*Au2hLhW?=X_s*y01+1v?IA8|ZtXmGdZqY??3AZOlI6p;jC7j#iUXJNfxM)5Pd|O? zsEg3*nsV_u*e@b%zTM7EQAj;!E4OflA6_2r#t?=Bd`~>FDR+)yOezrLMrRfpI7|?B zM3=XbC$%4k)8ds&;!W>fn&oq#dj%r_v}jhe04|I@(u`h?^m@$7rR4E~XPmTme))#% z^tUU!l*G_F#wLt7sW3t%#ylT6!|h!7z`yQkF*OdY{6IPH_JMT%YXdieA@E&V+!L$^ zS!5HGc%e#ZjU|)0PEo}4?$T0})659DEwP-cu!8Fsf$5)Af3l>_cgfKYHXcvQm7e0$l1KWKNN6}IAXL;Lq&s~>-hxMa zKw+h2HTHNT5Nde&^4YxVU?!UZZL(7La28XpH&f?f)bLt~1zUxO(dvnlwJuTQv;}9l zlX=CZuwOUX<_o~TqmdzR=>mcU0I*{mCHozXzsiL<5^4J@9wT%HVHju6<8QObN#e#P zjeX+BiY3 zJ0i2%#PZz2Wz|_xWC%NYI~oSSF8EVlx%>JZ{11jOLnBxT7Gazpb=+o%bc^_9zh_b^ zzqv2zvZQG1jIXK`{yxh0w&-hBbHx=p^LlOajc1&(E;X+sUP_mmodnGuaLL+JT!Y%} zJ|0zZ`1YD;tV<^_LVoUDC)MhYV2Mm_SM2&KY_x_+*w=wx>(Kq%9Rsbdk(}UTS>byo z7KFC9CCTJFnCXp?{B8JC)-h(%OL=k$T!+5<0h+>rR8ODk(ePi2x}K8;%S%|@PNtj_Sh*Y+lz^+`2w(_BwQbtr{D43?uKPp zQ@c65soLX!+T!#J;W zO0{4WEADinM@q$gi-g5m)Ol+v)iW}mp=gC!UYL&4@1lH<1%1o#RfLe4lVRIK4EfP_mq@V zgDRFqhnVJC#IF{~Nf*Ix9hm8&hn-&U~NFiity8Gjmv ztI(^7`wtX>X*T|m#;*deCc;0!d!&D_{6A^&D)?%Y{{fd{k}77w|0CwF+PNA?f9xb; d#(n=KthCkfFvB7MfDm)aU{d(kUn4cZ{{j1*>Iwh= literal 0 HcmV?d00001 diff --git a/pyjhora_batch/jhd_writer.py b/pyjhora_batch/jhd_writer.py new file mode 100644 index 000000000..ba9c44490 --- /dev/null +++ b/pyjhora_batch/jhd_writer.py @@ -0,0 +1,169 @@ +"""Write a Jagannatha Hora ``.jhd`` data file from a BirthRecord. + +PyJHora itself has no JHD import/export — it only shares JHora's *calculations*. +So this is a fresh serializer, calibrated byte-for-byte against a .jhd exported +by the Jagannatha Hora Windows app on 2026-07-29, read back alongside the app's +own on-screen rendering of that same file. Shown here on the reference chart +(15 Jun 1985, 10:30:00, 23.5N 75.75E, +5:30):: + + file app displays + ---------------------- --------------------------------- + 6 / 15 / 1985 June 15, 1985 + 10.300000000000000 10:30:00 + -5.300000 5:30:00 (East of GMT) + -75.450000 75 E 45' 00" + 23.300000 23 N 30' 00" + Ujjain / India Ujjain, India + +Encoding rules, all confirmed by that pair: + +* Every angular/temporal field is PACKED sexagesimal ``D.MMSS`` — the digits + after the point are literally the minutes and seconds, not a fraction. + 10:30:00 -> ``15.43``; 75 deg 48' -> ``75.48``. The decimal-degrees reading is + ruled out because it would render as 15:43 -> ``15.716667`` and + 75 deg 48' -> ``75.800000``, neither of which is in the file. +* The ONE exception is lines 9/10, which repeat the timezone as plain DECIMAL + hours (``-5.500000`` alongside the packed ``-5.300000`` on line 5). +* Line 4 (time) is printed with 15 decimals, lines 5-7 with 6. +* Sign conventions differ from the usual: timezone East-of-Greenwich is + NEGATIVE (India +5:30 -> ``-5.300000``); longitude East is NEGATIVE; + latitude North is POSITIVE. +* Lines 13/14 are CITY and COUNTRY, not name and place. The person's name lives + in the FILENAME only — JHora never stores it inside the file. +* Line endings are CRLF. + +The 18-line layout (one value per line): + 1 month 10 timezone decimal (repeat) + 2 day 11 0 + 3 year 12 105 (country index; 105 = India) + 4 time H.MMSS 13 city + 5 timezone H.MM 14 country + 6 longitude D.MMSS 15 1 (chart style flag) + 7 latitude D.MMSS 16 1013.250000 (pressure, hPa) + 8 0.000000 (alt) 17 20.000000 (temperature, C) + 9 timezone decimal 18 1 + +An earlier transcription of a 1948 export (pasted as text, never as a file) +appeared to show decimal degrees and briefly drove this module to write +decimal. That transcription was wrong — it contained ``-79.099540``, which is +not a legal packed value (95 seconds) nor consistent with this verified export. +Trust the file on disk, not the paste. +""" + +from __future__ import annotations + +from pathlib import Path + +DEFAULT_COUNTRY = "India" +COUNTRY_INDEX = "105" # JHora's atlas index for India (line 12) + + +def _pack_dms(value: float, *, seconds: bool = True) -> str: + """Decimal degrees/hours -> JHora packed ``D.MMSS00`` magnitude string. + + Sign is handled by the caller; this returns the absolute-value body. + With ``seconds=False`` only arc-minutes are encoded (used for timezone, + matching the reference writer which rounds tz to the minute). + """ + v = abs(float(value)) + d = int(v) + minutes_full = (v - d) * 60.0 + m = int(minutes_full) + if seconds: + s = round((minutes_full - m) * 60.0) + else: + m = round(minutes_full) + s = 0 + # normalize any rounding carry so MM<60 and SS<60 + if s >= 60: + s -= 60 + m += 1 + if m >= 60: + m -= 60 + d += 1 + return f"{d}.{m:02d}{s:02d}00" + + +def _timezone_field(tz_hours: float) -> str: + """Line 5: timezone packed ``H.MM``, East (positive offset) negative.""" + body = _pack_dms(tz_hours, seconds=False) + return f"-{body}" if tz_hours >= 0 else body + + +def _timezone_decimal_field(tz_hours: float) -> str: + """Lines 9/10: the same timezone as decimal hours, East still negative.""" + return f"{-float(tz_hours):f}" + + +def _longitude_field(longitude: float) -> str: + """Line 6: longitude packed ``D.MMSS``; East (positive) is negative here.""" + body = _pack_dms(longitude) + return f"-{body}" if longitude >= 0 else body + + +def _latitude_field(latitude: float) -> str: + """Line 7: latitude packed ``D.MMSS``; North (positive) stays positive.""" + body = _pack_dms(latitude) + return body if latitude >= 0 else f"-{body}" + + +def _time_field(time_of_birth: str) -> str: + """Line 4: clock time packed ``H.MMSS``, printed with 15 decimals. + + Built as a string rather than via ``float(...):.15f``: the round trip is + lossy for values like 15:45, which comes back as ``15.449999999999999`` — + digits an importer reading MM/SS positionally would see as 44 min 99 sec. + """ + hh, mm, ss = (int(x) for x in time_of_birth.split(":")) + return f"{hh}.{mm:02d}{ss:02d}00".ljust(len(str(hh)) + 1 + 15, "0") + + +def _place_fields(record) -> tuple[str, str]: + """Lines 13/14: city and country. + + JHora keeps the person's name in the filename, never in the file. Records + carry a single free-text place ("Ujjain, Madhya Pradesh") and no + country, so the whole string goes on the city line and the country falls + back to India — the app displays them joined, ", ". + """ + country = getattr(record, "country", "") or DEFAULT_COUNTRY + return record.place_name, country + + +def build_jhd(record) -> str: + """Return the JHD file contents (str) for a BirthRecord, CRLF-terminated.""" + year, month, day = (int(x) for x in record.date_of_birth.split(",")) + city, country = _place_fields(record) + + lines = [ + str(month), + str(day), + str(year), + _time_field(record.time_of_birth), + _timezone_field(record.timezone), + _longitude_field(record.longitude), + _latitude_field(record.latitude), + "0.000000", # altitude / observer height + # tz repeated twice more, but decimal instead of packed + _timezone_decimal_field(record.timezone), + _timezone_decimal_field(record.timezone), + "0", + COUNTRY_INDEX, + city, + country, + "1", # chart style flag + "1013.250000", # atmospheric pressure (hPa) + "20.000000", # temperature (C) + "1", + ] + return "\r\n".join(lines) + "\r\n" + + +def write_jhd(record, out_path) -> Path: + """Write ``record`` as a .jhd file at ``out_path`` and return the path.""" + out_path = Path(out_path) + out_path.parent.mkdir(parents=True, exist_ok=True) + # newline="" so the CRLF pairs built above survive verbatim on every OS + with out_path.open("w", encoding="utf-8", newline="") as fh: + fh.write(build_jhd(record)) + return out_path diff --git a/pyjhora_batch/pdf_writer.py b/pyjhora_batch/pdf_writer.py new file mode 100644 index 000000000..7af05e575 --- /dev/null +++ b/pyjhora_batch/pdf_writer.py @@ -0,0 +1,429 @@ +"""Render a :class:`~pyjhora_batch.report_data.Report` as a true vector PDF. + +The GUI's ``save_as_pdf`` pastes ~975x770 JPEG screenshots of Qt widgets onto +A4 — roughly 130 DPI of lossy pixels, ~10 MB, and nothing in it is real text. +This module lays the same content out with ReportLab instead, so glyphs stay +vector outlines: sharp at any zoom, selectable, searchable, and a fraction of +the size. + +Fonts +----- +The engine's strings are full of astrological symbols (Sun☉, ♑Capricorn, ℒ). +The built-in Type1 faces cannot render those, so a Unicode TrueType font is +located at import time (see :func:`resolve_font`). If none is found the text is +transliterated to ASCII rather than emitting black boxes, and the report says so. +""" + +from __future__ import annotations + +import os +from pathlib import Path +from typing import List, Optional, Sequence, Tuple + +from reportlab.lib import colors +from reportlab.lib.enums import TA_LEFT +from reportlab.lib.pagesizes import A4 +from reportlab.lib.styles import ParagraphStyle +from reportlab.lib.units import mm +from reportlab.pdfbase import pdfmetrics +from reportlab.pdfbase.ttfonts import TTFont +from reportlab.platypus import (BaseDocTemplate, Flowable, Frame, KeepTogether, + PageTemplate, Paragraph, Spacer, Table, TableStyle) + +from .report_data import ChartDiagram, Report, build_report + +# --- chart geometry --------------------------------------------------- +# South Indian: signs sit in fixed cells of a 4x4 ring, Pisces top-left and +# running clockwise. Taken from SouthIndianChart._zodiac_symbols in +# jhora/ui/chart_styles.py so the layout matches what the GUI draws. +_SOUTH_CELLS = { + 11: (0, 0), 0: (0, 1), 1: (0, 2), 2: (0, 3), + 10: (1, 0), 3: (1, 3), + 9: (2, 0), 4: (2, 3), + 8: (3, 0), 7: (3, 1), 6: (3, 2), 5: (3, 3), +} + +# North Indian: houses are fixed and signs rotate. The outer square, its two +# diagonals and the diamond on the four edge midpoints cut the box into twelve +# regions — four kites touching the edge midpoints and eight corner triangles. +# These are the exact centroids of those regions in a unit square measured DOWN +# from the top-left, in house order (H1 = top kite, then counter-clockwise). +# The ordering matches NorthIndianChart._north_label_positions in +# jhora/ui/chart_styles.py; only the anchoring differs — PyJHora left-aligns +# text at a tuned offset, here it is centred in the region. +_K = 0.25 # kite centroid, measured in from its edge +_TA, _TB = 0.25, 1.0 / 12.0 # corner-triangle centroid (long side, short side) +_NORTH_HOUSE_CENTROIDS = [ + (0.5, _K), (_TA, _TB), (_TB, _TA), (_K, 0.5), + (_TB, 1 - _TA), (_TA, 1 - _TB), (0.5, 1 - _K), (1 - _TA, 1 - _TB), + (1 - _TB, 1 - _TA), (1 - _K, 0.5), (1 - _TB, _TA), (1 - _TA, _TB), +] + +#: Short sign labels; the engine's own names carry glyphs we may not be able to +#: draw in ASCII fallback mode, so the diagram uses these instead. +_SIGN_ABBR = ("Ar", "Ta", "Ge", "Cn", "Le", "Vi", + "Li", "Sc", "Sg", "Cp", "Aq", "Pi") + +#: Probe characters that a usable report font must be able to draw. +_GLYPH_PROBE = "♑☉☾♂☿♃♀♄☊☋ℒ°" + +#: Searched in order; the first file that exists and covers _GLYPH_PROBE wins. +FONT_CANDIDATES: Tuple[str, ...] = ( + "/Library/Fonts/Arial Unicode.ttf", + "/System/Library/Fonts/Supplemental/Arial Unicode.ttf", + "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", + "/usr/share/fonts/truetype/freefont/FreeSerif.ttf", + "C:/Windows/Fonts/arialuni.ttf", + "C:/Windows/Fonts/seguisym.ttf", +) + +_ASCII_FALLBACK = { + "☉": "(Su)", "☾": "(Mo)", "♂": "(Ma)", "☿": "(Me)", "♃": "(Ju)", "♀": "(Ve)", + "♄": "(Sa)", "☊": "(Ra)", "☋": "(Ke)", "ℒ": "(Asc)", "°": " deg ", "’": "'", + "♈": "", "♉": "", "♊": "", "♋": "", "♌": "", "♍": "", + "♎": "", "♏": "", "♐": "", "♑": "", "♒": "", "♓": "", "︎": "", +} + +_registered_font: Optional[str] = None + + +def resolve_font(candidates: Sequence[str] = FONT_CANDIDATES) -> Optional[str]: + """Return the path of the first candidate that covers the astro symbols.""" + override = os.environ.get("PYJHORA_REPORT_FONT") + ordered = ([override] if override else []) + list(candidates) + for path in ordered: + if not path or not os.path.exists(path): + continue + try: + face = TTFont("_probe", path).face + if all(ord(ch) in face.charToGlyph for ch in _GLYPH_PROBE): + return path + except Exception: # noqa: BLE001 - an unreadable font is simply not a candidate + continue + return None + + +def _ensure_font() -> Optional[str]: + """Register the Unicode font once; return its reportlab name or None.""" + global _registered_font + if _registered_font is not None: + return _registered_font or None + path = resolve_font() + if path is None: + _registered_font = "" + return None + name = "JHoraUnicode" + pdfmetrics.registerFont(TTFont(name, path)) + # No matching bold face ships with these files; map bold onto the same + # outlines so never falls back to a font that lacks the symbols. + pdfmetrics.registerFont(TTFont(name + "-Bold", path)) + pdfmetrics.registerFontFamily(name, normal=name, bold=name + "-Bold", + italic=name, boldItalic=name + "-Bold") + _registered_font = name + return name + + +def _to_ascii(text: str) -> str: + for symbol, replacement in _ASCII_FALLBACK.items(): + text = text.replace(symbol, replacement) + return text.encode("ascii", "ignore").decode("ascii") + + +def _escape(text: str, ascii_only: bool) -> str: + text = str(text) + if ascii_only: + text = _to_ascii(text) + return (text.replace("&", "&").replace("<", "<").replace(">", ">")) + + +class _Styles: + def __init__(self, font: Optional[str]): + self.ascii_only = font is None + body_font = font or "Helvetica" + bold_font = (font + "-Bold") if font else "Helvetica-Bold" + self.title = ParagraphStyle("title", fontName=bold_font, fontSize=18, + leading=22, spaceAfter=2, textColor=colors.HexColor("#1a1a1a")) + self.subtitle = ParagraphStyle("subtitle", fontName=body_font, fontSize=9.5, + leading=13, textColor=colors.HexColor("#555555"), + spaceAfter=10) + self.heading = ParagraphStyle("heading", fontName=bold_font, fontSize=11.5, + leading=14, spaceBefore=12, spaceAfter=5, + textColor=colors.HexColor("#20364f")) + self.body = ParagraphStyle("body", fontName=body_font, fontSize=8.6, + leading=11.4, alignment=TA_LEFT, spaceAfter=4) + self.cell = ParagraphStyle("cell", fontName=body_font, fontSize=7.8, leading=10) + self.cell_bold = ParagraphStyle("cellb", parent=self.cell, fontName=bold_font) + self.note = ParagraphStyle("note", parent=self.body, fontSize=8, + textColor=colors.HexColor("#8a3b3b")) + self.chart_font = body_font + + +class ChartFlowable(Flowable): + """Draws one divisional chart as vector lines and text. + + Square by construction. ``occupants`` is sign-indexed; the North Indian + style rotates it so house 1 is the ascendant, which is why both styles can + share the same :class:`~pyjhora_batch.report_data.ChartDiagram`. + """ + + def __init__(self, diagram: ChartDiagram, size: float, font: str, + ascii_only: bool = False): + super().__init__() + self.diagram = diagram + self.size = size + self.font = font + self.ascii_only = ascii_only + + def wrap(self, availWidth, availHeight): + self.size = min(self.size, availWidth) + return self.size, self.size + + # -- helpers -------------------------------------------------------- + + def _label(self, text: str) -> str: + return _to_ascii(text) if self.ascii_only else text + + def _planet_font_size(self) -> float: + busiest = max((len(o) for o in self.diagram.occupants), default=0) + cell = self.size / 4.0 + # keep the fullest cell's stack inside its box + return max(4.2, min(7.5, cell / max(busiest, 1) / 1.45)) + + def _draw_stack(self, x, y, width, names, size): + """Draw planet names top-down from (x, y-as-top), clipped to width.""" + c = self.canv + c.setFont(self.font, size) + c.setFillColor(colors.HexColor("#14304d")) + line = size * 1.22 + for i, name in enumerate(names): + text = self._label(name) + while text and c.stringWidth(text, self.font, size) > width: + text = text[:-1] + c.drawString(x, y - (i + 1) * line, text) + + # -- styles --------------------------------------------------------- + + def _draw_south(self): + c = self.canv + s = self.size + cell = s / 4.0 + size = self._planet_font_size() + + c.setStrokeColor(_GRID_STRONG) + c.setLineWidth(0.7) + for sign, (row, col) in _SOUTH_CELLS.items(): + # ReportLab's origin is bottom-left; the table above is top-down + x = col * cell + y = s - (row + 1) * cell + c.rect(x, y, cell, cell, stroke=1, fill=0) + + if sign == self.diagram.ascendant: + # the traditional lagna mark: a stroke across the cell corner. + # Bottom-left, because the top-left holds the sign abbreviation + # and the planet stack grows down from there. + c.setStrokeColor(_ASC_MARK) + c.setLineWidth(1.0) + c.line(x, y + cell * 0.26, x + cell * 0.26, y) + c.setStrokeColor(_GRID_STRONG) + c.setLineWidth(0.7) + + c.setFont(self.font, 5.4) + c.setFillColor(_SIGN_COLOR) + c.drawString(x + 2, y + cell - 6.5, _SIGN_ABBR[sign]) + self._draw_stack(x + 2, y + cell - 6.0, cell - 4, + self.diagram.occupants[sign], size) + + def _draw_north(self): + c = self.canv + s = self.size + size = self._planet_font_size() + + c.setStrokeColor(_GRID_STRONG) + c.setLineWidth(0.7) + c.rect(0, 0, s, s, stroke=1, fill=0) + c.line(0, 0, s, s) # diagonals + c.line(0, s, s, 0) + h = s / 2.0 # inner diamond on the midpoints + c.line(h, 0, s, h) + c.line(s, h, h, s) + c.line(h, s, 0, h) + c.line(0, h, h, 0) + + line = size * 1.22 + for house, sign in enumerate(self.diagram.house_order()): + fx, fy = _NORTH_HOUSE_CENTROIDS[house] + cx = fx * s + cy = s - fy * s # flip to ReportLab's y-up + names = self.diagram.occupants[sign] + # centre the sign label + planet stack vertically on the centroid + top = cy + (len(names) + 1) * line / 2.0 + + c.setFont(self.font, 5.4) + c.setFillColor(_SIGN_COLOR) + c.drawCentredString(cx, top - line * 0.8, _SIGN_ABBR[sign]) + if house == 0: + c.setFillColor(_ASC_MARK) + c.drawCentredString(cx, top + line * 0.35, "Lagna") + + c.setFont(self.font, size) + c.setFillColor(colors.HexColor("#14304d")) + for i, name in enumerate(names): + text = self._label(name) + while text and c.stringWidth(text, self.font, size) > s * 0.30: + text = text[:-1] + c.drawCentredString(cx, top - (i + 2) * line, text) + + def draw(self): + if self.diagram.style == "north_indian": + self._draw_north() + else: + self._draw_south() + + +_GRID = colors.HexColor("#c9d2dc") +_GRID_STRONG = colors.HexColor("#7f8c9b") +_SIGN_COLOR = colors.HexColor("#9aa5b1") +_ASC_MARK = colors.HexColor("#c0392b") +_HEAD_BG = colors.HexColor("#e8edf3") +_ALT_BG = colors.HexColor("#f6f8fa") + + +def _table_style(has_header: bool) -> TableStyle: + cmds = [ + ("GRID", (0, 0), (-1, -1), 0.25, _GRID), + ("VALIGN", (0, 0), (-1, -1), "TOP"), + ("LEFTPADDING", (0, 0), (-1, -1), 4), + ("RIGHTPADDING", (0, 0), (-1, -1), 4), + ("TOPPADDING", (0, 0), (-1, -1), 2.5), + ("BOTTOMPADDING", (0, 0), (-1, -1), 2.5), + ] + if has_header: + cmds += [("BACKGROUND", (0, 0), (-1, 0), _HEAD_BG), + ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, _ALT_BG])] + else: + cmds += [("ROWBACKGROUNDS", (0, 0), (-1, -1), [colors.white, _ALT_BG])] + return TableStyle(cmds) + + +def _col_widths(headers: Sequence[str], rows: Sequence[Sequence[str]], + avail: float) -> List[float]: + ncols = max([len(headers)] + [len(r) for r in rows]) if (headers or rows) else 0 + if ncols == 0: + return [] + weights = [max(len(str(headers[i])) if i < len(headers) else 0, 1) + for i in range(ncols)] + for row in rows[:200]: # sampling is enough to size columns + for i, cell in enumerate(row): + weights[i] = max(weights[i], min(len(str(cell)), 90)) + total = sum(weights) or 1 + widths = [avail * w / total for w in weights] + floor = min(28.0, avail / max(ncols, 1)) + widths = [max(w, floor) for w in widths] + scale = avail / sum(widths) + return [w * scale for w in widths] + + +def _build_flowables(report: Report, styles: _Styles, avail: float) -> list: + esc = lambda t: _escape(t, styles.ascii_only) # noqa: E731 + rec = report.record + story: list = [ + Paragraph(esc(rec.name or rec.place_name), styles.title), + Paragraph(esc(f"{rec.date_of_birth.replace(',', '-')} at {rec.time_of_birth} — " + f"{rec.place_name}"), styles.subtitle), + ] + + for section in report.sections: + block: list = [Paragraph(esc(section.title), styles.heading)] + + if section.pairs: + rows = [[Paragraph(esc(k), styles.cell_bold), Paragraph(esc(v), styles.cell)] + for k, v in section.pairs] + table = Table(rows, colWidths=[avail * 0.32, avail * 0.68], repeatRows=0) + table.setStyle(_table_style(has_header=False)) + block.append(table) + + if section.note: + block.append(Paragraph(esc(section.note), styles.note)) + + if section.chart is not None: + # the diagram carries the same information as the sign/occupant + # table, so only one of the two is emitted + block.append(ChartFlowable(section.chart, min(avail, 250.0), + styles.chart_font, styles.ascii_only)) + story.append(KeepTogether(block)) + continue + + if section.rows: + headers = list(section.headers) + data = [] + if headers: + data.append([Paragraph(esc(h), styles.cell_bold) for h in headers]) + for row in section.rows: + data.append([Paragraph(esc(c), styles.cell) for c in row]) + widths = _col_widths(headers, section.rows, avail) + table = Table(data, colWidths=widths, repeatRows=1 if headers else 0) + table.setStyle(_table_style(has_header=bool(headers))) + block.append(table) + + if section.body: + for para in section.body.split("\n"): + if para.strip(): + block.append(Paragraph(esc(para), styles.body)) + else: + block.append(Spacer(1, 3)) + + # Keep short blocks whole; let long tables flow across pages naturally. + story.extend([KeepTogether(block)] if len(block) <= 2 and section.pairs + else block) + + if report.warnings: + story.append(Paragraph("Sections that could not be generated", styles.heading)) + for warning in report.warnings: + story.append(Paragraph(esc(f"• {warning}"), styles.note)) + if styles.ascii_only: + story.append(Paragraph( + "No Unicode font was available, so astrological symbols were replaced " + "with ASCII abbreviations. Set PYJHORA_REPORT_FONT to a .ttf to restore them.", + styles.note)) + return story + + +def render_pdf(report: Report, out_path, *, pagesize=A4, margin: float = 15 * mm) -> Path: + """Write ``report`` to ``out_path`` as a vector PDF.""" + out_path = Path(out_path) + out_path.parent.mkdir(parents=True, exist_ok=True) + + font = _ensure_font() + styles = _Styles(font) + page_w, page_h = pagesize + avail = page_w - 2 * margin + + rec = report.record + title = rec.name or rec.place_name + footer_font = font or "Helvetica" + footer_text = _escape(title, styles.ascii_only) + + def _decorate(canvas, doc): + canvas.saveState() + canvas.setFont(footer_font, 7.5) + canvas.setFillColor(colors.HexColor("#8a94a0")) + canvas.drawString(margin, margin * 0.6, footer_text) + canvas.drawRightString(page_w - margin, margin * 0.6, f"Page {doc.page}") + canvas.restoreState() + + doc = BaseDocTemplate(str(out_path), pagesize=pagesize, + leftMargin=margin, rightMargin=margin, + topMargin=margin, bottomMargin=margin, + title=f"Horoscope — {title}", author="pyjhora_batch") + frame = Frame(margin, margin, avail, page_h - 2 * margin, id="body", + leftPadding=0, rightPadding=0, topPadding=0, bottomPadding=0) + doc.addPageTemplates([PageTemplate(id="main", frames=[frame], onPage=_decorate)]) + doc.build(_build_flowables(report, styles, avail)) + return out_path + + +def write_pdf_report(record, out_path, *, report: Report | None = None, + **build_kwargs) -> Path: + """Build (or reuse) a report for ``record`` and write it as a vector PDF.""" + report = report if report is not None else build_report(record, **build_kwargs) + return render_pdf(report, out_path) diff --git a/pyjhora_batch/readers/__init__.py b/pyjhora_batch/readers/__init__.py new file mode 100644 index 000000000..39b049ead --- /dev/null +++ b/pyjhora_batch/readers/__init__.py @@ -0,0 +1,7 @@ +"""Input readers for the batch layer. Each reader yields raw row dicts; +validation/normalization happens later in wrapper.BirthRecord.from_dict. +""" + +from .csv_reader import RawRow, read_csv + +__all__ = ["RawRow", "read_csv"] diff --git a/pyjhora_batch/readers/csv_reader.py b/pyjhora_batch/readers/csv_reader.py new file mode 100644 index 000000000..e2612664a --- /dev/null +++ b/pyjhora_batch/readers/csv_reader.py @@ -0,0 +1,60 @@ +"""CSV reader for the batch layer. + +Yields one ``RawRow`` per data row: the raw column dict plus its 1-based source +line number (for error messages and retry files). It intentionally does no +astrology-specific validation — that belongs to ``BirthRecord.from_dict`` so a +single validation path is shared by every reader. +""" + +from __future__ import annotations + +import csv +from dataclasses import dataclass +from pathlib import Path +from typing import Iterator + + +@dataclass +class RawRow: + """One raw input row before validation.""" + + row_number: int # 1-based, counting data rows (header excluded) + line_number: int # 1-based physical line in the file (header included) + data: dict + + +def read_csv(path, *, encoding: str = "utf-8-sig") -> Iterator[RawRow]: + """Yield ``RawRow`` for each non-empty data row in ``path``. + + Uses ``utf-8-sig`` so a leading BOM is stripped from the first header. Fully + blank rows are skipped. Header names are lower-cased and stripped so the + downstream alias map matches regardless of source casing/whitespace. + + ``skipinitialspace`` matters more than it looks: a quoted field must + normally start immediately after the comma, so ``a, "1998,9,10",b`` would + otherwise parse the quote as literal text and let the commas inside split + the field into extra columns — corrupting every column after it. A space + after the comma is a very easy thing to type, so it is tolerated here. + """ + path = Path(path) + if not path.is_file(): + raise FileNotFoundError(f"CSV not found: {path}") + + with path.open("r", newline="", encoding=encoding) as fh: + reader = csv.DictReader(fh, skipinitialspace=True) + if reader.fieldnames is None: + return # empty file + reader.fieldnames = [(name or "").strip().lower() for name in reader.fieldnames] + row_number = 0 + for row in reader: + # csv.DictReader sets line_num to the physical line just read. + line_number = reader.line_num + cleaned = { + k: (v.strip() if isinstance(v, str) else v) + for k, v in row.items() + if k is not None + } + if not any(v not in (None, "") for v in cleaned.values()): + continue # skip fully-blank row + row_number += 1 + yield RawRow(row_number=row_number, line_number=line_number, data=cleaned) diff --git a/pyjhora_batch/readers/excel_reader.py b/pyjhora_batch/readers/excel_reader.py new file mode 100644 index 000000000..e19e84165 --- /dev/null +++ b/pyjhora_batch/readers/excel_reader.py @@ -0,0 +1,83 @@ +"""Excel (.xlsx) reader for the batch layer. + +Mirrors csv_reader: yields the same :class:`RawRow` objects so the rest of the +pipeline (BirthRecord.from_dict, engine) is reader-agnostic. The only extra work +here is turning native Excel cell types (numbers, dates, times) into the string +forms the shared validator expects — so a user can format the birth date as a +real date cell and the time as a real time cell instead of quoting text. +""" + +from __future__ import annotations + +import datetime as _dt +from pathlib import Path +from typing import Iterator + +from openpyxl import load_workbook + +from .csv_reader import RawRow + + +def _stringify_cell(value) -> str: + """Convert an Excel cell value to the canonical string BirthRecord expects. + + * date / date-only datetime -> ``YYYY,M,D`` + * time / time-only datetime (Excel's 1899 epoch) -> ``HH:MM:SS`` + * whole-number float -> integer text (27.0 -> "27") + * other numbers -> full-precision text (75.783333 stays exact) + """ + if value is None: + return "" + if isinstance(value, bool): + return str(value) + if isinstance(value, _dt.datetime): + # Excel stores a time-only cell as a datetime on 1899-12-30/31. + if value.year <= 1899: + return value.strftime("%H:%M:%S") + return f"{value.year},{value.month},{value.day}" + if isinstance(value, _dt.date): + return f"{value.year},{value.month},{value.day}" + if isinstance(value, _dt.time): + return value.strftime("%H:%M:%S") + if isinstance(value, float): + return str(int(value)) if value.is_integer() else repr(value) + return str(value).strip() + + +def read_excel(path, *, sheet=None) -> Iterator[RawRow]: + """Yield ``RawRow`` for each non-empty data row in an .xlsx worksheet. + + ``sheet`` selects a worksheet by name or index; default is the active sheet. + The first non-empty row is the header; header names are lower-cased and + stripped so the downstream alias map matches. Fully blank rows are skipped. + """ + path = Path(path) + if not path.is_file(): + raise FileNotFoundError(f"Excel file not found: {path}") + + wb = load_workbook(filename=str(path), read_only=True, data_only=True) + try: + if sheet is None: + ws = wb.active + elif isinstance(sheet, int): + ws = wb.worksheets[sheet] + else: + ws = wb[sheet] + + header = None + row_number = 0 + for line_number, row in enumerate(ws.iter_rows(values_only=True), start=1): + values = [_stringify_cell(v) for v in row] + if not any(v != "" for v in values): + continue # skip fully-blank row (also skips leading blanks) + if header is None: + header = [v.strip().lower() for v in values] + continue + # pad/truncate to header width + if len(values) < len(header): + values += [""] * (len(header) - len(values)) + data = {header[i]: values[i] for i in range(len(header)) if header[i]} + row_number += 1 + yield RawRow(row_number=row_number, line_number=line_number, data=data) + finally: + wb.close() diff --git a/pyjhora_batch/report_data.py b/pyjhora_batch/report_data.py new file mode 100644 index 000000000..9333f4aa9 --- /dev/null +++ b/pyjhora_batch/report_data.py @@ -0,0 +1,865 @@ +"""Build a complete, structured horoscope report straight from PyJHora's engine. + +This is the data layer behind both the plain-text report and the vector PDF. It +deliberately bypasses the Qt GUI: ``jhora.horoscope.info.Horoscope`` and the +chart/prediction modules are the same code the GUI calls, so the content is +identical while costing ~1s instead of ~60s and needing no QApplication. + +Two things learned the hard way and encoded here: + +* ``jhora.horoscope.main`` is stale — its ``get_calendar_information`` calls + ``drik.vaara(jd)`` against a two-argument signature and raises. The live class + is ``jhora.horoscope.info.Horoscope`` (what ``horo_chart_tabs`` imports). +* The engine modules share mutable global state (``utils.resource_strings`` and + friends). Calling predictions -> dosha -> yoga -> raja_yoga in one process can + make ``raja_yoga`` raise IndexError, while any shorter prefix of that sequence + is fine. Every section therefore re-applies the language and is isolated, so a + poisoned section degrades to a warning instead of losing the whole report. +""" + +from __future__ import annotations + +import html +import re +from dataclasses import dataclass, field +from typing import Callable, List, Optional, Sequence, Tuple + +from .wrapper import BirthRecord, RecordError + +# Chart-scoped keys come in two shapes: "Raasi (D1)-Sun☉" and "D-1-Arudha Lagna". +_CHART_KEY_RE = re.compile(r"^(?P.+\(D\d+\))-(?P.+)$", re.DOTALL) +_ALT_KEY_RE = re.compile(r"^D-(?P\d+)-(?P.+)$", re.DOTALL) +_TAG_RE = re.compile(r"<[^>]+>") +_BREAK_RE = re.compile(r"<\s*(br|/p|/div|/tr)\s*/?\s*>", re.IGNORECASE) +# U+FE0E/U+FE0F select text vs emoji presentation. The engine appends VS15 to +# some zodiac glyphs ("♑︎Capricorn"); most PDF/terminal fonts have no glyph for +# it and draw a tofu box, so it is stripped everywhere. +_VARIATION_RE = re.compile("[︎️]") + +#: Dhasa systems included by default. The engine exposes 26 graha dhasas plus +#: rasi/annual ones; emitting all of them yields hundreds of pages, so the +#: standard Vimsottari is the default and the rest are opt-in via ``dhasas=``. +DEFAULT_DHASAS: Tuple[str, ...] = ("vimsottari",) + +#: Passed as a dhasa name, expands to every system the engine exposes. +ALL_DHASAS = "all" + + +def available_dhasas() -> List[str]: + """Every dhasa system PyJHora exposes: graha, then rasi, then annual. + + Four of these (aayu, patyayini, varsha_vimsottari, varsha_narayana) take + arguments a natal report has no value for and will be reported as warnings + rather than sections. + """ + from jhora import const + return (list(const._graha_dhasa_dict) + list(const._rasi_dhasa_dict) + + list(const._annual_dhasa_dict)) + + +def resolve_dhasas(names: Optional[Sequence[str]]) -> Tuple[str, ...]: + """Expand the ``all`` sentinel and de-duplicate, preserving order.""" + if not names: + return DEFAULT_DHASAS + resolved: List[str] = [] + for name in names: + for item in (available_dhasas() if str(name).strip().lower() == ALL_DHASAS + else [name]): + if item not in resolved: + resolved.append(item) + return tuple(resolved) + + +#: Chart styles that can be drawn as a diagram; anything else falls back to +#: the South Indian grid (with the substitution noted on the section). +DRAWABLE_STYLES = ("south_indian", "north_indian") + + +@dataclass +class ChartDiagram: + """A divisional chart in the form a renderer can draw. + + ``occupants`` is indexed by *sign* (0=Aries .. 11=Pisces) — the South Indian + style paints signs into fixed cells, while the North Indian style paints + fixed houses, so it rotates this by ``ascendant``. + """ + + label: str + occupants: List[List[str]] + ascendant: int = 0 + style: str = "south_indian" + + def house_order(self) -> List[int]: + """Sign index for each house 1..12, i.e. counting from the ascendant.""" + return [(self.ascendant + h) % 12 for h in range(12)] + + +@dataclass +class Section: + """One titled block of the report. + + A section carries whichever shapes it needs: ``pairs`` for label/value + lists, ``headers``+``rows`` for tables, ``body`` for prose, and ``chart`` + for a drawable divisional chart. + """ + + title: str + pairs: List[Tuple[str, str]] = field(default_factory=list) + headers: List[str] = field(default_factory=list) + rows: List[List[str]] = field(default_factory=list) + body: str = "" + note: str = "" + chart: Optional[ChartDiagram] = None + + @property + def is_empty(self) -> bool: + return not (self.pairs or self.rows or self.body.strip() or self.chart) + + +@dataclass +class Report: + record: BirthRecord + sections: List[Section] = field(default_factory=list) + warnings: List[str] = field(default_factory=list) + + def section(self, title: str) -> Optional[Section]: + for s in self.sections: + if s.title == title: + return s + return None + + +def clean_text(value) -> str: + """Normalize an engine string for display in any renderer. + + Strips presentation variation selectors and turns the literal two-character + sequence ``\\n`` (which some prediction strings embed) into a real newline. + """ + text = _VARIATION_RE.sub("", str(value)) + return text.replace("\\n", "\n") + + +def _normalize(report: Report) -> Report: + """Apply :func:`clean_text` across every string the report will render.""" + for section in report.sections: + section.title = clean_text(section.title) + section.pairs = [(clean_text(k), clean_text(v)) for k, v in section.pairs] + section.headers = [clean_text(h) for h in section.headers] + section.rows = [[clean_text(c) for c in row] for row in section.rows] + section.body = clean_text(section.body) + section.note = clean_text(section.note) + if section.chart is not None: + section.chart.label = clean_text(section.chart.label) + section.chart.occupants = [[clean_text(p) for p in cell] + for cell in section.chart.occupants] + return report + + +def _strip_html(value: str) -> str: + """Turn the engine's HTML prediction/dosha blobs into readable plain text.""" + text = _BREAK_RE.sub("\n", str(value)) + text = _TAG_RE.sub("", text) + text = html.unescape(text) + # collapse the ragged blank lines the tag removal leaves behind + lines = [ln.strip() for ln in text.splitlines()] + out: List[str] = [] + for ln in lines: + if ln or (out and out[-1]): + out.append(ln) + return "\n".join(out).strip() + + +def _split_chart_key(key: str) -> Tuple[Optional[str], str]: + """Split an info key into (chart label, item). Chart is None if unscoped.""" + key = str(key) + m = _CHART_KEY_RE.match(key) + if m: + return m.group("chart").strip(), m.group("item").strip() + m = _ALT_KEY_RE.match(key) + if m: + return f"D-{m.group('num')}", m.group("item").strip() + return None, key.strip() + + +class _Builder: + """Assembles the report, isolating each section from the others.""" + + def __init__(self, record: BirthRecord, dhasas: Sequence[str]): + self.rec = record + self.dhasas = tuple(dhasas) + self.report = Report(record=record) + self._horo = None + self._info = None + self._charts = None + self._asc_houses = None + + # --- infrastructure ------------------------------------------------- + + def _reset_language(self) -> None: + """Re-apply the language before each section (see module docstring).""" + from jhora import utils + utils.set_language(self._lang_code()) + + def _lang_code(self) -> str: + from jhora import const + name = (self.rec.language or "English").strip() + for code, label in getattr(const, "available_languages", {}).items(): + # available_languages maps display name -> code in some versions and + # the reverse in others; accept either direction. + if label == name: + return code + if code == name: + return label + return "en" + + def _safe(self, title: str, fn: Callable[[], Optional[Section]]) -> None: + try: + self._reset_language() + section = fn() + except Exception as exc: # noqa: BLE001 - one bad section must not sink the report + self.report.warnings.append(f"{title}: {type(exc).__name__}: {exc}") + return + if section is not None and not section.is_empty: + self.report.sections.append(section) + elif section is not None: + self.report.warnings.append(f"{title}: no data produced") + + # --- engine handles ------------------------------------------------- + + def _place(self): + from jhora.panchanga import drik + return drik.Place(self.rec.place_name, self.rec.latitude, + self.rec.longitude, self.rec.timezone) + + def _date(self): + from jhora.panchanga import drik + y, m, d = (int(x) for x in self.rec.date_of_birth.split(",")) + return drik.Date(y, m, d) + + def _tob_tuple(self) -> Tuple[int, int, int]: + hh, mm, ss = (int(x) for x in self.rec.time_of_birth.split(":")) + return hh, mm, ss + + def _jd(self) -> float: + from jhora import utils + return utils.julian_day_number(self._date(), self._tob_tuple()) + + def horoscope(self): + if self._horo is None: + from jhora.horoscope import info + self._horo = info.Horoscope( + place_with_country_code=self.rec.place_name, + latitude=self.rec.latitude, longitude=self.rec.longitude, + timezone_offset=self.rec.timezone, date_in=self._date(), + birth_time=self.rec.time_of_birth, language=self._lang_code(), + ) + return self._horo + + def horoscope_information(self): + if self._info is None: + (self._info, self._charts, + self._asc_houses) = self.horoscope().get_horoscope_information() + return self._info, self._charts + + # --- sections ------------------------------------------------------- + + def birth_details(self) -> Section: + from jhora import utils + y, m, d = (int(x) for x in self.rec.date_of_birth.split(",")) + hh, mi, ss = self._tob_tuple() + tz = self.rec.timezone + sign = "+" if tz >= 0 else "-" + tz_h, tz_m = divmod(round(abs(tz) * 60), 60) + pairs = [ + ("Name", self.rec.name or "(not given)"), + ("Date of Birth", f"{d:02d}-{m:02d}-{y:04d}"), + ("Time of Birth", f"{hh:02d}:{mi:02d}:{ss:02d}"), + ("Place", self.rec.place_name), + ("Latitude", utils.to_dms(self.rec.latitude, is_lat_long="lat", as_string=True)), + ("Longitude", utils.to_dms(self.rec.longitude, is_lat_long="long", as_string=True)), + ("Timezone", f"UTC{sign}{tz_h:02d}:{tz_m:02d}"), + ] + gender_names = {0: "Female", 1: "Male", 2: "Transgender", 3: "Not specified"} + pairs.append(("Gender", gender_names.get(self.rec.gender, "Not specified"))) + return Section("Birth Details", pairs=pairs) + + def panchanga(self) -> Section: + cal = self.horoscope().calendar_info or {} + return Section("Panchanga at Birth", + pairs=[(str(k), str(v)) for k, v in cal.items()]) + + def positions(self) -> List[Section]: + """One section per divisional chart, from the 989 formatted info keys. + + The engine uses three key shapes, which have to be told apart or the + most important table in the report ends up under a meaningless heading: + + * ``Hora (D2)-Sun`` -> planetary positions for a divisional chart + * ``Raasi-Sun`` -> the same, for D-1, with no "(D1)" marker + * ``D-1-Arudha Lagna`` -> arudha padas, NOT positions + """ + info_dict, _ = self.horoscope_information() + rasi_word = self._rasi_word(info_dict) + rasi_prefix = f"{rasi_word}-" + + positions: dict = {} + arudhas: dict = {} + other: List[Tuple[str, str]] = [] + + for key, value in info_dict.items(): + text = str(key) + chart, item = _split_chart_key(text) + if chart is None: + if text.startswith(rasi_prefix): + # D-1 positions; drop the redundant prefix from each label + positions.setdefault(f"{rasi_word} (D1)", []).append( + (text[len(rasi_prefix):].strip(), str(value))) + else: + other.append((item, str(value))) + elif _ALT_KEY_RE.match(text): + arudhas.setdefault(chart, []).append((item, str(value))) + else: + positions.setdefault(chart, []).append((item, str(value))) + + sections = [Section(f"Positions — {chart}", pairs=pairs) + for chart, pairs in positions.items() if pairs] + sections += [Section(f"Arudha Padas — {chart}", pairs=pairs) + for chart, pairs in arudhas.items() if pairs] + if other: + sections.append(Section("Other Values", pairs=other)) + return sections + + @staticmethod + def _rasi_word(info_dict) -> str: + """The engine's localized word for the D-1 chart (e.g. 'Raasi').""" + return next((str(k).split("-")[0] for k in info_dict + if "Ascendant" in str(k) and not _CHART_KEY_RE.match(str(k))), + "Raasi") + + def _resolved_style(self) -> Tuple[str, str]: + """The drawable chart style for this record, plus a note if substituted.""" + style = (self.rec.chart_type or "south_indian").strip().lower() + if style in DRAWABLE_STYLES: + return style, "" + return "south_indian", (f"{style!r} diagrams are not drawn; the chart " + f"below uses the South Indian layout.") + + def _chart_labels(self, count: int) -> List[str]: + """Names for each divisional chart, in ``horoscope_charts`` order.""" + info_dict, _ = self.horoscope_information() + labels: List[str] = [] + seen = set() + for key in info_dict: + m = _CHART_KEY_RE.match(str(key)) + if m and m.group("chart") not in seen: + seen.add(m.group("chart")) + labels.append(m.group("chart")) + # The D-1 chart's keys are prefixed with the bare rasi word ("Raasi-Sun") + # and carry no "(D1)", so the loop above never sees it. It is chart 0. + labels.insert(0, f"{self._rasi_word(info_dict)} (D1)") + while len(labels) < count: + labels.append(f"Chart D-{len(labels) + 1}") + return labels[:count] + + def divisional_charts(self) -> List[Section]: + """Each chart as a drawable diagram plus a sign -> occupants table.""" + from jhora import utils + _, charts = self.horoscope_information() + if not charts: + return [] + ascendants = self._asc_houses or [] + signs = list(utils.RAASI_LIST) + labels = self._chart_labels(len(charts)) + + style, note = self._resolved_style() + sections = [] + for idx, chart in enumerate(charts): + if not isinstance(chart, (list, tuple)) or len(chart) != len(signs): + continue + occupants = [str(cell).split() and + [p for p in str(cell).splitlines() if p.strip()] or [] + for cell in chart] + asc = ascendants[idx] if idx < len(ascendants) else 0 + diagram = ChartDiagram(label=labels[idx], occupants=occupants, + ascendant=int(asc) if isinstance(asc, int) else 0, + style=style) + rows = [[signs[i], " ".join(occ) or "—"] for i, occ in enumerate(occupants)] + sections.append(Section(labels[idx], headers=["Rasi", "Occupants"], + rows=rows, chart=diagram, note=note)) + return sections + + def _dhasa_years(self, name: str) -> int: + """The varsha year number, following the engine's own aggregator. + + ``_get_annual_dhasa_bhukthi`` passes ``self.years`` to varsha-narayana + but ``self.years - 1`` to varsha-vimsottari (mudda). Matching that is + what puts mudda's year on the birth-year solar return instead of the + following one. + """ + years = getattr(self.horoscope(), "years", 1) + return years - 1 if name == "varsha_vimsottari" else years + + def _dhasa_call_args(self, method, name: str) -> list: + """Positional arguments for ``method``, derived from its signature. + + The dhasa methods do NOT share a calling convention: most take + ``(dob, tob, place)``, the varsha ones additionally need ``years``, + ``_get_varsha_vimsottari_dhasa`` takes ``(jd, place, years)``, and + ``_get_patyayini_dhasa`` takes none at all (it derives everything from + the Horoscope). Passing one fixed triple to all of them raises + TypeError on four systems, so the signature decides. + """ + import inspect + suppliers = { + "dob": self._date, + "tob": self._tob_tuple, + "place": self._place, + "jd": self._jd, + "years": lambda: self._dhasa_years(name), + } + args = [] + for param in inspect.signature(method).parameters.values(): + if param.kind not in (param.POSITIONAL_ONLY, param.POSITIONAL_OR_KEYWORD): + continue + if param.default is not param.empty: # optional - leave it alone + continue + if param.name not in suppliers: + raise TypeError(f"unsupported dhasa parameter {param.name!r}") + args.append(suppliers[param.name]()) + return args + + def _dhasa_kwargs(self, name: str) -> dict: + """Per-system keyword arguments needed to work around engine defects.""" + if name != "aayu": + return {} + # aayu's "pick the type automatically" sentinel is AAYU_TYPE.NONE, i.e. + # Python None -- but initialize_runtime() reloads it from + # factory_settings.json where it round-trips to the STRING "NONE". + # get_dhasa_antardhasa then takes it for a real choice and uses it as a + # planet key (KeyError: 'NONE'). Supply the value the auto branch would + # have computed: the stronger of lagna, Sun and Moon. + from jhora import const + from jhora.horoscope.chart import charts + from jhora.horoscope.dhasa.graha import aayu + positions = charts.rasi_chart(self._jd(), self._place())[:const._pp_count_upto_ketu] + return {"aayur_type": aayu._get_aayur_type(positions)} + + def dhasa(self, name: str) -> Section: + horo = self.horoscope() + horo.get_dhasa_bhukthi_as_raw_data = False + method = getattr(horo, f"_get_{name}_dhasa_bhukthi", None) or \ + getattr(horo, f"_get_{name}_dhasa", None) + if method is None: + raise AttributeError(f"no engine method for dhasa {name!r}") + # tob is supplied as the (h, m, s) tuple; the engine feeds it to + # julian_day_number, which cannot parse "HH:MM:SS" + data = method(*self._dhasa_call_args(method, name), **self._dhasa_kwargs(name)) + rows = [] + for entry in data or []: + if isinstance(entry, (list, tuple)) and len(entry) >= 2: + rows.append([str(entry[0]), str(entry[1])]) + else: + rows.append([str(entry), ""]) + title = name.replace("_", " ").title() + return Section(f"Dhasa-Bhukthi — {title}", + headers=["Dhasa-Bhukthi", "Starts"], rows=rows) + + def ashtakavarga(self) -> Section: + from jhora import utils + from jhora.horoscope.chart import ashtakavarga, charts + planet_positions = charts.rasi_chart(self._jd(), self._place()) + # get_ashtaka_varga wants the 1-D house->planet-index list, not positions + chart_1d = utils.get_house_planet_list_from_planet_positions(planet_positions) + bav, sav, _ = ashtakavarga.get_ashtaka_varga(chart_1d) + signs = list(utils.RAASI_LIST) + # BAV has 8 rows: the seven grahas THEN the lagna (0=Sun .. 6=Saturn, + # 7=Lagnam). Taking PLANET_NAMES[:8] would label that last row "Raagu", + # which is wrong — Rahu/Ketu have no bhinnashtakavarga. + names = list(utils.PLANET_NAMES)[:max(len(bav) - 1, 0)] + ["Lagna"] + # clean first, then slice: several sign names carry a variation selector + # that would otherwise eat one of the three characters ("♉T" vs "♈Ar") + headers = ["Planet"] + [clean_text(s)[:3] for s in signs] + rows = [] + for i, row in enumerate(bav): + label = names[i] if i < len(names) else f"P{i}" + rows.append([label] + [str(v) for v in row]) + if sav: + rows.append(["Sarva"] + [str(v) for v in sav]) + return Section("Ashtakavarga", headers=headers, rows=rows) + + # -- strengths and special points ------------------------------------ + # These are computed by the GUI but absent from get_horoscope_information(). + # Row/column labels come from utils.resource_strings so they localize with + # the rest of the report; the orientation matches the GUI's own tables. + + @staticmethod + def _res(key: str, fallback: str) -> str: + from jhora import utils + return (getattr(utils, "resource_strings", {}) or {}).get(key, fallback) + + @staticmethod + def _flat(value) -> str: + """Bala cells embed newlines ('Vyanjanaamsa\\n(D1/D2/D3)\\n14.5').""" + return " ".join(str(value).split()) + + def _planets(self) -> List[str]: + from jhora import utils + return list(utils.PLANET_NAMES)[:7] + + def sphuta(self) -> Section: + horo = self.horoscope() + data = horo._get_sphuta(self._date(), self._tob_tuple(), self._place()) + return Section(self._res("sphuta_str", "Sphuta"), + pairs=[(str(k), str(v)) for k, v in (data or {}).items()]) + + def shad_bala(self) -> Section: + horo = self.horoscope() + data = horo._get_shad_bala(self._date(), self._tob_tuple(), self._place()) + row_keys = [("sthaana_bala_str", "Positional Strength"), + ("kaala_bala_str", "Temporal Strength"), + ("dig_bala_str", "Directional Strength"), + ("chesta_bala_str", "Motion Strength"), + ("naisargika_bala_str", "Natural Strength"), + ("drik_bala_str", "Aspect Strength"), + ("shad_bala_str", "Shadh Bala"), + ("shad_bala_rupas_str", "Shadh Bala (Rupas)"), + ("shad_bala_strength_str", "Shad Bala (Strength)")] + rows = [[self._res(*row_keys[i])] + [str(v) for v in row] + for i, row in enumerate(data or []) if i < len(row_keys)] + return Section(self._res("shad_bala_str", "Shadh Bala"), + headers=["Strength"] + self._planets(), rows=rows) + + def bhava_bala(self) -> Section: + horo = self.horoscope() + data = horo._get_bhava_bala(self._date(), self._tob_tuple(), self._place()) + house = self._res("house_str", "House") + rows = [[f"{house}-{i + 1}"] + [str(v) for v in row] + for i, row in enumerate(data or [])] + return Section(self._res("bhava_bala_str", "Bhava Bala"), + headers=[house, + self._res("bhava_bala_str", "Bhava Bala"), + self._res("bhava_bala_rupas_str", "Bhava Bala (Rupas)"), + self._res("bhava_bala_strength_str", "Bhava Bala (Strength)")], + rows=rows) + + def _planet_keyed_bala(self, title: str, data, column_titles) -> Section: + """Shape a list-of-{planet: value} dicts into one planet-per-row table.""" + parts = list(data or []) + rows = [] + for planet in self._planets(): + row = [planet] + [self._flat(part.get(planet, "")) for part in parts] + rows.append(row) + return Section(title, headers=["Planet"] + list(column_titles[:len(parts)]), + rows=rows) + + def vimsopaka_bala(self) -> Section: + horo = self.horoscope() + data = horo._get_vimsopaka_bala(self._date(), self._tob_tuple(), self._place()) + return self._planet_keyed_bala( + self._res("vimsopaka_bala_str", "Varga Amsa Vimsopaka Bala"), data, + [self._res("shadvarga_bala_str", "Shad varga"), + self._res("sapthavarga_bala_str", "Saptha varga"), + self._res("dhasavarga_bala_str", "Dhasa varga"), + self._res("shodhasavarga_bala_str", "Shodhasa varga")]) + + def vaiseshikamsa_bala(self) -> Section: + horo = self.horoscope() + data = horo._get_vaiseshikamsa_bala(self._date(), self._tob_tuple(), self._place()) + return self._planet_keyed_bala( + self._res("vaiseshikamsa_bala_str", "Varga Amsa Vaiseshikamsa Bala"), data, + [self._res("shadvarga_bala_str", "Shad varga"), + self._res("sapthavarga_bala_str", "Saptha varga"), + self._res("dhasavarga_bala_str", "Dhasa varga"), + self._res("shodhasavarga_bala_str", "Shodhasa varga")]) + + def other_bala(self) -> Section: + horo = self.horoscope() + data = horo._get_other_bala(self._date(), self._tob_tuple(), self._place()) + return self._planet_keyed_bala( + self._res("harsha_pancha_dwadhasa_vargeeya_bala_str", + "Harsha Pancha Dwadhasa Vargeeya Bala"), data, + [self._res("harsha_bala_str", "Harsha bala"), + self._res("pancha_vargeeya_bala_str", "Pancha Vargeeya bala"), + self._res("dwadhasa_vargeeya_bala_str", "Dwadhasa Vargeeya bala")]) + + def bhava_chart(self) -> List[Section]: + """House cusps and the drawn bhava chart. + + Two sections, not one: the renderers show a section's diagram *or* its + table (they are normally the same data), but here the cusp table adds + the begin/middle/end degrees the diagram cannot express. + """ + horo = self.horoscope() + chart_1d, bhava_info, asc_house = horo.get_bhava_chart_information( + horo.julian_day, self._place()) + title = self._res("bhava_str", "Bhava Chart") + sections: List[Section] = [] + + if isinstance(chart_1d, (list, tuple)) and len(chart_1d) == 12: + style, note = self._resolved_style() + sections.append(Section(title, note=note, chart=ChartDiagram( + label=title, + occupants=[[p for p in str(cell).splitlines() if p.strip()] + for cell in chart_1d], + ascendant=int(asc_house) if isinstance(asc_house, int) else 0, + style=style))) + + rows = [[str(c) for c in row] for row in (bhava_info or [])] + if rows: + width = max(len(r) for r in rows) + headers = [self._res("house_str", "House"), "Begins", "Middle", + "Ends", "Planets"][:width] + sections.append(Section(f"{title} — Cusps", headers=headers, rows=rows)) + return sections + + # -- marriage compatibility ------------------------------------------- + + def _star_from_birth(self, date_str: str, time_str: str, lat: float, + lon: float, tz: float) -> Tuple[int, int]: + """Moon's nakshatra (1-27) and pada (1-4) for a set of birth details.""" + from jhora import utils + from jhora.panchanga import drik + from jhora.horoscope.chart import charts + year, month, day = (int(x) for x in date_str.split(",")) + hh, mm, ss = (int(x) for x in time_str.split(":")) + jd = utils.julian_day_number(drik.Date(year, month, day), (hh, mm, ss)) + place = drik.Place("spouse", lat, lon, tz) + moon = [p for p in charts.rasi_chart(jd, place) if p[0] == 1][0] + longitude = moon[1][0] * 30 + moon[1][1] + nakshatra, pada, _ = drik.nakshatra_pada(longitude) + return int(nakshatra), int(pada) + + def _spouse_star(self) -> Tuple[int, int, str]: + """(nakshatra, pada, how it was obtained) for the spouse.""" + from jhora import utils + rec = self.rec + if rec.has_spouse_birth_data(): + nak, pada = self._star_from_birth( + rec.spouse_date_of_birth, rec.spouse_time_of_birth, + rec.spouse_latitude, rec.spouse_longitude, rec.spouse_timezone) + return nak, pada, "computed from the spouse's birth details" + + raw = str(rec.spouse_nakshatra).strip() + if raw.isdigit(): + nak = int(raw) + else: + names = [str(n).strip().lower() for n in utils.NAKSHATRA_LIST] + key = raw.lower() + if key not in names: + raise RecordError(f"unknown spouse_nakshatra {rec.spouse_nakshatra!r}; " + f"use 1-27 or one of: {', '.join(names[:4])}, ...") + nak = names.index(key) + 1 + if not 1 <= nak <= 27: + raise RecordError(f"spouse_nakshatra must be 1-27 (got {nak})") + return nak, int(rec.spouse_pada), "taken from the supplied star and pada" + + def compatibility(self) -> Optional[Section]: + """Score the native against the spouse. + + Only the pair's nakshatra + pada feed the calculation. Which partner is + the "boy" matters (several kootas are asymmetric), so the native's + gender decides and the spouse is taken as the opposite unless stated. + """ + from jhora import utils + from jhora.horoscope.match import compatibility as comp + rec = self.rec + if not rec.has_spouse(): + return None + + spouse_nak, spouse_pada, provenance = self._spouse_star() + native_nak, native_pada = self._star_from_birth( + rec.date_of_birth, rec.time_of_birth, rec.latitude, rec.longitude, + rec.timezone) + + native_is_male = rec.gender == 1 + if rec.spouse_gender is not None: + native_is_male = rec.spouse_gender != 1 + if native_is_male: + boy, girl = (native_nak, native_pada), (spouse_nak, spouse_pada) + else: + boy, girl = (spouse_nak, spouse_pada), (native_nak, native_pada) + + method = rec.resolved_compatibility_method() + koota = comp.Ashtakoota(boy[0], boy[1], girl[0], girl[1], + method="South" if method == "south" else "North") + stars = list(utils.NAKSHATRA_LIST) + + def star_name(n): + return stars[n - 1] if 1 <= n <= len(stars) else str(n) + + pairs = [ + ("Spouse", rec.spouse_name or "(not named)"), + ("Native star", f"{star_name(native_nak)} pada {native_pada}"), + ("Spouse star", f"{star_name(spouse_nak)} pada {spouse_pada}"), + ("Spouse star source", provenance), + ("Boy / Girl", f"{star_name(boy[0])} p{boy[1]} / " + f"{star_name(girl[0])} p{girl[1]}"), + ("Method", "South (10 poruthams)" if method == "south" + else "North (Ashtakoota, out of 36)"), + ] + + if method == "south": + checks = [("Vasiya", "vasiya_porutham_south"), + ("Gana", "gana_porutham_south"), + ("Dina", "dina_porutham_south"), + ("Yoni", "yoni_porutham_south"), + ("Raasi Adhipathi", "raasi_adhipathi_porutham_south"), + ("Raasi", "raasi_porutham_south"), + ("Mahendra", "mahendra_porutham_south"), + ("Vedha", "vedha_porutham_south"), + ("Rajju", "rajju_porutham_south"), + ("Sthree Dheerga", "sthree_dheerga_porutham_south")] + rows, score = [], 0 + for label, attr in checks: + passed = bool(getattr(koota, attr)()) + score += passed + rows.append([label, "Yes" if passed else "No"]) + pairs.append(("Score", f"{score} / {len(checks)}")) + headers = ["Porutham", "Agrees"] + else: + # compatibility_score() -> 8 koota scores, total, then the 4 extras + result = koota.compatibility_score() + koota_names = ["Varna", "Vasiya", "Gana", "Nakshathra", "Yoni", + "Raasi Adhipathi", "Bahkut", "Naadi"] + maxima = [comp.varna_max_score, comp.vasiya_max_score, + comp.gana_max_score, comp.nakshathra_max_score, + comp.yoni_max_score, comp.raasi_adhipathi_max_score, + comp.raasi_max_score, comp.naadi_max_score] + rows = [[name, str(result[i]), str(maxima[i])] + for i, name in enumerate(koota_names)] + for label, value in zip(["Mahendra", "Vedha", "Rajju", "Sthree Dheerga"], + result[9:13]): + rows.append([label, "Yes" if value else "No", "-"]) + pairs.append(("Score", f"{result[8]} / 36")) + headers = ["Koota", "Score", "Max"] + + return Section("Marriage Compatibility", pairs=pairs, headers=headers, + rows=rows) + + def yogas(self) -> Section: + from jhora.horoscope.chart import yoga + results, _, _ = yoga.get_yoga_details(self._jd(), self._place(), + divisional_chart_factor=1, + language=self._lang_code()) + return _findings("Yogas", _yoga_rows(results)) + + def raja_yogas(self) -> Section: + from jhora.horoscope.chart import raja_yoga + results, _, _ = raja_yoga.get_raja_yoga_details(self._jd(), self._place(), + divisional_chart_factor=1, + language=self._lang_code()) + return _findings("Raja Yogas", _yoga_rows(results)) + + def doshas(self) -> Section: + from jhora.horoscope.chart import dosha + results = dosha.get_dosha_details(self._jd(), self._place(), + language=self._lang_code()) + parts = [f"{key}\n{'-' * len(str(key))}\n{_strip_html(value)}" + for key, value in (results or {}).items()] + return Section("Doshas", body="\n\n".join(parts)) + + def predictions(self) -> Section: + from jhora.horoscope.prediction import general + results = general.get_prediction_details(self._jd(), self._place(), + language=self._lang_code()) + parts = [f"{key}\n{'-' * len(str(key))}\n{_strip_html(value)}" + for key, value in (results or {}).items()] + return Section("General Predictions", body="\n\n".join(parts)) + + +def _findings(title: str, rows: List[List[str]]) -> Section: + """A findings table, or an explicit 'none' note — never a silently empty section.""" + if not rows: + return Section(title, body="None found for this chart.") + return Section(title, headers=["Yoga", "Description"], rows=rows, + note="Only yogas present in this chart are listed.") + + +def _yoga_rows(results) -> List[List[str]]: + """Normalize a yoga/raja-yoga result dict into [name, description]. + + The upstream result also carries a canned 'prediction' sentence, which we + deliberately drop: it is fixed boilerplate keyed on the yoga name, identical + for every chart the yoga fires in, and reads as an individual prognosis when + it is nothing of the sort. Reporting the rule and its condition is honest; + reporting its stock verdict is not. + """ + rows: List[List[str]] = [] + for key, value in (results or {}).items(): + if isinstance(value, (list, tuple)): + parts = [str(p) for p in value] + # shape is [chart, name, description, prediction] + name = parts[1] if len(parts) > 1 else str(key) + desc = _strip_html(parts[2]) if len(parts) > 2 else "" + else: + name, desc = str(key), _strip_html(value) + rows.append([name, desc]) + return rows + + +def build_report(record, *, dhasas: Sequence[str] = DEFAULT_DHASAS, + include_charts: bool = True) -> Report: + """Build the full structured report for ``record``. + + ``record`` may be a :class:`BirthRecord` or a raw dict. Individual sections + that fail are recorded in ``report.warnings`` rather than raising, so a + partial report is still produced. + """ + rec = record if isinstance(record, BirthRecord) else BirthRecord.from_dict(record) + dhasas = resolve_dhasas(dhasas) + + from jhora import config, const + from jhora.panchanga import drik + config.initialize_runtime(force_reload=True, silent=True) + # ChartTabbed.__init__ does this before computing anything. Skip it and + # swisseph keeps its own default sidereal mode, so every longitude — and + # therefore tithi, nakshatra, yoga and all divisional charts — silently + # disagrees with the GUI. Must happen before any engine call. + drik.set_ayanamsa_mode(const._DEFAULT_AYANAMSA_MODE) + drik.refresh_planet_flags(rec.longitude, rec.latitude, rec.elevation) + + b = _Builder(rec, dhasas) + b._safe("Birth Details", b.birth_details) + b._safe("Panchanga at Birth", b.panchanga) + + # raja_yoga runs before the other readings: the engine's shared globals make + # it fail if predictions/dosha/yoga have already run in this process. + b._safe("Raja Yogas", b.raja_yogas) + b._safe("Yogas", b.yogas) + b._safe("Doshas", b.doshas) + b._safe("General Predictions", b.predictions) + b._safe("Ashtakavarga", b.ashtakavarga) + + # strengths and special points — computed by the GUI but not returned by + # get_horoscope_information(), so each needs its own engine call + b._safe("Marriage Compatibility", b.compatibility) + b._safe("Sphuta", b.sphuta) + b._safe("Shad Bala", b.shad_bala) + b._safe("Bhava Bala", b.bhava_bala) + b._safe("Other Bala", b.other_bala) + b._safe("Vimsopaka Bala", b.vimsopaka_bala) + b._safe("Vaiseshikamsa Bala", b.vaiseshikamsa_bala) + + def _bhava(): + for s in b.bhava_chart(): + b.report.sections.append(s) + return None + b._safe("Bhava Chart", _bhava) + + for name in b.dhasas: + b._safe(f"Dhasa-Bhukthi — {name}", lambda n=name: b.dhasa(n)) + + if include_charts: + def _positions(): + for s in b.positions(): + b.report.sections.append(s) + return None + b._safe("Positions", _positions) + + def _grids(): + for s in b.divisional_charts(): + b.report.sections.append(s) + return None + b._safe("Chart Grids", _grids) + + return _normalize(b.report) diff --git a/pyjhora_batch/requirements.txt b/pyjhora_batch/requirements.txt new file mode 100644 index 000000000..abd9450b4 --- /dev/null +++ b/pyjhora_batch/requirements.txt @@ -0,0 +1,7 @@ +# pyjhora_batch dependencies (on top of a working PyJHora install). +# CSV -> .jhd and CSV -> .txt need nothing beyond PyJHora itself. + +reportlab>=4.0 # vector PDF output (--pdf-mode vector, the default) +openpyxl>=3.1 # optional: Excel (.xlsx) input +pytest>=7 # optional: running the test suite +pypdf>=4.0 # optional: asserts on PDF contents in the test suite diff --git a/pyjhora_batch/tests/__init__.py b/pyjhora_batch/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/pyjhora_batch/tests/conftest.py b/pyjhora_batch/tests/conftest.py new file mode 100644 index 000000000..d6b21e85c --- /dev/null +++ b/pyjhora_batch/tests/conftest.py @@ -0,0 +1,22 @@ +import os + +# Force headless Qt before anything imports the wrapper (which creates a +# QApplication on demand). Belt-and-suspenders: the wrapper sets this too. +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest + + +@pytest.fixture +def base_record_dict(): + """A minimal valid raw record (dict) for the Ujjain reference chart.""" + return { + "name": "Test Person", + "date_of_birth": "1985,6,15", + "time_of_birth": "10:30:00", + "place_name": "Ujjain", + "latitude": 23.5, + "longitude": 75.75, + "timezone": 5.5, + "gender": "male", + } diff --git a/pyjhora_batch/tests/test_birthrecord.py b/pyjhora_batch/tests/test_birthrecord.py new file mode 100644 index 000000000..8ae1cb97d --- /dev/null +++ b/pyjhora_batch/tests/test_birthrecord.py @@ -0,0 +1,98 @@ +import pytest + +from pyjhora_batch.wrapper import BirthRecord, RecordError + + +def test_valid_record(base_record_dict): + rec = BirthRecord.from_dict(base_record_dict) + assert rec.name == "Test Person" + assert rec.date_of_birth == "1985,6,15" + assert rec.time_of_birth == "10:30:00" + assert rec.place_name == "Ujjain" + assert rec.latitude == 23.5 and rec.longitude == 75.75 and rec.timezone == 5.5 + assert rec.gender == 1 # "male" -> 1 + assert rec.elevation == 0.0 # default + assert rec.chart_type == "south_indian" and rec.language == "English" + + +def test_column_aliases(): + rec = BirthRecord.from_dict({ + "dob": "2000,1,2", "tob": "06:07:08", "city": "X", + "lat": "10.5", "lng": "20.25", "tz": "5.5", + }) + assert rec.date_of_birth == "2000,1,2" + assert rec.place_name == "X" and rec.latitude == 10.5 and rec.longitude == 20.25 + + +def test_spaced_and_hyphen_headers(): + rec = BirthRecord.from_dict({ + "Date of Birth": "2000,1,2", "Time-of-Birth": "06:07:08", + "Place": "Y", "Latitude": "1.0", "Longitude": "2.0", "Time Zone": "5.5", + }) + assert rec.place_name == "Y" and rec.timezone == 5.5 + + +@pytest.mark.parametrize("value,expected", [ + (0, 0), (1, 1), (2, 2), (3, 3), + ("female", 0), ("MALE", 1), ("Trans", 2), ("no preference", 3), ("", 3), +]) +def test_gender_normalization(base_record_dict, value, expected): + base_record_dict["gender"] = value + assert BirthRecord.from_dict(base_record_dict).gender == expected + + +def test_gender_boolean_rejected(base_record_dict): + base_record_dict["gender"] = True + with pytest.raises(RecordError): + BirthRecord.from_dict(base_record_dict) + + +def test_gender_unknown_rejected(base_record_dict): + base_record_dict["gender"] = "alien" + with pytest.raises(RecordError): + BirthRecord.from_dict(base_record_dict) + + +@pytest.mark.parametrize("missing", ["date_of_birth", "time_of_birth", "place_name", + "latitude", "longitude", "timezone"]) +def test_missing_required(base_record_dict, missing): + del base_record_dict[missing] + with pytest.raises(RecordError) as e: + BirthRecord.from_dict(base_record_dict) + assert missing in str(e.value) + + +@pytest.mark.parametrize("bad", ["1990-4-18", "1990/4/18", "Apr 18 1990", ""]) +def test_bad_date_format(base_record_dict, bad): + base_record_dict["date_of_birth"] = bad + with pytest.raises(RecordError): + BirthRecord.from_dict(base_record_dict) + + +@pytest.mark.parametrize("bad", ["3:04pm", "15-43-00", "noon", ""]) +def test_bad_time_format(base_record_dict, bad): + base_record_dict["time_of_birth"] = bad + with pytest.raises(RecordError): + BirthRecord.from_dict(base_record_dict) + + +@pytest.mark.parametrize("field,value", [ + ("latitude", 91), ("latitude", -91), + ("longitude", 181), ("longitude", -181), + ("timezone", 15), ("timezone", -15), +]) +def test_out_of_range(base_record_dict, field, value): + base_record_dict[field] = value + with pytest.raises(RecordError): + BirthRecord.from_dict(base_record_dict) + + +def test_non_numeric_coord(base_record_dict): + base_record_dict["latitude"] = "north" + with pytest.raises(RecordError): + BirthRecord.from_dict(base_record_dict) + + +def test_from_dict_requires_dict(): + with pytest.raises(RecordError): + BirthRecord.from_dict("not a dict") diff --git a/pyjhora_batch/tests/test_cli.py b/pyjhora_batch/tests/test_cli.py new file mode 100644 index 000000000..b1d111201 --- /dev/null +++ b/pyjhora_batch/tests/test_cli.py @@ -0,0 +1,88 @@ +import argparse + +import pytest + +from pyjhora_batch import cli +from pyjhora_batch.engine import default_worker_count + + +# --- argument parsing -------------------------------------------------------- + +def test_parse_workers_auto(): + assert cli._parse_workers("auto") == default_worker_count() + + +def test_parse_workers_int(): + assert cli._parse_workers("4") == 4 + + +@pytest.mark.parametrize("bad", ["0", "-1", "two", "1.5"]) +def test_parse_workers_invalid(bad): + with pytest.raises(argparse.ArgumentTypeError): + cli._parse_workers(bad) + + +def test_parser_defaults(tmp_path): + args = cli.build_parser().parse_args(["in.csv"]) + assert args.workers == 1 and args.out_dir.name == "reports" + assert not args.no_pdf and not args.no_jhd + + +# --- main() exit codes (JHD-only for speed) --------------------------------- + +def _csv(tmp_path, name, body): + p = tmp_path / name + p.write_text(body, encoding="utf-8") + return p + + +_HEADER = "name,date_of_birth,time_of_birth,place,latitude,longitude,timezone,gender\n" +_GOOD = _HEADER + 'A,"1985,6,15",10:30:00,Ujjain,23.5,75.75,5.5,male\n' +_MIXED = _GOOD + 'Bad,"2000,1,1",06:00:00,Nowhere,,,5.5,male\n' + + +def test_main_all_valid_exit_0(tmp_path): + src = _csv(tmp_path, "good.csv", _GOOD) + code = cli.main([str(src), "-o", str(tmp_path / "out"), "--no-pdf", "-q"]) + assert code == 0 + assert (tmp_path / "out" / "A_1985-6-15.jhd").is_file() + + +def test_main_failures_exit_1(tmp_path): + src = _csv(tmp_path, "mixed.csv", _MIXED) + code = cli.main([str(src), "-o", str(tmp_path / "out"), "--no-pdf", "-q"]) + assert code == 1 + + +def test_main_allow_failures_exit_0(tmp_path): + src = _csv(tmp_path, "mixed.csv", _MIXED) + code = cli.main([str(src), "-o", str(tmp_path / "out"), "--no-pdf", "-q", + "--allow-failures"]) + assert code == 0 + + +def test_main_missing_input_exit_2(tmp_path): + code = cli.main([str(tmp_path / "nope.csv"), "-o", str(tmp_path / "out"), "-q"]) + assert code == 2 + + +def test_main_no_outputs_exit_2(tmp_path): + src = _csv(tmp_path, "good.csv", _GOOD) + code = cli.main([str(src), "-o", str(tmp_path / "out"), + "--no-pdf", "--no-jhd", "--no-txt", "-q"]) + assert code == 2 + + +def test_main_txt_alone_is_a_valid_output(tmp_path): + """Suppressing the PDF and JHD is fine as long as the text report remains.""" + src = _csv(tmp_path, "good.csv", _GOOD) + out = tmp_path / "out" + code = cli.main([str(src), "-o", str(out), "--no-pdf", "--no-jhd", "-q"]) + assert code == 0 + assert list(out.glob("*.txt")) + + +def test_main_unsupported_extension_exit_2(tmp_path): + src = _csv(tmp_path, "data.json", "{}") + code = cli.main([str(src), "-o", str(tmp_path / "out"), "-q"]) + assert code == 2 diff --git a/pyjhora_batch/tests/test_engine.py b/pyjhora_batch/tests/test_engine.py new file mode 100644 index 000000000..137f1755c --- /dev/null +++ b/pyjhora_batch/tests/test_engine.py @@ -0,0 +1,135 @@ +import csv as _csv +import os + +import pytest + +from pyjhora_batch.engine import (_output_name, _prepare, _slugify, run_batch, + default_worker_count) +from pyjhora_batch.readers.csv_reader import RawRow +from pyjhora_batch.wrapper import BirthRecord + + +def _raw(n, data): + return RawRow(row_number=n, line_number=n + 1, data=data) + + +def _valid(name="A", dob="1985,6,15", **over): + d = {"name": name, "date_of_birth": dob, "time_of_birth": "10:30:00", + "place_name": "Ujjain", "latitude": 23.5, "longitude": 75.75, + "timezone": 5.5, "gender": "male"} + d.update(over) + return d + + +# --- naming ------------------------------------------------------------------ + +def test_slugify(): + assert _slugify("Test Person") == "Test_Person" + assert _slugify(" a/b:c ") == "a_b_c" + assert _slugify("") == "chart" + + +def test_output_name_from_name_and_dob(): + rec = BirthRecord.from_dict(_valid("Jane Doe")) + assert _output_name(rec, {}) == "Jane_Doe_1985-6-15" + + +def test_output_name_explicit_column(): + rec = BirthRecord.from_dict(_valid("Jane")) + assert _output_name(rec, {"output": "custom.pdf"}) == "custom" + assert _output_name(rec, {"filename": "x/y"}) == "y" + + +# --- _prepare ---------------------------------------------------------------- + +def test_prepare_collision_suffix(tmp_path): + rows = [_raw(1, _valid("Same")), _raw(2, _valid("Same")), _raw(3, _valid("Same"))] + jobs, invalids = _prepare(rows, tmp_path) + assert invalids == [] + names = [j.pdf_path.stem for j in jobs] + assert names == ["Same_1985-6-15", "Same_1985-6-15_2", "Same_1985-6-15_3"] + + +def test_prepare_isolates_invalid(tmp_path): + rows = [_raw(1, _valid("Good")), + _raw(2, {"name": "Bad", "date_of_birth": "1990,1,1"})] # missing most fields + jobs, invalids = _prepare(rows, tmp_path) + assert len(jobs) == 1 and jobs[0].rec.name == "Good" + assert len(invalids) == 1 and invalids[0].status == "invalid" + assert invalids[0].row_number == 2 + + +# --- run_batch (JHD-only: fast, no Qt render) -------------------------------- + +def test_run_batch_jhd_only(tmp_path): + rows = [_raw(1, _valid("One")), _raw(2, _valid("Two", dob="1985,11,2")), + _raw(3, {"name": "NoCoords", "date_of_birth": "2000,1,1", + "time_of_birth": "06:00:00", "place_name": "Nowhere"})] + summary = run_batch(rows, tmp_path, write_pdf=False, write_txt=False, verbose=False) + + assert summary.total == 3 and summary.succeeded == 2 and summary.failed == 1 + assert (tmp_path / "One_1985-6-15.jhd").is_file() + assert (tmp_path / "Two_1985-11-2.jhd").is_file() + # results are sorted by row number + assert [r.row_number for r in summary.results] == [1, 2, 3] + # log + failures artifacts written + assert (tmp_path / "batch.log").is_file() + assert (tmp_path / "failures.csv").is_file() + + +def test_failures_csv_contents(tmp_path): + rows = [_raw(1, _valid("Good")), + _raw(2, {"name": "Bad", "date_of_birth": "2000,1,1", + "time_of_birth": "06:00:00", "place_name": "Nowhere"})] + run_batch(rows, tmp_path, write_pdf=False, write_txt=False, verbose=False) + with (tmp_path / "failures.csv").open() as fh: + rows_out = list(_csv.DictReader(fh)) + assert len(rows_out) == 1 + row = rows_out[0] + assert row["_row"] == "2" and row["_status"] == "invalid" + assert "missing required field" in row["_error"] + assert row["name"] == "Bad" # original columns preserved + + +def test_run_batch_writes_all_three_outputs(tmp_path): + """One valid row -> a vector .pdf, a .jhd and a .txt, with no Qt involved.""" + summary = run_batch([_raw(1, _valid("Trio"))], tmp_path, verbose=False) + assert summary.succeeded == 1 + + base = tmp_path / "Trio_1985-6-15" + pdf, jhd, txt = base.with_suffix(".pdf"), base.with_suffix(".jhd"), base.with_suffix(".txt") + assert pdf.is_file() and jhd.is_file() and txt.is_file() + + result = summary.results[0] + assert result.output == str(pdf) and result.jhd == str(jhd) and result.txt == str(txt) + # the vector PDF is orders of magnitude smaller than the screenshot one + assert pdf.stat().st_size < 1_000_000 + assert pdf.read_bytes().startswith(b"%PDF") + assert "HOROSCOPE REPORT" in txt.read_text(encoding="utf-8") + + +def test_run_batch_rejects_unknown_pdf_mode(tmp_path): + with pytest.raises(ValueError, match="pdf_mode"): + run_batch([], tmp_path, pdf_mode="screenshots", verbose=False) + + +def test_txt_only_run_skips_the_pdf(tmp_path): + run_batch([_raw(1, _valid("TxtOnly"))], tmp_path, write_pdf=False, + write_jhd_file=False, verbose=False) + assert (tmp_path / "TxtOnly_1985-6-15.txt").is_file() + assert not (tmp_path / "TxtOnly_1985-6-15.pdf").exists() + + +def test_default_worker_count_at_least_one(): + assert default_worker_count() >= 1 + + +@pytest.mark.skipif(os.environ.get("PYJHORA_TEST_PARALLEL") != "1", + reason="set PYJHORA_TEST_PARALLEL=1 to run the slow spawn-pool test") +def test_run_batch_parallel_jhd_only(tmp_path): + rows = [_raw(i, _valid(f"P{i}", dob=f"199{i},1,1")) for i in range(1, 5)] + summary = run_batch(rows, tmp_path, write_pdf=False, verbose=False, workers=2) + assert summary.succeeded == 4 and summary.failed == 0 + assert [r.row_number for r in summary.results] == [1, 2, 3, 4] # re-sorted + for r in summary.results: + assert r.jhd is not None and __import__("pathlib").Path(r.jhd).is_file() diff --git a/pyjhora_batch/tests/test_jhd_writer.py b/pyjhora_batch/tests/test_jhd_writer.py new file mode 100644 index 000000000..1c489d7f6 --- /dev/null +++ b/pyjhora_batch/tests/test_jhd_writer.py @@ -0,0 +1,102 @@ +from pyjhora_batch.jhd_writer import build_jhd, write_jhd, _pack_dms +from pyjhora_batch.wrapper import BirthRecord + + +def _lines(record_dict): + return build_jhd(BirthRecord.from_dict(record_dict)).splitlines() + + +def test_reference_chart(base_record_dict): + lines = _lines(base_record_dict) + assert lines == [ + "6", "15", "1985", + "10.300000000000000", # time packed H.MMSS, 15 dp + "-5.300000", # tz packed H.MM, East negative + "-75.450000", # lon packed D.MMSS, East negative + "23.300000", # lat packed D.MMSS, North positive + "0.000000", + "-5.500000", "-5.500000", # tz again, decimal hours + "0", "105", + "Ujjain", "India", # city, country -- the name is in the filename + "1", "1013.250000", "20.000000", "1", + ] + assert len(lines) == 18 + + +def test_full_body_layout_is_byte_exact(base_record_dict): + """The whole file, CRLF included. + + The field order, packing and sign conventions asserted here were derived + byte-for-byte from a .jhd written by the Jagannatha Hora Windows app and + the app's own rendering of it; see the jhd_writer module docstring. The + fixture is a synthetic chart, so this guards the layout, not one export. + """ + assert build_jhd(BirthRecord.from_dict(base_record_dict)) == ( + "6\r\n15\r\n1985\r\n" + "10.300000000000000\r\n" + "-5.300000\r\n" + "-75.450000\r\n" + "23.300000\r\n" + "0.000000\r\n" + "-5.500000\r\n-5.500000\r\n" + "0\r\n105\r\n" + "Ujjain\r\nIndia\r\n" + "1\r\n1013.250000\r\n20.000000\r\n1\r\n" + ) + + +def test_crlf_line_endings(base_record_dict, tmp_path): + out = write_jhd(BirthRecord.from_dict(base_record_dict), tmp_path / "x.jhd") + raw = out.read_bytes() + assert raw.count(b"\r\n") == 18 + assert b"\n" not in raw.replace(b"\r\n", b"") # no bare LF anywhere + + +def test_western_hemisphere_signs(): + # NYC: North lat positive, West lon positive, West tz positive + lines = _lines({ + "date_of_birth": "2000,1,1", "time_of_birth": "06:05:09", "place_name": "NYC", + "latitude": 40.7128, "longitude": -74.006, "timezone": -5, "gender": 1, + }) + assert lines[3] == "6.050900000000000" # time 06:05:09 packed H.MMSS + assert lines[4] == "5.000000" # tz positive for west + assert lines[5] == "74.002200" # lon positive for west + assert lines[6] == "40.424600" # lat positive for north + assert lines[8] == lines[9] == "5.000000" + + +def test_southern_latitude_negative(): + lines = _lines({ + "date_of_birth": "1990,1,1", "time_of_birth": "00:00:00", "place_name": "Sydney", + "latitude": -33.8688, "longitude": 151.2093, "timezone": 10, "gender": 1, + }) + assert lines[6].startswith("-33.") # south latitude negative + assert lines[5].startswith("-151.") # east longitude negative + + +def test_place_and_country_lines(): + lines = _lines({ + "name": "Someone", + "date_of_birth": "1990,1,1", "time_of_birth": "00:00:00", "place_name": "OnlyPlace", + "latitude": 1.0, "longitude": 2.0, "timezone": 5.5, + }) + assert lines[12] == "OnlyPlace" # city + assert lines[13] == "India" # country default + assert "Someone" not in lines # the person's name is never in the file + + +def test_pack_dms_basic(): + assert _pack_dms(75.78) == "75.464800" # 0.78*60=46.8' -> 46'48" + assert _pack_dms(27.73) == "27.434800" + + +def test_pack_dms_seconds_carry(): + # a value whose seconds round to 60 must carry into minutes, not print ":60" + packed = _pack_dms(10.0 + 59.996 / 60.0) # ~10 deg 59' 59.76" -> carries + d, frac = packed.split(".") + mm, ss = frac[:2], frac[2:4] + assert int(mm) < 60 and int(ss) < 60 + + +def test_timezone_rounds_to_minute(): + assert _pack_dms(5.5, seconds=False) == "5.300000" diff --git a/pyjhora_batch/tests/test_pdf_writer.py b/pyjhora_batch/tests/test_pdf_writer.py new file mode 100644 index 000000000..7cd01e948 --- /dev/null +++ b/pyjhora_batch/tests/test_pdf_writer.py @@ -0,0 +1,157 @@ +import pytest + +from pyjhora_batch.pdf_writer import (_col_widths, _to_ascii, render_pdf, + resolve_font) +from pyjhora_batch.report_data import Report, Section +from pyjhora_batch.wrapper import BirthRecord + +pypdf = pytest.importorskip("pypdf") + + +def _record(): + return BirthRecord.from_dict({ + "name": "Test Person", "date_of_birth": "1985,6,15", + "time_of_birth": "10:30:00", "place_name": "Ujjain", + "latitude": 23.5, "longitude": 75.75, + "timezone": 5.5, "gender": "male"}) + + +def _report(*sections): + return Report(record=_record(), sections=list(sections)) + + +def test_column_widths_fill_exactly_the_available_width(): + widths = _col_widths(["A", "B", "C"], [["x", "yy", "zzz"]], avail=400.0) + assert sum(widths) == pytest.approx(400.0) + assert all(w > 0 for w in widths) + + +def test_column_widths_handle_no_columns(): + assert _col_widths([], [], avail=400.0) == [] + + +def test_ascii_fallback_replaces_symbols(): + out = _to_ascii("Sun☉ in ♑Capricorn") + assert "☉" not in out and "♑" not in out + assert "(Su)" in out and "Capricorn" in out + + +def test_pdf_contains_real_text_not_images(tmp_path): + """The whole point of this renderer: vector text, zero rasterised pages.""" + target = tmp_path / "report.pdf" + render_pdf(_report( + Section("Birth Details", pairs=[("Name", "Test Person")]), + Section("Yogas", headers=["Yoga", "Description"], + rows=[["Nipuna Yoga", "Sun and Mercury are together."]]), + Section("Notes", body="Narrative paragraph."), + ), target) + + reader = pypdf.PdfReader(str(target)) + page = reader.pages[0] + text = page.extract_text() + assert list(page.images) == [] + assert "Test Person" in text + assert "Nipuna Yoga" in text + assert "Narrative paragraph." in text + + +def test_pdf_is_small(tmp_path): + """A screenshot-based page of the same content ran to megabytes.""" + target = tmp_path / "report.pdf" + render_pdf(_report(Section("Birth Details", pairs=[("Name", "Test Person")])), target) + assert target.stat().st_size < 400_000 + + +def test_pdf_renders_astro_symbols_when_a_unicode_font_exists(tmp_path): + if resolve_font() is None: + pytest.skip("no Unicode font on this machine") + target = tmp_path / "symbols.pdf" + render_pdf(_report(Section("Positions", pairs=[("Sun☉", "♑Capricorn 10° 13’ 41\"")])), + target) + text = pypdf.PdfReader(str(target)).pages[0].extract_text() + assert "☉" in text and "♑" in text + + +def test_warnings_are_surfaced_in_the_pdf(tmp_path): + target = tmp_path / "warned.pdf" + report = _report(Section("Birth Details", pairs=[("Name", "Test Person")])) + report.warnings.append("Doshas: KeyError: 0") + render_pdf(report, target) + text = pypdf.PdfReader(str(target)).pages[0].extract_text() + assert "could not be generated" in text + assert "KeyError" in text + + +def test_render_pdf_creates_parent_directories(tmp_path): + target = tmp_path / "a" / "b" / "report.pdf" + render_pdf(_report(Section("X", body="y")), target) + assert target.is_file() + + +# --- vector chart diagrams --------------------------------------------- + +from pyjhora_batch.report_data import ChartDiagram +from pyjhora_batch.pdf_writer import (ChartFlowable, _NORTH_HOUSE_CENTROIDS, + _SOUTH_CELLS) + + +def _diagram(style="south_indian"): + occ = [[] for _ in range(12)] + occ[0] = ["Sun", "Mercury"] + occ[4] = ["Ascendant"] + occ[9] = ["Moon", "Saturn", "Raagu"] + return ChartDiagram("Raasi (D1)", occ, ascendant=4, style=style) + + +def test_south_cells_cover_every_sign_on_the_ring(): + assert sorted(_SOUTH_CELLS) == list(range(12)) + cells = set(_SOUTH_CELLS.values()) + assert len(cells) == 12 + # the 2x2 centre is deliberately unoccupied + assert cells.isdisjoint({(1, 1), (1, 2), (2, 1), (2, 2)}) + # Pisces top-left, Aries next to it, running clockwise + assert _SOUTH_CELLS[11] == (0, 0) and _SOUTH_CELLS[0] == (0, 1) + + +def test_north_centroids_are_twelve_distinct_points_inside_the_box(): + assert len(_NORTH_HOUSE_CENTROIDS) == 12 + assert len(set(_NORTH_HOUSE_CENTROIDS)) == 12 + assert all(0 < x < 1 and 0 < y < 1 for x, y in _NORTH_HOUSE_CENTROIDS) + # house 1 sits top-centre, house 7 directly opposite + assert _NORTH_HOUSE_CENTROIDS[0][0] == pytest.approx(0.5) + assert _NORTH_HOUSE_CENTROIDS[6][0] == pytest.approx(0.5) + assert _NORTH_HOUSE_CENTROIDS[0][1] < _NORTH_HOUSE_CENTROIDS[6][1] + + +def test_chart_flowable_is_square_and_fits_the_frame(): + flow = ChartFlowable(_diagram(), 250.0, "Helvetica") + assert flow.wrap(180.0, 800.0) == (180.0, 180.0) # shrinks to fit + assert flow.wrap(400.0, 800.0) == (180.0, 180.0) # never grows back + + +@pytest.mark.parametrize("style", ["south_indian", "north_indian"]) +def test_chart_is_drawn_as_vector_text_not_an_image(tmp_path, style): + target = tmp_path / f"{style}.pdf" + render_pdf(_report(Section("Raasi (D1)", chart=_diagram(style))), target) + page = pypdf.PdfReader(str(target)).pages[0] + text = page.extract_text() + assert list(page.images) == [] + for planet in ("Sun", "Mercury", "Ascendant", "Moon", "Saturn", "Raagu"): + assert planet in text + assert "Le" in text and "Ar" in text # sign abbreviations + + +def test_north_chart_labels_houses_from_the_ascendant(tmp_path): + target = tmp_path / "north.pdf" + render_pdf(_report(Section("D1", chart=_diagram("north_indian"))), target) + text = pypdf.PdfReader(str(target)).pages[0].extract_text() + assert "Lagna" in text # house 1 is marked + + +def test_chart_section_omits_the_duplicate_table(tmp_path): + target = tmp_path / "chart.pdf" + render_pdf(_report(Section("Raasi (D1)", headers=["Rasi", "Occupants"], + rows=[["Aries", "Sun Mercury"]], + chart=_diagram())), target) + text = pypdf.PdfReader(str(target)).pages[0].extract_text() + assert "Occupants" not in text diff --git a/pyjhora_batch/tests/test_readers.py b/pyjhora_batch/tests/test_readers.py new file mode 100644 index 000000000..2b7f5903c --- /dev/null +++ b/pyjhora_batch/tests/test_readers.py @@ -0,0 +1,92 @@ +import datetime as dt + +from openpyxl import Workbook + +from pyjhora_batch.readers.csv_reader import read_csv +from pyjhora_batch.readers.excel_reader import read_excel, _stringify_cell + + +# --- CSV --------------------------------------------------------------------- + +def _write(path, text, encoding="utf-8"): + path.write_text(text, encoding=encoding) + return path + + +def test_csv_basic_rows_and_numbers(tmp_path): + csv = _write(tmp_path / "a.csv", + "name,dob\nAlice,\"1990,1,1\"\nBob,\"1991,2,3\"\n") + rows = list(read_csv(csv)) + assert [r.row_number for r in rows] == [1, 2] + assert rows[0].line_number == 2 and rows[1].line_number == 3 + assert rows[0].data["name"] == "Alice" + + +def test_csv_header_lowercased(tmp_path): + csv = _write(tmp_path / "b.csv", "Name,DOB\nX,\"2000,1,1\"\n") + row = next(iter(read_csv(csv))) + assert set(row.data) == {"name", "dob"} + + +def test_csv_skips_blank_rows(tmp_path): + csv = _write(tmp_path / "c.csv", "name,dob\nA,\"2000,1,1\"\n,\nB,\"2001,1,1\"\n") + rows = list(read_csv(csv)) + assert [r.data["name"] for r in rows] == ["A", "B"] + + +def test_csv_strips_bom(tmp_path): + csv = _write(tmp_path / "d.csv", "name,dob\nA,\"2000,1,1\"\n") + row = next(iter(read_csv(csv))) + assert "name" in row.data # BOM stripped from first header + + +def test_csv_missing_file(tmp_path): + import pytest + with pytest.raises(FileNotFoundError): + list(read_csv(tmp_path / "nope.csv")) + + +# --- Excel ------------------------------------------------------------------- + +def test_stringify_cell_types(): + assert _stringify_cell(dt.date(1985, 6, 15)) == "1985,6,15" + assert _stringify_cell(dt.time(4, 5, 30)) == "04:05:30" + assert _stringify_cell(27.0) == "27" # whole float -> int text + assert _stringify_cell(75.783333) == "75.783333" # precision kept + assert _stringify_cell(None) == "" + # Excel time-only cells arrive as a 1899-epoch datetime + assert _stringify_cell(dt.datetime(1899, 12, 31, 4, 5, 30)) == "04:05:30" + assert _stringify_cell(dt.datetime(1985, 6, 15, 0, 0, 0)) == "1985,6,15" + + +def test_excel_reads_typed_cells(tmp_path): + wb = Workbook(); ws = wb.active + ws.append(["Name", "Date of Birth", "Time of Birth", "Lat"]) + ws.append(["Devi", dt.date(1978, 7, 9), dt.time(4, 5, 30), 12.9716]) + ws.append([None, None, None, None]) # blank -> skipped + ws.append(["Ravi", "1965,12,25", "23:10:00", 9.9312]) + path = tmp_path / "s.xlsx"; wb.save(path) + + rows = list(read_excel(path)) + assert [r.data["name"] for r in rows] == ["Devi", "Ravi"] + assert rows[0].data["date of birth"] == "1978,7,9" + assert rows[0].data["time of birth"] == "04:05:30" + assert rows[0].data["lat"] == "12.9716" + + +def test_space_before_a_quoted_field_is_tolerated(tmp_path): + """`a, "1998,9,10",b` — a quote after a space is otherwise literal text, + letting the commas inside split the field and shift every later column.""" + src = tmp_path / "spaced.csv" + src.write_text( + "name,date_of_birth,time_of_birth,place,latitude,longitude,timezone,gender\n" + 'Ravi Kumar, "2001,11,3",07:15:00,"Chennai, Tamil Nadu",' + "27.147869,74.859489,5.5, male\n", + encoding="utf-8") + rows = list(read_csv(src)) + assert len(rows) == 1 + data = rows[0].data + assert data["date_of_birth"] == "2001,11,3" + assert data["place"] == "Chennai, Tamil Nadu" + assert data["gender"] == "male" + assert data["timezone"] == "5.5" diff --git a/pyjhora_batch/tests/test_report_data.py b/pyjhora_batch/tests/test_report_data.py new file mode 100644 index 000000000..133c3041f --- /dev/null +++ b/pyjhora_batch/tests/test_report_data.py @@ -0,0 +1,507 @@ +import pytest + +from pyjhora_batch.report_data import (Report, Section, _split_chart_key, + _strip_html, _yoga_rows, build_report, + clean_text) +from pyjhora_batch.wrapper import BirthRecord + + +# --- pure helpers (fast, no engine) ------------------------------------ + +def test_clean_text_strips_variation_selectors(): + # U+FE0E after a zodiac glyph renders as a tofu box in most PDF fonts + assert clean_text("♑︎Capricorn") == "♑Capricorn" + assert clean_text("♈️Aries") == "♈Aries" + + +def test_clean_text_expands_literal_backslash_n(): + # some prediction strings embed the two characters \ and n, not a newline + assert clean_text("happy.\\n This yoga") == "happy.\n This yoga" + + +def test_strip_html_produces_readable_text(): + out = _strip_html("Title
First line
Second & last") + assert "<" not in out and "&" not in out + assert out.splitlines() == ["Title", "First line", "Second & last"] + + +@pytest.mark.parametrize("key, chart, item", [ + ("Raasi (D1)-Sun☉", "Raasi (D1)", "Sun☉"), + ("Dwadas-Dwadasamsa (D144)-Moon", "Dwadas-Dwadasamsa (D144)", "Moon"), + ("D-1-Arudha Lagna (AL)", "D-1", "Arudha Lagna (AL)"), + ("Ascendant", None, "Ascendant"), +]) +def test_split_chart_key(key, chart, item): + assert _split_chart_key(key) == (chart, item) + + +def test_section_is_empty(): + assert Section("t").is_empty + assert not Section("t", pairs=[("a", "b")]).is_empty + assert not Section("t", rows=[["a"]]).is_empty + assert not Section("t", body="x").is_empty + + +def test_yoga_rows_drop_the_canned_prediction(): + """The 4th element is stock boilerplate keyed on the yoga name — never emit it.""" + results = {"vesi_yoga": ["D1", "Vesai Yoga", "A planet other than Moon in the 2nd " + "house from Sun", "You will be happy and comfortable."]} + assert _yoga_rows(results) == [["Vesai Yoga", "A planet other than Moon in the " + "2nd house from Sun"]] + + +def test_yoga_rows_survive_short_and_scalar_values(): + assert _yoga_rows({"x": ["D1", "X Yoga"]}) == [["X Yoga", ""]] + assert _yoga_rows({"y": "plain"}) == [["y", "plain"]] + assert _yoga_rows(None) == [] + + +def test_report_section_lookup(): + r = Report(record=None, sections=[Section("A"), Section("B", body="x")]) + assert r.section("B").body == "x" + assert r.section("missing") is None + + +# --- full build (drives the real engine) ------------------------------- + +@pytest.fixture(scope="module") +def built_report(base_record_dict_module): + return build_report(BirthRecord.from_dict(base_record_dict_module)) + + +@pytest.fixture(scope="module") +def base_record_dict_module(): + return {"name": "Test Person", "date_of_birth": "1985,6,15", + "time_of_birth": "10:30:00", "place_name": "Ujjain", + "latitude": 23.5, "longitude": 75.75, + "timezone": 5.5, "gender": "male"} + + +def test_build_report_has_no_warnings(built_report): + assert built_report.warnings == [] + + +def test_build_report_core_sections(built_report): + titles = [s.title for s in built_report.sections] + for expected in ("Birth Details", "Panchanga at Birth", "Yogas", + "Doshas", "General Predictions", "Ashtakavarga"): + assert expected in titles + assert len(built_report.sections) > 50 + + +def test_panchanga_matches_the_gui(built_report): + """Guards the ayanamsa setup: skip drik.set_ayanamsa_mode and these shift. + + Values captured from ChartTabbed.compute_horoscope for the same birth data. + """ + pan = dict(built_report.section("Panchanga at Birth").pairs) + assert pan["Day"] == "Saturday" + assert pan["Sun Rise"] == "05:44:08" + assert pan["Solar Month:"] == "Aani Date 2" + assert pan["Yoga"].startswith("Sukarma") + assert pan["Raasi"].startswith("♈Aries 21:16:46") + + +def test_birth_details_timezone_is_hours_minutes(built_report): + # 5.5 hours is +05:30, not +05:50 + assert dict(built_report.section("Birth Details").pairs)["Timezone"] == "UTC+05:30" + + +def test_dhasa_section_is_a_dated_table(built_report): + section = built_report.section("Dhasa-Bhukthi — Vimsottari") + assert section is not None + assert section.headers == ["Dhasa-Bhukthi", "Starts"] + assert len(section.rows) > 50 + assert section.rows[0][1].startswith("19") # an ISO-ish start date + + +def test_ashtakavarga_is_twelve_signs_wide(built_report): + section = built_report.section("Ashtakavarga") + assert len(section.headers) == 13 # planet + 12 rasis + assert section.rows[-1][0] == "Sarva" + assert all(cell.isdigit() for cell in section.rows[-1][1:]) + + +def test_no_section_renders_a_tofu_box(built_report): + """clean_text must have run over every string the renderers will see.""" + for section in built_report.sections: + blob = "".join([section.title, section.body] + + [f"{k}{v}" for k, v in section.pairs] + + ["".join(r) for r in section.rows]) + assert "︎" not in blob and "️" not in blob + assert "\\n" not in blob + + +def test_bad_record_raises_before_any_work(): + with pytest.raises(Exception): + build_report({"date_of_birth": "nonsense"}) + + +# --- divisional chart diagrams ----------------------------------------- + +def _divisional(report): + """Chart sections for the 23 vargas, excluding the bhava chart.""" + import re as _re + return [s for s in report.sections + if s.chart and _re.search(r"\(D\d+\)$", s.title)] + + +def test_charts_are_named_not_numbered(built_report): + titles = [s.title for s in _divisional(built_report)] + assert len(titles) == 23 + assert titles[0].endswith("(D1)") # the rasi chart is chart 0 + assert "Navamsam (D9)" in titles + + +def test_d1_chart_matches_the_gui(built_report): + """Occupants captured from ChartTabbed's Janma-Raasi tab for this birth.""" + chart = _divisional(built_report)[0].chart + assert chart.ascendant == 4 # Leo + assert chart.occupants[0] == ["Moon☾", "Venus♀", "Raagu☊"] # Aries + assert chart.occupants[2] == ["Sun☉", "Mars♂", "Mercury☿"] # Gemini + assert chart.occupants[4] == ["Ascendantℒ"] # Leo + assert chart.occupants[6] == ["Kethu☋"] # Libra + assert chart.occupants[7] == ["Saturn♄℞"] # Scorpio, retrograde + assert chart.occupants[9] == ["Jupiter♃℞"] # Capricorn, retrograde + assert chart.occupants[1] == [] # Taurus empty + + +def test_house_order_starts_at_the_ascendant(): + from pyjhora_batch.report_data import ChartDiagram + d = ChartDiagram("D1", [[] for _ in range(12)], ascendant=4) + assert d.house_order() == [4, 5, 6, 7, 8, 9, 10, 11, 0, 1, 2, 3] + assert d.house_order()[0] == d.ascendant + + +def test_chart_style_follows_the_record(): + rec = BirthRecord.from_dict({ + "date_of_birth": "1985,6,15", "time_of_birth": "10:30:00", + "place_name": "Ujjain", "latitude": 23.5, "longitude": 75.75, + "timezone": 5.5, "chart_type": "north_indian"}) + charts = [s.chart for s in build_report(rec).sections if s.chart] + assert len(charts) == 24 and all(c.style == "north_indian" for c in charts) + + +def test_undrawable_style_falls_back_with_a_visible_note(): + rec = BirthRecord.from_dict({ + "date_of_birth": "1985,6,15", "time_of_birth": "10:30:00", + "place_name": "Ujjain", "latitude": 23.5, "longitude": 75.75, + "timezone": 5.5, "chart_type": "east_indian"}) + report = build_report(rec) + sections = [s for s in report.sections if s.chart] + assert sections + assert all(s.chart.style == "south_indian" for s in sections) + # every drawn chart discloses the substitution, the bhava chart included + assert all("east_indian" in s.note for s in sections) + + +# --- dhasa selection --------------------------------------------------- + +def test_resolve_dhasas_defaults_to_vimsottari(): + from pyjhora_batch.report_data import DEFAULT_DHASAS, resolve_dhasas + assert resolve_dhasas(None) == DEFAULT_DHASAS + assert resolve_dhasas([]) == DEFAULT_DHASAS + + +def test_resolve_dhasas_keeps_order_and_dedupes(): + from pyjhora_batch.report_data import resolve_dhasas + assert resolve_dhasas(["yogini", "vimsottari", "yogini"]) == ("yogini", "vimsottari") + + +def test_resolve_dhasas_expands_all(): + from pyjhora_batch.report_data import available_dhasas, resolve_dhasas + every = resolve_dhasas(["all"]) + assert set(every) == set(available_dhasas()) + assert len(every) > 50 + # mixing 'all' with explicit names must not duplicate + assert len(resolve_dhasas(["vimsottari", "all"])) == len(every) + + +def test_all_dhasas_mostly_build_and_failures_are_reported(base_record_dict_module): + """'--dhasa all' is best-effort: a system that cannot apply becomes a warning.""" + report = build_report(BirthRecord.from_dict(base_record_dict_module), dhasas=["all"]) + built = [s for s in report.sections if s.title.startswith("Dhasa-Bhukthi")] + assert len(built) > 50 + # the four annual/varsha systems need arguments a natal chart has no value for + assert all(w.startswith("Dhasa-Bhukthi") for w in report.warnings) + assert len(report.warnings) < 8 + + +# --- regressions found in review --------------------------------------- + +def test_ashtakavarga_last_row_is_lagna_not_rahu(built_report): + """BAV rows are the 7 grahas THEN the lagna; Rahu/Ketu have no BAV. + + PLANET_NAMES[7] is Raagu, so slicing to len(bav) mislabels the lagna row. + """ + labels = [row[0] for row in built_report.section("Ashtakavarga").rows] + assert labels[-2] == "Lagna" + assert labels[-1] == "Sarva" + assert "Raagu☊" not in labels and "Kethu☋" not in labels + + +def test_ashtakavarga_totals_are_the_classical_values(built_report): + """Sun 48, Moon 49, Mars 39, Mercury 54, Jupiter 56, Venus 52, Saturn 39.""" + rows = {r[0]: sum(int(c) for c in r[1:]) + for r in built_report.section("Ashtakavarga").rows} + assert [rows["Sun☉"], rows["Moon☾"], rows["Mars♂"], rows["Mercury☿"], + rows["Jupiter♃"], rows["Venus♀"], rows["Saturn♄"]] == [48, 49, 39, 54, 56, 52, 39] + # Sarva is the sum of the seven grahas only - the lagna row is excluded + assert rows["Sarva"] == 337 + + +def test_d1_positions_are_a_named_section_not_a_leftover_bucket(built_report): + """The D-1 keys carry no '(D1)', so they used to land under 'Chart Summary'.""" + assert built_report.section("Chart Summary") is None + d1 = built_report.section("Positions — Raasi (D1)") + assert d1 is not None and len(d1.pairs) > 20 + # the redundant "Raasi-" prefix is stripped from every label + assert all(not k.startswith("Raasi-") for k, _ in d1.pairs) + assert any(k.startswith("Sun") for k, _ in d1.pairs) + + +def test_arudha_keys_are_not_labelled_as_positions(built_report): + """'D-1-Arudha Lagna' is an arudha pada, not a planetary position.""" + section = built_report.section("Arudha Padas — D-1") + assert section is not None + assert any("Arudha Lagna" in k for k, _ in section.pairs) + assert built_report.section("Positions — D-1") is None + + +def test_ashtakavarga_sign_headers_are_uniform(built_report): + """Variation selectors used to eat a character from some abbreviations.""" + headers = built_report.section("Ashtakavarga").headers[1:] + assert len(headers) == 12 + assert len({len(h) for h in headers}) == 1, headers + + +# --- strengths and special points --------------------------------------- + +def test_sphuta_section(built_report): + section = built_report.section("Sphuta") + assert section is not None and len(section.pairs) == 14 + assert any("Tri Sphuta" in k for k, _ in section.pairs) + + +def test_shad_bala_rows_and_arithmetic(built_report): + """Row 6 must equal the sum of the six component balas; row 7 is /60.""" + section = built_report.section("Shadh Bala") + assert len(section.rows) == 9 + assert section.headers[0] == "Strength" and len(section.headers) == 8 # + 7 grahas + col = 1 # the Sun column + components = sum(float(section.rows[i][col]) for i in range(6)) + total = float(section.rows[6][col]) + assert components == pytest.approx(total, abs=0.01) + assert float(section.rows[7][col]) == pytest.approx(total / 60.0, abs=0.01) + + +def test_bhava_bala_is_twelve_houses(built_report): + section = built_report.section("Bhava Bala") + assert len(section.rows) == 12 + assert section.rows[0][0].endswith("-1") and section.rows[-1][0].endswith("-12") + + +def test_planet_keyed_balas_have_one_row_per_graha(built_report): + for title in ("Varga Amsa Vimsoka Bala", "Varga Amsa Vaiseshikamsa Bala", + "Harsha Pancha Dwadhasa Vargeeya bala"): + section = built_report.section(title) + assert section is not None, title + assert len(section.rows) == 7, title # Sun..Saturn, no nodes + assert all("\n" not in c for row in section.rows for c in row) + + +def test_bhava_chart_is_split_into_diagram_and_cusps(built_report): + """One section would render as either the chart or the table, never both.""" + diagram = built_report.section("Bhava Chart") + cusps = built_report.section("Bhava Chart — Cusps") + assert diagram is not None and diagram.chart is not None and not diagram.rows + assert cusps is not None and cusps.chart is None and len(cusps.rows) == 12 + + +def test_bhava_cusps_centre_house_one_on_the_ascendant(built_report): + """Bhava madhya convention: the lagna degree is the MIDDLE of house 1.""" + cusps = built_report.section("Bhava Chart — Cusps") + middle_of_house_1 = cusps.rows[0][2] + asc = dict(built_report.section("Positions — Raasi (D1)").pairs) + asc_value = [v for k, v in asc.items() if k.startswith("Ascendant")][0] + lagna_degree = '4° 55’ 55"' # Leo 4°55'55" + assert lagna_degree in middle_of_house_1 and lagna_degree in asc_value + + +def test_bhava_chart_differs_from_the_rasi_chart(built_report): + """Cusp-based houses move planets that sit within 15 deg of a boundary.""" + rasi = _divisional(built_report)[0].chart + bhava = built_report.section("Bhava Chart").chart + assert bhava.occupants != rasi.occupants + + +# --- dhasa calling conventions ------------------------------------------ + +def test_every_dhasa_system_builds(base_record_dict_module): + """All 60 systems, no warnings: they do not share a call signature.""" + from pyjhora_batch.report_data import available_dhasas + report = build_report(BirthRecord.from_dict(base_record_dict_module), dhasas=["all"]) + built = [s for s in report.sections if s.title.startswith("Dhasa-Bhukthi")] + assert len(built) == len(available_dhasas()) == 60 + assert report.warnings == [] + + +@pytest.mark.parametrize("name", ["Aayu", "Patyayini", "Varsha Vimsottari", + "Varsha Narayana"]) +def test_previously_failing_dhasas_now_produce_rows(base_record_dict_module, name): + """These four raised TypeError/KeyError when called as (dob, tob, place).""" + report = build_report(BirthRecord.from_dict(base_record_dict_module), dhasas=["all"]) + section = report.section(f"Dhasa-Bhukthi — {name}") + assert section is not None and len(section.rows) > 10 + + +def test_varsha_vimsottari_covers_the_birth_year(base_record_dict_module): + """Mudda takes years-1, as the engine's own _get_annual_dhasa_bhukthi does. + + Passing `years` instead pushed the varsha a whole year late. + """ + report = build_report(BirthRecord.from_dict(base_record_dict_module), + dhasas=["varsha_vimsottari"]) + starts = [row[1][:10] for row in + report.section("Dhasa-Bhukthi — Varsha Vimsottari").rows] + assert starts[0] < "1985-06-15" <= starts[-1] # brackets the birth date + assert starts[-1] < "1986-06-01" # and only about one year + + +def test_dhasa_call_args_follow_the_signature(): + """The mapping is signature-driven, not a fixed (dob, tob, place) triple.""" + from pyjhora_batch.report_data import _Builder + b = _Builder(BirthRecord.from_dict({ + "date_of_birth": "1985,6,15", "time_of_birth": "10:30:00", + "place_name": "Ujjain", "latitude": 23.5, "longitude": 75.75, + "timezone": 5.5}), ("vimsottari",)) + + def standard(dob, tob, place, **kw): ... + def annual(dob, tob, place, years, divisional_chart_factor=1): ... + def mudda(jd, place, years, divisional_chart_factor=1): ... + def none_needed(divisional_chart_factor=1, **kw): ... + + assert len(b._dhasa_call_args(standard, "vimsottari")) == 3 + assert len(b._dhasa_call_args(annual, "varsha_narayana")) == 4 + assert len(b._dhasa_call_args(mudda, "varsha_vimsottari")) == 3 + assert b._dhasa_call_args(none_needed, "patyayini") == [] + + +def test_aayu_type_sentinel_is_worked_around(): + """initialize_runtime turns AAYU_TYPE.NONE (None) into the string 'NONE'.""" + from jhora import config, const + config.initialize_runtime(force_reload=True, silent=True) + assert const.AAYU_TYPE_DEFAULT == "NONE" # the corrupted sentinel + from pyjhora_batch.report_data import _Builder + b = _Builder(BirthRecord.from_dict({ + "date_of_birth": "1985,6,15", "time_of_birth": "10:30:00", + "place_name": "Ujjain", "latitude": 23.5, "longitude": 75.75, + "timezone": 5.5}), ("aayu",)) + assert b._dhasa_kwargs("aayu")["aayur_type"] in (0, 1, 2, "L") + assert b._dhasa_kwargs("vimsottari") == {} + + +# --- spouse columns / marriage compatibility ---------------------------- + +_SPOUSE_BIRTH = {"spouse_name": "Latha Menon", "spouse_dob": "1993,8,22", + "spouse_tob": "09:30:00", "spouse_place": "Jaipur", + "spouse_lat": 26.9124, "spouse_long": 75.7873, "spouse_tz": 5.5} + + +def _with(**extra): + return BirthRecord.from_dict({ + "name": "Test Person", "date_of_birth": "1985,6,15", + "time_of_birth": "10:30:00", "place_name": "Ujjain", + "latitude": 23.5, "longitude": 75.75, "timezone": 5.5, + "gender": "male", **extra}) + + +def test_spouse_columns_accept_aliases(): + rec = _with(**_SPOUSE_BIRTH) + assert rec.spouse_name == "Latha Menon" + assert rec.spouse_date_of_birth == "1993,8,22" + assert rec.spouse_latitude == 26.9124 and rec.spouse_timezone == 5.5 + assert rec.has_spouse_birth_data() and rec.has_spouse() + + +def test_spouse_star_columns_are_enough_on_their_own(): + rec = _with(spouse_star="Swaathi", spouse_pada=1) + assert not rec.has_spouse_birth_data() + assert rec.has_spouse_star() and rec.has_spouse() + + +def test_no_spouse_columns_is_valid_and_yields_no_section(): + rec = _with() + assert not rec.has_spouse() + report = build_report(rec) + assert report.section("Marriage Compatibility") is None + assert report.warnings == [] + + +@pytest.mark.parametrize("bad", [ + {"spouse_dob": "22-08-1993"}, + {"spouse_tob": "9.30am"}, + {"spouse_pada": 5}, + {"spouse_lat": "north"}, + {"compatibility_method": "vedic"}, +]) +def test_bad_spouse_input_is_rejected(bad): + with pytest.raises(Exception): + _with(**bad) + + +def test_compatibility_method_defaults_from_chart_type(): + assert _with().resolved_compatibility_method() == "south" # south_indian + assert _with(chart_type="north_indian").resolved_compatibility_method() == "north" + # an explicit column always wins + assert _with(compatibility_method="north").resolved_compatibility_method() == "north" + + +def test_both_input_routes_give_the_same_star(): + """Birth details -> Moon -> nakshatra/pada must match the star given directly.""" + from_birth = build_report(_with(**_SPOUSE_BIRTH)).section("Marriage Compatibility") + from_star = build_report(_with(spouse_star="Swaathi", spouse_pada=1) + ).section("Marriage Compatibility") + assert dict(from_birth.pairs)["Spouse star"] == dict(from_star.pairs)["Spouse star"] + assert "computed from" in dict(from_birth.pairs)["Spouse star source"] + assert "supplied" in dict(from_star.pairs)["Spouse star source"] + + +def test_north_scoring_is_out_of_36(): + section = build_report(_with(compatibility_method="north", + **_SPOUSE_BIRTH)).section("Marriage Compatibility") + assert section.headers == ["Koota", "Score", "Max"] + assert dict(section.pairs)["Score"].endswith("/ 36") + assert len(section.rows) == 12 # 8 kootas + 4 extra poruthams + total = sum(float(r[1]) for r in section.rows[:8]) + assert float(dict(section.pairs)["Score"].split("/")[0]) == pytest.approx(total) + + +def test_south_scoring_is_ten_poruthams(): + section = build_report(_with(compatibility_method="south", + **_SPOUSE_BIRTH)).section("Marriage Compatibility") + assert section.headers == ["Porutham", "Agrees"] + assert len(section.rows) == 10 + passed = sum(1 for r in section.rows if r[1] == "Yes") + assert dict(section.pairs)["Score"] == f"{passed} / 10" + + +def test_gender_decides_which_partner_is_the_boy(): + """Several kootas are asymmetric, so the roles must not be arbitrary.""" + male = build_report(_with(gender="male", **_SPOUSE_BIRTH)).section("Marriage Compatibility") + female = build_report(_with(gender="female", **_SPOUSE_BIRTH)).section("Marriage Compatibility") + assert dict(male.pairs)["Boy / Girl"] != dict(female.pairs)["Boy / Girl"] + assert dict(male.pairs)["Boy / Girl"].startswith("Bharani") # native is the boy + + +def test_spouse_gender_overrides_the_inference(): + rec = _with(gender="male", spouse_gender="male", **_SPOUSE_BIRTH) + section = build_report(rec).section("Marriage Compatibility") + # spouse stated as male -> the native is treated as the girl + assert dict(section.pairs)["Boy / Girl"].startswith("Swaathi") + + +def test_unknown_star_name_is_reported_clearly(): + report = build_report(_with(spouse_star="Nonesuch", spouse_pada=1)) + assert report.section("Marriage Compatibility") is None + assert any("Nonesuch" in w for w in report.warnings) diff --git a/pyjhora_batch/tests/test_text_writer.py b/pyjhora_batch/tests/test_text_writer.py new file mode 100644 index 000000000..0d048bea6 --- /dev/null +++ b/pyjhora_batch/tests/test_text_writer.py @@ -0,0 +1,134 @@ +from pyjhora_batch.report_data import Report, Section +from pyjhora_batch.text_writer import _render_table, _wrap, render_text, write_text +from pyjhora_batch.wrapper import BirthRecord + + +def _record(): + return BirthRecord.from_dict({ + "name": "Test Person", "date_of_birth": "1985,6,15", + "time_of_birth": "10:30:00", "place_name": "Ujjain", + "latitude": 23.5, "longitude": 75.75, + "timezone": 5.5, "gender": "male"}) + + +def _report(*sections): + return Report(record=_record(), sections=list(sections)) + + +def test_wrap_preserves_blank_lines(): + assert _wrap("a\n\nb", 40) == ["a", "", "b"] + + +def test_table_columns_stay_aligned_when_a_cell_wraps(): + long_text = "word " * 40 + lines = _render_table(["A", "B"], [["short", long_text], ["x", "y"]], width=60) + body = [ln for ln in lines[2:] if ln.strip()] + # every rendered line must fit the page width - a wrapped cell must not + # push the second column off the edge + assert all(len(ln) <= 60 for ln in body) + assert len(body) > 2 # the long cell really did wrap + + +def test_render_text_includes_all_section_kinds(): + out = render_text(_report( + Section("Pairs", pairs=[("Key", "Value")]), + Section("Table", headers=["H1", "H2"], rows=[["a", "b"]]), + Section("Prose", body="Some narrative text."), + ), width=80) + assert "HOROSCOPE REPORT — Test Person" in out + assert "Key" in out and "Value" in out + assert "H1" in out and "H2" in out + assert "Some narrative text." in out + + +def test_render_text_reports_warnings(): + report = _report(Section("Pairs", pairs=[("K", "V")])) + report.warnings.append("Doshas: KeyError: 0") + out = render_text(report) + assert "could not be generated" in out + assert "Doshas: KeyError: 0" in out + + +def test_no_line_exceeds_the_requested_width(): + report = _report(Section("Prose", body="lorem ipsum dolor sit amet " * 20)) + for line in render_text(report, width=72).splitlines(): + assert len(line) <= 72 + + +def test_write_text_creates_parent_directories(tmp_path): + target = tmp_path / "nested" / "deeper" / "report.txt" + written = write_text(_record(), target, report=_report( + Section("Pairs", pairs=[("Key", "Value")]))) + assert written == target + assert "Key" in target.read_text(encoding="utf-8") + + +# --- ASCII chart diagrams ---------------------------------------------- + +from pyjhora_batch.report_data import ChartDiagram +from pyjhora_batch.text_writer import _render_chart + + +def _diagram(style="south_indian"): + occ = [[] for _ in range(12)] + occ[0] = ["Sun", "Mercury"] # Aries + occ[4] = ["Ascendant"] # Leo (the ascendant) + occ[9] = ["Moon", "Saturn", "Raagu"] # Capricorn + return ChartDiagram("Raasi (D1)", occ, ascendant=4, style=style) + + +def test_ascii_chart_places_signs_in_the_south_indian_ring(): + lines = _render_chart(_diagram(), width=100) + # top row runs Pisces, Aries, Taurus, Gemini left to right + header = next(ln for ln in lines if "Pi " in ln) + assert header.index("Pi ") < header.index("Ar ") < header.index("Ta ") < header.index("Ge ") + # Capricorn sits on the left edge, two rows down + assert any("Cp " in ln for ln in lines) + + +def test_ascii_chart_middle_is_open(): + """The 2x2 centre has no cell of its own — rows 1-2 span it as blank space.""" + lines = _render_chart(_diagram(), width=100) + # an interior row has exactly four pipes: both outer edges plus the inner + # edge of the left and right cells, with nothing drawn between them + interior = [ln for ln in lines if ln.count("|") == 4] + assert interior + for line in interior: + first, second = line.index("|", line.index("|") + 1), line.rindex("|", 0, line.rindex("|")) + assert line[first + 1:second].strip() == "" + # by contrast the top and bottom rows are divided into four cells + assert any(ln.count("|") == 5 for ln in lines) + + +def test_ascii_chart_marks_the_ascendant_and_numbers_houses(): + lines = _render_chart(_diagram(), width=100) + text = "\n".join(lines) + assert "Le H1 <" in text # ascendant is house 1 and flagged + assert "Vi H2" in text # houses continue from the ascendant + assert "Ar H9" in text + + +def test_ascii_chart_shows_every_occupant(): + text = "\n".join(_render_chart(_diagram(), width=100)) + for planet in ("Sun", "Mercury", "Ascendant", "Moon", "Saturn", "Raagu"): + assert planet in text + + +def test_ascii_chart_respects_the_width(): + for width in (60, 80, 100): + assert all(len(ln) <= width for ln in _render_chart(_diagram(), width=width)) + + +def test_chart_section_renders_the_diagram_instead_of_the_table(): + section = Section("Raasi (D1)", headers=["Rasi", "Occupants"], + rows=[["Aries", "Sun Mercury"]], chart=_diagram()) + out = render_text(_report(section)) + assert "+---" in out # the diagram is drawn + assert "Rasi Occupants" not in out # the redundant table is not + + +def test_ascii_chart_never_overflows_a_narrow_page(): + """A floor of 12 per cell used to force 53 columns regardless of width.""" + for width in (40, 50, 60, 80, 100): + lines = _render_chart(_diagram(), width=width) + assert max(len(ln) for ln in lines) <= width, f"overflow at width={width}" diff --git a/pyjhora_batch/text_writer.py b/pyjhora_batch/text_writer.py new file mode 100644 index 000000000..b74d94f10 --- /dev/null +++ b/pyjhora_batch/text_writer.py @@ -0,0 +1,197 @@ +"""Render a :class:`~pyjhora_batch.report_data.Report` as a plain-text file. + +Fixed-width output meant to be read in a terminal, printed, or pasted into an +email. Tables wrap inside their columns rather than overflowing the page width, +so a long yoga description stays inside its column instead of destroying the +alignment of every row after it. +""" + +from __future__ import annotations + +import textwrap +from pathlib import Path +from typing import List, Sequence + +from .report_data import ChartDiagram, Report, Section, build_report + +DEFAULT_WIDTH = 100 +_MIN_COL = 8 + +#: Sign index -> (row, col) in the South Indian 4x4 ring, matching +#: SouthIndianChart._zodiac_symbols in jhora/ui/chart_styles.py. +_SOUTH_CELLS = { + 11: (0, 0), 0: (0, 1), 1: (0, 2), 2: (0, 3), + 10: (1, 0), 3: (1, 3), + 9: (2, 0), 4: (2, 3), + 8: (3, 0), 7: (3, 1), 6: (3, 2), 5: (3, 3), +} +_SIGN_ABBR = ("Ar", "Ta", "Ge", "Cn", "Le", "Vi", + "Li", "Sc", "Sg", "Cp", "Aq", "Pi") + + +def _rule(char: str, width: int) -> str: + return char * width + + +def _wrap(text: str, width: int) -> List[str]: + """Wrap one cell/paragraph, preserving deliberate blank lines.""" + lines: List[str] = [] + for para in str(text).split("\n"): + if not para.strip(): + lines.append("") + continue + lines.extend(textwrap.wrap(para, width=max(width, _MIN_COL)) or [""]) + return lines or [""] + + +def _render_pairs(pairs: Sequence, width: int) -> List[str]: + label_w = min(max((len(str(k)) for k, _ in pairs), default=0), width // 2) + value_w = width - label_w - 3 + out: List[str] = [] + for key, value in pairs: + wrapped = _wrap(value, value_w) + out.append(f"{str(key)[:label_w]:<{label_w}} : {wrapped[0]}") + for cont in wrapped[1:]: + out.append(f"{'':<{label_w}} {cont}") + return out + + +def _column_widths(headers: Sequence[str], rows: Sequence[Sequence[str]], + width: int) -> List[int]: + """Distribute the available width across columns, favouring wide content.""" + ncols = max([len(headers)] + [len(r) for r in rows]) if rows or headers else 0 + if ncols == 0: + return [] + # A column's natural width is its widest cell, capped so that one very long + # cell cannot claim the whole line. The cap must stay well above short + # labels like "Raasi Adhipathi", which used to wrap with 80 columns spare. + natural = [len(str(headers[i])) if i < len(headers) else 0 for i in range(ncols)] + for row in rows: + for i, cell in enumerate(row): + natural[i] = max(natural[i], min(len(str(cell)), 48)) + budget = width - 3 * (ncols - 1) + total = sum(natural) or 1 + if total <= budget: + return natural + # scale down proportionally, but never below a readable minimum + widths = [max(_MIN_COL, int(n * budget / total)) for n in natural] + while sum(widths) > budget and max(widths) > _MIN_COL: + widths[widths.index(max(widths))] -= 1 + return widths + + +def _render_table(headers: Sequence[str], rows: Sequence[Sequence[str]], + width: int) -> List[str]: + widths = _column_widths(headers, rows, width) + if not widths: + return [] + out: List[str] = [] + if headers: + out.append(" ".join(f"{str(h)[:w]:<{w}}" for h, w in zip(headers, widths))) + out.append(" ".join("-" * w for w in widths)) + for row in rows: + cells = [_wrap(row[i] if i < len(row) else "", widths[i]) + for i in range(len(widths))] + height = max(len(c) for c in cells) + for line_no in range(height): + parts = [] + for i, w in enumerate(widths): + text = cells[i][line_no] if line_no < len(cells[i]) else "" + parts.append(f"{text:<{w}}") + out.append(" ".join(parts).rstrip()) + return out + + +def _render_chart(diagram: ChartDiagram, width: int) -> List[str]: + """Draw the chart as a South Indian ASCII grid. + + The 4x4 ring with an open middle is the one chart layout that survives in + fixed-width text, so it is used for every style; each cell is annotated with + its house number so North Indian readers still get the house sequence. + """ + houses = {sign: h + 1 for h, sign in enumerate(diagram.house_order())} + # 4 cells + 5 border columns must fit; floor at 8 so a narrow page + # truncates names rather than overflowing the requested width + cell_w = max(8, min(22, (width - 5) // 4)) + cell_h = max(2, 1 + max((len(o) for o in diagram.occupants), default=0)) + + canvas_w = 4 * (cell_w + 1) + 1 + canvas_h = 4 * (cell_h + 1) + 1 + grid = [[" "] * canvas_w for _ in range(canvas_h)] + + def put(row, col, text): + for i, ch in enumerate(text): + if 0 <= row < canvas_h and 0 <= col + i < canvas_w: + grid[row][col + i] = ch + + for sign, (r, c) in _SOUTH_CELLS.items(): + x0, y0 = c * (cell_w + 1), r * (cell_h + 1) + # box outline; shared edges simply overwrite with the same characters + put(y0, x0, "+" + "-" * cell_w + "+") + put(y0 + cell_h + 1, x0, "+" + "-" * cell_w + "+") + for dy in range(1, cell_h + 1): + put(y0 + dy, x0, "|") + put(y0 + dy, x0 + cell_w + 1, "|") + + header = f"{_SIGN_ABBR[sign]} H{houses[sign]}" + if sign == diagram.ascendant: + header += " <" # lagna marker + put(y0 + 1, x0 + 1, f" {header[:cell_w - 1]}") + for i, name in enumerate(diagram.occupants[sign]): + put(y0 + 2 + i, x0 + 1, f" {name[:cell_w - 1]}") + + return ["".join(row).rstrip() for row in grid] + + +def _render_section(section: Section, width: int) -> List[str]: + out = [section.title, _rule("-", min(len(section.title), width))] + if section.note: + out.extend(_wrap(f"Note: {section.note}", width) + [""]) + if section.chart is not None: + # the diagram carries the same data as the sign/occupant table + out.extend(_render_chart(section.chart, width)) + out.append("") + return out + if section.pairs: + out.extend(_render_pairs(section.pairs, width)) + if section.rows: + if section.pairs: + out.append("") + out.extend(_render_table(section.headers, section.rows, width)) + if section.body: + if section.pairs or section.rows: + out.append("") + for para in section.body.split("\n"): + out.extend(_wrap(para, width) if para.strip() else [""]) + out.append("") + return out + + +def render_text(report: Report, *, width: int = DEFAULT_WIDTH) -> str: + """Return the full report as plain text.""" + rec = report.record + title = rec.name or rec.place_name + lines = [_rule("=", width), f"HOROSCOPE REPORT — {title}", _rule("=", width), ""] + + for section in report.sections: + lines.extend(_render_section(section, width)) + + if report.warnings: + lines.extend([_rule("=", width), "Sections that could not be generated", + _rule("-", width)]) + lines.extend(f" - {w}" for w in report.warnings) + lines.append("") + + lines.append(_rule("=", width)) + lines.append(f"Generated by pyjhora_batch — {len(report.sections)} sections") + return "\n".join(lines) + "\n" + + +def write_text(record, out_path, *, width: int = DEFAULT_WIDTH, + report: Report | None = None, **build_kwargs) -> Path: + """Build (or reuse) a report for ``record`` and write it as ``out_path``.""" + report = report if report is not None else build_report(record, **build_kwargs) + out_path = Path(out_path) + out_path.parent.mkdir(parents=True, exist_ok=True) + out_path.write_text(render_text(report, width=width), encoding="utf-8") + return out_path diff --git a/pyjhora_batch/wrapper.py b/pyjhora_batch/wrapper.py new file mode 100644 index 000000000..4d5f4442d --- /dev/null +++ b/pyjhora_batch/wrapper.py @@ -0,0 +1,326 @@ +"""Reusable wrapper around PyJHora's ChartTabbed for headless/batch PDF generation. + +This is Phase 2 of the batch framework: a single clean entry point, +``generate_pdf(record, out_path)``, that the batch engine calls once per input +row. It deliberately treats the GUI as a presentation layer and drives it +programmatically rather than duplicating any horoscope calculations. + +Why the setters (and not the constructor kwargs) +------------------------------------------------- +``ChartTabbed.__init__`` documents ``date_of_birth`` / ``time_of_birth`` / +``place_of_birth`` kwargs, but as of V4.8.7 it never applies them: it only uses +them to decide whether to fall back to *now* and an *IP-based location*. Passing +birth details to the constructor therefore silently produces a chart for today +at the host's IP location. + +So we construct with networking disabled (``use_internet_for_location_check= +False``) and inject every field through the public setters — ``date_of_birth()``, +``time_of_birth()``, ``place()``, ``gender()``, ``name()`` — which do wire the +values into the widgets that ``compute_horoscope()`` reads. This keeps each +record's own coordinates honored and makes the run fully offline/deterministic. +""" + +from __future__ import annotations + +import os +import re +import sys +from dataclasses import dataclass, field +from pathlib import Path +from typing import Optional + +# ChartTabbed needs a QApplication. In headless/batch use we default to Qt's +# offscreen platform; a caller that already created a GUI QApplication is +# respected (see ensure_app). +if "QT_QPA_PLATFORM" not in os.environ: + os.environ["QT_QPA_PLATFORM"] = "offscreen" + +from PyQt6.QtWidgets import QApplication # noqa: E402 + +from jhora import config # noqa: E402 +from jhora.ui.horo_chart_tabs import ChartTabbed # noqa: E402 + +_DATE_RE = re.compile(r"^\s*\d{1,5},\d{1,2},\d{1,2}\s*$") +_TIME_RE = re.compile(r"^\s*\d{1,2}:\d{1,2}:\d{1,2}\s*$") + +# ChartTabbed's gender combo is initially ['Female','Male','Transgender', +# 'No preference'] -> gender() takes that index. We pass the int straight +# through and map a few common strings onto the same ordering. +_GENDER_ALIASES = { + "female": 0, "f": 0, "woman": 0, + "male": 1, "m": 1, "man": 1, + "transgender": 2, "trans": 2, "t": 2, + "no preference": 3, "none": 3, "na": 3, "n/a": 3, "": 3, +} + +# Accepted input column names -> canonical BirthRecord field. +_FIELD_ALIASES = { + "name": "name", "person": "name", "full_name": "name", + "date_of_birth": "date_of_birth", "dob": "date_of_birth", "date": "date_of_birth", + "time_of_birth": "time_of_birth", "tob": "time_of_birth", "time": "time_of_birth", + "place_name": "place_name", "place": "place_name", "place_of_birth": "place_name", + "location": "place_name", "city": "place_name", + "latitude": "latitude", "lat": "latitude", + "longitude": "longitude", "long": "longitude", "lon": "longitude", "lng": "longitude", + "timezone": "timezone", "tz": "timezone", "timezone_offset": "timezone", + "time_zone": "timezone", "utc_offset": "timezone", + "gender": "gender", "sex": "gender", + "elevation": "elevation", "altitude": "elevation", + "chart_type": "chart_type", "chart": "chart_type", + "language": "language", "lang": "language", + # --- spouse / marriage compatibility --- + "spouse_name": "spouse_name", "partner_name": "spouse_name", + "spouse_date_of_birth": "spouse_date_of_birth", "spouse_dob": "spouse_date_of_birth", + "partner_dob": "spouse_date_of_birth", "spouse_date": "spouse_date_of_birth", + "spouse_time_of_birth": "spouse_time_of_birth", "spouse_tob": "spouse_time_of_birth", + "partner_tob": "spouse_time_of_birth", "spouse_time": "spouse_time_of_birth", + "spouse_place": "spouse_place_name", "spouse_place_name": "spouse_place_name", + "spouse_location": "spouse_place_name", "spouse_city": "spouse_place_name", + "spouse_latitude": "spouse_latitude", "spouse_lat": "spouse_latitude", + "spouse_longitude": "spouse_longitude", "spouse_long": "spouse_longitude", + "spouse_lon": "spouse_longitude", "spouse_lng": "spouse_longitude", + "spouse_timezone": "spouse_timezone", "spouse_tz": "spouse_timezone", + "spouse_gender": "spouse_gender", "spouse_sex": "spouse_gender", + "spouse_nakshatra": "spouse_nakshatra", "spouse_star": "spouse_nakshatra", + "spouse_nakshathra": "spouse_nakshatra", + "spouse_pada": "spouse_pada", "spouse_paadham": "spouse_pada", + "spouse_quarter": "spouse_pada", + "compatibility_method": "compatibility_method", "match_method": "compatibility_method", +} + + +class RecordError(ValueError): + """Raised when an input record is missing or has invalid mandatory fields.""" + + +@dataclass +class BirthRecord: + """A validated set of birth details for one horoscope.""" + + date_of_birth: str # "YYYY,M,D" e.g. "1985,6,15" + time_of_birth: str # "HH:MM:SS" e.g. "15:43:00" + place_name: str + latitude: float + longitude: float + timezone: float # hours offset from UTC, e.g. 5.5 + name: str = "" + gender: int = 3 # 0=Female,1=Male,2=Transgender,3=No preference + elevation: float = 0.0 + chart_type: str = "south_indian" + language: str = "English" + + # --- spouse, for marriage compatibility (all optional) --- + # Compatibility needs only the pair's nakshatra + pada. Supply the spouse's + # birth details and they are derived exactly; or give the star directly when + # the birth time is unknown (pada is ~6 hours of Moon travel, so a guessed + # time makes the pada — and the score — unreliable). + spouse_name: str = "" + spouse_date_of_birth: str = "" # "YYYY,M,D" + spouse_time_of_birth: str = "" # "HH:MM:SS" + spouse_place_name: str = "" + spouse_latitude: Optional[float] = None + spouse_longitude: Optional[float] = None + spouse_timezone: Optional[float] = None + spouse_gender: Optional[int] = None + spouse_nakshatra: Optional[str] = None # 1..27, or a name ("Swaathi") + spouse_pada: Optional[int] = None # 1..4 + compatibility_method: str = "" # "north" | "south"; else from chart_type + + def __post_init__(self): + self.date_of_birth = str(self.date_of_birth).strip() + self.time_of_birth = str(self.time_of_birth).strip() + self.place_name = str(self.place_name).strip() + if not _DATE_RE.match(self.date_of_birth): + raise RecordError(f"date_of_birth must be 'YYYY,M,D' (got {self.date_of_birth!r})") + if not _TIME_RE.match(self.time_of_birth): + raise RecordError(f"time_of_birth must be 'HH:MM:SS' (got {self.time_of_birth!r})") + if not self.place_name: + raise RecordError("place_name is required") + try: + self.latitude = float(self.latitude) + self.longitude = float(self.longitude) + self.timezone = float(self.timezone) + self.elevation = float(self.elevation) + except (TypeError, ValueError) as exc: + raise RecordError(f"latitude/longitude/timezone/elevation must be numeric ({exc})") from exc + if not (-90.0 <= self.latitude <= 90.0): + raise RecordError(f"latitude out of range: {self.latitude}") + if not (-180.0 <= self.longitude <= 180.0): + raise RecordError(f"longitude out of range: {self.longitude}") + if not (-14.0 <= self.timezone <= 14.0): + raise RecordError(f"timezone out of range: {self.timezone}") + self.gender = _normalize_gender(self.gender) + self._validate_spouse() + + def _validate_spouse(self) -> None: + """Validate whatever spouse fields were supplied; all are optional.""" + self.spouse_name = str(self.spouse_name or "").strip() + self.spouse_place_name = str(self.spouse_place_name or "").strip() + self.spouse_date_of_birth = str(self.spouse_date_of_birth or "").strip() + self.spouse_time_of_birth = str(self.spouse_time_of_birth or "").strip() + + if self.spouse_date_of_birth and not _DATE_RE.match(self.spouse_date_of_birth): + raise RecordError("spouse_date_of_birth must be 'YYYY,M,D' " + f"(got {self.spouse_date_of_birth!r})") + if self.spouse_time_of_birth and not _TIME_RE.match(self.spouse_time_of_birth): + raise RecordError("spouse_time_of_birth must be 'HH:MM:SS' " + f"(got {self.spouse_time_of_birth!r})") + + for field_name in ("spouse_latitude", "spouse_longitude", "spouse_timezone"): + value = getattr(self, field_name) + if value in (None, ""): + setattr(self, field_name, None) + continue + try: + setattr(self, field_name, float(value)) + except (TypeError, ValueError) as exc: + raise RecordError(f"{field_name} must be numeric ({exc})") from exc + if self.spouse_latitude is not None and not (-90.0 <= self.spouse_latitude <= 90.0): + raise RecordError(f"spouse_latitude out of range: {self.spouse_latitude}") + if self.spouse_longitude is not None and not (-180.0 <= self.spouse_longitude <= 180.0): + raise RecordError(f"spouse_longitude out of range: {self.spouse_longitude}") + if self.spouse_timezone is not None and not (-14.0 <= self.spouse_timezone <= 14.0): + raise RecordError(f"spouse_timezone out of range: {self.spouse_timezone}") + + if self.spouse_gender not in (None, ""): + self.spouse_gender = _normalize_gender(self.spouse_gender) + else: + self.spouse_gender = None + + if self.spouse_pada not in (None, ""): + try: + self.spouse_pada = int(self.spouse_pada) + except (TypeError, ValueError) as exc: + raise RecordError(f"spouse_pada must be 1-4 ({exc})") from exc + if not 1 <= self.spouse_pada <= 4: + raise RecordError(f"spouse_pada must be 1-4 (got {self.spouse_pada})") + else: + self.spouse_pada = None + + if self.spouse_nakshatra in (None, ""): + self.spouse_nakshatra = None + else: + self.spouse_nakshatra = str(self.spouse_nakshatra).strip() + + method = str(self.compatibility_method or "").strip().lower() + if method and method not in ("north", "south"): + raise RecordError("compatibility_method must be 'north' or 'south' " + f"(got {self.compatibility_method!r})") + self.compatibility_method = method + + def has_spouse_birth_data(self) -> bool: + """True when the spouse's star can be computed exactly from birth data.""" + return bool(self.spouse_date_of_birth and self.spouse_time_of_birth + and self.spouse_latitude is not None + and self.spouse_longitude is not None + and self.spouse_timezone is not None) + + def has_spouse_star(self) -> bool: + """True when the spouse's nakshatra and pada were given directly.""" + return self.spouse_nakshatra is not None and self.spouse_pada is not None + + def has_spouse(self) -> bool: + return self.has_spouse_birth_data() or self.has_spouse_star() + + def resolved_compatibility_method(self) -> str: + """Explicit column wins; otherwise follow the chart style, as the GUI does.""" + if self.compatibility_method: + return self.compatibility_method + return "south" if "south" in (self.chart_type or "").lower() else "north" + + @classmethod + def from_dict(cls, data: dict) -> "BirthRecord": + """Build a record from a raw dict (e.g. a CSV row), tolerating aliases.""" + if not isinstance(data, dict): + raise RecordError(f"record must be a dict, got {type(data).__name__}") + canonical: dict = {} + for key, value in data.items(): + if key is None: + continue + # normalize header: lowercase, and collapse spaces/hyphens to '_' + # so "Date of Birth" / "date-of-birth" both match "date_of_birth". + norm = re.sub(r"[\s\-]+", "_", str(key).strip().lower()) + field_name = _FIELD_ALIASES.get(norm) + if field_name is None or value in (None, ""): + continue + canonical[field_name] = value + missing = [f for f in ("date_of_birth", "time_of_birth", "place_name", + "latitude", "longitude", "timezone") if f not in canonical] + if missing: + raise RecordError(f"missing required field(s): {', '.join(missing)}") + return cls(**canonical) + + +def _normalize_gender(value) -> int: + if isinstance(value, bool): # avoid True/False sneaking through as ints + raise RecordError(f"gender must not be a boolean: {value!r}") + if isinstance(value, (int, float)) and int(value) in (0, 1, 2, 3): + return int(value) + key = str(value).strip().lower() + if key in _GENDER_ALIASES: + return _GENDER_ALIASES[key] + raise RecordError(f"gender must be 0-3 or a known label (got {value!r})") + + +def ensure_app(headless: bool = True) -> QApplication: + """Return the singleton QApplication, creating a headless one if needed. + + A batch should create the app once and reuse it across all records. If a + QApplication already exists (e.g. the caller is running the GUI) it is + returned unchanged. + """ + app = QApplication.instance() + if app is None: + if headless and os.environ.get("QT_QPA_PLATFORM") != "offscreen": + os.environ["QT_QPA_PLATFORM"] = "offscreen" + app = QApplication(sys.argv[:1]) + return app + + +_runtime_ready = False + + +def _ensure_runtime() -> None: + global _runtime_ready + if not _runtime_ready: + config.initialize_runtime(force_reload=True, silent=True) + _runtime_ready = True + + +def generate_pdf(record, out_path, *, app: QApplication | None = None, + expand_all_tabs=None) -> Path: + """Generate one horoscope PDF for ``record`` at ``out_path``. + + ``record`` may be a :class:`BirthRecord` or a raw dict (see + ``BirthRecord.from_dict``). Returns the written path. Raises + :class:`RecordError` for bad input and propagates any error from the + PyJHora engine; the batch engine is responsible for per-record isolation. + """ + rec = record if isinstance(record, BirthRecord) else BirthRecord.from_dict(record) + app = app or ensure_app() + _ensure_runtime() + + out_path = Path(out_path) + out_path.parent.mkdir(parents=True, exist_ok=True) + + chart = ChartTabbed( + chart_type=rec.chart_type, + language=rec.language, + use_internet_for_location_check=False, + ) + try: + if rec.name: + chart.name(rec.name) + chart.date_of_birth(rec.date_of_birth) + chart.time_of_birth(rec.time_of_birth) + chart.place(rec.place_name, rec.latitude, rec.longitude, rec.timezone, rec.elevation) + chart.gender(rec.gender) + chart.compute_horoscope() + app.processEvents() # let Qt finish laying out widgets before rendering + chart.save_as_pdf(str(out_path), expand_all_tabs=expand_all_tabs) + app.processEvents() + finally: + chart.close() + chart.deleteLater() + app.processEvents() + return out_path