ssb-pubmd 0.0.13__tar.gz → 0.0.15__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.13
3
+ Version: 0.0.15
4
4
  Summary: SSB Pubmd
5
5
  License: MIT
6
6
  Author: Olav Landsverk
@@ -51,10 +51,6 @@ Description-Content-Type: text/markdown
51
51
  [black]: https://github.com/psf/black
52
52
  [poetry]: https://python-poetry.org/
53
53
 
54
- ## Features
55
-
56
- - Supports logging in through a popup browser window.
57
-
58
54
 
59
55
  ## Installation
60
56
 
@@ -67,7 +63,7 @@ pip install ssb-pubmd
67
63
  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
64
 
69
65
  ```console
70
- playwright install --with-deps chromium`
66
+ playwright install --with-deps chromium
71
67
  ```
72
68
 
73
69
  ## Usage
@@ -25,10 +25,6 @@
25
25
  [black]: https://github.com/psf/black
26
26
  [poetry]: https://python-poetry.org/
27
27
 
28
- ## Features
29
-
30
- - Supports logging in through a popup browser window.
31
-
32
28
 
33
29
  ## Installation
34
30
 
@@ -41,7 +37,7 @@ pip install ssb-pubmd
41
37
  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):
42
38
 
43
39
  ```console
44
- playwright install --with-deps chromium`
40
+ playwright install --with-deps chromium
45
41
  ```
46
42
 
47
43
  ## Usage
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "ssb-pubmd"
3
- version = "0.0.13"
3
+ version = "0.0.15"
4
4
  description = "SSB Pubmd"
5
5
  authors = ["Olav Landsverk <stud-oll@ssb.no>"]
6
6
  license = "MIT"
@@ -0,0 +1,6 @@
1
+ """SSB Pubmd."""
2
+
3
+ from .browser_request_handler import BrowserRequestHandler
4
+ from .markdown_syncer import MarkdownSyncer
5
+
6
+ __all__ = ["BrowserRequestHandler", "MarkdownSyncer"]
@@ -0,0 +1,107 @@
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()
@@ -0,0 +1,85 @@
1
+ import os
2
+ from collections.abc import Iterator
3
+ from contextlib import contextmanager
4
+ from enum import Enum
5
+
6
+ from playwright.sync_api import BrowserContext
7
+ from playwright.sync_api import sync_playwright
8
+
9
+ from .markdown_syncer 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: str, login_url: str = "") -> None:
27
+ """Initializes an empty browser context object."""
28
+ self._context_file_path: str = os.path.abspath(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 = None
79
+
80
+ response = Response(
81
+ status_code=api_response.status,
82
+ body=body,
83
+ )
84
+
85
+ return response
@@ -18,8 +18,8 @@ class Response:
18
18
  body: dict[str, Any] | None = None
19
19
 
20
20
 
21
- class RequestContext(Protocol):
22
- """Interface for the context in which a request is sent.
21
+ class RequestHandler(Protocol):
22
+ """Interface for the handling how the request is sent.
23
23
 
24
24
  Implementing classes may handle authentication, sessions, etc.
25
25
  """
@@ -34,11 +34,11 @@ class RequestContext(Protocol):
34
34
  ...
35
35
 
36
36
 
37
- class BasicRequestContext:
38
- """Basic, unauthenticated request context."""
37
+ class BasicRequestHandler:
38
+ """Basic, unauthenticated request handler."""
39
39
 
40
40
  def __init__(self) -> None:
41
- """Initializes the basic request context."""
41
+ """Initializes the basic request handler."""
42
42
  pass
43
43
 
44
44
  def send_request(
@@ -91,10 +91,10 @@ class MarkdownSyncer:
91
91
 
92
92
  ID_KEY = "_id"
93
93
 
94
- def __init__(self, post_url: str, request_context: RequestContext) -> None:
94
+ def __init__(self, post_url: str, request_handler: RequestHandler) -> None:
95
95
  """Creates a markdown syncer instance that connects to the CMS through the post url."""
96
96
  self._post_url: str = post_url
97
- self._context: RequestContext = request_context
97
+ self._request_handler: RequestHandler = request_handler
98
98
  self._content_file_path: str = ""
99
99
  self._content_file_type: FileType = FileType.MARKDOWN
100
100
 
@@ -132,7 +132,7 @@ class MarkdownSyncer:
132
132
  @property
133
133
  def data_path(self) -> str:
134
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] + ".json"
135
+ return os.path.splitext(self.content_file_path)[0] + "-PUBMD.json"
136
136
 
137
137
  @property
138
138
  def display_name(self) -> str:
@@ -197,7 +197,7 @@ class MarkdownSyncer:
197
197
 
198
198
  def _send_request(self) -> str:
199
199
  """Sends the request to the CMS endpoint and returns the content id from the response."""
200
- response = self._context.send_request(
200
+ response = self._request_handler.send_request(
201
201
  url=self._post_url, data=self._request_data()
202
202
  )
203
203
 
@@ -1,6 +0,0 @@
1
- """SSB Pubmd."""
2
-
3
- from .browser_context import BrowserRequestContext as BrowserContext
4
- from .markdown_syncer import MarkdownSyncer
5
-
6
- __all__ = ["BrowserContext", "MarkdownSyncer"]
@@ -1,92 +0,0 @@
1
- """Command-line interface."""
2
-
3
- import json
4
-
5
- import click
6
-
7
- from ssb_pubmd.browser_context import BrowserRequestContext as RequestContext
8
- from ssb_pubmd.markdown_syncer import MarkdownSyncer
9
-
10
- CONFIG_FILE = "pubmd_config.json"
11
-
12
-
13
- class ConfigKeys:
14
- """The keys used in the configuration file."""
15
-
16
- LOGIN = "login_url"
17
- POST = "post_url"
18
-
19
-
20
- def get_config_value(key: str) -> str:
21
- """Get a configuration value."""
22
- try:
23
- with open(CONFIG_FILE) as json_file:
24
- config = json.load(json_file)
25
- value = config[key]
26
- except FileNotFoundError:
27
- click.echo(
28
- f"Configuration file '{CONFIG_FILE}' not found. Please run the 'config' command first."
29
- )
30
- except Exception:
31
- click.echo(
32
- f"Error reading configuration file '{CONFIG_FILE}'. Please run the 'pubmd config' command again."
33
- )
34
- return str(value)
35
-
36
-
37
- @click.group()
38
- def cli() -> None:
39
- """'pubmd' is a tool to sync markdown and notebook files to a CMS application.
40
-
41
- Setup with subcommands 'config' and 'login', then use subcommand 'sync'.
42
- """
43
- pass
44
-
45
-
46
- @click.command()
47
- def config() -> None:
48
- """Configure the CMS to connect to."""
49
- login_url = click.prompt("Enter the login URL", type=str)
50
- post_url = click.prompt("Enter the post URL", type=str)
51
-
52
- config = {ConfigKeys.LOGIN: login_url, ConfigKeys.POST: post_url}
53
-
54
- with open(CONFIG_FILE, "w") as json_file:
55
- json.dump(config, json_file, indent=4)
56
-
57
- click.echo(f"\nThe configuration has been stored in:\n{CONFIG_FILE}")
58
-
59
-
60
- @click.command()
61
- def login() -> None:
62
- """Login to the CMS application."""
63
- login_url = get_config_value(ConfigKeys.LOGIN)
64
- request_context = RequestContext()
65
- storage_state_file, storage_state = request_context.create_new(login_url)
66
- click.echo(f"\nThe browser context has been stored in:\n{storage_state_file}")
67
-
68
-
69
- @click.command()
70
- @click.argument("content_file_path", type=click.Path())
71
- def sync(content_file_path: str) -> None:
72
- """Sync a markdown or notebook file to the CMS."""
73
- post_url = get_config_value(ConfigKeys.POST)
74
- request_context = RequestContext()
75
- request_context.recreate_from_file()
76
-
77
- syncer = MarkdownSyncer(post_url=post_url, request_context=request_context)
78
- syncer.content_file_path = content_file_path
79
-
80
- content_id = syncer.sync_content()
81
-
82
- click.echo(
83
- f"File '{click.format_filename(content_file_path)}' synced to CMS with content ID: {content_id}"
84
- )
85
-
86
-
87
- cli.add_command(config)
88
- cli.add_command(login)
89
- cli.add_command(sync)
90
-
91
- if __name__ == "__main__":
92
- cli() # pragma: no cover
@@ -1,71 +0,0 @@
1
- from playwright.sync_api import BrowserContext
2
- from playwright.sync_api import StorageState
3
- from playwright.sync_api import sync_playwright
4
-
5
- from .markdown_syncer import Response
6
-
7
- BROWSER_CONTEXT_FILE = "pubmd_browser_context.json"
8
-
9
-
10
- class BrowserRequestContext:
11
- """This class is used to create a logged in browser context from which to send requests."""
12
-
13
- def __init__(self) -> None:
14
- """Initializes an empty browser context object."""
15
- self._storage_state_path: str = BROWSER_CONTEXT_FILE
16
- self._context: BrowserContext | None = None
17
-
18
- def create_new(self, login_url: str) -> tuple[str, StorageState]:
19
- """Creates a browser context by opening a login page and waiting for it to be closed by user.
20
-
21
- This function also saves the browser context to a file for later use.
22
- """
23
- playwright = sync_playwright().start()
24
- browser = playwright.chromium.launch(headless=False)
25
-
26
- self._context = browser.new_context()
27
- login_page = self._context.new_page()
28
-
29
- login_page.goto(login_url)
30
- login_page.wait_for_event("close", timeout=0)
31
-
32
- storage_state = self._context.storage_state(path=self._storage_state_path)
33
-
34
- return self._storage_state_path, storage_state
35
-
36
- def recreate_from_file(self) -> BrowserContext:
37
- """Recreates a browser context object from a file."""
38
- playwright = sync_playwright().start()
39
- browser = playwright.chromium.launch(headless=False)
40
-
41
- self._context = browser.new_context(storage_state=self._storage_state_path)
42
-
43
- return self._context
44
-
45
- def send_request(
46
- self,
47
- url: str,
48
- headers: dict[str, str] | None = None,
49
- data: dict[str, str] | None = None,
50
- ) -> Response:
51
- """Sends a request to the specified url, optionally with headers and data, within the browser context."""
52
- if self._context is None:
53
- raise ValueError("Browser context has not been created.")
54
-
55
- api_response = self._context.request.post(
56
- url,
57
- params=data,
58
- )
59
-
60
- try:
61
- body = api_response.json()
62
- body = dict(body)
63
- except Exception:
64
- body = None
65
-
66
- response = Response(
67
- status_code=api_response.status,
68
- body=body,
69
- )
70
-
71
- return response
File without changes