Add :NeuralExplain
Add a new command for visual mode to explain the highlighted lines of text. Explanations will be printed to a preview window.
This commit is contained in:
@@ -8,6 +8,8 @@ A ChatGPT Vim plugin, an OpenAI Neovim plugin, and so much more! Neural integrat
|
||||
|
||||
* Generate text easily `:Neural write a story`
|
||||
* Support for multiple machine learning models
|
||||
* Focused on privacy and avoiding leaking data to third parties
|
||||
* Easily ask AI to explain code or paragraphs `:NeuralExplain`
|
||||
* Compatible with Vim 8.0+ & Neovim 0.8+
|
||||
* Supported on Linux, Mac OSX, and Windows
|
||||
* Only dependency is Python 3.7+
|
||||
@@ -107,11 +109,25 @@ Try typing `:Neural say hello`, and if all goes well the machine learning
|
||||
tool will say "hello" to you in the current buffer. Type `:help neural` to
|
||||
see the full documentation.
|
||||
|
||||
## 🛠️ Commands
|
||||
|
||||
### `:NeuralExplain`
|
||||
|
||||
You can ask Neural to explain code or text by visually selecting it and running
|
||||
the `:NeuralExplain` command. You may also create a custom keybind for
|
||||
explaining a visual range with `<Plug>(neural_explain)`.
|
||||
|
||||
Neural will make basic attempts to redact lines that appear to contain passwords
|
||||
or secrets. You may audit this code by reading
|
||||
[`autoload/neural/redact.vim`](https://github.com/dense-analysis/neural/blob/main/autoload/neural/redact.vim)
|
||||
|
||||
### `:NeuralStop`
|
||||
|
||||
You can stop Neural from working by with the `NeuralStop` command. Unless
|
||||
another keybind for `<C-c>` (CTRL+C) is defined in normal mode, Neural will run
|
||||
the stop command by default when you enter that key combination. The default
|
||||
keybind can be disabled by setting `g:neural.set_default_keybinds` to any falsy
|
||||
value.
|
||||
value. You can set a keybind to stop Neural by mapping to `<Plug>(neural_stop)`.
|
||||
|
||||
## 📜 Acknowledgements
|
||||
|
||||
|
||||
+36
-22
@@ -23,7 +23,9 @@ function! neural#GetScriptDir() abort
|
||||
return s:neural_script_dir
|
||||
endfunction
|
||||
|
||||
function! s:OutputErrorMessage(message) abort
|
||||
" Output an error message. The message should be a string.
|
||||
" The output error lines will be split in a platform-independent way.
|
||||
function! neural#OutputErrorMessage(message) abort
|
||||
let l:lines = split(a:message, '\v\r\n|\n|\r')
|
||||
|
||||
if len(l:lines) > 0
|
||||
@@ -100,7 +102,7 @@ function! s:HandleOutputEnd(buffer, job_data, exit_code) abort
|
||||
|
||||
" Output an error message from the program if something goes wrong.
|
||||
if a:exit_code != 0
|
||||
call s:OutputErrorMessage(join(a:job_data.error_lines, "\n"))
|
||||
call neural#OutputErrorMessage(join(a:job_data.error_lines, "\n"))
|
||||
else
|
||||
" Signal Neural is done for plugin integration.
|
||||
silent doautocmd <nomodeline> User NeuralWritePost
|
||||
@@ -110,6 +112,8 @@ function! s:HandleOutputEnd(buffer, job_data, exit_code) abort
|
||||
echomsg 'Neural is done!'
|
||||
endif
|
||||
endif
|
||||
|
||||
let s:current_job = 0
|
||||
endfunction
|
||||
|
||||
" Get the path to the executable for a script language.
|
||||
@@ -151,8 +155,10 @@ function! neural#Escape(str) abort
|
||||
return shellescape (a:str)
|
||||
endfunction
|
||||
|
||||
" Complain about no prompt text being provided.
|
||||
" This function is also called from Lua code.
|
||||
function! neural#ComplainNoPromptText() abort
|
||||
call s:OutputErrorMessage('No prompt text!')
|
||||
call neural#OutputErrorMessage('No prompt text!')
|
||||
endfunction
|
||||
|
||||
function! neural#OpenPrompt() abort
|
||||
@@ -166,10 +172,6 @@ function! neural#OpenPrompt() abort
|
||||
endif
|
||||
endfunction
|
||||
|
||||
function! neural#ComplainNoPromptText() abort
|
||||
call s:OutputErrorMessage('No prompt text!')
|
||||
endfunction
|
||||
|
||||
function! s:InitiallyInformUser(job_id) abort
|
||||
if neural#job#IsRunning(a:job_id)
|
||||
" no-custom-checks
|
||||
@@ -185,8 +187,17 @@ function! s:InformUserIfStillBusy(job_id) abort
|
||||
endfunction
|
||||
|
||||
function! neural#Cleanup() abort
|
||||
" Stop any currently running jobs.
|
||||
call neural#job#Stop(s:current_job)
|
||||
" Stop :NeuralExplain if it might be running.
|
||||
if exists('*neural#explain#Cleanup')
|
||||
call neural#explain#Cleanup()
|
||||
endif
|
||||
|
||||
if s:current_job
|
||||
" Stop any currently running jobs.
|
||||
call neural#job#Stop(s:current_job)
|
||||
let s:current_job = 0
|
||||
endif
|
||||
|
||||
" Stop timers for informing the user.
|
||||
call timer_stop(s:initial_timer)
|
||||
call timer_stop(s:busy_timer)
|
||||
@@ -229,7 +240,7 @@ function! s:LoadDataSource() abort
|
||||
try
|
||||
let l:source = function('neural#source#' . selected . '#Get')()
|
||||
catch /E117/
|
||||
call s:OutputErrorMessage('Invalid source: ' . l:selected)
|
||||
call neural#OutputErrorMessage('Invalid source: ' . l:selected)
|
||||
|
||||
return
|
||||
endtry
|
||||
@@ -253,6 +264,16 @@ function! s:GetSourceInput(buffer, source, prompt) abort
|
||||
return l:input
|
||||
endfunction
|
||||
|
||||
function! neural#GetCommand(buffer) abort
|
||||
let l:source = s:LoadDataSource()
|
||||
let l:script_exe = s:GetScriptExecutable(l:source)
|
||||
let l:command = neural#Escape(l:script_exe)
|
||||
\ . ' ' . neural#Escape(l:source.script)
|
||||
let l:command = neural#job#PrepareCommand(a:buffer, l:command)
|
||||
|
||||
return [l:source, l:command]
|
||||
endfunction
|
||||
|
||||
function! neural#Prompt(prompt) abort
|
||||
" Reload the Neural config on a prompt request if needed.
|
||||
call neural#config#Load()
|
||||
@@ -262,7 +283,7 @@ function! neural#Prompt(prompt) abort
|
||||
if has('nvim') && g:neural.ui.prompt_enabled
|
||||
call neural#OpenPrompt()
|
||||
else
|
||||
call s:OutputErrorMessage('No prompt text!')
|
||||
call neural#ComplainNoPromptText()
|
||||
endif
|
||||
|
||||
return
|
||||
@@ -276,18 +297,12 @@ function! neural#Prompt(prompt) abort
|
||||
let l:moving_line -= 1
|
||||
endif
|
||||
|
||||
let l:source = s:LoadDataSource()
|
||||
let l:script_exe = s:GetScriptExecutable(l:source)
|
||||
let l:command = neural#Escape(l:script_exe)
|
||||
\ . ' ' . neural#Escape(l:source.script)
|
||||
let l:command = neural#job#PrepareCommand(l:buffer, l:command)
|
||||
let [l:source, l:command] = neural#GetCommand(l:buffer)
|
||||
let l:job_data = {
|
||||
\ 'moving_line': l:moving_line,
|
||||
\ 'error_lines': [],
|
||||
\ 'content_started': 0,
|
||||
\}
|
||||
let l:input = s:GetSourceInput(l:buffer, l:source, a:prompt)
|
||||
|
||||
let l:job_id = neural#job#Start(l:command, {
|
||||
\ 'mode': 'nl',
|
||||
\ 'out_cb': {job_id, line -> s:AddLineToBuffer(l:buffer, l:job_data, line)},
|
||||
@@ -296,11 +311,10 @@ function! neural#Prompt(prompt) abort
|
||||
\})
|
||||
|
||||
if l:job_id > 0
|
||||
let l:stdin_data = json_encode(l:input) . "\n"
|
||||
|
||||
call neural#job#SendRaw(l:job_id, l:stdin_data)
|
||||
let l:input = s:GetSourceInput(l:buffer, l:source, a:prompt)
|
||||
call neural#job#SendRaw(l:job_id, json_encode(l:input) . "\n")
|
||||
else
|
||||
call s:OutputErrorMessage('Failed to run ' . l:source.name)
|
||||
call neural#OutputErrorMessage('Failed to run ' . l:source.name)
|
||||
|
||||
return
|
||||
endif
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
" Author: w0rp <devw0rp@gmail.com>
|
||||
" Description: Explain a visual selection with Neural.
|
||||
|
||||
let s:current_job = get(s:, 'current_explain_job', 0)
|
||||
|
||||
function! s:AddOutputLine(buffer, job_data, line) abort
|
||||
call add(a:job_data.output_lines, a:line)
|
||||
endfunction
|
||||
|
||||
function! s:AddErrorLine(buffer, job_data, line) abort
|
||||
call add(a:job_data.error_lines, a:line)
|
||||
endfunction
|
||||
|
||||
function! s:HandleOutputEnd(buffer, job_data, exit_code) abort
|
||||
" Output an error message from the program if something goes wrong.
|
||||
if a:exit_code != 0
|
||||
" Complain when something goes wrong.
|
||||
call neural#OutputErrorMessage(join(a:job_data.error_lines, "\n"))
|
||||
else
|
||||
let l:i = 0
|
||||
|
||||
while l:i < len(a:job_data.output_lines)
|
||||
if !empty(a:job_data.output_lines[l:i])
|
||||
break
|
||||
endif
|
||||
|
||||
let l:i += 1
|
||||
endwhile
|
||||
|
||||
call neural#preview#Show(
|
||||
\ a:job_data.output_lines[l:i :],
|
||||
\ {'stay_here': 1},
|
||||
\)
|
||||
endif
|
||||
|
||||
let s:current_job = 0
|
||||
endfunction
|
||||
|
||||
function! neural#explain#Cleanup() abort
|
||||
if s:current_job
|
||||
call neural#job#Stop(s:current_job)
|
||||
let s:current_job = 0
|
||||
endif
|
||||
endfunction
|
||||
|
||||
function! neural#explain#SelectedLines() abort
|
||||
" Reload the Neural config if needed.
|
||||
call neural#config#Load()
|
||||
" Stop Neural doing anything else if explaining code.
|
||||
call neural#Cleanup()
|
||||
|
||||
let l:range = neural#visual#GetRange()
|
||||
let l:buffer = bufnr('')
|
||||
|
||||
let [l:source, l:command] = neural#GetCommand(l:buffer)
|
||||
|
||||
let l:job_data = {
|
||||
\ 'output_lines': [],
|
||||
\ 'error_lines': [],
|
||||
\}
|
||||
let l:job_id = neural#job#Start(l:command, {
|
||||
\ 'mode': 'nl',
|
||||
\ 'out_cb': {job_id, line -> s:AddOutputLine(l:buffer, l:job_data, line)},
|
||||
\ 'err_cb': {job_id, line -> s:AddErrorLine(l:buffer, l:job_data, line)},
|
||||
\ 'exit_cb': {job_id, exit_code -> s:HandleOutputEnd(l:buffer, l:job_data, exit_code)},
|
||||
\})
|
||||
|
||||
if l:job_id > 0
|
||||
let l:lines = neural#redact#PasswordsAndSecrets(l:range.selection)
|
||||
|
||||
let l:config = get(g:neural.source, l:source.name, {})
|
||||
|
||||
" If the config is not a Dictionary, throw it away.
|
||||
if type(l:config) isnot v:t_dict
|
||||
let l:config = {}
|
||||
endif
|
||||
|
||||
let l:input = {
|
||||
\ 'config': l:config,
|
||||
\ 'prompt': "Explain these lines:\n\n" . join(l:lines, "\n"),
|
||||
\}
|
||||
call neural#job#SendRaw(l:job_id, json_encode(l:input) . "\n")
|
||||
else
|
||||
call neural#OutputErrorMessage('Failed to run ' . l:source.name)
|
||||
|
||||
return
|
||||
endif
|
||||
|
||||
let s:current_job = l:job_id
|
||||
endfunction
|
||||
@@ -0,0 +1,27 @@
|
||||
" Author: w0rp <devw0rp@gmail.com>
|
||||
" Description: Preview windows for showing whatever information in.
|
||||
|
||||
" Open a preview window and show some lines in it.
|
||||
" The second argument allows options to be passed in.
|
||||
"
|
||||
" filetype - The filetype to use, defaulting to 'neural-preview'
|
||||
" stay_here - If 1, stay in the window you came from.
|
||||
function! neural#preview#Show(lines, options) abort
|
||||
silent pedit NeuralPreviewWindow
|
||||
wincmd P
|
||||
|
||||
setlocal modifiable
|
||||
setlocal noreadonly
|
||||
setlocal nobuflisted
|
||||
setlocal buftype=nofile
|
||||
setlocal bufhidden=wipe
|
||||
:%d
|
||||
call setline(1, a:lines)
|
||||
setlocal nomodifiable
|
||||
setlocal readonly
|
||||
let &l:filetype = get(a:options, 'filetype', 'neural-preview')
|
||||
|
||||
if get(a:options, 'stay_here')
|
||||
wincmd p
|
||||
endif
|
||||
endfunction
|
||||
@@ -0,0 +1,36 @@
|
||||
" Author: w0rp <devw0rp@gmail.com>
|
||||
" Description: Redact passwords and secrets from code.
|
||||
|
||||
" This will never be a perfect implementation, but effort can be made.
|
||||
"
|
||||
" The regex to use for keys in objects.
|
||||
let s:key_regex = 'password|secret[_ ]?key'
|
||||
" This substitution will be applied for each of the s:key_value_groups.
|
||||
let s:key_value_sub = '\1\3*\4'
|
||||
" Regular expressions to replace with s:key_value_sub
|
||||
let s:key_value_groups = map(
|
||||
\ [
|
||||
\ '("(KEYS)")( *[=:]+ *r?")[^"]+(")',
|
||||
\ '("(KEYS)")( *[=:]+ *r?'')[^'']+('')',
|
||||
\ '(''(KEYS)'')( *[=:]+ *r?'')[^'']+('')',
|
||||
\ '(''(KEYS)'')( *[=:]+ *r?")[^"]+(")',
|
||||
\ '((KEYS))( *[=:]+ *r?")[^"]+(")',
|
||||
\ '((KEYS))( *[=:]+ *r?'')[^'']+('')',
|
||||
\ '((KEYS))( *[=:]+ *r?`)[^`]+(`)',
|
||||
\ ],
|
||||
\ {_, template -> '\v\c' . substitute(template, 'KEYS', s:key_regex, '')}
|
||||
\)
|
||||
|
||||
function! neural#redact#PasswordsAndSecrets(unredacted) abort
|
||||
let l:line_list = []
|
||||
|
||||
for l:line in a:unredacted
|
||||
for l:regex in s:key_value_groups
|
||||
let l:line = substitute(l:line, l:regex, s:key_value_sub, 'g')
|
||||
endfor
|
||||
|
||||
call add(l:line_list, l:line)
|
||||
endfor
|
||||
|
||||
return l:line_list
|
||||
endfunction
|
||||
@@ -0,0 +1,30 @@
|
||||
" Author: w0rp <devw0rp@gmail.com>
|
||||
" Description: Neural functions for working with Vim's visual mode.
|
||||
|
||||
" Get all information for a visual range command we might want.
|
||||
"
|
||||
" Credit to xolox for most of this.
|
||||
function! neural#visual#GetRange() abort
|
||||
let [l:lnum, l:col] = getpos("'<")[1:2]
|
||||
let [l:end_lnum, l:end_col] = getpos("'>")[1:2]
|
||||
let l:end_offset = &selection is# 'inclusive' ? 1 : 2
|
||||
|
||||
" Get the line range and slice the text we selected.
|
||||
let l:selection = getline(l:lnum, l:end_lnum)
|
||||
|
||||
if !empty(l:selection)
|
||||
let l:selection[0] = l:selection[0][l:col - 1:]
|
||||
let l:selection[-1] = l:selection[-1][: l:end_col - l:end_offset]
|
||||
" Get the actual end column from the text length, as Vim can give us
|
||||
" the maximum int for visual line mode.
|
||||
let l:end_col = len(l:selection[-1])
|
||||
endif
|
||||
|
||||
return {
|
||||
\ 'lnum': l:lnum,
|
||||
\ 'col': l:col,
|
||||
\ 'end_lnum': l:end_lnum,
|
||||
\ 'end_col': l:end_col,
|
||||
\ 'selection': l:selection,
|
||||
\}
|
||||
endfunction
|
||||
@@ -53,6 +53,18 @@ Neural *Neural*
|
||||
completes writing text to a buffer.
|
||||
|
||||
|
||||
NeuralExplain *NeuralExplain*
|
||||
|
||||
A |visual-mode| command for explaining the highlighted lines. The visual
|
||||
selection will be sent to the currently selected source model and the
|
||||
response will be displayed in a preview window.
|
||||
|
||||
Neural will make basic attempts to redact lines that appear to contain
|
||||
passwords or secrets.
|
||||
|
||||
A plug mapping `<Plug>(neural_explain)` is defined for this command.
|
||||
|
||||
|
||||
NeuralStop *NeuralStop*
|
||||
|
||||
Stop any currently running Neural tasks, and immediately stop printing text
|
||||
|
||||
+2
-2
@@ -41,7 +41,7 @@ function Neural.prompt()
|
||||
end
|
||||
|
||||
function Neural.start_animated_sign(line)
|
||||
local sign_enabled = vim.g.neural.ui.animated_sign_enabled
|
||||
local sign_enabled = vim.g.neural and vim.g.neural.ui.animated_sign_enabled
|
||||
|
||||
if has_significant and (sign_enabled and sign_enabled ~= 0) and line > 0 then
|
||||
AnimatedSign.start_animated_sign(line, 'dots', 100)
|
||||
@@ -49,7 +49,7 @@ function Neural.start_animated_sign(line)
|
||||
end
|
||||
|
||||
function Neural.stop_animated_sign(line)
|
||||
local sign_enabled = vim.g.neural.ui.animated_sign_enabled
|
||||
local sign_enabled = vim.g.neural and vim.g.neural.ui.animated_sign_enabled
|
||||
|
||||
if has_significant and (sign_enabled and sign_enabled ~= 0) and line > 0 then
|
||||
AnimatedSign.stop_animated_sign(line, {unplace_sign=true})
|
||||
|
||||
@@ -143,6 +143,30 @@ def load_config(raw_config: Dict[str, Any]) -> Config:
|
||||
)
|
||||
|
||||
|
||||
def get_error_message(error: urllib.error.HTTPError) -> str:
|
||||
message = error.read().decode('utf-8', errors='ignore')
|
||||
|
||||
try:
|
||||
# JSON data might look like this:
|
||||
# {
|
||||
# "error": {
|
||||
# "message": "...",
|
||||
# "type": "...",
|
||||
# "param": null,
|
||||
# "code": null
|
||||
# }
|
||||
# }
|
||||
message = json.loads(message)['error']['message']
|
||||
|
||||
if "This model's maximum context length is" in message:
|
||||
message = 'Too much text for a request!'
|
||||
except Exception:
|
||||
# If we can't get a better message use the JSON payload at least.
|
||||
pass
|
||||
|
||||
return message
|
||||
|
||||
|
||||
def main() -> None:
|
||||
input_data = json.loads(sys.stdin.readline())
|
||||
|
||||
@@ -153,8 +177,11 @@ def main() -> None:
|
||||
|
||||
try:
|
||||
get_chatgpt_completion(config, input_data["prompt"])
|
||||
except urllib.error.URLError as error:
|
||||
if isinstance(error, urllib.error.HTTPError) and error.code == 429:
|
||||
except urllib.error.HTTPError as error:
|
||||
if error.code == 400:
|
||||
message = get_error_message(error)
|
||||
sys.exit('Neural error: OpenAI request failure: ' + message)
|
||||
elif error.code == 429:
|
||||
sys.exit("Neural error: OpenAI request limit reached!")
|
||||
else:
|
||||
raise
|
||||
|
||||
@@ -131,6 +131,30 @@ def load_config(raw_config: Dict[str, Any]) -> Config:
|
||||
)
|
||||
|
||||
|
||||
def get_error_message(error: urllib.error.HTTPError) -> str:
|
||||
message = error.read().decode('utf-8', errors='ignore')
|
||||
|
||||
try:
|
||||
# JSON data might look like this:
|
||||
# {
|
||||
# "error": {
|
||||
# "message": "...",
|
||||
# "type": "...",
|
||||
# "param": null,
|
||||
# "code": null
|
||||
# }
|
||||
# }
|
||||
message = json.loads(message)['error']['message']
|
||||
|
||||
if "This model's maximum context length is" in message:
|
||||
message = 'Too much text for a request!'
|
||||
except Exception:
|
||||
# If we can't get a better message use the JSON payload at least.
|
||||
pass
|
||||
|
||||
return message
|
||||
|
||||
|
||||
def main() -> None:
|
||||
input_data = json.loads(sys.stdin.readline())
|
||||
|
||||
@@ -141,8 +165,11 @@ def main() -> None:
|
||||
|
||||
try:
|
||||
get_openai_completion(config, input_data["prompt"])
|
||||
except urllib.error.URLError as error:
|
||||
if isinstance(error, urllib.error.HTTPError) and error.code == 429:
|
||||
except urllib.error.HTTPError as error:
|
||||
if error.code == 400:
|
||||
message = get_error_message(error)
|
||||
sys.exit('Neural error: OpenAI request failure: ' + message)
|
||||
elif error.code == 429:
|
||||
sys.exit("Neural error: OpenAI request limit reached!")
|
||||
else:
|
||||
raise
|
||||
|
||||
@@ -29,12 +29,17 @@ if !s:has_features
|
||||
finish
|
||||
endif
|
||||
|
||||
" Have Neural write to the buffer given a prompt.
|
||||
command! -nargs=? Neural :call neural#Prompt(<q-args>)
|
||||
" Stop Neural doing anything.
|
||||
command! -nargs=0 NeuralStop :call neural#Stop()
|
||||
" Have Neural explain the visually selected lines.
|
||||
command! -range NeuralExplain :call neural#explain#SelectedLines()
|
||||
|
||||
" <Plug> mappings for commands
|
||||
nnoremap <silent> <Plug>(neural_prompt) :call neural#OpenPrompt()<Return>
|
||||
nnoremap <silent> <Plug>(neural_stop) :call neural#Stop()<Return>
|
||||
vnoremap <silent> <Plug>(neural_explain) :NeuralExplain<Return>
|
||||
|
||||
" Set default keybinds for Neural unless we're told not to. We should almost
|
||||
" never define keybinds by default in a plugin, but we can add only a few to
|
||||
|
||||
@@ -3,7 +3,7 @@ import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from io import BytesIO
|
||||
from typing import Any, Dict, cast
|
||||
from typing import Any, Dict, Optional, cast
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
@@ -144,7 +144,44 @@ def test_main_function_bad_config():
|
||||
assert str(exc.value) == 'expect this'
|
||||
|
||||
|
||||
def test_main_function_rate_limit_error():
|
||||
@pytest.mark.parametrize(
|
||||
'code, error_text, expected_message',
|
||||
(
|
||||
pytest.param(
|
||||
429,
|
||||
None,
|
||||
'OpenAI request limit reached!',
|
||||
id="request_limit",
|
||||
),
|
||||
pytest.param(
|
||||
400,
|
||||
'{]',
|
||||
'OpenAI request failure: {]',
|
||||
id="error_with_mangled_json",
|
||||
),
|
||||
pytest.param(
|
||||
400,
|
||||
json.dumps({'error': {}}),
|
||||
'OpenAI request failure: {"error": {}}',
|
||||
id="error_with_missing_message_key",
|
||||
),
|
||||
pytest.param(
|
||||
400,
|
||||
json.dumps({
|
||||
'error': {
|
||||
'message': "This model's maximum context length is 123",
|
||||
},
|
||||
}),
|
||||
'OpenAI request failure: Too much text for a request!',
|
||||
id="too_much_text",
|
||||
),
|
||||
)
|
||||
)
|
||||
def test_api_error(
|
||||
code: int,
|
||||
error_text: Optional[str],
|
||||
expected_message: str,
|
||||
):
|
||||
with mock.patch.object(sys.stdin, 'readline') as readline_mock, \
|
||||
mock.patch.object(chatgpt, 'get_chatgpt_completion') as compl_mock:
|
||||
|
||||
@@ -152,9 +189,10 @@ def test_main_function_rate_limit_error():
|
||||
url='',
|
||||
msg='',
|
||||
hdrs=mock.Mock(),
|
||||
fp=None,
|
||||
code=429,
|
||||
fp=BytesIO(error_text.encode('utf-8')) if error_text else None,
|
||||
code=code,
|
||||
)
|
||||
|
||||
readline_mock.return_value = json.dumps({
|
||||
"config": get_valid_config(),
|
||||
"prompt": "hello there",
|
||||
@@ -163,4 +201,4 @@ def test_main_function_rate_limit_error():
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
chatgpt.main()
|
||||
|
||||
assert str(exc.value) == 'Neural error: OpenAI request limit reached!'
|
||||
assert str(exc.value) == f'Neural error: {expected_message}'
|
||||
|
||||
@@ -3,7 +3,7 @@ import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from io import BytesIO
|
||||
from typing import Any, Dict, cast
|
||||
from typing import Any, Dict, Optional, cast
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
@@ -137,17 +137,55 @@ def test_main_function_bad_config():
|
||||
assert str(exc.value) == 'expect this'
|
||||
|
||||
|
||||
def test_main_function_rate_limit_error():
|
||||
@pytest.mark.parametrize(
|
||||
'code, error_text, expected_message',
|
||||
(
|
||||
pytest.param(
|
||||
429,
|
||||
None,
|
||||
'OpenAI request limit reached!',
|
||||
id="request_limit",
|
||||
),
|
||||
pytest.param(
|
||||
400,
|
||||
'{]',
|
||||
'OpenAI request failure: {]',
|
||||
id="error_with_mangled_json",
|
||||
),
|
||||
pytest.param(
|
||||
400,
|
||||
json.dumps({'error': {}}),
|
||||
'OpenAI request failure: {"error": {}}',
|
||||
id="error_with_missing_message_key",
|
||||
),
|
||||
pytest.param(
|
||||
400,
|
||||
json.dumps({
|
||||
'error': {
|
||||
'message': "This model's maximum context length is 123",
|
||||
},
|
||||
}),
|
||||
'OpenAI request failure: Too much text for a request!',
|
||||
id="too_much_text",
|
||||
),
|
||||
)
|
||||
)
|
||||
def test_api_error(
|
||||
code: int,
|
||||
error_text: Optional[str],
|
||||
expected_message: str,
|
||||
):
|
||||
with mock.patch.object(sys.stdin, 'readline') as readline_mock, \
|
||||
mock.patch.object(openai, 'get_openai_completion') as completion_mock:
|
||||
mock.patch.object(openai, 'get_openai_completion') as compl_mock:
|
||||
|
||||
completion_mock.side_effect = urllib.error.HTTPError(
|
||||
compl_mock.side_effect = urllib.error.HTTPError(
|
||||
url='',
|
||||
msg='',
|
||||
hdrs=mock.Mock(),
|
||||
fp=None,
|
||||
code=429,
|
||||
fp=BytesIO(error_text.encode('utf-8')) if error_text else None,
|
||||
code=code,
|
||||
)
|
||||
|
||||
readline_mock.return_value = json.dumps({
|
||||
"config": get_valid_config(),
|
||||
"prompt": "hello there",
|
||||
@@ -156,4 +194,4 @@ def test_main_function_rate_limit_error():
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
openai.main()
|
||||
|
||||
assert str(exc.value) == 'Neural error: OpenAI request limit reached!'
|
||||
assert str(exc.value) == f'Neural error: {expected_message}'
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
Before:
|
||||
Save g:neural
|
||||
|
||||
" Load modules so we can mock the fuctions.
|
||||
runtime autoload/neural/job.vim
|
||||
runtime autoload/neural/visual.vim
|
||||
runtime autoload/neural/preview.vim
|
||||
|
||||
unlet! g:neural
|
||||
let g:job_id = 0
|
||||
let g:calls = []
|
||||
let g:preview_calls = []
|
||||
let g:OutCb = {->0}
|
||||
let g:ErrCb = {->0}
|
||||
let g:ExitCb = {->0}
|
||||
|
||||
function! neural#job#Start(command, options) abort
|
||||
let g:job_id += 1
|
||||
call add(g:calls, ['neural#job#Start'])
|
||||
|
||||
let g:OutCb = a:options.out_cb
|
||||
let g:ErrCb = a:options.err_cb
|
||||
let g:ExitCb = a:options.exit_cb
|
||||
|
||||
return g:job_id
|
||||
endfunction
|
||||
|
||||
function! neural#job#IsRunning(job_id) abort
|
||||
return g:job_id == a:job_id
|
||||
endfunction
|
||||
|
||||
function! neural#job#SendRaw(job_id, string) abort
|
||||
call add(g:calls, ['neural#job#SendRaw', a:job_id, a:string])
|
||||
endfunction
|
||||
|
||||
function! neural#job#Stop(job_id) abort
|
||||
call add(g:calls, ['neural#job#Stop', a:job_id])
|
||||
let g:job_id = 0
|
||||
endfunction
|
||||
|
||||
function! neural#visual#GetRange() abort
|
||||
return g:get_range_result
|
||||
endfunction
|
||||
|
||||
function! neural#preview#Show(lines, options) abort
|
||||
call add(g:preview_calls, [a:lines, a:options])
|
||||
endfunction
|
||||
|
||||
After:
|
||||
Restore
|
||||
|
||||
unlet! g:job_id
|
||||
unlet! g:calls
|
||||
unlet! g:preview_calls
|
||||
unlet! g:OutCb
|
||||
unlet! g:ErrCb
|
||||
unlet! g:ExitCb
|
||||
unlet! g:get_range_result
|
||||
|
||||
" Load actual code again.
|
||||
runtime autoload/neural/job.vim
|
||||
runtime autoload/neural/visual.vim
|
||||
runtime autoload/neural/preview.vim
|
||||
|
||||
Given text(Some lines):
|
||||
One
|
||||
Two
|
||||
Three
|
||||
Four
|
||||
Five
|
||||
Six
|
||||
Execute():
|
||||
" Set up a mocked result for the range selection.
|
||||
let g:get_range_result = {
|
||||
\ 'lnum': 2,
|
||||
\ 'col': 2,
|
||||
\ 'end_lnum': 4,
|
||||
\ 'end_col': 3,
|
||||
\ 'selection': ['ne', 'Two', 'Three', 'Fou'],
|
||||
\}
|
||||
|
||||
" Run the function :NeuralExplain runs.
|
||||
call neural#explain#SelectedLines()
|
||||
call timer_stopall()
|
||||
|
||||
" We should send the visually selected lines to the job.
|
||||
AssertEqual
|
||||
\ [
|
||||
\ ['neural#job#Start'],
|
||||
\ [
|
||||
\ 'neural#job#SendRaw',
|
||||
\ 1,
|
||||
\ json_encode({
|
||||
\ 'config': g:neural.source[g:neural.selected],
|
||||
\ 'prompt': "Explain these lines:\n\nne\nTwo\nThree\nFou",
|
||||
\ }) . "\n",
|
||||
\ ]
|
||||
\ ],
|
||||
\ g:calls
|
||||
|
||||
call g:OutCb(g:job_id, 'first output line')
|
||||
call g:OutCb(g:job_id, 'second output line')
|
||||
call g:ExitCb(g:job_id, 0)
|
||||
|
||||
" We should make a call to preview the lines.
|
||||
AssertEqual
|
||||
\ [
|
||||
\ [
|
||||
\ ['first output line', 'second output line'],
|
||||
\ {'stay_here': 1}
|
||||
\ ],
|
||||
\ ],
|
||||
\ g:preview_calls
|
||||
@@ -1,6 +1,7 @@
|
||||
Before:
|
||||
Save g:neural
|
||||
|
||||
" Load modules so we can mock the fuctions.
|
||||
runtime autoload/neural/job.vim
|
||||
|
||||
unlet! g:neural
|
||||
@@ -39,6 +40,8 @@ Before:
|
||||
call neural#ResetState()
|
||||
|
||||
After:
|
||||
Restore
|
||||
|
||||
redir END
|
||||
|
||||
unlet! g:job_id
|
||||
@@ -46,15 +49,15 @@ After:
|
||||
unlet! g:OutCb
|
||||
unlet! g:ErrCb
|
||||
unlet! g:ExitCb
|
||||
unlet! g:captured_output
|
||||
|
||||
augroup WritePostGroup
|
||||
autocmd!
|
||||
augroup END
|
||||
|
||||
" Load actual code again.
|
||||
runtime autoload/neural/job.vim
|
||||
|
||||
Restore
|
||||
|
||||
Given text (A file with two lines and a blank line in the middle):
|
||||
First line
|
||||
|
||||
@@ -71,8 +74,8 @@ Execute(Running Neural and collecting output should work):
|
||||
autocmd User NeuralWritePost :call add(g:calls, ['NeuralWritePost'])
|
||||
augroup END
|
||||
|
||||
call g:OutCb(g:job_id, "first output line")
|
||||
call g:OutCb(g:job_id, "second output line")
|
||||
call g:OutCb(g:job_id, 'first output line')
|
||||
call g:OutCb(g:job_id, 'second output line')
|
||||
call g:ExitCb(g:job_id, 0)
|
||||
|
||||
AssertEqual
|
||||
@@ -91,18 +94,16 @@ Execute(Running Neural and collecting output should work):
|
||||
|
||||
AssertEqual
|
||||
\ [
|
||||
\ ['neural#job#Stop', 0],
|
||||
\ ['neural#job#Start'],
|
||||
\ [
|
||||
\ 'neural#job#SendRaw',
|
||||
\ 1,
|
||||
\ json_encode({
|
||||
\ 'config': g:neural.source.openai,
|
||||
\ 'config': g:neural.source[g:neural.selected],
|
||||
\ 'prompt': 'this is a test',
|
||||
\ }) . "\n",
|
||||
\ ],
|
||||
\ ['NeuralWritePost'],
|
||||
\ ['neural#job#Stop', 1],
|
||||
\ ],
|
||||
\ g:calls
|
||||
|
||||
@@ -170,7 +171,6 @@ Execute(It should be possible to stop Neural):
|
||||
|
||||
AssertEqual
|
||||
\ [
|
||||
\ 'neural#job#Stop',
|
||||
\ 'neural#job#Start',
|
||||
\ 'neural#job#SendRaw',
|
||||
\ 'neural#job#Stop',
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
After:
|
||||
unlet! g:preview_data
|
||||
|
||||
Execute(Preview windows should be opened correctly):
|
||||
call neural#preview#Show(['a', 'b'], {'stay_here': 1})
|
||||
wincmd p
|
||||
let g:preview_data = {
|
||||
\ 'lines': getline(1, '$'),
|
||||
\ 'filetype': &filetype,
|
||||
\ 'modifiable': &modifiable,
|
||||
\ 'readonly': &readonly,
|
||||
\}
|
||||
:q
|
||||
|
||||
" Assert values only after closing the preview window.
|
||||
" Otherwise the window will remain open.
|
||||
AssertEqual
|
||||
\ {
|
||||
\ 'lines': ['a', 'b'],
|
||||
\ 'filetype': 'neural-preview',
|
||||
\ 'modifiable': 0,
|
||||
\ 'readonly': 1,
|
||||
\ },
|
||||
\ g:preview_data
|
||||
@@ -0,0 +1,32 @@
|
||||
Execute(Object values for password and secret keys should be redacted):
|
||||
AssertEqual
|
||||
\ [
|
||||
\ '{',
|
||||
\ ' "password": "*", "SecretKey": r"*",',
|
||||
\ ' ''password'': ''*'', ''Secret_Key'': r''*'',',
|
||||
\ ' "password": ''*'', "secret key": r''*'',',
|
||||
\ ' ''password'': "*", ''secretkey'' : r"*",',
|
||||
\ '}',
|
||||
\ ],
|
||||
\ neural#redact#PasswordsAndSecrets([
|
||||
\ '{',
|
||||
\ ' "password": "hideme", "SecretKey": r"hideme",',
|
||||
\ ' ''password'': ''hideme'', ''Secret_Key'': r''hideme'',',
|
||||
\ ' "password": ''hideme'', "secret key": r''hideme'',',
|
||||
\ ' ''password'': "hideme", ''secretkey'' : r"hideme",',
|
||||
\ '}',
|
||||
\ ])
|
||||
|
||||
Execute(Assignment expressions should be redacted):
|
||||
AssertEqual
|
||||
\ ['password = ''*'''],
|
||||
\ neural#redact#PasswordsAndSecrets(['password = ''hideme'''])
|
||||
AssertEqual
|
||||
\ ['password = "*"'],
|
||||
\ neural#redact#PasswordsAndSecrets(['password = "hideme"'])
|
||||
AssertEqual
|
||||
\ ['password = `*`'],
|
||||
\ neural#redact#PasswordsAndSecrets(['password = `hideme`'])
|
||||
AssertEqual
|
||||
\ ['password := `*`'],
|
||||
\ neural#redact#PasswordsAndSecrets(['password := `hideme`'])
|
||||
Reference in New Issue
Block a user