ausecon 0.1.0 → 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.
- package/README.md +11 -8
- package/dist/main.mjs +86 -28
- package/package.json +5 -2
package/README.md
CHANGED
|
@@ -41,7 +41,10 @@ Read a watch list of series IDs from any catalogue numbers:
|
|
|
41
41
|
ausecon abs get --series A2603039T --series A2454446F
|
|
42
42
|
```
|
|
43
43
|
|
|
44
|
-
|
|
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:
|
|
45
48
|
|
|
46
49
|
```shell
|
|
47
50
|
ausecon abs get --catalogue 6345.0 --format json --no-metadata
|
|
@@ -52,13 +55,13 @@ Pass exactly one of `--catalogue` or `--series`. `--table` needs
|
|
|
52
55
|
|
|
53
56
|
## Exit Codes
|
|
54
57
|
|
|
55
|
-
| Code | Meaning
|
|
56
|
-
| ---- |
|
|
57
|
-
| 0 | The read succeeded.
|
|
58
|
-
| 1 |
|
|
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 |
|
|
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. |
|
|
62
65
|
|
|
63
66
|
## Shell Completions
|
|
64
67
|
|
package/dist/main.mjs
CHANGED
|
@@ -1,43 +1,96 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { NodeRuntime, NodeServices } from "@effect/platform-node";
|
|
3
|
-
import { Cause, Config, Console, Effect, Exit, Match, Option, Runtime, Schema, Stdio
|
|
3
|
+
import { Cause, Config, Console, Context, Effect, Exit, Layer, Match, Option, Runtime, Schema, Stdio } from "effect";
|
|
4
4
|
import { FetchHttpClient } from "effect/http";
|
|
5
|
+
import { AbsRequest, readAbs } from "@ausecon/core";
|
|
5
6
|
import { CliError, CliOutput, Command, Flag } from "effect/cli";
|
|
6
|
-
import { AbsRequest, Observation, ObservationValue, readAbs } from "@ausecon/core";
|
|
7
7
|
// package.json
|
|
8
|
-
var version = "0.
|
|
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
|
+
};
|
|
9
20
|
// src/output.ts
|
|
10
21
|
/** The output formats, in the order help lists them. */
|
|
11
22
|
const OUTPUT_FORMATS = ["csv", "json"];
|
|
12
|
-
/**
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
+
})));
|
|
16
62
|
/** Quote a CSV field when it holds a comma, a quote, or a line break (RFC 4180). */
|
|
17
63
|
const csvField = (value) => {
|
|
18
64
|
const text = `${value}`;
|
|
19
65
|
return /[",\r\n]/u.test(text) ? `"${text.replaceAll("\"", "\"\"")}"` : text;
|
|
20
66
|
};
|
|
21
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
|
+
});
|
|
22
73
|
/**
|
|
23
|
-
*
|
|
74
|
+
* Render Series for stdout.
|
|
24
75
|
*
|
|
25
76
|
* CSV has a header row and one line per observation, with `\n` line endings on
|
|
26
|
-
* every platform. JSON is
|
|
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.
|
|
27
80
|
*
|
|
28
|
-
* @param
|
|
29
|
-
* @param
|
|
30
|
-
*
|
|
31
|
-
*
|
|
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.
|
|
32
85
|
*/
|
|
33
|
-
const
|
|
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);
|
|
34
87
|
// src/commands/abs-get.ts
|
|
35
88
|
const flags = {
|
|
36
89
|
catalogue: Flag.String("catalogue").pipe(Flag.withDescription("Catalogue number to read, such as 6345.0."), Flag.optional),
|
|
37
90
|
table: Flag.String("table").pipe(Flag.withDescription("Table number to read, such as 1 or 7a. Repeat for more tables."), Flag.atLeast(0)),
|
|
38
91
|
series: Flag.String("series").pipe(Flag.withDescription("Series ID to read, such as A2603039T. Repeat for more series."), Flag.atLeast(0)),
|
|
39
92
|
format: Flag.Literals("format", OUTPUT_FORMATS).pipe(Flag.withDescription("Output format: csv or json."), Flag.withDefault("csv")),
|
|
40
|
-
|
|
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))
|
|
41
94
|
};
|
|
42
95
|
const usageError = (message) => Effect.fail(new CliError.ValidationError({ message }));
|
|
43
96
|
/**
|
|
@@ -56,9 +109,12 @@ const requestOf = (input) => Option.match(input.catalogue, {
|
|
|
56
109
|
const get = Effect.fn("absGet")(function* (input) {
|
|
57
110
|
const request = yield* requestOf(input);
|
|
58
111
|
const isTerminal = yield* (yield* Stdio.Stdio).stdoutIsTerminal;
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
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
|
+
}));
|
|
62
118
|
});
|
|
63
119
|
/** The `ausecon abs get` command: read ABS time series to stdout. */
|
|
64
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([
|
|
@@ -76,7 +132,7 @@ const absGet = Command.make("get", flags, get).pipe(Command.validate((input) =>
|
|
|
76
132
|
},
|
|
77
133
|
{
|
|
78
134
|
command: "ausecon abs get --catalogue 6345.0 --format json --no-metadata",
|
|
79
|
-
description: "Print
|
|
135
|
+
description: "Print each series ID and its observations as JSON, without metadata"
|
|
80
136
|
}
|
|
81
137
|
]));
|
|
82
138
|
// src/commands/abs.ts
|
|
@@ -92,7 +148,7 @@ const EXIT_CODE = {
|
|
|
92
148
|
failure: 1,
|
|
93
149
|
usage: 2,
|
|
94
150
|
notFound: 3,
|
|
95
|
-
|
|
151
|
+
unavailable: 4
|
|
96
152
|
};
|
|
97
153
|
const outcome = (exitCode, message, nextStep) => ({
|
|
98
154
|
exitCode,
|
|
@@ -102,12 +158,14 @@ const USAGE_HINT = "Run `ausecon abs get --help` for usage and examples.";
|
|
|
102
158
|
const ISSUES = "https://github.com/jackemcpherson/ausecon/issues";
|
|
103
159
|
/** The table, and the sheet if known, that a layout error names. */
|
|
104
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.`;
|
|
105
162
|
/**
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
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.
|
|
109
167
|
*/
|
|
110
|
-
const
|
|
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}.`);
|
|
111
169
|
/** Describe a command-line usage error, or a help request, for a terminal user. */
|
|
112
170
|
const describeCliError = (formatter) => Match.type().pipe(Match.tag("ShowHelp", (error) => error.errors.length === 0 ? {
|
|
113
171
|
exitCode: EXIT_CODE.success,
|
|
@@ -123,14 +181,14 @@ const describeCliError = (formatter) => Match.type().pipe(Match.tag("ShowHelp",
|
|
|
123
181
|
* Describe a failed read for a terminal user. The match is exhaustive, so a new
|
|
124
182
|
* `ReadAbsError` does not compile until it has an exit code and a next step.
|
|
125
183
|
*/
|
|
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, `
|
|
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);
|
|
127
185
|
/** Describe a failed command for a terminal user. */
|
|
128
186
|
const describeError = (formatter) => (error) => CliError.isCliError(error) ? describeCliError(formatter)(error) : describeReadError(error);
|
|
129
187
|
/**
|
|
130
188
|
* Decide the exit code and stderr message for a finished command.
|
|
131
189
|
*
|
|
132
|
-
* Usage errors exit 2, missing data 3,
|
|
133
|
-
*
|
|
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.
|
|
134
192
|
*
|
|
135
193
|
* @param exit - The result of running the command.
|
|
136
194
|
* @param formatter - The help formatter, used for `effect/cli` errors.
|
|
@@ -231,5 +289,5 @@ Effect.gen(function* () {
|
|
|
231
289
|
const argv = yield* (yield* Stdio.Stdio).args;
|
|
232
290
|
const code = yield* runAusecon(argv, version);
|
|
233
291
|
if (code !== 0) return yield* new ExitCode({ code });
|
|
234
|
-
}).pipe(Effect.provide([NodeServices.layer, FetchHttpClient.layer]), NodeRuntime.runMain({ disableErrorReporting: true }));
|
|
292
|
+
}).pipe(Effect.provide([NodeServices.layer, AbsReader.layer.pipe(Layer.provide(FetchHttpClient.layer))]), NodeRuntime.runMain({ disableErrorReporting: true }));
|
|
235
293
|
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ausecon",
|
|
3
|
-
"version": "0.
|
|
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.
|
|
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
|
}
|