runspec-confluence-core 0.1.0__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.
- runspec_confluence_core-0.1.0/.gitignore +61 -0
- runspec_confluence_core-0.1.0/CHANGELOG.md +25 -0
- runspec_confluence_core-0.1.0/PKG-INFO +10 -0
- runspec_confluence_core-0.1.0/pyproject.toml +32 -0
- runspec_confluence_core-0.1.0/runspec_confluence_core/__init__.py +67 -0
- runspec_confluence_core-0.1.0/runspec_confluence_core/client.py +68 -0
- runspec_confluence_core-0.1.0/runspec_confluence_core/config.py +108 -0
- runspec_confluence_core-0.1.0/runspec_confluence_core/content.py +44 -0
- runspec_confluence_core-0.1.0/runspec_confluence_core/errors.py +13 -0
- runspec_confluence_core-0.1.0/runspec_confluence_core/pages.py +114 -0
- runspec_confluence_core-0.1.0/runspec_confluence_core/search.py +15 -0
- runspec_confluence_core-0.1.0/tests/__init__.py +0 -0
- runspec_confluence_core-0.1.0/tests/conftest.py +72 -0
- runspec_confluence_core-0.1.0/tests/test_config.py +75 -0
- runspec_confluence_core-0.1.0/tests/test_ops.py +125 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.pyo
|
|
5
|
+
*.pyd
|
|
6
|
+
.Python
|
|
7
|
+
*.egg
|
|
8
|
+
*.egg-info/
|
|
9
|
+
dist/
|
|
10
|
+
build/
|
|
11
|
+
.eggs/
|
|
12
|
+
.venv/
|
|
13
|
+
venv/
|
|
14
|
+
env/
|
|
15
|
+
.env
|
|
16
|
+
pip-wheel-metadata/
|
|
17
|
+
.pytest_cache/
|
|
18
|
+
.mypy_cache/
|
|
19
|
+
.ruff_cache/
|
|
20
|
+
htmlcov/
|
|
21
|
+
.coverage
|
|
22
|
+
coverage.xml
|
|
23
|
+
*.cover
|
|
24
|
+
|
|
25
|
+
# Node
|
|
26
|
+
node_modules/
|
|
27
|
+
dist/
|
|
28
|
+
*.js.map
|
|
29
|
+
.npm
|
|
30
|
+
|
|
31
|
+
# Go
|
|
32
|
+
*.exe
|
|
33
|
+
*.test
|
|
34
|
+
*.out
|
|
35
|
+
vendor/
|
|
36
|
+
|
|
37
|
+
# IDE
|
|
38
|
+
.idea/
|
|
39
|
+
.vscode/
|
|
40
|
+
*.iml
|
|
41
|
+
*.iws
|
|
42
|
+
*.ipr
|
|
43
|
+
.DS_Store
|
|
44
|
+
Thumbs.db
|
|
45
|
+
|
|
46
|
+
# Docs
|
|
47
|
+
site/
|
|
48
|
+
|
|
49
|
+
# Misc
|
|
50
|
+
*.log
|
|
51
|
+
*.tmp
|
|
52
|
+
|
|
53
|
+
# External reference repos (cloned locally, not committed)
|
|
54
|
+
chainlit-docs/
|
|
55
|
+
.chainlit/
|
|
56
|
+
|
|
57
|
+
# Claude Code local config (machine-specific)
|
|
58
|
+
.claude/launch.json
|
|
59
|
+
|
|
60
|
+
# Stray committed test venv (removed from tracking)
|
|
61
|
+
.venv-test/
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `runspec-confluence-core` are documented here.
|
|
4
|
+
|
|
5
|
+
## [0.1.0] — 2026-08-07
|
|
6
|
+
|
|
7
|
+
Initial release. Pure-Python Confluence helpers over `atlassian-python-api` — the
|
|
8
|
+
importable core behind `runspec-confluence`. No `runspec` dependency, no
|
|
9
|
+
`runspec.toml`, no entry points (surfaces zero runnables).
|
|
10
|
+
|
|
11
|
+
- `ConfluenceConfig` + `config_from_env(env=None)` — env-var auth
|
|
12
|
+
(`CONFLUENCE_URL`, `CONFLUENCE_USERNAME`/`CONFLUENCE_API_TOKEN` for Cloud/DC
|
|
13
|
+
basic, `CONFLUENCE_PERSONAL_TOKEN` for DC PAT) with `CONFLUENCE_<ENV>_*`
|
|
14
|
+
prefixes selected by `--env`. Cloud vs Server/DC via `CONFLUENCE_CLOUD` or a
|
|
15
|
+
`.atlassian.net` host.
|
|
16
|
+
- `build_client` constructs `atlassian.Confluence`, picking exactly one auth mode.
|
|
17
|
+
- Operation helpers: pages (get/children/ancestors/create/update/delete/move,
|
|
18
|
+
space listing + space metadata), CQL `search`, and page content
|
|
19
|
+
(history/labels/comments/restrictions).
|
|
20
|
+
- `translate_errors` surfaces the SDK's `requests` exceptions as `ConfluenceError`.
|
|
21
|
+
|
|
22
|
+
Content is passed in a Confluence `representation` (`storage` XHTML default, or
|
|
23
|
+
`wiki`/`editor`). Deferred: markdown↔storage conversion, attachments/images,
|
|
24
|
+
page diffs, space page-tree, setting restrictions, inline comments, templates,
|
|
25
|
+
analytics, OAuth 2.0.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: runspec-confluence-core
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Pure-Python Confluence helpers (over atlassian-python-api) — the importable core behind runspec-confluence (no runspec dependency, no runnables)
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Requires-Dist: atlassian-python-api>=3.41
|
|
7
|
+
Provides-Extra: dev
|
|
8
|
+
Requires-Dist: mypy; extra == 'dev'
|
|
9
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
10
|
+
Requires-Dist: ruff==0.15.20; extra == 'dev'
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "runspec-confluence-core"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
requires-python = ">=3.10"
|
|
9
|
+
description = "Pure-Python Confluence helpers (over atlassian-python-api) — the importable core behind runspec-confluence (no runspec dependency, no runnables)"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"atlassian-python-api>=3.41",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[project.optional-dependencies]
|
|
15
|
+
dev = [
|
|
16
|
+
"ruff==0.15.20",
|
|
17
|
+
"mypy",
|
|
18
|
+
"pytest>=8.0",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
[tool.pytest.ini_options]
|
|
22
|
+
testpaths = ["tests"]
|
|
23
|
+
|
|
24
|
+
[tool.mypy]
|
|
25
|
+
python_version = "3.10"
|
|
26
|
+
|
|
27
|
+
[tool.ruff]
|
|
28
|
+
line-length = 200
|
|
29
|
+
target-version = "py310"
|
|
30
|
+
|
|
31
|
+
[tool.ruff.lint]
|
|
32
|
+
select = ["E", "F", "I", "UP", "B", "SIM"]
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""runspec-confluence-core — Confluence helpers over atlassian-python-api.
|
|
2
|
+
|
|
3
|
+
This package depends only on ``atlassian-python-api`` — it has **no dependency on
|
|
4
|
+
runspec** and ships **no runspec.toml and no entry points**, so installing it
|
|
5
|
+
exposes the helper functions for import without surfacing any runnables (it is
|
|
6
|
+
invisible to ``runspec local`` / ``runspec serve`` discovery). ``runspec-confluence``
|
|
7
|
+
depends on it and wraps each helper as a verb of the ``confluence`` runnable.
|
|
8
|
+
|
|
9
|
+
Every operation takes an explicit :class:`ConfluenceConfig` first argument, so a
|
|
10
|
+
private (e.g. Nexus-hosted) wrapper package can inject credentials in code, bake in
|
|
11
|
+
a default ``space`` / ``env``, and build on top. Each function returns plain data
|
|
12
|
+
and raises :class:`ConfluenceError` on failure. Cloud and Server/Data Center are
|
|
13
|
+
both supported.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from runspec_confluence_core.client import build_client, translate_errors
|
|
17
|
+
from runspec_confluence_core.config import VALID_ENVS, ConfluenceConfig, config_from_env
|
|
18
|
+
from runspec_confluence_core.content import (
|
|
19
|
+
add_comment,
|
|
20
|
+
add_label,
|
|
21
|
+
get_comments,
|
|
22
|
+
get_labels,
|
|
23
|
+
get_page_history,
|
|
24
|
+
get_page_restrictions,
|
|
25
|
+
)
|
|
26
|
+
from runspec_confluence_core.errors import ConfluenceError
|
|
27
|
+
from runspec_confluence_core.pages import (
|
|
28
|
+
create_page,
|
|
29
|
+
delete_page,
|
|
30
|
+
get_page,
|
|
31
|
+
get_page_ancestors,
|
|
32
|
+
get_page_children,
|
|
33
|
+
get_space,
|
|
34
|
+
get_space_pages,
|
|
35
|
+
move_page,
|
|
36
|
+
update_page,
|
|
37
|
+
)
|
|
38
|
+
from runspec_confluence_core.search import search
|
|
39
|
+
|
|
40
|
+
__all__ = [
|
|
41
|
+
# config / client
|
|
42
|
+
"ConfluenceConfig",
|
|
43
|
+
"ConfluenceError",
|
|
44
|
+
"config_from_env",
|
|
45
|
+
"build_client",
|
|
46
|
+
"translate_errors",
|
|
47
|
+
"VALID_ENVS",
|
|
48
|
+
# pages
|
|
49
|
+
"get_page",
|
|
50
|
+
"get_page_children",
|
|
51
|
+
"get_page_ancestors",
|
|
52
|
+
"create_page",
|
|
53
|
+
"update_page",
|
|
54
|
+
"delete_page",
|
|
55
|
+
"move_page",
|
|
56
|
+
"get_space_pages",
|
|
57
|
+
"get_space",
|
|
58
|
+
# search
|
|
59
|
+
"search",
|
|
60
|
+
# content
|
|
61
|
+
"get_page_history",
|
|
62
|
+
"get_labels",
|
|
63
|
+
"add_label",
|
|
64
|
+
"get_comments",
|
|
65
|
+
"add_comment",
|
|
66
|
+
"get_page_restrictions",
|
|
67
|
+
]
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""client.py — build an ``atlassian.Confluence`` client + shared op helpers.
|
|
2
|
+
|
|
3
|
+
``build_client`` picks exactly one auth mode (Basic when username+api_token are
|
|
4
|
+
set, else Bearer PAT). ``@translate_errors`` wraps each operation so the SDK's
|
|
5
|
+
``requests`` exceptions surface as a :class:`ConfluenceError` carrying
|
|
6
|
+
Confluence's own error message.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import functools
|
|
12
|
+
from collections.abc import Callable
|
|
13
|
+
from typing import Any, TypeVar
|
|
14
|
+
|
|
15
|
+
import requests
|
|
16
|
+
from atlassian import Confluence
|
|
17
|
+
|
|
18
|
+
from runspec_confluence_core.config import ConfluenceConfig
|
|
19
|
+
from runspec_confluence_core.errors import ConfluenceError
|
|
20
|
+
|
|
21
|
+
_T = TypeVar("_T")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _auth_kwargs(config: ConfluenceConfig) -> dict[str, Any]:
|
|
25
|
+
base: dict[str, Any] = {"url": config.url, "cloud": config.cloud, "verify_ssl": config.verify_ssl}
|
|
26
|
+
if config.username and config.api_token:
|
|
27
|
+
base.update(username=config.username, password=config.api_token)
|
|
28
|
+
elif config.personal_token:
|
|
29
|
+
base.update(token=config.personal_token)
|
|
30
|
+
else:
|
|
31
|
+
raise ConfluenceError("ConfluenceConfig has no usable credentials (need personal_token or username+api_token)")
|
|
32
|
+
return base
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def build_client(config: ConfluenceConfig) -> Confluence:
|
|
36
|
+
"""Construct an ``atlassian.Confluence`` client for ``config``."""
|
|
37
|
+
return Confluence(**_auth_kwargs(config))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _message(exc: requests.HTTPError) -> str:
|
|
41
|
+
"""Extract a human-readable message from a Confluence error response."""
|
|
42
|
+
resp = getattr(exc, "response", None)
|
|
43
|
+
if resp is None:
|
|
44
|
+
return f"Confluence request failed: {exc}"
|
|
45
|
+
try:
|
|
46
|
+
body = resp.json()
|
|
47
|
+
except Exception:
|
|
48
|
+
return f"Confluence {resp.status_code}: {resp.text[:500]}"
|
|
49
|
+
if isinstance(body, dict):
|
|
50
|
+
message = body.get("message") or body.get("reason")
|
|
51
|
+
if message:
|
|
52
|
+
return f"Confluence {resp.status_code}: {message}"
|
|
53
|
+
return f"Confluence {resp.status_code}: {str(body)[:500]}"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def translate_errors(fn: Callable[..., _T]) -> Callable[..., _T]:
|
|
57
|
+
"""Decorator: turn the SDK's ``requests`` exceptions into ``ConfluenceError``."""
|
|
58
|
+
|
|
59
|
+
@functools.wraps(fn)
|
|
60
|
+
def wrapper(*args: Any, **kwargs: Any) -> _T:
|
|
61
|
+
try:
|
|
62
|
+
return fn(*args, **kwargs)
|
|
63
|
+
except requests.HTTPError as exc:
|
|
64
|
+
raise ConfluenceError(_message(exc)) from exc
|
|
65
|
+
except requests.RequestException as exc:
|
|
66
|
+
raise ConfluenceError(f"Confluence request failed: {exc}") from exc
|
|
67
|
+
|
|
68
|
+
return wrapper
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""config.py — connection + identity for a Confluence instance, from the environment.
|
|
2
|
+
|
|
3
|
+
Mirrors ``runspec-jira-core``'s config: ``config_from_env`` reads the
|
|
4
|
+
``CONFLUENCE_*`` family (with an optional ``CONFLUENCE_<ENV>_*`` prefix selected by
|
|
5
|
+
``--env``). Every operation function takes an explicit ``ConfluenceConfig`` first
|
|
6
|
+
argument, so a private (e.g. Nexus-hosted) wrapper can inject credentials in code
|
|
7
|
+
and bake in a default ``space`` / ``env``.
|
|
8
|
+
|
|
9
|
+
Cloud vs Server/Data Center is resolved once: an explicit ``CONFLUENCE_CLOUD`` wins,
|
|
10
|
+
otherwise a ``.atlassian.net`` host is treated as Cloud and anything else as
|
|
11
|
+
Server/DC.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
|
|
19
|
+
from runspec_confluence_core.errors import ConfluenceError
|
|
20
|
+
|
|
21
|
+
VALID_ENVS = ("dev", "uat", "prod")
|
|
22
|
+
|
|
23
|
+
_FALSEY = ("0", "false", "no", "off")
|
|
24
|
+
_TRUTHY = ("1", "true", "yes", "on")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class ConfluenceConfig:
|
|
29
|
+
"""Connection + identity for a Confluence instance.
|
|
30
|
+
|
|
31
|
+
Provide EITHER ``personal_token`` (a Server/Data Center Personal Access Token,
|
|
32
|
+
sent as a Bearer token) OR ``username`` + ``api_token`` (Cloud email + API
|
|
33
|
+
token, or a Server/DC basic username + password). ``space`` is an optional
|
|
34
|
+
default a wrapper can bake in.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
url: str
|
|
38
|
+
cloud: bool
|
|
39
|
+
username: str | None = None
|
|
40
|
+
api_token: str | None = None
|
|
41
|
+
personal_token: str | None = None
|
|
42
|
+
space: str | None = None
|
|
43
|
+
verify_ssl: bool = True
|
|
44
|
+
env: str | None = None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _env_first(names: list[str]) -> str | None:
|
|
48
|
+
"""Return the first non-empty environment variable among ``names``."""
|
|
49
|
+
for name in names:
|
|
50
|
+
value = os.environ.get(name)
|
|
51
|
+
if value:
|
|
52
|
+
return value
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _resolve_cloud(url: str, override: str | None) -> bool:
|
|
57
|
+
"""Decide Cloud vs Server/DC: explicit override wins, else infer from host."""
|
|
58
|
+
if override is not None:
|
|
59
|
+
return override.strip().lower() in _TRUTHY
|
|
60
|
+
return ".atlassian.net" in url.lower()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def config_from_env(env: str | None = None) -> ConfluenceConfig:
|
|
64
|
+
"""Build a :class:`ConfluenceConfig` from environment variables.
|
|
65
|
+
|
|
66
|
+
When ``env`` (or the ``CONFLUENCE_ENV`` variable) selects dev/uat/prod, the
|
|
67
|
+
environment-prefixed variables are read first (e.g. ``CONFLUENCE_UAT_URL``),
|
|
68
|
+
falling back to the unprefixed ``CONFLUENCE_*`` family. Raises
|
|
69
|
+
:class:`ConfluenceError` when the env label is invalid, the base URL is
|
|
70
|
+
missing, or no usable credentials are configured.
|
|
71
|
+
"""
|
|
72
|
+
env = env or os.environ.get("CONFLUENCE_ENV")
|
|
73
|
+
if env is not None:
|
|
74
|
+
env = env.lower()
|
|
75
|
+
if env not in VALID_ENVS:
|
|
76
|
+
raise ConfluenceError(f"Invalid env {env!r} — expected one of {', '.join(VALID_ENVS)}")
|
|
77
|
+
|
|
78
|
+
def lookup(suffix: str) -> str | None:
|
|
79
|
+
names = []
|
|
80
|
+
if env:
|
|
81
|
+
names.append(f"CONFLUENCE_{env.upper()}_{suffix}")
|
|
82
|
+
names.append(f"CONFLUENCE_{suffix}")
|
|
83
|
+
return _env_first(names)
|
|
84
|
+
|
|
85
|
+
url = lookup("URL")
|
|
86
|
+
if not url:
|
|
87
|
+
hint = f"CONFLUENCE_{env.upper()}_URL" if env else "CONFLUENCE_URL"
|
|
88
|
+
raise ConfluenceError(f"No base URL configured — set {hint}")
|
|
89
|
+
|
|
90
|
+
username = lookup("USERNAME")
|
|
91
|
+
api_token = lookup("API_TOKEN")
|
|
92
|
+
personal_token = lookup("PERSONAL_TOKEN")
|
|
93
|
+
if not personal_token and not (username and api_token):
|
|
94
|
+
raise ConfluenceError("No credentials configured — set CONFLUENCE_PERSONAL_TOKEN (Server/DC PAT) or CONFLUENCE_USERNAME + CONFLUENCE_API_TOKEN (Cloud email + API token, or DC basic auth)")
|
|
95
|
+
|
|
96
|
+
verify = lookup("VERIFY_SSL")
|
|
97
|
+
verify_ssl = True if verify is None else verify.strip().lower() not in _FALSEY
|
|
98
|
+
|
|
99
|
+
return ConfluenceConfig(
|
|
100
|
+
url=url.rstrip("/"),
|
|
101
|
+
cloud=_resolve_cloud(url, lookup("CLOUD")),
|
|
102
|
+
username=username,
|
|
103
|
+
api_token=api_token,
|
|
104
|
+
personal_token=personal_token,
|
|
105
|
+
space=lookup("SPACE"),
|
|
106
|
+
verify_ssl=verify_ssl,
|
|
107
|
+
env=env,
|
|
108
|
+
)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""content.py — history, labels, comments, and restrictions for a page."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from runspec_confluence_core.client import build_client, translate_errors
|
|
8
|
+
from runspec_confluence_core.config import ConfluenceConfig
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@translate_errors
|
|
12
|
+
def get_page_history(config: ConfluenceConfig, page_id: str) -> Any:
|
|
13
|
+
"""Return a page's content history (created/last-updated, versions)."""
|
|
14
|
+
return build_client(config).get_content_history(page_id)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@translate_errors
|
|
18
|
+
def get_labels(config: ConfluenceConfig, page_id: str) -> Any:
|
|
19
|
+
"""List a page's labels."""
|
|
20
|
+
return build_client(config).get_page_labels(page_id)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@translate_errors
|
|
24
|
+
def add_label(config: ConfluenceConfig, page_id: str, label: str) -> Any:
|
|
25
|
+
"""Add a label to a page."""
|
|
26
|
+
return build_client(config).set_page_label(page_id, label)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@translate_errors
|
|
30
|
+
def get_comments(config: ConfluenceConfig, page_id: str, *, limit: int = 25) -> Any:
|
|
31
|
+
"""List a page's comments (rendered bodies)."""
|
|
32
|
+
return build_client(config).get_page_comments(page_id, expand="body.view", limit=limit)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@translate_errors
|
|
36
|
+
def add_comment(config: ConfluenceConfig, page_id: str, text: str) -> Any:
|
|
37
|
+
"""Add a comment to a page."""
|
|
38
|
+
return build_client(config).add_comment(page_id, text)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@translate_errors
|
|
42
|
+
def get_page_restrictions(config: ConfluenceConfig, page_id: str) -> Any:
|
|
43
|
+
"""Return the view/edit restrictions on a page."""
|
|
44
|
+
return build_client(config).get_all_restrictions_for_content(page_id)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""errors.py — the exception type raised across runspec-confluence-core.
|
|
2
|
+
|
|
3
|
+
Every operation function raises :class:`ConfluenceError` on configuration
|
|
4
|
+
problems, invalid arguments, and non-2xx API responses (the underlying
|
|
5
|
+
``atlassian-python-api`` raises ``requests`` exceptions, translated to
|
|
6
|
+
``ConfluenceError`` at the operation boundary).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ConfluenceError(Exception):
|
|
13
|
+
"""Raised on config problems, invalid arguments, and Confluence API errors."""
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""pages.py — page read/create/update/delete/move + space listing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from runspec_confluence_core.client import build_client, translate_errors
|
|
8
|
+
from runspec_confluence_core.config import ConfluenceConfig
|
|
9
|
+
from runspec_confluence_core.errors import ConfluenceError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@translate_errors
|
|
13
|
+
def get_page(
|
|
14
|
+
config: ConfluenceConfig,
|
|
15
|
+
*,
|
|
16
|
+
page_id: str | None = None,
|
|
17
|
+
space: str | None = None,
|
|
18
|
+
title: str | None = None,
|
|
19
|
+
expand: str | None = None,
|
|
20
|
+
) -> Any:
|
|
21
|
+
"""Fetch a page by id, or by space + title. ``expand`` (e.g.
|
|
22
|
+
``body.storage,version``) controls what is returned."""
|
|
23
|
+
client = build_client(config)
|
|
24
|
+
if page_id:
|
|
25
|
+
return client.get_page_by_id(page_id, expand=expand)
|
|
26
|
+
space = space or config.space
|
|
27
|
+
if space and title:
|
|
28
|
+
return client.get_page_by_title(space, title, expand=expand)
|
|
29
|
+
raise ConfluenceError("get-page needs --page-id, or --space (or CONFLUENCE_SPACE) and --title")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@translate_errors
|
|
33
|
+
def get_page_children(config: ConfluenceConfig, page_id: str) -> Any:
|
|
34
|
+
"""List a page's immediate child pages."""
|
|
35
|
+
return build_client(config).get_child_pages(page_id)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@translate_errors
|
|
39
|
+
def get_page_ancestors(config: ConfluenceConfig, page_id: str) -> Any:
|
|
40
|
+
"""List a page's ancestor pages (its breadcrumb, root first)."""
|
|
41
|
+
return build_client(config).get_page_ancestors(page_id)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@translate_errors
|
|
45
|
+
def create_page(
|
|
46
|
+
config: ConfluenceConfig,
|
|
47
|
+
space: str,
|
|
48
|
+
title: str,
|
|
49
|
+
body: str,
|
|
50
|
+
*,
|
|
51
|
+
parent_id: str | None = None,
|
|
52
|
+
representation: str = "storage",
|
|
53
|
+
) -> Any:
|
|
54
|
+
"""Create a page. ``body`` is in ``representation`` format (``storage`` XHTML by
|
|
55
|
+
default, or ``wiki`` / ``editor``)."""
|
|
56
|
+
return build_client(config).create_page(space, title, body, parent_id=parent_id, representation=representation)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@translate_errors
|
|
60
|
+
def update_page(
|
|
61
|
+
config: ConfluenceConfig,
|
|
62
|
+
page_id: str,
|
|
63
|
+
title: str,
|
|
64
|
+
*,
|
|
65
|
+
body: str | None = None,
|
|
66
|
+
parent_id: str | None = None,
|
|
67
|
+
representation: str = "storage",
|
|
68
|
+
version_comment: str | None = None,
|
|
69
|
+
minor_edit: bool = False,
|
|
70
|
+
) -> Any:
|
|
71
|
+
"""Update a page. ``title`` is required by the API even when only the body
|
|
72
|
+
changes (pass the page's current title)."""
|
|
73
|
+
return build_client(config).update_page(
|
|
74
|
+
page_id,
|
|
75
|
+
title,
|
|
76
|
+
body=body,
|
|
77
|
+
parent_id=parent_id,
|
|
78
|
+
representation=representation,
|
|
79
|
+
minor_edit=minor_edit,
|
|
80
|
+
version_comment=version_comment,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@translate_errors
|
|
85
|
+
def delete_page(config: ConfluenceConfig, page_id: str, *, recursive: bool = False) -> dict[str, Any]:
|
|
86
|
+
"""Delete a page (and, with ``recursive``, its descendants)."""
|
|
87
|
+
build_client(config).remove_page(page_id, recursive=recursive)
|
|
88
|
+
return {"page_id": page_id, "deleted": True}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@translate_errors
|
|
92
|
+
def move_page(
|
|
93
|
+
config: ConfluenceConfig,
|
|
94
|
+
space: str,
|
|
95
|
+
page_id: str,
|
|
96
|
+
*,
|
|
97
|
+
target_id: str | None = None,
|
|
98
|
+
target_title: str | None = None,
|
|
99
|
+
position: str = "append",
|
|
100
|
+
) -> Any:
|
|
101
|
+
"""Move a page relative to a target (``append`` under it, or ``above``/``below``)."""
|
|
102
|
+
return build_client(config).move_page(space, page_id, target_id=target_id, target_title=target_title, position=position)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@translate_errors
|
|
106
|
+
def get_space_pages(config: ConfluenceConfig, space: str, *, start: int = 0, limit: int = 50) -> Any:
|
|
107
|
+
"""List pages in a space (one page of results)."""
|
|
108
|
+
return build_client(config).get_all_pages_from_space(space, start=start, limit=limit)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@translate_errors
|
|
112
|
+
def get_space(config: ConfluenceConfig, space_key: str) -> Any:
|
|
113
|
+
"""Fetch a space's metadata."""
|
|
114
|
+
return build_client(config).get_space(space_key)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""search.py — CQL content search."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from runspec_confluence_core.client import build_client, translate_errors
|
|
8
|
+
from runspec_confluence_core.config import ConfluenceConfig
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@translate_errors
|
|
12
|
+
def search(config: ConfluenceConfig, cql: str, *, start: int = 0, limit: int = 25, expand: str | None = None) -> Any:
|
|
13
|
+
"""Search content with CQL (Confluence Query Language), e.g.
|
|
14
|
+
``type = page AND space = DOCS AND text ~ "onboarding"``. Returns one page."""
|
|
15
|
+
return build_client(config).cql(cql, start=start, limit=limit, expand=expand)
|
|
File without changes
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Shared test helpers: a clean CONFLUENCE_* env and a fake Confluence installer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from runspec_confluence_core import client as client_mod
|
|
10
|
+
|
|
11
|
+
_SUFFIXES = ("URL", "USERNAME", "API_TOKEN", "PERSONAL_TOKEN", "CLOUD", "SPACE", "VERIFY_SSL")
|
|
12
|
+
_ALL_VARS = ["CONFLUENCE_ENV"] + [f"CONFLUENCE_{s}" for s in _SUFFIXES]
|
|
13
|
+
for _env in ("DEV", "UAT", "PROD"):
|
|
14
|
+
_ALL_VARS += [f"CONFLUENCE_{_env}_{s}" for s in _SUFFIXES]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@pytest.fixture(autouse=True)
|
|
18
|
+
def _clean_env(monkeypatch):
|
|
19
|
+
for var in _ALL_VARS:
|
|
20
|
+
monkeypatch.delenv(var, raising=False)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class FakeClient:
|
|
24
|
+
"""Records method calls; returns preset values (or raises preset exceptions)."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, returns: dict[str, Any] | None = None):
|
|
27
|
+
self.calls: list[tuple[str, tuple, dict]] = []
|
|
28
|
+
self._returns = returns or {}
|
|
29
|
+
|
|
30
|
+
def __getattr__(self, name: str):
|
|
31
|
+
def method(*args: Any, **kwargs: Any) -> Any:
|
|
32
|
+
self.calls.append((name, args, kwargs))
|
|
33
|
+
value = self._returns.get(name)
|
|
34
|
+
if isinstance(value, BaseException):
|
|
35
|
+
raise value
|
|
36
|
+
return value() if callable(value) else value
|
|
37
|
+
|
|
38
|
+
return method
|
|
39
|
+
|
|
40
|
+
def last(self, name: str) -> tuple[tuple, dict]:
|
|
41
|
+
for called, args, kwargs in reversed(self.calls):
|
|
42
|
+
if called == name:
|
|
43
|
+
return args, kwargs
|
|
44
|
+
raise AssertionError(f"{name} was not called; calls={[c[0] for c in self.calls]}")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class FakeResp:
|
|
48
|
+
def __init__(self, status: int, payload: Any):
|
|
49
|
+
self.status_code = status
|
|
50
|
+
self._payload = payload
|
|
51
|
+
self.text = str(payload)
|
|
52
|
+
|
|
53
|
+
def json(self) -> Any:
|
|
54
|
+
return self._payload
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@pytest.fixture
|
|
58
|
+
def install(monkeypatch):
|
|
59
|
+
"""Install a fake Confluence client; return (client, made_kwargs)."""
|
|
60
|
+
|
|
61
|
+
def _install(returns: dict[str, Any] | None = None):
|
|
62
|
+
client = FakeClient(returns)
|
|
63
|
+
made: dict[str, dict] = {}
|
|
64
|
+
|
|
65
|
+
def make(**kw):
|
|
66
|
+
made["kw"] = kw
|
|
67
|
+
return client
|
|
68
|
+
|
|
69
|
+
monkeypatch.setattr(client_mod, "Confluence", make)
|
|
70
|
+
return client, made
|
|
71
|
+
|
|
72
|
+
return _install
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from runspec_confluence_core.config import ConfluenceConfig, config_from_env
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_cloud_basic_from_env(monkeypatch):
|
|
7
|
+
monkeypatch.setenv("CONFLUENCE_URL", "https://acme.atlassian.net/wiki")
|
|
8
|
+
monkeypatch.setenv("CONFLUENCE_USERNAME", "me@acme.com")
|
|
9
|
+
monkeypatch.setenv("CONFLUENCE_API_TOKEN", "tok")
|
|
10
|
+
cfg = config_from_env()
|
|
11
|
+
assert cfg.cloud is True
|
|
12
|
+
assert cfg.username == "me@acme.com"
|
|
13
|
+
assert cfg.api_token == "tok"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_dc_pat_not_cloud_and_strips_slash(monkeypatch):
|
|
17
|
+
monkeypatch.setenv("CONFLUENCE_URL", "https://wiki.corp.example/")
|
|
18
|
+
monkeypatch.setenv("CONFLUENCE_PERSONAL_TOKEN", "pat")
|
|
19
|
+
cfg = config_from_env()
|
|
20
|
+
assert cfg.url == "https://wiki.corp.example"
|
|
21
|
+
assert cfg.cloud is False
|
|
22
|
+
assert cfg.personal_token == "pat"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_cloud_override(monkeypatch):
|
|
26
|
+
monkeypatch.setenv("CONFLUENCE_URL", "https://wiki.corp.example")
|
|
27
|
+
monkeypatch.setenv("CONFLUENCE_PERSONAL_TOKEN", "pat")
|
|
28
|
+
monkeypatch.setenv("CONFLUENCE_CLOUD", "yes")
|
|
29
|
+
assert config_from_env().cloud is True
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_env_prefixed_resolution(monkeypatch):
|
|
33
|
+
monkeypatch.setenv("CONFLUENCE_UAT_URL", "https://uat.atlassian.net/wiki")
|
|
34
|
+
monkeypatch.setenv("CONFLUENCE_UAT_PERSONAL_TOKEN", "uat")
|
|
35
|
+
cfg = config_from_env(env="uat")
|
|
36
|
+
assert cfg.personal_token == "uat"
|
|
37
|
+
assert cfg.env == "uat"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_env_prefixed_falls_back(monkeypatch):
|
|
41
|
+
monkeypatch.setenv("CONFLUENCE_PROD_URL", "https://prod.atlassian.net/wiki")
|
|
42
|
+
monkeypatch.setenv("CONFLUENCE_USERNAME", "u@x")
|
|
43
|
+
monkeypatch.setenv("CONFLUENCE_API_TOKEN", "shared")
|
|
44
|
+
cfg = config_from_env(env="prod")
|
|
45
|
+
assert cfg.api_token == "shared"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_invalid_env(monkeypatch):
|
|
49
|
+
with pytest.raises(Exception, match="Invalid env"):
|
|
50
|
+
config_from_env(env="staging")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_missing_url(monkeypatch):
|
|
54
|
+
monkeypatch.setenv("CONFLUENCE_PERSONAL_TOKEN", "p")
|
|
55
|
+
with pytest.raises(Exception, match="No base URL"):
|
|
56
|
+
config_from_env()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_missing_credentials(monkeypatch):
|
|
60
|
+
monkeypatch.setenv("CONFLUENCE_URL", "https://acme.atlassian.net/wiki")
|
|
61
|
+
with pytest.raises(Exception, match="No credentials"):
|
|
62
|
+
config_from_env()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_space_default(monkeypatch):
|
|
66
|
+
monkeypatch.setenv("CONFLUENCE_URL", "https://acme.atlassian.net/wiki")
|
|
67
|
+
monkeypatch.setenv("CONFLUENCE_PERSONAL_TOKEN", "p")
|
|
68
|
+
monkeypatch.setenv("CONFLUENCE_SPACE", "DOCS")
|
|
69
|
+
assert config_from_env().space == "DOCS"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_dataclass_defaults():
|
|
73
|
+
cfg = ConfluenceConfig(url="https://x", cloud=True)
|
|
74
|
+
assert cfg.verify_ssl is True
|
|
75
|
+
assert cfg.space is None
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Operation wiring: each core op calls the right SDK method, resolves
|
|
2
|
+
space defaults, branches get-page by id vs title, and translates errors."""
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
import requests
|
|
6
|
+
|
|
7
|
+
from runspec_confluence_core import (
|
|
8
|
+
ConfluenceConfig,
|
|
9
|
+
ConfluenceError,
|
|
10
|
+
add_comment,
|
|
11
|
+
add_label,
|
|
12
|
+
build_client,
|
|
13
|
+
create_page,
|
|
14
|
+
delete_page,
|
|
15
|
+
get_comments,
|
|
16
|
+
get_page,
|
|
17
|
+
search,
|
|
18
|
+
update_page,
|
|
19
|
+
)
|
|
20
|
+
from tests.conftest import FakeResp
|
|
21
|
+
|
|
22
|
+
CLOUD = ConfluenceConfig(url="https://acme.atlassian.net/wiki", cloud=True, username="me@x.com", api_token="tok")
|
|
23
|
+
DC = ConfluenceConfig(url="https://wiki.corp", cloud=False, personal_token="pat")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_build_client_basic_kwargs(install):
|
|
27
|
+
_, made = install()
|
|
28
|
+
build_client(CLOUD)
|
|
29
|
+
assert made["kw"] == {"url": "https://acme.atlassian.net/wiki", "cloud": True, "verify_ssl": True, "username": "me@x.com", "password": "tok"}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_build_client_pat_kwargs(install):
|
|
33
|
+
_, made = install()
|
|
34
|
+
build_client(DC)
|
|
35
|
+
assert made["kw"] == {"url": "https://wiki.corp", "cloud": False, "verify_ssl": True, "token": "pat"}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_get_page_by_id(install):
|
|
39
|
+
client, _ = install(returns={"get_page_by_id": {"id": "1"}})
|
|
40
|
+
assert get_page(CLOUD, page_id="1", expand="body.storage") == {"id": "1"}
|
|
41
|
+
args, kwargs = client.last("get_page_by_id")
|
|
42
|
+
assert args == ("1",)
|
|
43
|
+
assert kwargs["expand"] == "body.storage"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_get_page_by_title(install):
|
|
47
|
+
client, _ = install(returns={"get_page_by_title": {"id": "2"}})
|
|
48
|
+
get_page(CLOUD, space="DOCS", title="Home")
|
|
49
|
+
args, _ = client.last("get_page_by_title")
|
|
50
|
+
assert args == ("DOCS", "Home")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_get_page_uses_config_space(install):
|
|
54
|
+
cfg = ConfluenceConfig(url="https://acme.atlassian.net/wiki", cloud=True, personal_token="p", space="TEAM")
|
|
55
|
+
client, _ = install(returns={"get_page_by_title": {}})
|
|
56
|
+
get_page(cfg, title="Runbook")
|
|
57
|
+
args, _ = client.last("get_page_by_title")
|
|
58
|
+
assert args == ("TEAM", "Runbook")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_get_page_without_locator_raises(install):
|
|
62
|
+
install()
|
|
63
|
+
with pytest.raises(ConfluenceError, match="page-id"):
|
|
64
|
+
get_page(CLOUD)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_create_page_passes_representation(install):
|
|
68
|
+
client, _ = install(returns={"create_page": {"id": "9"}})
|
|
69
|
+
create_page(CLOUD, "DOCS", "New", "<p>hi</p>", parent_id="3", representation="storage")
|
|
70
|
+
args, kwargs = client.last("create_page")
|
|
71
|
+
assert args == ("DOCS", "New", "<p>hi</p>")
|
|
72
|
+
assert kwargs == {"parent_id": "3", "representation": "storage"}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_update_page_threads_args(install):
|
|
76
|
+
client, _ = install(returns={"update_page": {}})
|
|
77
|
+
update_page(CLOUD, "9", "Title", body="<p>x</p>", version_comment="tweak", minor_edit=True)
|
|
78
|
+
args, kwargs = client.last("update_page")
|
|
79
|
+
assert args == ("9", "Title")
|
|
80
|
+
assert kwargs["version_comment"] == "tweak"
|
|
81
|
+
assert kwargs["minor_edit"] is True
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_delete_page_returns_status(install):
|
|
85
|
+
client, _ = install()
|
|
86
|
+
assert delete_page(CLOUD, "9", recursive=True) == {"page_id": "9", "deleted": True}
|
|
87
|
+
_, kwargs = client.last("remove_page")
|
|
88
|
+
assert kwargs["recursive"] is True
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_search_calls_cql(install):
|
|
92
|
+
client, _ = install(returns={"cql": {"results": []}})
|
|
93
|
+
search(CLOUD, "type = page", limit=5)
|
|
94
|
+
args, kwargs = client.last("cql")
|
|
95
|
+
assert args == ("type = page",)
|
|
96
|
+
assert kwargs["limit"] == 5
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_get_comments_expands_body(install):
|
|
100
|
+
client, _ = install(returns={"get_page_comments": {"results": []}})
|
|
101
|
+
get_comments(CLOUD, "9")
|
|
102
|
+
_, kwargs = client.last("get_page_comments")
|
|
103
|
+
assert kwargs["expand"] == "body.view"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_add_label(install):
|
|
107
|
+
client, _ = install()
|
|
108
|
+
add_label(CLOUD, "9", "runbook")
|
|
109
|
+
args, _ = client.last("set_page_label")
|
|
110
|
+
assert args == ("9", "runbook")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_add_comment(install):
|
|
114
|
+
client, _ = install()
|
|
115
|
+
add_comment(CLOUD, "9", "looks good")
|
|
116
|
+
args, _ = client.last("add_comment")
|
|
117
|
+
assert args == ("9", "looks good")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def test_http_error_becomes_confluence_error(install):
|
|
121
|
+
err = requests.HTTPError("boom")
|
|
122
|
+
err.response = FakeResp(404, {"message": "No content found with id: 9"})
|
|
123
|
+
install(returns={"get_page_by_id": err})
|
|
124
|
+
with pytest.raises(ConfluenceError, match="No content found"):
|
|
125
|
+
get_page(CLOUD, page_id="9")
|