ssb-pubmd 0.0.15__tar.gz → 0.0.17__tar.gz
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-0.0.15 → ssb_pubmd-0.0.17}/PKG-INFO +4 -1
- {ssb_pubmd-0.0.15 → ssb_pubmd-0.0.17}/pyproject.toml +4 -1
- ssb_pubmd-0.0.17/src/ssb_pubmd/__main__.py +161 -0
- {ssb_pubmd-0.0.15 → ssb_pubmd-0.0.17}/src/ssb_pubmd/browser_request_handler.py +5 -5
- ssb_pubmd-0.0.17/src/ssb_pubmd/constants.py +22 -0
- ssb_pubmd-0.0.17/src/ssb_pubmd/jwt_request_handler.py +99 -0
- {ssb_pubmd-0.0.15 → ssb_pubmd-0.0.17}/src/ssb_pubmd/markdown_syncer.py +70 -114
- ssb_pubmd-0.0.17/src/ssb_pubmd/request_handler.py +56 -0
- ssb_pubmd-0.0.15/src/ssb_pubmd/__main__.py +0 -107
- {ssb_pubmd-0.0.15 → ssb_pubmd-0.0.17}/LICENSE +0 -0
- {ssb_pubmd-0.0.15 → ssb_pubmd-0.0.17}/README.md +0 -0
- {ssb_pubmd-0.0.15 → ssb_pubmd-0.0.17}/src/ssb_pubmd/__init__.py +0 -0
- {ssb_pubmd-0.0.15 → ssb_pubmd-0.0.17}/src/ssb_pubmd/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: ssb-pubmd
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.17
|
|
4
4
|
Summary: SSB Pubmd
|
|
5
5
|
License: MIT
|
|
6
6
|
Author: Olav Landsverk
|
|
@@ -14,8 +14,11 @@ Classifier: Programming Language :: Python :: 3.11
|
|
|
14
14
|
Classifier: Programming Language :: Python :: 3.12
|
|
15
15
|
Classifier: Programming Language :: Python :: 3.13
|
|
16
16
|
Requires-Dist: click (>=8.0.1)
|
|
17
|
+
Requires-Dist: google-cloud-secret-manager (>=2.24.0,<3.0.0)
|
|
17
18
|
Requires-Dist: nbformat (>=5.10.4,<6.0.0)
|
|
19
|
+
Requires-Dist: platformdirs (>=4.3.8,<5.0.0)
|
|
18
20
|
Requires-Dist: playwright (>=1.51.0,<2.0.0)
|
|
21
|
+
Requires-Dist: pyjwt (>=2.10.1,<3.0.0)
|
|
19
22
|
Requires-Dist: requests (>=2.32.3,<3.0.0)
|
|
20
23
|
Requires-Dist: types-requests (>=2.32.0.20250306,<3.0.0.0)
|
|
21
24
|
Project-URL: Changelog, https://github.com/statisticsnorway/ssb-pubmd/releases
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[tool.poetry]
|
|
2
2
|
name = "ssb-pubmd"
|
|
3
|
-
version = "0.0.
|
|
3
|
+
version = "0.0.17"
|
|
4
4
|
description = "SSB Pubmd"
|
|
5
5
|
authors = ["Olav Landsverk <stud-oll@ssb.no>"]
|
|
6
6
|
license = "MIT"
|
|
@@ -20,6 +20,9 @@ nbformat = "^5.10.4"
|
|
|
20
20
|
requests = "^2.32.3"
|
|
21
21
|
types-requests = "^2.32.0.20250306"
|
|
22
22
|
playwright = "^1.51.0"
|
|
23
|
+
platformdirs = "^4.3.8"
|
|
24
|
+
pyjwt = "^2.10.1"
|
|
25
|
+
google-cloud-secret-manager = "^2.24.0"
|
|
23
26
|
|
|
24
27
|
[tool.poetry.group.dev.dependencies]
|
|
25
28
|
pygments = ">=2.10.0"
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Command-line interface."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from enum import Enum
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import cast
|
|
8
|
+
from urllib.parse import urlparse
|
|
9
|
+
|
|
10
|
+
import click
|
|
11
|
+
|
|
12
|
+
from ssb_pubmd.browser_request_handler import BrowserRequestHandler
|
|
13
|
+
from ssb_pubmd.browser_request_handler import CreateContextMethod
|
|
14
|
+
from ssb_pubmd.constants import APP_NAME
|
|
15
|
+
from ssb_pubmd.constants import CACHE_FILE
|
|
16
|
+
from ssb_pubmd.constants import CONFIG_FILE
|
|
17
|
+
from ssb_pubmd.jwt_request_handler import JWTRequestHandler
|
|
18
|
+
from ssb_pubmd.markdown_syncer import MarkdownSyncer
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ConfigKey(Enum):
|
|
22
|
+
"""Configuration keys for the application."""
|
|
23
|
+
|
|
24
|
+
BASE_URL = "base_url"
|
|
25
|
+
LOGIN_URL = "login_url"
|
|
26
|
+
POST_URL = "post_url"
|
|
27
|
+
AUTH_METHOD = "auth_method"
|
|
28
|
+
GC_SECRET_RESOURCE_NAME = "gc_secret_resource_name"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def get_config_value(config_key: ConfigKey) -> str:
|
|
32
|
+
"""Load a configuration value, with precedence environment variable > config file."""
|
|
33
|
+
key = config_key.value
|
|
34
|
+
|
|
35
|
+
def get_env_value() -> str:
|
|
36
|
+
"""Get value from environment variable, by uppercasing the key and adding prefix."""
|
|
37
|
+
prefix = f"{APP_NAME.upper()}_"
|
|
38
|
+
value = os.getenv(f"{prefix}{key.upper()}")
|
|
39
|
+
|
|
40
|
+
return cast(str, value)
|
|
41
|
+
|
|
42
|
+
def get_config_file_value() -> str:
|
|
43
|
+
"""Get value from the config file."""
|
|
44
|
+
with open(CONFIG_FILE) as f:
|
|
45
|
+
data = json.load(f)
|
|
46
|
+
|
|
47
|
+
value = data.get(key)
|
|
48
|
+
|
|
49
|
+
return cast(str, value)
|
|
50
|
+
|
|
51
|
+
return get_env_value() or get_config_file_value()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def set_config_value(config_key: ConfigKey, value: str) -> None:
|
|
55
|
+
"""Set a configuration value in the config file."""
|
|
56
|
+
key = config_key.value
|
|
57
|
+
|
|
58
|
+
with open(CONFIG_FILE) as f:
|
|
59
|
+
try:
|
|
60
|
+
data = json.load(f)
|
|
61
|
+
except json.JSONDecodeError:
|
|
62
|
+
data = {}
|
|
63
|
+
|
|
64
|
+
data[key] = value
|
|
65
|
+
|
|
66
|
+
with open(CONFIG_FILE, "w") as f:
|
|
67
|
+
json.dump(data, f, indent=4)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@click.group()
|
|
71
|
+
def cli() -> None:
|
|
72
|
+
"""Pubmd - a tool to sync markdown and notebook files to a CMS."""
|
|
73
|
+
pass
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@cli.command()
|
|
77
|
+
def settings() -> None:
|
|
78
|
+
"""Set the login and post URL for the CMS."""
|
|
79
|
+
login_url = click.prompt("Enter the login URL", type=str)
|
|
80
|
+
set_config_value(ConfigKey.LOGIN_URL, login_url)
|
|
81
|
+
|
|
82
|
+
post_url = click.prompt("Enter the post URL", type=str)
|
|
83
|
+
set_config_value(ConfigKey.POST_URL, post_url)
|
|
84
|
+
|
|
85
|
+
click.echo(f"\nSettings stored in:\n{click.format_filename(CONFIG_FILE)}")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@cli.command()
|
|
89
|
+
def login() -> None:
|
|
90
|
+
"""Log in to the CMS application."""
|
|
91
|
+
login_url = get_config_value(ConfigKey.LOGIN_URL)
|
|
92
|
+
request_handler = BrowserRequestHandler(CACHE_FILE, login_url)
|
|
93
|
+
|
|
94
|
+
method = CreateContextMethod.FROM_LOGIN
|
|
95
|
+
with request_handler.new_context(method=method):
|
|
96
|
+
click.echo("Logging in...")
|
|
97
|
+
|
|
98
|
+
click.echo(f"\nBrowser context stored in:\n{CACHE_FILE}")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def sync_with_browser(content_file_path: str) -> None:
|
|
102
|
+
"""Sync a markdown or notebook file to the CMS."""
|
|
103
|
+
login_url = get_config_value(ConfigKey.LOGIN_URL)
|
|
104
|
+
request_handler = BrowserRequestHandler(CACHE_FILE, login_url)
|
|
105
|
+
|
|
106
|
+
with request_handler.new_context() as context:
|
|
107
|
+
post_url = get_config_value(ConfigKey.POST_URL)
|
|
108
|
+
syncer = MarkdownSyncer(post_url, request_handler)
|
|
109
|
+
|
|
110
|
+
syncer.content_file_path = Path(content_file_path)
|
|
111
|
+
response = syncer.sync_content()
|
|
112
|
+
|
|
113
|
+
click.echo("Content synced successfully.")
|
|
114
|
+
|
|
115
|
+
path = response.body.get("previewPath", "")
|
|
116
|
+
preview = urlparse(login_url)._replace(path=path).geturl()
|
|
117
|
+
if preview:
|
|
118
|
+
page = context.new_page()
|
|
119
|
+
page.goto(preview)
|
|
120
|
+
click.echo(f"Preview opened in new browser: {preview}")
|
|
121
|
+
click.echo("Close the browser tab to finish.")
|
|
122
|
+
page.wait_for_event("close", timeout=0)
|
|
123
|
+
else:
|
|
124
|
+
click.echo("No preview url found in the response.")
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def sync_with_jwt(content_file_path: str) -> None:
|
|
128
|
+
"""Sync a markdown or notebook file to the CMS."""
|
|
129
|
+
gc_secret_resource_name = get_config_value(ConfigKey.GC_SECRET_RESOURCE_NAME)
|
|
130
|
+
request_handler = JWTRequestHandler(gc_secret_resource_name)
|
|
131
|
+
|
|
132
|
+
post_url = get_config_value(ConfigKey.POST_URL)
|
|
133
|
+
syncer = MarkdownSyncer(post_url, request_handler)
|
|
134
|
+
|
|
135
|
+
syncer.content_file_path = Path(content_file_path)
|
|
136
|
+
response = syncer.sync_content()
|
|
137
|
+
|
|
138
|
+
click.echo("Content synced successfully.")
|
|
139
|
+
|
|
140
|
+
preview_path = response.body.get("previewPath", "")
|
|
141
|
+
if preview_path:
|
|
142
|
+
base_url = get_config_value(ConfigKey.BASE_URL)
|
|
143
|
+
preview = urlparse(base_url)._replace(path=preview_path).geturl()
|
|
144
|
+
click.echo(f"Preview url found in the response: {preview}")
|
|
145
|
+
else:
|
|
146
|
+
click.echo("No preview url found in the response.")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@cli.command()
|
|
150
|
+
@click.argument("content_file_path", type=click.Path())
|
|
151
|
+
def sync(content_file_path: str) -> None:
|
|
152
|
+
"""Sync a markdown or notebook file to the CMS."""
|
|
153
|
+
auth_method = get_config_value(ConfigKey.AUTH_METHOD)
|
|
154
|
+
if auth_method == "browser":
|
|
155
|
+
sync_with_browser(content_file_path)
|
|
156
|
+
else:
|
|
157
|
+
sync_with_jwt(content_file_path)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
if __name__ == "__main__":
|
|
161
|
+
cli()
|
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
import os
|
|
2
1
|
from collections.abc import Iterator
|
|
3
2
|
from contextlib import contextmanager
|
|
4
3
|
from enum import Enum
|
|
4
|
+
from pathlib import Path
|
|
5
5
|
|
|
6
6
|
from playwright.sync_api import BrowserContext
|
|
7
7
|
from playwright.sync_api import sync_playwright
|
|
8
8
|
|
|
9
|
-
from .
|
|
9
|
+
from ssb_pubmd.request_handler import Response
|
|
10
10
|
|
|
11
11
|
|
|
12
12
|
class CreateContextMethod(Enum):
|
|
@@ -23,9 +23,9 @@ class CreateContextMethod(Enum):
|
|
|
23
23
|
class BrowserRequestHandler:
|
|
24
24
|
"""This class is used to create a logged in browser context from which to send requests."""
|
|
25
25
|
|
|
26
|
-
def __init__(self, context_file_path:
|
|
26
|
+
def __init__(self, context_file_path: Path, login_url: str) -> None:
|
|
27
27
|
"""Initializes an empty browser context object."""
|
|
28
|
-
self._context_file_path:
|
|
28
|
+
self._context_file_path: Path = context_file_path
|
|
29
29
|
self._login_url: str = login_url
|
|
30
30
|
self._context: BrowserContext | None = None
|
|
31
31
|
|
|
@@ -75,7 +75,7 @@ class BrowserRequestHandler:
|
|
|
75
75
|
body = api_response.json()
|
|
76
76
|
body = dict(body)
|
|
77
77
|
except Exception:
|
|
78
|
-
body =
|
|
78
|
+
body = {}
|
|
79
79
|
|
|
80
80
|
response = Response(
|
|
81
81
|
status_code=api_response.status,
|
|
@@ -0,0 +1,22 @@
|
|
|
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"
|
|
@@ -0,0 +1,99 @@
|
|
|
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
|
+
)
|
|
@@ -1,79 +1,17 @@
|
|
|
1
1
|
import json
|
|
2
|
-
import
|
|
3
|
-
from dataclasses import dataclass
|
|
4
|
-
from enum import Enum
|
|
5
|
-
from typing import Any
|
|
6
|
-
from typing import Protocol
|
|
2
|
+
from pathlib import Path
|
|
7
3
|
|
|
8
4
|
import nbformat
|
|
9
|
-
import requests
|
|
10
5
|
from nbformat import NotebookNode
|
|
11
6
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
status_code: int
|
|
18
|
-
body: dict[str, Any] | None = None
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
class RequestHandler(Protocol):
|
|
22
|
-
"""Interface for the handling how the request is sent.
|
|
23
|
-
|
|
24
|
-
Implementing classes may handle authentication, sessions, etc.
|
|
25
|
-
"""
|
|
26
|
-
|
|
27
|
-
def send_request(
|
|
28
|
-
self,
|
|
29
|
-
url: str,
|
|
30
|
-
headers: dict[str, str] | None = None,
|
|
31
|
-
data: dict[str, str] | None = None,
|
|
32
|
-
) -> Response:
|
|
33
|
-
"""Sends the request to the specified url, optionally with headers and data, and returns the response."""
|
|
34
|
-
...
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
class BasicRequestHandler:
|
|
38
|
-
"""Basic, unauthenticated request handler."""
|
|
39
|
-
|
|
40
|
-
def __init__(self) -> None:
|
|
41
|
-
"""Initializes the basic request handler."""
|
|
42
|
-
pass
|
|
43
|
-
|
|
44
|
-
def send_request(
|
|
45
|
-
self,
|
|
46
|
-
url: str,
|
|
47
|
-
headers: dict[str, str] | None = None,
|
|
48
|
-
data: dict[str, str] | None = None,
|
|
49
|
-
) -> Response:
|
|
50
|
-
"""Sends the request to the specified url without any headers."""
|
|
51
|
-
response = requests.post(
|
|
52
|
-
url,
|
|
53
|
-
data=data,
|
|
54
|
-
)
|
|
55
|
-
|
|
56
|
-
try:
|
|
57
|
-
body = response.json()
|
|
58
|
-
body = dict(body)
|
|
59
|
-
except Exception:
|
|
60
|
-
body = None
|
|
61
|
-
|
|
62
|
-
return Response(
|
|
63
|
-
status_code=response.status_code,
|
|
64
|
-
body=body,
|
|
65
|
-
)
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
class FileType(Enum):
|
|
69
|
-
"""File extensions for markdown and notebook files."""
|
|
70
|
-
|
|
71
|
-
MARKDOWN = ".md"
|
|
72
|
-
NOTEBOOK = ".ipynb"
|
|
7
|
+
from ssb_pubmd.constants import METADATA_FILE
|
|
8
|
+
from ssb_pubmd.constants import ContentType
|
|
9
|
+
from ssb_pubmd.request_handler import RequestHandler
|
|
10
|
+
from ssb_pubmd.request_handler import Response
|
|
73
11
|
|
|
74
12
|
|
|
75
13
|
class MarkdownSyncer:
|
|
76
|
-
"""This class syncs a
|
|
14
|
+
"""This class syncs a content file to a CMS (Content Management System).
|
|
77
15
|
|
|
78
16
|
The CMS must have an endpoint that satisfies the following constraints:
|
|
79
17
|
|
|
@@ -91,68 +29,91 @@ class MarkdownSyncer:
|
|
|
91
29
|
|
|
92
30
|
ID_KEY = "_id"
|
|
93
31
|
|
|
94
|
-
def __init__(
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
post_url: str,
|
|
35
|
+
request_handler: RequestHandler,
|
|
36
|
+
metadata_file: Path = METADATA_FILE,
|
|
37
|
+
) -> None:
|
|
95
38
|
"""Creates a markdown syncer instance that connects to the CMS through the post url."""
|
|
96
39
|
self._post_url: str = post_url
|
|
97
40
|
self._request_handler: RequestHandler = request_handler
|
|
98
|
-
self._content_file_path:
|
|
99
|
-
self._content_file_type:
|
|
41
|
+
self._content_file_path: Path = Path()
|
|
42
|
+
self._content_file_type: ContentType = ContentType.MARKDOWN
|
|
43
|
+
self._metadata_file_path: Path = metadata_file
|
|
100
44
|
|
|
101
45
|
@property
|
|
102
|
-
def content_file_path(self) ->
|
|
103
|
-
"""Returns the path of the
|
|
46
|
+
def content_file_path(self) -> Path:
|
|
47
|
+
"""Returns the path of the content file."""
|
|
104
48
|
return self._content_file_path
|
|
105
49
|
|
|
106
50
|
@content_file_path.setter
|
|
107
|
-
def content_file_path(self,
|
|
108
|
-
"""Sets the path of the
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
if ext == e.value:
|
|
117
|
-
self._content_file_type = e
|
|
51
|
+
def content_file_path(self, path: Path) -> None:
|
|
52
|
+
"""Sets the path of the content file."""
|
|
53
|
+
if not path.is_file():
|
|
54
|
+
raise FileNotFoundError(f"The file {path} does not exist.")
|
|
55
|
+
|
|
56
|
+
ext = path.suffix.lower()
|
|
57
|
+
for t in ContentType:
|
|
58
|
+
if ext == t.value:
|
|
59
|
+
self._content_file_type = t
|
|
118
60
|
break
|
|
119
61
|
else:
|
|
62
|
+
allowed_extensions = [t.value for t in ContentType]
|
|
63
|
+
sep = ", "
|
|
120
64
|
raise ValueError(
|
|
121
|
-
f"The file
|
|
65
|
+
f"The file {path} has extension {ext}, but should be one of: {sep.join(allowed_extensions)}."
|
|
122
66
|
)
|
|
123
67
|
|
|
124
|
-
self._content_file_path =
|
|
68
|
+
self._content_file_path = path
|
|
125
69
|
|
|
126
70
|
@property
|
|
127
71
|
def basename(self) -> str:
|
|
128
|
-
"""The name of the
|
|
129
|
-
|
|
130
|
-
return os.path.splitext(basename)[0]
|
|
131
|
-
|
|
132
|
-
@property
|
|
133
|
-
def data_path(self) -> str:
|
|
134
|
-
"""The absolute path of the file to store the data returned from the CMS."""
|
|
135
|
-
return os.path.splitext(self.content_file_path)[0] + "-PUBMD.json"
|
|
72
|
+
"""The name of the content file without extension."""
|
|
73
|
+
return self._content_file_path.stem
|
|
136
74
|
|
|
137
75
|
@property
|
|
138
76
|
def display_name(self) -> str:
|
|
139
77
|
"""Generate a display name for the content."""
|
|
140
78
|
return self.basename.replace("_", " ").title()
|
|
141
79
|
|
|
80
|
+
@property
|
|
81
|
+
def metadata_file_path(self) -> Path:
|
|
82
|
+
"""The path of the metadata file."""
|
|
83
|
+
return self._metadata_file_path
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def metadata_key(self) -> str:
|
|
87
|
+
"""The key that the content metadata will be stored under in the metadata file."""
|
|
88
|
+
return str(self._content_file_path.absolute())
|
|
89
|
+
|
|
142
90
|
def _save_content_id(self, content_id: str) -> None:
|
|
143
|
-
"""Saves the content id to the
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
91
|
+
"""Saves the content id to the metadata file."""
|
|
92
|
+
with open(self._metadata_file_path) as f:
|
|
93
|
+
try:
|
|
94
|
+
data = json.load(f)
|
|
95
|
+
except json.JSONDecodeError:
|
|
96
|
+
data = {}
|
|
97
|
+
|
|
98
|
+
data[self.metadata_key] = {
|
|
99
|
+
self.ID_KEY: content_id,
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
with open(self._metadata_file_path, "w") as f:
|
|
103
|
+
json.dump(data, f, indent=4)
|
|
147
104
|
|
|
148
105
|
def _get_content_id(self) -> str:
|
|
149
|
-
"""Fetches the content id from the
|
|
150
|
-
|
|
106
|
+
"""Fetches the content id from the metadata file if it exists, otherwise an empty string."""
|
|
107
|
+
with open(self._metadata_file_path) as f:
|
|
108
|
+
try:
|
|
109
|
+
data = json.load(f)
|
|
110
|
+
except json.JSONDecodeError:
|
|
111
|
+
data = {}
|
|
112
|
+
|
|
113
|
+
metadata: dict[str, str] = data.get(self.metadata_key, {})
|
|
114
|
+
|
|
115
|
+
content_id = metadata.get(self.ID_KEY, "")
|
|
151
116
|
|
|
152
|
-
filename = self.data_path
|
|
153
|
-
if os.path.exists(filename):
|
|
154
|
-
with open(filename) as file:
|
|
155
|
-
content_id = json.load(file)[self.ID_KEY]
|
|
156
117
|
return content_id
|
|
157
118
|
|
|
158
119
|
def _read_notebook(self) -> NotebookNode:
|
|
@@ -181,9 +142,9 @@ class MarkdownSyncer:
|
|
|
181
142
|
def _get_content(self) -> str:
|
|
182
143
|
content = ""
|
|
183
144
|
match self._content_file_type:
|
|
184
|
-
case
|
|
145
|
+
case ContentType.MARKDOWN:
|
|
185
146
|
content = self._get_content_from_markdown_file()
|
|
186
|
-
case
|
|
147
|
+
case ContentType.NOTEBOOK:
|
|
187
148
|
content = self._get_content_from_notebook_file()
|
|
188
149
|
return content
|
|
189
150
|
|
|
@@ -195,7 +156,7 @@ class MarkdownSyncer:
|
|
|
195
156
|
"markdown": self._get_content(),
|
|
196
157
|
}
|
|
197
158
|
|
|
198
|
-
def
|
|
159
|
+
def sync_content(self) -> Response:
|
|
199
160
|
"""Sends the request to the CMS endpoint and returns the content id from the response."""
|
|
200
161
|
response = self._request_handler.send_request(
|
|
201
162
|
url=self._post_url, data=self._request_data()
|
|
@@ -217,11 +178,6 @@ class MarkdownSyncer:
|
|
|
217
178
|
f"Response from the CMS does not contain a valid content id: {result}"
|
|
218
179
|
)
|
|
219
180
|
content_id: str = result
|
|
220
|
-
|
|
221
|
-
return content_id
|
|
222
|
-
|
|
223
|
-
def sync_content(self) -> str:
|
|
224
|
-
"""Sends the markdown content to the CMS endpoint and stores the id from the response."""
|
|
225
|
-
content_id = self._send_request()
|
|
226
181
|
self._save_content_id(content_id)
|
|
227
|
-
|
|
182
|
+
|
|
183
|
+
return response
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import Any
|
|
3
|
+
from typing import Protocol
|
|
4
|
+
|
|
5
|
+
import requests
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class Response:
|
|
10
|
+
"""The response object used in the package."""
|
|
11
|
+
|
|
12
|
+
status_code: int
|
|
13
|
+
body: dict[str, Any]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class RequestHandler(Protocol):
|
|
17
|
+
"""Interface for handling how a request are sent.
|
|
18
|
+
|
|
19
|
+
Implementing classes may handle authentication, sessions, etc.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def send_request(
|
|
23
|
+
self,
|
|
24
|
+
url: str,
|
|
25
|
+
headers: dict[str, str] | None = None,
|
|
26
|
+
data: dict[str, str] | None = None,
|
|
27
|
+
) -> Response:
|
|
28
|
+
"""Sends the request to the specified url, optionally with headers and data, and returns the response."""
|
|
29
|
+
...
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class BasicRequestHandler:
|
|
33
|
+
"""Basic, unauthenticated request handler."""
|
|
34
|
+
|
|
35
|
+
def send_request(
|
|
36
|
+
self,
|
|
37
|
+
url: str,
|
|
38
|
+
headers: dict[str, str] | None = None,
|
|
39
|
+
data: dict[str, str] | None = None,
|
|
40
|
+
) -> Response:
|
|
41
|
+
"""Sends the request to the specified url without any headers."""
|
|
42
|
+
response = requests.post(
|
|
43
|
+
url,
|
|
44
|
+
data=data,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
body = response.json()
|
|
49
|
+
body = dict(body)
|
|
50
|
+
except Exception:
|
|
51
|
+
body = {}
|
|
52
|
+
|
|
53
|
+
return Response(
|
|
54
|
+
status_code=response.status_code,
|
|
55
|
+
body=body,
|
|
56
|
+
)
|
|
@@ -1,107 +0,0 @@
|
|
|
1
|
-
"""Command-line interface."""
|
|
2
|
-
|
|
3
|
-
import json
|
|
4
|
-
import os
|
|
5
|
-
from dataclasses import asdict
|
|
6
|
-
from dataclasses import dataclass
|
|
7
|
-
|
|
8
|
-
import click
|
|
9
|
-
|
|
10
|
-
from ssb_pubmd.browser_request_handler import BrowserRequestHandler
|
|
11
|
-
from ssb_pubmd.browser_request_handler import CreateContextMethod
|
|
12
|
-
from ssb_pubmd.markdown_syncer import MarkdownSyncer
|
|
13
|
-
|
|
14
|
-
BASE_DIR = os.path.join(os.path.expanduser("~"), ".pubmd")
|
|
15
|
-
os.makedirs(BASE_DIR, exist_ok=True)
|
|
16
|
-
|
|
17
|
-
CONFIG_PATH = os.path.join(BASE_DIR, "config.json")
|
|
18
|
-
BROWSER_CONTEXT_PATH = os.path.join(BASE_DIR, "browser_context.json")
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
@dataclass
|
|
22
|
-
class Config:
|
|
23
|
-
"""Handles the user configuration."""
|
|
24
|
-
|
|
25
|
-
login_url: str = ""
|
|
26
|
-
post_url: str = ""
|
|
27
|
-
|
|
28
|
-
@classmethod
|
|
29
|
-
def load(cls, path: str) -> "Config":
|
|
30
|
-
"""Loads the configuration from a file."""
|
|
31
|
-
with open(path) as f:
|
|
32
|
-
data = json.load(f)
|
|
33
|
-
return cls(**data)
|
|
34
|
-
|
|
35
|
-
def save(self, path: str) -> None:
|
|
36
|
-
"""Saves the configuration to a file."""
|
|
37
|
-
with open(path, "w") as f:
|
|
38
|
-
json.dump(asdict(self), f, indent=4)
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
@click.command()
|
|
42
|
-
def config() -> None:
|
|
43
|
-
"""Set the login and post URL for the CMS."""
|
|
44
|
-
config_file = CONFIG_PATH
|
|
45
|
-
|
|
46
|
-
login_url = click.prompt("Enter the login URL", type=str)
|
|
47
|
-
post_url = click.prompt("Enter the post URL", type=str)
|
|
48
|
-
|
|
49
|
-
config = Config(login_url=login_url, post_url=post_url)
|
|
50
|
-
|
|
51
|
-
config.save(config_file)
|
|
52
|
-
|
|
53
|
-
click.echo(f"\nConfiguration stored in:\n{config_file}")
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
@click.command()
|
|
57
|
-
def login() -> None:
|
|
58
|
-
"""Log in to the CMS application."""
|
|
59
|
-
config_file = CONFIG_PATH
|
|
60
|
-
browser_context_file = BROWSER_CONTEXT_PATH
|
|
61
|
-
|
|
62
|
-
config = Config.load(config_file)
|
|
63
|
-
request_handler = BrowserRequestHandler(browser_context_file, config.login_url)
|
|
64
|
-
|
|
65
|
-
method = CreateContextMethod.FROM_LOGIN
|
|
66
|
-
with request_handler.new_context(method=method):
|
|
67
|
-
click.echo("Logging in...")
|
|
68
|
-
|
|
69
|
-
click.echo(f"\nBrowser context stored in:\n{browser_context_file}")
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
@click.command()
|
|
73
|
-
@click.argument("content_file_path", type=click.Path())
|
|
74
|
-
def sync(content_file_path: str) -> None:
|
|
75
|
-
"""Sync a markdown or notebook file to the CMS."""
|
|
76
|
-
config_file = CONFIG_PATH
|
|
77
|
-
browser_context_file = BROWSER_CONTEXT_PATH
|
|
78
|
-
|
|
79
|
-
request_handler = BrowserRequestHandler(browser_context_file)
|
|
80
|
-
|
|
81
|
-
with request_handler.new_context():
|
|
82
|
-
config = Config.load(config_file)
|
|
83
|
-
syncer = MarkdownSyncer(
|
|
84
|
-
post_url=config.post_url, request_handler=request_handler
|
|
85
|
-
)
|
|
86
|
-
|
|
87
|
-
syncer.content_file_path = content_file_path
|
|
88
|
-
content_id = syncer.sync_content()
|
|
89
|
-
|
|
90
|
-
click.echo(
|
|
91
|
-
f"File '{click.format_filename(browser_context_file)}' synced to CMS with content ID: {content_id}."
|
|
92
|
-
)
|
|
93
|
-
click.echo(f"Response data saved to '{click.format_filename(syncer.data_path)}'.")
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
@click.group()
|
|
97
|
-
def cli() -> None:
|
|
98
|
-
"""Pubmd - a tool to sync markdown and notebook files to a CMS."""
|
|
99
|
-
pass
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
cli.add_command(config)
|
|
103
|
-
cli.add_command(login)
|
|
104
|
-
cli.add_command(sync)
|
|
105
|
-
|
|
106
|
-
if __name__ == "__main__":
|
|
107
|
-
cli()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|