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/__init__.py +3 -5
- ssb_pubmd/__main__.py +7 -157
- ssb_pubmd/adapters/content_parser.py +185 -0
- ssb_pubmd/adapters/document_processor.py +149 -0
- ssb_pubmd/adapters/publish_client.py +124 -0
- ssb_pubmd/adapters/storage.py +42 -0
- ssb_pubmd/cli.py +78 -0
- ssb_pubmd/config.py +23 -0
- ssb_pubmd/domain/document_publisher.py +46 -0
- ssb_pubmd/notebook_client.py +130 -0
- {ssb_pubmd-0.0.19.dist-info → ssb_pubmd-0.1.1.dist-info}/METADATA +19 -22
- ssb_pubmd-0.1.1.dist-info/RECORD +16 -0
- {ssb_pubmd-0.0.19.dist-info → ssb_pubmd-0.1.1.dist-info}/WHEEL +1 -1
- ssb_pubmd-0.1.1.dist-info/entry_points.txt +3 -0
- ssb_pubmd/browser_request_handler.py +0 -85
- ssb_pubmd/constants.py +0 -22
- ssb_pubmd/jwt_request_handler.py +0 -99
- ssb_pubmd/markdown_syncer.py +0 -183
- ssb_pubmd/request_handler.py +0 -56
- ssb_pubmd-0.0.19.dist-info/RECORD +0 -13
- ssb_pubmd-0.0.19.dist-info/entry_points.txt +0 -3
- {ssb_pubmd-0.0.19.dist-info → ssb_pubmd-0.1.1.dist-info}/LICENSE +0 -0
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.
|
|
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 ::
|
|
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:
|
|
17
|
-
Requires-Dist:
|
|
18
|
-
Requires-Dist:
|
|
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:
|
|
21
|
-
Requires-Dist:
|
|
22
|
-
Requires-Dist:
|
|
23
|
-
Requires-Dist: requests (>=2.32.
|
|
24
|
-
Requires-Dist:
|
|
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
|
-
|
|
60
|
+
- TODO
|
|
60
61
|
|
|
61
|
-
|
|
62
|
+
## Requirements
|
|
62
63
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
64
|
+
- TODO
|
|
65
|
+
|
|
66
|
+
## Installation
|
|
66
67
|
|
|
67
|
-
|
|
68
|
+
You can install _SSB Pubmd_ via [pip] from [PyPI]:
|
|
68
69
|
|
|
69
70
|
```console
|
|
70
|
-
|
|
71
|
+
pip install ssb-pubmd
|
|
71
72
|
```
|
|
72
73
|
|
|
73
74
|
## Usage
|
|
74
75
|
|
|
75
|
-
|
|
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,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"
|
ssb_pubmd/jwt_request_handler.py
DELETED
|
@@ -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
|
-
)
|