Files
weatherreporter/internal/cli/root.go

413 lines
12 KiB
Go

package cli
import (
"context"
"encoding/json"
"flag"
"fmt"
"io"
"gitea.maximumdirect.net/eric/weatherreporter/internal/app"
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
)
const helpText = `weatherreporter prepares weather reports from normalized forecast data.
Usage:
weatherreporter --help
weatherreporter generate daily [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--date YYYY-MM-DD]
weatherreporter generate tomorrow [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
weatherreporter generate near-term [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
weatherreporter generate three-day [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
weatherreporter generate weekend [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
weatherreporter generate storm [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] --start TIME --end TIME
weatherreporter run morning [--config PATH] [--units VALUE] [--tz NAME] [--out-dir PATH]
weatherreporter run evening [--config PATH] [--units VALUE] [--tz NAME] [--out-dir PATH]
weatherreporter inspect reports [--config PATH] [--limit N]
weatherreporter inspect metadata [--config PATH] RUN_ID
weatherreporter inspect modules [--config PATH] RUN_ID
weatherreporter inspect data-package [--config PATH] RUN_ID
weatherreporter inspect prior [--config PATH] RUN_ID
weatherreporter inspect sources [--config PATH] RUN_ID
Options:
-h, --help Show this help message.
--config PATH Load configuration from PATH instead of /usr/local/etc/weatherreporter/config.yml.
--units VALUE Override weather API units.
--tz NAME Override weather API timezone.
--out PATH Write an extra Markdown report copy for generate commands.
--out-dir PATH Write extra Markdown report copies for run commands.
`
type Runner struct {
Clock timeutil.Clock
}
func Run(ctx context.Context, args []string, stdout io.Writer, stderr io.Writer) error {
return Runner{Clock: timeutil.SystemClock{}}.Run(ctx, args, stdout, stderr)
}
func (r Runner) Run(ctx context.Context, args []string, stdout io.Writer, stderr io.Writer) error {
_ = stderr
if r.Clock == nil {
r.Clock = timeutil.SystemClock{}
}
if len(args) == 0 || args[0] == "--help" || args[0] == "-h" {
_, err := fmt.Fprint(stdout, helpText)
return err
}
switch args[0] {
case "generate":
req, err := r.resolveGenerate(args[1:])
if err != nil {
return err
}
return app.Generate(ctx, req)
case "run":
req, err := r.resolveRun(args[1:])
if err != nil {
return err
}
result, err := app.RunBatchDetailed(ctx, req)
if result != nil {
writeRunLogs(stderr, result)
if encodeErr := writeJSON(stdout, result); encodeErr != nil {
return encodeErr
}
if result.Failed > 0 {
return app.BatchError{Result: result}
}
}
return err
case "inspect":
return r.runInspect(ctx, args[1:], stdout)
default:
return fmt.Errorf("unknown command %q", args[0])
}
}
type commonOptions struct {
ConfigPath string
Units string
Timezone string
Output string
OutputDir string
}
type generateOptions struct {
commonOptions
Date string
Start string
End string
}
type inspectOptions struct {
ConfigPath string
Limit int
RunID string
}
type inspectRunCommand struct {
Name string
Inspect func(context.Context, app.InspectRunRequest) (any, error)
}
var inspectRunCommands = []inspectRunCommand{
{Name: "metadata", Inspect: func(ctx context.Context, req app.InspectRunRequest) (any, error) {
return app.InspectMetadata(ctx, req)
}},
{Name: "modules", Inspect: func(ctx context.Context, req app.InspectRunRequest) (any, error) {
return app.InspectModules(ctx, req)
}},
{Name: "data-package", Inspect: func(ctx context.Context, req app.InspectRunRequest) (any, error) {
return app.InspectDataPackage(ctx, req)
}},
{Name: "prior", Inspect: func(ctx context.Context, req app.InspectRunRequest) (any, error) {
return app.InspectPriorSnapshot(ctx, req)
}},
{Name: "sources", Inspect: func(ctx context.Context, req app.InspectRunRequest) (any, error) {
return app.InspectSources(ctx, req)
}},
}
func (r Runner) runInspect(ctx context.Context, args []string, stdout io.Writer) error {
if len(args) == 0 {
return fmt.Errorf("inspect requires a command")
}
command := args[0]
switch command {
case "reports":
opts, err := parseInspectReportsFlags(args[1:])
if err != nil {
return err
}
cfg, err := config.Load(config.LoadOptions{Path: opts.ConfigPath})
if err != nil {
return err
}
records, err := app.InspectReports(ctx, app.InspectReportsRequest{Config: cfg, Limit: opts.Limit})
if err != nil {
return err
}
return writeJSON(stdout, records)
default:
for _, candidate := range inspectRunCommands {
if candidate.Name == command {
return runInspectRunCommand(ctx, stdout, candidate, args[1:])
}
}
return fmt.Errorf("unknown inspect command %q", command)
}
}
func runInspectRunCommand(ctx context.Context, stdout io.Writer, command inspectRunCommand, args []string) error {
opts, err := parseInspectRunFlags(command.Name, args)
if err != nil {
return err
}
cfg, err := config.Load(config.LoadOptions{Path: opts.ConfigPath})
if err != nil {
return err
}
value, err := command.Inspect(ctx, app.InspectRunRequest{Config: cfg, RunID: opts.RunID})
if err != nil {
return err
}
return writeJSON(stdout, value)
}
func (r Runner) resolveGenerate(args []string) (app.GenerateRequest, error) {
if r.Clock == nil {
r.Clock = timeutil.SystemClock{}
}
if len(args) == 0 {
return app.GenerateRequest{}, fmt.Errorf("generate requires a report name")
}
reportKind, ok := reportKind(args[0])
if !ok {
return app.GenerateRequest{}, fmt.Errorf("unknown generate report %q", args[0])
}
opts, err := parseGenerateFlags(reportKind, args[1:])
if err != nil {
return app.GenerateRequest{}, err
}
cfg, err := config.Load(config.LoadOptions{
Path: opts.ConfigPath,
Units: opts.Units,
Timezone: opts.Timezone,
})
if err != nil {
return app.GenerateRequest{}, err
}
location, err := timeutil.LoadLocation(cfg.WeatherAPI.Timezone)
if err != nil {
return app.GenerateRequest{}, err
}
req := app.GenerateRequest{
Config: cfg,
Report: reportKind,
OutputPath: opts.Output,
Now: r.Clock.Now(),
}
switch reportKind {
case app.ReportDaily:
if opts.Date == "" {
req.Date = timeutil.LocalDate(r.Clock.Now(), location)
} else {
req.Date, err = timeutil.ParseLocalDate(opts.Date, location)
if err != nil {
return app.GenerateRequest{}, err
}
}
case app.ReportStorm:
if opts.Start == "" {
return app.GenerateRequest{}, fmt.Errorf("generate storm requires --start")
}
if opts.End == "" {
return app.GenerateRequest{}, fmt.Errorf("generate storm requires --end")
}
period, err := report.ParseStormPeriod(opts.Start, opts.End, location)
if err != nil {
return app.GenerateRequest{}, err
}
req.StormStart = period.Start
req.StormEnd = period.End
}
return req, nil
}
func (r Runner) resolveRun(args []string) (app.BatchRequest, error) {
if r.Clock == nil {
r.Clock = timeutil.SystemClock{}
}
if len(args) == 0 {
return app.BatchRequest{}, fmt.Errorf("run requires a batch name")
}
batch, ok := batchKind(args[0])
if !ok {
return app.BatchRequest{}, fmt.Errorf("unknown run batch %q", args[0])
}
opts, err := parseRunFlags(args[1:])
if err != nil {
return app.BatchRequest{}, err
}
cfg, err := config.Load(config.LoadOptions{
Path: opts.ConfigPath,
Units: opts.Units,
Timezone: opts.Timezone,
})
if err != nil {
return app.BatchRequest{}, err
}
return app.BatchRequest{Config: cfg, Batch: batch, Now: r.Clock.Now(), OutputDir: opts.OutputDir}, nil
}
func resolveRun(args []string) (app.BatchRequest, error) {
return Runner{Clock: timeutil.SystemClock{}}.resolveRun(args)
}
func parseGenerateFlags(report app.ReportKind, args []string) (generateOptions, error) {
fs := flag.NewFlagSet("generate "+string(report), flag.ContinueOnError)
fs.SetOutput(io.Discard)
opts := generateOptions{}
addCommonFlags(fs, &opts.commonOptions, true)
if report == app.ReportDaily {
fs.StringVar(&opts.Date, "date", "", "report date in YYYY-MM-DD")
}
if report == app.ReportStorm {
fs.StringVar(&opts.Start, "start", "", "storm start time")
fs.StringVar(&opts.End, "end", "", "storm end time")
}
if err := fs.Parse(args); err != nil {
return generateOptions{}, err
}
if fs.NArg() > 0 {
return generateOptions{}, fmt.Errorf("unexpected argument %q", fs.Arg(0))
}
return opts, nil
}
func parseRunFlags(args []string) (commonOptions, error) {
fs := flag.NewFlagSet("run", flag.ContinueOnError)
fs.SetOutput(io.Discard)
opts := commonOptions{}
addCommonFlags(fs, &opts, false)
fs.StringVar(&opts.OutputDir, "out-dir", "", "extra Markdown report copy directory")
if err := fs.Parse(args); err != nil {
return commonOptions{}, err
}
if fs.NArg() > 0 {
return commonOptions{}, fmt.Errorf("unexpected argument %q", fs.Arg(0))
}
return opts, nil
}
func parseInspectReportsFlags(args []string) (inspectOptions, error) {
fs := flag.NewFlagSet("inspect reports", flag.ContinueOnError)
fs.SetOutput(io.Discard)
opts := inspectOptions{Limit: 20}
fs.StringVar(&opts.ConfigPath, "config", "", "configuration file path")
fs.IntVar(&opts.Limit, "limit", 20, "maximum reports to list")
if err := fs.Parse(args); err != nil {
return inspectOptions{}, err
}
if fs.NArg() > 0 {
return inspectOptions{}, fmt.Errorf("unexpected argument %q", fs.Arg(0))
}
if opts.Limit < 0 {
return inspectOptions{}, fmt.Errorf("limit must be zero or greater")
}
return opts, nil
}
func parseInspectRunFlags(command string, args []string) (inspectOptions, error) {
fs := flag.NewFlagSet("inspect "+command, flag.ContinueOnError)
fs.SetOutput(io.Discard)
opts := inspectOptions{}
fs.StringVar(&opts.ConfigPath, "config", "", "configuration file path")
if err := fs.Parse(args); err != nil {
return inspectOptions{}, err
}
if fs.NArg() != 1 {
return inspectOptions{}, fmt.Errorf("inspect %s requires a run id", command)
}
opts.RunID = fs.Arg(0)
return opts, nil
}
func writeJSON(stdout io.Writer, value any) error {
encoder := json.NewEncoder(stdout)
encoder.SetIndent("", " ")
return encoder.Encode(value)
}
func writeRunLogs(stderr io.Writer, result *app.BatchResult) {
if stderr == nil || result == nil {
return
}
for _, item := range result.Reports {
notificationFields := ""
if item.NotificationStatus != "" {
notificationFields += fmt.Sprintf(" notificationStatus=%q", item.NotificationStatus)
}
if item.NotificationRunID != "" {
notificationFields += fmt.Sprintf(" notificationRunId=%q", item.NotificationRunID)
}
if item.NotificationError != "" {
notificationFields += fmt.Sprintf(" notificationError=%q", item.NotificationError)
}
if item.Status == "failed" {
_, _ = fmt.Fprintf(stderr, "report=%s status=failed error=%q%s\n", item.ReportID, item.Error, notificationFields)
continue
}
_, _ = fmt.Fprintf(stderr, "report=%s status=succeeded output=%q%s\n", item.ReportID, item.OutputPath, notificationFields)
}
_, _ = fmt.Fprintf(stderr, "batch=%s total=%d succeeded=%d failed=%d\n", result.Batch, result.Total, result.Succeeded, result.Failed)
}
func addCommonFlags(fs *flag.FlagSet, opts *commonOptions, includeOutput bool) {
fs.StringVar(&opts.ConfigPath, "config", "", "configuration file path")
fs.StringVar(&opts.Units, "units", "", "weather API units")
fs.StringVar(&opts.Timezone, "tz", "", "weather API timezone")
if includeOutput {
fs.StringVar(&opts.Output, "out", "", "extra Markdown report copy path")
}
}
func reportKind(value string) (app.ReportKind, bool) {
switch value {
case string(app.ReportDaily):
return app.ReportDaily, true
case string(app.ReportTomorrow):
return app.ReportTomorrow, true
case string(app.ReportNearTerm):
return app.ReportNearTerm, true
case string(app.ReportThreeDay):
return app.ReportThreeDay, true
case string(app.ReportWeekend):
return app.ReportWeekend, true
case string(app.ReportStorm):
return app.ReportStorm, true
default:
return "", false
}
}
func batchKind(value string) (app.BatchKind, bool) {
switch value {
case string(app.BatchMorning):
return app.BatchMorning, true
case string(app.BatchEvening):
return app.BatchEvening, true
default:
return "", false
}
}