ssb-pubmd 0.0.19__py3-none-any.whl → 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ssb_pubmd/cli.py ADDED
@@ -0,0 +1,78 @@
1
+ import subprocess
2
+ import sys
3
+ from pathlib import Path
4
+
5
+ from watchfiles import watch
6
+
7
+ from ssb_pubmd.adapters.content_parser import MimirContentParser
8
+ from ssb_pubmd.adapters.document_processor import PandocDocumentProcessor
9
+ from ssb_pubmd.adapters.publish_client import PublishClient
10
+ from ssb_pubmd.adapters.publish_client import get_publish_client
11
+ from ssb_pubmd.adapters.storage import LocalFileStorage
12
+ from ssb_pubmd.config import Config
13
+ from ssb_pubmd.domain.document_publisher import sync_document
14
+
15
+
16
+ def run_cli(system_arguments: list[str], config: Config) -> None:
17
+ match system_arguments:
18
+ case [_, "preview", file_path]:
19
+ _preview(file_path, config)
20
+ case _:
21
+ print("Usage: ssb-pubmd preview QUARTO_MARKDOWN_FILE")
22
+ sys.exit(1)
23
+
24
+
25
+ def _preview(file_path: str, config: Config) -> None:
26
+ if Path(file_path).suffix != ".qmd":
27
+ print("Only Quarto Markdown (.qmd) files are supported.")
28
+ sys.exit(1)
29
+ try:
30
+ print("Fetching labid token...")
31
+ publish_client = get_publish_client(config, use_dapla_token_client=True)
32
+ except Exception:
33
+ print("Failed to fetch labid token; using environment variable...")
34
+ publish_client = get_publish_client(config, use_dapla_token_client=False)
35
+
36
+ _sync_updated_file(file_path, publish_client)
37
+
38
+ print("Watching for file changes...")
39
+ for changes in watch(file_path):
40
+ _sync_updated_file(file_path, publish_client)
41
+
42
+ def _sync_updated_file(file_path: str, publish_client: PublishClient) -> None:
43
+ print("Syncing updated document...")
44
+ try:
45
+ preview_url = _sync_quarto_file(file_path, publish_client)
46
+ print(f"Content synced successfully. Preview URL: {preview_url}")
47
+ except Exception as e:
48
+ print(f"Error during sync: {e}")
49
+
50
+ def _sync_quarto_file(file_path: str, publish_client: PublishClient) -> str:
51
+ pandoc_document = _quarto_to_pandoc(file_path)
52
+ adapters = (
53
+ PandocDocumentProcessor(),
54
+ MimirContentParser(),
55
+ LocalFileStorage(project_folder=Path(file_path).parent),
56
+ publish_client,
57
+ )
58
+ return sync_document(pandoc_document, *adapters)
59
+
60
+
61
+ def _quarto_to_pandoc(file_path: str) -> str:
62
+ result = subprocess.run(
63
+ [
64
+ "quarto",
65
+ "render",
66
+ file_path,
67
+ "--to",
68
+ "json",
69
+ "-M",
70
+ "include:false",
71
+ "--output",
72
+ "-",
73
+ ],
74
+ text=True,
75
+ capture_output=True,
76
+ check=True,
77
+ )
78
+ return result.stdout
ssb_pubmd/config.py ADDED
@@ -0,0 +1,23 @@
1
+ """App configuration through environment variables."""
2
+
3
+ import os
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+
7
+ APP_NAME = "SSB_PUBMD"
8
+
9
+
10
+ @dataclass
11
+ class Config:
12
+ publish_base_url: str
13
+ publish_endpoint: str
14
+ publish_preview_base_path: str
15
+
16
+
17
+ def get_config(metadata_file_path: Path | None = None) -> Config:
18
+ """Get config from enviromnent variables."""
19
+ return Config(
20
+ publish_base_url=os.environ[f"{APP_NAME}_BASE_URL"],
21
+ publish_endpoint=os.environ[f"{APP_NAME}_ENDPOINT"],
22
+ publish_preview_base_path=os.environ[f"{APP_NAME}_PREVIEW_BASE_PATH"],
23
+ )
@@ -0,0 +1,46 @@
1
+ from ssb_pubmd.adapters.content_parser import ContentParser
2
+ from ssb_pubmd.adapters.document_processor import DocumentProcessor
3
+ from ssb_pubmd.adapters.publish_client import PublishClient
4
+ from ssb_pubmd.adapters.storage import Storage
5
+
6
+ USER_KEY_PREFIX = "user:"
7
+ DOCUMENT_KEY = "app:document"
8
+
9
+ def sync_document(
10
+ raw_document_content: str,
11
+ document_processor: DocumentProcessor,
12
+ content_parser: ContentParser,
13
+ storage: Storage,
14
+ publish_client: PublishClient,
15
+ ) -> str:
16
+ document_processor.load(raw_document_content)
17
+
18
+ document_metadata = document_processor.extract_metadata(target_key="ssb")
19
+ document_metadata["content_type"] = "article"
20
+
21
+ document_publish_path = storage.get(DOCUMENT_KEY).get("publish_path")
22
+ if not document_publish_path:
23
+ content = content_parser.parse(metadata=document_metadata, html=None)
24
+ response = publish_client.send_content(content)
25
+ storage.update(
26
+ DOCUMENT_KEY,
27
+ {"publish_id": response.publish_id, "publish_path": response.publish_path},
28
+ )
29
+ document_publish_path = response.publish_path
30
+
31
+ document_elements = document_processor.extract_elements(target_class="ssb")
32
+ for id_, html in document_elements:
33
+ key = USER_KEY_PREFIX + id_
34
+ metadata = storage.get(key) | {"publish_folder": document_publish_path}
35
+ component = content_parser.parse(metadata, html)
36
+ response = publish_client.send_content(component)
37
+ storage.update(key, {"publish_id": response.publish_id})
38
+ document_processor.replace_element(id_, response.publish_html)
39
+
40
+ article_metadata = document_metadata | {
41
+ "publish_id": storage.get(DOCUMENT_KEY).get("publish_id")
42
+ }
43
+ html = document_processor.extract_html()
44
+ article = content_parser.parse(metadata=article_metadata, html=html)
45
+ response = publish_client.send_content(article)
46
+ return response.publish_url
@@ -0,0 +1,130 @@
1
+ from pathlib import Path
2
+ from typing import Literal
3
+
4
+ import narwhals as nw
5
+ from narwhals.typing import IntoDataFrame
6
+
7
+ from ssb_pubmd.adapters.content_parser import Content
8
+ from ssb_pubmd.adapters.content_parser import ContentParser
9
+ from ssb_pubmd.adapters.content_parser import MimirContentParser
10
+ from ssb_pubmd.adapters.storage import LocalFileStorage
11
+ from ssb_pubmd.adapters.storage import Storage
12
+ from ssb_pubmd.domain.document_publisher import USER_KEY_PREFIX
13
+
14
+ STORAGE: Storage = LocalFileStorage(project_folder=Path.cwd())
15
+ CONTENT_PARSER: ContentParser = MimirContentParser()
16
+
17
+ class NotebookClientError(Exception): ...
18
+
19
+ def configure_factbox(
20
+ key: str,
21
+ title: str,
22
+ display_type: Literal["default", "sneakPeek", "aiIcon"] = "default",
23
+ ) -> None:
24
+ """Oppretter en faktaboks og printer en Markdown-snippet som kan limes inn i artikkelen (på en ny linje).
25
+
26
+ :param key: En unik nøkkel for innholdet.
27
+ :param title: Tittelen til faktaboksen.
28
+ :param display_type: Visning av faktaboksen.
29
+
30
+ Alternativer:
31
+
32
+ * "default": Bare tittel (standard)
33
+ * "sneakPeek": Tittel og litt av forklaringsteksten
34
+ * "aiIcon": Tittel og litt av forklaringsteksten + KI-ikon
35
+ """
36
+ metadata = {
37
+ "content_type": "factBox",
38
+ "title": title,
39
+ "display_type": display_type,
40
+ }
41
+
42
+ content = CONTENT_PARSER.parse(
43
+ metadata=metadata,
44
+ html=None,
45
+ )
46
+ _store_user_content(user_key=key, content=content)
47
+
48
+ md = _get_markdown_snippet(key, placeholder_text="Faktaboksens tekst skrives her.")
49
+ print(md)
50
+
51
+
52
+ def create_highchart(
53
+ key: str,
54
+ title: str,
55
+ dataframe: IntoDataFrame | None = None,
56
+ tbml: str | None = None,
57
+ graph_type: Literal["line", "pie", "column", "bar", "area", "barNegative"] = "line",
58
+ xlabel: str = "x",
59
+ ylabel: str = "y"
60
+ ) -> None:
61
+ """Oppretter et highchart og printer en Markdown-snippet som kan limes inn i artikkelen (på en ny linje).
62
+
63
+ Som datakilde er det nødvendig å spesifisere enten `dataframe` eller `tbml`.
64
+
65
+ :param key: En unik nøkkel for innholdet.
66
+ :param title: Tittelen til highchartet.
67
+ :param dataframe: En pandas, Polars eller PyArrow dataframe.
68
+ :param tbml: URL eller TBML-id.
69
+ :param graph_type: Graftype.
70
+
71
+ Alternativer:
72
+
73
+ * "line": Linje (standard)
74
+ * "pie": Kake
75
+ * "column": Stolpe
76
+ * "bar": Liggende stolpe
77
+ * "area": Areal
78
+ * "barNegative": Pyramide
79
+
80
+ :param xlabel: X-akse, tittel.
81
+ :param ylabel: Y-akse, tittel.
82
+ """
83
+ if dataframe is None and tbml is None:
84
+ raise NotebookClientError("Either 'dataframe' or 'tbml' must be specified.")
85
+ metadata = {
86
+ "content_type": "highchart",
87
+ "title": title,
88
+ "graph_type": graph_type,
89
+ "xlabel": xlabel,
90
+ "ylabel": ylabel
91
+ }
92
+ if tbml is not None:
93
+ metadata["tbml"] = tbml
94
+ html = _dataframe_to_html_table(dataframe) if dataframe is not None else None
95
+ content = CONTENT_PARSER.parse(metadata, html)
96
+ _store_user_content(user_key=key, content=content)
97
+
98
+ md = _get_markdown_snippet(key)
99
+ print(md)
100
+
101
+
102
+ def _store_user_content(user_key: str, content: Content) -> None:
103
+ key = USER_KEY_PREFIX + str(user_key)
104
+ STORAGE.update(key, content.to_dict())
105
+
106
+
107
+ def _dataframe_to_html_table(dataframe: IntoDataFrame) -> str:
108
+ df = nw.from_native(dataframe)
109
+ html = "<table><tbody>\n"
110
+
111
+ html += "<tr>\n"
112
+ for name in df.columns:
113
+ html += f" <td>{name}</td>\n"
114
+ html += "</tr>\n"
115
+
116
+ for row in df.iter_rows():
117
+ html += " <tr>\n"
118
+ for value in row:
119
+ html += f" <td>{value}</td>\n"
120
+ html += " </tr>\n"
121
+
122
+ html += "</tbody></table>"
123
+
124
+ return html
125
+
126
+
127
+ def _get_markdown_snippet(key: str, placeholder_text: str | None = None) -> str:
128
+ div_config = f"{{ #{key} .ssb }}"
129
+ div_content = f"\n{placeholder_text}\n\n" if placeholder_text is not None else ""
130
+ return f"::: {div_config}\n{div_content}:::"
@@ -1,27 +1,27 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: ssb-pubmd
3
- Version: 0.0.19
3
+ Version: 0.1.1
4
4
  Summary: SSB Pubmd
5
5
  License: MIT
6
6
  Author: Olav Landsverk
7
7
  Author-email: stud-oll@ssb.no
8
8
  Requires-Python: >=3.10,<4.0
9
- Classifier: Development Status :: 1 - Planning
9
+ Classifier: Development Status :: 3 - Alpha
10
10
  Classifier: License :: OSI Approved :: MIT License
11
11
  Classifier: Programming Language :: Python :: 3
12
12
  Classifier: Programming Language :: Python :: 3.10
13
13
  Classifier: Programming Language :: Python :: 3.11
14
14
  Classifier: Programming Language :: Python :: 3.12
15
15
  Classifier: Programming Language :: Python :: 3.13
16
- Requires-Dist: click (>=8.0.1)
17
- Requires-Dist: cryptography (>=45.0.4,<46.0.0)
18
- Requires-Dist: google-cloud-secret-manager (>=2.24.0,<3.0.0)
16
+ Requires-Dist: dapla-auth-client (>=1.2.5,<2.0.0)
17
+ Requires-Dist: ipynbname (>=2025.8.0.0,<2026.0.0.0)
18
+ Requires-Dist: narwhals (>=2.15.0,<3.0.0)
19
19
  Requires-Dist: nbformat (>=5.10.4,<6.0.0)
20
- Requires-Dist: platformdirs (>=4.3.8,<5.0.0)
21
- Requires-Dist: playwright (>=1.51.0,<2.0.0)
22
- Requires-Dist: pyjwt (>=2.10.1,<3.0.0)
23
- Requires-Dist: requests (>=2.32.3,<3.0.0)
24
- Requires-Dist: types-requests (>=2.32.0.20250306,<3.0.0.0)
20
+ Requires-Dist: nh3 (>=0.3.2,<0.4.0)
21
+ Requires-Dist: pandocfilters (>=1.5.1,<2.0.0)
22
+ Requires-Dist: pydantic (>=2.12.5,<3.0.0)
23
+ Requires-Dist: requests (>=2.32.4,<3.0.0)
24
+ Requires-Dist: watchfiles (>=1.1.1,<2.0.0)
25
25
  Project-URL: Changelog, https://github.com/statisticsnorway/ssb-pubmd/releases
26
26
  Project-URL: Documentation, https://statisticsnorway.github.io/ssb-pubmd
27
27
  Project-URL: Homepage, https://github.com/statisticsnorway/ssb-pubmd
@@ -55,28 +55,25 @@ Description-Content-Type: text/markdown
55
55
  [black]: https://github.com/psf/black
56
56
  [poetry]: https://python-poetry.org/
57
57
 
58
+ ## Features
58
59
 
59
- ## Installation
60
+ - TODO
60
61
 
61
- Installation with pip:
62
+ ## Requirements
62
63
 
63
- ```console
64
- pip install ssb-pubmd
65
- ```
64
+ - TODO
65
+
66
+ ## Installation
66
67
 
67
- If you need to create a logged-in browser context, you will also need to install a [Playwright browser](https://playwright.dev/python/docs/browsers#install-browsers):
68
+ You can install _SSB Pubmd_ via [pip] from [PyPI]:
68
69
 
69
70
  ```console
70
- playwright install --with-deps chromium
71
+ pip install ssb-pubmd
71
72
  ```
72
73
 
73
74
  ## Usage
74
75
 
75
- Run the main command in a terminal to see available subcommands with documentation:
76
-
77
- ```console
78
- pubmd
79
- ```
76
+ Please see the [Reference Guide] for details.
80
77
 
81
78
  ## Contributing
82
79
 
@@ -0,0 +1,16 @@
1
+ ssb_pubmd/__init__.py,sha256=GmZebzEEIJcwwYD6_J5irpY5-lGAKFr8lRrTGB_nAPA,170
2
+ ssb_pubmd/__main__.py,sha256=7Trn-DZkNbVn6s5J8FVg9JFtpFEHv-4VKZ34MVs23Cc,204
3
+ ssb_pubmd/adapters/content_parser.py,sha256=ExOULfwoFBVb9h2wH8m61fWFy192ZEIG2LI3fuFRpbE,5141
4
+ ssb_pubmd/adapters/document_processor.py,sha256=GN4FJmWcyiCdTBzWkyUReFGd7AME-F4Eq5N4JmarddQ,4271
5
+ ssb_pubmd/adapters/publish_client.py,sha256=mBRfOEEcrmKlDHJVsaqQR984XlJel9oBK4PGP-GhriE,3451
6
+ ssb_pubmd/adapters/storage.py,sha256=Dexfgw0csQ9wljC6lqf9kFmoM2CHdfMghm-qBrgdWjM,1227
7
+ ssb_pubmd/cli.py,sha256=dusmoCX3U6Lpc_uSqqgCRC0U0m8fRq48ExdsYBXRtr4,2555
8
+ ssb_pubmd/config.py,sha256=chnW-GC5Ie5kEcjVb-4_a5_Vq6glhATorDVIghc50SI,606
9
+ ssb_pubmd/domain/document_publisher.py,sha256=hgzJx9kGZJOVLrgFRg-JGAmNsasvHA9_BA1g3htcWrQ,1920
10
+ ssb_pubmd/notebook_client.py,sha256=MANyeyIdTuci8b0flD9e3jgFnEwx5dGo4zLMp39tkRE,4007
11
+ ssb_pubmd/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
+ ssb_pubmd-0.1.1.dist-info/LICENSE,sha256=tF5bnYv09fgH5ph9t1EpH1MGrVOGTQeswL4dzVeZ_ak,1073
13
+ ssb_pubmd-0.1.1.dist-info/METADATA,sha256=z_bEnr4p9KiPhscq4X5r8mHmn34Wambsacj7DLlp_bA,4101
14
+ ssb_pubmd-0.1.1.dist-info/WHEEL,sha256=XbeZDeTWKc1w7CSIyre5aMDU_-PohRwTQceYnisIYYY,88
15
+ ssb_pubmd-0.1.1.dist-info/entry_points.txt,sha256=o4oU99zbZNIBKGYWdgdEG6ev-62ZRWEJOe7EOjJaajk,53
16
+ ssb_pubmd-0.1.1.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: poetry-core 2.1.3
2
+ Generator: poetry-core 2.1.1
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ ssb-pubmd=ssb_pubmd.__main__:main
3
+
@@ -1,85 +0,0 @@
1
- from collections.abc import Iterator
2
- from contextlib import contextmanager
3
- from enum import Enum
4
- from pathlib import Path
5
-
6
- from playwright.sync_api import BrowserContext
7
- from playwright.sync_api import sync_playwright
8
-
9
- from ssb_pubmd.request_handler import Response
10
-
11
-
12
- class CreateContextMethod(Enum):
13
- """The method used to create the browser context.
14
-
15
- Can be either from a file containing the context data,
16
- or from a login popup window.
17
- """
18
-
19
- FROM_FILE = "from_file"
20
- FROM_LOGIN = "from_login"
21
-
22
-
23
- class BrowserRequestHandler:
24
- """This class is used to create a logged in browser context from which to send requests."""
25
-
26
- def __init__(self, context_file_path: Path, login_url: str) -> None:
27
- """Initializes an empty browser context object."""
28
- self._context_file_path: Path = context_file_path
29
- self._login_url: str = login_url
30
- self._context: BrowserContext | None = None
31
-
32
- @contextmanager
33
- def new_context(
34
- self, method: CreateContextMethod = CreateContextMethod.FROM_FILE
35
- ) -> Iterator[BrowserContext]:
36
- """Wrapper around playwright's context manager.
37
-
38
- The default is to create a new context from a file.
39
- If `from_file` is set False, a new context is created through a browser popup with user login,
40
- and the context is saved to a file.
41
- """
42
- with sync_playwright() as playwright:
43
- browser = playwright.chromium.launch(headless=False)
44
- match method:
45
- case CreateContextMethod.FROM_FILE:
46
- self._context = browser.new_context(
47
- storage_state=self._context_file_path
48
- )
49
- case CreateContextMethod.FROM_LOGIN:
50
- self._context = browser.new_context()
51
- login_page = self._context.new_page()
52
- login_page.goto(self._login_url)
53
- login_page.wait_for_event("close", timeout=0)
54
- self._context.storage_state(path=self._context_file_path)
55
- yield self._context
56
- self._context.close()
57
- browser.close()
58
-
59
- def send_request(
60
- self,
61
- url: str,
62
- headers: dict[str, str] | None = None,
63
- data: dict[str, str] | None = None,
64
- ) -> Response:
65
- """Sends a request to the specified url, optionally with headers and data, within the browser context."""
66
- if self._context is None:
67
- raise ValueError("Browser context has not been created.")
68
-
69
- api_response = self._context.request.post(
70
- url,
71
- data=data,
72
- )
73
-
74
- try:
75
- body = api_response.json()
76
- body = dict(body)
77
- except Exception:
78
- body = {}
79
-
80
- response = Response(
81
- status_code=api_response.status,
82
- body=body,
83
- )
84
-
85
- return response
ssb_pubmd/constants.py DELETED
@@ -1,22 +0,0 @@
1
- from enum import Enum
2
-
3
- from platformdirs import user_cache_path
4
- from platformdirs import user_config_path
5
- from platformdirs import user_data_path
6
-
7
- APP_NAME = "pubmd"
8
-
9
- METADATA_FILE = user_data_path(APP_NAME, ensure_exists=True) / "metadata.json"
10
- CACHE_FILE = user_cache_path(APP_NAME, ensure_exists=True) / "cache.json"
11
- CONFIG_FILE = user_config_path(APP_NAME, ensure_exists=True) / "config.json"
12
-
13
- CACHE_FILE.touch()
14
- CONFIG_FILE.touch()
15
- METADATA_FILE.touch()
16
-
17
-
18
- class ContentType(Enum):
19
- """Allowed content types."""
20
-
21
- MARKDOWN = ".md"
22
- NOTEBOOK = ".ipynb"
@@ -1,99 +0,0 @@
1
- import json
2
- from dataclasses import dataclass
3
- from datetime import datetime
4
-
5
- import jwt
6
- import requests
7
- from google.cloud import secretmanager
8
-
9
- from ssb_pubmd.request_handler import Response
10
-
11
- TYPE = "JWT"
12
- ALGORITHM = "RS256"
13
-
14
-
15
- @dataclass
16
- class SecretData:
17
- """Data class to hold private key and connected data."""
18
-
19
- private_key: str
20
- kid: str
21
- principal_key: str
22
-
23
-
24
- class JWTRequestHandler:
25
- """This class is used to send requests with a JSON Web Token (JWT) in the header."""
26
-
27
- def __init__(self, gc_secret_resource_name: str) -> None:
28
- """Initializes a JWT request handler object."""
29
- self._gc_secret_resource_name: str = gc_secret_resource_name
30
-
31
- def _private_key_from_secret_manager(self) -> SecretData:
32
- """Fetches the private key from Google Cloud Secret Manager."""
33
- client = secretmanager.SecretManagerServiceClient()
34
- print(f"Fetching secret from {self._gc_secret_resource_name}")
35
- response = client.access_secret_version(name=self._gc_secret_resource_name)
36
- raw_data = response.payload.data.decode("UTF-8")
37
- data = json.loads(raw_data)
38
- try:
39
- secret_data = SecretData(
40
- private_key=data["privateKey"],
41
- kid=data["kid"],
42
- principal_key=data["principalKey"],
43
- )
44
- except KeyError as e:
45
- raise ValueError(
46
- "The secret must be a JSON object with keys 'privateKey', 'kid' and 'principalKey'."
47
- ) from e
48
- return secret_data
49
-
50
- def _generate_token(self) -> str:
51
- secret_data = self._private_key_from_secret_manager()
52
-
53
- header = {
54
- "kid": secret_data.kid,
55
- "typ": TYPE,
56
- "alg": ALGORITHM,
57
- }
58
-
59
- iat = int(datetime.now().timestamp())
60
- exp = iat + 30
61
- payload = {
62
- "sub": secret_data.principal_key,
63
- "iat": iat,
64
- "exp": exp,
65
- }
66
-
67
- token = jwt.encode(
68
- payload, secret_data.private_key, algorithm=ALGORITHM, headers=header
69
- )
70
- return token
71
-
72
- def send_request(
73
- self,
74
- url: str,
75
- headers: dict[str, str] | None = None,
76
- data: dict[str, str] | None = None,
77
- ) -> Response:
78
- """Sends the request to the specified url with bearer token in header."""
79
- token = self._generate_token()
80
- headers = {
81
- "Authorization": f"Bearer {token}",
82
- "Content-Type": "application/json",
83
- }
84
- response = requests.post(
85
- url,
86
- headers=headers,
87
- json=data,
88
- )
89
-
90
- try:
91
- body = response.json()
92
- body = dict(body)
93
- except Exception:
94
- body = {}
95
-
96
- return Response(
97
- status_code=response.status_code,
98
- body=body,
99
- )