eledoctl 1.0.0__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.
eledoctl/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Command-line toolkit for Eledo."""
@@ -0,0 +1 @@
1
+ """CLI command definitions."""
eledoctl/cli/common.py ADDED
@@ -0,0 +1,25 @@
1
+ """Shared CLI utilities."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ from collections.abc import Coroutine
8
+ from typing import Any
9
+
10
+ import click
11
+
12
+ from eledoctl.config.settings import ConnectionSettings, load_connection_settings
13
+
14
+
15
+ def run[T](coro: Coroutine[Any, Any, T]) -> T:
16
+ """Run an asynchronous command implementation."""
17
+ return asyncio.run(coro)
18
+
19
+
20
+ def require_connection_settings() -> ConnectionSettings:
21
+ """Load persisted connection settings or raise a user-facing CLI error."""
22
+ try:
23
+ return load_connection_settings()
24
+ except (OSError, ValueError, json.JSONDecodeError) as exc:
25
+ raise click.ClickException(str(exc)) from exc
@@ -0,0 +1,195 @@
1
+ """PDF generation CLI commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+ import click
9
+
10
+ from eledoctl.cli.common import require_connection_settings, run
11
+ from eledoctl.config.settings import ConnectionSettings
12
+ from pyeledo import EledoClient
13
+ from pyeledo.types import JsonObject
14
+ from pyeledo.utils import parse_json_object
15
+
16
+
17
+ @click.group("documents")
18
+ def documents_group() -> None:
19
+ """Document generation commands."""
20
+
21
+
22
+ @documents_group.command("generate")
23
+ @click.argument("template_id")
24
+ @click.option("--template-version", type=int, default=None, help="Optional template version.")
25
+ @click.option(
26
+ "--payload",
27
+ type=str,
28
+ default=None,
29
+ help='Inline JSON containing the Eledo "file" object.',
30
+ )
31
+ @click.option(
32
+ "--payload-file",
33
+ type=click.Path(path_type=Path, exists=True, dir_okay=False),
34
+ default=None,
35
+ help='Read the Eledo "file" object from a JSON file.',
36
+ )
37
+ @click.option(
38
+ "--payload-stdin",
39
+ is_flag=True,
40
+ help='Read the Eledo "file" object from standard input.',
41
+ )
42
+ @click.option(
43
+ "--add-field",
44
+ "fields",
45
+ multiple=True,
46
+ metavar="KEY=VALUE",
47
+ help=("Add a top-level primitive field. May be repeated. Ignored when a JSON payload source is provided."),
48
+ )
49
+ @click.option(
50
+ "--output",
51
+ "output_path",
52
+ type=click.Path(path_type=Path, dir_okay=False),
53
+ default=None,
54
+ help="Document output path. Defaults to filename returned by Eledo.",
55
+ )
56
+ @click.option(
57
+ "--output-dir",
58
+ type=click.Path(path_type=Path, file_okay=False),
59
+ default=None,
60
+ help="Directory for the generated document when --output is not provided.",
61
+ )
62
+ @click.option("--base64-json", is_flag=True, help="Print JSON metadata with base64 PDF content.")
63
+ def generate_pdf(
64
+ template_id: str,
65
+ template_version: int | None,
66
+ payload: str | None,
67
+ payload_file: Path | None,
68
+ payload_stdin: bool,
69
+ fields: tuple[str, ...],
70
+ output_path: Path | None,
71
+ output_dir: Path | None,
72
+ base64_json: bool,
73
+ ) -> None:
74
+ """Generate a PDF document from an Eledo template."""
75
+ settings = require_connection_settings()
76
+ run(
77
+ _generate_pdf(
78
+ template_id=template_id,
79
+ settings=settings,
80
+ template_version=template_version,
81
+ file_data=_resolve_payload(
82
+ payload=payload, payload_file=payload_file, payload_stdin=payload_stdin, fields=fields
83
+ ),
84
+ output_path=output_path,
85
+ output_dir=output_dir,
86
+ base64_json=base64_json,
87
+ )
88
+ )
89
+
90
+
91
+ async def _generate_pdf(
92
+ *,
93
+ template_id: str,
94
+ settings: ConnectionSettings,
95
+ template_version: int | None,
96
+ file_data: JsonObject | None,
97
+ output_path: Path | None,
98
+ output_dir: Path | None,
99
+ base64_json: bool,
100
+ ) -> None:
101
+ async with EledoClient(base_url=settings.base_url, token=settings.token) as client:
102
+ result = await client.generate_pdf(
103
+ template_id=template_id,
104
+ template_version=template_version,
105
+ file_data=file_data,
106
+ )
107
+
108
+ if base64_json:
109
+ click.echo(json.dumps(result.as_json(), indent=2))
110
+ return
111
+
112
+ destination = _resolve_output_path(
113
+ filename=result.filename,
114
+ output_path=output_path,
115
+ output_dir=output_dir,
116
+ )
117
+ destination.write_bytes(result.content)
118
+ click.echo(str(destination))
119
+
120
+
121
+ def _resolve_payload(
122
+ *,
123
+ payload: str | None,
124
+ payload_file: Path | None,
125
+ payload_stdin: bool,
126
+ fields: tuple[str, ...],
127
+ ) -> JsonObject | None:
128
+ """Read and parse document data from one configured input source."""
129
+ source_count = sum(
130
+ (
131
+ payload is not None,
132
+ payload_file is not None,
133
+ payload_stdin,
134
+ )
135
+ )
136
+
137
+ if source_count > 1:
138
+ raise click.ClickException("Use only one of --payload, --payload-file, or --payload-stdin.")
139
+
140
+ if source_count == 1 and fields:
141
+ click.echo(
142
+ "Warning: --add-field values are ignored because a JSON payload was provided.",
143
+ err=True,
144
+ )
145
+
146
+ if payload is not None:
147
+ return parse_json_object(payload) or None
148
+
149
+ if payload_file is not None:
150
+ return parse_json_object(payload_file.read_text(encoding="utf-8")) or None
151
+
152
+ if payload_stdin:
153
+ return parse_json_object(click.get_text_stream("stdin").read()) or None
154
+
155
+ return _build_field_payload(fields)
156
+
157
+
158
+ def _build_field_payload(fields: tuple[str, ...]) -> JsonObject | None:
159
+ """Build a JSON object from repeated KEY=VALUE field arguments."""
160
+ if not fields:
161
+ return None
162
+
163
+ payload: JsonObject = {}
164
+
165
+ for field in fields:
166
+ key, separator, value = field.partition("=")
167
+
168
+ if separator == "":
169
+ raise click.ClickException(f"Invalid field {field!r}. Expected KEY=VALUE.")
170
+
171
+ key = key.strip()
172
+
173
+ if not key:
174
+ raise click.ClickException(f"Invalid field {field!r}. Field name cannot be empty.")
175
+
176
+ payload[key] = value
177
+
178
+ return payload
179
+
180
+
181
+ def _resolve_output_path(
182
+ *,
183
+ filename: str,
184
+ output_path: Path | None,
185
+ output_dir: Path | None,
186
+ ) -> Path:
187
+ """Resolve the destination path for a generated document."""
188
+ if output_path is not None:
189
+ return output_path
190
+
191
+ if output_dir is not None:
192
+ output_dir.mkdir(parents=True, exist_ok=True)
193
+ return output_dir / filename
194
+
195
+ return Path(filename)
File without changes
@@ -0,0 +1,37 @@
1
+ """Internal documentation synchronization CLI commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import click
8
+
9
+
10
+ @click.group("internal")
11
+ def internal_group() -> None:
12
+ """Internal Eledo operational tooling."""
13
+
14
+
15
+ @internal_group.group("docs")
16
+ def internal_docs_group() -> None:
17
+ """Internal documentation synchronization tooling."""
18
+
19
+
20
+ @internal_docs_group.command("sync")
21
+ @click.argument("path", type=click.Path(path_type=Path))
22
+ @click.option(
23
+ "--review-report",
24
+ type=click.Path(path_type=Path, dir_okay=False),
25
+ default=None,
26
+ help="Optional path where manual review report will be written.",
27
+ )
28
+ @click.option("--dry-run", is_flag=True, help="Analyze without uploading changes.")
29
+ def sync_docs(path: Path, review_report: Path | None, dry_run: bool) -> None:
30
+ """Synchronize Git documentation into Eledo CMS.
31
+
32
+ Implementation will be added after the Eledo CMS CRUD API is available.
33
+ """
34
+ click.echo("Documentation sync scaffold is ready, but implementation is pending.")
35
+ click.echo(f"path={path}")
36
+ click.echo(f"review_report={review_report}")
37
+ click.echo(f"dry_run={dry_run}")
eledoctl/cli/login.py ADDED
@@ -0,0 +1,72 @@
1
+ """Login CLI command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import webbrowser
7
+
8
+ import click
9
+
10
+ from eledoctl.cli.common import run
11
+ from eledoctl.config.settings import DEFAULT_BASE_URL, ConnectionSettings, save_connection_settings
12
+ from pyeledo import EledoApiError, EledoAuthenticationError, EledoClient
13
+ from pyeledo.profile import Profile
14
+
15
+ LOGIN_PATH = "/app/login/start"
16
+
17
+
18
+ @click.command("login")
19
+ @click.option(
20
+ "--base-url",
21
+ default=DEFAULT_BASE_URL,
22
+ show_default=True,
23
+ help="Eledo base URL.",
24
+ )
25
+ def login(base_url: str) -> None:
26
+ """Authenticate eledoctl with an Eledo API token."""
27
+ normalized_base_url = base_url.rstrip("/")
28
+ login_url = f"{normalized_base_url}{LOGIN_PATH}"
29
+
30
+ click.echo("Opening Eledo in your browser.")
31
+ click.echo()
32
+ click.echo("To obtain your API token:")
33
+ click.echo(" 1. Log in to your Eledo account.")
34
+ click.echo(" 2. Open Profile in the bottom-left corner.")
35
+ click.echo(" 3. Open the API tab under Your Profile.")
36
+ click.echo(" 4. Generate an API key if needed, then copy it.")
37
+ click.echo()
38
+ click.echo(f"Login URL: {login_url}")
39
+
40
+ if not webbrowser.open(login_url, new=2):
41
+ click.echo("The browser could not be opened automatically. Open the URL above manually.", err=True)
42
+
43
+ click.echo()
44
+ token = click.prompt("Paste your Eledo API token", hide_input=True).strip()
45
+
46
+ if not token:
47
+ raise click.ClickException("The API token cannot be empty.")
48
+
49
+ settings = ConnectionSettings(
50
+ base_url=normalized_base_url,
51
+ token=token,
52
+ )
53
+
54
+ try:
55
+ profile = run(_validate_token(settings))
56
+ except (EledoApiError, EledoAuthenticationError) as exc:
57
+ raise click.ClickException(f"Authentication failed: {exc}") from exc
58
+
59
+ try:
60
+ save_connection_settings(ConnectionSettings(base_url=normalized_base_url, token=token))
61
+ except (OSError, ValueError, json.JSONDecodeError) as exc:
62
+ raise click.ClickException(f"Could not save the local configuration: {exc}") from exc
63
+
64
+ click.echo()
65
+ click.echo(f"Authenticated as {profile.account}.")
66
+ click.echo("The API token has been saved to the local eledoctl configuration.")
67
+
68
+
69
+ async def _validate_token(settings: ConnectionSettings) -> Profile:
70
+ """Validate an Eledo API token and return its profile."""
71
+ async with EledoClient(base_url=settings.base_url, token=settings.token) as client:
72
+ return await client.get_profile()
eledoctl/cli/main.py ADDED
@@ -0,0 +1,30 @@
1
+ """Top-level eledoctl command tree."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import click
6
+
7
+ from eledoctl.cli.documents import documents_group
8
+ from eledoctl.cli.internal.docs import internal_group
9
+ from eledoctl.cli.login import login
10
+ from eledoctl.cli.profile import profile
11
+ from eledoctl.cli.templates import templates
12
+
13
+
14
+ @click.group(context_settings={"help_option_names": ["-h", "--help"]})
15
+ def main() -> None:
16
+ """Command-line toolkit for Eledo."""
17
+
18
+
19
+ main.add_command(login)
20
+ main.add_command(profile)
21
+ main.add_command(templates)
22
+ main.add_command(documents_group)
23
+ main.add_command(internal_group)
24
+
25
+ # Compatibility alias for direct imports.
26
+ app = main
27
+
28
+
29
+ if __name__ == "__main__":
30
+ main()
@@ -0,0 +1,26 @@
1
+ """Profile CLI command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+
7
+ import click
8
+
9
+ from eledoctl.cli.common import require_connection_settings, run
10
+ from eledoctl.config.settings import ConnectionSettings
11
+ from pyeledo import EledoClient
12
+
13
+
14
+ @click.command("profile")
15
+ def profile() -> None:
16
+ """Fetch current Eledo profile."""
17
+ settings = require_connection_settings()
18
+ run(_profile(settings=settings))
19
+
20
+
21
+ async def _profile(*, settings: ConnectionSettings) -> None:
22
+ """Fetch and print the current Eledo profile."""
23
+ async with EledoClient(base_url=settings.base_url, token=settings.token) as client:
24
+ result = await client.get_profile()
25
+
26
+ click.echo(json.dumps({"account": result.account}, indent=2))
@@ -0,0 +1,48 @@
1
+ """Template CLI commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+
7
+ import click
8
+
9
+ from eledoctl.cli.common import require_connection_settings, run
10
+ from eledoctl.config.settings import ConnectionSettings
11
+ from pyeledo import EledoClient, TemplateScope
12
+ from pyeledo.types import JsonValue
13
+
14
+
15
+ @click.command("templates")
16
+ @click.option(
17
+ "--scope",
18
+ type=click.Choice(["private", "public"], case_sensitive=False),
19
+ default="private",
20
+ show_default=True,
21
+ help="Template scope.",
22
+ )
23
+ def templates(scope: str) -> None:
24
+ """List Eledo templates."""
25
+ settings = require_connection_settings()
26
+ template_scope = TemplateScope.PUBLIC if scope.lower() == "public" else TemplateScope.PRIVATE
27
+ run(_templates(settings=settings, scope=template_scope))
28
+
29
+
30
+ async def _templates(*, settings: ConnectionSettings, scope: TemplateScope) -> None:
31
+ """Fetch and print Eledo templates for the selected scope."""
32
+ async with EledoClient(base_url=settings.base_url, token=settings.token) as client:
33
+ result = await client.get_templates(scope=scope)
34
+
35
+ payload: JsonValue = {
36
+ "total": result.total,
37
+ "templates": [
38
+ {
39
+ "id": template.id,
40
+ "name": template.name,
41
+ "version": template.version,
42
+ "bulk": template.bulk,
43
+ }
44
+ for template in result.templates
45
+ ],
46
+ }
47
+
48
+ click.echo(json.dumps(payload, indent=2))
@@ -0,0 +1 @@
1
+ """Local eledoctl configuration."""
@@ -0,0 +1,108 @@
1
+ """Local configuration persistence for eledoctl."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import tempfile
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+
11
+ from platformdirs import user_config_path
12
+
13
+ from pyeledo.types import JsonObject, JsonValue
14
+
15
+ APP_NAME = "eledoctl"
16
+ CONFIG_FILE = "config.json"
17
+ DEFAULT_BASE_URL = "https://eledo.online"
18
+
19
+
20
+ @dataclass(frozen=True, slots=True)
21
+ class ConnectionSettings:
22
+ base_url: str
23
+ token: str
24
+
25
+
26
+ def load_connection_settings() -> ConnectionSettings:
27
+ """Load the default persisted Eledo connection settings."""
28
+ config = load_config()
29
+ default = config.get("default")
30
+
31
+ if not isinstance(default, dict):
32
+ raise ValueError("No Eledo credentials found. Run `eledoctl login` first.")
33
+
34
+ base_url = default.get("base_url")
35
+ token = default.get("token")
36
+
37
+ if not isinstance(base_url, str) or not base_url:
38
+ raise ValueError("Stored Eledo base URL is missing or invalid.")
39
+
40
+ if not isinstance(token, str) or not token:
41
+ raise ValueError("Stored Eledo API token is missing or invalid.")
42
+
43
+ return ConnectionSettings(
44
+ base_url=base_url,
45
+ token=token,
46
+ )
47
+
48
+
49
+ def config_dir() -> Path:
50
+ """Return the user configuration directory."""
51
+ path = user_config_path(APP_NAME, ensure_exists=True)
52
+
53
+ if os.name == "posix":
54
+ path.chmod(0o700)
55
+
56
+ return path
57
+
58
+
59
+ def config_path() -> Path:
60
+ """Return the local configuration file path."""
61
+ return config_dir() / CONFIG_FILE
62
+
63
+
64
+ def load_config() -> JsonObject:
65
+ """Load the local eledoctl configuration."""
66
+ path = config_path()
67
+
68
+ if not path.exists():
69
+ return {}
70
+
71
+ data: JsonValue = json.loads(path.read_text(encoding="utf-8"))
72
+
73
+ if not isinstance(data, dict):
74
+ raise ValueError("Configuration root must be a JSON object.")
75
+
76
+ return data
77
+
78
+
79
+ def save_config(config: JsonObject) -> None:
80
+ """Persist the local configuration atomically."""
81
+ destination = config_path()
82
+
83
+ with tempfile.NamedTemporaryFile(
84
+ mode="w",
85
+ encoding="utf-8",
86
+ dir=destination.parent,
87
+ prefix=f".{destination.name}.",
88
+ suffix=".tmp",
89
+ delete=False,
90
+ ) as temporary_file:
91
+ json.dump(config, temporary_file, indent=2, sort_keys=True)
92
+ temporary_file.write("\n")
93
+ temporary_path = Path(temporary_file.name)
94
+
95
+ if os.name == "posix":
96
+ temporary_path.chmod(0o600)
97
+
98
+ temporary_path.replace(destination)
99
+
100
+
101
+ def save_connection_settings(settings: ConnectionSettings) -> None:
102
+ """Persist the default Eledo connection and API token."""
103
+ config = load_config()
104
+ config["default"] = {
105
+ "base_url": settings.base_url.rstrip("/"),
106
+ "token": settings.token,
107
+ }
108
+ save_config(config)
File without changes
File without changes
File without changes
@@ -0,0 +1,21 @@
1
+ """Slug generation helpers for documentation synchronization."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+
8
+ def build_slug(path: Path, *, docs_root: Path) -> str:
9
+ """Build CMS slug from a documentation file path.
10
+
11
+ Example:
12
+ docs/product/template-editor.md -> /product/template-editor
13
+ """
14
+ relative = path.relative_to(docs_root)
15
+ without_suffix = relative.with_suffix("")
16
+
17
+ parts = list(without_suffix.parts)
18
+ if parts[-1] == "index":
19
+ parts = parts[:-1]
20
+
21
+ return "/" + "/".join(parts)
File without changes
@@ -0,0 +1,39 @@
1
+ """Markdown transformation helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any
7
+
8
+
9
+ @dataclass(frozen=True, slots=True)
10
+ class GitDocument:
11
+ """Parsed Git document with frontmatter separated from body."""
12
+
13
+ frontmatter: dict[str, Any]
14
+ content: str
15
+
16
+
17
+ def parse_git_document(text: str) -> GitDocument:
18
+ """Parse a Markdown document and strip simple YAML frontmatter.
19
+
20
+ This intentionally avoids depending on a full YAML parser in the scaffold.
21
+ Complex frontmatter support can be added later if needed.
22
+ """
23
+ if not text.startswith("---\n"):
24
+ return GitDocument(frontmatter={}, content=text.strip())
25
+
26
+ marker = "\n---\n"
27
+ end = text.find(marker, 4)
28
+ if end == -1:
29
+ return GitDocument(frontmatter={}, content=text.strip())
30
+
31
+ raw_frontmatter = text[4:end]
32
+ body = text[end + len(marker) :]
33
+ frontmatter: dict[str, Any] = {}
34
+ for line in raw_frontmatter.splitlines():
35
+ if not line.strip() or ":" not in line:
36
+ continue
37
+ key, value = line.split(":", 1)
38
+ frontmatter[key.strip()] = value.strip().strip("\"'")
39
+ return GitDocument(frontmatter=frontmatter, content=body.strip())