markdown-to-confluence 0.5.4__py3-none-any.whl → 0.5.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.
md2conf/api.py CHANGED
@@ -402,12 +402,7 @@ class ConfluenceAPI:
402
402
  )
403
403
  return self.session
404
404
 
405
- def __exit__(
406
- self,
407
- exc_type: type[BaseException] | None,
408
- exc_val: BaseException | None,
409
- exc_tb: TracebackType | None,
410
- ) -> None:
405
+ def __exit__(self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: TracebackType | None) -> None:
411
406
  """
412
407
  Closes an open connection.
413
408
  """
@@ -429,15 +424,7 @@ class ConfluenceSession:
429
424
  _space_id_to_key: dict[str, str]
430
425
  _space_key_to_id: dict[str, str]
431
426
 
432
- def __init__(
433
- self,
434
- session: requests.Session,
435
- *,
436
- api_url: str | None,
437
- domain: str | None,
438
- base_path: str | None,
439
- space_key: str | None,
440
- ) -> None:
427
+ def __init__(self, session: requests.Session, *, api_url: str | None, domain: str | None, base_path: str | None, space_key: str | None) -> None:
441
428
  self.session = session
442
429
  self._space_id_to_key = {}
443
430
  self._space_key_to_id = {}
@@ -488,12 +475,7 @@ class ConfluenceSession:
488
475
  self.session.close()
489
476
  self.session = requests.Session()
490
477
 
491
- def _build_url(
492
- self,
493
- version: ConfluenceVersion,
494
- path: str,
495
- query: dict[str, str] | None = None,
496
- ) -> str:
478
+ def _build_url(self, version: ConfluenceVersion, path: str, query: dict[str, str] | None = None) -> str:
497
479
  """
498
480
  Builds a full URL for invoking the Confluence API.
499
481
 
@@ -506,14 +488,7 @@ class ConfluenceSession:
506
488
  base_url = f"{self.api_url}{version.value}{path}"
507
489
  return build_url(base_url, query)
508
490
 
509
- def _get(
510
- self,
511
- version: ConfluenceVersion,
512
- path: str,
513
- response_type: type[T],
514
- *,
515
- query: dict[str, str] | None = None,
516
- ) -> T:
491
+ def _get(self, version: ConfluenceVersion, path: str, response_type: type[T], *, query: dict[str, str] | None = None) -> T:
517
492
  "Executes an HTTP request via Confluence API."
518
493
 
519
494
  url = self._build_url(version, path, query)
@@ -829,13 +804,7 @@ class ConfluenceSession:
829
804
  LOGGER.info("Updating attachment: %s", attachment_id)
830
805
  self._put(ConfluenceVersion.VERSION_1, path, request, None)
831
806
 
832
- def get_page_properties_by_title(
833
- self,
834
- title: str,
835
- *,
836
- space_id: str | None = None,
837
- space_key: str | None = None,
838
- ) -> ConfluencePageProperties:
807
+ def get_page_properties_by_title(self, title: str, *, space_id: str | None = None, space_key: str | None = None) -> ConfluencePageProperties:
839
808
  """
840
809
  Looks up a Confluence wiki page ID by title.
841
810
 
@@ -916,14 +885,7 @@ class ConfluenceSession:
916
885
 
917
886
  return self.get_page_properties(page_id).version.number
918
887
 
919
- def update_page(
920
- self,
921
- page_id: str,
922
- content: str,
923
- *,
924
- title: str,
925
- version: int,
926
- ) -> None:
888
+ def update_page(self, page_id: str, content: str, *, title: str, version: int, message: str) -> None:
927
889
  """
928
890
  Updates a page via the Confluence API.
929
891
 
@@ -939,35 +901,28 @@ class ConfluenceSession:
939
901
  status=ConfluenceStatus.CURRENT,
940
902
  title=title,
941
903
  body=ConfluencePageBody(storage=ConfluencePageStorage(representation=ConfluenceRepresentation.STORAGE, value=content)),
942
- version=ConfluenceContentVersion(number=version, minorEdit=True),
904
+ version=ConfluenceContentVersion(number=version, minorEdit=True, message=message),
943
905
  )
944
906
  LOGGER.info("Updating page: %s", page_id)
945
907
  self._put(ConfluenceVersion.VERSION_2, path, request, None)
946
908
 
947
- def create_page(
948
- self,
949
- parent_id: str,
950
- title: str,
951
- new_content: str,
952
- ) -> ConfluencePage:
909
+ def create_page(self, *, title: str, content: str, parent_id: str, space_id: str) -> ConfluencePage:
953
910
  """
954
911
  Creates a new page via Confluence API.
955
912
  """
956
913
 
957
914
  LOGGER.info("Creating page: %s", title)
958
915
 
959
- parent_page = self.get_page_properties(parent_id)
960
-
961
916
  path = "/pages/"
962
917
  request = ConfluenceCreatePageRequest(
963
- spaceId=parent_page.spaceId,
918
+ spaceId=space_id,
964
919
  status=ConfluenceStatus.CURRENT,
965
920
  title=title,
966
921
  parentId=parent_id,
967
922
  body=ConfluencePageBody(
968
923
  storage=ConfluencePageStorage(
969
924
  representation=ConfluenceRepresentation.STORAGE,
970
- value=new_content,
925
+ value=content,
971
926
  )
972
927
  ),
973
928
  )
@@ -1009,23 +964,15 @@ class ConfluenceSession:
1009
964
  response = self.session.delete(url, verify=True)
1010
965
  response.raise_for_status()
1011
966
 
1012
- def page_exists(
1013
- self,
1014
- title: str,
1015
- *,
1016
- space_id: str | None = None,
1017
- space_key: str | None = None,
1018
- ) -> str | None:
967
+ def page_exists(self, title: str, *, space_id: str | None = None) -> str | None:
1019
968
  """
1020
969
  Checks if a Confluence page exists with the given title.
1021
970
 
1022
971
  :param title: Page title. Pages in the same Confluence space must have a unique title.
1023
- :param space_key: Identifies the Confluence space.
1024
-
972
+ :param space_id: Identifies the Confluence space.
1025
973
  :returns: Confluence page ID of a matching page (if found), or `None`.
1026
974
  """
1027
975
 
1028
- space_id = self._get_space_id(space_id=space_id, space_key=space_key)
1029
976
  path = "/pages"
1030
977
  query = {"title": title}
1031
978
  if space_id is not None:
@@ -1062,14 +1009,15 @@ class ConfluenceSession:
1062
1009
  """
1063
1010
 
1064
1011
  parent_page = self.get_page_properties(parent_id)
1065
- page_id = self.page_exists(title, space_id=parent_page.spaceId)
1012
+ space_id = parent_page.spaceId
1013
+ page_id = self.page_exists(title, space_id=space_id)
1066
1014
 
1067
1015
  if page_id is not None:
1068
1016
  LOGGER.debug("Retrieving existing page: %s", page_id)
1069
1017
  return self.get_page(page_id)
1070
1018
  else:
1071
1019
  LOGGER.debug("Creating new page with title: %s", title)
1072
- return self.create_page(parent_id, title, "")
1020
+ return self.create_page(title=title, content="", parent_id=parent_id, space_id=space_id)
1073
1021
 
1074
1022
  def get_labels(self, page_id: str) -> list[ConfluenceIdentifiedLabel]:
1075
1023
  """
@@ -1133,6 +1081,23 @@ class ConfluenceSession:
1133
1081
  remove_labels.sort()
1134
1082
  self.remove_labels(page_id, remove_labels)
1135
1083
 
1084
+ def get_content_property_for_page(self, page_id: str, key: str) -> ConfluenceIdentifiedContentProperty | None:
1085
+ """
1086
+ Retrieves a content property for a Confluence page.
1087
+
1088
+ :param page_id: The Confluence page ID.
1089
+ :param key: The name of the property to fetch (with case-sensitive match).
1090
+ :returns: The content property value, or `None` if not found.
1091
+ """
1092
+
1093
+ path = f"/pages/{page_id}/properties"
1094
+ results = self._fetch(path, query={"key": key})
1095
+ properties = json_to_object(list[ConfluenceIdentifiedContentProperty], results)
1096
+ if len(properties) == 1:
1097
+ return properties.pop()
1098
+ else:
1099
+ return None
1100
+
1136
1101
  def get_content_properties_for_page(self, page_id: str) -> list[ConfluenceIdentifiedContentProperty]:
1137
1102
  """
1138
1103
  Retrieves content properties for a Confluence page.
md2conf/attachment.py CHANGED
@@ -40,6 +40,9 @@ class AttachmentCatalog:
40
40
  self.embedded_files[filename] = data
41
41
 
42
42
 
43
+ _DISALLOWED_CHAR_REGEXP = re.compile(r"[^\-0-9A-Za-z_.]", re.UNICODE)
44
+
45
+
43
46
  def attachment_name(ref: Path | str) -> str:
44
47
  """
45
48
  Safe name for use with attachment uploads.
@@ -60,13 +63,11 @@ def attachment_name(ref: Path | str) -> str:
60
63
  if path.drive or path.root:
61
64
  raise ValueError(f"required: relative path; got: {ref}")
62
65
 
63
- regexp = re.compile(r"[^\-0-9A-Za-z_.]", re.UNICODE)
64
-
65
66
  def replace_part(part: str) -> str:
66
67
  if part == "..":
67
68
  return "PAR"
68
69
  else:
69
- return regexp.sub("_", part)
70
+ return _DISALLOWED_CHAR_REGEXP.sub("_", part)
70
71
 
71
72
  parts = [replace_part(p) for p in path.parts]
72
73
  return Path(*parts).as_posix().replace("/", "_")
md2conf/clio.py ADDED
@@ -0,0 +1,226 @@
1
+ """
2
+ Publish Markdown files to Confluence wiki.
3
+
4
+ Copyright 2022-2026, Levente Hunyadi
5
+
6
+ :see: https://github.com/hunyadi/md2conf
7
+ """
8
+
9
+ from argparse import ArgumentParser, Namespace
10
+ from dataclasses import MISSING, Field, dataclass, fields, is_dataclass
11
+ from types import NoneType, UnionType
12
+ from typing import Any, Literal, TypeVar, cast, get_args, get_origin
13
+
14
+ from .compatibility import LiteralString
15
+
16
+
17
+ class BaseOption:
18
+ @staticmethod
19
+ def field_name() -> str:
20
+ return "argument"
21
+
22
+
23
+ @dataclass
24
+ class BooleanOption(BaseOption):
25
+ true_text: LiteralString
26
+ false_text: LiteralString
27
+
28
+
29
+ def boolean_option(true_text: LiteralString, false_text: LiteralString) -> dict[str, Any]:
30
+ "Identifies a command-line argument as a boolean (on/off) flag."
31
+
32
+ return {BaseOption.field_name(): BooleanOption(true_text, false_text)}
33
+
34
+
35
+ @dataclass
36
+ class ValueOption(BaseOption):
37
+ text: LiteralString
38
+
39
+
40
+ def value_option(text: LiteralString) -> dict[str, Any]:
41
+ "Identifies a command-line argument as an option that assigns a value."
42
+
43
+ return {BaseOption.field_name(): ValueOption(text)}
44
+
45
+
46
+ class CompositeOption(BaseOption):
47
+ pass
48
+
49
+
50
+ def composite_option() -> dict[str, Any]:
51
+ "Identifies a command-line argument as a data-class that needs to be unnested."
52
+
53
+ return {BaseOption.field_name(): CompositeOption()}
54
+
55
+
56
+ T = TypeVar("T")
57
+
58
+
59
+ def _has_metadata(field: Field[Any]) -> bool:
60
+ return field.metadata.get(BaseOption.field_name()) is not None
61
+
62
+
63
+ def _get_metadata(field: Field[Any], tp: type[T]) -> T | None:
64
+ attrs = field.metadata.get(BaseOption.field_name())
65
+ if attrs is None:
66
+ return None
67
+ elif isinstance(attrs, tp):
68
+ return attrs
69
+ else:
70
+ raise TypeError(f"expected: {tp.__name__}; got: {type(attrs).__name__}")
71
+
72
+
73
+ class _OptionTreeVisitor:
74
+ "Adds arguments to a command-line argument parser by recursively visiting fields of a composite type."
75
+
76
+ parser: ArgumentParser
77
+ prefixes: list[str]
78
+
79
+ def __init__(self, parser: ArgumentParser) -> None:
80
+ self.parser = parser
81
+ self.prefixes = []
82
+
83
+ def _get_arg_name(self, arg_name: str) -> str:
84
+ return f"--{'-'.join([*self.prefixes, arg_name])}"
85
+
86
+ def _get_field_name(self, field_name: str) -> str:
87
+ return "_".join([*self.prefixes, field_name])
88
+
89
+ def _add_value_field(self, field: Field[Any], field_type: Any) -> None:
90
+ arg_name = field.name.replace("_", "-")
91
+ value_opt = _get_metadata(field, ValueOption)
92
+ if value_opt is None:
93
+ return
94
+ help_text = value_opt.text
95
+ if field.default is not MISSING and field.default is not None:
96
+ help_text += f" (default: {field.default!s})"
97
+ if isinstance(field_type, type):
98
+ metavar = field_type.__name__.upper()
99
+ else:
100
+ metavar = None
101
+ self.parser.add_argument(
102
+ self._get_arg_name(arg_name),
103
+ dest=self._get_field_name(field.name),
104
+ default=field.default,
105
+ type=field_type,
106
+ help=help_text,
107
+ metavar=metavar,
108
+ )
109
+
110
+ def _add_field_as_argument(self, field: Field[Any]) -> None:
111
+ arg_name = field.name.replace("_", "-")
112
+ if field.type is bool:
113
+ bool_opt = _get_metadata(field, BooleanOption)
114
+ if bool_opt is None:
115
+ return
116
+ true_text = bool_opt.true_text
117
+ if field.default is True:
118
+ true_text += " (default)"
119
+ self.parser.add_argument(
120
+ self._get_arg_name(arg_name),
121
+ dest=self._get_field_name(field.name),
122
+ action="store_true",
123
+ default=field.default,
124
+ help=true_text,
125
+ )
126
+ if arg_name.startswith("skip-"):
127
+ inverse_arg_name = "keep-" + arg_name.removeprefix("skip-")
128
+ elif arg_name.startswith("keep-"):
129
+ inverse_arg_name = "skip-" + arg_name.removeprefix("keep-")
130
+ else:
131
+ inverse_arg_name = f"no-{arg_name}"
132
+ false_text = bool_opt.false_text
133
+ if field.default is False:
134
+ false_text += " (default)"
135
+ self.parser.add_argument(
136
+ self._get_arg_name(inverse_arg_name),
137
+ dest=self._get_field_name(field.name),
138
+ action="store_false",
139
+ help=false_text,
140
+ )
141
+ return
142
+
143
+ origin = get_origin(field.type)
144
+ if origin is Literal:
145
+ value_opt = _get_metadata(field, ValueOption)
146
+ if value_opt is None:
147
+ return
148
+ value_text = value_opt.text
149
+ if field.default is not MISSING and field.default is not None:
150
+ value_text += f" (default: {field.default!s})"
151
+ self.parser.add_argument(
152
+ self._get_arg_name(arg_name),
153
+ dest=self._get_field_name(field.name),
154
+ choices=get_args(field.type),
155
+ default=field.default,
156
+ help=value_text,
157
+ )
158
+
159
+ elif origin is UnionType:
160
+ union_types = list(get_args(field.type))
161
+ if len(union_types) != 2 or NoneType not in union_types:
162
+ raise TypeError(f"expected: `T` or `T | None` as argument type; got: {field.type}")
163
+ union_types.remove(NoneType)
164
+ required_type = union_types.pop()
165
+
166
+ self._add_value_field(field, required_type)
167
+
168
+ elif isinstance(field.type, type):
169
+ if hasattr(field.type, "__dataclass_fields__"):
170
+ composite_opt = _get_metadata(field, CompositeOption)
171
+ if composite_opt is None:
172
+ return
173
+
174
+ self.prefixes.append(arg_name)
175
+ self.add_arguments(field.type)
176
+ self.prefixes.pop()
177
+
178
+ else:
179
+ self._add_value_field(field, field.type)
180
+
181
+ elif _has_metadata(field):
182
+ raise TypeError(f"expected: known argument type; got: {field.type}")
183
+
184
+ def add_arguments(self, options_type: type[Any]) -> None:
185
+ if is_dataclass(options_type):
186
+ for field in fields(options_type):
187
+ self._add_field_as_argument(field)
188
+
189
+
190
+ def add_arguments(parser: ArgumentParser, options_type: type[Any]) -> None:
191
+ """
192
+ Adds arguments to a command-line argument parser.
193
+
194
+ :param parser: A command-line argument parser.
195
+ :param options_type: A data-class type that encapsulates configuration options.
196
+ """
197
+
198
+ _OptionTreeVisitor(parser).add_arguments(options_type)
199
+
200
+
201
+ def _get_options(args: Namespace, options_type: type[T], prefixes: tuple[str, ...]) -> T:
202
+ params: dict[str, Any] = {}
203
+ if is_dataclass(options_type): # always true, condition included for type checkers
204
+ for field in fields(options_type):
205
+ field_prefixes = (*prefixes, field.name)
206
+ if isinstance(field.type, type) and is_dataclass(field.type):
207
+ params[field.name] = _get_options(args, field.type, field_prefixes)
208
+ else:
209
+ field_param = getattr(args, "_".join(field_prefixes), MISSING)
210
+ if field_param is not MISSING:
211
+ params[field.name] = field_param
212
+ return options_type(**params)
213
+
214
+
215
+ def get_options(args: Namespace, options_type: type[T]) -> T:
216
+ """
217
+ Extracts configuration options from command-line arguments acquired by an argument parser.
218
+
219
+ :param args: Arguments acquired by a command-line argument parser.
220
+ :param options_type: A data-class type that encapsulates configuration options.
221
+ :returns: Configuration options as a data-class instance.
222
+ """
223
+
224
+ if not is_dataclass(options_type):
225
+ raise TypeError(f"expected: data-class as argument target; got: {type(options_type).__name__}")
226
+ return _get_options(args, cast(type[T], options_type), ())
md2conf/compatibility.py CHANGED
@@ -8,6 +8,11 @@ Copyright 2022-2026, Levente Hunyadi
8
8
 
9
9
  import sys
10
10
 
11
+ if sys.version_info >= (3, 11):
12
+ from typing import LiteralString as LiteralString # noqa: F401
13
+ else:
14
+ from typing_extensions import LiteralString as LiteralString # noqa: F401
15
+
11
16
  if sys.version_info >= (3, 12):
12
17
  from typing import override as override # noqa: F401
13
18
  else: