markdown-to-confluence 0.3.3__py3-none-any.whl → 0.3.5__py3-none-any.whl

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.4
2
2
  Name: markdown-to-confluence
3
- Version: 0.3.3
3
+ Version: 0.3.5
4
4
  Summary: Publish Markdown files to Confluence wiki
5
5
  Home-page: https://github.com/hunyadi/md2conf
6
6
  Author: Levente Hunyadi
@@ -21,12 +21,12 @@ Classifier: Typing :: Typed
21
21
  Requires-Python: >=3.9
22
22
  Description-Content-Type: text/markdown
23
23
  License-File: LICENSE
24
- Requires-Dist: lxml>=5.3
25
- Requires-Dist: types-lxml>=2024.12.13
26
- Requires-Dist: markdown>=3.7
27
- Requires-Dist: types-markdown>=3.7
28
- Requires-Dist: pymdown-extensions>=10.14
29
- Requires-Dist: pyyaml>=6.0
24
+ Requires-Dist: lxml>=5.4
25
+ Requires-Dist: types-lxml>=2025.3.30
26
+ Requires-Dist: markdown>=3.8
27
+ Requires-Dist: types-markdown>=3.8
28
+ Requires-Dist: pymdown-extensions>=10.15
29
+ Requires-Dist: PyYAML>=6.0
30
30
  Requires-Dist: types-PyYAML>=6.0
31
31
  Requires-Dist: requests>=2.32
32
32
  Requires-Dist: types-requests>=2.32
@@ -62,13 +62,13 @@ Whenever possible, the implementation uses [Confluence REST API v2](https://deve
62
62
 
63
63
  ## Installation
64
64
 
65
- Install the core package from PyPI:
65
+ **Required.** Install the core package from [PyPI](https://pypi.org/project/markdown-to-confluence/):
66
66
 
67
67
  ```sh
68
68
  pip install markdown-to-confluence
69
69
  ```
70
70
 
71
- Converting code blocks of Mermaid diagrams into Confluence image attachments requires [mermaid-cli](https://github.com/mermaid-js/mermaid-cli):
71
+ **Optional.** Converting code blocks of Mermaid diagrams into Confluence image attachments requires [mermaid-cli](https://github.com/mermaid-js/mermaid-cli):
72
72
 
73
73
  ```sh
74
74
  npm install -g @mermaid-js/mermaid-cli
@@ -198,20 +198,26 @@ root
198
198
  └── Mean vs. median
199
199
  ```
200
200
 
201
+ ### Lists and tables
202
+
203
+ If your Markdown lists or tables don't appear in Confluence as expected, verify that the list or table is delimited by a blank line both before and after, as per strict Markdown syntax. While some previewers accept a more lenient syntax (e.g. an itemized list immediately following a paragraph), *md2conf* uses [Python-Markdown](https://python-markdown.github.io/) internally to convert Markdown into XHTML, which expects the Markdown document to adhere to the stricter syntax.
204
+
201
205
  ### Publishing images
202
206
 
203
207
  Local images referenced in a Markdown file are automatically published to Confluence as attachments to the page.
204
208
 
205
- Unfortunately, Confluence struggles with SVG images, e.g. they may only show in *edit* mode, display in a wrong size or text labels in the image may be truncated. In order to mitigate the issue, whenever *md2conf* encounters a reference to an SVG image in a Markdown file, it checks whether a corresponding PNG image also exists in the same directory, and if a PNG image is found, it is published instead.
209
+ Unfortunately, Confluence struggles with SVG images, e.g. they may only show in *edit* mode, display in a wrong size or text labels in the image may be truncated. (This seems to be a known issue in Confluence.) In order to mitigate the issue, whenever *md2conf* encounters a reference to an SVG image in a Markdown file, it checks whether a corresponding PNG image also exists in the same directory, and if a PNG image is found, it is published instead.
206
210
 
207
211
  External images referenced with an absolute URL retain the original URL.
208
212
 
209
213
  ### Ignoring files
210
214
 
211
- Skip files in a directory with rules defined in `.mdignore`. Each rule should occupy a single line. Rules follow the syntax of [fnmatch](https://docs.python.org/3/library/fnmatch.html#fnmatch.fnmatch). Specifically, `?` matches any single character, and `*` matches zero or more characters. For example, use `up-*.md` to exclude Markdown files that start with `up-`. Lines that start with `#` are treated as comments.
215
+ Skip files in a directory with rules defined in `.mdignore`. Each rule should occupy a single line. Rules follow the syntax (and constraints) of [fnmatch](https://docs.python.org/3/library/fnmatch.html#fnmatch.fnmatch). Specifically, `?` matches any single character, and `*` matches zero or more characters. For example, use `up-*.md` to exclude Markdown files that start with `up-`. Lines that start with `#` are treated as comments.
212
216
 
213
217
  Files that don't have the extension `*.md` are skipped automatically. Hidden directories (whose name starts with `.`) are not recursed into.
214
218
 
219
+ Relative paths to items in a nested directory are not supported. You must put `.mdignore` in the same directory where the items to be skipped reside.
220
+
215
221
  ### Page title
216
222
 
217
223
  *md2conf* makes a best-effort attempt at setting the Confluence wiki page title when it publishes a Markdown document the first time. The following are probed in this order:
@@ -222,6 +228,13 @@ Files that don't have the extension `*.md` are skipped automatically. Hidden dir
222
228
 
223
229
  If a matching Confluence page already exists for a Markdown file, the page title in Confluence is left unchanged.
224
230
 
231
+ ### Converting diagrams
232
+
233
+ You can include [Mermaid diagrams](https://mermaid.js.org/) in your Markdown documents to create visual representations of systems, processes, and relationships. When a Markdown document contains a code block with the language specifier `mermaid`, *md2conf* offers two options to publish the diagram:
234
+
235
+ 1. Pre-render into an image. The code block is interpreted by and converted into a PNG or SVG image with the Mermaid diagram utility [mermaid-cli](https://github.com/mermaid-js/mermaid-cli). The generated image is then uploaded to Confluence as an attachment to the page. This is the approach we use and support.
236
+ 2. Render on demand. The code block is transformed into a [diagram macro](https://atlasauthority.atlassian.net/wiki/spaces/MARKDOWNCLOUD/pages/2946826241/Diagram+Macro), which is processed by Confluence. You need a [Confluence plugin](https://marketplace.atlassian.com/apps/1211438/markdown-html-plantuml-latex-diagrams-open-api-mermaid) to turn macro definitions into images when a Confluence page is visited. This is a contributed feature. As authors of *md2conf*, we don't endorse or support any particular Confluence plugin.
237
+
225
238
  ### Running the tool
226
239
 
227
240
  You execute the command-line tool `md2conf` to synchronize the Markdown file with Confluence:
@@ -0,0 +1,23 @@
1
+ markdown_to_confluence-0.3.5.dist-info/licenses/LICENSE,sha256=Pv43so2bPfmKhmsrmXFyAvS7M30-1i1tzjz6-dfhyOo,1077
2
+ md2conf/__init__.py,sha256=Uaqb3maQScpYs3FiH8kuM6pUh5JzE4Vy52MgU9pvMTw,402
3
+ md2conf/__main__.py,sha256=bFcfmSnTWeuhmDm7bJ3jJabZ2S8W9biuAP6_R-Cc9As,8034
4
+ md2conf/api.py,sha256=VxrAJ4yCsdGFVAEQQWw5aONwsMz0b6KvN4EMLXCKOwE,26905
5
+ md2conf/application.py,sha256=SIM4yLHaLnvG7wRJLbRvptrkc0q4JMuAhDnanqsuYzA,6697
6
+ md2conf/converter.py,sha256=ASXhs7g79dOU4x1QhfvKL8mtwth508GTGcb3AUHigC4,37286
7
+ md2conf/emoji.py,sha256=48QJtOD0F3Be1laYLvAOwe0GxrJS-vcfjtCdiBsNcAc,1960
8
+ md2conf/entities.dtd,sha256=M6NzqL5N7dPs_eUA_6sDsiSLzDaAacrx9LdttiufvYU,30215
9
+ md2conf/local.py,sha256=998bBRpDAOywA-L0KD4_VyuL2Xftflv0ler-uNPQZn4,3866
10
+ md2conf/matcher.py,sha256=y5WEZNklTpUoJtMJlulTvfhl_v-UMU6wySJAKit91ig,4940
11
+ md2conf/mermaid.py,sha256=ZETocFDKi_fSYyVR1pJ7fo207YYFSuT44MSYFQ8-cZ0,2562
12
+ md2conf/metadata.py,sha256=Xozg2PjJnis7VQYQT_edIvTb8u0cs_ZizPOAxc1N8vg,1003
13
+ md2conf/processor.py,sha256=jSLFy8hqZJXf3b79jp31Fn9-cm4j9xq4HDChp9pyhP0,6706
14
+ md2conf/properties.py,sha256=TOCXLdTfYkKjRwZaMgvXw0mNCI4opEUwpBXro2Kv2B4,2467
15
+ md2conf/puppeteer-config.json,sha256=-dMTAN_7kNTGbDlfXzApl0KJpAWna9YKZdwMKbpOb60,159
16
+ md2conf/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
17
+ md2conf/scanner.py,sha256=iF8NCQAFO6Yut5aAQr7uxfWzVMMt9j3T5ADoVVSJWKQ,3543
18
+ markdown_to_confluence-0.3.5.dist-info/METADATA,sha256=NiXwBXtQ5WhHce_JX7TBUSefQSR5jk5fERe46BL4vwE,18462
19
+ markdown_to_confluence-0.3.5.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
20
+ markdown_to_confluence-0.3.5.dist-info/entry_points.txt,sha256=F1zxa1wtEObtbHS-qp46330WVFLHdMnV2wQ-ZorRmX0,50
21
+ markdown_to_confluence-0.3.5.dist-info/top_level.txt,sha256=_FJfl_kHrHNidyjUOuS01ngu_jDsfc-ZjSocNRJnTzU,8
22
+ markdown_to_confluence-0.3.5.dist-info/zip-safe,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
23
+ markdown_to_confluence-0.3.5.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (80.3.1)
2
+ Generator: setuptools (80.9.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
md2conf/__init__.py CHANGED
@@ -5,7 +5,7 @@ Parses Markdown files, converts Markdown content into the Confluence Storage For
5
5
  Confluence API endpoints to upload images and content.
6
6
  """
7
7
 
8
- __version__ = "0.3.3"
8
+ __version__ = "0.3.5"
9
9
  __author__ = "Levente Hunyadi"
10
10
  __copyright__ = "Copyright 2022-2025, Levente Hunyadi"
11
11
  __license__ = "MIT"
md2conf/__main__.py CHANGED
@@ -22,8 +22,9 @@ import requests
22
22
  from . import __version__
23
23
  from .api import ConfluenceAPI
24
24
  from .application import Application
25
- from .converter import ConfluenceDocumentOptions, ConfluenceSiteMetadata
26
- from .processor import Processor
25
+ from .converter import ConfluenceDocumentOptions, ConfluencePageID
26
+ from .local import LocalConverter
27
+ from .metadata import ConfluenceSiteMetadata
27
28
  from .properties import (
28
29
  ArgumentError,
29
30
  ConfluenceConnectionProperties,
@@ -199,7 +200,7 @@ def main() -> None:
199
200
  heading_anchors=args.heading_anchors,
200
201
  ignore_invalid_url=args.ignore_invalid_url,
201
202
  generated_by=args.generated_by,
202
- root_page_id=args.root_page,
203
+ root_page_id=ConfluencePageID(args.root_page) if args.root_page else None,
203
204
  keep_hierarchy=args.keep_hierarchy,
204
205
  render_mermaid=args.render_mermaid,
205
206
  diagram_output_format=args.diagram_output_format,
@@ -219,7 +220,7 @@ def main() -> None:
219
220
  base_path=site_properties.base_path,
220
221
  space_key=site_properties.space_key,
221
222
  )
222
- Processor(options, site_metadata).process(args.mdpath)
223
+ LocalConverter(options, site_metadata).process(args.mdpath)
223
224
  else:
224
225
  try:
225
226
  properties = ConfluenceConnectionProperties(
@@ -237,7 +238,7 @@ def main() -> None:
237
238
  Application(
238
239
  api,
239
240
  options,
240
- ).synchronize(args.mdpath)
241
+ ).process(args.mdpath)
241
242
  except requests.exceptions.HTTPError as err:
242
243
  logging.error(err)
243
244
 
md2conf/api.py CHANGED
@@ -7,6 +7,7 @@ Copyright 2022-2025, Levente Hunyadi
7
7
  """
8
8
 
9
9
  import enum
10
+ import functools
10
11
  import io
11
12
  import json
12
13
  import logging
@@ -21,6 +22,7 @@ from urllib.parse import urlencode, urlparse, urlunparse
21
22
  import requests
22
23
 
23
24
  from .converter import ParseError, sanitize_confluence
25
+ from .metadata import ConfluenceSiteMetadata
24
26
  from .properties import (
25
27
  ArgumentError,
26
28
  ConfluenceConnectionProperties,
@@ -41,13 +43,23 @@ JsonType = Union[
41
43
 
42
44
 
43
45
  class ConfluenceVersion(enum.Enum):
46
+ """
47
+ Confluence REST API version an HTTP request corresponds to.
48
+
49
+ For some operations, Confluence Cloud supports v2 endpoints exclusively. However, for other operations, only v1 endpoints are available via REST API.
50
+ Some versions of Confluence Server and Data Center, unfortunately, don't support v2 endpoints at all.
51
+
52
+ The principal use case for *md2conf* is Confluence Cloud. As such, *md2conf* uses v2 endpoints when available, and resorts to v1 endpoints only when
53
+ necessary.
54
+ """
55
+
44
56
  VERSION_1 = "rest/api"
45
57
  VERSION_2 = "api/v2"
46
58
 
47
59
 
48
60
  class ConfluencePageParentContentType(enum.Enum):
49
61
  """
50
- Content types that can be a parent to a Confluence page
62
+ Content types that can be a parent to a Confluence page.
51
63
  """
52
64
 
53
65
  PAGE = "page"
@@ -76,26 +88,75 @@ def build_url(base_url: str, query: Optional[dict[str, str]] = None) -> str:
76
88
  LOGGER = logging.getLogger(__name__)
77
89
 
78
90
 
79
- @dataclass
91
+ @dataclass(frozen=True)
80
92
  class ConfluenceAttachment:
93
+ """
94
+ Holds data for an object uploaded to Confluence as a page attachment.
95
+
96
+ :param id: Unique ID for the attachment.
97
+ :param media_type: MIME type for the attachment.
98
+ :param file_size: Size in bytes.
99
+ :param comment: Description for the attachment.
100
+ """
101
+
81
102
  id: str
82
103
  media_type: str
83
104
  file_size: int
84
105
  comment: str
85
106
 
86
107
 
87
- @dataclass
88
- class ConfluencePage:
108
+ @dataclass(frozen=True)
109
+ class ConfluencePageProperties:
110
+ """
111
+ Holds Confluence page properties used for page synchronization.
112
+
113
+ :param id: Confluence page ID.
114
+ :param space_id: Confluence space ID.
115
+ :param parent_id: Confluence page ID of the immediate parent.
116
+ :param parent_type: Identifies the content type of the parent.
117
+ :param title: Page title.
118
+ :param version: Page version. Incremented when the page is updated.
119
+ """
120
+
89
121
  id: str
90
122
  space_id: str
91
123
  parent_id: str
92
124
  parent_type: Optional[ConfluencePageParentContentType]
93
125
  title: str
94
126
  version: int
127
+
128
+
129
+ @dataclass(frozen=True)
130
+ class ConfluencePage(ConfluencePageProperties):
131
+ """
132
+ Holds Confluence page data used for page synchronization.
133
+
134
+ :param content: Page content in Confluence Storage Format.
135
+ """
136
+
95
137
  content: str
96
138
 
97
139
 
140
+ @dataclass(frozen=True)
141
+ class ConfluenceLabel:
142
+ """
143
+ Holds information about a single label.
144
+
145
+ :param id: ID of the label.
146
+ :param name: Name of the label.
147
+ :param prefix: Prefix of the label.
148
+ """
149
+
150
+ id: str
151
+ name: str
152
+ prefix: str
153
+
154
+
98
155
  class ConfluenceAPI:
156
+ """
157
+ Represents an active connection to a Confluence server.
158
+ """
159
+
99
160
  properties: ConfluenceConnectionProperties
100
161
  session: Optional["ConfluenceSession"] = None
101
162
 
@@ -136,10 +197,12 @@ class ConfluenceAPI:
136
197
 
137
198
 
138
199
  class ConfluenceSession:
200
+ """
201
+ Information about an open session to a Confluence server.
202
+ """
203
+
139
204
  session: requests.Session
140
- domain: str
141
- base_path: str
142
- space_key: Optional[str]
205
+ site: ConfluenceSiteMetadata
143
206
 
144
207
  _space_id_to_key: dict[str, str]
145
208
  _space_key_to_id: dict[str, str]
@@ -152,9 +215,7 @@ class ConfluenceSession:
152
215
  space_key: Optional[str] = None,
153
216
  ) -> None:
154
217
  self.session = session
155
- self.domain = domain
156
- self.base_path = base_path
157
- self.space_key = space_key
218
+ self.site = ConfluenceSiteMetadata(domain, base_path, space_key)
158
219
 
159
220
  self._space_id_to_key = {}
160
221
  self._space_key_to_id = {}
@@ -178,7 +239,9 @@ class ConfluenceSession:
178
239
  :returns: A full URL.
179
240
  """
180
241
 
181
- base_url = f"https://{self.domain}{self.base_path}{version.value}{path}"
242
+ base_url = (
243
+ f"https://{self.site.domain}{self.site.base_path}{version.value}{path}"
244
+ )
182
245
  return build_url(base_url, query)
183
246
 
184
247
  def _invoke(
@@ -187,7 +250,7 @@ class ConfluenceSession:
187
250
  path: str,
188
251
  query: Optional[dict[str, str]] = None,
189
252
  ) -> JsonType:
190
- "Execute an HTTP request via Confluence API."
253
+ "Executes an HTTP request via Confluence API."
191
254
 
192
255
  url = self._build_url(version, path, query)
193
256
  response = self.session.get(url)
@@ -196,7 +259,33 @@ class ConfluenceSession:
196
259
  response.raise_for_status()
197
260
  return response.json()
198
261
 
199
- def _save(self, version: ConfluenceVersion, path: str, data: dict) -> None:
262
+ def _fetch(
263
+ self, path: str, query: Optional[dict[str, str]] = None
264
+ ) -> list[JsonType]:
265
+ "Retrieves all results of a REST API v2 paginated result-set."
266
+
267
+ items: list[JsonType] = []
268
+ url = self._build_url(ConfluenceVersion.VERSION_2, path, query)
269
+ while True:
270
+ response = self.session.get(url)
271
+ response.raise_for_status()
272
+
273
+ payload = typing.cast(dict[str, JsonType], response.json())
274
+ results = typing.cast(list[JsonType], payload["results"])
275
+ items.extend(results)
276
+
277
+ links = typing.cast(dict[str, JsonType], payload.get("_links", {}))
278
+ link = typing.cast(str, links.get("next", ""))
279
+ if link:
280
+ url = f"https://{self.site.domain}{link}"
281
+ else:
282
+ break
283
+
284
+ return items
285
+
286
+ def _save(self, version: ConfluenceVersion, path: str, data: JsonType) -> None:
287
+ "Persists data via Confluence REST API."
288
+
200
289
  url = self._build_url(version, path)
201
290
  response = self.session.put(
202
291
  url,
@@ -251,9 +340,36 @@ class ConfluenceSession:
251
340
 
252
341
  return id
253
342
 
343
+ def get_space_id(
344
+ self, *, space_id: Optional[str] = None, space_key: Optional[str] = None
345
+ ) -> Optional[str]:
346
+ """
347
+ Coalesces a space ID or space key into a space ID, accounting for site default.
348
+
349
+ :param space_id: A Confluence space ID.
350
+ :param space_key: A Confluence space key.
351
+ """
352
+
353
+ if space_id is not None and space_key is not None:
354
+ raise ConfluenceError("either space ID or space key is required; not both")
355
+
356
+ if space_id is not None:
357
+ return space_id
358
+
359
+ space_key = space_key or self.site.space_key
360
+ if space_key is not None:
361
+ return self.space_key_to_id(space_key)
362
+
363
+ # space ID and key are unset, and no default space is configured
364
+ return None
365
+
254
366
  def get_attachment_by_name(
255
367
  self, page_id: str, filename: str
256
368
  ) -> ConfluenceAttachment:
369
+ """
370
+ Retrieves a Confluence page attachment by an unprefixed file name.
371
+ """
372
+
257
373
  path = f"/pages/{page_id}/attachments"
258
374
  query = {"filename": filename}
259
375
  data = typing.cast(
@@ -282,6 +398,18 @@ class ConfluenceSession:
282
398
  comment: Optional[str] = None,
283
399
  force: bool = False,
284
400
  ) -> None:
401
+ """
402
+ Uploads a new attachment to a Confluence page.
403
+
404
+ :param page_id: Confluence page ID.
405
+ :param attachment_name: Unprefixed name unique to the page.
406
+ :param attachment_path: Path to the file to upload as an attachment.
407
+ :param raw_data: Raw data to upload as an attachment.
408
+ :param content_type: Attachment MIME type.
409
+ :param comment: Attachment description.
410
+ :param force: Overwrite an existing attachment even if there seem to be no changes.
411
+ """
412
+
285
413
  if attachment_path is None and raw_data is None:
286
414
  raise ArgumentError("required: `attachment_path` or `raw_data`")
287
415
 
@@ -378,7 +506,7 @@ class ConfluenceSession:
378
506
  ) -> None:
379
507
  id = attachment_id.removeprefix("att")
380
508
  path = f"/content/{page_id}/child/attachment/{id}"
381
- data = {
509
+ data: JsonType = {
382
510
  "id": attachment_id,
383
511
  "type": "attachment",
384
512
  "status": "current",
@@ -393,13 +521,15 @@ class ConfluenceSession:
393
521
  self,
394
522
  title: str,
395
523
  *,
524
+ space_id: Optional[str] = None,
396
525
  space_key: Optional[str] = None,
397
526
  ) -> str:
398
527
  """
399
- Look up a Confluence wiki page ID by title.
528
+ Looks up a Confluence wiki page ID by title.
400
529
 
401
530
  :param title: The page title.
402
- :param space_key: The Confluence space key (unless the default space is to be used).
531
+ :param space_id: The Confluence space ID (unless the default space is to be used). Exclusive with space key.
532
+ :param space_key: The Confluence space key (unless the default space is to be used). Exclusive with space ID.
403
533
  :returns: Confluence page ID.
404
534
  """
405
535
 
@@ -408,9 +538,9 @@ class ConfluenceSession:
408
538
  query = {
409
539
  "title": title,
410
540
  }
411
- coalesced_space_key = space_key or self.space_key
412
- if coalesced_space_key is not None:
413
- query["space-id"] = self.space_key_to_id(coalesced_space_key)
541
+ space_id = self.get_space_id(space_id=space_id, space_key=space_key)
542
+ if space_id is not None:
543
+ query["space-id"] = space_id
414
544
 
415
545
  payload = self._invoke(ConfluenceVersion.VERSION_2, path, query)
416
546
  payload = typing.cast(dict[str, JsonType], payload)
@@ -425,10 +555,10 @@ class ConfluenceSession:
425
555
 
426
556
  def get_page(self, page_id: str) -> ConfluencePage:
427
557
  """
428
- Retrieve Confluence wiki page details.
558
+ Retrieves Confluence wiki page details and content.
429
559
 
430
560
  :param page_id: The Confluence page ID.
431
- :returns: Confluence page info.
561
+ :returns: Confluence page info and content.
432
562
  """
433
563
 
434
564
  path = f"/pages/{page_id}"
@@ -453,9 +583,36 @@ class ConfluenceSession:
453
583
  content=typing.cast(str, storage["value"]),
454
584
  )
455
585
 
586
+ @functools.cache
587
+ def get_page_properties(self, page_id: str) -> ConfluencePageProperties:
588
+ """
589
+ Retrieves Confluence wiki page details.
590
+
591
+ :param page_id: The Confluence page ID.
592
+ :returns: Confluence page info.
593
+ """
594
+
595
+ path = f"/pages/{page_id}"
596
+ payload = self._invoke(ConfluenceVersion.VERSION_2, path)
597
+ data = typing.cast(dict[str, JsonType], payload)
598
+ version = typing.cast(dict[str, JsonType], data["version"])
599
+
600
+ return ConfluencePageProperties(
601
+ id=page_id,
602
+ space_id=typing.cast(str, data["spaceId"]),
603
+ parent_id=typing.cast(str, data["parentId"]),
604
+ parent_type=(
605
+ ConfluencePageParentContentType(typing.cast(str, data["parentType"]))
606
+ if data["parentType"] is not None
607
+ else None
608
+ ),
609
+ title=typing.cast(str, data["title"]),
610
+ version=typing.cast(int, version["number"]),
611
+ )
612
+
456
613
  def get_page_version(self, page_id: str) -> int:
457
614
  """
458
- Retrieve a Confluence wiki page version.
615
+ Retrieves a Confluence wiki page version.
459
616
 
460
617
  :param page_id: The Confluence page ID.
461
618
  :returns: Confluence page version.
@@ -475,7 +632,7 @@ class ConfluenceSession:
475
632
  title: Optional[str] = None,
476
633
  ) -> None:
477
634
  """
478
- Update a page via the Confluence API.
635
+ Updates a page via the Confluence API.
479
636
 
480
637
  :param page_id: The Confluence page ID.
481
638
  :param new_content: Confluence Storage Format XHTML.
@@ -494,7 +651,7 @@ class ConfluenceSession:
494
651
  LOGGER.warning(exc)
495
652
 
496
653
  path = f"/pages/{page_id}"
497
- data = {
654
+ data: JsonType = {
498
655
  "id": page_id,
499
656
  "status": "current",
500
657
  "title": new_title,
@@ -507,26 +664,21 @@ class ConfluenceSession:
507
664
 
508
665
  def create_page(
509
666
  self,
510
- parent_page_id: str,
667
+ parent_id: str,
511
668
  title: str,
512
669
  new_content: str,
513
- *,
514
- space_key: Optional[str] = None,
515
670
  ) -> ConfluencePage:
516
671
  """
517
- Create a new page via Confluence API.
672
+ Creates a new page via Confluence API.
518
673
  """
519
674
 
520
- coalesced_space_key = space_key or self.space_key
521
- if coalesced_space_key is None:
522
- raise ArgumentError("Confluence space key required for creating a new page")
523
-
675
+ parent_page = self.get_page_properties(parent_id)
524
676
  path = "/pages/"
525
677
  query = {
526
- "spaceId": self.space_key_to_id(coalesced_space_key),
678
+ "spaceId": parent_page.space_id,
527
679
  "status": "current",
528
680
  "title": title,
529
- "parentId": parent_page_id,
681
+ "parentId": parent_id,
530
682
  "body": {"storage": {"value": new_content, "representation": "storage"}},
531
683
  }
532
684
 
@@ -561,10 +713,10 @@ class ConfluenceSession:
561
713
 
562
714
  def delete_page(self, page_id: str, *, purge: bool = False) -> None:
563
715
  """
564
- Delete a page via Confluence API.
716
+ Deletes a page via Confluence API.
565
717
 
566
718
  :param page_id: The Confluence page ID.
567
- :param purge: True to completely purge the page, False to move to trash only.
719
+ :param purge: `True` to completely purge the page, `False` to move to trash only.
568
720
  """
569
721
 
570
722
  path = f"/pages/{page_id}"
@@ -584,13 +736,26 @@ class ConfluenceSession:
584
736
  response.raise_for_status()
585
737
 
586
738
  def page_exists(
587
- self, title: str, *, space_key: Optional[str] = None
739
+ self,
740
+ title: str,
741
+ *,
742
+ space_id: Optional[str] = None,
743
+ space_key: Optional[str] = None,
588
744
  ) -> Optional[str]:
745
+ """
746
+ Checks if a Confluence page exists with the given title.
747
+
748
+ :param title: Page title. Pages in the same Confluence space must have a unique title.
749
+ :param space_key: Identifies the Confluence space.
750
+
751
+ :returns: Confluence page ID of a matching page (if found), or `None`.
752
+ """
753
+
754
+ space_id = self.get_space_id(space_id=space_id, space_key=space_key)
589
755
  path = "/pages"
590
- coalesced_space_key = space_key or self.space_key
591
756
  query = {"title": title}
592
- if coalesced_space_key is not None:
593
- query["space-id"] = self.space_key_to_id(coalesced_space_key)
757
+ if space_id is not None:
758
+ query["space-id"] = space_id
594
759
 
595
760
  LOGGER.info("Checking if page exists with title: %s", title)
596
761
 
@@ -609,14 +774,39 @@ class ConfluenceSession:
609
774
  else:
610
775
  return None
611
776
 
612
- def get_or_create_page(
613
- self, title: str, parent_id: str, *, space_key: Optional[str] = None
614
- ) -> ConfluencePage:
615
- page_id = self.page_exists(title)
777
+ def get_or_create_page(self, title: str, parent_id: str) -> ConfluencePage:
778
+ """
779
+ Finds a page with the given title, or creates a new page if no such page exists.
780
+
781
+ :param title: Page title. Pages in the same Confluence space must have a unique title.
782
+ :param parent_id: Identifies the parent page for a new child page.
783
+ """
784
+
785
+ parent_page = self.get_page_properties(parent_id)
786
+ page_id = self.page_exists(title, space_id=parent_page.space_id)
616
787
 
617
788
  if page_id is not None:
618
789
  LOGGER.debug("Retrieving existing page: %s", page_id)
619
790
  return self.get_page(page_id)
620
791
  else:
621
792
  LOGGER.debug("Creating new page with title: %s", title)
622
- return self.create_page(parent_id, title, "", space_key=space_key)
793
+ return self.create_page(parent_id, title, "")
794
+
795
+ def get_labels(self, page_id: str) -> list[ConfluenceLabel]:
796
+ """
797
+ Retrieves labels for a Confluence page.
798
+
799
+ :param page_id: The Confluence page ID.
800
+ :returns: A list of page labels.
801
+ """
802
+
803
+ items: list[ConfluenceLabel] = []
804
+ path = f"/pages/{page_id}/labels"
805
+ results = self._fetch(path)
806
+ for r in results:
807
+ result = typing.cast(dict[str, JsonType], r)
808
+ id = typing.cast(str, result["id"])
809
+ name = typing.cast(str, result["name"])
810
+ prefix = typing.cast(str, result["prefix"])
811
+ items.append(ConfluenceLabel(id, name, prefix))
812
+ return items