Skip to content

Commit

Permalink
feat(Makefile): add 'test' target to run Go tests
Browse files Browse the repository at this point in the history
feat(go.mod): add 'github.com/google/go-cmp' dependency for testing
feat(cli.go): add 'Update' and 'Dry' options for updating missing fields and dry run
feat(cli.go): add logic to handle 'Update' and 'Dry' options in 'Run' method
feat(json.go): add new file for JSON diff, extract, and merge operations
test(json_test.go): add tests for JSON diff and extract functions
feat(client.go): add 'ResponseFormat' option to configure OpenAI API response format

This commit introduces several new features and tests. The 'test' target in the Makefile and the 'go-cmp' dependency are added to facilitate testing. In 'cli.go', 'Update' and 'Dry' options are introduced to allow updating missing fields in the output file and performing a dry run respectively. The handling of these options is also added in the 'Run' method. A new file 'json.go' is added to perform JSON diff, extract, and merge operations. Tests for these operations are added in 'json_test.go'. Finally, the 'ResponseFormat' option is added in 'client.go' to configure the format of the response from the OpenAI API.
  • Loading branch information
bounoable committed Feb 21, 2024
1 parent c0ef1e7 commit 2432d74
Show file tree
Hide file tree
Showing 7 changed files with 368 additions and 5 deletions.
4 changes: 4 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
.PHONY: docs
docs:
@./docs.sh

.PHONY: test
test:
go test ./...
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ go 1.20
require (
github.com/MakeNowJust/heredoc/v2 v2.0.1
github.com/alecthomas/kong v0.8.1
github.com/google/go-cmp v0.6.0
github.com/sashabaranov/go-openai v1.17.9
github.com/tiktoken-go/tokenizer v0.1.0
)
Expand Down
6 changes: 2 additions & 4 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,16 +1,14 @@
github.com/MakeNowJust/heredoc/v2 v2.0.1 h1:rlCHh70XXXv7toz95ajQWOWQnN4WNLt0TdpZYIR/J6A=
github.com/MakeNowJust/heredoc/v2 v2.0.1/go.mod h1:6/2Abh5s+hc3g9nbWLe9ObDIOhaRrqsyY9MWy+4JdRM=
github.com/alecthomas/assert/v2 v2.1.0 h1:tbredtNcQnoSd3QBhQWI7QZ3XHOVkw1Moklp2ojoH/0=
github.com/alecthomas/kong v0.8.0 h1:ryDCzutfIqJPnNn0omnrgHLbAggDQM2VWHikE1xqK7s=
github.com/alecthomas/kong v0.8.0/go.mod h1:n1iCIO2xS46oE8ZfYCNDqdR0b0wZNrXAIAqro/2132U=
github.com/alecthomas/kong v0.8.1 h1:acZdn3m4lLRobeh3Zi2S2EpnXTd1mOL6U7xVml+vfkY=
github.com/alecthomas/kong v0.8.1/go.mod h1:n1iCIO2xS46oE8ZfYCNDqdR0b0wZNrXAIAqro/2132U=
github.com/alecthomas/repr v0.1.0 h1:ENn2e1+J3k09gyj2shc0dHr/yjaWSHRlrJ4DPMevDqE=
github.com/dlclark/regexp2 v1.10.0 h1:+/GIL799phkJqYW+3YbOd8LCcbHzT0Pbo8zl70MHsq0=
github.com/dlclark/regexp2 v1.10.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/sashabaranov/go-openai v1.13.0 h1:EAusFfnhaMaaUspUZ2+MbB/ZcVeD4epJmTOlZ+8AcAE=
github.com/sashabaranov/go-openai v1.13.0/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg=
github.com/sashabaranov/go-openai v1.17.9 h1:QEoBiGKWW68W79YIfXWEFZ7l5cEgZBV4/Ow3uy+5hNY=
github.com/sashabaranov/go-openai v1.17.9/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg=
github.com/tiktoken-go/tokenizer v0.1.0 h1:c1fXriHSR/NmhMDTwUDLGiNhHwTV+ElABGvqhCWLRvY=
Expand Down
66 changes: 65 additions & 1 deletion internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@ package cli
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"os"
"os/signal"
"syscall"
Expand All @@ -23,6 +25,8 @@ var options struct {
Preserve []string `short:"p" help:"Preserve the specified terms/words" env:"DRAGOMAN_PRESERVE"`
Rules []string `name:"rule" short:"r" help:"Additional rules for the prompt" env:"DRAGOMAN_RULES"`
Out string `short:"o" help:"Output file" type:"path" env:"DRAGOMAN_OUT"`
Update bool `short:"u" help:"Only translate missing fields in output file (requires JSON files)" env:"DRAGOMAN_UPDATE"`
Dry bool `help:"Write the result to stdout" env:"DRAGOMAN_DRY_RUN"`

OpenAIKey string `name:"openai-key" help:"OpenAI API key" env:"OPENAI_KEY"`
OpenAIModel string `name:"openai-model" help:"OpenAI model" env:"OPENAI_MODEL" default:"gpt-3.5-turbo"`
Expand Down Expand Up @@ -69,6 +73,14 @@ func New(version string) *App {
// process using AI language models. It manages errors gracefully, provides
// feedback to the user, and ensures proper resource cleanup.
func (app *App) Run() {
if options.Update && options.Out == "" {
app.kong.Fatalf("you must provide the <out> file when using --update")
}

if options.Out == "" {
options.Dry = true
}

ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()

Expand Down Expand Up @@ -104,6 +116,44 @@ func (app *App) Run() {
app.kong.FatalIfErrorf(err, "failed to read source file %q", options.SourcePath)
}

var (
sourceMap map[string]any
originalOutMap map[string]any
)
if options.Update {
err = json.Unmarshal(source, &sourceMap)
app.kong.FatalIfErrorf(err, "failed to unmarshal source as JSON")

outFile, err := os.ReadFile(options.Out)
if err != nil && !errors.Is(err, fs.ErrNotExist) {
app.kong.FatalIfErrorf(err, "failed to read target file %q", options.Out)
} else if err == nil {
err = json.Unmarshal(outFile, &originalOutMap)
app.kong.FatalIfErrorf(err, "failed to unmarshal target file %q", options.Out)
} else {
originalOutMap = map[string]any{}
}

paths, err := dragoman.JSONDiff(sourceMap, originalOutMap)
app.kong.FatalIfErrorf(err, "failed to diff source and target")

if len(paths) == 0 {
if options.Verbose {
fmt.Fprintf(os.Stderr, "No fields missing in output file %q.\n", options.Out)
}
return
}

sourceMap, err := dragoman.JSONExtract(source, paths)
if err != nil {
app.kong.FatalIfErrorf(err, "failed to extract missing fields from source")
}

if source, err = json.Marshal(sourceMap); err != nil {
app.kong.FatalIfErrorf(err, "failed to marshal source map")
}
}

if options.SourceLang == "auto" {
options.SourceLang = ""
}
Expand All @@ -118,11 +168,25 @@ func (app *App) Run() {
)
app.kong.FatalIfErrorf(err)

if options.Out == "" {
if options.Dry {
fmt.Fprintf(os.Stdout, "%s\n", result)
return
}

if options.Update {
var resultMap map[string]any
if err := json.Unmarshal([]byte(result), &resultMap); err != nil {
app.kong.FatalIfErrorf(err, "failed to unmarshal result as JSON")
}
dragoman.JSONMerge(originalOutMap, resultMap)

marshaled, err := json.Marshal(resultMap)
if err != nil {
app.kong.FatalIfErrorf(err, "failed to marshal result map")
}
result = string(marshaled)
}

f, err := os.Create(options.Out)
if err != nil {
app.kong.FatalIfErrorf(err, "failed to create output file %q", options.Out)
Expand Down
181 changes: 181 additions & 0 deletions json.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
package dragoman

import (
"encoding/json"
"fmt"
)

// JSONPath represents a sequence of keys that specify a unique path through a
// JSON object hierarchy, similar to an address for locating a specific value
// within a nested JSON structure. It is used to traverse and extract data from
// complex JSON documents.
type JSONPath []string

// JSONDiff identifies the differences between two JSON objects or two raw JSON
// byte representations. It returns a slice of JSONPaths that represent the
// hierarchical structure of keys where differences exist, and an error if any
// occur during the process. The function is generic and can accept either raw
// bytes or maps as inputs for comparison.
func JSONDiff[TInput []byte | map[string]any](source, target TInput) ([]JSONPath, error) {
var sourceMap, targetMap map[string]any

switch source := any(source).(type) {
case []byte:
if err := json.Unmarshal(source, &sourceMap); err != nil {
return nil, fmt.Errorf("unmarshal source: %w", err)
}

if err := json.Unmarshal(any(target).([]byte), &targetMap); err != nil {
return nil, fmt.Errorf("unmarshal target: %w", err)
}
case map[string]any:
sourceMap = source
targetMap = any(target).(map[string]any)
}

return jsonDiffPaths(sourceMap, targetMap)
}

func jsonDiffPaths(source, target map[string]any) (paths []JSONPath, _ error) {
for k, v := range source {
switch v := v.(type) {
case map[string]any:
targetValue, ok := target[k]
if ok {
targetMap, ok := targetValue.(map[string]any)
if !ok {
return paths, fmt.Errorf("target value at %q is not a map", k)
}

subPaths, err := jsonDiffPaths(v, targetMap)
if err != nil {
return paths, err
}

subPaths = mapSlice(subPaths, func(p JSONPath) JSONPath {
return append(JSONPath{k}, p...)
})

paths = append(paths, subPaths...)
} else {
subKeys := allKeys(v)
subKeys = mapSlice(subKeys, func(p JSONPath) JSONPath {
return append(JSONPath{k}, p...)
})

paths = append(paths, subKeys...)
}
default:
if _, ok := target[k]; !ok {
paths = append(paths, JSONPath{k})
}
}
}
return
}

// JSONExtract extracts values from a JSON document according to specified paths
// and returns them as a map. It supports both raw JSON bytes and already-parsed
// maps as input. If any path does not exist or leads to an unexpected type, an
// error is returned alongside the partial output.
func JSONExtract[TData []byte | map[string]any](data TData, paths []JSONPath) (map[string]any, error) {
var dataMap map[string]any
switch data := any(data).(type) {
case []byte:
if err := json.Unmarshal(data, &dataMap); err != nil {
return nil, fmt.Errorf("unmarshal data: %w", err)
}
case map[string]any:
dataMap = data
}

out := make(map[string]any)
for _, path := range paths {
if err := jsonExtract(dataMap, path, out); err != nil {
return out, err
}
}
return out, nil
}

func jsonExtract(data map[string]any, path JSONPath, out map[string]any) error {
if len(path) == 0 {
return nil
}

key := path[0]
value, ok := data[key]
if !ok {
return fmt.Errorf("key %q not found", key)
}

if len(path) == 1 {
out[key] = value
return nil
}

subPath := path[1:]
subMap, ok := value.(map[string]any)
if !ok {
return fmt.Errorf("value at %q is not a map", key)
}

if _, ok := out[key]; !ok {
outSubMap := make(map[string]any)
out[key] = outSubMap
}

outSubMap := out[key].(map[string]any)

return jsonExtract(subMap, subPath, outSubMap)
}

// JSONMerge combines the contents of two JSON object maps, where 'from' is
// merged into 'into'. If there are matching keys, the values from 'from' will
// overwrite those in 'into'. For nested maps, merging is performed recursively.
// This function modifies the 'into' map directly and does not return a new map.
func JSONMerge(into map[string]any, from map[string]any) {
for k, v := range from {
switch v := v.(type) {
case map[string]any:
intoValue, ok := into[k]
if ok {
intoMap, ok := intoValue.(map[string]any)
if !ok {
intoMap = make(map[string]any)
into[k] = intoMap
}
JSONMerge(intoMap, v)
} else {
into[k] = v
}
default:
into[k] = v
}
}
}

func mapSlice[V, O any](s []V, fn func(V) O) []O {
out := make([]O, len(s))
for i, v := range s {
out[i] = fn(v)
}
return out
}

func allKeys(m map[string]any) []JSONPath {
var keys []JSONPath
for k, v := range m {
switch v := v.(type) {
case map[string]any:
subKeys := allKeys(v)
subKeys = mapSlice(subKeys, func(p JSONPath) JSONPath {
return append(JSONPath{k}, p...)
})
keys = append(keys, subKeys...)
default:
keys = append(keys, JSONPath{k})
}
}
return keys
}
Loading

0 comments on commit 2432d74

Please sign in to comment.