parser.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355
  1. import logging
  2. import re
  3. from pathlib import Path
  4. import cv2
  5. import yaml
  6. from fastapi import HTTPException
  7. from markdown_it import MarkdownIt
  8. from mdit_py_plugins.anchors import anchors_plugin as section_header
  9. from pydantic import ValidationError
  10. from app.schema import (
  11. DocumentCheckout,
  12. DocumentEmail,
  13. DocumentIndex,
  14. DocumentOrders,
  15. DocumentPage,
  16. DocumentProduct,
  17. DocumentProductIndex,
  18. DocumentSite,
  19. )
  20. logger = logging.getLogger(__name__)
  21. def md(text: str) -> str:
  22. """Helper function to convert parsed markdown-formatted text to HTML."""
  23. md = (
  24. MarkdownIt("commonmark", {"breaks": True, "html": True})
  25. .enable("table")
  26. .use(section_header)
  27. )
  28. return md.render(text)
  29. def get_files_by_extension(folder: Path, extensions: list[str], ignore_list: list = [], relative_path: bool = False) -> list[Path]:
  30. """Getting list of files specified by extensions"""
  31. file_list = []
  32. for extension in extensions:
  33. file_list.extend([f for f in list(folder.rglob(f'{extension}')) if f not in ignore_list])
  34. if relative_path:
  35. for i in range(len(file_list)):
  36. file_list[i] = file_list[i].relative_to(folder)
  37. return file_list
  38. def set_default_product_meta_fields(block) -> dict[str, str]:
  39. """
  40. Helper function to make sure all necessary fields expected by the
  41. document schema are present in dictionary. This prevents errors
  42. when in the text document some keys have been omitted (because
  43. unnecessary / empty), but are needed by the document scheme.
  44. """
  45. if block['template'] == 'product':
  46. if 'code' not in block:
  47. block['code'] = ''
  48. elif 'product_id' not in block:
  49. block['product_id'] = ''
  50. elif 'price' not in block:
  51. block['price'] = 0
  52. elif 'weight' not in block:
  53. block['weight'] = 0
  54. elif 'customs_tariff_code' not in block:
  55. block['customs_tariff_code'] = ''
  56. elif 'origin_country_iso' not in block:
  57. block['origin_country_iso'] = ''
  58. elif 'category' not in block:
  59. block['category'] = ''
  60. if 'options' in block and len(block['options']) > 0:
  61. if 'size' not in block['options']:
  62. block['options']['size'] = None
  63. if 'style' not in block['options']:
  64. block['options']['style'] = None
  65. else:
  66. block['options'] = {
  67. 'size':None,
  68. 'style': None,
  69. }
  70. if 'inventory' in block and len(block['inventory']) > 0:
  71. for item in block['inventory']:
  72. if 'size' not in item:
  73. item['size'] = None
  74. if 'style' not in item:
  75. item['style'] = None
  76. if 'amount' not in item:
  77. item['amount'] = 0
  78. else:
  79. block['inventory'] = [
  80. {
  81. 'size': None, 'style': None, 'amount': 0
  82. }
  83. ]
  84. return block
  85. def make_block_md(block: str) -> dict[str]:
  86. """Helper function to prepare a dictionary object for a block of type
  87. text. Block texts are free-form strings and not formatted with
  88. YAML, so we prepare the dictionary here.
  89. """
  90. block.strip()
  91. if block != "":
  92. return {"value": block, "type": "text"}
  93. def make_block_image(
  94. repo_path: str, meta_path: str, block: dict[str, str]
  95. ) -> dict[str, int]:
  96. """Helper function to enrich the image block with a few more info
  97. about the file it points to. We make use of this when displaying
  98. the image in the frontend template and prepare the correct srcset
  99. list of sizes we can display for the given image.
  100. """
  101. filepath = None
  102. if (meta_path != '.'):
  103. filepath = f"{repo_path}/{meta_path}/{block['url']}"
  104. else:
  105. filepath = f"{repo_path}/{block['url']}"
  106. img = cv2.imread(filepath)
  107. if img is not None and img.any():
  108. if 'caption' not in block:
  109. block['caption'] = ""
  110. block["info"] = {
  111. "width": round(img.shape[1]),
  112. "height": round(img.shape[0]),
  113. }
  114. return block
  115. else:
  116. return {
  117. "type": "image",
  118. "value": "",
  119. "url": "",
  120. "caption": "",
  121. "info": {
  122. "width": 0,
  123. "height": 0
  124. },
  125. }
  126. def parse_file(
  127. repo_path: str, input_path: str | Path, block_types: list[str]
  128. ) -> (
  129. DocumentSite
  130. | DocumentIndex
  131. | DocumentPage
  132. | DocumentProductIndex
  133. | DocumentProduct
  134. | DocumentCheckout
  135. | DocumentOrders
  136. | DocumentEmail
  137. ):
  138. """Parse file at given filepath: return a dictionary with meta
  139. dictionary and list of blocks.
  140. """
  141. document = {
  142. "meta": {
  143. "type": "",
  144. "title": "",
  145. "template": "",
  146. "path": "",
  147. },
  148. "blocks": [],
  149. }
  150. try:
  151. p = Path(input_path)
  152. except KeyError:
  153. document["meta"]["path"] = ""
  154. data = input_path.read_text()
  155. block_marker = "\n---\n"
  156. blocks = data.split(block_marker)
  157. blocks = [re.sub(r"^---\n", "", block) for block in blocks]
  158. # set meta_path to default value
  159. meta_path = document["meta"]["path"]
  160. for block in blocks:
  161. # we check if current block is a potential
  162. # yaml block, then check if any other block
  163. # has a field type in the ones "allowed".
  164. # in case the block.type has no matches
  165. # or it fails the yaml parser, we take it
  166. # as a markdown block
  167. try:
  168. block_yaml = yaml.safe_load(block)
  169. if block_yaml is not None:
  170. if type(block_yaml) is dict:
  171. # check if "valid" YAML dict is actually
  172. # a long string with a colon in the middle;
  173. # somehow this trips up the YAML parser and
  174. # we need to take care of it ourselves
  175. for k in block_yaml.keys():
  176. if len(k.split()) > 1 and ":" in k:
  177. block_txt = make_block_md(block)
  178. document["blocks"].append(block_txt)
  179. if "type" in block_yaml:
  180. if block_yaml["type"] == "meta":
  181. document["meta"] = set_default_product_meta_fields(block_yaml)
  182. for k, v in block_yaml.items():
  183. document["meta"][k] = v
  184. # set meta-path
  185. if "template" in block_yaml:
  186. doc_path = Path(p).relative_to(repo_path).parent
  187. document["meta"]["path"] = str(doc_path)
  188. meta_path = document["meta"]["path"]
  189. elif (
  190. "type" in block_yaml
  191. and block_yaml["type"].lower() in block_types
  192. ):
  193. if block_yaml["type"] == "image":
  194. make_block_image(repo_path, meta_path, block_yaml)
  195. document["blocks"].append(block_yaml)
  196. else:
  197. block_md = make_block_md(block)
  198. document["blocks"].append(block_md)
  199. except yaml.YAMLError:
  200. block_md = make_block_md(block)
  201. document["blocks"].append(block_md)
  202. try:
  203. if document["meta"]["template"] == "site":
  204. return DocumentSite(**document)
  205. elif document["meta"]["template"] == "index":
  206. return DocumentIndex(**document)
  207. elif document["meta"]["template"] == "page":
  208. return DocumentPage(**document)
  209. elif document["meta"]["template"] == "product-index":
  210. return DocumentProductIndex(**document)
  211. elif document["meta"]["template"] == "product":
  212. return DocumentProduct(**document)
  213. elif document["meta"]["template"] == "checkout":
  214. return DocumentCheckout(**document)
  215. elif document["meta"]["template"] == "orders":
  216. return DocumentOrders(**document)
  217. elif document["meta"]["template"] == "email":
  218. return DocumentEmail(**document)
  219. except ValidationError as e:
  220. logger.error("pydantic validation errors =>")
  221. for e in e.errors():
  222. logger.error(f"error => {e}")
  223. raise FileNotFoundError
  224. def read_dir(
  225. repo_path: str,
  226. document_match: list[str],
  227. document_exclude: list[str],
  228. block_types: list[str],
  229. exclude_doc: str | None = None,
  230. tree: str | None = None,
  231. ):
  232. """Run get_files_by_extension with appropriate pattern matching to return list"""
  233. d = Path(repo_path).expanduser()
  234. pattern_matching = []
  235. [pattern_matching.append(f"*{ext}") for ext in document_match]
  236. ignore_list = []
  237. for file in document_exclude:
  238. ignore_list.append(Path(f"{repo_path}/{file}"))
  239. paths = get_files_by_extension(d, pattern_matching, ignore_list)
  240. paths = [p for p in paths if tree in str(p)]
  241. docs = []
  242. for p in paths:
  243. doc = parse_file(d, p, block_types)
  244. if doc.meta.path != exclude_doc:
  245. docs.append(doc)
  246. return docs
  247. def read_file(
  248. repo_path: str,
  249. filename: str,
  250. file_match: list[str],
  251. block_types: list[str],
  252. tree: str | None = None,
  253. ):
  254. """Helper function to read file from disk. Check if filepath's
  255. extension is allowed (by settings.toml) and filepath is tracked by
  256. git, then read its content.
  257. """
  258. filepath = None
  259. ext = file_match[0]
  260. if tree:
  261. p = f"{Path(repo_path)}/{tree}/{filename}"
  262. else:
  263. p = f"{Path(repo_path)}/{filename}"
  264. if Path(p).is_dir():
  265. file_path = f"{p}/index{ext}"
  266. if Path(file_path).is_file():
  267. filepath = file_path
  268. else:
  269. raise FileNotFoundError
  270. elif Path(f"{p}{ext}").is_file():
  271. filepath = f"{p}{ext}"
  272. if filepath:
  273. # get file path relative to the content repo
  274. # eg <path/to/repo/dir/file> => <dir>/<file>
  275. filename = str(Path(filepath).relative_to(repo_path))
  276. doc = parse_file(repo_path, Path(filepath), block_types)
  277. return doc
  278. else:
  279. raise HTTPException(status_code=404, detail="Nothing was found here.")