parser.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401
  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. logger.error(f"PARSE_FILE DEBUG: Parsing file: {input_path}")
  142. logger.error(f"PARSE_FILE DEBUG: Absolute path: {Path(input_path).absolute()}")
  143. logger.error(f"PARSE_FILE DEBUG: File exists: {Path(input_path).exists()}")
  144. # Read first few lines for debugging
  145. try:
  146. with open(input_path, 'r') as f:
  147. first_lines = [next(f) for _ in range(5)]
  148. logger.error(f"PARSE_FILE DEBUG: First 5 lines of file:")
  149. for i, line in enumerate(first_lines):
  150. logger.error(f" Line {i}: {line.strip()}")
  151. except Exception as e:
  152. logger.error(f"PARSE_FILE DEBUG: Could not read file: {e}")
  153. document = {
  154. "meta": {
  155. "type": "",
  156. "title": "",
  157. "template": "",
  158. "path": "",
  159. },
  160. "blocks": [],
  161. }
  162. try:
  163. p = Path(input_path)
  164. except KeyError:
  165. document["meta"]["path"] = ""
  166. data = input_path.read_text()
  167. block_marker = "\n---\n"
  168. blocks = data.split(block_marker)
  169. blocks = [re.sub(r"^---\n", "", block) for block in blocks]
  170. # set meta_path to default value
  171. meta_path = document["meta"]["path"]
  172. for block in blocks:
  173. # we check if current block is a potential
  174. # yaml block, then check if any other block
  175. # has a field type in the ones "allowed".
  176. # in case the block.type has no matches
  177. # or it fails the yaml parser, we take it
  178. # as a markdown block
  179. try:
  180. block_yaml = yaml.safe_load(block)
  181. if block_yaml is not None:
  182. if type(block_yaml) is dict:
  183. # check if "valid" YAML dict is actually
  184. # a long string with a colon in the middle;
  185. # somehow this trips up the YAML parser and
  186. # we need to take care of it ourselves
  187. for k in block_yaml.keys():
  188. if len(k.split()) > 1 and ":" in k:
  189. block_txt = make_block_md(block)
  190. document["blocks"].append(block_txt)
  191. if "type" in block_yaml:
  192. if block_yaml["type"] == "meta":
  193. document["meta"] = set_default_product_meta_fields(block_yaml)
  194. for k, v in block_yaml.items():
  195. document["meta"][k] = v
  196. # set meta-path
  197. if "template" in block_yaml:
  198. doc_path = Path(p).relative_to(repo_path).parent
  199. document["meta"]["path"] = str(doc_path)
  200. meta_path = document["meta"]["path"]
  201. elif (
  202. "type" in block_yaml
  203. and block_yaml["type"].lower() in block_types
  204. ):
  205. if block_yaml["type"] == "image":
  206. make_block_image(repo_path, meta_path, block_yaml)
  207. document["blocks"].append(block_yaml)
  208. else:
  209. block_md = make_block_md(block)
  210. document["blocks"].append(block_md)
  211. except yaml.YAMLError:
  212. block_md = make_block_md(block)
  213. document["blocks"].append(block_md)
  214. logger.error("=== DEBUG PARSED DOCUMENT ===")
  215. logger.error(f"File being parsed: {input_path}")
  216. logger.error(f"Document meta keys: {list(document['meta'].keys())}")
  217. logger.error(f"Document meta values: {document['meta']}")
  218. logger.error(f"Template found: '{document['meta'].get('template', 'NOT FOUND')}'")
  219. logger.error(f"Number of content blocks: {len(document['blocks'])}")
  220. # Check if template is missing
  221. if not document['meta'].get('template'):
  222. logger.error("CRITICAL: No template found in document!")
  223. if blocks and len(blocks) > 0:
  224. logger.error(f"First block preview: {blocks[0][:500]}")
  225. try:
  226. if document["meta"]["template"] == "site":
  227. return DocumentSite(**document)
  228. elif document["meta"]["template"] == "index":
  229. return DocumentIndex(**document)
  230. elif document["meta"]["template"] == "page":
  231. return DocumentPage(**document)
  232. elif document["meta"]["template"] == "product-index":
  233. return DocumentProductIndex(**document)
  234. elif document["meta"]["template"] == "product":
  235. return DocumentProduct(**document)
  236. elif document["meta"]["template"] == "checkout":
  237. return DocumentCheckout(**document)
  238. elif document["meta"]["template"] == "orders":
  239. return DocumentOrders(**document)
  240. elif document["meta"]["template"] == "email":
  241. return DocumentEmail(**document)
  242. except ValidationError as e:
  243. logger.error("pydantic validation errors =>")
  244. for e in e.errors():
  245. logger.error(f"error => {e}")
  246. # Add more context about what failed
  247. logger.error(f"Validation failed for template: '{document['meta'].get('template', 'NO TEMPLATE')}'")
  248. logger.error(f"Document structure had keys: {list(document.keys())}")
  249. if 'meta' in document:
  250. logger.error(f"Meta had keys: {list(document['meta'].keys())}")
  251. raise FileNotFoundError
  252. def read_dir(
  253. repo_path: str,
  254. document_match: list[str],
  255. document_exclude: list[str],
  256. block_types: list[str],
  257. exclude_doc: str | None = None,
  258. tree: str | None = None,
  259. ):
  260. """Run get_files_by_extension with appropriate pattern matching to return list"""
  261. d = Path(repo_path).expanduser()
  262. pattern_matching = []
  263. [pattern_matching.append(f"*{ext}") for ext in document_match]
  264. ignore_list = []
  265. for file in document_exclude:
  266. ignore_list.append(Path(f"{repo_path}/{file}"))
  267. paths = get_files_by_extension(d, pattern_matching, ignore_list)
  268. paths = [p for p in paths if tree in str(p)]
  269. docs = []
  270. for p in paths:
  271. doc = parse_file(d, p, block_types)
  272. if doc.meta.path != exclude_doc:
  273. docs.append(doc)
  274. return docs
  275. def read_file(
  276. repo_path: str,
  277. filename: str,
  278. file_match: list[str],
  279. block_types: list[str],
  280. tree: str | None = None,
  281. ):
  282. """Helper function to read file from disk. Check if filepath's
  283. extension is allowed (by settings.toml) and filepath is tracked by
  284. git, then read its content.
  285. """
  286. filepath = None
  287. ext = file_match[0]
  288. if tree:
  289. p = f"{Path(repo_path)}/{tree}/{filename}"
  290. else:
  291. p = f"{Path(repo_path)}/{filename}"
  292. logger.error(f"READ_FILE DEBUG: Looking for file at path: {p}")
  293. logger.error(f"READ_FILE DEBUG: tree={tree}, filename={filename}, ext={ext}")
  294. if Path(p).is_dir():
  295. file_path = f"{p}/index{ext}"
  296. logger.error(f"READ_FILE DEBUG: Path is directory, trying: {file_path}")
  297. if Path(file_path).is_file():
  298. filepath = file_path
  299. logger.error(f"READ_FILE DEBUG: Found directory index: {filepath}")
  300. else:
  301. logger.error(f"READ_FILE DEBUG: Directory index not found: {file_path}")
  302. raise FileNotFoundError
  303. elif Path(f"{p}{ext}").is_file():
  304. filepath = f"{p}{ext}"
  305. logger.error(f"READ_FILE DEBUG: Found file directly: {filepath}")
  306. if filepath:
  307. logger.error(f"READ_FILE DEBUG: Reading file: {filepath}")
  308. logger.error(f"READ_FILE DEBUG: File exists: {Path(filepath).exists()}")
  309. logger.error(f"READ_FILE DEBUG: File size: {Path(filepath).stat().st_size if Path(filepath).exists() else 'N/A'}")
  310. # get file path relative to the content repo
  311. # eg <path/to/repo/dir/file> => <dir>/<file>
  312. filename = str(Path(filepath).relative_to(repo_path))
  313. doc = parse_file(repo_path, Path(filepath), block_types)
  314. return doc
  315. else:
  316. logger.error(f"READ_FILE DEBUG: No file found at: {p}{ext}")
  317. raise HTTPException(status_code=404, detail="Nothing was found here.")