Stabilize process API
- SHA
afad5481c62901707dfcb2af70c314947a908c28- Parents
-
02c19e3 - Tree
ac4961c
afad548
afad5481c62901707dfcb2af70c314947a908c2802c19e3
ac4961c| Status | File | + | - |
|---|---|---|---|
| M |
.gitignore
|
1 | 0 |
| M |
README.md
|
22 | 10 |
| M |
docs/ROADMAP.md
|
11 | 13 |
| M |
src/fgof_process.f90
|
87 | 33 |
| A |
src/fgof_process_types.f90
|
66 | 0 |
| M |
test/test_command.f90
|
2 | 1 |
| A |
test/test_shell.f90
|
14 | 0 |
| A |
test/test_validation.f90
|
33 | 0 |
.gitignoremodified@@ -1,2 +1,3 @@ | ||
| 1 | 1 | build/ |
| 2 | +.docs/ | |
| 2 | 3 | .DS_Store |
README.mdmodified@@ -2,17 +2,20 @@ | ||
| 2 | 2 | |
| 3 | 3 | POSIX-first process and subprocess helpers for modern Fortran applications. |
| 4 | 4 | |
| 5 | -`fgof-process` is intended to be a small, standalone library that gives Fortran tools a more ergonomic process API than raw `execute_command_line` or ad hoc C interop. | |
| 5 | +`fgof-process` is intended to be a small, standalone library that gives Fortran tools a more ergonomic process API than raw `execute_command_line`, thin POSIX wrappers, or experimental process surfaces that still feel too low-level for real tooling. | |
| 6 | 6 | |
| 7 | -Initial scope: | |
| 7 | +Current v1 target: | |
| 8 | 8 | |
| 9 | 9 | - argv-first command construction |
| 10 | -- synchronous process execution | |
| 10 | +- synchronous process execution on macOS and Linux | |
| 11 | +- explicit shell-command convenience through `/bin/sh -c` | |
| 11 | 12 | - environment overrides |
| 12 | 13 | - working-directory overrides |
| 14 | +- stdin support | |
| 13 | 15 | - stdout and stderr capture |
| 14 | 16 | - exit-status reporting |
| 15 | 17 | - timeout-aware execution |
| 18 | +- structured, result-first errors | |
| 16 | 19 | |
| 17 | 20 | Future scope: |
| 18 | 21 | |
@@ -20,12 +23,13 @@ Future scope: | ||
| 20 | 23 | - async spawn and wait |
| 21 | 24 | - signal helpers |
| 22 | 25 | - PTY-friendly integration points for a future `fgof-pty` |
| 26 | +- a dedicated `fgof-proc-test` companion package | |
| 23 | 27 | |
| 24 | 28 | ## Status |
| 25 | 29 | |
| 26 | -Early scaffold. | |
| 30 | +Sprint 00 and 01 scaffold. | |
| 27 | 31 | |
| 28 | -This repository is being created as the first package in the FortranGoingOnForty reusable library family and is intended to be consumed standalone or via the umbrella catalog repo at `lib-modules`. | |
| 32 | +This repository is the first package in the FortranGoingOnForty reusable library family and is intended to be consumed standalone or via the umbrella catalog repo at `lib-modules`. | |
| 29 | 33 | |
| 30 | 34 | ## Package Goals |
| 31 | 35 | |
@@ -33,24 +37,32 @@ This repository is being created as the first package in the FortranGoingOnForty | ||
| 33 | 37 | - prefer argv-based execution over shell-string execution |
| 34 | 38 | - make tests easy to write |
| 35 | 39 | - stay useful for shells, editors, TUI apps, and developer tooling |
| 40 | +- close a real ecosystem gap rather than mirroring `stdlib_system` | |
| 36 | 41 | |
| 37 | -## Planned API Shape | |
| 42 | +## Public API Shape | |
| 38 | 43 | |
| 39 | 44 | Primary module: |
| 40 | 45 | |
| 41 | 46 | - `fgof_process` |
| 42 | 47 | |
| 43 | -Initial public types: | |
| 48 | +Public types: | |
| 44 | 49 | |
| 45 | 50 | - `process_command` |
| 46 | -- `process_result` | |
| 47 | 51 | - `process_options` |
| 52 | +- `process_result` | |
| 48 | 53 | |
| 49 | -Initial public procedures: | |
| 54 | +Public procedures: | |
| 50 | 55 | |
| 51 | 56 | - `command` |
| 57 | +- `shell` | |
| 52 | 58 | - `run` |
| 53 | 59 | |
| 60 | +## Current Boundaries | |
| 61 | + | |
| 62 | +- Direct POSIX backend is planned for v1. | |
| 63 | +- `stdlib_system` can inform behavior, but is not a required backend. | |
| 64 | +- Async process handles are explicitly deferred until after the sync-first release. | |
| 65 | + | |
| 54 | 66 | ## Development Notes |
| 55 | 67 | |
| 56 | 68 | - POSIX first: macOS and Linux |
@@ -59,4 +71,4 @@ Initial public procedures: | ||
| 59 | 71 | |
| 60 | 72 | ## License |
| 61 | 73 | |
| 62 | -TBD | |
| 74 | +MIT | |
docs/ROADMAP.mdmodified@@ -2,22 +2,20 @@ | ||
| 2 | 2 | |
| 3 | 3 | ## v0.1 |
| 4 | 4 | |
| 5 | -- establish standalone repo and `fpm` package | |
| 6 | -- define initial public types | |
| 7 | -- implement argv-first command construction | |
| 8 | -- implement synchronous `run` | |
| 9 | -- add basic tests | |
| 5 | +- establish the stable public API | |
| 6 | +- implement synchronous argv-first execution | |
| 7 | +- add shell convenience via explicit `shell()` | |
| 8 | +- support cwd, env, stdin, stdout, stderr, and timeouts | |
| 9 | +- ship a documented sync-first library that tool authors actually want to use | |
| 10 | 10 | |
| 11 | 11 | ## v0.2 |
| 12 | 12 | |
| 13 | -- stdout and stderr capture | |
| 14 | -- cwd and env overrides | |
| 15 | -- timeout handling | |
| 16 | -- richer errors | |
| 13 | +- async process handles | |
| 14 | +- richer process control | |
| 15 | +- signal helpers | |
| 16 | +- stronger process-fixture testing support | |
| 17 | 17 | |
| 18 | 18 | ## v0.3 |
| 19 | 19 | |
| 20 | -- async spawn or wait | |
| 21 | -- process handles | |
| 22 | -- kill and terminate helpers | |
| 23 | -- integration-test utilities | |
| 20 | +- companion packages such as `fgof-proc-test` | |
| 21 | +- possible PTY-facing integrations | |
src/fgof_process.f90modified@@ -1,48 +1,47 @@ | ||
| 1 | 1 | module fgof_process |
| 2 | + use fgof_process_types, only : & | |
| 3 | + FGOF_PROCESS_ERR_INVALID_COMMAND, & | |
| 4 | + FGOF_PROCESS_ERR_INVALID_OPTION, & | |
| 5 | + FGOF_PROCESS_ERR_NOT_IMPLEMENTED, & | |
| 6 | + FGOF_PROCESS_MODE_ARGV, & | |
| 7 | + FGOF_PROCESS_MODE_NONE, & | |
| 8 | + FGOF_PROCESS_MODE_SHELL, & | |
| 9 | + FGOF_PROCESS_OK, & | |
| 10 | + process_command, & | |
| 11 | + process_options, & | |
| 12 | + process_result | |
| 2 | 13 | implicit none |
| 3 | 14 | private |
| 4 | 15 | |
| 5 | 16 | public :: process_command |
| 6 | 17 | public :: process_options |
| 7 | 18 | public :: process_result |
| 19 | + public :: FGOF_PROCESS_MODE_NONE | |
| 20 | + public :: FGOF_PROCESS_MODE_ARGV | |
| 21 | + public :: FGOF_PROCESS_MODE_SHELL | |
| 22 | + public :: FGOF_PROCESS_OK | |
| 23 | + public :: FGOF_PROCESS_ERR_INVALID_COMMAND | |
| 24 | + public :: FGOF_PROCESS_ERR_INVALID_OPTION | |
| 25 | + public :: FGOF_PROCESS_ERR_NOT_IMPLEMENTED | |
| 8 | 26 | public :: command |
| 27 | + public :: shell | |
| 9 | 28 | public :: run |
| 10 | 29 | |
| 11 | - type :: process_command | |
| 12 | - character(len=:), allocatable :: program | |
| 13 | - character(len=:), allocatable :: argv(:) | |
| 14 | - end type process_command | |
| 15 | - | |
| 16 | - type :: process_options | |
| 17 | - character(len=:), allocatable :: cwd | |
| 18 | - logical :: capture_stdout = .false. | |
| 19 | - logical :: capture_stderr = .false. | |
| 20 | - integer :: timeout_ms = 0 | |
| 21 | - character(len=:), allocatable :: env(:) | |
| 22 | - end type process_options | |
| 23 | - | |
| 24 | - type :: process_result | |
| 25 | - integer :: exit_code = -1 | |
| 26 | - logical :: timed_out = .false. | |
| 27 | - character(len=:), allocatable :: stdout | |
| 28 | - character(len=:), allocatable :: stderr | |
| 29 | - character(len=:), allocatable :: error_message | |
| 30 | - end type process_result | |
| 31 | - | |
| 32 | 30 | contains |
| 33 | 31 | |
| 34 | 32 | function command(program, argv) result(cmd) |
| 35 | 33 | character(len=*), intent(in) :: program |
| 36 | 34 | character(len=*), intent(in), optional :: argv(:) |
| 37 | 35 | type(process_command) :: cmd |
| 36 | + integer :: arg_len | |
| 38 | 37 | integer :: i |
| 39 | 38 | |
| 39 | + cmd%mode = FGOF_PROCESS_MODE_ARGV | |
| 40 | 40 | cmd%program = trim(program) |
| 41 | 41 | |
| 42 | 42 | if (present(argv)) then |
| 43 | - allocate(character(len=len_trim(program)) :: cmd%argv(0)) | |
| 44 | - deallocate(cmd%argv) | |
| 45 | - allocate(character(len=max(1, max_trimmed_length(argv))) :: cmd%argv(size(argv))) | |
| 43 | + arg_len = max_trimmed_length(argv) | |
| 44 | + allocate(character(len=arg_len) :: cmd%argv(size(argv))) | |
| 46 | 45 | do i = 1, size(argv) |
| 47 | 46 | cmd%argv(i) = trim(argv(i)) |
| 48 | 47 | end do |
@@ -51,30 +50,85 @@ contains | ||
| 51 | 50 | end if |
| 52 | 51 | end function command |
| 53 | 52 | |
| 53 | + function shell(command_line) result(cmd) | |
| 54 | + character(len=*), intent(in) :: command_line | |
| 55 | + type(process_command) :: cmd | |
| 56 | + | |
| 57 | + cmd%mode = FGOF_PROCESS_MODE_SHELL | |
| 58 | + cmd%command_line = trim(command_line) | |
| 59 | + allocate(character(len=1) :: cmd%argv(0)) | |
| 60 | + end function shell | |
| 61 | + | |
| 54 | 62 | function run(cmd, options) result(res) |
| 55 | 63 | type(process_command), intent(in) :: cmd |
| 56 | 64 | type(process_options), intent(in), optional :: options |
| 57 | 65 | type(process_result) :: res |
| 58 | 66 | |
| 59 | - res%exit_code = -1 | |
| 60 | - res%timed_out = .false. | |
| 61 | - res%stdout = "" | |
| 62 | - res%stderr = "" | |
| 63 | - res%error_message = "run() is not implemented yet" | |
| 67 | + call init_result(res) | |
| 68 | + | |
| 69 | + select case (cmd%mode) | |
| 70 | + case (FGOF_PROCESS_MODE_ARGV) | |
| 71 | + if (.not. allocated(cmd%program)) then | |
| 72 | + call set_error(res, FGOF_PROCESS_ERR_INVALID_COMMAND, "argv command program is not set") | |
| 73 | + return | |
| 74 | + end if | |
| 75 | + | |
| 76 | + if (len_trim(cmd%program) == 0) then | |
| 77 | + call set_error(res, FGOF_PROCESS_ERR_INVALID_COMMAND, "argv command program must not be empty") | |
| 78 | + return | |
| 79 | + end if | |
| 80 | + | |
| 81 | + case (FGOF_PROCESS_MODE_SHELL) | |
| 82 | + if (.not. allocated(cmd%command_line)) then | |
| 83 | + call set_error(res, FGOF_PROCESS_ERR_INVALID_COMMAND, "shell command line is not set") | |
| 84 | + return | |
| 85 | + end if | |
| 86 | + | |
| 87 | + if (len_trim(cmd%command_line) == 0) then | |
| 88 | + call set_error(res, FGOF_PROCESS_ERR_INVALID_COMMAND, "shell command line must not be empty") | |
| 89 | + return | |
| 90 | + end if | |
| 64 | 91 | |
| 65 | - if (.not. allocated(cmd%program)) then | |
| 66 | - res%error_message = "command program is not set" | |
| 92 | + case default | |
| 93 | + call set_error(res, FGOF_PROCESS_ERR_INVALID_COMMAND, "command mode is not set") | |
| 67 | 94 | return |
| 68 | - end if | |
| 95 | + end select | |
| 69 | 96 | |
| 70 | 97 | if (present(options)) then |
| 71 | 98 | if (options%timeout_ms < 0) then |
| 72 | - res%error_message = "timeout_ms must be >= 0" | |
| 99 | + call set_error(res, FGOF_PROCESS_ERR_INVALID_OPTION, "timeout_ms must be >= 0") | |
| 73 | 100 | return |
| 74 | 101 | end if |
| 75 | 102 | end if |
| 103 | + | |
| 104 | + call set_error(res, FGOF_PROCESS_ERR_NOT_IMPLEMENTED, "run() is not implemented yet") | |
| 76 | 105 | end function run |
| 77 | 106 | |
| 107 | + subroutine init_result(res) | |
| 108 | + type(process_result), intent(out) :: res | |
| 109 | + | |
| 110 | + res%launched = .false. | |
| 111 | + res%completed = .false. | |
| 112 | + res%timed_out = .false. | |
| 113 | + res%exited_normally = .false. | |
| 114 | + res%exit_code = -1 | |
| 115 | + res%term_signal = 0 | |
| 116 | + res%stdout = "" | |
| 117 | + res%stderr = "" | |
| 118 | + res%error_code = FGOF_PROCESS_OK | |
| 119 | + res%error_message = "" | |
| 120 | + res%elapsed_ms = 0 | |
| 121 | + end subroutine init_result | |
| 122 | + | |
| 123 | + subroutine set_error(res, code, message) | |
| 124 | + type(process_result), intent(inout) :: res | |
| 125 | + integer, intent(in) :: code | |
| 126 | + character(len=*), intent(in) :: message | |
| 127 | + | |
| 128 | + res%error_code = code | |
| 129 | + res%error_message = trim(message) | |
| 130 | + end subroutine set_error | |
| 131 | + | |
| 78 | 132 | integer function max_trimmed_length(values) result(max_len) |
| 79 | 133 | character(len=*), intent(in) :: values(:) |
| 80 | 134 | integer :: i |
src/fgof_process_types.f90added@@ -0,0 +1,66 @@ | ||
| 1 | +module fgof_process_types | |
| 2 | + implicit none | |
| 3 | + private | |
| 4 | + | |
| 5 | + public :: FGOF_PROCESS_MODE_NONE | |
| 6 | + public :: FGOF_PROCESS_MODE_ARGV | |
| 7 | + public :: FGOF_PROCESS_MODE_SHELL | |
| 8 | + public :: FGOF_PROCESS_OK | |
| 9 | + public :: FGOF_PROCESS_ERR_INVALID_COMMAND | |
| 10 | + public :: FGOF_PROCESS_ERR_INVALID_OPTION | |
| 11 | + public :: FGOF_PROCESS_ERR_SPAWN_FAILED | |
| 12 | + public :: FGOF_PROCESS_ERR_EXEC_FAILED | |
| 13 | + public :: FGOF_PROCESS_ERR_PIPE_FAILED | |
| 14 | + public :: FGOF_PROCESS_ERR_TIMEOUT | |
| 15 | + public :: FGOF_PROCESS_ERR_INTERNAL | |
| 16 | + public :: FGOF_PROCESS_ERR_NOT_IMPLEMENTED | |
| 17 | + public :: process_command | |
| 18 | + public :: process_options | |
| 19 | + public :: process_result | |
| 20 | + | |
| 21 | + integer, parameter :: FGOF_PROCESS_MODE_NONE = 0 | |
| 22 | + integer, parameter :: FGOF_PROCESS_MODE_ARGV = 1 | |
| 23 | + integer, parameter :: FGOF_PROCESS_MODE_SHELL = 2 | |
| 24 | + | |
| 25 | + integer, parameter :: FGOF_PROCESS_OK = 0 | |
| 26 | + integer, parameter :: FGOF_PROCESS_ERR_INVALID_COMMAND = 10 | |
| 27 | + integer, parameter :: FGOF_PROCESS_ERR_INVALID_OPTION = 11 | |
| 28 | + integer, parameter :: FGOF_PROCESS_ERR_SPAWN_FAILED = 20 | |
| 29 | + integer, parameter :: FGOF_PROCESS_ERR_EXEC_FAILED = 21 | |
| 30 | + integer, parameter :: FGOF_PROCESS_ERR_PIPE_FAILED = 22 | |
| 31 | + integer, parameter :: FGOF_PROCESS_ERR_TIMEOUT = 23 | |
| 32 | + integer, parameter :: FGOF_PROCESS_ERR_INTERNAL = 99 | |
| 33 | + integer, parameter :: FGOF_PROCESS_ERR_NOT_IMPLEMENTED = 1000 | |
| 34 | + | |
| 35 | + type :: process_command | |
| 36 | + integer :: mode = FGOF_PROCESS_MODE_NONE | |
| 37 | + character(len=:), allocatable :: program | |
| 38 | + character(len=:), allocatable :: argv(:) | |
| 39 | + character(len=:), allocatable :: command_line | |
| 40 | + end type process_command | |
| 41 | + | |
| 42 | + type :: process_options | |
| 43 | + character(len=:), allocatable :: cwd | |
| 44 | + character(len=:), allocatable :: stdin | |
| 45 | + logical :: capture_stdout = .false. | |
| 46 | + logical :: capture_stderr = .false. | |
| 47 | + integer :: timeout_ms = 0 | |
| 48 | + character(len=:), allocatable :: env_set(:) | |
| 49 | + character(len=:), allocatable :: env_unset(:) | |
| 50 | + end type process_options | |
| 51 | + | |
| 52 | + type :: process_result | |
| 53 | + logical :: launched = .false. | |
| 54 | + logical :: completed = .false. | |
| 55 | + logical :: timed_out = .false. | |
| 56 | + logical :: exited_normally = .false. | |
| 57 | + integer :: exit_code = -1 | |
| 58 | + integer :: term_signal = 0 | |
| 59 | + character(len=:), allocatable :: stdout | |
| 60 | + character(len=:), allocatable :: stderr | |
| 61 | + integer :: error_code = FGOF_PROCESS_OK | |
| 62 | + character(len=:), allocatable :: error_message | |
| 63 | + integer :: elapsed_ms = 0 | |
| 64 | + end type process_result | |
| 65 | + | |
| 66 | +end module fgof_process_types | |
test/test_command.f90modified@@ -1,11 +1,12 @@ | ||
| 1 | 1 | program test_command |
| 2 | - use fgof_process, only : process_command, command | |
| 2 | + use fgof_process, only : FGOF_PROCESS_MODE_ARGV, command, process_command | |
| 3 | 3 | implicit none |
| 4 | 4 | |
| 5 | 5 | type(process_command) :: cmd |
| 6 | 6 | |
| 7 | 7 | cmd = command("printf", ["hello", "world"]) |
| 8 | 8 | |
| 9 | + if (cmd%mode /= FGOF_PROCESS_MODE_ARGV) error stop "mode mismatch" | |
| 9 | 10 | if (.not. allocated(cmd%program)) error stop "program not allocated" |
| 10 | 11 | if (cmd%program /= "printf") error stop "program mismatch" |
| 11 | 12 | if (.not. allocated(cmd%argv)) error stop "argv not allocated" |
test/test_shell.f90added@@ -0,0 +1,14 @@ | ||
| 1 | +program test_shell | |
| 2 | + use fgof_process, only : FGOF_PROCESS_MODE_SHELL, process_command, shell | |
| 3 | + implicit none | |
| 4 | + | |
| 5 | + type(process_command) :: cmd | |
| 6 | + | |
| 7 | + cmd = shell("printf 'hello from shell'") | |
| 8 | + | |
| 9 | + if (cmd%mode /= FGOF_PROCESS_MODE_SHELL) error stop "mode mismatch" | |
| 10 | + if (.not. allocated(cmd%command_line)) error stop "command_line not allocated" | |
| 11 | + if (cmd%command_line /= "printf 'hello from shell'") error stop "command_line mismatch" | |
| 12 | + if (.not. allocated(cmd%argv)) error stop "argv not allocated" | |
| 13 | + if (size(cmd%argv) /= 0) error stop "shell argv should be empty" | |
| 14 | +end program test_shell | |
test/test_validation.f90added@@ -0,0 +1,33 @@ | ||
| 1 | +program test_validation | |
| 2 | + use fgof_process, only : & | |
| 3 | + FGOF_PROCESS_ERR_INVALID_COMMAND, & | |
| 4 | + FGOF_PROCESS_ERR_INVALID_OPTION, & | |
| 5 | + FGOF_PROCESS_MODE_ARGV, & | |
| 6 | + process_command, & | |
| 7 | + process_options, & | |
| 8 | + process_result, & | |
| 9 | + run, & | |
| 10 | + shell | |
| 11 | + implicit none | |
| 12 | + | |
| 13 | + type(process_command) :: argv_cmd | |
| 14 | + type(process_command) :: shell_cmd | |
| 15 | + type(process_options) :: opts | |
| 16 | + type(process_result) :: res | |
| 17 | + | |
| 18 | + argv_cmd%mode = FGOF_PROCESS_MODE_ARGV | |
| 19 | + argv_cmd%program = "" | |
| 20 | + allocate(character(len=1) :: argv_cmd%argv(0)) | |
| 21 | + | |
| 22 | + res = run(argv_cmd) | |
| 23 | + if (res%error_code /= FGOF_PROCESS_ERR_INVALID_COMMAND) error stop "empty argv command should be invalid" | |
| 24 | + | |
| 25 | + shell_cmd = shell("") | |
| 26 | + res = run(shell_cmd) | |
| 27 | + if (res%error_code /= FGOF_PROCESS_ERR_INVALID_COMMAND) error stop "empty shell command should be invalid" | |
| 28 | + | |
| 29 | + shell_cmd = shell("printf 'ok'") | |
| 30 | + opts%timeout_ms = -1 | |
| 31 | + res = run(shell_cmd, opts) | |
| 32 | + if (res%error_code /= FGOF_PROCESS_ERR_INVALID_OPTION) error stop "negative timeout should be invalid" | |
| 33 | +end program test_validation | |