moveq-cli 0.1.2__tar.gz → 0.2.0__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: moveq-cli
3
- Version: 0.1.2
3
+ Version: 0.2.0
4
4
  Summary: Command-line tool for moveq: Gini, Palma, Concentration Index, composite scores, and catalogue validation from CSV or JSON.
5
5
  Author-email: Soura <martisoura@gmail.com>
6
6
  License-Expression: BSD-3-Clause
@@ -54,7 +54,7 @@ only the argparse front-end.
54
54
 
55
55
  **`moveq gini`** and **`moveq palma`** read a CSV and treat one column as
56
56
  the service value (trips, departures, coverage) and another as the
57
- population weight. They print a single number:
57
+ population weight. By default they print a single number:
58
58
 
59
59
  - Gini — overall inequality of that service across people (`0` = equal)
60
60
  - Palma — mean service of the best-served 10% of population over the
@@ -62,10 +62,11 @@ population weight. They print a single number:
62
62
  equal, including all-zero service; `inf` if the bottom 40% have no
63
63
  service and the top 10% do not)
64
64
 
65
- **`moveq ci`** is the Wagstaff Concentration Index. It needs a third
66
- column: socioeconomic rank (`1` = most deprived in this library).
67
- Positive values mean service is concentrated in less-deprived areas;
68
- negative values mean the opposite.
65
+ **`moveq ci`** is the Wagstaff Concentration Index. It needs a rank
66
+ column and **`--rank-direction`** (`higher-is-advantaged` or
67
+ `higher-is-disadvantaged`). Positive values are advantage-concentrated;
68
+ negative values are disadvantage-concentrated. A zero or cancelled mean
69
+ exits with code `3`.
69
70
 
70
71
  **`moveq score`** builds a 0–100 weighted composite. Terms live in
71
72
  `[0, 1]`; JSON `null` means “this term was not collected.” Missing terms
@@ -77,6 +78,10 @@ in a JSON file (`--json`).
77
78
  the same / replace / omit rules: every base section must have an
78
79
  explicit decision, replacements need a title, omissions need a note.
79
80
 
81
+ `moveq gini`, `moveq palma`, and `moveq ci` accept `--json` to print the
82
+ full `EquityResult` (`metric`, `value`, `method`, `n_areas`, warnings).
83
+ The default line is unchanged.
84
+
80
85
  Column names are whatever your CSV actually uses; you point at them with
81
86
  `--value`, `--weight`, and `--rank`.
82
87
 
@@ -95,7 +100,7 @@ Inequality from a CSV (headers `trips`, `population`, `deprivation_rank`):
95
100
  ```bash
96
101
  moveq gini data.csv --value trips --weight population
97
102
  moveq palma data.csv --value trips --weight population
98
- moveq ci data.csv --value trips --rank deprivation_rank --weight population
103
+ moveq ci data.csv --value trips --rank deprivation_rank --weight population --rank-direction higher-is-advantaged
99
104
  ```
100
105
 
101
106
  Composite score, inline or from a file:
@@ -19,7 +19,7 @@ only the argparse front-end.
19
19
 
20
20
  **`moveq gini`** and **`moveq palma`** read a CSV and treat one column as
21
21
  the service value (trips, departures, coverage) and another as the
22
- population weight. They print a single number:
22
+ population weight. By default they print a single number:
23
23
 
24
24
  - Gini — overall inequality of that service across people (`0` = equal)
25
25
  - Palma — mean service of the best-served 10% of population over the
@@ -27,10 +27,11 @@ population weight. They print a single number:
27
27
  equal, including all-zero service; `inf` if the bottom 40% have no
28
28
  service and the top 10% do not)
29
29
 
30
- **`moveq ci`** is the Wagstaff Concentration Index. It needs a third
31
- column: socioeconomic rank (`1` = most deprived in this library).
32
- Positive values mean service is concentrated in less-deprived areas;
33
- negative values mean the opposite.
30
+ **`moveq ci`** is the Wagstaff Concentration Index. It needs a rank
31
+ column and **`--rank-direction`** (`higher-is-advantaged` or
32
+ `higher-is-disadvantaged`). Positive values are advantage-concentrated;
33
+ negative values are disadvantage-concentrated. A zero or cancelled mean
34
+ exits with code `3`.
34
35
 
35
36
  **`moveq score`** builds a 0–100 weighted composite. Terms live in
36
37
  `[0, 1]`; JSON `null` means “this term was not collected.” Missing terms
@@ -42,6 +43,10 @@ in a JSON file (`--json`).
42
43
  the same / replace / omit rules: every base section must have an
43
44
  explicit decision, replacements need a title, omissions need a note.
44
45
 
46
+ `moveq gini`, `moveq palma`, and `moveq ci` accept `--json` to print the
47
+ full `EquityResult` (`metric`, `value`, `method`, `n_areas`, warnings).
48
+ The default line is unchanged.
49
+
45
50
  Column names are whatever your CSV actually uses; you point at them with
46
51
  `--value`, `--weight`, and `--rank`.
47
52
 
@@ -60,7 +65,7 @@ Inequality from a CSV (headers `trips`, `population`, `deprivation_rank`):
60
65
  ```bash
61
66
  moveq gini data.csv --value trips --weight population
62
67
  moveq palma data.csv --value trips --weight population
63
- moveq ci data.csv --value trips --rank deprivation_rank --weight population
68
+ moveq ci data.csv --value trips --rank deprivation_rank --weight population --rank-direction higher-is-advantaged
64
69
  ```
65
70
 
66
71
  Composite score, inline or from a file:
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "moveq-cli"
7
- version = "0.1.2"
7
+ version = "0.2.0"
8
8
  description = "Command-line tool for moveq: Gini, Palma, Concentration Index, composite scores, and catalogue validation from CSV or JSON."
9
9
  readme = "README.md"
10
10
  license = "BSD-3-Clause"
@@ -1,3 +1,3 @@
1
1
  """moveq-cli — command-line tool for moveq."""
2
2
 
3
- __version__ = "0.1.2"
3
+ __version__ = "0.2.0"
@@ -2,7 +2,7 @@
2
2
 
3
3
  moveq gini data.csv --value trips --weight population
4
4
  moveq palma data.csv --value trips --weight population
5
- moveq ci data.csv --value trips --rank deprivation_rank --weight population
5
+ moveq ci data.csv --value trips --rank deprivation_rank --weight population --rank-direction higher-is-advantaged
6
6
  moveq score --terms '{"coverage": 0.8, "evening": 0.5}' --weights '{"coverage": 0.6, "evening": 0.4}'
7
7
  moveq score config.json
8
8
  moveq catalogue validate config.json
@@ -21,10 +21,11 @@ import numpy as np
21
21
 
22
22
  from moveq_catalogue import Catalogue, SectionAction
23
23
  from moveq_core import (
24
- compute_concentration_index,
25
- compute_gini,
26
- compute_palma_ratio,
24
+ EquityResult,
27
25
  compute_score,
26
+ concentration_index_result,
27
+ gini_result,
28
+ palma_result,
28
29
  )
29
30
 
30
31
 
@@ -40,25 +41,49 @@ def _read_columns(path: str, columns: list[str]) -> dict[str, np.ndarray]:
40
41
  return {c: np.array([float(r[c]) for r in rows]) for c in columns}
41
42
 
42
43
 
44
+ _RANK_DIRECTION_CLI = {
45
+ "higher-is-advantaged": "higher_is_advantaged",
46
+ "higher-is-disadvantaged": "higher_is_disadvantaged",
47
+ }
48
+
49
+
50
+ def _print_equity(result: EquityResult, label: str, as_json: bool) -> int:
51
+ if as_json:
52
+ print(json.dumps(result.to_dict(), indent=2))
53
+ elif result.status == "undefined" or result.value is None:
54
+ print(f"{label}: undefined", file=sys.stderr)
55
+ if result.reason:
56
+ print(f"reason: {result.reason}", file=sys.stderr)
57
+ else:
58
+ print(f"{label}: {result.value:.4f}")
59
+ if result.status == "undefined":
60
+ return 3
61
+ return 0
62
+
63
+
43
64
  def _cmd_gini(args: argparse.Namespace) -> int:
44
65
  cols = _read_columns(args.csv, [args.value, args.weight])
45
- result = compute_gini(cols[args.value], cols[args.weight])
46
- print(f"gini: {result:.4f}")
47
- return 0
66
+ return _print_equity(gini_result(cols[args.value], cols[args.weight]), "gini", args.json)
48
67
 
49
68
 
50
69
  def _cmd_palma(args: argparse.Namespace) -> int:
51
70
  cols = _read_columns(args.csv, [args.value, args.weight])
52
- result = compute_palma_ratio(cols[args.value], cols[args.weight])
53
- print(f"palma: {result:.4f}")
54
- return 0
71
+ return _print_equity(palma_result(cols[args.value], cols[args.weight]), "palma", args.json)
55
72
 
56
73
 
57
74
  def _cmd_ci(args: argparse.Namespace) -> int:
58
75
  cols = _read_columns(args.csv, [args.value, args.rank, args.weight])
59
- result = compute_concentration_index(cols[args.value], cols[args.rank], cols[args.weight])
60
- print(f"concentration_index: {result:.4f}")
61
- return 0
76
+ rank_direction = _RANK_DIRECTION_CLI[args.rank_direction]
77
+ return _print_equity(
78
+ concentration_index_result(
79
+ cols[args.value],
80
+ cols[args.rank],
81
+ cols[args.weight],
82
+ rank_direction=rank_direction,
83
+ ),
84
+ "concentration_index",
85
+ args.json,
86
+ )
62
87
 
63
88
 
64
89
  def _cmd_score(args: argparse.Namespace) -> int:
@@ -178,6 +203,7 @@ def build_parser() -> argparse.ArgumentParser:
178
203
  gini.add_argument("csv", help="Path to input CSV file")
179
204
  gini.add_argument("--value", required=True, help="Service-level column")
180
205
  gini.add_argument("--weight", required=True, help="Population-weight column")
206
+ gini.add_argument("--json", action="store_true", help="Output machine-readable JSON")
181
207
  gini.set_defaults(func=_cmd_gini)
182
208
 
183
209
  # Palma
@@ -185,6 +211,7 @@ def build_parser() -> argparse.ArgumentParser:
185
211
  palma.add_argument("csv", help="Path to input CSV file")
186
212
  palma.add_argument("--value", required=True, help="Service-level column")
187
213
  palma.add_argument("--weight", required=True, help="Population-weight column")
214
+ palma.add_argument("--json", action="store_true", help="Output machine-readable JSON")
188
215
  palma.set_defaults(func=_cmd_palma)
189
216
 
190
217
  # Concentration Index
@@ -193,6 +220,13 @@ def build_parser() -> argparse.ArgumentParser:
193
220
  ci.add_argument("--value", required=True, help="Service-level column")
194
221
  ci.add_argument("--rank", required=True, help="Ranking column (e.g. deprivation rank)")
195
222
  ci.add_argument("--weight", required=True, help="Population-weight column")
223
+ ci.add_argument(
224
+ "--rank-direction",
225
+ required=True,
226
+ choices=sorted(_RANK_DIRECTION_CLI),
227
+ help="Which end of --rank is advantaged (required)",
228
+ )
229
+ ci.add_argument("--json", action="store_true", help="Output machine-readable JSON")
196
230
  ci.set_defaults(func=_cmd_ci)
197
231
 
198
232
  # Score
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: moveq-cli
3
- Version: 0.1.2
3
+ Version: 0.2.0
4
4
  Summary: Command-line tool for moveq: Gini, Palma, Concentration Index, composite scores, and catalogue validation from CSV or JSON.
5
5
  Author-email: Soura <martisoura@gmail.com>
6
6
  License-Expression: BSD-3-Clause
@@ -54,7 +54,7 @@ only the argparse front-end.
54
54
 
55
55
  **`moveq gini`** and **`moveq palma`** read a CSV and treat one column as
56
56
  the service value (trips, departures, coverage) and another as the
57
- population weight. They print a single number:
57
+ population weight. By default they print a single number:
58
58
 
59
59
  - Gini — overall inequality of that service across people (`0` = equal)
60
60
  - Palma — mean service of the best-served 10% of population over the
@@ -62,10 +62,11 @@ population weight. They print a single number:
62
62
  equal, including all-zero service; `inf` if the bottom 40% have no
63
63
  service and the top 10% do not)
64
64
 
65
- **`moveq ci`** is the Wagstaff Concentration Index. It needs a third
66
- column: socioeconomic rank (`1` = most deprived in this library).
67
- Positive values mean service is concentrated in less-deprived areas;
68
- negative values mean the opposite.
65
+ **`moveq ci`** is the Wagstaff Concentration Index. It needs a rank
66
+ column and **`--rank-direction`** (`higher-is-advantaged` or
67
+ `higher-is-disadvantaged`). Positive values are advantage-concentrated;
68
+ negative values are disadvantage-concentrated. A zero or cancelled mean
69
+ exits with code `3`.
69
70
 
70
71
  **`moveq score`** builds a 0–100 weighted composite. Terms live in
71
72
  `[0, 1]`; JSON `null` means “this term was not collected.” Missing terms
@@ -77,6 +78,10 @@ in a JSON file (`--json`).
77
78
  the same / replace / omit rules: every base section must have an
78
79
  explicit decision, replacements need a title, omissions need a note.
79
80
 
81
+ `moveq gini`, `moveq palma`, and `moveq ci` accept `--json` to print the
82
+ full `EquityResult` (`metric`, `value`, `method`, `n_areas`, warnings).
83
+ The default line is unchanged.
84
+
80
85
  Column names are whatever your CSV actually uses; you point at them with
81
86
  `--value`, `--weight`, and `--rank`.
82
87
 
@@ -95,7 +100,7 @@ Inequality from a CSV (headers `trips`, `population`, `deprivation_rank`):
95
100
  ```bash
96
101
  moveq gini data.csv --value trips --weight population
97
102
  moveq palma data.csv --value trips --weight population
98
- moveq ci data.csv --value trips --rank deprivation_rank --weight population
103
+ moveq ci data.csv --value trips --rank deprivation_rank --weight population --rank-direction higher-is-advantaged
99
104
  ```
100
105
 
101
106
  Composite score, inline or from a file:
@@ -0,0 +1,405 @@
1
+ import csv
2
+ import io
3
+ import json
4
+ from contextlib import redirect_stderr, redirect_stdout
5
+
6
+ import pytest
7
+
8
+ from moveq_cli.main import main
9
+
10
+
11
+ def _write_csv(path, rows, fieldnames):
12
+ with open(path, "w", newline="", encoding="utf-8") as fh:
13
+ writer = csv.DictWriter(fh, fieldnames=fieldnames)
14
+ writer.writeheader()
15
+ writer.writerows(rows)
16
+
17
+
18
+ def test_gini_command(tmp_path):
19
+ csv_path = tmp_path / "data.csv"
20
+ _write_csv(
21
+ csv_path,
22
+ [{"trips": "10", "population": "100"}, {"trips": "10", "population": "100"}],
23
+ ["trips", "population"],
24
+ )
25
+ buf = io.StringIO()
26
+ with redirect_stdout(buf):
27
+ code = main(["gini", str(csv_path), "--value", "trips", "--weight", "population"])
28
+ assert code == 0
29
+ assert buf.getvalue() == "gini: 0.0000\n"
30
+
31
+
32
+ def test_gini_json_contains_metric_and_value(tmp_path):
33
+ csv_path = tmp_path / "data.csv"
34
+ _write_csv(
35
+ csv_path,
36
+ [{"trips": "10", "population": "100"}, {"trips": "10", "population": "100"}],
37
+ ["trips", "population"],
38
+ )
39
+ buf = io.StringIO()
40
+ with redirect_stdout(buf):
41
+ code = main(
42
+ ["gini", str(csv_path), "--value", "trips", "--weight", "population", "--json"]
43
+ )
44
+ assert code == 0
45
+ data = json.loads(buf.getvalue())
46
+ assert data["metric"] == "gini"
47
+ assert data["value"] == pytest.approx(0.0)
48
+
49
+
50
+ def test_palma_command(tmp_path):
51
+ csv_path = tmp_path / "data.csv"
52
+ _write_csv(
53
+ csv_path,
54
+ [{"trips": str(i * 10), "population": "100"} for i in range(1, 11)],
55
+ ["trips", "population"],
56
+ )
57
+ buf = io.StringIO()
58
+ with redirect_stdout(buf):
59
+ code = main(["palma", str(csv_path), "--value", "trips", "--weight", "population"])
60
+ assert code == 0
61
+ assert "palma:" in buf.getvalue()
62
+
63
+
64
+ def test_ci_command(tmp_path):
65
+ csv_path = tmp_path / "data.csv"
66
+ _write_csv(
67
+ csv_path,
68
+ [
69
+ {"trips": "10", "dep_rank": "1", "population": "100"},
70
+ {"trips": "20", "dep_rank": "2", "population": "100"},
71
+ ],
72
+ ["trips", "dep_rank", "population"],
73
+ )
74
+ buf = io.StringIO()
75
+ with redirect_stdout(buf):
76
+ code = main(
77
+ [
78
+ "ci",
79
+ str(csv_path),
80
+ "--value",
81
+ "trips",
82
+ "--rank",
83
+ "dep_rank",
84
+ "--weight",
85
+ "population",
86
+ "--rank-direction",
87
+ "higher-is-advantaged",
88
+ ]
89
+ )
90
+ assert code == 0
91
+ out = buf.getvalue()
92
+ assert "concentration_index:" in out
93
+ assert "{" not in out
94
+
95
+
96
+ def test_palma_json_contains_metric_and_value(tmp_path):
97
+ csv_path = tmp_path / "data.csv"
98
+ _write_csv(
99
+ csv_path,
100
+ [{"trips": str(i * 10), "population": "100"} for i in range(1, 11)],
101
+ ["trips", "population"],
102
+ )
103
+ buf = io.StringIO()
104
+ with redirect_stdout(buf):
105
+ code = main(
106
+ ["palma", str(csv_path), "--value", "trips", "--weight", "population", "--json"]
107
+ )
108
+ assert code == 0
109
+ data = json.loads(buf.getvalue())
110
+ assert data["metric"] == "palma"
111
+ assert isinstance(data["value"], float)
112
+
113
+
114
+ def test_ci_json_contains_metric_and_value(tmp_path):
115
+ csv_path = tmp_path / "data.csv"
116
+ _write_csv(
117
+ csv_path,
118
+ [
119
+ {"trips": "10", "dep_rank": "1", "population": "100"},
120
+ {"trips": "20", "dep_rank": "2", "population": "100"},
121
+ ],
122
+ ["trips", "dep_rank", "population"],
123
+ )
124
+ buf = io.StringIO()
125
+ with redirect_stdout(buf):
126
+ code = main(
127
+ [
128
+ "ci",
129
+ str(csv_path),
130
+ "--value",
131
+ "trips",
132
+ "--rank",
133
+ "dep_rank",
134
+ "--weight",
135
+ "population",
136
+ "--rank-direction",
137
+ "higher-is-advantaged",
138
+ "--json",
139
+ ]
140
+ )
141
+ assert code == 0
142
+ data = json.loads(buf.getvalue())
143
+ assert data["metric"] == "ci"
144
+ assert isinstance(data["value"], float)
145
+
146
+
147
+ def test_missing_column_returns_error(tmp_path):
148
+ csv_path = tmp_path / "data.csv"
149
+ _write_csv(csv_path, [{"trips": "10"}], ["trips"])
150
+ code = main(["gini", str(csv_path), "--value", "trips", "--weight", "population"])
151
+ assert code == 1
152
+
153
+
154
+ def test_score_inline_args():
155
+ buf = io.StringIO()
156
+ with redirect_stdout(buf):
157
+ code = main([
158
+ "score",
159
+ "--terms",
160
+ '{"coverage": 0.8, "evening": 0.5, "frequency": null}',
161
+ "--weights",
162
+ '{"coverage": 0.5, "evening": 0.3, "frequency": 0.2}',
163
+ ])
164
+ assert code == 0
165
+ out = buf.getvalue()
166
+ assert "score: 68.8" in out
167
+ assert "renormalised" in out
168
+
169
+
170
+ def test_score_json_config(tmp_path):
171
+ config_path = tmp_path / "score_config.json"
172
+ config_path.write_text(
173
+ json.dumps({
174
+ "terms": {"coverage": 0.8, "evening": 0.5},
175
+ "weights": {"coverage": 0.6, "evening": 0.4},
176
+ }),
177
+ encoding="utf-8",
178
+ )
179
+ buf = io.StringIO()
180
+ with redirect_stdout(buf):
181
+ code = main(["score", str(config_path), "--json"])
182
+ assert code == 0
183
+ data = json.loads(buf.getvalue())
184
+ assert data["score"] == 68.0
185
+
186
+
187
+ def test_score_missing_arguments():
188
+ code = main(["score"])
189
+ assert code == 1
190
+
191
+
192
+ def test_catalogue_validate_valid(tmp_path):
193
+ cat_path = tmp_path / "catalogue.json"
194
+ cat_path.write_text(
195
+ json.dumps({
196
+ "country": "france",
197
+ "base_sections": ["s1", "s2", "s3"],
198
+ "mappings": [
199
+ {"section_id": "s1", "action": "same"},
200
+ {"section_id": "s2", "action": "replace", "replacement_title": "Local S2"},
201
+ {"section_id": "s3", "action": "omit", "note": "No census data available"},
202
+ ],
203
+ }),
204
+ encoding="utf-8",
205
+ )
206
+ buf = io.StringIO()
207
+ with redirect_stdout(buf):
208
+ code = main(["catalogue", "validate", str(cat_path)])
209
+ assert code == 0
210
+ assert "status: valid (fully specified)" in buf.getvalue()
211
+
212
+
213
+ def test_catalogue_validate_invalid_unmapped(tmp_path):
214
+ cat_path = tmp_path / "catalogue_unmapped.json"
215
+ cat_path.write_text(
216
+ json.dumps({
217
+ "country": "france",
218
+ "base_sections": ["s1", "s2"],
219
+ "mappings": [
220
+ {"section_id": "s1", "action": "same"},
221
+ ],
222
+ }),
223
+ encoding="utf-8",
224
+ )
225
+ buf = io.StringIO()
226
+ with redirect_stdout(buf):
227
+ code = main(["catalogue", "validate", str(cat_path), "--json"])
228
+ assert code == 1
229
+ data = json.loads(buf.getvalue())
230
+ assert data["valid"] is False
231
+ assert len(data["issues"]) == 1
232
+
233
+
234
+ def test_ci_missing_rank_direction_exits_nonzero(tmp_path):
235
+ csv_path = tmp_path / "data.csv"
236
+ _write_csv(
237
+ csv_path,
238
+ [
239
+ {"trips": "10", "dep_rank": "1", "population": "100"},
240
+ {"trips": "20", "dep_rank": "2", "population": "100"},
241
+ ],
242
+ ["trips", "dep_rank", "population"],
243
+ )
244
+ with pytest.raises(SystemExit) as excinfo:
245
+ main(
246
+ [
247
+ "ci",
248
+ str(csv_path),
249
+ "--value",
250
+ "trips",
251
+ "--rank",
252
+ "dep_rank",
253
+ "--weight",
254
+ "population",
255
+ ]
256
+ )
257
+ assert excinfo.value.code != 0
258
+
259
+
260
+ def test_ci_invalid_rank_direction_exits_2(tmp_path):
261
+ csv_path = tmp_path / "data.csv"
262
+ _write_csv(
263
+ csv_path,
264
+ [
265
+ {"trips": "10", "dep_rank": "1", "population": "100"},
266
+ {"trips": "20", "dep_rank": "2", "population": "100"},
267
+ ],
268
+ ["trips", "dep_rank", "population"],
269
+ )
270
+ with pytest.raises(SystemExit) as excinfo:
271
+ main(
272
+ [
273
+ "ci",
274
+ str(csv_path),
275
+ "--value",
276
+ "trips",
277
+ "--rank",
278
+ "dep_rank",
279
+ "--weight",
280
+ "population",
281
+ "--rank-direction",
282
+ "sideways",
283
+ ]
284
+ )
285
+ assert excinfo.value.code == 2
286
+
287
+
288
+ def test_ci_reversed_rank_with_flipped_direction_matches(tmp_path):
289
+ csv_a = tmp_path / "a.csv"
290
+ csv_b = tmp_path / "b.csv"
291
+ _write_csv(
292
+ csv_a,
293
+ [
294
+ {"trips": "10", "dep_rank": "1", "population": "100"},
295
+ {"trips": "20", "dep_rank": "2", "population": "100"},
296
+ {"trips": "30", "dep_rank": "3", "population": "100"},
297
+ {"trips": "40", "dep_rank": "4", "population": "100"},
298
+ ],
299
+ ["trips", "dep_rank", "population"],
300
+ )
301
+ _write_csv(
302
+ csv_b,
303
+ [
304
+ {"trips": "10", "dep_rank": "4", "population": "100"},
305
+ {"trips": "20", "dep_rank": "3", "population": "100"},
306
+ {"trips": "30", "dep_rank": "2", "population": "100"},
307
+ {"trips": "40", "dep_rank": "1", "population": "100"},
308
+ ],
309
+ ["trips", "dep_rank", "population"],
310
+ )
311
+ buf_a = io.StringIO()
312
+ buf_b = io.StringIO()
313
+ with redirect_stdout(buf_a):
314
+ code_a = main(
315
+ [
316
+ "ci",
317
+ str(csv_a),
318
+ "--value",
319
+ "trips",
320
+ "--rank",
321
+ "dep_rank",
322
+ "--weight",
323
+ "population",
324
+ "--rank-direction",
325
+ "higher-is-advantaged",
326
+ ]
327
+ )
328
+ with redirect_stdout(buf_b):
329
+ code_b = main(
330
+ [
331
+ "ci",
332
+ str(csv_b),
333
+ "--value",
334
+ "trips",
335
+ "--rank",
336
+ "dep_rank",
337
+ "--weight",
338
+ "population",
339
+ "--rank-direction",
340
+ "higher-is-disadvantaged",
341
+ ]
342
+ )
343
+ assert code_a == 0
344
+ assert code_b == 0
345
+ assert buf_a.getvalue() == buf_b.getvalue()
346
+
347
+
348
+ def test_ci_undefined_exits_3(tmp_path):
349
+ csv_path = tmp_path / "data.csv"
350
+ _write_csv(
351
+ csv_path,
352
+ [
353
+ {"trips": "0", "dep_rank": "1", "population": "100"},
354
+ {"trips": "0", "dep_rank": "2", "population": "100"},
355
+ ],
356
+ ["trips", "dep_rank", "population"],
357
+ )
358
+ err = io.StringIO()
359
+ with redirect_stderr(err):
360
+ code = main(
361
+ [
362
+ "ci",
363
+ str(csv_path),
364
+ "--value",
365
+ "trips",
366
+ "--rank",
367
+ "dep_rank",
368
+ "--weight",
369
+ "population",
370
+ "--rank-direction",
371
+ "higher-is-advantaged",
372
+ ]
373
+ )
374
+ assert code == 3
375
+ assert "undefined" in err.getvalue()
376
+
377
+ buf = io.StringIO()
378
+ with redirect_stdout(buf):
379
+ json_code = main(
380
+ [
381
+ "ci",
382
+ str(csv_path),
383
+ "--value",
384
+ "trips",
385
+ "--rank",
386
+ "dep_rank",
387
+ "--weight",
388
+ "population",
389
+ "--rank-direction",
390
+ "higher-is-advantaged",
391
+ "--json",
392
+ ]
393
+ )
394
+ assert json_code == 3
395
+ data = json.loads(buf.getvalue())
396
+ assert data["value"] is None
397
+ assert data["status"] == "undefined"
398
+
399
+
400
+ def test_help_command():
401
+ try:
402
+ main(["--help"])
403
+ except SystemExit as exc:
404
+ assert exc.code == 0
405
+
@@ -1,153 +0,0 @@
1
- import csv
2
- import io
3
- import json
4
- from contextlib import redirect_stdout
5
-
6
- from moveq_cli.main import main
7
-
8
-
9
- def _write_csv(path, rows, fieldnames):
10
- with open(path, "w", newline="", encoding="utf-8") as fh:
11
- writer = csv.DictWriter(fh, fieldnames=fieldnames)
12
- writer.writeheader()
13
- writer.writerows(rows)
14
-
15
-
16
- def test_gini_command(tmp_path):
17
- csv_path = tmp_path / "data.csv"
18
- _write_csv(
19
- csv_path,
20
- [{"trips": "10", "population": "100"}, {"trips": "10", "population": "100"}],
21
- ["trips", "population"],
22
- )
23
- buf = io.StringIO()
24
- with redirect_stdout(buf):
25
- code = main(["gini", str(csv_path), "--value", "trips", "--weight", "population"])
26
- assert code == 0
27
- assert "gini: 0.0000" in buf.getvalue()
28
-
29
-
30
- def test_palma_command(tmp_path):
31
- csv_path = tmp_path / "data.csv"
32
- _write_csv(
33
- csv_path,
34
- [{"trips": str(i * 10), "population": "100"} for i in range(1, 11)],
35
- ["trips", "population"],
36
- )
37
- buf = io.StringIO()
38
- with redirect_stdout(buf):
39
- code = main(["palma", str(csv_path), "--value", "trips", "--weight", "population"])
40
- assert code == 0
41
- assert "palma:" in buf.getvalue()
42
-
43
-
44
- def test_ci_command(tmp_path):
45
- csv_path = tmp_path / "data.csv"
46
- _write_csv(
47
- csv_path,
48
- [
49
- {"trips": "10", "dep_rank": "1", "population": "100"},
50
- {"trips": "20", "dep_rank": "2", "population": "100"},
51
- ],
52
- ["trips", "dep_rank", "population"],
53
- )
54
- buf = io.StringIO()
55
- with redirect_stdout(buf):
56
- code = main(["ci", str(csv_path), "--value", "trips", "--rank", "dep_rank", "--weight", "population"])
57
- assert code == 0
58
- assert "concentration_index:" in buf.getvalue()
59
-
60
-
61
- def test_missing_column_returns_error(tmp_path):
62
- csv_path = tmp_path / "data.csv"
63
- _write_csv(csv_path, [{"trips": "10"}], ["trips"])
64
- code = main(["gini", str(csv_path), "--value", "trips", "--weight", "population"])
65
- assert code == 1
66
-
67
-
68
- def test_score_inline_args():
69
- buf = io.StringIO()
70
- with redirect_stdout(buf):
71
- code = main([
72
- "score",
73
- "--terms",
74
- '{"coverage": 0.8, "evening": 0.5, "frequency": null}',
75
- "--weights",
76
- '{"coverage": 0.5, "evening": 0.3, "frequency": 0.2}',
77
- ])
78
- assert code == 0
79
- out = buf.getvalue()
80
- assert "score: 68.8" in out
81
- assert "renormalised" in out
82
-
83
-
84
- def test_score_json_config(tmp_path):
85
- config_path = tmp_path / "score_config.json"
86
- config_path.write_text(
87
- json.dumps({
88
- "terms": {"coverage": 0.8, "evening": 0.5},
89
- "weights": {"coverage": 0.6, "evening": 0.4},
90
- }),
91
- encoding="utf-8",
92
- )
93
- buf = io.StringIO()
94
- with redirect_stdout(buf):
95
- code = main(["score", str(config_path), "--json"])
96
- assert code == 0
97
- data = json.loads(buf.getvalue())
98
- assert data["score"] == 68.0
99
-
100
-
101
- def test_score_missing_arguments():
102
- code = main(["score"])
103
- assert code == 1
104
-
105
-
106
- def test_catalogue_validate_valid(tmp_path):
107
- cat_path = tmp_path / "catalogue.json"
108
- cat_path.write_text(
109
- json.dumps({
110
- "country": "france",
111
- "base_sections": ["s1", "s2", "s3"],
112
- "mappings": [
113
- {"section_id": "s1", "action": "same"},
114
- {"section_id": "s2", "action": "replace", "replacement_title": "Local S2"},
115
- {"section_id": "s3", "action": "omit", "note": "No census data available"},
116
- ],
117
- }),
118
- encoding="utf-8",
119
- )
120
- buf = io.StringIO()
121
- with redirect_stdout(buf):
122
- code = main(["catalogue", "validate", str(cat_path)])
123
- assert code == 0
124
- assert "status: valid (fully specified)" in buf.getvalue()
125
-
126
-
127
- def test_catalogue_validate_invalid_unmapped(tmp_path):
128
- cat_path = tmp_path / "catalogue_unmapped.json"
129
- cat_path.write_text(
130
- json.dumps({
131
- "country": "france",
132
- "base_sections": ["s1", "s2"],
133
- "mappings": [
134
- {"section_id": "s1", "action": "same"},
135
- ],
136
- }),
137
- encoding="utf-8",
138
- )
139
- buf = io.StringIO()
140
- with redirect_stdout(buf):
141
- code = main(["catalogue", "validate", str(cat_path), "--json"])
142
- assert code == 1
143
- data = json.loads(buf.getvalue())
144
- assert data["valid"] is False
145
- assert len(data["issues"]) == 1
146
-
147
-
148
- def test_help_command():
149
- try:
150
- main(["--help"])
151
- except SystemExit as exc:
152
- assert exc.code == 0
153
-
File without changes
File without changes