Posting own answer which mostly works as I'd like (needed some special consideration for evil
mode).
While it works well for basic usage, errors will also replace the selection,
ideally a nonzero exit code would display as a message instead of putting the error into the editor.
;; See: https://emacs.stackexchange.com/a/34900/2418
;; Wrapper for 'shell-command-on-region', keeps the selection.
(defun shell-command-on-region-and-select
(start end command &optional
output-buffer replace
error-buffer display-error-buffer
region-noncontiguous-p)
"Wrapper for 'shell-command-on-region', re-selecting the output.
Useful when called with a selection, so it can be modified in-place"
(interactive)
(let ((buffer-size-init (buffer-size)))
(shell-command-on-region
start end command output-buffer replace
error-buffer display-error-buffer
region-noncontiguous-p)
(setq deactivate-mark nil)
(setq end (+ end (- (buffer-size) buffer-size-init)))
(set-mark start)
(goto-char end)
(activate-mark)
;; needed for evil line mode
(when (string= evil-state "visual")
(when (eq (evil-visual-type) evil-visual-line)
(evil-visual-select start end 'line)))))
(defun eval-region-as-py ()
"Evaluate selection as a python expression, replacing it with the result"
(interactive)
(shell-command-on-region-and-select
(region-beginning)
(region-end)
"python -c 'import sys; sys.stdout.write(str((eval(sys.stdin.read()))))'" 0 t))
(define-key evil-visual-state-map (kbd "RET") 'eval-region-as-py)