ssb-pubmd 0.0.16__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: ssb-pubmd
3
- Version: 0.0.16
3
+ Version: 0.0.17
4
4
  Summary: SSB Pubmd
5
5
  License: MIT
6
6
  Author: Olav Landsverk
@@ -14,9 +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)
18
19
  Requires-Dist: platformdirs (>=4.3.8,<5.0.0)
19
20
  Requires-Dist: playwright (>=1.51.0,<2.0.0)
21
+ Requires-Dist: pyjwt (>=2.10.1,<3.0.0)
20
22
  Requires-Dist: requests (>=2.32.3,<3.0.0)
21
23
  Requires-Dist: types-requests (>=2.32.0.20250306,<3.0.0.0)
22
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.16"
3
+ version = "0.0.17"
4
4
  description = "SSB Pubmd"
5
5
  authors = ["Olav Landsverk <stud-oll@ssb.no>"]
6
6
  license = "MIT"
@@ -21,6 +21,8 @@ requests = "^2.32.3"
21
21
  types-requests = "^2.32.0.20250306"
22
22
  playwright = "^1.51.0"
23
23
  platformdirs = "^4.3.8"
24
+ pyjwt = "^2.10.1"
25
+ google-cloud-secret-manager = "^2.24.0"
24
26
 
25
27
  [tool.poetry.group.dev.dependencies]
26
28
  pygments = ">=2.10.0"
@@ -14,14 +14,18 @@ from ssb_pubmd.browser_request_handler import CreateContextMethod
14
14
  from ssb_pubmd.constants import APP_NAME
15
15
  from ssb_pubmd.constants import CACHE_FILE
16
16
  from ssb_pubmd.constants import CONFIG_FILE
17
+ from ssb_pubmd.jwt_request_handler import JWTRequestHandler
17
18
  from ssb_pubmd.markdown_syncer import MarkdownSyncer
18
19
 
19
20
 
20
21
  class ConfigKey(Enum):
21
22
  """Configuration keys for the application."""
22
23
 
24
+ BASE_URL = "base_url"
23
25
  LOGIN_URL = "login_url"
24
26
  POST_URL = "post_url"
27
+ AUTH_METHOD = "auth_method"
28
+ GC_SECRET_RESOURCE_NAME = "gc_secret_resource_name"
25
29
 
26
30
 
27
31
  def get_config_value(config_key: ConfigKey) -> str:
@@ -94,9 +98,7 @@ def login() -> None:
94
98
  click.echo(f"\nBrowser context stored in:\n{CACHE_FILE}")
95
99
 
96
100
 
97
- @cli.command()
98
- @click.argument("content_file_path", type=click.Path())
99
- def sync(content_file_path: str) -> None:
101
+ def sync_with_browser(content_file_path: str) -> None:
100
102
  """Sync a markdown or notebook file to the CMS."""
101
103
  login_url = get_config_value(ConfigKey.LOGIN_URL)
102
104
  request_handler = BrowserRequestHandler(CACHE_FILE, login_url)
@@ -122,5 +124,38 @@ def sync(content_file_path: str) -> None:
122
124
  click.echo("No preview url found in the response.")
123
125
 
124
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
+
125
160
  if __name__ == "__main__":
126
161
  cli()
@@ -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
+ )
File without changes
File without changes