ausecon 0.0.0 → 0.1.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.
- package/README.md +50 -8
- package/dist/main.mjs +228 -5
- package/package.json +2 -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,59 @@ 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
|
+
Print only the series ID, date, and value as JSON:
|
|
45
|
+
|
|
46
|
+
```shell
|
|
47
|
+
ausecon abs get --catalogue 6345.0 --format json --no-metadata
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Pass exactly one of `--catalogue` or `--series`. `--table` needs
|
|
51
|
+
`--catalogue`. Run `ausecon abs get --help` for every flag.
|
|
52
|
+
|
|
53
|
+
## Exit Codes
|
|
54
|
+
|
|
55
|
+
| Code | Meaning |
|
|
56
|
+
| ---- | ------------------------------------------------------------ |
|
|
57
|
+
| 0 | The read succeeded. |
|
|
58
|
+
| 1 | An unexpected failure, such as a table layout change. |
|
|
59
|
+
| 2 | A usage error, such as a missing flag or an invalid value. |
|
|
60
|
+
| 3 | No such catalogue number, table number, or series ID. |
|
|
61
|
+
| 4 | A network failure, or the ABS answered with an HTTP error. |
|
|
62
|
+
|
|
63
|
+
## Shell Completions
|
|
64
|
+
|
|
65
|
+
`ausecon --completions <shell>` prints a completion script for `bash`, `zsh`,
|
|
66
|
+
`fish`, `sh`, `powershell`, or `pwsh`.
|
|
67
|
+
|
|
27
68
|
## Links
|
|
28
69
|
|
|
29
70
|
- [Source and glossary](https://github.com/jackemcpherson/ausecon)
|
|
71
|
+
- [Changelog](https://github.com/jackemcpherson/ausecon/blob/main/CHANGELOG.md)
|
|
30
72
|
- [`readabs` R package](https://github.com/MattCowgill/readabs)
|
|
31
73
|
- [`readrba` R package](https://github.com/MattCowgill/readrba)
|
|
32
74
|
|
package/dist/main.mjs
CHANGED
|
@@ -1,12 +1,235 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { NodeRuntime, NodeServices } from "@effect/platform-node";
|
|
3
|
-
import { Effect } from "effect";
|
|
4
|
-
import {
|
|
3
|
+
import { Cause, Config, Console, Effect, Exit, Match, Option, Runtime, Schema, Stdio, Struct } from "effect";
|
|
4
|
+
import { FetchHttpClient } from "effect/http";
|
|
5
|
+
import { CliError, CliOutput, Command, Flag } from "effect/cli";
|
|
6
|
+
import { AbsRequest, Observation, ObservationValue, readAbs } from "@ausecon/core";
|
|
5
7
|
// package.json
|
|
6
|
-
var version = "0.
|
|
8
|
+
var version = "0.1.0";
|
|
9
|
+
// src/output.ts
|
|
10
|
+
/** The output formats, in the order help lists them. */
|
|
11
|
+
const OUTPUT_FORMATS = ["csv", "json"];
|
|
12
|
+
/** The fields of an observation with metadata, in spec table order. */
|
|
13
|
+
const METADATA_COLUMNS = Struct.keys(Observation.fields);
|
|
14
|
+
/** The fields of an observation without metadata. */
|
|
15
|
+
const VALUE_COLUMNS = Struct.keys(ObservationValue.fields);
|
|
16
|
+
/** Quote a CSV field when it holds a comma, a quote, or a line break (RFC 4180). */
|
|
17
|
+
const csvField = (value) => {
|
|
18
|
+
const text = `${value}`;
|
|
19
|
+
return /[",\r\n]/u.test(text) ? `"${text.replaceAll("\"", "\"\"")}"` : text;
|
|
20
|
+
};
|
|
21
|
+
const toCsv = (columns, rows) => [columns.join(","), ...rows.map((row) => columns.map((column) => csvField(row[column])).join(","))].join("\n");
|
|
22
|
+
/**
|
|
23
|
+
* Build a renderer of observations for stdout.
|
|
24
|
+
*
|
|
25
|
+
* CSV has a header row and one line per observation, with `\n` line endings on
|
|
26
|
+
* every platform. JSON is one array, indented only for a terminal.
|
|
27
|
+
*
|
|
28
|
+
* @param format - The output format.
|
|
29
|
+
* @param isTerminal - Whether stdout is a terminal.
|
|
30
|
+
* @returns A function of the fields to write, in order, and the observations,
|
|
31
|
+
* that returns the text to write to stdout.
|
|
32
|
+
*/
|
|
33
|
+
const renderObservations = (format, isTerminal) => (columns, rows) => format === "csv" ? toCsv(columns, rows) : JSON.stringify(rows, null, isTerminal ? 2 : void 0);
|
|
34
|
+
// src/commands/abs-get.ts
|
|
35
|
+
const flags = {
|
|
36
|
+
catalogue: Flag.String("catalogue").pipe(Flag.withDescription("Catalogue number to read, such as 6345.0."), Flag.optional),
|
|
37
|
+
table: Flag.String("table").pipe(Flag.withDescription("Table number to read, such as 1 or 7a. Repeat for more tables."), Flag.atLeast(0)),
|
|
38
|
+
series: Flag.String("series").pipe(Flag.withDescription("Series ID to read, such as A2603039T. Repeat for more series."), Flag.atLeast(0)),
|
|
39
|
+
format: Flag.Literals("format", OUTPUT_FORMATS).pipe(Flag.withDescription("Output format: csv or json."), Flag.withDefault("csv")),
|
|
40
|
+
shouldOmitMetadata: Flag.Boolean("no-metadata").pipe(Flag.withDescription("Write only series ID, date, and value."), Flag.withoutNegation, Flag.withDefault(false))
|
|
41
|
+
};
|
|
42
|
+
const usageError = (message) => Effect.fail(new CliError.ValidationError({ message }));
|
|
43
|
+
/**
|
|
44
|
+
* Build the read request from the flags, or fail with a usage error.
|
|
45
|
+
*
|
|
46
|
+
* `--catalogue` and `--series` exclude each other, and one of them is needed.
|
|
47
|
+
* `--table` needs `--catalogue`.
|
|
48
|
+
*/
|
|
49
|
+
const requestOf = (input) => Option.match(input.catalogue, {
|
|
50
|
+
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 })),
|
|
51
|
+
onSome: (catalogueNumber) => input.series.length > 0 ? usageError("Pass --catalogue or --series, not both.") : Effect.succeed(AbsRequest.Catalogue({
|
|
52
|
+
catalogueNumber,
|
|
53
|
+
tableNumbers: input.table
|
|
54
|
+
}))
|
|
55
|
+
});
|
|
56
|
+
const get = Effect.fn("absGet")(function* (input) {
|
|
57
|
+
const request = yield* requestOf(input);
|
|
58
|
+
const isTerminal = yield* (yield* Stdio.Stdio).stdoutIsTerminal;
|
|
59
|
+
const render = renderObservations(input.format, isTerminal);
|
|
60
|
+
const output = input.shouldOmitMetadata ? render(VALUE_COLUMNS, yield* readAbs(request, { hasMetadata: false })) : render(METADATA_COLUMNS, yield* readAbs(request));
|
|
61
|
+
yield* Console.log(output);
|
|
62
|
+
});
|
|
63
|
+
/** The `ausecon abs get` command: read ABS time series to stdout. */
|
|
64
|
+
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([
|
|
65
|
+
{
|
|
66
|
+
command: "ausecon abs get --catalogue 6345.0 > wages.csv",
|
|
67
|
+
description: "Save every table of the Wage Price Index as CSV"
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
command: "ausecon abs get --catalogue 6202.0 --table 1 --table 12",
|
|
71
|
+
description: "Read only tables 1 and 12 of the Labour Force release"
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
command: "ausecon abs get --series A2603039T --series A2454446F",
|
|
75
|
+
description: "Read a watch list of series IDs from any catalogue numbers"
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
command: "ausecon abs get --catalogue 6345.0 --format json --no-metadata",
|
|
79
|
+
description: "Print only series ID, date, and value as JSON"
|
|
80
|
+
}
|
|
81
|
+
]));
|
|
82
|
+
// src/commands/abs.ts
|
|
83
|
+
/** The `ausecon abs` noun: Australian Bureau of Statistics data. */
|
|
84
|
+
const abs = Command.make("abs").pipe(Command.withDescription("Read data from the Australian Bureau of Statistics (ABS)."), Command.withSubcommands([absGet]));
|
|
7
85
|
// src/commands/ausecon.ts
|
|
8
86
|
/** 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."));
|
|
87
|
+
const ausecon = Command.make("ausecon").pipe(Command.withDescription("Read Australian economic data from the ABS and RBA."), Command.withSubcommands([abs]));
|
|
88
|
+
// src/failure.ts
|
|
89
|
+
/** Exit codes of the tool. */
|
|
90
|
+
const EXIT_CODE = {
|
|
91
|
+
success: 0,
|
|
92
|
+
failure: 1,
|
|
93
|
+
usage: 2,
|
|
94
|
+
notFound: 3,
|
|
95
|
+
network: 4
|
|
96
|
+
};
|
|
97
|
+
const outcome = (exitCode, message, nextStep) => ({
|
|
98
|
+
exitCode,
|
|
99
|
+
message: Option.some(`ausecon: ${message}\n${nextStep}`)
|
|
100
|
+
});
|
|
101
|
+
const USAGE_HINT = "Run `ausecon abs get --help` for usage and examples.";
|
|
102
|
+
const ISSUES = "https://github.com/jackemcpherson/ausecon/issues";
|
|
103
|
+
/** The table, and the sheet if known, that a layout error names. */
|
|
104
|
+
const layoutSubject = (error) => error.sheet === void 0 ? `Table ${error.tableId}` : `Table ${error.tableId}, sheet ${error.sheet},`;
|
|
105
|
+
/**
|
|
106
|
+
* The next step for an HTTP failure. Only a transport error points at the
|
|
107
|
+
* network. A status code means the ABS answered, and a response the tool
|
|
108
|
+
* cannot read is a fault to report.
|
|
109
|
+
*/
|
|
110
|
+
const httpNextStep = Match.type().pipe(Match.tag("TransportError", () => "Check your network connection, then try again. The ABS website may be down."), Match.tag("StatusCodeError", () => `Try again later. The ABS website may be down, or the data may have moved: if the error stays, report it at ${ISSUES}.`), Match.tag("DecodeError", "EmptyBodyError", "EncodeError", "InvalidUrlError", () => `Report this at ${ISSUES} with the command you ran.`), Match.exhaustive);
|
|
111
|
+
/** Describe a command-line usage error, or a help request, for a terminal user. */
|
|
112
|
+
const describeCliError = (formatter) => Match.type().pipe(Match.tag("ShowHelp", (error) => error.errors.length === 0 ? {
|
|
113
|
+
exitCode: EXIT_CODE.success,
|
|
114
|
+
message: Option.none()
|
|
115
|
+
} : {
|
|
116
|
+
exitCode: EXIT_CODE.usage,
|
|
117
|
+
message: Option.some(formatter.formatErrors(error.errors))
|
|
118
|
+
}), Match.orElse((error) => ({
|
|
119
|
+
exitCode: EXIT_CODE.usage,
|
|
120
|
+
message: Option.some(formatter.formatError(error))
|
|
121
|
+
})));
|
|
122
|
+
/**
|
|
123
|
+
* Describe a failed read for a terminal user. The match is exhaustive, so a new
|
|
124
|
+
* `ReadAbsError` does not compile until it has an exit code and a next step.
|
|
125
|
+
*/
|
|
126
|
+
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, `The Time Series Directory has no record of series ID ${error.seriesIds.join(", ")}.`, "Check the series ID in the ABS table that holds it.")), Match.tag("HttpClientError", (error) => outcome(EXIT_CODE.network, `Could not read from the ABS: ${error.message}`, httpNextStep(error.reason))), Match.tag("WorkbookLayoutError", (error) => outcome(EXIT_CODE.failure, `${layoutSubject(error)} does not have the ABS time series layout: ${error.reason}`, `Report this at ${ISSUES} with the command you ran.`)), Match.exhaustive);
|
|
127
|
+
/** Describe a failed command for a terminal user. */
|
|
128
|
+
const describeError = (formatter) => (error) => CliError.isCliError(error) ? describeCliError(formatter)(error) : describeReadError(error);
|
|
129
|
+
/**
|
|
130
|
+
* Decide the exit code and stderr message for a finished command.
|
|
131
|
+
*
|
|
132
|
+
* Usage errors exit 2, missing data 3, network and HTTP errors 4, and any
|
|
133
|
+
* other failure 1.
|
|
134
|
+
*
|
|
135
|
+
* @param exit - The result of running the command.
|
|
136
|
+
* @param formatter - The help formatter, used for `effect/cli` errors.
|
|
137
|
+
* @returns The exit code and the message for stderr.
|
|
138
|
+
*/
|
|
139
|
+
const outcomeOf = (exit, formatter) => Exit.match(exit, {
|
|
140
|
+
onSuccess: () => ({
|
|
141
|
+
exitCode: EXIT_CODE.success,
|
|
142
|
+
message: Option.none()
|
|
143
|
+
}),
|
|
144
|
+
onFailure: (cause) => Option.match(Cause.findErrorOption(cause), {
|
|
145
|
+
onSome: describeError(formatter),
|
|
146
|
+
onNone: () => outcome(EXIT_CODE.failure, `failed unexpectedly.\n${Cause.pretty(cause)}`, `Report this at ${ISSUES}.`)
|
|
147
|
+
})
|
|
148
|
+
});
|
|
149
|
+
// src/help.ts
|
|
150
|
+
/**
|
|
151
|
+
* Build the help formatter, which puts examples before everything else.
|
|
152
|
+
*
|
|
153
|
+
* The default `effect/cli` formatter prints examples last. Users learn a
|
|
154
|
+
* command fastest from its examples, so this formatter moves them to the top.
|
|
155
|
+
*
|
|
156
|
+
* @param hasColours - Whether to style the help with ANSI colours.
|
|
157
|
+
* @returns The formatter.
|
|
158
|
+
*/
|
|
159
|
+
const helpFormatter = (hasColours) => {
|
|
160
|
+
const base = CliOutput.defaultFormatter({ colors: hasColours });
|
|
161
|
+
const bold = (text) => hasColours ? `\u001B[1m${text}\u001B[0m` : text;
|
|
162
|
+
return {
|
|
163
|
+
...base,
|
|
164
|
+
formatHelpDoc: (doc) => {
|
|
165
|
+
const examples = doc.examples ?? [];
|
|
166
|
+
const rest = base.formatHelpDoc({
|
|
167
|
+
...doc,
|
|
168
|
+
examples: []
|
|
169
|
+
});
|
|
170
|
+
return examples.length === 0 ? rest : [
|
|
171
|
+
bold("EXAMPLES"),
|
|
172
|
+
...examples.flatMap((example) => [...example.description === void 0 ? [] : [` # ${example.description}`], ` ${example.command}`]),
|
|
173
|
+
"",
|
|
174
|
+
rest
|
|
175
|
+
].join("\n");
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
};
|
|
179
|
+
// src/run.ts
|
|
180
|
+
/**
|
|
181
|
+
* Colour is on only when stdout and stderr are terminals and `NO_COLOR` is
|
|
182
|
+
* unset or empty.
|
|
183
|
+
*
|
|
184
|
+
* The default covers an unset `NO_COLOR`, but a config provider can still fail
|
|
185
|
+
* to read its source. Colour then stays off, because help without colour
|
|
186
|
+
* serves every user and the command itself does not need the variable.
|
|
187
|
+
*/
|
|
188
|
+
const hasColours = Effect.gen(function* () {
|
|
189
|
+
const stdio = yield* Stdio.Stdio;
|
|
190
|
+
const noColor = yield* Config.String("NO_COLOR").pipe(Config.withDefault(""));
|
|
191
|
+
return (yield* stdio.stdoutIsTerminal) && (yield* stdio.stderrIsTerminal) && noColor === "";
|
|
192
|
+
}).pipe(Effect.catchTag("ConfigError", () => Effect.succeed(false)));
|
|
193
|
+
/** A console that holds `log` output in `held` and passes every other call to `console`. */
|
|
194
|
+
const holding = (console, held) => Object.assign(Object.create(console), { log: (...args) => {
|
|
195
|
+
held.push(args);
|
|
196
|
+
} });
|
|
197
|
+
/**
|
|
198
|
+
* Run `ausecon` with the given arguments and return its exit code.
|
|
199
|
+
*
|
|
200
|
+
* Everything the command writes with `Console.log` is held back until it
|
|
201
|
+
* finishes. On success it goes to stdout. On a failure it goes to stderr,
|
|
202
|
+
* with the error message and a next step, so stdout never holds partial data
|
|
203
|
+
* or help text.
|
|
204
|
+
*
|
|
205
|
+
* @param argv - The arguments after `ausecon`.
|
|
206
|
+
* @param version - The version that `--version` prints.
|
|
207
|
+
* @returns An Effect that yields the exit code.
|
|
208
|
+
*/
|
|
209
|
+
const runAusecon = Effect.fn("runAusecon")(function* (argv, version) {
|
|
210
|
+
const formatter = helpFormatter(yield* hasColours);
|
|
211
|
+
const console = yield* Console.Console;
|
|
212
|
+
const held = [];
|
|
213
|
+
const exit = yield* Command.runWith(ausecon, {
|
|
214
|
+
version,
|
|
215
|
+
renderErrors: false
|
|
216
|
+
})(argv).pipe(Effect.provideService(Console.Console, holding(console, held)), Effect.provideService(CliOutput.Formatter, formatter), Effect.exit);
|
|
217
|
+
const outcome = outcomeOf(exit, formatter);
|
|
218
|
+
const write = outcome.exitCode === EXIT_CODE.success ? Console.log : Console.error;
|
|
219
|
+
yield* Effect.forEach(held, (args) => write(...args), { discard: true });
|
|
220
|
+
if (Option.isSome(outcome.message)) yield* Console.error(outcome.message.value);
|
|
221
|
+
return outcome.exitCode;
|
|
222
|
+
});
|
|
10
223
|
// src/main.ts
|
|
11
|
-
|
|
224
|
+
/** Ends the process with a non-zero exit code. `runAusecon` has already written the message. */
|
|
225
|
+
var ExitCode = class extends Schema.TaggedError()("ExitCode", { code: Schema.Number }) {
|
|
226
|
+
get [Runtime.errorExitCode]() {
|
|
227
|
+
return this.code;
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
Effect.gen(function* () {
|
|
231
|
+
const argv = yield* (yield* Stdio.Stdio).args;
|
|
232
|
+
const code = yield* runAusecon(argv, version);
|
|
233
|
+
if (code !== 0) return yield* new ExitCode({ code });
|
|
234
|
+
}).pipe(Effect.provide([NodeServices.layer, FetchHttpClient.layer]), NodeRuntime.runMain({ disableErrorReporting: true }));
|
|
12
235
|
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ausecon",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.1.0",
|
|
4
4
|
"description": "Read Australian economic data from the ABS and RBA in your terminal.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
"build": "vp pack"
|
|
20
20
|
},
|
|
21
21
|
"dependencies": {
|
|
22
|
-
"@ausecon/core": "0.
|
|
22
|
+
"@ausecon/core": "0.1.0",
|
|
23
23
|
"@effect/platform-node": "4.0.6",
|
|
24
24
|
"effect": "4.0.6"
|
|
25
25
|
},
|