ssb-pubmd 0.0.15__tar.gz → 0.0.16__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.15
3
+ Version: 0.0.16
4
4
  Summary: SSB Pubmd
5
5
  License: MIT
6
6
  Author: Olav Landsverk
@@ -15,6 +15,7 @@ Classifier: Programming Language :: Python :: 3.12
15
15
  Classifier: Programming Language :: Python :: 3.13
16
16
  Requires-Dist: click (>=8.0.1)
17
17
  Requires-Dist: nbformat (>=5.10.4,<6.0.0)
18
+ Requires-Dist: platformdirs (>=4.3.8,<5.0.0)
18
19
  Requires-Dist: playwright (>=1.51.0,<2.0.0)
19
20
  Requires-Dist: requests (>=2.32.3,<3.0.0)
20
21
  Requires-Dist: types-requests (>=2.32.0.20250306,<3.0.0.0)
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "ssb-pubmd"
3
- version = "0.0.15"
3
+ version = "0.0.16"
4
4
  description = "SSB Pubmd"
5
5
  authors = ["Olav Landsverk <stud-oll@ssb.no>"]
6
6
  license = "MIT"
@@ -20,6 +20,7 @@ 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"
23
24
 
24
25
  [tool.poetry.group.dev.dependencies]
25
26
  pygments = ">=2.10.0"
@@ -0,0 +1,126 @@
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.markdown_syncer import MarkdownSyncer
18
+
19
+
20
+ class ConfigKey(Enum):
21
+ """Configuration keys for the application."""
22
+
23
+ LOGIN_URL = "login_url"
24
+ POST_URL = "post_url"
25
+
26
+
27
+ def get_config_value(config_key: ConfigKey) -> str:
28
+ """Load a configuration value, with precedence environment variable > config file."""
29
+ key = config_key.value
30
+
31
+ def get_env_value() -> str:
32
+ """Get value from environment variable, by uppercasing the key and adding prefix."""
33
+ prefix = f"{APP_NAME.upper()}_"
34
+ value = os.getenv(f"{prefix}{key.upper()}")
35
+
36
+ return cast(str, value)
37
+
38
+ def get_config_file_value() -> str:
39
+ """Get value from the config file."""
40
+ with open(CONFIG_FILE) as f:
41
+ data = json.load(f)
42
+
43
+ value = data.get(key)
44
+
45
+ return cast(str, value)
46
+
47
+ return get_env_value() or get_config_file_value()
48
+
49
+
50
+ def set_config_value(config_key: ConfigKey, value: str) -> None:
51
+ """Set a configuration value in the config file."""
52
+ key = config_key.value
53
+
54
+ with open(CONFIG_FILE) as f:
55
+ try:
56
+ data = json.load(f)
57
+ except json.JSONDecodeError:
58
+ data = {}
59
+
60
+ data[key] = value
61
+
62
+ with open(CONFIG_FILE, "w") as f:
63
+ json.dump(data, f, indent=4)
64
+
65
+
66
+ @click.group()
67
+ def cli() -> None:
68
+ """Pubmd - a tool to sync markdown and notebook files to a CMS."""
69
+ pass
70
+
71
+
72
+ @cli.command()
73
+ def settings() -> None:
74
+ """Set the login and post URL for the CMS."""
75
+ login_url = click.prompt("Enter the login URL", type=str)
76
+ set_config_value(ConfigKey.LOGIN_URL, login_url)
77
+
78
+ post_url = click.prompt("Enter the post URL", type=str)
79
+ set_config_value(ConfigKey.POST_URL, post_url)
80
+
81
+ click.echo(f"\nSettings stored in:\n{click.format_filename(CONFIG_FILE)}")
82
+
83
+
84
+ @cli.command()
85
+ def login() -> None:
86
+ """Log in to the CMS application."""
87
+ login_url = get_config_value(ConfigKey.LOGIN_URL)
88
+ request_handler = BrowserRequestHandler(CACHE_FILE, login_url)
89
+
90
+ method = CreateContextMethod.FROM_LOGIN
91
+ with request_handler.new_context(method=method):
92
+ click.echo("Logging in...")
93
+
94
+ click.echo(f"\nBrowser context stored in:\n{CACHE_FILE}")
95
+
96
+
97
+ @cli.command()
98
+ @click.argument("content_file_path", type=click.Path())
99
+ def sync(content_file_path: str) -> None:
100
+ """Sync a markdown or notebook file to the CMS."""
101
+ login_url = get_config_value(ConfigKey.LOGIN_URL)
102
+ request_handler = BrowserRequestHandler(CACHE_FILE, login_url)
103
+
104
+ with request_handler.new_context() as context:
105
+ post_url = get_config_value(ConfigKey.POST_URL)
106
+ syncer = MarkdownSyncer(post_url, request_handler)
107
+
108
+ syncer.content_file_path = Path(content_file_path)
109
+ response = syncer.sync_content()
110
+
111
+ click.echo("Content synced successfully.")
112
+
113
+ path = response.body.get("previewPath", "")
114
+ preview = urlparse(login_url)._replace(path=path).geturl()
115
+ if preview:
116
+ page = context.new_page()
117
+ page.goto(preview)
118
+ click.echo(f"Preview opened in new browser: {preview}")
119
+ click.echo("Close the browser tab to finish.")
120
+ page.wait_for_event("close", timeout=0)
121
+ else:
122
+ click.echo("No preview url found in the response.")
123
+
124
+
125
+ if __name__ == "__main__":
126
+ 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 .markdown_syncer import Response
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: str, login_url: str = "") -> None:
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: str = os.path.abspath(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 = None
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"
@@ -1,79 +1,17 @@
1
1
  import json
2
- import os
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
- @dataclass
14
- class Response:
15
- """The expected response object used in this module."""
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 markdown/notebook file to a CMS (Content Management System).
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__(self, post_url: str, request_handler: RequestHandler) -> None:
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: str = ""
99
- self._content_file_type: FileType = FileType.MARKDOWN
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) -> str:
103
- """Returns the path of the markdown/notebook file."""
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, content_file_path: str) -> None:
108
- """Sets the path of the markdown/notebook file."""
109
- content_file_path = os.path.abspath(content_file_path)
110
-
111
- if not os.path.exists(content_file_path):
112
- raise FileNotFoundError(f"The file '{content_file_path}' does not exist.")
113
-
114
- ext = os.path.splitext(content_file_path)[1]
115
- for e in FileType:
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 '{content_file_path}' is not a markdown or notebook 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 = 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 markdown/notebook file without extension."""
129
- basename = os.path.basename(self.content_file_path)
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 data file."""
144
- filename = self.data_path
145
- with open(filename, "w") as file:
146
- json.dump({self.ID_KEY: content_id}, file)
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 data file if it exists, otherwise an empty string."""
150
- content_id = ""
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 FileType.MARKDOWN:
145
+ case ContentType.MARKDOWN:
185
146
  content = self._get_content_from_markdown_file()
186
- case FileType.NOTEBOOK:
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 _send_request(self) -> str:
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
- return content_id
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