pesa-cli 1.0.0__tar.gz → 1.0.2__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pesa-cli
3
- Version: 1.0.0
3
+ Version: 1.0.2
4
4
  Summary: Command-line tool for M-Pesa Daraja v3 — STK Push, B2C, balance, config management.
5
5
  Author-email: Gabriel Mahia <contact@aikungfu.dev>
6
6
  License: CC-BY-NC-ND-4.0
@@ -22,6 +22,7 @@ Requires-Dist: rich>=13
22
22
  Provides-Extra: dev
23
23
  Requires-Dist: pytest>=7; extra == "dev"
24
24
  Requires-Dist: ruff; extra == "dev"
25
+ Requires-Dist: daraja-mock>=1.0.0; extra == "dev"
25
26
  Dynamic: license-file
26
27
 
27
28
  # pesa-cli
@@ -182,3 +183,7 @@ if scripting against pesa-cli output is needed.
182
183
 
183
184
  *Part of the [nairobi-stack](https://github.com/gabrielmahia/nairobi-stack) East Africa engineering ecosystem.*
184
185
  *Maintained by [Gabriel Mahia](https://github.com/gabrielmahia). Kenya × USA.*
186
+
187
+ ## Getting sandbox credentials
188
+
189
+ New to Daraja, or on a new machine? Follow [the sandbox setup guide](https://github.com/gabrielmahia/mpesa-python/blob/main/docs/SANDBOX_SETUP.md): it creates a free sandbox app and runs OAuth, an STK Push and a status query, with exact portal labels. Set `DARAJA_CALLBACK_URL` to a public HTTPS address you can watch (for example a webhook.site URL); without it the CLI uses a placeholder and tells you so.
@@ -156,3 +156,7 @@ if scripting against pesa-cli output is needed.
156
156
 
157
157
  *Part of the [nairobi-stack](https://github.com/gabrielmahia/nairobi-stack) East Africa engineering ecosystem.*
158
158
  *Maintained by [Gabriel Mahia](https://github.com/gabrielmahia). Kenya × USA.*
159
+
160
+ ## Getting sandbox credentials
161
+
162
+ New to Daraja, or on a new machine? Follow [the sandbox setup guide](https://github.com/gabrielmahia/mpesa-python/blob/main/docs/SANDBOX_SETUP.md): it creates a free sandbox app and runs OAuth, an STK Push and a status query, with exact portal labels. Set `DARAJA_CALLBACK_URL` to a public HTTPS address you can watch (for example a webhook.site URL); without it the CLI uses a placeholder and tells you so.
@@ -26,9 +26,7 @@ from __future__ import annotations
26
26
 
27
27
  import json
28
28
  import os
29
- import sys
30
29
  import time
31
- import hashlib
32
30
  import base64
33
31
  import urllib.request
34
32
  import urllib.error
@@ -42,7 +40,6 @@ from rich import print as rprint
42
40
  from rich.console import Console
43
41
  from rich.table import Table
44
42
  from rich.panel import Panel
45
- from rich.text import Text
46
43
 
47
44
  app = typer.Typer(
48
45
  name="pesa",
@@ -107,6 +104,10 @@ def require_credential(key: str, label: str) -> str:
107
104
  # ── Daraja HTTP layer ──────────────────────────────────────────────────────────
108
105
 
109
106
  def _base_url() -> str:
107
+ # Documented in the README ("Use with daraja-mock"): point the CLI at a local test server instead of Safaricom.
108
+ override = os.environ.get("PESA_BASE_URL")
109
+ if override:
110
+ return override.rstrip("/")
110
111
  env = get_credential("environment") or "sandbox"
111
112
  if env == "production":
112
113
  return "https://api.safaricom.co.ke"
@@ -186,8 +187,7 @@ def config_show():
186
187
  display = ("*" * 8 + val[-4:] if val and is_secret and len(val) > 4 else val or "—")
187
188
  table.add_row(key, source, display)
188
189
  console.print(table)
189
- rprint(f"
190
- [dim]Config file: {_config_path()}[/dim]")
190
+ rprint(f"\n[dim]Config file: {_config_path()}[/dim]")
191
191
 
192
192
 
193
193
  @config_app.command("set")
@@ -219,12 +219,9 @@ def auth():
219
219
  raise typer.Exit(1)
220
220
  env = get_credential("environment") or "sandbox"
221
221
  rprint(Panel(
222
- f"[green]✓ Authenticated[/green]
223
- "
224
- f"Environment: [cyan]{env}[/cyan]
225
- "
226
- f"Token: [dim]{token[:12]}...{token[-4:]}[/dim]
227
- "
222
+ f"[green]✓ Authenticated[/green]\n"
223
+ f"Environment: [cyan]{env}[/cyan]\n"
224
+ f"Token: [dim]{token[:12]}...{token[-4:]}[/dim]\n"
228
225
  f"Expires in: [cyan]~60 minutes[/cyan]",
229
226
  title="pesa auth", border_style="green",
230
227
  ))
@@ -243,7 +240,8 @@ def stk_push(
243
240
  consumer_secret = require_credential("consumer_secret", "Consumer Secret")
244
241
  shortcode = require_credential("shortcode", "Shortcode")
245
242
  passkey = require_credential("passkey", "Passkey")
246
- callback_url = get_credential("callback_url") or "https://example.com/mpesa/callback"
243
+ callback_given = get_credential("callback_url")
244
+ callback_url = callback_given or "https://example.com/mpesa/callback"
247
245
  ref = ref[:12] # Daraja hard limit
248
246
 
249
247
  timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
@@ -271,27 +269,24 @@ def stk_push(
271
269
 
272
270
  if response.get("ResponseCode") == "0":
273
271
  checkout_id = response.get("CheckoutRequestID", "")
272
+ # Without a callback URL Safaricom posts the result to a placeholder you cannot see; say so instead of failing silently.
273
+ notice = "" if callback_given else (
274
+ "\n\n[yellow]No callback URL set (DARAJA_CALLBACK_URL). Safaricom will post the result to "
275
+ f"{callback_url}, which you cannot see. Use 'pesa stk query' to check the outcome.[/yellow]"
276
+ )
274
277
  rprint(Panel(
275
- f"[green]✓ STK Push sent[/green]
276
- "
277
- f"Phone: [cyan]+{phone}[/cyan]
278
- "
279
- f"Amount: [cyan]KES {amount:,}[/cyan]
280
- "
281
- f"Reference: [cyan]{ref}[/cyan]
282
- "
283
- f"Checkout ID: [dim]{checkout_id}[/dim]
284
-
285
- "
286
- f"[dim]The customer will receive a PIN prompt on their phone.[/dim]
287
- "
288
- f"[dim]Run: pesa stk query {checkout_id}[/dim]",
278
+ f"[green]✓ STK Push sent[/green]\n"
279
+ f"Phone: [cyan]+{phone}[/cyan]\n"
280
+ f"Amount: [cyan]KES {amount:,}[/cyan]\n"
281
+ f"Reference: [cyan]{ref}[/cyan]\n"
282
+ f"Checkout ID: [dim]{checkout_id}[/dim]\n\n"
283
+ f"[dim]The customer will receive a PIN prompt on their phone.[/dim]\n"
284
+ f"[dim]Run: pesa stk query {checkout_id}[/dim]{notice}",
289
285
  title="STK Push", border_style="green",
290
286
  ))
291
287
  else:
292
288
  rprint(Panel(
293
- f"[red]✗ Request rejected[/red]
294
- {json.dumps(response, indent=2)}",
289
+ f"[red]✗ Request rejected[/red]\n{json.dumps(response, indent=2)}",
295
290
  title="STK Push", border_style="red",
296
291
  ))
297
292
  raise typer.Exit(1)
@@ -329,10 +324,8 @@ def stk_query(
329
324
  color = STATUS_COLORS.get(str(result_code), "red")
330
325
  symbol = "✓" if str(result_code) == "0" else "✗"
331
326
  rprint(Panel(
332
- f"[{color}]{symbol} {result_desc}[/{color}]
333
- "
334
- f"Result Code: [cyan]{result_code}[/cyan]
335
- "
327
+ f"[{color}]{symbol} {result_desc}[/{color}]\n"
328
+ f"Result Code: [cyan]{result_code}[/cyan]\n"
336
329
  f"Checkout ID: [dim]{checkout_id}[/dim]",
337
330
  title="STK Query", border_style=color,
338
331
  ))
@@ -377,22 +370,16 @@ def b2c(
377
370
 
378
371
  if response.get("ResponseCode") == "0":
379
372
  rprint(Panel(
380
- f"[green]✓ B2C request accepted[/green]
381
- "
382
- f"Phone: [cyan]+{phone}[/cyan]
383
- "
384
- f"Amount: [cyan]KES {amount:,}[/cyan]
385
- "
386
- f"Conversation ID: [dim]{response.get('ConversationID', '')}[/dim]
387
-
388
- "
373
+ f"[green]✓ B2C request accepted[/green]\n"
374
+ f"Phone: [cyan]+{phone}[/cyan]\n"
375
+ f"Amount: [cyan]KES {amount:,}[/cyan]\n"
376
+ f"Conversation ID: [dim]{response.get('ConversationID', '')}[/dim]\n\n"
389
377
  f"[dim]Result will be posted to your ResultURL.[/dim]",
390
378
  title="B2C", border_style="green",
391
379
  ))
392
380
  else:
393
381
  rprint(Panel(
394
- f"[red]✗ B2C rejected[/red]
395
- {json.dumps(response, indent=2)}",
382
+ f"[red]✗ B2C rejected[/red]\n{json.dumps(response, indent=2)}",
396
383
  title="B2C", border_style="red",
397
384
  ))
398
385
  raise typer.Exit(1)
@@ -428,20 +415,15 @@ def balance():
428
415
 
429
416
  if response.get("ResponseCode") == "0":
430
417
  rprint(Panel(
431
- f"[green]✓ Balance request accepted[/green]
432
- "
433
- f"Shortcode: [cyan]{shortcode}[/cyan]
434
- "
435
- f"Conversation ID: [dim]{response.get('ConversationID', '')}[/dim]
436
-
437
- "
418
+ f"[green]✓ Balance request accepted[/green]\n"
419
+ f"Shortcode: [cyan]{shortcode}[/cyan]\n"
420
+ f"Conversation ID: [dim]{response.get('ConversationID', '')}[/dim]\n\n"
438
421
  f"[dim]Balance will be posted to your ResultURL asynchronously.[/dim]",
439
422
  title="Account Balance", border_style="green",
440
423
  ))
441
424
  else:
442
425
  rprint(Panel(
443
- f"[red]✗ Request rejected[/red]
444
- {json.dumps(response, indent=2)}",
426
+ f"[red]✗ Request rejected[/red]\n{json.dumps(response, indent=2)}",
445
427
  title="Account Balance", border_style="red",
446
428
  ))
447
429
  raise typer.Exit(1)
@@ -25,10 +25,8 @@ from __future__ import annotations
25
25
 
26
26
  import base64
27
27
  import os
28
- import sys
29
28
  import time
30
29
  from datetime import datetime
31
- from typing import Optional
32
30
 
33
31
  import requests
34
32
  import typer
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pesa-cli
3
- Version: 1.0.0
3
+ Version: 1.0.2
4
4
  Summary: Command-line tool for M-Pesa Daraja v3 — STK Push, B2C, balance, config management.
5
5
  Author-email: Gabriel Mahia <contact@aikungfu.dev>
6
6
  License: CC-BY-NC-ND-4.0
@@ -22,6 +22,7 @@ Requires-Dist: rich>=13
22
22
  Provides-Extra: dev
23
23
  Requires-Dist: pytest>=7; extra == "dev"
24
24
  Requires-Dist: ruff; extra == "dev"
25
+ Requires-Dist: daraja-mock>=1.0.0; extra == "dev"
25
26
  Dynamic: license-file
26
27
 
27
28
  # pesa-cli
@@ -182,3 +183,7 @@ if scripting against pesa-cli output is needed.
182
183
 
183
184
  *Part of the [nairobi-stack](https://github.com/gabrielmahia/nairobi-stack) East Africa engineering ecosystem.*
184
185
  *Maintained by [Gabriel Mahia](https://github.com/gabrielmahia). Kenya × USA.*
186
+
187
+ ## Getting sandbox credentials
188
+
189
+ New to Daraja, or on a new machine? Follow [the sandbox setup guide](https://github.com/gabrielmahia/mpesa-python/blob/main/docs/SANDBOX_SETUP.md): it creates a free sandbox app and runs OAuth, an STK Push and a status query, with exact portal labels. Set `DARAJA_CALLBACK_URL` to a public HTTPS address you can watch (for example a webhook.site URL); without it the CLI uses a placeholder and tells you so.
@@ -11,4 +11,5 @@ pesa_cli.egg-info/dependency_links.txt
11
11
  pesa_cli.egg-info/entry_points.txt
12
12
  pesa_cli.egg-info/requires.txt
13
13
  pesa_cli.egg-info/top_level.txt
14
- tests/test_cli.py
14
+ tests/test_cli.py
15
+ tests/test_cli_e2e.py
@@ -5,3 +5,4 @@ rich>=13
5
5
  [dev]
6
6
  pytest>=7
7
7
  ruff
8
+ daraja-mock>=1.0.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pesa-cli"
7
- version = "1.0.0"
7
+ version = "1.0.2"
8
8
  description = "Command-line tool for M-Pesa Daraja v3 — STK Push, B2C, balance, config management."
9
9
  readme = "README.md"
10
10
  license = {text = "CC-BY-NC-ND-4.0"}
@@ -30,7 +30,7 @@ dependencies = [
30
30
  pesa = "pesa_cli.cli:app"
31
31
 
32
32
  [project.optional-dependencies]
33
- dev = ["pytest>=7", "ruff"]
33
+ dev = ["pytest>=7", "ruff", "daraja-mock>=1.0.0"]
34
34
 
35
35
  [project.urls]
36
36
  Homepage = "https://github.com/gabrielmahia/pesa-cli"
@@ -1,6 +1,5 @@
1
1
  """Tests for pesa-cli — config and normalize logic without live calls."""
2
2
  import os
3
- import pytest
4
3
  from typer.testing import CliRunner
5
4
  from pesa_cli import app
6
5
  from pesa_cli.main import _normalize
@@ -0,0 +1,128 @@
1
+ """End-to-end tests of the CLI that actually ships (`pesa = "pesa_cli.cli:app"`), driven against the daraja-mock test server.
2
+
3
+ Why this exists: pesa_cli/cli.py had a SyntaxError from its first commit, so the published `pesa` command could not start, while the old tests passed because
4
+ they imported a sibling module (pesa_cli.main). These tests exercise the real entry point, every command, and the documented PESA_BASE_URL override."""
5
+ import importlib
6
+ import py_compile
7
+ import re
8
+ import socket
9
+ from pathlib import Path
10
+
11
+ import pytest
12
+ from typer.testing import CliRunner
13
+
14
+ from daraja_mock import DarajaMock
15
+
16
+ ROOT = Path(__file__).resolve().parents[1]
17
+ runner = CliRunner()
18
+
19
+
20
+ def _free_port() -> int:
21
+ with socket.socket() as s:
22
+ s.bind(("127.0.0.1", 0))
23
+ return s.getsockname()[1]
24
+
25
+
26
+ @pytest.fixture(scope="module")
27
+ def mock():
28
+ m = DarajaMock(consumer_key="testkey", consumer_secret="testsecret")
29
+ m.base_url = m.run_thread(port=_free_port())
30
+ return m
31
+
32
+
33
+ @pytest.fixture
34
+ def env(mock, tmp_path):
35
+ mock.reset()
36
+ return {
37
+ "PESA_BASE_URL": mock.base_url, "PESA_CONFIG": str(tmp_path / "cfg.json"),
38
+ "DARAJA_CONSUMER_KEY": "testkey", "DARAJA_CONSUMER_SECRET": "testsecret", "DARAJA_SHORTCODE": "174379", "DARAJA_PASSKEY": "testpasskey",
39
+ "DARAJA_INITIATOR_NAME": "testapi", "DARAJA_SECURITY_CREDENTIAL": "testcred",
40
+ }
41
+
42
+
43
+ def _app():
44
+ return importlib.import_module("pesa_cli.cli").app
45
+
46
+
47
+ def _posts(mock, path):
48
+ return [e for e in mock.request_log() if e["path"] == path]
49
+
50
+
51
+ # ── guards against shipping something that cannot start ────────────────────────────────────────────────────────────────
52
+ def test_every_module_in_the_package_compiles():
53
+ for f in sorted((ROOT / "pesa_cli").glob("*.py")):
54
+ py_compile.compile(str(f), doraise=True)
55
+
56
+
57
+ def test_the_declared_entry_point_resolves():
58
+ target = re.search(r'^\s*pesa\s*=\s*"([\w.]+):(\w+)"', (ROOT / "pyproject.toml").read_text(encoding="utf-8"), re.M)
59
+ assert target, "no `pesa` entry point in pyproject.toml"
60
+ assert getattr(importlib.import_module(target.group(1)), target.group(2)) is _app()
61
+
62
+
63
+ def test_help_lists_every_command():
64
+ out = runner.invoke(_app(), ["--help"]).output
65
+ for cmd in ("auth", "b2c", "balance", "stk", "config"):
66
+ assert cmd in out
67
+
68
+
69
+ # ── every command against the mock ────────────────────────────────────────────────────────────────────────────────────
70
+ def test_auth(mock, env):
71
+ r = runner.invoke(_app(), ["auth"], env=env)
72
+ assert r.exit_code == 0 and "Authenticated" in r.output
73
+ assert _posts(mock, "/oauth/v1/generate")
74
+
75
+
76
+ def test_stk_push_sends_the_normalised_payload(mock, env):
77
+ r = runner.invoke(_app(), ["stk", "push", "0712345678", "100", "--ref", "E2E"], env=env)
78
+ assert r.exit_code == 0 and "STK Push sent" in r.output
79
+ body = _posts(mock, "/mpesa/stkpush/v1/processrequest")[-1]["body"]
80
+ assert str(body["PhoneNumber"]) == "254712345678" and str(body["Amount"]) == "100" and body["AccountReference"] == "E2E"
81
+
82
+
83
+ def test_stk_query_reports_success_and_cancellation(mock, env):
84
+ ok = runner.invoke(_app(), ["stk", "query", "ws_CO_TEST"], env=env)
85
+ assert ok.exit_code == 0 and "Result Code: 0" in ok.output
86
+ mock.set_stk_result(1032)
87
+ cancelled = runner.invoke(_app(), ["stk", "query", "ws_CO_TEST"], env=env)
88
+ assert "Result Code: 1032" in cancelled.output
89
+
90
+
91
+ def test_b2c_and_balance_are_accepted(mock, env):
92
+ assert "B2C request accepted" in runner.invoke(_app(), ["b2c", "0712345678", "50"], env=env).output
93
+ assert "Balance request accepted" in runner.invoke(_app(), ["balance"], env=env).output
94
+ assert _posts(mock, "/mpesa/b2c/v3/paymentrequest") and _posts(mock, "/mpesa/accountbalance/v1/query")
95
+
96
+
97
+ def test_missing_credentials_fail_cleanly(env):
98
+ bare = {k: "" for k in env if k.startswith("DARAJA_")}
99
+ bare.update(PESA_BASE_URL=env["PESA_BASE_URL"], PESA_CONFIG=env["PESA_CONFIG"])
100
+ r = runner.invoke(_app(), ["stk", "push", "0712345678", "100"], env=bare)
101
+ assert r.exit_code == 1 and "Traceback" not in r.output
102
+
103
+
104
+ @pytest.mark.parametrize("raw,expected", [("0712345678", "254712345678"), ("+254712345678", "254712345678"), ("712345678", "254712345678"),
105
+ ("0112345678", "254112345678"), ("+254110123456", "254110123456")])
106
+ def test_phone_normalisation(raw, expected):
107
+ assert importlib.import_module("pesa_cli.cli")._normalise_phone(raw) == expected
108
+
109
+
110
+ def test_invalid_phone_is_rejected():
111
+ import typer
112
+
113
+ with pytest.raises(typer.Exit):
114
+ importlib.import_module("pesa_cli.cli")._normalise_phone("12345")
115
+
116
+
117
+ def test_a_missing_callback_url_is_announced_not_silent(mock, env):
118
+ """Without DARAJA_CALLBACK_URL the CLI substitutes https://example.com/mpesa/callback, so the user never sees the result and had no hint why."""
119
+ r = runner.invoke(_app(), ["stk", "push", "0712345678", "10", "--ref", "NOCB"], env=env)
120
+ assert r.exit_code == 0 and "No callback URL" in r.output
121
+ body = _posts(mock, "/mpesa/stkpush/v1/processrequest")[-1]["body"]
122
+ assert body["CallBackURL"] == "https://example.com/mpesa/callback"
123
+
124
+
125
+ def test_a_configured_callback_url_is_used_and_not_warned_about(mock, env):
126
+ r = runner.invoke(_app(), ["stk", "push", "0712345678", "10", "--ref", "WITHCB"], env={**env, "DARAJA_CALLBACK_URL": "https://webhook.site/abc"})
127
+ assert r.exit_code == 0 and "No callback URL" not in r.output
128
+ assert _posts(mock, "/mpesa/stkpush/v1/processrequest")[-1]["body"]["CallBackURL"] == "https://webhook.site/abc"
File without changes
File without changes
File without changes
File without changes