markdown-to-confluence 0.4.5__py3-none-any.whl → 0.4.7__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.
md2conf/api.py CHANGED
@@ -11,19 +11,28 @@ import enum
11
11
  import io
12
12
  import logging
13
13
  import mimetypes
14
+ import ssl
15
+ import sys
14
16
  import typing
15
17
  from dataclasses import dataclass
16
18
  from pathlib import Path
17
19
  from types import TracebackType
18
- from typing import Any, Optional, TypeVar
20
+ from typing import Any, Optional, TypeVar, overload
19
21
  from urllib.parse import urlencode, urlparse, urlunparse
20
22
 
21
23
  import requests
24
+ from requests.adapters import HTTPAdapter
22
25
  from strong_typing.core import JsonType
23
26
  from strong_typing.serialization import DeserializerOptions, json_dump_string, json_to_object, object_to_json
24
27
 
28
+ from .environment import ArgumentError, ConfluenceConnectionProperties, ConfluenceError, PageError
29
+ from .extra import override
25
30
  from .metadata import ConfluenceSiteMetadata
26
- from .properties import ArgumentError, ConfluenceConnectionProperties, ConfluenceError, PageError
31
+
32
+ if sys.version_info >= (3, 10):
33
+ import truststore
34
+ else:
35
+ import certifi
27
36
 
28
37
  T = TypeVar("T")
29
38
 
@@ -62,16 +71,24 @@ def build_url(base_url: str, query: Optional[dict[str, str]] = None) -> str:
62
71
  LOGGER = logging.getLogger(__name__)
63
72
 
64
73
 
65
- def response_cast(response_type: type[T], response: requests.Response) -> T:
74
+ @overload
75
+ def response_cast(response_type: None, response: requests.Response) -> None: ...
76
+
77
+
78
+ @overload
79
+ def response_cast(response_type: type[T], response: requests.Response) -> T: ...
80
+
81
+
82
+ def response_cast(response_type: Optional[type[T]], response: requests.Response) -> Optional[T]:
66
83
  "Converts a response body into the expected type."
67
84
 
68
85
  if response.text:
69
86
  LOGGER.debug("Received HTTP payload:\n%s", response.text)
70
87
  response.raise_for_status()
71
- if response_type is not type(None):
72
- return _json_to_object(response_type, response.json())
73
- else:
88
+ if response_type is None:
74
89
  return None
90
+ else:
91
+ return _json_to_object(response_type, response.json())
75
92
 
76
93
 
77
94
  @enum.unique
@@ -336,9 +353,33 @@ class ConfluenceUpdateAttachmentRequest:
336
353
  version: ConfluenceContentVersion
337
354
 
338
355
 
356
+ class TruststoreAdapter(HTTPAdapter):
357
+ """
358
+ Provides a general-case interface for HTTPS sessions to connect to HTTPS URLs.
359
+
360
+ This class implements the Transport Adapter interface in the Python library `requests`.
361
+
362
+ This class will usually be created by the :class:`requests.Session` class under the covers.
363
+ """
364
+
365
+ @override
366
+ def init_poolmanager(self, connections: int, maxsize: int, block: bool = False, **pool_kwargs: Any) -> None:
367
+ """
368
+ Adapts the pool manager to use the provided SSL context instead of the default.
369
+ """
370
+
371
+ if sys.version_info >= (3, 10):
372
+ ctx = truststore.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
373
+ else:
374
+ ctx = ssl.create_default_context(ssl.Purpose.SERVER_AUTH, cafile=certifi.where())
375
+ ctx.check_hostname = True
376
+ ctx.verify_mode = ssl.CERT_REQUIRED
377
+ super().init_poolmanager(connections, maxsize, block, ssl_context=ctx, **pool_kwargs) # type: ignore[no-untyped-call]
378
+
379
+
339
380
  class ConfluenceAPI:
340
381
  """
341
- Represents an active connection to a Confluence server.
382
+ Encapsulates operations that can be invoked via the [Confluence REST API](https://developer.atlassian.com/cloud/confluence/rest/v2/).
342
383
  """
343
384
 
344
385
  properties: ConfluenceConnectionProperties
@@ -348,7 +389,13 @@ class ConfluenceAPI:
348
389
  self.properties = properties or ConfluenceConnectionProperties()
349
390
 
350
391
  def __enter__(self) -> "ConfluenceSession":
392
+ """
393
+ Opens a connection to a Confluence server.
394
+ """
395
+
351
396
  session = requests.Session()
397
+ session.mount("https://", TruststoreAdapter())
398
+
352
399
  if self.properties.user_name:
353
400
  session.auth = (self.properties.user_name, self.properties.api_key)
354
401
  else:
@@ -372,6 +419,10 @@ class ConfluenceAPI:
372
419
  exc_val: Optional[BaseException],
373
420
  exc_tb: Optional[TracebackType],
374
421
  ) -> None:
422
+ """
423
+ Closes an open connection.
424
+ """
425
+
375
426
  if self.session is not None:
376
427
  self.session.close()
377
428
  self.session = None
@@ -379,7 +430,7 @@ class ConfluenceAPI:
379
430
 
380
431
  class ConfluenceSession:
381
432
  """
382
- Information about an open session to a Confluence server.
433
+ Represents an active connection to a Confluence server.
383
434
  """
384
435
 
385
436
  session: requests.Session
@@ -407,7 +458,7 @@ class ConfluenceSession:
407
458
 
408
459
  if not domain or not base_path:
409
460
  data = self._get(ConfluenceVersion.VERSION_2, "/spaces", ConfluenceResultSet, query={"limit": "1"})
410
- base_url = data._links.base
461
+ base_url = data._links.base # pyright: ignore[reportPrivateUsage]
411
462
 
412
463
  _, domain, base_path, _, _, _ = urlparse(base_url)
413
464
  if not base_path.endswith("/"):
@@ -418,8 +469,31 @@ class ConfluenceSession:
418
469
  if not base_path:
419
470
  raise ArgumentError("Confluence base path not specified and cannot be inferred")
420
471
  self.site = ConfluenceSiteMetadata(domain, base_path, space_key)
472
+
421
473
  if not api_url:
422
- self.api_url = f"https://{self.site.domain}{self.site.base_path}"
474
+ LOGGER.info("Discovering Confluence REST API URL")
475
+ try:
476
+ # obtain cloud ID to build URL for access with scoped token
477
+ response = self.session.get(f"https://{self.site.domain}/_edge/tenant_info", headers={"Accept": "application/json"}, verify=True)
478
+ if response.text:
479
+ LOGGER.debug("Received HTTP payload:\n%s", response.text)
480
+ response.raise_for_status()
481
+ cloud_id = response.json()["cloudId"]
482
+
483
+ # try next-generation REST API URL
484
+ LOGGER.info("Probing scoped Confluence REST API URL")
485
+ self.api_url = f"https://api.atlassian.com/ex/confluence/{cloud_id}/"
486
+ url = self._build_url(ConfluenceVersion.VERSION_2, "/spaces", {"limit": "1"})
487
+ response = self.session.get(url, headers={"Accept": "application/json"}, verify=True)
488
+ if response.text:
489
+ LOGGER.debug("Received HTTP payload:\n%s", response.text)
490
+ response.raise_for_status()
491
+
492
+ LOGGER.info("Configured scoped Confluence REST API URL: %s", self.api_url)
493
+ except requests.exceptions.HTTPError:
494
+ # fall back to classic REST API URL
495
+ self.api_url = f"https://{self.site.domain}{self.site.base_path}"
496
+ LOGGER.info("Configured classic Confluence REST API URL: %s", self.api_url)
423
497
 
424
498
  def close(self) -> None:
425
499
  self.session.close()
@@ -454,7 +528,7 @@ class ConfluenceSession:
454
528
  "Executes an HTTP request via Confluence API."
455
529
 
456
530
  url = self._build_url(version, path, query)
457
- response = self.session.get(url, headers={"Accept": "application/json"})
531
+ response = self.session.get(url, headers={"Accept": "application/json"}, verify=True)
458
532
  if response.text:
459
533
  LOGGER.debug("Received HTTP payload:\n%s", response.text)
460
534
  response.raise_for_status()
@@ -466,7 +540,7 @@ class ConfluenceSession:
466
540
  items: list[JsonType] = []
467
541
  url = self._build_url(ConfluenceVersion.VERSION_2, path, query)
468
542
  while True:
469
- response = self.session.get(url, headers={"Accept": "application/json"})
543
+ response = self.session.get(url, headers={"Accept": "application/json"}, verify=True)
470
544
  response.raise_for_status()
471
545
 
472
546
  payload = typing.cast(dict[str, JsonType], response.json())
@@ -482,42 +556,42 @@ class ConfluenceSession:
482
556
 
483
557
  return items
484
558
 
485
- def _build_request(self, version: ConfluenceVersion, path: str, body: Any, response_type: type[T]) -> tuple[str, dict[str, str], bytes]:
559
+ def _build_request(self, version: ConfluenceVersion, path: str, body: Any, response_type: Optional[type[T]]) -> tuple[str, dict[str, str], bytes]:
486
560
  "Generates URL, headers and raw payload for a typed request/response."
487
561
 
488
562
  url = self._build_url(version, path)
489
- if response_type is not type(None):
490
- headers = {
491
- "Content-Type": "application/json; charset=utf-8",
492
- "Accept": "application/json",
493
- }
494
- else:
495
- headers = {
496
- "Content-Type": "application/json; charset=utf-8",
497
- }
563
+ headers = {"Content-Type": "application/json"}
564
+ if response_type is not None:
565
+ headers["Accept"] = "application/json"
498
566
  data = json_dump_string(object_to_json(body)).encode("utf-8")
499
567
  return url, headers, data
500
568
 
501
- def _post(self, version: ConfluenceVersion, path: str, body: Any, response_type: type[T]) -> T:
569
+ @overload
570
+ def _post(self, version: ConfluenceVersion, path: str, body: Any, response_type: None) -> None: ...
571
+
572
+ @overload
573
+ def _post(self, version: ConfluenceVersion, path: str, body: Any, response_type: type[T]) -> T: ...
574
+
575
+ def _post(self, version: ConfluenceVersion, path: str, body: Any, response_type: Optional[type[T]]) -> Optional[T]:
502
576
  "Creates a new object via Confluence REST API."
503
577
 
504
578
  url, headers, data = self._build_request(version, path, body, response_type)
505
- response = self.session.post(
506
- url,
507
- data=data,
508
- headers=headers,
509
- )
579
+ response = self.session.post(url, data=data, headers=headers, verify=True)
580
+ response.raise_for_status()
510
581
  return response_cast(response_type, response)
511
582
 
512
- def _put(self, version: ConfluenceVersion, path: str, body: Any, response_type: type[T]) -> T:
583
+ @overload
584
+ def _put(self, version: ConfluenceVersion, path: str, body: Any, response_type: None) -> None: ...
585
+
586
+ @overload
587
+ def _put(self, version: ConfluenceVersion, path: str, body: Any, response_type: type[T]) -> T: ...
588
+
589
+ def _put(self, version: ConfluenceVersion, path: str, body: Any, response_type: Optional[type[T]]) -> Optional[T]:
513
590
  "Updates an existing object via Confluence REST API."
514
591
 
515
592
  url, headers, data = self._build_request(version, path, body, response_type)
516
- response = self.session.put(
517
- url,
518
- data=data,
519
- headers=headers,
520
- )
593
+ response = self.session.put(url, data=data, headers=headers, verify=True)
594
+ response.raise_for_status()
521
595
  return response_cast(response_type, response)
522
596
 
523
597
  def space_id_to_key(self, id: str) -> str:
@@ -687,6 +761,7 @@ class ConfluenceSession:
687
761
  "X-Atlassian-Token": "no-check",
688
762
  "Accept": "application/json",
689
763
  },
764
+ verify=True,
690
765
  )
691
766
  elif raw_data is not None:
692
767
  LOGGER.info("Uploading raw data: %s", attachment_name)
@@ -714,6 +789,7 @@ class ConfluenceSession:
714
789
  "X-Atlassian-Token": "no-check",
715
790
  "Accept": "application/json",
716
791
  },
792
+ verify=True,
717
793
  )
718
794
  else:
719
795
  raise NotImplementedError("parameter match not exhaustive")
@@ -744,7 +820,7 @@ class ConfluenceSession:
744
820
  )
745
821
 
746
822
  LOGGER.info("Updating attachment: %s", attachment_id)
747
- self._put(ConfluenceVersion.VERSION_1, path, request, type(None))
823
+ self._put(ConfluenceVersion.VERSION_1, path, request, None)
748
824
 
749
825
  def get_page_properties_by_title(
750
826
  self,
@@ -837,7 +913,7 @@ class ConfluenceSession:
837
913
  version=ConfluenceContentVersion(number=version, minorEdit=True),
838
914
  )
839
915
  LOGGER.info("Updating page: %s", page_id)
840
- self._put(ConfluenceVersion.VERSION_2, path, request, type(None))
916
+ self._put(ConfluenceVersion.VERSION_2, path, request, None)
841
917
 
842
918
  def create_page(
843
919
  self,
@@ -872,9 +948,10 @@ class ConfluenceSession:
872
948
  url,
873
949
  data=json_dump_string(object_to_json(request)).encode("utf-8"),
874
950
  headers={
875
- "Content-Type": "application/json; charset=utf-8",
951
+ "Content-Type": "application/json",
876
952
  "Accept": "application/json",
877
953
  },
954
+ verify=True,
878
955
  )
879
956
  response.raise_for_status()
880
957
  return _json_to_object(ConfluencePage, response.json())
@@ -892,7 +969,7 @@ class ConfluenceSession:
892
969
  # move to trash
893
970
  url = self._build_url(ConfluenceVersion.VERSION_2, path)
894
971
  LOGGER.info("Moving page to trash: %s", page_id)
895
- response = self.session.delete(url)
972
+ response = self.session.delete(url, verify=True)
896
973
  response.raise_for_status()
897
974
 
898
975
  if purge:
@@ -900,7 +977,7 @@ class ConfluenceSession:
900
977
  query = {"purge": "true"}
901
978
  url = self._build_url(ConfluenceVersion.VERSION_2, path, query)
902
979
  LOGGER.info("Permanently deleting page: %s", page_id)
903
- response = self.session.delete(url)
980
+ response = self.session.delete(url, verify=True)
904
981
  response.raise_for_status()
905
982
 
906
983
  def page_exists(
@@ -932,9 +1009,10 @@ class ConfluenceSession:
932
1009
  url,
933
1010
  params=query,
934
1011
  headers={
935
- "Content-Type": "application/json; charset=utf-8",
1012
+ "Content-Type": "application/json",
936
1013
  "Accept": "application/json",
937
1014
  },
1015
+ verify=True,
938
1016
  )
939
1017
  response.raise_for_status()
940
1018
  data = typing.cast(dict[str, JsonType], response.json())
@@ -984,7 +1062,7 @@ class ConfluenceSession:
984
1062
  """
985
1063
 
986
1064
  path = f"/content/{page_id}/label"
987
- self._post(ConfluenceVersion.VERSION_1, path, labels, type(None))
1065
+ self._post(ConfluenceVersion.VERSION_1, path, labels, None)
988
1066
 
989
1067
  def remove_labels(self, page_id: str, labels: list[ConfluenceLabel]) -> None:
990
1068
  """
@@ -999,7 +1077,7 @@ class ConfluenceSession:
999
1077
  query = {"name": label.name}
1000
1078
 
1001
1079
  url = self._build_url(ConfluenceVersion.VERSION_1, path, query)
1002
- response = self.session.delete(url)
1080
+ response = self.session.delete(url, verify=True)
1003
1081
  if response.text:
1004
1082
  LOGGER.debug("Received HTTP payload:\n%s", response.text)
1005
1083
  response.raise_for_status()
@@ -1058,7 +1136,7 @@ class ConfluenceSession:
1058
1136
 
1059
1137
  path = f"/pages/{page_id}/properties/{property_id}"
1060
1138
  url = self._build_url(ConfluenceVersion.VERSION_2, path)
1061
- response = self.session.delete(url)
1139
+ response = self.session.delete(url, verify=True)
1062
1140
  response.raise_for_status()
1063
1141
 
1064
1142
  def update_content_property_for_page(