Vous ne pouvez pas sélectionner plus de 25 sujets Les noms de sujets doivent commencer par une lettre ou un nombre, peuvent contenir des tirets ('-') et peuvent comporter jusqu'à 35 caractères.
 
 
 
 

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