This document proposes a Python-compatible way to execute AmigaDOS commands in PythonAmi. The public API follows real CPython functions where practical, especially os.system(), subprocess.run(), subprocess.Popen(), and shutil.which(), while keeping the first implementation feasible on Motorola 68000 systems with limited memory.
The recommended implementation strategy is incremental:
- Implement
os.system()as the smallest synchronous primitive. - Implement a restricted
subprocess.run()on top of an internal process backend. - Add output capture through temporary files or DOS pipes.
- Add a limited
subprocess.Popen()only after process handles, lifetime, and cleanup are reliable.
PythonAmi should preserve familiar Python behavior where the AmigaDOS model permits it.
import os
status = os.system("list SYS:")
if status != 0:
print("Command failed:", status)import subprocess
result = subprocess.run(
["list", "SYS:"],
capture_output=True,
text=True,
)
print(result.returncode)
print(result.stdout)Primary goals:
- Familiar APIs for Python programmers.
- Correct argument quoting for AmigaDOS.
- Predictable return-code handling.
- No hidden dependence on Unix
fork(), POSIX file descriptors, or Windows process handles. - Operation on a plain M68000 without an MMU.
- Bounded allocations and explicit cleanup on all error paths.
Non-goals for the first version:
- Full CPython
subprocesscompatibility. - Unix signals and process groups.
- Arbitrary descriptor inheritance.
- Shell pipeline emulation inside PythonAmi.
- Thread-safe concurrent process management.
os.system(command: str) -> int- Execute
commandthrough AmigaDOS. - Display command output through the PythonAmi process's current input and output streams.
- Wait until the command completes.
- Return the AmigaDOS command return code directly.
- Raise
TypeErrorifcommandis not a string. - Raise
ValueErrorifcommandcontains an embedded NUL character. - Raise
OSErrorif PythonAmi cannot start the command at all.
Unlike POSIX CPython, PythonAmi should not encode the result as a wait-status word. Returning the AmigaDOS result directly is more useful and avoids pretending that POSIX wait semantics exist. This intentional difference must be documented.
import os
rc = os.system('copy "RAM:input file" "RAM:output file"')
if rc == 0:
print("Copy completed")
elif rc == 5:
print("Warning")
elif rc == 10:
print("Error")
else:
print("DOS return code:", rc)PythonAmi should expose symbolic constants if practical:
os.RETURN_OK # 0
os.RETURN_WARN # 5
os.RETURN_ERROR # 10
os.RETURN_FAIL # 20Applications should still accept other integer return codes because programs may define their own values.
subprocess.run(
args,
*,
stdin=None,
stdout=None,
stderr=None,
capture_output=False,
shell=False,
cwd=None,
env=None,
text=False,
encoding=None,
errors=None,
timeout=None,
check=False,
) -> CompletedProcessNot every parameter needs to be supported in version 1. Unsupported combinations should raise NotImplementedError, rather than being silently ignored.
class CompletedProcess:
args: str | list[str]
returncode: int
stdout: bytes | str | None
stderr: bytes | str | None
def check_returncode(self): ...Version 1 should support:
argsas a command string.argsas a sequence of strings.- Synchronous execution.
check=True.capture_output=True.stdout=subprocess.PIPE.stderr=subprocess.PIPEwhere the OS/backend supports a separate error stream.text=Trueusing PythonAmi's default text encoding.- An explicit
encodinganderrorspolicy. cwdby temporarily changing the child command's current directory without changing PythonAmi's global current directory, if supported by the selected AmigaOS API.
Version 1 may reject:
timeout.- Custom
envmappings. stdin=subprocess.PIPE.- Asynchronous use.
shell=Falsewhen no direct executable-launch backend exists.
import subprocess
result = subprocess.run("version", capture_output=True, text=True)
print(result.stdout)import subprocess
subprocess.run(["copy", "RAM:source", "RAM:destination"], check=True)import subprocess
try:
subprocess.run(["delete", "RAM:missing-file"], check=True)
except subprocess.CalledProcessError as exc:
print("Command:", exc.cmd)
print("Return code:", exc.returncode)These can be thin wrappers around run().
def call(args, **kwargs):
return run(args, **kwargs).returncode
def check_call(args, **kwargs):
run(args, check=True, **kwargs)
return 0Proposed signatures:
subprocess.call(args, **kwargs) -> int
subprocess.check_call(args, **kwargs) -> intsubprocess.check_output(args, *, text=False, encoding=None, errors=None, **kwargs)Conceptual implementation:
def check_output(args, **kwargs):
if "stdout" in kwargs:
raise ValueError("stdout argument is not allowed")
return run(args, stdout=PIPE, check=True, **kwargs).stdoutA complete CPython-compatible Popen is expensive and should not block the first release.
A later PythonAmi version may support:
process = subprocess.Popen(
["type", "RAM:large-file"],
stdout=subprocess.PIPE,
text=True,
)
output, errors = process.communicate()
print(process.returncode)Initial restrictions may include:
- No
poll()unless an AmigaOS process-completion mechanism is installed. - No
terminate(),kill(), or signal support. - No simultaneous unbounded stdout and stderr buffering.
communicate()may internally use temporary files.- Only one active child process per interpreter in the M68000 build.
If true asynchronous execution is not implemented, constructing Popen should raise NotImplementedError. It should not secretly execute synchronously because that would violate user expectations.
A compatible executable lookup helper is valuable independently of subprocess.
shutil.which(command, mode=os.F_OK | os.X_OK, path=None) -> str | NoneFor AmigaDOS, the lookup should consider:
- An explicit path or volume-qualified name in
command. - The current directory.
- The AmigaDOS command path.
- The resident command list, if the backend can query it.
Example:
from shutil import which
copy_command = which("Copy")
if copy_command is None:
print("Copy command not found")Command-name matching should follow the filesystem and DOS rules of the running AmigaOS version. PythonAmi should not impose Unix case sensitivity.
Python programmers expect this to be safe:
subprocess.run(["copy", "RAM:my file", "RAM:backup file"])PythonAmi must convert the sequence into a correctly quoted AmigaDOS command line.
- With
args: str, pass the command string through unchanged. - With
args: sequence[str], quote each element using one centralized AmigaDOS quoting routine. - Reject empty sequences.
- Reject non-string elements.
- Reject embedded NUL characters.
subprocess.list2cmdline(args) -> strThis name exists in CPython, but PythonAmi's implementation should use AmigaDOS quoting, not Windows quoting.
For each argument:
- If it is empty, emit
"". - If it contains whitespace or AmigaDOS-special characters, wrap it in double quotes.
- Escape embedded double quotes according to the actual AmigaDOS command-line rules supported by the target OS.
- Preserve ordinary characters exactly.
- Detect overflow while calculating the output length.
Do not concatenate command strings with untrusted input. Prefer a sequence:
# Preferred
subprocess.run(["type", user_selected_file])
# Unsafe if the value contains command syntax
subprocess.run("type " + user_selected_file)The sequence form prevents accidental token splitting, but if the final command still passes through a command interpreter, PythonAmi must clearly document that it is not a complete security boundary against every shell metacharacter.
CPython uses shell=False by default. PythonAmi should preserve the parameter but define it precisely.
- The command is interpreted by AmigaDOS.
- Redirection, command separators, aliases, and resident commands may be available according to the host environment.
argsshould preferably be a string.
Preferred long-term behavior:
- Resolve an executable or command explicitly.
- Start it without allowing command separators or shell redirection to be reinterpreted.
- Pass a constructed argument string to the executable using the native process API.
If the initial backend can only invoke the DOS command interpreter, PythonAmi should either:
- support only
shell=True, or - document that sequence arguments are quoted but still interpreted by DOS.
It must not claim true shell=False isolation unless the backend actually bypasses shell parsing.
capture_output=True is equivalent to:
stdout=subprocess.PIPE
stderr=subprocess.PIPEUse temporary files for the first implementation:
- Create unique files in
T:orRAM:T/. - Open them through AmigaDOS.
- attach the handles as child output and error output;
- run the command synchronously;
- seek/read the captured output after completion;
- close and delete all temporary files in one cleanup path.
Advantages:
- Avoids pipe deadlocks.
- Avoids requiring a reader task.
- Keeps the implementation synchronous.
- Works with output larger than available memory until the final read.
Disadvantages:
- Slower than pipes.
- Requires writable temporary storage.
- Captured output still needs a size policy before conversion to a Python object.
PythonAmi should support an implementation limit to avoid exhausting memory:
subprocess.MAX_CAPTURE_SIZEThe default should be selected for the target build, not copied from desktop CPython. If output exceeds the limit, recommended behavior is:
- terminate reading;
- complete child cleanup;
- raise
subprocess.OutputLimitExceeded; - include the partial captured output if memory permits.
Silently truncating output is not recommended.
Support the real Python idiom:
result = subprocess.run(
["command"],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
)If AmigaDOS cannot provide a separate error stream for a selected launch method, PythonAmi should state that stderr=PIPE is unavailable there and allow stderr=STDOUT as the fallback.
Default behavior should match Python:
result.stdout # bytes when capture is enabledWith text mode:
result = subprocess.run(
["type", "RAM:readme"],
capture_output=True,
text=True,
encoding="latin-1",
errors="replace",
)
result.stdout # strRecommended defaults:
- Binary capture returns
bytesunchanged. text=Truedecodes bytes after the child exits.encoding=Noneuses PythonAmi's configured system encoding.errors=Noneusesstrictunless the interpreter defines a different documented default.- Newline conversion should use the same text-I/O layer as
open(..., text mode).
Avoid assuming UTF-8 on classic Amiga systems. The configured encoding may be an Amiga code page or Latin-1-compatible encoding.
Implement the real Python exception structure where practical.
class SubprocessError(Exception):
pass
class CalledProcessError(SubprocessError):
def __init__(self, returncode, cmd, output=None, stderr=None):
self.returncode = returncode
self.cmd = cmd
self.output = output
self.stdout = output
self.stderr = stderr
class TimeoutExpired(SubprocessError):
def __init__(self, cmd, timeout, output=None, stderr=None):
self.cmd = cmd
self.timeout = timeout
self.output = output
self.stdout = output
self.stderr = stderrAdditional PythonAmi-specific exception:
class OutputLimitExceeded(SubprocessError):
def __init__(self, cmd, limit, output=None, stderr=None):
self.cmd = cmd
self.limit = limit
self.output = output
self.stdout = output
self.stderr = stderrUse exceptions consistently:
- Command starts and returns a nonzero code: normal result, unless
check=True. - Command cannot be launched:
OSErroror a suitable subclass. - Executable not found in a true direct-launch path:
FileNotFoundError. - Unsupported feature:
NotImplementedError. - Invalid parameter combination:
ValueError. - Invalid argument type:
TypeError.
Do not turn every DOS nonzero result into OSError; the child did execute in that case.
A clean separation will let the same Python layer support multiple AmigaOS targets.
Python modules
os.system
subprocess.run
subprocess.call
subprocess.check_call
subprocess.check_output
|
v
Internal PythonAmi process API
_dos_exec(command, options)
_dos_wait(process)
_dos_close(process)
|
v
AmigaOS backend
DOS command execution
process creation
BPTR/file-handle conversion
current directory handling
result and I/O-error retrieval
Conceptual C structure:
struct PyAmiExecRequest {
const char *command;
BPTR input;
BPTR output;
BPTR error_output;
BPTR current_dir;
unsigned long stack_size;
unsigned long flags;
};
struct PyAmiExecResult {
long return_code;
long io_error;
unsigned long flags;
};Keep native Amiga types inside the backend. Python-visible code should not manipulate BPTR, DOS packets, message ports, or task pointers.
int
pyami_dos_execute(const struct PyAmiExecRequest *request,
struct PyAmiExecResult *result)
{
int started = 0;
/* Validate pointers and command length. */
/* Acquire or duplicate required DOS handles. */
/* Invoke selected AmigaDOS execution function. */
/* Save the command result immediately. */
/* Save IoErr() immediately where meaningful. */
/* Release every acquired resource. */
/* Return whether launch infrastructure succeeded. */
return started;
}The exact native function should be selected by the PythonAmi target matrix and verified against the relevant AmigaOS SDK. Possible backends may differ between classic AmigaOS releases and compatible systems, so this proposal intentionally keeps the public API independent of one native call.
Child commands may require more stack than the PythonAmi default.
Possible extension:
subprocess.run(args, ami_stack_size=16384)This must be a keyword-only PythonAmi extension. Validate:
- integer type;
- positive value;
- alignment required by the backend;
- upper bound that prevents overflow;
- enough room for the native launch mechanism.
A global default may also be exposed:
subprocess.DEFAULT_AMIGA_STACK_SIZEOn M68000, misaligned word or long access can fault. Native structures, temporary buffers, command-line storage, and message payloads must have suitable alignment. Do not cast arbitrary byte-buffer positions to native structure pointers.
Length calculations must detect overflow before allocation:
if (arg_len > UINT32_MAX - total_len - quote_overhead) {
PyErr_SetString(PyExc_OverflowError, "command line is too long");
return NULL;
}Also enforce the smaller command-length limit imposed by the actual DOS API.
Avoid constructing multiple full copies of:
- the argument vector;
- the quoted command line;
- captured output;
- decoded text.
Recommended sequence:
- Compute quoted command length with overflow checks.
- Allocate once.
- Write directly into the final buffer.
- Release the argument-conversion temporaries before launching.
- Read captured output in chunks.
- Decode incrementally when feasible.
A child process may corrupt shared memory if the OS does not isolate address spaces. PythonAmi must treat native handles and shared structures conservatively and should not expose backend pointers to Python code.
The base M68000 has no on-chip instruction or data cache. Optimize primarily for:
- fewer allocations;
- fewer library calls;
- sequential memory access;
- compact code;
- avoiding 32-bit division and multiplication in hot loops.
Do not make the quoting parser cryptic for tiny speed gains. Correctness and command safety are more important.
The Python parameter is:
subprocess.run(args, cwd="RAM:work")Preferred behavior:
- Lock the target directory.
- Associate that directory with the child process.
- Do not change the PythonAmi process's global current directory.
- Release the lock after the child finishes or launch fails.
If the selected OS backend cannot give the child a distinct current directory, version 1 may temporarily switch directories only in a single-threaded build:
- Lock the requested directory.
- Save the existing directory lock.
- switch to the requested directory;
- execute synchronously;
- restore the previous directory in an unconditional cleanup block;
- unlock the temporary directory.
This fallback must not be used once PythonAmi supports concurrent threads that can access the process-global current directory.
CPython accepts an env mapping. AmigaDOS environments do not necessarily map cleanly to a POSIX per-process dictionary.
Recommended staged support:
subprocess.run(args, env=None)env=Noneinherits the normal DOS environment.- Any mapping raises
NotImplementedError.
Support a mapping only if the backend can create child-local variables without mutating PythonAmi's own environment.
Never implement env by globally changing variables around an asynchronous launch. Even synchronously, global mutation is unsafe if callbacks, interrupts, or other tasks can observe it.
A reliable timeout needs:
- asynchronous child launch;
- a timer source;
- a completion message or polling mechanism;
- a defined cancellation strategy;
- cleanup when the child ignores cancellation;
- output-handle cleanup without use-after-free.
Therefore version 1 should reject it explicitly:
subprocess.run(["wait", "10"], timeout=1)
# raises NotImplementedErrorDo not accept timeout and then ignore it.
Suggested interpretation helpers:
import subprocess
result = subprocess.run(["command"])
if result.returncode == 0:
print("success")
elif result.returncode < 0:
print("PythonAmi-reserved termination status")
else:
print("AmigaDOS/program return code", result.returncode)Reserve negative values only if PythonAmi later needs to represent forced termination. Ordinary DOS command results should remain nonnegative integers.
check=True should raise for every nonzero result, matching Python's broad rule, even though AmigaDOS distinguishes warning, error, and failure levels.
PythonAmi may add an optional extension without changing the default:
subprocess.run(args, ami_check_level=10)Meaning: raise only when returncode >= 10. This should remain explicitly Amiga-specific and should not replace check=True.
subprocess.run(["type", filename])instead of:
subprocess.run("type " + filename, shell=True)- Reject NUL characters.
- Apply a native command-length limit.
- Quote empty arguments correctly.
- Test quotes, spaces, tabs, colons, slashes, wildcard characters, redirection characters, and command separators.
- Do not search writable directories unexpectedly.
- Do not silently fall back from direct execution to shell execution.
- Never place sensitive values in exception text unless they were already part of the public command representation.
Every native execution path should have one cleanup section. Track ownership explicitly for:
- command buffer;
- directory lock;
- input handle;
- output handle;
- error handle;
- temporary filenames;
- temporary-file locks;
- process structure;
- message port;
- child completion message;
- captured byte buffers;
- decoded Python strings.
Conceptual pattern:
int ok = 0;
BPTR temp_out = 0;
BPTR old_dir = 0;
char *command = NULL;
command = build_command(args);
if (command == NULL)
goto cleanup;
temp_out = open_capture_file();
if (temp_out == 0)
goto cleanup;
/* Launch and collect result. */
ok = 1;
cleanup:
if (old_dir != 0)
restore_current_dir(old_dir);
if (temp_out != 0)
Close(temp_out);
if (command != NULL)
PyMem_Free(command);
delete_owned_temp_files();
return ok;Save the native I/O error immediately after a failing DOS call. Cleanup calls may overwrite the thread/task's error value.
os.system(str)- Direct AmigaDOS return code
- Inherited console I/O
- NUL and length validation
- Basic
OSErrormapping
subprocess.run()CompletedProcess- Sequence-to-command conversion
check=Truecall()check_call()check_output()cwd
PIPESTDOUT- Temporary-file capture
- bytes/text modes
- encoding/error policies
- capture-size limit
- Restricted
Popen wait()communicate()- completion messages
- possibly
poll()
- timeouts;
- stdin pipes;
- true direct launch for
shell=False; - controlled environment mappings;
- multiple concurrent children;
- pipe-based streaming.
This pure-Python layer illustrates how public functions can share one native primitive.
# subprocess.py for PythonAmi
PIPE = -1
STDOUT = -2
DEVNULL = -3
class SubprocessError(Exception):
pass
class CalledProcessError(SubprocessError):
def __init__(self, returncode, cmd, output=None, stderr=None):
self.returncode = returncode
self.cmd = cmd
self.output = output
self.stdout = output
self.stderr = stderr
super().__init__(returncode, cmd)
def __str__(self):
return "Command %r returned non-zero status %d" % (
self.cmd,
self.returncode,
)
class CompletedProcess:
def __init__(self, args, returncode, stdout=None, stderr=None):
self.args = args
self.returncode = returncode
self.stdout = stdout
self.stderr = stderr
def check_returncode(self):
if self.returncode:
raise CalledProcessError(
self.returncode,
self.args,
output=self.stdout,
stderr=self.stderr,
)
def run(args, *, stdin=None, stdout=None, stderr=None,
capture_output=False, shell=False, cwd=None,
env=None, text=False, encoding=None, errors=None,
timeout=None, check=False):
if capture_output:
if stdout is not None or stderr is not None:
raise ValueError(
"stdout and stderr may not be used with capture_output"
)
stdout = PIPE
stderr = PIPE
if timeout is not None:
raise NotImplementedError("timeout is not implemented")
if env is not None:
raise NotImplementedError("custom env is not implemented")
command = _normalize_args(args, shell=shell)
# Native primitive returns raw bytes for captured streams.
rc, out, err = _amiga_execute(
command,
stdin=stdin,
stdout=stdout,
stderr=stderr,
cwd=cwd,
shell=shell,
)
if text:
selected_encoding = encoding or _default_system_encoding()
selected_errors = errors or "strict"
if out is not None:
out = out.decode(selected_encoding, selected_errors)
if err is not None:
err = err.decode(selected_encoding, selected_errors)
result = CompletedProcess(args, rc, out, err)
if check:
result.check_returncode()
return result
def call(args, **kwargs):
return run(args, **kwargs).returncode
def check_call(args, **kwargs):
run(args, check=True, **kwargs)
return 0
def check_output(args, **kwargs):
if "stdout" in kwargs:
raise ValueError("stdout argument is not allowed")
return run(args, stdout=PIPE, check=True, **kwargs).stdout_amiga_execute(), _normalize_args(), and _default_system_encoding() are PythonAmi internals. They should be implemented once and shared with os.system() where possible.
assert subprocess.run(["version"]).returncode == 0
assert isinstance(os.system("version"), int)Test:
- no arguments;
- one argument;
- empty argument;
- argument containing one space;
- leading/trailing spaces;
- tabs;
- embedded quote;
- volume name such as
SYS:; - path containing spaces;
- wildcard characters;
- shell metacharacters;
- non-ASCII bytes representable in the selected encoding;
- unrepresentable characters;
- embedded NUL;
- maximum accepted command length;
- one byte beyond the limit;
- integer-overflow-sized synthetic lengths where the object model permits testing.
Use helper commands or a PythonAmi test executable that returns:
- 0;
- 5;
- 10;
- 20;
- another positive code;
- the largest representable backend code.
Verify check=False, check=True, and CalledProcessError fields.
Test:
- empty stdout;
- empty stderr;
- stdout only;
- stderr only;
- interleaved output with
stderr=STDOUT; - output exactly at capture limit;
- output one byte over the limit;
- output larger than a single read buffer;
- binary output containing zero bytes;
- invalid text encoding sequences;
errors="strict",replace, andignoreif supported;- temporary-file open failure;
- read failure;
- cleanup after decode failure.
Test:
- valid directory;
- missing directory;
- path naming a file;
- inaccessible directory;
- child observes requested directory;
- parent directory remains unchanged after success;
- parent directory remains unchanged after launch failure;
- parent directory remains unchanged after a Python exception.
Under memory and handle instrumentation, inject failure after every allocation/open/lock step and verify:
- no leaked memory;
- no leaked DOS handles;
- no leaked locks;
- no remaining temporary files;
- current directory restored;
- original error preserved;
- no double-close or use-after-free.
- Run on a real or accurately emulated M68000 configuration.
- Use odd-sized command buffers and argument lengths.
- Exercise low-memory conditions.
- Test stack sizes near minimum and maximum accepted values.
- Verify no unaligned word/long accesses.
- Verify length calculation with 32-bit boundaries.
- Repeatedly execute commands to detect gradual memory loss.
For the first production-ready PythonAmi release, implement this exact subset:
os.system(command)
subprocess.run(
args,
*,
stdout=None,
stderr=None,
capture_output=False,
cwd=None,
text=False,
encoding=None,
errors=None,
check=False,
)
subprocess.call(args, **kwargs)
subprocess.check_call(args, **kwargs)
subprocess.check_output(args, **kwargs)With these explicit restrictions:
- synchronous execution only;
- inherited stdin only;
- temporary-file output capture;
- no timeout;
- no custom environment;
- no
Popenuntil asynchronous behavior is real; - direct AmigaDOS return code;
- documented capture and command-size limits;
- sequence arguments passed through a single tested AmigaDOS quoting function.
This subset gives PythonAmi useful compatibility without forcing a Unix process model onto AmigaDOS or creating a fragile pseudo-Popen implementation.