ausecon 0.0.1 → 0.2.0

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 (3) hide show
  1. package/README.md +53 -8
  2. package/dist/main.mjs +286 -5
  3. package/package.json +5 -2
package/README.md CHANGED
@@ -7,14 +7,6 @@ It ports the `readabs` and `readrba` R packages and uses
7
7
 
8
8
  ---
9
9
 
10
- ## Status
11
-
12
- The tool is in development. No release exists.
13
-
14
- The first release reads ABS time series by catalogue number and table number,
15
- or by series ID. The tool now runs `--help`, `--version`, and shell
16
- completions only.
17
-
18
10
  ## Install
19
11
 
20
12
  The tool needs Node 22.18 or later.
@@ -24,9 +16,62 @@ npm install --global ausecon
24
16
  ausecon --help
25
17
  ```
26
18
 
19
+ ## Read ABS Time Series
20
+
21
+ `ausecon abs get` reads ABS time series from the latest release and writes
22
+ the observations to stdout as CSV. Messages and errors go to stderr, so you
23
+ can redirect the data to a file.
24
+
25
+ Save every table of the Wage Price Index:
26
+
27
+ ```shell
28
+ ausecon abs get --catalogue 6345.0 > wages.csv
29
+ ```
30
+
31
+ Read only tables 1 and 12 of the Labour Force release. Large catalogue
32
+ numbers such as 6202.0 have many tables, so pass table numbers:
33
+
34
+ ```shell
35
+ ausecon abs get --catalogue 6202.0 --table 1 --table 12
36
+ ```
37
+
38
+ Read a watch list of series IDs from any catalogue numbers:
39
+
40
+ ```shell
41
+ ausecon abs get --series A2603039T --series A2454446F
42
+ ```
43
+
44
+ CSV has one row per observation, with the 12 `readabs` columns. JSON is a
45
+ list of series, each with its metadata, its table, and its observations as
46
+ `date` and `value` pairs. `--no-metadata` writes only series ID, date, and
47
+ value as CSV, and only the series ID and observations of each series as JSON:
48
+
49
+ ```shell
50
+ ausecon abs get --catalogue 6345.0 --format json --no-metadata
51
+ ```
52
+
53
+ Pass exactly one of `--catalogue` or `--series`. `--table` needs
54
+ `--catalogue`. Run `ausecon abs get --help` for every flag.
55
+
56
+ ## Exit Codes
57
+
58
+ | Code | Meaning |
59
+ | ---- | ---------------------------------------------------------------------------- |
60
+ | 0 | The read succeeded. |
61
+ | 1 | A fault to report, such as a Time Series Directory error or a layout change. |
62
+ | 2 | A usage error, such as a missing flag or an invalid value. |
63
+ | 3 | No such catalogue number, table number, or series ID. |
64
+ | 4 | The ABS gave no response, or answered with a status outside 2xx. |
65
+
66
+ ## Shell Completions
67
+
68
+ `ausecon --completions <shell>` prints a completion script for `bash`, `zsh`,
69
+ `fish`, `sh`, `powershell`, or `pwsh`.
70
+
27
71
  ## Links
28
72
 
29
73
  - [Source and glossary](https://github.com/jackemcpherson/ausecon)
74
+ - [Changelog](https://github.com/jackemcpherson/ausecon/blob/main/CHANGELOG.md)
30
75
  - [`readabs` R package](https://github.com/MattCowgill/readabs)
31
76
  - [`readrba` R package](https://github.com/MattCowgill/readrba)
32
77
 
package/dist/main.mjs CHANGED
@@ -1,12 +1,293 @@
1
1
  #!/usr/bin/env node
2
2
  import { NodeRuntime, NodeServices } from "@effect/platform-node";
3
- import { Effect } from "effect";
4
- import { Command } from "effect/cli";
3
+ import { Cause, Config, Console, Context, Effect, Exit, Layer, Match, Option, Runtime, Schema, Stdio } from "effect";
4
+ import { FetchHttpClient } from "effect/http";
5
+ import { AbsRequest, readAbs } from "@ausecon/core";
6
+ import { CliError, CliOutput, Command, Flag } from "effect/cli";
5
7
  // package.json
6
- var version = "0.0.1";
8
+ var version = "0.2.0";
9
+ // src/reader.ts
10
+ /**
11
+ * Reads ABS time series for the tool.
12
+ *
13
+ * Commands read through this service, so tests can hand in Series or a chosen
14
+ * failure without an HTTP fixture. The entry point provides `AbsReader.layer`.
15
+ */
16
+ var AbsReader = class AbsReader extends Context.Service()("ausecon/AbsReader") {
17
+ /** The live reader: `readAbs` over the `HttpClient` in context. */
18
+ static layer = Layer.effect(AbsReader, Effect.map(Effect.context(), (context) => ({ read: (request) => readAbs(request).pipe(Effect.provideContext(context)) })));
19
+ };
20
+ // src/output.ts
21
+ /** The output formats, in the order help lists them. */
22
+ const OUTPUT_FORMATS = ["csv", "json"];
23
+ /**
24
+ * The CSV columns: the 12 `readabs` columns in camelCase, in the v0.1.0
25
+ * order. The tool owns this list, so a change to the library cannot alter it.
26
+ */
27
+ const CSV_COLUMNS = [
28
+ "tableId",
29
+ "sheet",
30
+ "tableTitle",
31
+ "date",
32
+ "seriesDescription",
33
+ "value",
34
+ "seriesType",
35
+ "dataType",
36
+ "collectionMonth",
37
+ "frequency",
38
+ "seriesId",
39
+ "unit"
40
+ ];
41
+ /** The CSV columns with `--no-metadata`. */
42
+ const CSV_COLUMNS_WITHOUT_METADATA = [
43
+ "seriesId",
44
+ "date",
45
+ "value"
46
+ ];
47
+ /** Flatten Series into one row per observation. An absent table title is empty. */
48
+ const csvRows = (series) => series.flatMap((one) => one.observations.map(({ date, value }) => ({
49
+ tableId: one.table.tableId,
50
+ sheet: one.sheet,
51
+ tableTitle: one.table.tableTitle ?? "",
52
+ date,
53
+ seriesDescription: one.seriesDescription,
54
+ value,
55
+ seriesType: one.seriesType,
56
+ dataType: one.dataType,
57
+ collectionMonth: one.collectionMonth,
58
+ frequency: one.frequency,
59
+ seriesId: one.seriesId,
60
+ unit: one.unit
61
+ })));
62
+ /** Quote a CSV field when it holds a comma, a quote, or a line break (RFC 4180). */
63
+ const csvField = (value) => {
64
+ const text = `${value}`;
65
+ return /[",\r\n]/u.test(text) ? `"${text.replaceAll("\"", "\"\"")}"` : text;
66
+ };
67
+ const toCsv = (columns, rows) => [columns.join(","), ...rows.map((row) => columns.map((column) => csvField(row[column])).join(","))].join("\n");
68
+ /** A Series without its metadata, as `--no-metadata` writes it in JSON. */
69
+ const withoutMetadata = ({ seriesId, observations }) => ({
70
+ seriesId,
71
+ observations
72
+ });
73
+ /**
74
+ * Render Series for stdout.
75
+ *
76
+ * CSV has a header row and one line per observation, with `\n` line endings on
77
+ * every platform. JSON is the Series list, indented only for a terminal.
78
+ * Without metadata, CSV holds series ID, date, and value, and each JSON Series
79
+ * holds only its series ID and observations.
80
+ *
81
+ * @param series - The Series to write.
82
+ * @param options - The output format, whether to write metadata, and whether
83
+ * stdout is a terminal.
84
+ * @returns The text to write to stdout.
85
+ */
86
+ const renderSeries = (series, options) => options.format === "csv" ? toCsv(options.hasMetadata ? CSV_COLUMNS : CSV_COLUMNS_WITHOUT_METADATA, csvRows(series)) : JSON.stringify(options.hasMetadata ? series : series.map(withoutMetadata), null, options.isTerminal ? 2 : void 0);
87
+ // src/commands/abs-get.ts
88
+ const flags = {
89
+ catalogue: Flag.String("catalogue").pipe(Flag.withDescription("Catalogue number to read, such as 6345.0."), Flag.optional),
90
+ table: Flag.String("table").pipe(Flag.withDescription("Table number to read, such as 1 or 7a. Repeat for more tables."), Flag.atLeast(0)),
91
+ series: Flag.String("series").pipe(Flag.withDescription("Series ID to read, such as A2603039T. Repeat for more series."), Flag.atLeast(0)),
92
+ format: Flag.Literals("format", OUTPUT_FORMATS).pipe(Flag.withDescription("Output format: csv or json."), Flag.withDefault("csv")),
93
+ hasMetadata: Flag.Boolean("no-metadata").pipe(Flag.withDescription("Leave out the metadata: write series ID, date, and value as CSV, or series ID and observations as JSON."), Flag.withoutNegation, Flag.withDefault(false), Flag.map((hasNoMetadata) => !hasNoMetadata))
94
+ };
95
+ const usageError = (message) => Effect.fail(new CliError.ValidationError({ message }));
96
+ /**
97
+ * Build the read request from the flags, or fail with a usage error.
98
+ *
99
+ * `--catalogue` and `--series` exclude each other, and one of them is needed.
100
+ * `--table` needs `--catalogue`.
101
+ */
102
+ const requestOf = (input) => Option.match(input.catalogue, {
103
+ onNone: () => input.table.length > 0 ? usageError("--table needs --catalogue <catalogue number>.") : input.series.length === 0 ? usageError("Pass --catalogue <catalogue number> or --series <series ID>.") : Effect.succeed(AbsRequest.Series({ seriesIds: input.series })),
104
+ onSome: (catalogueNumber) => input.series.length > 0 ? usageError("Pass --catalogue or --series, not both.") : Effect.succeed(AbsRequest.Catalogue({
105
+ catalogueNumber,
106
+ tableNumbers: input.table
107
+ }))
108
+ });
109
+ const get = Effect.fn("absGet")(function* (input) {
110
+ const request = yield* requestOf(input);
111
+ const isTerminal = yield* (yield* Stdio.Stdio).stdoutIsTerminal;
112
+ const series = yield* (yield* AbsReader).read(request);
113
+ yield* Console.log(renderSeries(series, {
114
+ format: input.format,
115
+ hasMetadata: input.hasMetadata,
116
+ isTerminal
117
+ }));
118
+ });
119
+ /** The `ausecon abs get` command: read ABS time series to stdout. */
120
+ const absGet = Command.make("get", flags, get).pipe(Command.validate((input) => Effect.asVoid(requestOf(input))), Command.withDescription("Read ABS time series from the latest release and write the observations to stdout. Large catalogue numbers such as 6202.0 have many pages and tables, so pass table numbers or series IDs."), Command.withExamples([
121
+ {
122
+ command: "ausecon abs get --catalogue 6345.0 > wages.csv",
123
+ description: "Save every table of the Wage Price Index as CSV"
124
+ },
125
+ {
126
+ command: "ausecon abs get --catalogue 6202.0 --table 1 --table 12",
127
+ description: "Read only tables 1 and 12 of the Labour Force release"
128
+ },
129
+ {
130
+ command: "ausecon abs get --series A2603039T --series A2454446F",
131
+ description: "Read a watch list of series IDs from any catalogue numbers"
132
+ },
133
+ {
134
+ command: "ausecon abs get --catalogue 6345.0 --format json --no-metadata",
135
+ description: "Print each series ID and its observations as JSON, without metadata"
136
+ }
137
+ ]));
138
+ // src/commands/abs.ts
139
+ /** The `ausecon abs` noun: Australian Bureau of Statistics data. */
140
+ const abs = Command.make("abs").pipe(Command.withDescription("Read data from the Australian Bureau of Statistics (ABS)."), Command.withSubcommands([absGet]));
7
141
  // src/commands/ausecon.ts
8
142
  /** The root `ausecon` command. Subcommands attach here. */
9
- const ausecon = Command.make("ausecon").pipe(Command.withDescription("Read Australian economic data from the ABS and RBA."));
143
+ const ausecon = Command.make("ausecon").pipe(Command.withDescription("Read Australian economic data from the ABS and RBA."), Command.withSubcommands([abs]));
144
+ // src/failure.ts
145
+ /** Exit codes of the tool. */
146
+ const EXIT_CODE = {
147
+ success: 0,
148
+ failure: 1,
149
+ usage: 2,
150
+ notFound: 3,
151
+ unavailable: 4
152
+ };
153
+ const outcome = (exitCode, message, nextStep) => ({
154
+ exitCode,
155
+ message: Option.some(`ausecon: ${message}\n${nextStep}`)
156
+ });
157
+ const USAGE_HINT = "Run `ausecon abs get --help` for usage and examples.";
158
+ const ISSUES = "https://github.com/jackemcpherson/ausecon/issues";
159
+ /** The table, and the sheet if known, that a layout error names. */
160
+ const layoutSubject = (error) => error.sheet === void 0 ? `Table ${error.tableId}` : `Table ${error.tableId}, sheet ${error.sheet},`;
161
+ const REPORT_IT = `Report this at ${ISSUES} with the command you ran.`;
162
+ /**
163
+ * Describe an ABS request that failed. The library words the detail, status
164
+ * included, so the message only adds the URL. A status means the ABS
165
+ * answered, so the next step is to wait or report moved data. No status
166
+ * points at the network.
167
+ */
168
+ const describeUnavailable = (error) => outcome(EXIT_CODE.unavailable, `Could not read ${error.url}: ${error.detail}`, error.status === void 0 ? "Check your network connection, then try again. The ABS website may be down." : `Try again later. The ABS website may be down, or the data may have moved: if the error stays, report it at ${ISSUES}.`);
169
+ /** Describe a command-line usage error, or a help request, for a terminal user. */
170
+ const describeCliError = (formatter) => Match.type().pipe(Match.tag("ShowHelp", (error) => error.errors.length === 0 ? {
171
+ exitCode: EXIT_CODE.success,
172
+ message: Option.none()
173
+ } : {
174
+ exitCode: EXIT_CODE.usage,
175
+ message: Option.some(formatter.formatErrors(error.errors))
176
+ }), Match.orElse((error) => ({
177
+ exitCode: EXIT_CODE.usage,
178
+ message: Option.some(formatter.formatError(error))
179
+ })));
180
+ /**
181
+ * Describe a failed read for a terminal user. The match is exhaustive, so a new
182
+ * `ReadAbsError` does not compile until it has an exit code and a next step.
183
+ */
184
+ const describeReadError = Match.type().pipe(Match.tag("InvalidRequest", (error) => outcome(EXIT_CODE.usage, error.message, USAGE_HINT)), Match.tag("CatalogueNotFound", (error) => outcome(EXIT_CODE.notFound, `The Time Series Directory has no series for catalogue number ${error.catalogueNumber}.`, "Check the catalogue number on the ABS website, such as 6345.0 for the Wage Price Index.")), Match.tag("TableNotFound", (error) => outcome(EXIT_CODE.notFound, `Catalogue number ${error.catalogueNumber} has no table numbered ${error.tableNumbers.join(", ")}.`, "Leave out --table to read every table, then pick table numbers from the tableTitle column.")), Match.tag("SeriesNotFound", (error) => outcome(EXIT_CODE.notFound, `Found no series ID ${error.seriesIds.join(", ")} in the Time Series Directory or the table it names.`, "Check the series ID in the ABS table that holds it.")), Match.tag("AbsUnavailable", describeUnavailable), Match.tag("TimeSeriesDirectoryError", (error) => outcome(EXIT_CODE.failure, `Time Series Directory fault at ${error.url}: ${error.reason}`, REPORT_IT)), Match.tag("TableLayoutError", (error) => outcome(EXIT_CODE.failure, `${layoutSubject(error)} does not have the ABS time series layout: ${error.reason}`, REPORT_IT)), Match.exhaustive);
185
+ /** Describe a failed command for a terminal user. */
186
+ const describeError = (formatter) => (error) => CliError.isCliError(error) ? describeCliError(formatter)(error) : describeReadError(error);
187
+ /**
188
+ * Decide the exit code and stderr message for a finished command.
189
+ *
190
+ * Usage errors exit 2, missing data 3, an unavailable ABS 4, and any other
191
+ * failure 1, including a Time Series Directory or table layout fault.
192
+ *
193
+ * @param exit - The result of running the command.
194
+ * @param formatter - The help formatter, used for `effect/cli` errors.
195
+ * @returns The exit code and the message for stderr.
196
+ */
197
+ const outcomeOf = (exit, formatter) => Exit.match(exit, {
198
+ onSuccess: () => ({
199
+ exitCode: EXIT_CODE.success,
200
+ message: Option.none()
201
+ }),
202
+ onFailure: (cause) => Option.match(Cause.findErrorOption(cause), {
203
+ onSome: describeError(formatter),
204
+ onNone: () => outcome(EXIT_CODE.failure, `failed unexpectedly.\n${Cause.pretty(cause)}`, `Report this at ${ISSUES}.`)
205
+ })
206
+ });
207
+ // src/help.ts
208
+ /**
209
+ * Build the help formatter, which puts examples before everything else.
210
+ *
211
+ * The default `effect/cli` formatter prints examples last. Users learn a
212
+ * command fastest from its examples, so this formatter moves them to the top.
213
+ *
214
+ * @param hasColours - Whether to style the help with ANSI colours.
215
+ * @returns The formatter.
216
+ */
217
+ const helpFormatter = (hasColours) => {
218
+ const base = CliOutput.defaultFormatter({ colors: hasColours });
219
+ const bold = (text) => hasColours ? `\u001B[1m${text}\u001B[0m` : text;
220
+ return {
221
+ ...base,
222
+ formatHelpDoc: (doc) => {
223
+ const examples = doc.examples ?? [];
224
+ const rest = base.formatHelpDoc({
225
+ ...doc,
226
+ examples: []
227
+ });
228
+ return examples.length === 0 ? rest : [
229
+ bold("EXAMPLES"),
230
+ ...examples.flatMap((example) => [...example.description === void 0 ? [] : [` # ${example.description}`], ` ${example.command}`]),
231
+ "",
232
+ rest
233
+ ].join("\n");
234
+ }
235
+ };
236
+ };
237
+ // src/run.ts
238
+ /**
239
+ * Colour is on only when stdout and stderr are terminals and `NO_COLOR` is
240
+ * unset or empty.
241
+ *
242
+ * The default covers an unset `NO_COLOR`, but a config provider can still fail
243
+ * to read its source. Colour then stays off, because help without colour
244
+ * serves every user and the command itself does not need the variable.
245
+ */
246
+ const hasColours = Effect.gen(function* () {
247
+ const stdio = yield* Stdio.Stdio;
248
+ const noColor = yield* Config.String("NO_COLOR").pipe(Config.withDefault(""));
249
+ return (yield* stdio.stdoutIsTerminal) && (yield* stdio.stderrIsTerminal) && noColor === "";
250
+ }).pipe(Effect.catchTag("ConfigError", () => Effect.succeed(false)));
251
+ /** A console that holds `log` output in `held` and passes every other call to `console`. */
252
+ const holding = (console, held) => Object.assign(Object.create(console), { log: (...args) => {
253
+ held.push(args);
254
+ } });
255
+ /**
256
+ * Run `ausecon` with the given arguments and return its exit code.
257
+ *
258
+ * Everything the command writes with `Console.log` is held back until it
259
+ * finishes. On success it goes to stdout. On a failure it goes to stderr,
260
+ * with the error message and a next step, so stdout never holds partial data
261
+ * or help text.
262
+ *
263
+ * @param argv - The arguments after `ausecon`.
264
+ * @param version - The version that `--version` prints.
265
+ * @returns An Effect that yields the exit code.
266
+ */
267
+ const runAusecon = Effect.fn("runAusecon")(function* (argv, version) {
268
+ const formatter = helpFormatter(yield* hasColours);
269
+ const console = yield* Console.Console;
270
+ const held = [];
271
+ const exit = yield* Command.runWith(ausecon, {
272
+ version,
273
+ renderErrors: false
274
+ })(argv).pipe(Effect.provideService(Console.Console, holding(console, held)), Effect.provideService(CliOutput.Formatter, formatter), Effect.exit);
275
+ const outcome = outcomeOf(exit, formatter);
276
+ const write = outcome.exitCode === EXIT_CODE.success ? Console.log : Console.error;
277
+ yield* Effect.forEach(held, (args) => write(...args), { discard: true });
278
+ if (Option.isSome(outcome.message)) yield* Console.error(outcome.message.value);
279
+ return outcome.exitCode;
280
+ });
10
281
  // src/main.ts
11
- Command.run(ausecon, { version }).pipe(Effect.provide(NodeServices.layer), NodeRuntime.runMain);
282
+ /** Ends the process with a non-zero exit code. `runAusecon` has already written the message. */
283
+ var ExitCode = class extends Schema.TaggedError()("ExitCode", { code: Schema.Number }) {
284
+ get [Runtime.errorExitCode]() {
285
+ return this.code;
286
+ }
287
+ };
288
+ Effect.gen(function* () {
289
+ const argv = yield* (yield* Stdio.Stdio).args;
290
+ const code = yield* runAusecon(argv, version);
291
+ if (code !== 0) return yield* new ExitCode({ code });
292
+ }).pipe(Effect.provide([NodeServices.layer, AbsReader.layer.pipe(Layer.provide(FetchHttpClient.layer))]), NodeRuntime.runMain({ disableErrorReporting: true }));
12
293
  export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ausecon",
3
- "version": "0.0.1",
3
+ "version": "0.2.0",
4
4
  "description": "Read Australian economic data from the ABS and RBA in your terminal.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -19,10 +19,13 @@
19
19
  "build": "vp pack"
20
20
  },
21
21
  "dependencies": {
22
- "@ausecon/core": "0.0.1",
22
+ "@ausecon/core": "0.2.0",
23
23
  "@effect/platform-node": "4.0.6",
24
24
  "effect": "4.0.6"
25
25
  },
26
+ "devDependencies": {
27
+ "@ausecon/testkit": "0.0.0"
28
+ },
26
29
  "engines": {
27
30
  "node": ">=22.18.0"
28
31
  }