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.
Files changed (26) hide show
  1. {article-0.1.2 → article-0.1.3}/PKG-INFO +4 -4
  2. {article-0.1.2 → article-0.1.3}/README.md +1 -1
  3. {article-0.1.2 → article-0.1.3}/article/__main__.py +30 -17
  4. {article-0.1.2 → article-0.1.3}/article/misc/CHANGELOG.md +17 -0
  5. {article-0.1.2 → article-0.1.3}/pyproject.toml +2 -2
  6. article-0.1.3/tests/test_cli.py +186 -0
  7. {article-0.1.2 → article-0.1.3}/.editorconfig +0 -0
  8. {article-0.1.2 → article-0.1.3}/.env.example +0 -0
  9. {article-0.1.2 → article-0.1.3}/.gitattributes +0 -0
  10. {article-0.1.2 → article-0.1.3}/.github/workflows/ci.yml +0 -0
  11. {article-0.1.2 → article-0.1.3}/.gitignore +0 -0
  12. {article-0.1.2 → article-0.1.3}/LICENSE +0 -0
  13. {article-0.1.2 → article-0.1.3}/article/__init__.py +0 -0
  14. {article-0.1.2 → article-0.1.3}/article/adapters/__init__.py +0 -0
  15. {article-0.1.2 → article-0.1.3}/article/adapters/dev_to.py +0 -0
  16. {article-0.1.2 → article-0.1.3}/article/adapters/hashnode.py +0 -0
  17. {article-0.1.2 → article-0.1.3}/article/adapters/medium.py +0 -0
  18. {article-0.1.2 → article-0.1.3}/article/adapters/substack.py +0 -0
  19. {article-0.1.2 → article-0.1.3}/article/base.py +0 -0
  20. {article-0.1.2 → article-0.1.3}/article/config.py +0 -0
  21. {article-0.1.2 → article-0.1.3}/article/engine.py +0 -0
  22. {article-0.1.2 → article-0.1.3}/article/registry.py +0 -0
  23. {article-0.1.2 → article-0.1.3}/article/state.py +0 -0
  24. {article-0.1.2 → article-0.1.3}/article/util.py +0 -0
  25. {article-0.1.2 → article-0.1.3}/examples/article.example.json +0 -0
  26. {article-0.1.2 → article-0.1.3}/tests/test_pipeline.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: article
3
- Version: 0.1.2
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: argh
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` | `argh` dispatch of the two commands |
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` | `argh` dispatch of the two commands |
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 argh
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 argh.CommandError(str(e)) from e
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 argh.CommandError(str(e)) from e
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 (argh maps ``_`` in names to ``-``).
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
- argcomplete.autocomplete(parser)
100
- except ImportError:
101
- pass
102
- parser.dispatch()
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.2"
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
- "argh",
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