article 0.1.2__tar.gz → 0.1.3__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.
- {article-0.1.2 → article-0.1.3}/PKG-INFO +4 -4
- {article-0.1.2 → article-0.1.3}/README.md +1 -1
- {article-0.1.2 → article-0.1.3}/article/__main__.py +30 -17
- {article-0.1.2 → article-0.1.3}/article/misc/CHANGELOG.md +17 -0
- {article-0.1.2 → article-0.1.3}/pyproject.toml +2 -2
- article-0.1.3/tests/test_cli.py +186 -0
- {article-0.1.2 → article-0.1.3}/.editorconfig +0 -0
- {article-0.1.2 → article-0.1.3}/.env.example +0 -0
- {article-0.1.2 → article-0.1.3}/.gitattributes +0 -0
- {article-0.1.2 → article-0.1.3}/.github/workflows/ci.yml +0 -0
- {article-0.1.2 → article-0.1.3}/.gitignore +0 -0
- {article-0.1.2 → article-0.1.3}/LICENSE +0 -0
- {article-0.1.2 → article-0.1.3}/article/__init__.py +0 -0
- {article-0.1.2 → article-0.1.3}/article/adapters/__init__.py +0 -0
- {article-0.1.2 → article-0.1.3}/article/adapters/dev_to.py +0 -0
- {article-0.1.2 → article-0.1.3}/article/adapters/hashnode.py +0 -0
- {article-0.1.2 → article-0.1.3}/article/adapters/medium.py +0 -0
- {article-0.1.2 → article-0.1.3}/article/adapters/substack.py +0 -0
- {article-0.1.2 → article-0.1.3}/article/base.py +0 -0
- {article-0.1.2 → article-0.1.3}/article/config.py +0 -0
- {article-0.1.2 → article-0.1.3}/article/engine.py +0 -0
- {article-0.1.2 → article-0.1.3}/article/registry.py +0 -0
- {article-0.1.2 → article-0.1.3}/article/state.py +0 -0
- {article-0.1.2 → article-0.1.3}/article/util.py +0 -0
- {article-0.1.2 → article-0.1.3}/examples/article.example.json +0 -0
- {article-0.1.2 → article-0.1.3}/tests/test_pipeline.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: article
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.3
|
|
4
4
|
Summary: Tools to semi-automate the publication of articles
|
|
5
5
|
Project-URL: Homepage, https://github.com/thorwhalen/article
|
|
6
6
|
Project-URL: Repository, https://github.com/thorwhalen/article
|
|
@@ -17,7 +17,7 @@ Classifier: Programming Language :: Python :: 3.12
|
|
|
17
17
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
18
18
|
Classifier: Topic :: Text Processing :: Markup
|
|
19
19
|
Requires-Python: >=3.10
|
|
20
|
-
Requires-Dist:
|
|
20
|
+
Requires-Dist: cw<0.2,>=0.1.1
|
|
21
21
|
Requires-Dist: httpx
|
|
22
22
|
Requires-Dist: pydantic-settings>=2
|
|
23
23
|
Requires-Dist: pydantic>=2
|
|
@@ -158,7 +158,7 @@ async def publish(article, *, canonical_url, config, secrets):
|
|
|
158
158
|
| Registry | `article/registry.py` | `PublishAdapter` protocol + open-closed registry |
|
|
159
159
|
| Engine | `article/engine.py` | Orchestrates phase 1 → phase 2; canonical handoff; graceful errors |
|
|
160
160
|
| Adapters | `article/adapters/*` | One module per platform (config + secrets injected) |
|
|
161
|
-
| CLI | `article/__main__.py` | `
|
|
161
|
+
| CLI | `article/__main__.py` | `cw` dispatch of the two commands |
|
|
162
162
|
|
|
163
163
|
> **Status:** the layers above are complete and tested; each adapter's actual
|
|
164
164
|
> network / Playwright call is a clearly-marked `TODO` stub that returns a typed
|
|
@@ -120,7 +120,7 @@ async def publish(article, *, canonical_url, config, secrets):
|
|
|
120
120
|
| Registry | `article/registry.py` | `PublishAdapter` protocol + open-closed registry |
|
|
121
121
|
| Engine | `article/engine.py` | Orchestrates phase 1 → phase 2; canonical handoff; graceful errors |
|
|
122
122
|
| Adapters | `article/adapters/*` | One module per platform (config + secrets injected) |
|
|
123
|
-
| CLI | `article/__main__.py` | `
|
|
123
|
+
| CLI | `article/__main__.py` | `cw` dispatch of the two commands |
|
|
124
124
|
|
|
125
125
|
> **Status:** the layers above are complete and tested; each adapter's actual
|
|
126
126
|
> network / Playwright call is a clearly-marked `TODO` stub that returns a typed
|
|
@@ -15,10 +15,11 @@ takes ``--platforms medium,dev_to`` to syndicate a subset.
|
|
|
15
15
|
|
|
16
16
|
from __future__ import annotations
|
|
17
17
|
|
|
18
|
+
import argparse
|
|
18
19
|
import json
|
|
19
20
|
from typing import Optional
|
|
20
21
|
|
|
21
|
-
import
|
|
22
|
+
import cw
|
|
22
23
|
|
|
23
24
|
from .base import ArticleError, RunSummary
|
|
24
25
|
from .config import load_article, load_settings
|
|
@@ -39,12 +40,11 @@ def _prepare(article_path: str, *, env_file: Optional[str], state_path: Optional
|
|
|
39
40
|
settings = load_settings(env_file=env_file, **overrides)
|
|
40
41
|
article = load_article(article_path)
|
|
41
42
|
except ArticleError as e:
|
|
42
|
-
raise
|
|
43
|
+
raise cw.CommandError(str(e)) from e
|
|
43
44
|
store = JsonStateStore(settings.state_path)
|
|
44
45
|
return settings, article, store
|
|
45
46
|
|
|
46
47
|
|
|
47
|
-
@argh.arg("article_path", help="Path to the article JSON file")
|
|
48
48
|
def publish_primary(
|
|
49
49
|
article_path: str,
|
|
50
50
|
*,
|
|
@@ -60,7 +60,6 @@ def publish_primary(
|
|
|
60
60
|
return _render(summary, json_out=json_out)
|
|
61
61
|
|
|
62
62
|
|
|
63
|
-
@argh.arg("article_path", help="Path to the article JSON file")
|
|
64
63
|
def syndicate_secondary(
|
|
65
64
|
article_path: str,
|
|
66
65
|
*,
|
|
@@ -81,26 +80,40 @@ def syndicate_secondary(
|
|
|
81
80
|
)
|
|
82
81
|
)
|
|
83
82
|
except ArticleError as e:
|
|
84
|
-
raise
|
|
83
|
+
raise cw.CommandError(str(e)) from e
|
|
85
84
|
return _render(summary, json_out=json_out)
|
|
86
85
|
|
|
87
86
|
|
|
88
|
-
#: SSOT list of dispatchable commands (
|
|
87
|
+
#: SSOT list of dispatchable commands (``cw`` maps ``_`` in names to ``-``).
|
|
89
88
|
_dispatch_funcs = [publish_primary, syndicate_secondary]
|
|
90
89
|
|
|
90
|
+
#: Per-parameter ``add_argument`` particulars the signature cannot carry -- here, the
|
|
91
|
+
#: one ``help`` string that used to ride on an ``@argh.arg`` decorator. ``cw`` reads
|
|
92
|
+
#: ``config``, never decorator metadata, so this is where such declarations live now.
|
|
93
|
+
_dispatch_config = {
|
|
94
|
+
command: {"article_path": {"help": "Path to the article JSON file"}}
|
|
95
|
+
for command in ("publish-primary", "syndicate-secondary")
|
|
96
|
+
}
|
|
91
97
|
|
|
92
|
-
def main() -> None:
|
|
93
|
-
"""Assemble the argh parser and dispatch (with optional tab completion)."""
|
|
94
|
-
parser = argh.ArghParser()
|
|
95
|
-
parser.add_commands(_dispatch_funcs)
|
|
96
|
-
try:
|
|
97
|
-
import argcomplete
|
|
98
98
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
99
|
+
def mk_parser() -> argparse.ArgumentParser:
|
|
100
|
+
"""Build the CLI parser -- a plain :class:`argparse.ArgumentParser`, no I/O.
|
|
101
|
+
|
|
102
|
+
The single place the command list and its per-parameter declarations meet, so a
|
|
103
|
+
test that inspects the grammar inspects the very parser :func:`main` dispatches.
|
|
104
|
+
"""
|
|
105
|
+
return cw.mk_parser(_dispatch_funcs, config=_dispatch_config)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def main() -> int:
|
|
109
|
+
"""Dispatch a CLI command and return its exit code.
|
|
110
|
+
|
|
111
|
+
``cw.run`` offers the parser to ``argcomplete`` itself (the
|
|
112
|
+
``# PYTHON_ARGCOMPLETE_OK`` marker on line 1 is what the shell hook looks for),
|
|
113
|
+
so there is no hand-written completion block to keep in step.
|
|
114
|
+
"""
|
|
115
|
+
return cw.run(mk_parser())
|
|
103
116
|
|
|
104
117
|
|
|
105
118
|
if __name__ == "__main__":
|
|
106
|
-
main()
|
|
119
|
+
raise SystemExit(main())
|
|
@@ -2,6 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
AI-assisted change log (major changes only).
|
|
4
4
|
|
|
5
|
+
## 2026-09-04 — CLI dispatch moves from argh to cw
|
|
6
|
+
|
|
7
|
+
- **CLI** (`__main__.py`): replaced `argh` (LGPL) with
|
|
8
|
+
[`cw`](https://github.com/i2mint/cw) (MIT, zero runtime dependencies), which
|
|
9
|
+
reproduces argh's grammar bit-for-bit. All 11 recorded argv vectors — help
|
|
10
|
+
surfaces, argh's usage-to-stdout-and-exit-0 no-argument case, four grammar
|
|
11
|
+
errors and both `CommandError` paths — replay byte-identically in stdout,
|
|
12
|
+
stderr and exit code.
|
|
13
|
+
- The two `@argh.arg(help=...)` decorators became a `_dispatch_config` mapping:
|
|
14
|
+
`cw` reads `config`, never decorator metadata, so the declaration had to move
|
|
15
|
+
or the help string would have been dropped silently.
|
|
16
|
+
- `main()` now **returns** the exit code (`cw.run` returns where argh raised),
|
|
17
|
+
and the `__main__` guard does `raise SystemExit(main())`.
|
|
18
|
+
- The hand-written `argcomplete` block is gone — `cw.run` fires completion itself.
|
|
19
|
+
- **Tests** (`tests/test_cli.py`): new characterization suite pinning the grammar,
|
|
20
|
+
the migrated help string, the `CommandError` contract and the exit codes.
|
|
21
|
+
|
|
5
22
|
## 2026-06-21 — Initial pipeline scaffold (0.1.0)
|
|
6
23
|
|
|
7
24
|
Built the full SSOT publishing pipeline skeleton, fully wired and importable:
|
|
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
|
|
|
6
6
|
|
|
7
7
|
[project]
|
|
8
8
|
name = "article"
|
|
9
|
-
version = "0.1.
|
|
9
|
+
version = "0.1.3"
|
|
10
10
|
description = "Tools to semi-automate the publication of articles"
|
|
11
11
|
readme = "README.md"
|
|
12
12
|
requires-python = ">=3.10"
|
|
@@ -37,7 +37,7 @@ classifiers = [
|
|
|
37
37
|
dependencies = [
|
|
38
38
|
"pydantic>=2",
|
|
39
39
|
"pydantic-settings>=2",
|
|
40
|
-
"
|
|
40
|
+
"cw>=0.1.1,<0.2",
|
|
41
41
|
"httpx",
|
|
42
42
|
"python-dotenv",
|
|
43
43
|
]
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""Characterization tests pinning the ``article`` command-line grammar.
|
|
2
|
+
|
|
3
|
+
Recorded from the pre-migration ``argh`` implementation and replayed against
|
|
4
|
+
:mod:`cw`. Three things here would not be caught by any other test:
|
|
5
|
+
|
|
6
|
+
* the ``article-path`` **help string**, which used to ride on an ``@argh.arg``
|
|
7
|
+
decorator. ``cw`` reads ``config``, never decorator metadata, so deleting the
|
|
8
|
+
decorator without moving the declaration drops the help silently — no error, just a
|
|
9
|
+
quietly poorer ``--help``;
|
|
10
|
+
* the ``CommandError`` contract — one line to stderr, no traceback, **exit 1**;
|
|
11
|
+
* the exit code itself, which ``cw.dispatch`` *returns* where argh raised it, so
|
|
12
|
+
``__main__`` must ``raise SystemExit(main())``.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import os
|
|
19
|
+
import subprocess
|
|
20
|
+
import sys
|
|
21
|
+
|
|
22
|
+
import pytest
|
|
23
|
+
|
|
24
|
+
from article.__main__ import _dispatch_funcs, main, mk_parser
|
|
25
|
+
|
|
26
|
+
COMMANDS = ("publish-primary", "syndicate-secondary")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@pytest.fixture(scope="module")
|
|
30
|
+
def parser():
|
|
31
|
+
"""The very parser :func:`article.__main__.main` dispatches, built without I/O.
|
|
32
|
+
|
|
33
|
+
Built through ``mk_parser`` rather than reassembled here, so a declaration that
|
|
34
|
+
stops reaching the real parser cannot keep passing these tests. ``sys.argv[0]`` is
|
|
35
|
+
pinned because argparse derives ``prog`` from it at construction time.
|
|
36
|
+
"""
|
|
37
|
+
argv = sys.argv
|
|
38
|
+
sys.argv = ["article"]
|
|
39
|
+
try:
|
|
40
|
+
return mk_parser()
|
|
41
|
+
finally:
|
|
42
|
+
sys.argv = argv
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@pytest.fixture(scope="module")
|
|
46
|
+
def subparsers(parser):
|
|
47
|
+
action = next(
|
|
48
|
+
a for a in parser._actions if isinstance(a, argparse._SubParsersAction)
|
|
49
|
+
)
|
|
50
|
+
return action.choices
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _run(*argv, cwd=None):
|
|
54
|
+
"""Run ``python -m article ARGV`` end to end."""
|
|
55
|
+
env = {k: v for k, v in os.environ.items() if k != "COLUMNS"}
|
|
56
|
+
return subprocess.run(
|
|
57
|
+
[sys.executable, "-m", "article", *argv],
|
|
58
|
+
capture_output=True,
|
|
59
|
+
text=True,
|
|
60
|
+
cwd=cwd,
|
|
61
|
+
env={"COLUMNS": "80", **env},
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# --------------------------------------------------------------------------- grammar
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_the_two_phases_are_the_two_commands(parser, subparsers):
|
|
69
|
+
assert [f.__name__ for f in _dispatch_funcs] == [
|
|
70
|
+
"publish_primary",
|
|
71
|
+
"syndicate_secondary",
|
|
72
|
+
]
|
|
73
|
+
assert tuple(subparsers) == COMMANDS
|
|
74
|
+
assert parser.format_usage() == (
|
|
75
|
+
"usage: article [-h] {publish-primary,syndicate-secondary} ...\n"
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@pytest.mark.parametrize(
|
|
80
|
+
"command, usage",
|
|
81
|
+
[
|
|
82
|
+
(
|
|
83
|
+
"publish-primary",
|
|
84
|
+
"usage: article publish-primary [-h] [-e ENV_FILE] [-s STATE_PATH] [-j]"
|
|
85
|
+
" article-path",
|
|
86
|
+
),
|
|
87
|
+
(
|
|
88
|
+
"syndicate-secondary",
|
|
89
|
+
"usage: article syndicate-secondary [-h] [-e ENV_FILE] [-s STATE_PATH]"
|
|
90
|
+
" [-p PLATFORMS] [-j] article-path",
|
|
91
|
+
),
|
|
92
|
+
],
|
|
93
|
+
)
|
|
94
|
+
def test_each_command_keeps_its_recorded_usage_line(subparsers, command, usage):
|
|
95
|
+
"""Flag spellings and short options, exactly as argh rendered them.
|
|
96
|
+
|
|
97
|
+
Line wrapping is normalised away — it depends on the terminal width the test
|
|
98
|
+
happens to run under.
|
|
99
|
+
"""
|
|
100
|
+
assert " ".join(subparsers[command].format_usage().split()) == usage
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@pytest.mark.parametrize("command", COMMANDS)
|
|
104
|
+
def test_the_positional_keeps_the_help_that_used_to_be_an_argh_arg(
|
|
105
|
+
subparsers, command
|
|
106
|
+
):
|
|
107
|
+
"""The migrated ``@argh.arg(help=...)`` declaration.
|
|
108
|
+
|
|
109
|
+
``cw`` does **not** read argh's decorator metadata, so dropping
|
|
110
|
+
``_dispatch_config`` would leave this help string at the ``-`` placeholder with no
|
|
111
|
+
error anywhere. This test is the only thing that would notice.
|
|
112
|
+
"""
|
|
113
|
+
positional = next(
|
|
114
|
+
a for a in subparsers[command]._actions if not a.option_strings
|
|
115
|
+
)
|
|
116
|
+
assert positional.dest == "article-path" # argh hyphenates positionals
|
|
117
|
+
assert positional.help == "Path to the article JSON file"
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@pytest.mark.parametrize("command", COMMANDS)
|
|
121
|
+
def test_defaults_match_the_function_signatures(subparsers, command):
|
|
122
|
+
namespace = subparsers[command].parse_args(["a.json"])
|
|
123
|
+
assert getattr(namespace, "article-path") == "a.json"
|
|
124
|
+
assert namespace.env_file is None
|
|
125
|
+
assert namespace.state_path is None
|
|
126
|
+
assert namespace.json_out is False
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def test_platforms_is_only_on_phase_two(subparsers):
|
|
130
|
+
def usage(command):
|
|
131
|
+
return " ".join(subparsers[command].format_usage().split())
|
|
132
|
+
|
|
133
|
+
assert "-p PLATFORMS" in usage("syndicate-secondary")
|
|
134
|
+
assert "-p PLATFORMS" not in usage("publish-primary")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# ------------------------------------------------------------------------ exit codes
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def test_no_arguments_prints_usage_to_stdout_and_exits_zero():
|
|
141
|
+
"""argh's behaviour, which bare argparse does not reproduce. Pinned deliberately."""
|
|
142
|
+
done = _run()
|
|
143
|
+
assert done.returncode == 0
|
|
144
|
+
assert done.stdout.startswith("usage: ")
|
|
145
|
+
assert done.stderr == ""
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@pytest.mark.parametrize(
|
|
149
|
+
"argv",
|
|
150
|
+
[
|
|
151
|
+
("no-such-command",),
|
|
152
|
+
("publish-primary",), # missing required positional
|
|
153
|
+
("syndicate-secondary",), # missing required positional
|
|
154
|
+
("publish-primary", "--env-file"), # option missing its value
|
|
155
|
+
],
|
|
156
|
+
)
|
|
157
|
+
def test_bad_invocations_exit_two(argv):
|
|
158
|
+
done = _run(*argv)
|
|
159
|
+
assert done.returncode == 2
|
|
160
|
+
assert done.stdout == ""
|
|
161
|
+
assert done.stderr.startswith("usage: ")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@pytest.mark.parametrize("command", COMMANDS)
|
|
165
|
+
def test_an_unreadable_article_is_a_command_error_not_a_traceback(command, tmp_path):
|
|
166
|
+
"""``CommandError``: one line to stderr, **exit 1**, and no traceback."""
|
|
167
|
+
done = _run(command, "no_such_article.json", cwd=tmp_path)
|
|
168
|
+
assert done.returncode == 1
|
|
169
|
+
assert done.stdout == ""
|
|
170
|
+
assert done.stderr.startswith("CommandError: Could not read article from ")
|
|
171
|
+
assert "Traceback" not in done.stderr
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def test_main_returns_the_exit_code_rather_than_swallowing_it(monkeypatch, tmp_path):
|
|
175
|
+
"""The in-process half: ``main()`` yields an int, not ``None``.
|
|
176
|
+
|
|
177
|
+
This is what makes ``raise SystemExit(main())`` in ``__main__`` — and the console
|
|
178
|
+
script's own ``sys.exit(main())`` — report the failure instead of success.
|
|
179
|
+
"""
|
|
180
|
+
monkeypatch.chdir(tmp_path)
|
|
181
|
+
monkeypatch.setattr(sys, "argv", ["article", "no-such-command"])
|
|
182
|
+
assert main() == 2
|
|
183
|
+
monkeypatch.setattr(
|
|
184
|
+
sys, "argv", ["article", "publish-primary", "no_such_article.json"]
|
|
185
|
+
)
|
|
186
|
+
assert main() == 1
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|