From 79ea1efab64766dbc75b107e8917ddb8caecc2dc Mon Sep 17 00:00:00 2001 From: Kellen Murphy Date: Mon, 20 Jul 2026 16:48:57 -0400 Subject: [PATCH 1/2] Add Confluence Cloud support alongside Data Center - CloudConfluenceClient: v2 API for page get/update; inherited v1 endpoint for attachment upload (v2 has none). Auto-detected from *.atlassian.net hostnames; instance_type config field overrides for custom domains. - Cloud auth: CONFLUENCE_EMAIL + CONFLUENCE_TOKEN (API token) basic auth, not gated behind --unsafe-auth. /wiki is appended to bare site URLs and written back to config so attachment URL matching keeps working. - Attachment content reads use the REST download endpoint on Cloud; the legacy /download/attachments path 401s for API-token auth there. - Credentials may come from the environment without a .csync.env file; csync injects FIFO-served env files (e.g. 1Password) via --env-file since Docker VM file sharing cannot pass host FIFOs. - Portable BSD/GNU sed in the release Makefile. - README/CLAUDE.md/examples updated; note on IPv6-related image build failures and the Docker Desktop IPv4-only fix. --- .csync.env.example | 10 ++- .py-conf-sync.config.example.yaml | 5 ++ CLAUDE.md | 55 ++++++++++-- Makefile | 2 +- README.md | 72 +++++++++++---- csync | 14 ++- py_conf_sync.py | 98 ++++++++++++++++++--- tests/test_client.py | 142 +++++++++++++++++++++++++++++- tests/test_commands.py | 107 ++++++++++++++++++++-- 9 files changed, 460 insertions(+), 45 deletions(-) diff --git a/.csync.env.example b/.csync.env.example index 8663285..063f187 100644 --- a/.csync.env.example +++ b/.csync.env.example @@ -1,7 +1,15 @@ # Confluence credentials + +# --- Data Center --- # Personal Access Token (Profile → Personal Access Tokens in Confluence DC) CONFLUENCE_TOKEN= -# Or basic auth — less preferred, PAT is better +# Or basic auth — less preferred, PAT is better (requires --unsafe-auth) # CONFLUENCE_USERNAME=user@example.com # CONFLUENCE_PASSWORD= + +# --- Cloud (yoursite.atlassian.net) --- +# API token from https://id.atlassian.com/manage-profile/security/api-tokens +# plus your Atlassian account email. No --unsafe-auth needed. +# CONFLUENCE_TOKEN= +# CONFLUENCE_EMAIL=you@example.com diff --git a/.py-conf-sync.config.example.yaml b/.py-conf-sync.config.example.yaml index 8059c0c..097cdad 100644 --- a/.py-conf-sync.config.example.yaml +++ b/.py-conf-sync.config.example.yaml @@ -5,6 +5,11 @@ confluence_url: https://confluence.example.com jira_url: https://jira.example.com # optional — enables Jira macro links on pull +# For Atlassian Cloud, use your site URL instead (auto-detected from *.atlassian.net): +# confluence_url: https://yoursite.atlassian.net/wiki +# jira_url: https://yoursite.atlassian.net +# instance_type: cloud # optional override — only needed for Cloud behind a custom domain + pages: - page_id: "123456" file_path: docs/onboarding.md diff --git a/CLAUDE.md b/CLAUDE.md index 7793c28..51d867c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,8 +5,8 @@ This file provides context for Claude Code when working in this project. ## What this project does -`py_conf_sync.py` is a single-file CLI tool that syncs Confluence Data Center pages -with Markdown files in a git repository. It pulls pages down from Confluence (converting +`py_conf_sync.py` is a single-file CLI tool that syncs Confluence pages (Data Center +or Cloud) with Markdown files in a git repository. It pulls pages down from Confluence (converting storage format to Markdown), allows local editing, then pushes changes back (converting Markdown back to Confluence storage format). @@ -48,10 +48,20 @@ Dependabot regenerates them automatically on its weekly dep-bump PRs. Everything lives in `py_conf_sync.py`. The main sections are: -- **`ConfluenceClient`** — thin `requests` wrapper around the Confluence REST API. - Three methods: `get_page()`, `update_page()`, and `upload_attachment()`. Auth is +- **`ConfluenceClient`** — thin `requests` wrapper around the Confluence DC REST API + (v1). Three methods: `get_page()`, `update_page()`, and `upload_attachment()`. Auth is either a PAT Bearer token or basic auth, loaded from `.env` via `python-dotenv`. +- **`CloudConfluenceClient`** — subclass for Atlassian Cloud. Overrides `get_page()` + and `update_page()` to use the v2 API (`/api/v2/pages/{id}`); inherits + `upload_attachment()` because v2 has no attachment upload endpoint (Cloud still uses + the v1 path). Auth is basic auth with `CONFLUENCE_EMAIL` + `CONFLUENCE_TOKEN` + (Atlassian API token) — no `--unsafe-auth` needed. Selected by `_is_cloud()`: + explicit `instance_type` config field wins, else hostname ends with `.atlassian.net`. + `_get_client()` appends `/wiki` to the base URL if missing and writes it back to + `config["confluence_url"]` (conversion code builds/matches attachment download URLs + from that value). + - **`storage_to_markdown()`** — converts Confluence storage format (XHTML + `ac:*`/`ri:*` macros) to Markdown. Named macro patterns are applied in order before markdownify runs. Many macros have full round-trip support; unknown macros are stripped by `_MACRO_RE`. @@ -113,6 +123,12 @@ pages: `page_id` is always treated as a string. `file_path` is relative to wherever the script is run from (intended to be the repo root). +For Atlassian Cloud, set `confluence_url: https://yoursite.atlassian.net/wiki` (the +`/wiki` suffix is appended automatically if omitted) and `jira_url` to the bare site URL. +Cloud is auto-detected from `*.atlassian.net` hostnames; the optional +`instance_type: cloud | datacenter` field overrides detection (needed for Cloud behind +a custom domain). + **`img_dir`**: On pull, if an attachment image's filename exists as `{img_dir}/{filename}` locally, the Markdown image reference uses the local relative path instead of the Confluence download URL. On push, local image references are uploaded as Confluence attachments before @@ -121,19 +137,27 @@ converting to storage format. ## Credentials (`.csync.env`) ``` +# Data Center CONFLUENCE_TOKEN= # or (requires --unsafe-auth flag at runtime) CONFLUENCE_USERNAME=user@example.com CONFLUENCE_PASSWORD= + +# Cloud — API token from id.atlassian.com + account email; no --unsafe-auth needed +CONFLUENCE_TOKEN= +CONFLUENCE_EMAIL=user@example.com ``` Credentials are **never created by `init`** — users set them up manually by copying -`.csync.env.example` to `~/.csync.env` and filling in their PAT. +`.csync.env.example` to `~/.csync.env` and filling in their token. The credential file is located by searching in order: `~/.csync.env` (preferred), then the current working directory, then the script's own directory. -Basic auth is disabled by default and requires `--unsafe-auth` to activate. +Password basic auth (DC only) is disabled by default and requires `--unsafe-auth` to +activate. Cloud's email + API token basic auth is not gated — it is Atlassian's +recommended method for scripts. Cloud with a missing `CONFLUENCE_EMAIL` or +`CONFLUENCE_TOKEN` is a hard error. ## Known limitations / gotchas @@ -180,10 +204,25 @@ Basic auth is disabled by default and requires `--unsafe-auth` to activate. ## Confluence API reference +Data Center (v1, base `{confluence_url}/rest/api`): + - Get page: `GET /rest/api/content/{id}?expand=body.storage,version,title` - Update page: `PUT /rest/api/content/{id}` with JSON body (see `update_page()`) - Upload attachment: `POST /rest/api/content/{id}/child/attachment` - Space content: `GET /rest/api/space/{KEY}/content` -All endpoints are Confluence DC REST API v1 (`/rest/api/`). This tool does not use the -v2 API (`/api/v2/`) introduced in newer DC versions. +Cloud (base `{confluence_url}` = `https://site.atlassian.net/wiki`): + +- Get page: `GET /api/v2/pages/{id}?body-format=storage` — response carries `title`, + `version.number`, and `body.storage.value` in the same shapes the v1 consumer code + reads, so `cmd_pull`/`cmd_push` are client-agnostic. +- Update page: `PUT /api/v2/pages/{id}` with `{id, status: "current", title, body, version}` +- Upload attachment: `POST /rest/api/content/{id}/child/attachment` — still v1; the v2 + API has no attachment upload endpoint. +- Download attachment content: `GET /rest/api/content/{id}/child/attachment/{attId}/download` + (redirects to a signed media URL). The legacy `/download/attachments/{id}/{file}` path + returns 401 for API-token auth on Cloud — it only accepts browser-session cookies — + which is why `download_attachment_text()` is overridden in the Cloud client. + +Atlassian is deprecating Cloud v1 content endpoints on a rolling timeline, which is why +Cloud page reads/writes use v2 while DC stays on v1. diff --git a/Makefile b/Makefile index a8395c9..5e5df14 100644 --- a/Makefile +++ b/Makefile @@ -24,7 +24,7 @@ major: $(call _release,$(NEW_VERSION)) define _release - sed -i 's/__version__ = "$(CURRENT_VERSION)"/__version__ = "$(1)"/' py_conf_sync.py + sed -i.bak 's/__version__ = "$(CURRENT_VERSION)"/__version__ = "$(1)"/' py_conf_sync.py && rm py_conf_sync.py.bak git add py_conf_sync.py git commit -m "chore: bump version to $(1)" git tag v$(1) diff --git a/README.md b/README.md index 4a27ca7..e8453bd 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,15 @@ Or rebuild explicitly without running a command: ./csync --rebuild status ``` +> **Image build fails downloading Chromium?** If `playwright install chromium` dies with +> `Client network socket disconnected before secure TLS connection was established` on +> every mirror, your network is advertising IPv6 that doesn't actually route (common on +> home networks; frequently reported from Docker Desktop on Apple Silicon Macs, but it is +> a network issue, not an architecture one — playwright's Node-based downloader tries IPv6 +> first and never falls back to IPv4). Fix: in Docker Desktop go to +> **Settings → Resources → Network** and set **Default networking mode** to **IPv4 only**, +> then Apply & restart and rebuild. + ## Commands ``` @@ -219,9 +228,9 @@ Confluence will render correctly, and pulling that page back produces the same M ### Mermaid diagrams Fenced ` ```mermaid ` blocks are automatically rendered to PNG images and uploaded as -Confluence attachments on push. Confluence Data Center does not natively render Mermaid, -so this gives you diagrams in git-tracked Markdown that display correctly on the published -page — without any Confluence app or plugin. +Confluence attachments on push. Confluence (Data Center and Cloud alike) does not natively +render Mermaid, so this gives you diagrams in git-tracked Markdown that display correctly +on the published page — without any Confluence app or plugin. **On push:** each ` ```mermaid ` block is rendered to a retina-quality PNG by headless Chromium (bundled in the Docker image via Playwright and a bundled `mermaid.min.js` — no @@ -310,17 +319,38 @@ img_dir: assets/images ## Credentials +Credentials are **never created automatically** by `csync init`. Set them up by hand: + +```bash +cp .csync.env.example ~/.csync.env +# edit ~/.csync.env and fill in your credentials (see below) +``` + +### Data Center + Use a Personal Access Token (PAT) — it's scoped, revocable, and doesn't expose your password: `Confluence → Profile → Personal Access Tokens → Create token` -Credentials are **never created automatically** by `csync init`. Set them up by hand: +``` +CONFLUENCE_TOKEN= +``` -```bash -cp .csync.env.example ~/.csync.env -# edit ~/.csync.env and fill in CONFLUENCE_TOKEN +### Cloud + +Use an Atlassian API token together with your Atlassian account email: + +`https://id.atlassian.com/manage-profile/security/api-tokens → Create API token` + +``` +CONFLUENCE_TOKEN= +CONFLUENCE_EMAIL=you@example.com ``` +Cloud authenticates with HTTP basic auth (email + API token) — this is Atlassian's +recommended method for scripts and does **not** require `--unsafe-auth`, which only +gates password-based basic auth on Data Center. + ### Credential file location The tool searches for `.csync.env` in this order and uses the first file found: @@ -344,13 +374,23 @@ without this flag and the command will exit with an error. PAT is strongly prefe ## Version support -This tool targets **Confluence Data Center** and uses the v1 REST API -(`/rest/api/content/`). It has been tested against **DC 9.2.x**. +The tool supports both **Confluence Data Center** and **Confluence Cloud**. +The instance type is auto-detected from the `confluence_url` hostname +(`*.atlassian.net` → Cloud); set `instance_type: cloud` or +`instance_type: datacenter` in the config to override (e.g. for a Cloud site +behind a custom domain). -The v1 API has been stable since Confluence 6.x and remains fully supported -in the 9.x line, so any reasonably modern DC instance should work without -changes. - -**Confluence Cloud is not currently supported.** Cloud uses a different base -URL structure and OAuth-based authentication model. Cloud support is planned -once the relevant infrastructure migration is complete. +| | Data Center | Cloud | +|---|---|---| +| `confluence_url` | `https://confluence.example.com` | `https://yoursite.atlassian.net/wiki` (`/wiki` is appended automatically if omitted) | +| Auth | Bearer PAT (`CONFLUENCE_TOKEN`) | Basic auth: `CONFLUENCE_EMAIL` + `CONFLUENCE_TOKEN` (API token) | +| Page read/write | v1 REST API (`/rest/api/content/`) | v2 REST API (`/api/v2/pages/`) | +| Attachment upload | v1 REST API | v1 REST API (v2 has no upload endpoint) | + +Data Center has been tested against **DC 9.2.x**. The v1 API has been stable +since Confluence 6.x and remains fully supported in the 9.x line, so any +reasonably modern DC instance should work without changes. + +On Cloud, page reads and writes use the v2 API (the v1 content endpoints are +being deprecated by Atlassian); attachment upload still uses the v1 endpoint +because v2 does not provide one. diff --git a/csync b/csync index 67c2dfa..e3f89c2 100755 --- a/csync +++ b/csync @@ -22,9 +22,17 @@ else IMAGE="$LOCAL_IMAGE" fi -# Mount ~/.csync.env into the container at the same path so _find_env_file() can locate it +# Mount ~/.csync.env into the container at the same path so _find_env_file() can locate it. +# If it's a FIFO (e.g. a 1Password-served environment), Docker's VM file sharing cannot +# pass it through — read it once on the host and inject the values as environment +# variables instead (py_conf_sync accepts credentials from the environment). The content +# is passed via process substitution on the exec line below: the fd only stays open +# through exec if the substitution is part of that command, not a prior array assignment. home_env_args=() -if [[ -f "$HOME/.csync.env" ]]; then +env_file_content="" +if [[ -p "$HOME/.csync.env" ]]; then + env_file_content="$(cat "$HOME/.csync.env")" +elif [[ -f "$HOME/.csync.env" ]]; then home_env_args+=(-v "$HOME/.csync.env:$HOME/.csync.env" -e "HOME=$HOME") fi @@ -36,11 +44,13 @@ for arg in "$@"; do fi done +# An empty env-file is a harmless no-op, so the substitution is unconditional. exec docker run --rm \ --network=host \ --user "$(id -u):$(id -g)" \ -v "$(pwd):/workspace" \ -w /workspace \ + --env-file <(printf '%s\n' "$env_file_content") \ "${home_env_args[@]+"${home_env_args[@]}"}" \ "${extra_mounts[@]+"${extra_mounts[@]}"}" \ "$IMAGE" \ diff --git a/py_conf_sync.py b/py_conf_sync.py index 6e54111..36d6692 100644 --- a/py_conf_sync.py +++ b/py_conf_sync.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 """ -py-conf-sync: Keep Confluence Data Center pages in sync with Markdown files in a git repo. +py-conf-sync: Keep Confluence (Data Center or Cloud) pages in sync with Markdown files in a git repo. Usage: python py_conf_sync.py [--config PATH] pull [--page PAGE_ID] [--dry-run] @@ -19,7 +19,7 @@ import sys import tempfile from pathlib import Path -from urllib.parse import quote, unquote +from urllib.parse import quote, unquote, urlparse import requests import yaml @@ -127,6 +127,13 @@ def upload_attachment(self, page_id: str, file_path: Path) -> str: resp.raise_for_status() return f"{self.base_url}/download/attachments/{page_id}/{quote(filename, safe='')}" + def download_attachment_text(self, page_id: str, filename: str) -> str: + """Fetch an attachment's content as text.""" + url = f"{self.base_url}/download/attachments/{page_id}/{quote(filename, safe='')}" + resp = self.session.get(url) + resp.raise_for_status() + return resp.text + def update_page(self, page_id: str, title: str, storage_body: str, version: int) -> dict: url = f"{self.base_url}/rest/api/content/{page_id}" payload = { @@ -145,6 +152,45 @@ def update_page(self, page_id: str, title: str, storage_body: str, version: int) return resp.json() +class CloudConfluenceClient(ConfluenceClient): + """Atlassian Cloud: v2 API for page get/update; inherited v1 endpoint for + attachments (v2 has no upload endpoint). base_url must include /wiki.""" + + def get_page(self, page_id: str) -> dict: + resp = self.session.get( + f"{self.base_url}/api/v2/pages/{page_id}", + params={"body-format": "storage"}, + ) + resp.raise_for_status() + return resp.json() + + def update_page(self, page_id: str, title: str, storage_body: str, version: int) -> dict: + payload = { + "id": str(page_id), + "status": "current", + "title": title, + "body": {"representation": "storage", "value": storage_body}, + "version": {"number": version}, + } + resp = self.session.put(f"{self.base_url}/api/v2/pages/{page_id}", data=json.dumps(payload)) + resp.raise_for_status() + return resp.json() + + def download_attachment_text(self, page_id: str, filename: str) -> str: + # Cloud's /download/attachments/ path only accepts browser-session + # cookies (401 for API tokens); token auth must use the REST download + # endpoint, which redirects to a signed media URL. + attach_url = f"{self.base_url}/rest/api/content/{page_id}/child/attachment" + check = self.session.get(attach_url, params={"filename": filename}) + check.raise_for_status() + results = check.json().get("results", []) + if not results: + raise requests.HTTPError(f"attachment not found: {filename}") + resp = self.session.get(f"{attach_url}/{results[0]['id']}/download") + resp.raise_for_status() + return resp.text + + # --------------------------------------------------------------------------- # Format conversion # --------------------------------------------------------------------------- @@ -634,14 +680,28 @@ def cmd_remove(args): print(f"[ok] Removed page {args.page_id}") +def _is_cloud(config: dict) -> bool: + """Cloud vs Data Center: explicit instance_type wins, else detect by hostname.""" + explicit = (config.get("instance_type") or "").lower() + if explicit == "cloud": + return True + if explicit in ("datacenter", "dc", "server"): + return False + host = urlparse(config.get("confluence_url") or "").hostname or "" + return host.endswith(".atlassian.net") + + def _get_client(config: dict, args) -> ConfluenceClient: env_file = _find_env_file() - if not env_file: - print("[error] No .csync.env found.") + if env_file: + load_dotenv(env_file) + elif not os.getenv("CONFLUENCE_TOKEN") and not ( + os.getenv("CONFLUENCE_USERNAME") and os.getenv("CONFLUENCE_PASSWORD") + ): + print("[error] No .csync.env found and no credentials in the environment.") print(" Checked: ~/.csync.env, current directory, script directory.") - print(" Run 'init' to create one. Preferred location: ~/.csync.env") + print(" Run 'init' to create one, or export CONFLUENCE_TOKEN directly.") sys.exit(1) - load_dotenv(env_file) base_url = config.get("confluence_url") if not base_url: print("[error] confluence_url not set in config.") @@ -654,6 +714,22 @@ def _get_client(config: dict, args) -> ConfluenceClient: username = os.getenv("CONFLUENCE_USERNAME") password = os.getenv("CONFLUENCE_PASSWORD") + if _is_cloud(config): + # Cloud wiki content lives under /wiki; conversion code builds and + # matches attachment download URLs from config["confluence_url"], so + # the normalized URL must be written back, not just used for the client. + if "/wiki" not in urlparse(base_url).path: + base_url = base_url.rstrip("/") + "/wiki" + config["confluence_url"] = base_url + print(f"[info] Cloud instance detected — using {base_url}") + email = os.getenv("CONFLUENCE_EMAIL") + if not token or not email: + print("[error] Confluence Cloud requires CONFLUENCE_TOKEN (API token from") + print(" https://id.atlassian.com/manage-profile/security/api-tokens)") + print(" and CONFLUENCE_EMAIL in .csync.env") + sys.exit(1) + return CloudConfluenceClient(base_url=base_url, username=email, password=token) + if not token and (username or password): if not getattr(args, "unsafe_auth", False): print("[error] Basic auth credentials found but --unsafe-auth was not passed.") @@ -726,11 +802,11 @@ def cmd_pull(args): ) source_map: dict = {} for src_m in _MERMAID_SOURCE_URL_RE.finditer(markdown_text): - url, digest = src_m.group(1), src_m.group(2) + digest = src_m.group(2) try: - resp = client.session.get(url) - resp.raise_for_status() - source_map[digest] = resp.text.strip() + source_map[digest] = client.download_attachment_text( + page_id, f"mermaid-{digest}.txt" + ).strip() except Exception: pass markdown_text = _restore_mermaid_blocks(markdown_text, source_map) @@ -1170,7 +1246,7 @@ def _write_front_matter(fm: dict, body: str) -> str: def main(): parser = argparse.ArgumentParser( prog="py_conf_sync", - description="Sync Confluence Data Center pages with Markdown files in a git repo.", + description="Sync Confluence (Data Center or Cloud) pages with Markdown files in a git repo.", ) parser.add_argument( "--config", diff --git a/tests/test_client.py b/tests/test_client.py index 16538f8..0e33206 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,9 +1,11 @@ +import json import pytest from unittest.mock import MagicMock, patch from pathlib import Path -from py_conf_sync import ConfluenceClient +from py_conf_sync import ConfluenceClient, CloudConfluenceClient BASE = "https://confluence.example.com" +CLOUD_BASE = "https://site.atlassian.net/wiki" def _make_session_mock(): @@ -119,3 +121,141 @@ def test_puts_page_and_returns_json(self): assert result["id"] == "123" resp.raise_for_status.assert_called_once() session.put.assert_called_once() + + +class TestCloudClientInit: + def test_email_token_sets_basic_auth(self): + session = _make_session_mock() + with patch('py_conf_sync.requests.Session', return_value=session): + CloudConfluenceClient(CLOUD_BASE, username="me@example.com", password="apitok") + assert session.auth == ("me@example.com", "apitok") + assert "Authorization" not in session.headers + + +class TestCloudClientGetPage: + def test_gets_v2_endpoint_with_storage_format(self): + session = _make_session_mock() + resp = MagicMock() + resp.json.return_value = {"id": "123", "title": "T"} + session.get.return_value = resp + + with patch('py_conf_sync.requests.Session', return_value=session): + client = CloudConfluenceClient(CLOUD_BASE, username="e", password="t") + client.get_page("123") + + resp.raise_for_status.assert_called_once() + session.get.assert_called_once_with( + f"{CLOUD_BASE}/api/v2/pages/123", + params={"body-format": "storage"}, + ) + + def test_response_shape_matches_command_contract(self): + # Realistic v2 response — lock in that the access paths cmd_pull/cmd_push + # use (title, version.number, body.storage.value) resolve unchanged. + session = _make_session_mock() + resp = MagicMock() + resp.json.return_value = { + "id": "123", + "title": "Cloud Page", + "version": {"number": 3}, + "body": {"storage": {"value": "

x

", "representation": "storage"}}, + } + session.get.return_value = resp + + with patch('py_conf_sync.requests.Session', return_value=session): + client = CloudConfluenceClient(CLOUD_BASE, username="e", password="t") + page = client.get_page("123") + + assert page["title"] == "Cloud Page" + assert page["version"]["number"] == 3 + assert page["body"]["storage"]["value"] == "

x

" + + +class TestCloudClientUpdatePage: + def test_puts_v2_payload(self): + session = _make_session_mock() + resp = MagicMock() + resp.json.return_value = {"id": "123", "version": {"number": 4}} + session.put.return_value = resp + + with patch('py_conf_sync.requests.Session', return_value=session): + client = CloudConfluenceClient(CLOUD_BASE, username="e", password="t") + client.update_page("123", "My Page", "

body

", 4) + + resp.raise_for_status.assert_called_once() + put_url = session.put.call_args[0][0] + assert put_url == f"{CLOUD_BASE}/api/v2/pages/123" + payload = json.loads(session.put.call_args[1]["data"]) + assert payload["id"] == "123" + assert payload["status"] == "current" + assert payload["title"] == "My Page" + assert payload["version"] == {"number": 4} + assert payload["body"] == {"representation": "storage", "value": "

body

"} + + +class TestDownloadAttachmentText: + def test_dc_fetches_download_url(self): + session = _make_session_mock() + resp = MagicMock() + resp.text = "flowchart LR\n A --> B" + session.get.return_value = resp + + with patch('py_conf_sync.requests.Session', return_value=session): + client = ConfluenceClient(BASE, token="t") + text = client.download_attachment_text("111", "mermaid-abc123def456.txt") + + assert text == "flowchart LR\n A --> B" + resp.raise_for_status.assert_called_once() + session.get.assert_called_once_with( + f"{BASE}/download/attachments/111/mermaid-abc123def456.txt" + ) + + def test_cloud_resolves_id_and_uses_rest_download(self): + session = _make_session_mock() + check_resp = MagicMock() + check_resp.json.return_value = {"results": [{"id": "att999"}]} + dl_resp = MagicMock() + dl_resp.text = "flowchart LR\n A --> B" + session.get.side_effect = [check_resp, dl_resp] + + with patch('py_conf_sync.requests.Session', return_value=session): + client = CloudConfluenceClient(CLOUD_BASE, username="e", password="t") + text = client.download_attachment_text("111", "mermaid-abc123def456.txt") + + assert text == "flowchart LR\n A --> B" + first_call, second_call = session.get.call_args_list + assert first_call[0][0] == f"{CLOUD_BASE}/rest/api/content/111/child/attachment" + assert first_call[1]["params"] == {"filename": "mermaid-abc123def456.txt"} + assert second_call[0][0] == f"{CLOUD_BASE}/rest/api/content/111/child/attachment/att999/download" + + def test_cloud_raises_when_attachment_missing(self): + session = _make_session_mock() + check_resp = MagicMock() + check_resp.json.return_value = {"results": []} + session.get.return_value = check_resp + + with patch('py_conf_sync.requests.Session', return_value=session): + client = CloudConfluenceClient(CLOUD_BASE, username="e", password="t") + with pytest.raises(Exception, match="attachment not found"): + client.download_attachment_text("111", "missing.txt") + + +class TestCloudClientUploadAttachment: + def test_inherited_upload_uses_wiki_paths(self, tmp_path): + img = tmp_path / "diagram.png" + img.write_bytes(b"fake image data") + + session = _make_session_mock() + check_resp = MagicMock() + check_resp.json.return_value = {"results": []} + post_resp = MagicMock() + session.get.return_value = check_resp + session.post.return_value = post_resp + + with patch('py_conf_sync.requests.Session', return_value=session): + client = CloudConfluenceClient(CLOUD_BASE, username="e", password="t") + url = client.upload_attachment("456", img) + + post_url = session.post.call_args[0][0] + assert post_url.startswith(f"{CLOUD_BASE}/rest/api/content/456/child/attachment") + assert url.startswith(f"{CLOUD_BASE}/download/attachments/456/") diff --git a/tests/test_commands.py b/tests/test_commands.py index 3f80875..fa828b5 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -135,6 +135,8 @@ def _args(self, unsafe_auth=False): def test_exits_when_no_env_file(self, tmp_path, monkeypatch): monkeypatch.setenv("HOME", str(tmp_path / "fakehome")) monkeypatch.chdir(tmp_path) + for var in ("CONFLUENCE_TOKEN", "CONFLUENCE_USERNAME", "CONFLUENCE_PASSWORD"): + monkeypatch.delenv(var, raising=False) config = {"confluence_url": "https://conf.example.com"} import py_conf_sync as _m if (Path(_m.__file__).parent / ".csync.env").exists(): @@ -142,6 +144,23 @@ def test_exits_when_no_env_file(self, tmp_path, monkeypatch): with pytest.raises(SystemExit): _get_client(config, self._args()) + def test_env_vars_work_without_env_file(self, tmp_path, monkeypatch): + # FIFO/1Password or CI pattern: credentials injected as environment + # variables with no .csync.env on disk. + monkeypatch.setenv("HOME", str(tmp_path / "fakehome")) + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("CONFLUENCE_TOKEN", "envtok") + monkeypatch.delenv("CONFLUENCE_USERNAME", raising=False) + monkeypatch.delenv("CONFLUENCE_PASSWORD", raising=False) + config = {"confluence_url": "https://conf.example.com"} + import py_conf_sync as _m + if (Path(_m.__file__).parent / ".csync.env").exists(): + pytest.skip("Script dir has .csync.env") + session = MagicMock(headers={}) + with patch('py_conf_sync.requests.Session', return_value=session): + client = _get_client(config, self._args()) + assert session.headers["Authorization"] == "Bearer envtok" + def test_exits_when_no_base_url(self, tmp_path, monkeypatch): env_file = tmp_path / ".csync.env" env_file.write_text("CONFLUENCE_TOKEN=tok\n") @@ -192,6 +211,86 @@ def test_returns_client_with_basic_auth_unsafe(self, tmp_path, monkeypatch): client = _get_client(config, self._args(unsafe_auth=True)) assert client is not None + def _cloud_env(self, tmp_path, monkeypatch, token="apitok", email="me@example.com"): + env_file = tmp_path / ".csync.env" + lines = [] + if token: + lines.append(f"CONFLUENCE_TOKEN={token}") + monkeypatch.setenv("CONFLUENCE_TOKEN", token) + else: + monkeypatch.delenv("CONFLUENCE_TOKEN", raising=False) + if email: + lines.append(f"CONFLUENCE_EMAIL={email}") + monkeypatch.setenv("CONFLUENCE_EMAIL", email) + else: + monkeypatch.delenv("CONFLUENCE_EMAIL", raising=False) + env_file.write_text("\n".join(lines) + "\n") + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.delenv("CONFLUENCE_USERNAME", raising=False) + monkeypatch.delenv("CONFLUENCE_PASSWORD", raising=False) + + def test_cloud_url_returns_cloud_client(self, tmp_path, monkeypatch): + self._cloud_env(tmp_path, monkeypatch) + config = {"confluence_url": "https://site.atlassian.net/wiki"} + with patch('py_conf_sync.requests.Session', return_value=MagicMock(headers={})): + client = _get_client(config, self._args()) + assert isinstance(client, py_conf_sync.CloudConfluenceClient) + assert client.base_url == "https://site.atlassian.net/wiki" + + def test_cloud_appends_wiki_to_bare_site_url(self, tmp_path, monkeypatch): + self._cloud_env(tmp_path, monkeypatch) + config = {"confluence_url": "https://site.atlassian.net"} + with patch('py_conf_sync.requests.Session', return_value=MagicMock(headers={})): + client = _get_client(config, self._args()) + assert client.base_url == "https://site.atlassian.net/wiki" + # Conversion code reads confluence_url from config — must be normalized too. + assert config["confluence_url"] == "https://site.atlassian.net/wiki" + + def test_cloud_without_email_exits(self, tmp_path, monkeypatch): + self._cloud_env(tmp_path, monkeypatch, email=None) + config = {"confluence_url": "https://site.atlassian.net/wiki"} + with patch('py_conf_sync.requests.Session', return_value=MagicMock(headers={})): + with pytest.raises(SystemExit): + _get_client(config, self._args()) + + def test_cloud_without_token_exits(self, tmp_path, monkeypatch): + self._cloud_env(tmp_path, monkeypatch, token=None) + config = {"confluence_url": "https://site.atlassian.net/wiki"} + with patch('py_conf_sync.requests.Session', return_value=MagicMock(headers={})): + with pytest.raises(SystemExit): + _get_client(config, self._args()) + + def test_instance_type_cloud_overrides_custom_domain(self, tmp_path, monkeypatch): + self._cloud_env(tmp_path, monkeypatch) + config = {"confluence_url": "https://wiki.company.com/wiki", "instance_type": "cloud"} + with patch('py_conf_sync.requests.Session', return_value=MagicMock(headers={})): + client = _get_client(config, self._args()) + assert isinstance(client, py_conf_sync.CloudConfluenceClient) + + def test_instance_type_datacenter_overrides_atlassian_host(self, tmp_path, monkeypatch): + self._cloud_env(tmp_path, monkeypatch) + session = MagicMock(headers={}) + config = {"confluence_url": "https://site.atlassian.net", "instance_type": "datacenter"} + with patch('py_conf_sync.requests.Session', return_value=session): + client = _get_client(config, self._args()) + assert not isinstance(client, py_conf_sync.CloudConfluenceClient) + assert session.headers["Authorization"] == "Bearer apitok" + + def test_dc_unchanged_ignores_stray_email(self, tmp_path, monkeypatch): + env_file = tmp_path / ".csync.env" + env_file.write_text("CONFLUENCE_TOKEN=mytoken\nCONFLUENCE_EMAIL=me@example.com\n") + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("CONFLUENCE_TOKEN", "mytoken") + monkeypatch.setenv("CONFLUENCE_EMAIL", "me@example.com") + monkeypatch.delenv("CONFLUENCE_USERNAME", raising=False) + monkeypatch.delenv("CONFLUENCE_PASSWORD", raising=False) + session = MagicMock(headers={}) + config = {"confluence_url": "https://conf.example.com"} + with patch('py_conf_sync.requests.Session', return_value=session): + client = _get_client(config, self._args()) + assert not isinstance(client, py_conf_sync.CloudConfluenceClient) + assert session.headers["Authorization"] == "Bearer mytoken" + # --------------------------------------------------------------------------- # _resolve_pages @@ -295,12 +394,10 @@ def test_pull_fetches_mermaid_source_from_txt_attachment(self, tmp_path, monkeyp storage = f'

Mermaid source

' config_path = _save(tmp_path, [{"page_id": "111", "file_path": "page.md", "title": "Test"}]) client = _mock_client(storage=storage) - mock_resp = MagicMock() - mock_resp.text = "flowchart LR\n A --> B" - client.session.get.return_value = mock_resp + client.download_attachment_text.return_value = "flowchart LR\n A --> B" monkeypatch.setattr(py_conf_sync, '_get_client', lambda c, a: client) cmd_pull(_pull_args(config_path)) - client.session.get.assert_called_once_with(source_url) + client.download_attachment_text.assert_called_once_with("111", f"mermaid-{digest}.txt") assert "Mermaid source" not in (tmp_path / "page.md").read_text() def test_pull_mermaid_fetch_exception_ignored(self, tmp_path, monkeypatch): @@ -309,7 +406,7 @@ def test_pull_mermaid_fetch_exception_ignored(self, tmp_path, monkeypatch): storage = f'

Mermaid source

' config_path = _save(tmp_path, [{"page_id": "111", "file_path": "page.md", "title": "Test"}]) client = _mock_client(storage=storage) - client.session.get.side_effect = Exception("network error") + client.download_attachment_text.side_effect = Exception("network error") monkeypatch.setattr(py_conf_sync, '_get_client', lambda c, a: client) cmd_pull(_pull_args(config_path)) assert (tmp_path / "page.md").exists() From 05a11ebf32d702a6941591319ff23903a7c4e638 Mon Sep 17 00:00:00 2001 From: Kellen Murphy Date: Mon, 20 Jul 2026 16:49:13 -0400 Subject: [PATCH 2/2] chore: bump version to 2.0.0 --- py_conf_sync.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/py_conf_sync.py b/py_conf_sync.py index 36d6692..868f433 100644 --- a/py_conf_sync.py +++ b/py_conf_sync.py @@ -26,7 +26,7 @@ from markdownify import markdownify as md from dotenv import load_dotenv -__version__ = "1.3.1" +__version__ = "2.0.0" # --------------------------------------------------------------------------- # Config helpers