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.
Files changed (3) hide show
  1. package/README.md +11 -8
  2. package/dist/main.mjs +86 -28
  3. 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
- Print only the series ID, date, and value as JSON:
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 | 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. |
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, Struct } from "effect";
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.1.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
- /** 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);
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
- * Build a renderer of observations for stdout.
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 one array, indented only for a terminal.
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 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.
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 renderObservations = (format, isTerminal) => (columns, rows) => format === "csv" ? toCsv(columns, rows) : JSON.stringify(rows, null, isTerminal ? 2 : void 0);
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
- shouldOmitMetadata: Flag.Boolean("no-metadata").pipe(Flag.withDescription("Write only series ID, date, and value."), Flag.withoutNegation, Flag.withDefault(false))
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 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);
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 only series ID, date, and value as JSON"
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
- network: 4
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
- * 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.
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 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);
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, `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);
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, network and HTTP errors 4, and any
133
- * other failure 1.
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.1.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.1.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
  }