kci-confluence-cli 0.4.2__tar.gz → 0.4.3__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.
Files changed (73) hide show
  1. {kci_confluence_cli-0.4.2 → kci_confluence_cli-0.4.3}/Makefile +1 -2
  2. {kci_confluence_cli-0.4.2 → kci_confluence_cli-0.4.3}/PKG-INFO +19 -1
  3. {kci_confluence_cli-0.4.2 → kci_confluence_cli-0.4.3}/README.md +18 -0
  4. {kci_confluence_cli-0.4.2 → kci_confluence_cli-0.4.3}/confluence/common/api.py +86 -14
  5. kci_confluence_cli-0.4.3/docs/requirements.txt +4 -0
  6. kci_confluence_cli-0.4.3/tests/test_common_api.py +134 -0
  7. kci_confluence_cli-0.4.3/uv.lock +1502 -0
  8. kci_confluence_cli-0.4.2/docs/requirements.txt +0 -4
  9. kci_confluence_cli-0.4.2/uv.lock +0 -1425
  10. {kci_confluence_cli-0.4.2 → kci_confluence_cli-0.4.3}/.devcontainer/.dockerignore +0 -0
  11. {kci_confluence_cli-0.4.2 → kci_confluence_cli-0.4.3}/.devcontainer/.extensions/.gitignore +0 -0
  12. {kci_confluence_cli-0.4.2 → kci_confluence_cli-0.4.3}/.devcontainer/Dockerfile +0 -0
  13. {kci_confluence_cli-0.4.2 → kci_confluence_cli-0.4.3}/.devcontainer/devcontainer.json +0 -0
  14. {kci_confluence_cli-0.4.2 → kci_confluence_cli-0.4.3}/.github/dependabot.yml +0 -0
  15. {kci_confluence_cli-0.4.2 → kci_confluence_cli-0.4.3}/.github/workflows/lint-test.yml +0 -0
  16. {kci_confluence_cli-0.4.2 → kci_confluence_cli-0.4.3}/.github/workflows/publish-to-pypi.yml +0 -0
  17. {kci_confluence_cli-0.4.2 → kci_confluence_cli-0.4.3}/.gitignore +0 -0
  18. {kci_confluence_cli-0.4.2 → kci_confluence_cli-0.4.3}/.readthedocs.yaml +0 -0
  19. {kci_confluence_cli-0.4.2 → kci_confluence_cli-0.4.3}/.vscode/cspell.json +0 -0
  20. {kci_confluence_cli-0.4.2 → kci_confluence_cli-0.4.3}/.vscode/extension.json +0 -0
  21. {kci_confluence_cli-0.4.2 → kci_confluence_cli-0.4.3}/.vscode/settings.json +0 -0
  22. {kci_confluence_cli-0.4.2 → kci_confluence_cli-0.4.3}/confluence/__init__.py +0 -0
  23. {kci_confluence_cli-0.4.2 → kci_confluence_cli-0.4.3}/confluence/__main__.py +0 -0
  24. {kci_confluence_cli-0.4.2 → kci_confluence_cli-0.4.3}/confluence/__version__.py +0 -0
  25. {kci_confluence_cli-0.4.2 → kci_confluence_cli-0.4.3}/confluence/attachment/__init__.py +0 -0
  26. {kci_confluence_cli-0.4.2 → kci_confluence_cli-0.4.3}/confluence/attachment/create_attachment.py +0 -0
  27. {kci_confluence_cli-0.4.2 → kci_confluence_cli-0.4.3}/confluence/attachment/delete_attachment.py +0 -0
  28. {kci_confluence_cli-0.4.2 → kci_confluence_cli-0.4.3}/confluence/attachment/get_attachment.py +0 -0
  29. {kci_confluence_cli-0.4.2 → kci_confluence_cli-0.4.3}/confluence/attachment/subcommand.py +0 -0
  30. {kci_confluence_cli-0.4.2 → kci_confluence_cli-0.4.3}/confluence/common/__init__.py +0 -0
  31. {kci_confluence_cli-0.4.2 → kci_confluence_cli-0.4.3}/confluence/common/cli.py +0 -0
  32. {kci_confluence_cli-0.4.2 → kci_confluence_cli-0.4.3}/confluence/common/utils.py +0 -0
  33. {kci_confluence_cli-0.4.2 → kci_confluence_cli-0.4.3}/confluence/content/__init__.py +0 -0
  34. {kci_confluence_cli-0.4.2 → kci_confluence_cli-0.4.3}/confluence/content/get_content_by_id.py +0 -0
  35. {kci_confluence_cli-0.4.2 → kci_confluence_cli-0.4.3}/confluence/content/subcommand.py +0 -0
  36. {kci_confluence_cli-0.4.2 → kci_confluence_cli-0.4.3}/confluence/data/logging.yaml +0 -0
  37. {kci_confluence_cli-0.4.2 → kci_confluence_cli-0.4.3}/confluence/local/__init__.py +0 -0
  38. {kci_confluence_cli-0.4.2 → kci_confluence_cli-0.4.3}/confluence/local/convert_html_to_xml.py +0 -0
  39. {kci_confluence_cli-0.4.2 → kci_confluence_cli-0.4.3}/confluence/local/subcommand.py +0 -0
  40. {kci_confluence_cli-0.4.2 → kci_confluence_cli-0.4.3}/confluence/page/__init__.py +0 -0
  41. {kci_confluence_cli-0.4.2 → kci_confluence_cli-0.4.3}/confluence/page/get_page_body.py +0 -0
  42. {kci_confluence_cli-0.4.2 → kci_confluence_cli-0.4.3}/confluence/page/subcommand.py +0 -0
  43. {kci_confluence_cli-0.4.2 → kci_confluence_cli-0.4.3}/confluence/page/update_page.py +0 -0
  44. {kci_confluence_cli-0.4.2 → kci_confluence_cli-0.4.3}/confluence/py.typed +0 -0
  45. {kci_confluence_cli-0.4.2 → kci_confluence_cli-0.4.3}/docs/Makefile +0 -0
  46. {kci_confluence_cli-0.4.2 → kci_confluence_cli-0.4.3}/docs/_static/.gitkeep +0 -0
  47. {kci_confluence_cli-0.4.2 → kci_confluence_cli-0.4.3}/docs/_templates/.gitkeep +0 -0
  48. {kci_confluence_cli-0.4.2 → kci_confluence_cli-0.4.3}/docs/command_reference/attachment/create.rst +0 -0
  49. {kci_confluence_cli-0.4.2 → kci_confluence_cli-0.4.3}/docs/command_reference/attachment/delete.rst +0 -0
  50. {kci_confluence_cli-0.4.2 → kci_confluence_cli-0.4.3}/docs/command_reference/attachment/get.rst +0 -0
  51. {kci_confluence_cli-0.4.2 → kci_confluence_cli-0.4.3}/docs/command_reference/attachment/index.rst +0 -0
  52. {kci_confluence_cli-0.4.2 → kci_confluence_cli-0.4.3}/docs/command_reference/content/get_by_id.rst +0 -0
  53. {kci_confluence_cli-0.4.2 → kci_confluence_cli-0.4.3}/docs/command_reference/content/index.rst +0 -0
  54. {kci_confluence_cli-0.4.2 → kci_confluence_cli-0.4.3}/docs/command_reference/index.rst +0 -0
  55. {kci_confluence_cli-0.4.2 → kci_confluence_cli-0.4.3}/docs/command_reference/local/convert_html.rst +0 -0
  56. {kci_confluence_cli-0.4.2 → kci_confluence_cli-0.4.3}/docs/command_reference/local/index.rst +0 -0
  57. {kci_confluence_cli-0.4.2 → kci_confluence_cli-0.4.3}/docs/command_reference/page/get_body.rst +0 -0
  58. {kci_confluence_cli-0.4.2 → kci_confluence_cli-0.4.3}/docs/command_reference/page/index.rst +0 -0
  59. {kci_confluence_cli-0.4.2 → kci_confluence_cli-0.4.3}/docs/command_reference/page/update.rst +0 -0
  60. {kci_confluence_cli-0.4.2 → kci_confluence_cli-0.4.3}/docs/conf.py +0 -0
  61. {kci_confluence_cli-0.4.2 → kci_confluence_cli-0.4.3}/docs/index.rst +0 -0
  62. {kci_confluence_cli-0.4.2 → kci_confluence_cli-0.4.3}/docs/user_guide/getting_started.rst +0 -0
  63. {kci_confluence_cli-0.4.2 → kci_confluence_cli-0.4.3}/docs/user_guide/google_docs_migration.rst +0 -0
  64. {kci_confluence_cli-0.4.2 → kci_confluence_cli-0.4.3}/docs/user_guide/images/confluence_source_editor.png +0 -0
  65. {kci_confluence_cli-0.4.2 → kci_confluence_cli-0.4.3}/docs/user_guide/images/google_docs_download.png +0 -0
  66. {kci_confluence_cli-0.4.2 → kci_confluence_cli-0.4.3}/docs/user_guide/index.rst +0 -0
  67. {kci_confluence_cli-0.4.2 → kci_confluence_cli-0.4.3}/pyproject.toml +0 -0
  68. {kci_confluence_cli-0.4.2 → kci_confluence_cli-0.4.3}/pytest.ini +0 -0
  69. {kci_confluence_cli-0.4.2 → kci_confluence_cli-0.4.3}/tests/__init__.py +0 -0
  70. {kci_confluence_cli-0.4.2 → kci_confluence_cli-0.4.3}/tests/local/__init__.py +0 -0
  71. {kci_confluence_cli-0.4.2 → kci_confluence_cli-0.4.3}/tests/local/test__convert_html_to_xml.py +0 -0
  72. {kci_confluence_cli-0.4.2 → kci_confluence_cli-0.4.3}/tests/resources/page.html +0 -0
  73. {kci_confluence_cli-0.4.2 → kci_confluence_cli-0.4.3}/tests/test____main__.py +0 -0
@@ -19,8 +19,7 @@ lint:
19
19
 
20
20
  test:
21
21
  # 並列実行してレポートも出力する
22
- uv run pytest -n auto --cov=kci --cov-report=html tests
22
+ uv run pytest -n auto --cov=confluence --cov-report=html tests
23
23
 
24
24
  docs:
25
25
  cd docs && uv run make html
26
-
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: kci-confluence-cli
3
- Version: 0.4.2
3
+ Version: 0.4.3
4
4
  Summary: Confluence v6.15.7を操作するためのCLIです。
5
5
  Project-URL: Repository, https://github.com/kurusugawa-computer/confluence-cli
6
6
  Author: Kurusugawa Computer Inc.
@@ -47,6 +47,24 @@ $ pip install kci-confluence-cli
47
47
  $ uvx --from kci-confluence-cli confluence page get_body --page_id 123456
48
48
  ```
49
49
 
50
+ Python clientとして利用する場合は、共通のtimeout、redirect、response size policyを指定できます。
51
+
52
+ ```python
53
+ from confluence.common.api import Api, TransportPolicy
54
+
55
+ api = Api(
56
+ username,
57
+ password,
58
+ base_url,
59
+ transport_policy=TransportPolicy(
60
+ timeout_second=10,
61
+ follow_redirects=False,
62
+ response_byte_bound=2 * 1024 * 1024,
63
+ ),
64
+ )
65
+ content = api.get_content_by_id(content_id, query_params={"expand": "space,body.storage,version,ancestors"})
66
+ ```
67
+
50
68
 
51
69
  # Quick Start
52
70
 
@@ -22,6 +22,24 @@ $ pip install kci-confluence-cli
22
22
  $ uvx --from kci-confluence-cli confluence page get_body --page_id 123456
23
23
  ```
24
24
 
25
+ Python clientとして利用する場合は、共通のtimeout、redirect、response size policyを指定できます。
26
+
27
+ ```python
28
+ from confluence.common.api import Api, TransportPolicy
29
+
30
+ api = Api(
31
+ username,
32
+ password,
33
+ base_url,
34
+ transport_policy=TransportPolicy(
35
+ timeout_second=10,
36
+ follow_redirects=False,
37
+ response_byte_bound=2 * 1024 * 1024,
38
+ ),
39
+ )
40
+ content = api.get_content_by_id(content_id, query_params={"expand": "space,body.storage,version,ancestors"})
41
+ ```
42
+
25
43
 
26
44
  # Quick Start
27
45
 
@@ -4,9 +4,11 @@ import copy
4
4
  import logging
5
5
  import mimetypes
6
6
  import time
7
+ from dataclasses import dataclass
7
8
  from pathlib import Path
8
- from typing import Any
9
+ from typing import Any, cast
9
10
 
11
+ from requests import HTTPError, Response
10
12
  from requests_toolbelt import sessions
11
13
 
12
14
  logger = logging.getLogger(__name__)
@@ -15,6 +17,25 @@ QueryParams = dict[str, Any]
15
17
  RequestBody = dict[str, Any]
16
18
 
17
19
 
20
+ class ResponseByteBoundExceeded(RuntimeError):
21
+ """Response body が configured byte bound を超えたことを表す。"""
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class TransportPolicy:
26
+ """Confluence HTTP transport に共通する bounded execution policy。"""
27
+
28
+ timeout_second: float = 30
29
+ follow_redirects: bool = True
30
+ response_byte_bound: int | None = None
31
+
32
+ def __post_init__(self) -> None:
33
+ if self.timeout_second <= 0:
34
+ raise ValueError("timeout_secondは0より大きい値にしてください。")
35
+ if self.response_byte_bound is not None and self.response_byte_bound < 1:
36
+ raise ValueError("response_byte_boundは1以上の値にしてください。")
37
+
38
+
18
39
  class Api:
19
40
  """
20
41
  https://docs.atlassian.com/ConfluenceServer/rest/6.15.7/
@@ -22,10 +43,19 @@ class Api:
22
43
  Args:
23
44
  base_url: example: `https://kurusugawa.jp/confluence`
24
45
  delay_second: APIを連続で実行する際、何秒以上間隔を空けるか。Confluenceに負荷をかけすぎないようにするため、少なくとも0.3秒以上にすること。
46
+ transport_policy: timeout, redirect, response size の共通policy。
25
47
 
26
48
  """
27
49
 
28
- def __init__(self, username: str, password: str, base_url: str, delay_second: float = 1) -> None:
50
+ def __init__(
51
+ self,
52
+ username: str,
53
+ password: str,
54
+ base_url: str,
55
+ delay_second: float = 1,
56
+ *,
57
+ transport_policy: TransportPolicy | None = None,
58
+ ) -> None:
29
59
  if delay_second < 0.3:
30
60
  raise RuntimeError(f"引数'delay_second'は0.3以上にしてください。 :: {delay_second=}")
31
61
 
@@ -34,6 +64,7 @@ class Api:
34
64
  self.session.auth = (username, password)
35
65
 
36
66
  self.delay_second = delay_second
67
+ self.transport_policy = transport_policy or TransportPolicy()
37
68
  self._previous_timestamp: float = 0
38
69
 
39
70
  @staticmethod
@@ -57,21 +88,24 @@ class Api:
57
88
  params: QueryParams | None = None,
58
89
  data: Any = None, # noqa: ANN401
59
90
  **kwargs,
60
- ) -> Any: # noqa: ANN401
91
+ ) -> Response:
61
92
  """
62
93
  HTTP Requestを投げて、Responseを返す。
63
94
 
64
95
  Args:
65
96
  http_method:
66
- url_path:
67
- query_params:
68
- header_params:
69
- body:
70
- log_response_with_error: HTTP Errorが発生したときにレスポンスの中身をログに出力するか否か
97
+ url:
98
+ headers:
99
+ params:
100
+ data:
101
+ kwargs:
71
102
 
72
103
  Returns:
73
- responseの中身。content_typeにより型が変わる。
74
- application/jsonならdict型, text/*ならばstr型, それ以外ならばbite型。
104
+ HTTP response。response byte bound が指定されている場合は、bodyをbound内で読み込んだresponseを返す。
105
+
106
+ Raises:
107
+ HTTPError: HTTP status がerrorを表す場合。
108
+ ResponseByteBoundExceeded: Response body がconfigured byte boundを超えた場合。
75
109
 
76
110
  """
77
111
  now = time.time()
@@ -79,9 +113,22 @@ class Api:
79
113
  if diff_time < self.delay_second:
80
114
  time.sleep(self.delay_second - diff_time)
81
115
 
116
+ kwargs.setdefault("timeout", self.transport_policy.timeout_second)
117
+ kwargs.setdefault("allow_redirects", self.transport_policy.follow_redirects)
118
+ if self.transport_policy.response_byte_bound is not None:
119
+ kwargs["stream"] = True
120
+
82
121
  response = self.session.request(http_method, url, params=params, data=data, headers=headers, **kwargs)
83
122
  self._previous_timestamp = time.time()
84
123
 
124
+ try:
125
+ response.raise_for_status()
126
+ except HTTPError:
127
+ response.close()
128
+ raise
129
+ response_content = self._response_content(response)
130
+
131
+ # `kwargs["json"]` は credential やcontentを含みうるため、arbitrary JSON payloadは意図的にlogへ複製しない。
85
132
  logger.debug(
86
133
  "Sent a request :: %s",
87
134
  {
@@ -94,13 +141,39 @@ class Api:
94
141
  },
95
142
  "response": {
96
143
  "status_code": response.status_code,
97
- "content_length": len(response.content),
144
+ "content_length": len(response_content),
98
145
  },
99
146
  },
100
147
  )
101
- response.raise_for_status()
102
148
  return response
103
149
 
150
+ def _response_content(self, response: Response) -> bytes:
151
+ byte_bound = self.transport_policy.response_byte_bound
152
+ if byte_bound is None:
153
+ return response.content
154
+
155
+ body = bytearray()
156
+ try:
157
+ content_length = response.headers.get("Content-Length")
158
+ if content_length is not None:
159
+ try:
160
+ if int(content_length) > byte_bound:
161
+ raise ResponseByteBoundExceeded(f"Response bodyがbyte boundを超えました。 :: {byte_bound=}")
162
+ except ValueError:
163
+ pass
164
+
165
+ for chunk in response.iter_content(chunk_size=min(64 * 1024, byte_bound + 1)):
166
+ body.extend(chunk)
167
+ if len(body) > byte_bound:
168
+ raise ResponseByteBoundExceeded(f"Response bodyがbyte boundを超えました。 :: {byte_bound=}")
169
+ finally:
170
+ response.close()
171
+
172
+ response_content = bytes(body)
173
+ response._content = response_content
174
+ cast(Any, response)._content_consumed = True
175
+ return response_content
176
+
104
177
  def get_attachments(self, content_id: str, *, query_params: QueryParams | None = None) -> dict[str, Any]:
105
178
  url = f"content/{content_id}/child/attachment"
106
179
  return self._request("get", url, params=query_params).json()
@@ -170,5 +243,4 @@ class Api:
170
243
 
171
244
  https://docs.atlassian.com/ConfluenceServer/rest/6.15.7/#api/content-search
172
245
  """
173
- response = self.session.get("content/search", params=query_params)
174
- return response.json()
246
+ return self._request("get", "content/search", params=query_params).json()
@@ -0,0 +1,4 @@
1
+ # ReadTheDocs用の依存パッケージ
2
+ sphinx==9.1.0
3
+ pydata-sphinx-theme==0.23.0
4
+ sphinx-argparse==0.6.1
@@ -0,0 +1,134 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ import pytest
6
+ from requests import HTTPError, Response
7
+
8
+ from confluence.common.api import Api, ResponseByteBoundExceeded, TransportPolicy
9
+
10
+
11
+ def _json_response(body: bytes = b'{"id":"123456"}') -> Response:
12
+ response = Response()
13
+ response.status_code = 200
14
+ response.url = "https://confluence.example.test/confluence/rest/api/content/123456"
15
+ response.headers["Content-Type"] = "application/json"
16
+ response._content = body # noqa: SLF001
17
+ response._content_consumed = True # noqa: SLF001
18
+ return response
19
+
20
+
21
+ def test_transport_policy_is_applied_to_protocol_request(monkeypatch: pytest.MonkeyPatch) -> None:
22
+ api = Api(
23
+ "reader",
24
+ "secret",
25
+ "https://confluence.example.test/confluence",
26
+ transport_policy=TransportPolicy(timeout_second=7, follow_redirects=False, response_byte_bound=1024),
27
+ )
28
+ request_argument: dict[str, Any] = {}
29
+
30
+ def fake_request(method: str, url: str, **kwargs: Any) -> Response: # noqa: ANN401
31
+ request_argument.update(method=method, url=url, **kwargs)
32
+ return _json_response()
33
+
34
+ monkeypatch.setattr(api.session, "request", fake_request)
35
+
36
+ result = api.get_content_by_id("123456", query_params={"expand": "space,body.storage"})
37
+
38
+ assert result == {"id": "123456"}
39
+ assert request_argument == {
40
+ "method": "get",
41
+ "url": "content/123456",
42
+ "params": {"expand": "space,body.storage"},
43
+ "data": None,
44
+ "headers": None,
45
+ "timeout": 7,
46
+ "allow_redirects": False,
47
+ "stream": True,
48
+ }
49
+
50
+
51
+ @pytest.mark.parametrize(
52
+ ("policy", "message"),
53
+ [
54
+ (TransportPolicy(timeout_second=1, response_byte_bound=4), "byte bound"),
55
+ ],
56
+ )
57
+ def test_response_byte_bound_is_enforced(
58
+ monkeypatch: pytest.MonkeyPatch,
59
+ policy: TransportPolicy,
60
+ message: str,
61
+ ) -> None:
62
+ api = Api("reader", "secret", "https://confluence.example.test/confluence", transport_policy=policy)
63
+ response = _json_response(b"12345")
64
+ monkeypatch.setattr(api.session, "request", lambda *_args, **_kwargs: response)
65
+
66
+ with pytest.raises(ResponseByteBoundExceeded, match=message):
67
+ api.get_content_by_id("123456")
68
+
69
+
70
+ def test_streamed_error_response_is_closed(monkeypatch: pytest.MonkeyPatch) -> None:
71
+ api = Api(
72
+ "reader",
73
+ "secret",
74
+ "https://confluence.example.test/confluence",
75
+ transport_policy=TransportPolicy(response_byte_bound=1024),
76
+ )
77
+ response = _json_response()
78
+ response.status_code = 500
79
+ closed = False
80
+
81
+ def close() -> None:
82
+ nonlocal closed
83
+ closed = True
84
+
85
+ monkeypatch.setattr(response, "close", close)
86
+ monkeypatch.setattr(api.session, "request", lambda *_args, **_kwargs: response)
87
+
88
+ with pytest.raises(HTTPError):
89
+ api.get_content_by_id("123456")
90
+
91
+ assert closed is True
92
+
93
+
94
+ def test_streamed_success_response_is_closed(monkeypatch: pytest.MonkeyPatch) -> None:
95
+ api = Api(
96
+ "reader",
97
+ "secret",
98
+ "https://confluence.example.test/confluence",
99
+ transport_policy=TransportPolicy(response_byte_bound=1024),
100
+ )
101
+ response = _json_response()
102
+ closed = False
103
+
104
+ def close() -> None:
105
+ nonlocal closed
106
+ closed = True
107
+
108
+ monkeypatch.setattr(response, "close", close)
109
+ monkeypatch.setattr(api.session, "request", lambda *_args, **_kwargs: response)
110
+
111
+ assert api.get_content_by_id("123456") == {"id": "123456"}
112
+ assert closed is True
113
+
114
+
115
+ def test_transport_policy_rejects_non_positive_bounds() -> None:
116
+ with pytest.raises(ValueError, match="timeout_second"):
117
+ TransportPolicy(timeout_second=0)
118
+ with pytest.raises(ValueError, match="response_byte_bound"):
119
+ TransportPolicy(response_byte_bound=0)
120
+
121
+
122
+ def test_search_uses_common_transport_policy(monkeypatch: pytest.MonkeyPatch) -> None:
123
+ api = Api("reader", "secret", "https://confluence.example.test/confluence")
124
+ request_argument: dict[str, Any] = {}
125
+
126
+ def fake_request(method: str, url: str, **kwargs: Any) -> Response: # noqa: ANN401
127
+ request_argument.update(method=method, url=url, **kwargs)
128
+ return _json_response(b"{}")
129
+
130
+ monkeypatch.setattr(api.session, "request", fake_request)
131
+
132
+ assert api.search_content(query_params={"cql": "type=page"}) == {}
133
+ assert request_argument["timeout"] == 30
134
+ assert request_argument["allow_redirects"] is True