You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

546 lines
18 KiB

  1. #!/usr/bin/env python3
  2. # Kosmorro - Compute The Next Ephemerides
  3. # Copyright (C) 2019 Jérôme Deuchnord <jerome@deuchnord.fr>
  4. #
  5. # This program is free software: you can redistribute it and/or modify
  6. # it under the terms of the GNU Affero General Public License as
  7. # published by the Free Software Foundation, either version 3 of the
  8. # License, or (at your option) any later version.
  9. #
  10. # This program is distributed in the hope that it will be useful,
  11. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  12. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  13. # GNU Affero General Public License for more details.
  14. #
  15. # You should have received a copy of the GNU Affero General Public License
  16. # along with this program. If not, see <https://www.gnu.org/licenses/>.
  17. from abc import ABC, abstractmethod
  18. import datetime
  19. import json
  20. import os
  21. import tempfile
  22. import subprocess
  23. from babel.dates import format_date, format_time
  24. from tabulate import tabulate
  25. from termcolor import colored
  26. from kosmorrolib import AsterEphemerides, Event, EventType
  27. from kosmorrolib.model import ASTERS, MoonPhase
  28. from .i18n.utils import _
  29. from .i18n import strings
  30. from .exceptions import (
  31. CompileError,
  32. UnavailableFeatureError as KosmorroUnavailableFeatureError,
  33. )
  34. from .debug import debug_print
  35. from .version import KOSMORRO_VERSION
  36. class Dumper(ABC):
  37. ephemerides: [AsterEphemerides]
  38. moon_phase: MoonPhase
  39. events: [Event]
  40. date: datetime.date
  41. timezone: int
  42. with_colors: bool
  43. show_graph: bool
  44. def __init__(
  45. self,
  46. ephemerides: [AsterEphemerides],
  47. moon_phase: MoonPhase,
  48. events: [Event],
  49. date: datetime.date,
  50. timezone: int,
  51. with_colors: bool,
  52. show_graph: bool,
  53. ):
  54. self.ephemerides = ephemerides
  55. self.moon_phase = moon_phase
  56. self.events = events
  57. self.date = date
  58. self.timezone = timezone
  59. self.with_colors = with_colors
  60. self.show_graph = show_graph
  61. def get_date_as_string(self, capitalized: bool = False) -> str:
  62. date = format_date(self.date, "full")
  63. if capitalized:
  64. return "".join([date[0].upper(), date[1:]])
  65. return date
  66. def __str__(self):
  67. return self.to_string()
  68. @abstractmethod
  69. def to_string(self):
  70. pass
  71. @staticmethod
  72. def is_file_output_needed() -> bool:
  73. return False
  74. class JsonDumper(Dumper):
  75. SUPPORTED_EVENTS = [
  76. EventType.OPPOSITION,
  77. EventType.CONJUNCTION,
  78. EventType.OCCULTATION,
  79. EventType.MAXIMAL_ELONGATION,
  80. EventType.PERIGEE,
  81. EventType.APOGEE,
  82. ]
  83. def to_string(self):
  84. return json.dumps(
  85. {
  86. "ephemerides": [
  87. ephemeris.serialize() for ephemeris in self.ephemerides
  88. ],
  89. "moon_phase": self.moon_phase.serialize(),
  90. "events": list(self.get_events()),
  91. },
  92. indent=4,
  93. )
  94. def get_events(self) -> [{str: any}]:
  95. for event in self.events:
  96. if event.event_type not in self.SUPPORTED_EVENTS:
  97. continue
  98. yield event.serialize()
  99. class TextDumper(Dumper):
  100. def to_string(self):
  101. text = [self.style(self.get_date_as_string(capitalized=True), "h1")]
  102. if len(self.ephemerides) > 0:
  103. text.append(self.stringify_ephemerides())
  104. text.append(self.get_moon(self.moon_phase))
  105. if len(self.events) > 0:
  106. events = self.get_events(self.events)
  107. if events.strip("\n") != "":
  108. text.append(
  109. "\n".join(
  110. [
  111. self.style(_("Expected events:"), "h2"),
  112. events,
  113. ]
  114. )
  115. )
  116. if self.timezone == 0:
  117. text.append(self.style(_("Note: All the hours are given in UTC."), "em"))
  118. else:
  119. tz_offset = str(self.timezone)
  120. if self.timezone > 0:
  121. tz_offset = "".join(["+", tz_offset])
  122. text.append(
  123. self.style(
  124. _(
  125. "Note: All the hours are given in the UTC{offset} timezone."
  126. ).format(offset=tz_offset),
  127. "em",
  128. )
  129. )
  130. return "\n\n".join(text)
  131. def style(self, text: str, tag: str) -> str:
  132. if not self.with_colors:
  133. return text
  134. styles = {
  135. "h1": lambda t: colored(t, "green", attrs=["bold"]),
  136. "h2": lambda t: colored(t, "magenta", attrs=["bold"]),
  137. "th": lambda t: colored(t, attrs=["bold"]),
  138. "strong": lambda t: colored(t, attrs=["bold"]),
  139. "em": lambda t: colored(t, attrs=["dark"]),
  140. }
  141. return styles.get(tag, lambda t: t)(text)
  142. def stringify_ephemerides(self) -> str:
  143. data = []
  144. for ephemeris in self.ephemerides:
  145. object_name = strings.from_object(ephemeris.object.identifier)
  146. if object_name is None:
  147. continue
  148. name = self.style(object_name, "th")
  149. planet_rise = (
  150. "-"
  151. if ephemeris.rise_time is None
  152. else format_time(ephemeris.rise_time, "short")
  153. )
  154. planet_culmination = (
  155. "-"
  156. if ephemeris.culmination_time is None
  157. else format_time(ephemeris.culmination_time, "short")
  158. )
  159. planet_set = (
  160. "-"
  161. if ephemeris.set_time is None
  162. else format_time(ephemeris.set_time, "short")
  163. )
  164. data.append([name, planet_rise, planet_culmination, planet_set])
  165. return tabulate(
  166. data,
  167. headers=[
  168. self.style(_("Object"), "th"),
  169. self.style(_("Rise time"), "th"),
  170. self.style(_("Culmination time"), "th"),
  171. self.style(_("Set time"), "th"),
  172. ],
  173. tablefmt="simple",
  174. stralign="center",
  175. colalign=("left",),
  176. )
  177. def get_events(self, events: [Event]) -> str:
  178. data = []
  179. for event in events:
  180. description = strings.from_event(event)
  181. if description is None:
  182. continue
  183. data.append(
  184. [
  185. self.style(format_time(event.start_time, "short"), "th"),
  186. description,
  187. ]
  188. )
  189. return tabulate(data, tablefmt="plain", stralign="left")
  190. def get_moon(self, moon_phase: MoonPhase) -> str:
  191. if moon_phase is None:
  192. return _("Moon phase is unavailable for this date.")
  193. current_moon_phase = " ".join(
  194. [
  195. self.style(_("Moon phase:"), "strong"),
  196. strings.from_moon_phase(moon_phase.phase_type),
  197. ]
  198. )
  199. new_moon_phase = _(
  200. "{next_moon_phase} on {next_moon_phase_date} at {next_moon_phase_time}"
  201. ).format(
  202. next_moon_phase=_(strings.from_moon_phase(moon_phase.get_next_phase())),
  203. next_moon_phase_date=format_date(moon_phase.next_phase_date, "full"),
  204. next_moon_phase_time=format_time(moon_phase.next_phase_date, "short"),
  205. )
  206. return "\n".join([current_moon_phase, new_moon_phase])
  207. class LatexDumper(Dumper):
  208. def to_string(self):
  209. template_path = os.path.join(
  210. os.path.abspath(os.path.dirname(__file__)),
  211. "assets",
  212. "latex",
  213. "template.tex",
  214. )
  215. with open(template_path, mode="r") as file:
  216. template = file.read()
  217. return self._make_document(template)
  218. def _make_document(self, template: str) -> str:
  219. kosmorro_logo_path = os.path.join(
  220. os.path.abspath(os.path.dirname(__file__)),
  221. "assets",
  222. "png",
  223. "kosmorro-logo.png",
  224. )
  225. moon_phase_graphics = os.path.join(
  226. os.path.abspath(os.path.dirname(__file__)),
  227. "assets",
  228. "moonphases",
  229. "png",
  230. ".".join(
  231. [self.moon_phase.phase_type.name.lower().replace("_", "-"), "png"]
  232. ),
  233. )
  234. document = template
  235. if self.ephemerides is None:
  236. document = self._remove_section(document, "ephemerides")
  237. if len(self.events) == 0:
  238. document = self._remove_section(document, "events")
  239. document = self.add_strings(document, kosmorro_logo_path, moon_phase_graphics)
  240. if self.show_graph:
  241. # The graphephemerides environment beginning tag must end with a percent symbol to ensure
  242. # that no extra space will interfere with the graph.
  243. document = document.replace(
  244. r"\begin{ephemerides}", r"\begin{graphephemerides}%"
  245. ).replace(r"\end{ephemerides}", r"\end{graphephemerides}")
  246. return document
  247. def add_strings(
  248. self, document: str, kosmorro_logo_path: str, moon_phase_graphics: str
  249. ) -> str:
  250. document = document.replace(
  251. "+++CURRENT-DATE+++", datetime.datetime.now().isoformat()
  252. )
  253. document = document.replace("+++KOSMORRO-VERSION+++", KOSMORRO_VERSION)
  254. document = document.replace("+++KOSMORRO-LOGO+++", kosmorro_logo_path)
  255. document = document.replace("+++DOCUMENT-TITLE+++", _("Overview of your sky"))
  256. document = document.replace(
  257. "+++DOCUMENT-DATE+++", self.get_date_as_string(capitalized=True)
  258. )
  259. document = document.replace(
  260. "+++INTRODUCTION+++",
  261. "\n\n".join(
  262. [
  263. _(
  264. "This document summarizes the ephemerides and the events of {date}. "
  265. "It aims to help you to prepare your observation session. "
  266. "All the hours are given in {timezone}."
  267. ).format(
  268. date=self.get_date_as_string(),
  269. timezone="UTC+%d" % self.timezone
  270. if self.timezone != 0
  271. else "UTC",
  272. ),
  273. _(
  274. "Don't forget to check the weather forecast before you go out with your equipment."
  275. ),
  276. ]
  277. ),
  278. )
  279. document = document.replace(
  280. "+++SECTION-EPHEMERIDES+++", _("Ephemerides of the day")
  281. )
  282. document = document.replace("+++EPHEMERIDES-OBJECT+++", _("Object"))
  283. document = document.replace("+++EPHEMERIDES-RISE-TIME+++", _("Rise time"))
  284. document = document.replace(
  285. "+++EPHEMERIDES-CULMINATION-TIME+++", _("Culmination time")
  286. )
  287. document = document.replace("+++EPHEMERIDES-SET-TIME+++", _("Set time"))
  288. document = document.replace("+++EPHEMERIDES+++", self._make_ephemerides())
  289. document = document.replace("+++GRAPH_LABEL_HOURS+++", _("hours"))
  290. document = document.replace("+++MOON-PHASE-GRAPHICS+++", moon_phase_graphics)
  291. document = document.replace("+++CURRENT-MOON-PHASE-TITLE+++", _("Moon phase:"))
  292. document = document.replace(
  293. "+++CURRENT-MOON-PHASE+++",
  294. strings.from_moon_phase(self.moon_phase.phase_type),
  295. )
  296. document = document.replace("+++SECTION-EVENTS+++", _("Expected events"))
  297. document = document.replace("+++EVENTS+++", self._make_events())
  298. for aster in ASTERS:
  299. object_name = strings.from_object(aster.identifier)
  300. if object_name is None:
  301. continue
  302. document = document.replace(
  303. "+++ASTER_%s+++" % aster.identifier.name,
  304. object_name,
  305. )
  306. return document
  307. def _make_ephemerides(self) -> str:
  308. latex = []
  309. graph_y_component = 18
  310. if self.ephemerides is not None:
  311. for ephemeris in self.ephemerides:
  312. aster_rise = (
  313. "-"
  314. if ephemeris.rise_time is None
  315. else format_time(ephemeris.rise_time, "short")
  316. )
  317. aster_culmination = (
  318. "-"
  319. if ephemeris.culmination_time is None
  320. else format_time(ephemeris.culmination_time, "short")
  321. )
  322. aster_set = (
  323. "-"
  324. if ephemeris.set_time is None
  325. else format_time(ephemeris.set_time, "short")
  326. )
  327. if not self.show_graph:
  328. object_name = strings.from_object(ephemeris.object.identifier)
  329. if object_name is not None:
  330. latex.append(
  331. r"\object{%s}{%s}{%s}{%s}"
  332. % (
  333. object_name,
  334. aster_rise,
  335. aster_culmination,
  336. aster_set,
  337. )
  338. )
  339. else:
  340. if ephemeris.rise_time is not None:
  341. raise_hour = ephemeris.rise_time.hour
  342. raise_minute = ephemeris.rise_time.minute
  343. else:
  344. raise_hour = raise_minute = 0
  345. aster_rise = ""
  346. if ephemeris.set_time is not None:
  347. set_hour = ephemeris.set_time.hour
  348. set_minute = ephemeris.set_time.minute
  349. else:
  350. set_hour = 24
  351. set_minute = 0
  352. aster_set = ""
  353. sets_after_end = set_hour > raise_hour
  354. if not sets_after_end:
  355. latex.append(
  356. r"\graphobject{%d}{gray}{0}{0}{%d}{%d}{}{%s}"
  357. % (graph_y_component, set_hour, set_minute, aster_set)
  358. )
  359. set_hour = 24
  360. set_minute = 0
  361. latex.append(
  362. r"\graphobject{%d}{gray}{%d}{%d}{%d}{%d}{%s}{%s}"
  363. % (
  364. graph_y_component,
  365. raise_hour,
  366. raise_minute,
  367. set_hour,
  368. set_minute,
  369. aster_rise,
  370. aster_set if sets_after_end else "",
  371. )
  372. )
  373. graph_y_component -= 2
  374. return "".join(latex)
  375. def _make_events(self) -> str:
  376. latex = []
  377. for event in self.events:
  378. event_name = strings.from_event(event)
  379. if event_name is None:
  380. continue
  381. latex.append(
  382. r"\event{%s}{%s}" % (format_time(event.start_time, "short"), event_name)
  383. )
  384. return "".join(latex)
  385. @staticmethod
  386. def _remove_section(document: str, section: str):
  387. begin_section_tag = "%%%%%% BEGIN-%s-SECTION" % section.upper()
  388. end_section_tag = "%%%%%% END-%s-SECTION" % section.upper()
  389. document = document.split("\n")
  390. new_document = []
  391. ignore_line = False
  392. for line in document:
  393. if begin_section_tag in line or end_section_tag in line:
  394. ignore_line = not ignore_line
  395. continue
  396. if ignore_line:
  397. continue
  398. new_document.append(line)
  399. return "\n".join(new_document)
  400. class PdfDumper(Dumper):
  401. def to_string(self):
  402. try:
  403. latex_dumper = LatexDumper(
  404. self.ephemerides,
  405. self.moon_phase,
  406. self.events,
  407. date=self.date,
  408. timezone=self.timezone,
  409. with_colors=self.with_colors,
  410. show_graph=self.show_graph,
  411. )
  412. return self._compile(latex_dumper.to_string())
  413. except RuntimeError as error:
  414. raise KosmorroUnavailableFeatureError(
  415. _(
  416. "Building PDF was not possible, because some dependencies are not"
  417. " installed.\nPlease look at the documentation at https://kosmorro.space/cli/generate-pdf/ "
  418. "for more information."
  419. )
  420. ) from error
  421. @staticmethod
  422. def is_file_output_needed() -> bool:
  423. return True
  424. @staticmethod
  425. def _compile(latex_input) -> bytes:
  426. timestamp = datetime.datetime.now().strftime("%Y%m%d%H%M%S")
  427. current_dir = (
  428. os.getcwd()
  429. ) # Keep the current directory to return to it after the PDFLaTeX execution
  430. try:
  431. temp_dir = tempfile.mkdtemp()
  432. temp_tex = "%s/%s.tex" % (temp_dir, timestamp)
  433. with open(temp_tex, "w") as tex_file:
  434. tex_file.write(latex_input)
  435. os.chdir(temp_dir)
  436. debug_print("LaTeX content:\n%s" % latex_input)
  437. subprocess.run(
  438. ["pdflatex", "-interaction", "nonstopmode", "%s.tex" % timestamp],
  439. capture_output=True,
  440. check=True,
  441. )
  442. os.chdir(current_dir)
  443. with open("%s/%s.pdf" % (temp_dir, timestamp), "rb") as pdffile:
  444. return bytes(pdffile.read())
  445. except FileNotFoundError as error:
  446. raise KosmorroUnavailableFeatureError(
  447. "TeXLive is not installed."
  448. ) from error
  449. except subprocess.CalledProcessError as error:
  450. with open("/tmp/kosmorro-%s.log" % timestamp, "wb") as file:
  451. file.write(error.stdout)
  452. raise CompileError(
  453. _(
  454. "An error occurred during the compilation of the PDF.\n"
  455. "Please open an issue at https://github.com/Kosmorro/kosmorro/issues and share "
  456. "the content of the log file at /tmp/kosmorro-%s.log" % timestamp
  457. )
  458. ) from error