apcore-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
apcore_cli/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """apcore-cli: CLI adapter for the apcore module ecosystem."""
2
+
3
+ __version__ = "0.1.0"
apcore_cli/__main__.py ADDED
@@ -0,0 +1,142 @@
1
+ """Entry point for apcore-cli (FE-01)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ import sys
8
+
9
+ import click
10
+
11
+ from apcore_cli import __version__
12
+ from apcore_cli.cli import LazyModuleGroup, set_audit_logger
13
+ from apcore_cli.config import ConfigResolver
14
+ from apcore_cli.discovery import register_discovery_commands
15
+ from apcore_cli.security.audit import AuditLogger
16
+ from apcore_cli.shell import register_shell_commands
17
+
18
+ logger = logging.getLogger("apcore_cli")
19
+
20
+ EXIT_CONFIG_NOT_FOUND = 47
21
+
22
+
23
+ def _extract_extensions_dir(argv: list[str] | None = None) -> str | None:
24
+ """Extract --extensions-dir value from argv before Click parses it.
25
+
26
+ This is needed because the registry must be created before Click runs,
27
+ but --extensions-dir is a Click option parsed at runtime.
28
+ Returns None if the flag is not present.
29
+ """
30
+ args = argv if argv is not None else sys.argv[1:]
31
+ for i, arg in enumerate(args):
32
+ if arg == "--extensions-dir" and i + 1 < len(args):
33
+ return args[i + 1]
34
+ if arg.startswith("--extensions-dir="):
35
+ return arg.split("=", 1)[1]
36
+ return None
37
+
38
+
39
+ def create_cli(extensions_dir: str | None = None) -> click.Group:
40
+ """Create the CLI application.
41
+
42
+ Args:
43
+ extensions_dir: Override for extensions directory.
44
+ When None, resolves via ConfigResolver (env/file/default).
45
+ """
46
+ if extensions_dir is not None:
47
+ ext_dir = extensions_dir
48
+ else:
49
+ config = ConfigResolver()
50
+ ext_dir = config.resolve(
51
+ "extensions.root",
52
+ cli_flag="--extensions-dir",
53
+ env_var="APCORE_EXTENSIONS_ROOT",
54
+ )
55
+
56
+ ext_dir_missing = not os.path.exists(ext_dir)
57
+ ext_dir_unreadable = not ext_dir_missing and not os.access(ext_dir, os.R_OK)
58
+
59
+ if ext_dir_missing:
60
+ click.echo(
61
+ f"Error: Extensions directory not found: '{ext_dir}'. " "Set APCORE_EXTENSIONS_ROOT or verify the path.",
62
+ err=True,
63
+ )
64
+ sys.exit(EXIT_CONFIG_NOT_FOUND)
65
+
66
+ if ext_dir_unreadable:
67
+ click.echo(
68
+ f"Error: Cannot read extensions directory: '{ext_dir}'. Check permissions.",
69
+ err=True,
70
+ )
71
+ sys.exit(EXIT_CONFIG_NOT_FOUND)
72
+
73
+ try:
74
+ from apcore import Executor, Registry
75
+
76
+ registry = Registry(extensions_dir=ext_dir)
77
+ try:
78
+ logger.debug("Loading extensions from %s", ext_dir)
79
+ count = registry.discover()
80
+ logger.info("Initialized apcore-cli with %d modules.", count)
81
+ except Exception as e:
82
+ logger.warning("Discovery failed: %s", e)
83
+
84
+ executor = Executor(registry)
85
+ except Exception as e:
86
+ click.echo(f"Error: Failed to initialize registry: {e}", err=True)
87
+ sys.exit(EXIT_CONFIG_NOT_FOUND)
88
+
89
+ # Initialize audit logger
90
+ try:
91
+ audit_logger = AuditLogger()
92
+ set_audit_logger(audit_logger)
93
+ except Exception as e:
94
+ logger.warning("Failed to initialize audit logger: %s", e)
95
+
96
+ @click.group(
97
+ cls=LazyModuleGroup,
98
+ registry=registry,
99
+ executor=executor,
100
+ name="apcore-cli",
101
+ help="CLI adapter for the apcore module ecosystem.",
102
+ )
103
+ @click.version_option(
104
+ version=__version__,
105
+ prog_name="apcore-cli",
106
+ )
107
+ @click.option(
108
+ "--extensions-dir",
109
+ "extensions_dir_opt",
110
+ default=None,
111
+ help="Path to apcore extensions directory.",
112
+ )
113
+ @click.option(
114
+ "--log-level",
115
+ default=None,
116
+ type=click.Choice(["DEBUG", "INFO", "WARN", "ERROR"], case_sensitive=False),
117
+ help="Log level.",
118
+ )
119
+ @click.pass_context
120
+ def cli(ctx: click.Context, extensions_dir_opt: str | None = None, log_level: str | None = None) -> None:
121
+ ctx.ensure_object(dict)
122
+ ctx.obj["extensions_dir"] = ext_dir
123
+
124
+ # Register discovery commands
125
+ register_discovery_commands(cli, registry)
126
+
127
+ # Register shell integration commands
128
+ register_shell_commands(cli)
129
+
130
+ return cli
131
+
132
+
133
+ def main() -> None:
134
+ """Main entry point for apcore-cli."""
135
+ # Extract --extensions-dir from argv before Click parses
136
+ ext_dir = _extract_extensions_dir()
137
+ cli = create_cli(extensions_dir=ext_dir)
138
+ cli(standalone_mode=True)
139
+
140
+
141
+ if __name__ == "__main__":
142
+ main()
@@ -0,0 +1,25 @@
1
+ """Entry point for sandboxed module execution (FE-05)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import sys
8
+
9
+
10
+ def main() -> None:
11
+ module_id = sys.argv[1]
12
+ input_data = json.loads(sys.stdin.read())
13
+ extensions_root = os.environ.get("APCORE_EXTENSIONS_ROOT", "./extensions")
14
+
15
+ from apcore import Executor, Registry
16
+
17
+ registry = Registry(extensions_dir=extensions_root)
18
+ registry.discover()
19
+ executor = Executor(registry)
20
+ result = executor.call(module_id, input_data)
21
+ json.dump(result, sys.stdout)
22
+
23
+
24
+ if __name__ == "__main__":
25
+ main()
apcore_cli/approval.py ADDED
@@ -0,0 +1,167 @@
1
+ """Approval Gate — TTY-aware HITL approval (FE-03)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ import sys
8
+ import threading
9
+ from typing import Any
10
+
11
+ import click
12
+
13
+ logger = logging.getLogger("apcore_cli.approval")
14
+
15
+
16
+ class ApprovalTimeoutError(Exception):
17
+ """Raised when the approval prompt times out."""
18
+
19
+ pass
20
+
21
+
22
+ def _get_annotation(annotations: Any, key: str, default: Any = None) -> Any:
23
+ """Get an annotation value from either a dict or a ModuleAnnotations object."""
24
+ if isinstance(annotations, dict):
25
+ return annotations.get(key, default)
26
+ return getattr(annotations, key, default)
27
+
28
+
29
+ def check_approval(module_def: Any, auto_approve: bool, ctx: click.Context) -> None:
30
+ """Check if module requires approval and handle accordingly.
31
+
32
+ Returns None if approved (or approval not required).
33
+ Calls sys.exit(46) if denied/timed out/pending.
34
+ """
35
+ annotations = getattr(module_def, "annotations", None)
36
+ if annotations is None or (not isinstance(annotations, dict) and not hasattr(annotations, "requires_approval")):
37
+ return
38
+
39
+ requires = _get_annotation(annotations, "requires_approval", False)
40
+ if requires is not True:
41
+ return
42
+
43
+ module_id = getattr(module_def, "module_id", getattr(module_def, "canonical_id", "unknown"))
44
+
45
+ # Bypass: --yes flag (highest priority)
46
+ if auto_approve is True:
47
+ logger.info("Approval bypassed via --yes flag for module '%s'.", module_id)
48
+ return
49
+
50
+ # Bypass: APCORE_CLI_AUTO_APPROVE env var
51
+ env_val = os.environ.get("APCORE_CLI_AUTO_APPROVE", "")
52
+ if env_val == "1":
53
+ logger.info(
54
+ "Approval bypassed via APCORE_CLI_AUTO_APPROVE for module '%s'.",
55
+ module_id,
56
+ )
57
+ return
58
+ if env_val != "" and env_val != "1":
59
+ logger.warning(
60
+ "APCORE_CLI_AUTO_APPROVE is set to '%s', expected '1'. Ignoring.",
61
+ env_val,
62
+ )
63
+
64
+ # Non-TTY check
65
+ is_tty = sys.stdin.isatty()
66
+ if not is_tty:
67
+ click.echo(
68
+ f"Error: Module '{module_id}' requires approval but no interactive "
69
+ "terminal is available. Use --yes or set APCORE_CLI_AUTO_APPROVE=1 "
70
+ "to bypass.",
71
+ err=True,
72
+ )
73
+ logger.error(
74
+ "Non-interactive environment, no bypass provided for module '%s'.",
75
+ module_id,
76
+ )
77
+ sys.exit(46)
78
+
79
+ # TTY prompt
80
+ _prompt_with_timeout(module_def, timeout=60)
81
+
82
+
83
+ def _prompt_with_timeout(module_def: Any, timeout: int = 60) -> None:
84
+ """Display approval prompt with timeout."""
85
+ # Clamp timeout to valid range
86
+ timeout = max(1, min(timeout, 3600))
87
+
88
+ module_id = getattr(module_def, "module_id", getattr(module_def, "canonical_id", "unknown"))
89
+ annotations = getattr(module_def, "annotations", None) or {}
90
+ message = _get_annotation(annotations, "approval_message", None)
91
+ if message is None:
92
+ message = f"Module '{module_id}' requires approval to execute."
93
+
94
+ click.echo(message, err=True)
95
+
96
+ if sys.platform != "win32":
97
+ # Unix: use SIGALRM for timeout
98
+ _prompt_unix(module_id, timeout)
99
+ else:
100
+ # Windows: use threading.Timer for timeout
101
+ _prompt_windows(module_id, timeout)
102
+
103
+
104
+ def _prompt_unix(module_id: str, timeout: int) -> None:
105
+ """Unix approval prompt using SIGALRM."""
106
+ import signal
107
+
108
+ def _timeout_handler(signum: int, frame: Any) -> None:
109
+ raise ApprovalTimeoutError()
110
+
111
+ old_handler = signal.signal(signal.SIGALRM, _timeout_handler)
112
+ signal.alarm(timeout)
113
+
114
+ try:
115
+ approved = click.confirm("Proceed?", default=False)
116
+ signal.alarm(0)
117
+ signal.signal(signal.SIGALRM, old_handler)
118
+
119
+ if approved:
120
+ logger.info("User approved execution of module '%s'.", module_id)
121
+ return
122
+ else:
123
+ logger.warning("Approval rejected by user for module '%s'.", module_id)
124
+ click.echo("Error: Approval denied.", err=True)
125
+ sys.exit(46)
126
+ except ApprovalTimeoutError:
127
+ signal.signal(signal.SIGALRM, old_handler)
128
+ logger.warning("Approval timed out after %ds for module '%s'.", timeout, module_id)
129
+ click.echo(
130
+ f"Error: Approval prompt timed out after {timeout} seconds.",
131
+ err=True,
132
+ )
133
+ sys.exit(46)
134
+
135
+
136
+ def _prompt_windows(module_id: str, timeout: int) -> None:
137
+ """Windows approval prompt using threading.Timer + ctypes."""
138
+ import ctypes
139
+
140
+ def _interrupt_main() -> None:
141
+ ctypes.pythonapi.PyThreadState_SetAsyncExc(
142
+ ctypes.c_ulong(threading.main_thread().ident),
143
+ ctypes.py_object(ApprovalTimeoutError),
144
+ )
145
+
146
+ timer = threading.Timer(timeout, _interrupt_main)
147
+ timer.start()
148
+
149
+ try:
150
+ approved = click.confirm("Proceed?", default=False)
151
+ timer.cancel()
152
+
153
+ if approved:
154
+ logger.info("User approved execution of module '%s'.", module_id)
155
+ return
156
+ else:
157
+ logger.warning("Approval rejected by user for module '%s'.", module_id)
158
+ click.echo("Error: Approval denied.", err=True)
159
+ sys.exit(46)
160
+ except ApprovalTimeoutError:
161
+ timer.cancel()
162
+ logger.warning("Approval timed out after %ds for module '%s'.", timeout, module_id)
163
+ click.echo(
164
+ f"Error: Approval prompt timed out after {timeout} seconds.",
165
+ err=True,
166
+ )
167
+ sys.exit(46)
apcore_cli/cli.py ADDED
@@ -0,0 +1,315 @@
1
+ """Core Dispatcher — CLI entry point and module routing (FE-01)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import re
8
+ import sys
9
+ import time
10
+ from typing import TYPE_CHECKING, Any
11
+
12
+ import click
13
+ import jsonschema
14
+
15
+ from apcore_cli.approval import check_approval
16
+ from apcore_cli.output import format_exec_result
17
+ from apcore_cli.ref_resolver import resolve_refs
18
+ from apcore_cli.schema_parser import reconvert_enum_values, schema_to_click_options
19
+ from apcore_cli.security.sandbox import Sandbox
20
+
21
+ if TYPE_CHECKING:
22
+ from apcore import Executor, Registry
23
+ from apcore.registry.types import ModuleDescriptor
24
+
25
+ from apcore_cli.security.audit import AuditLogger
26
+
27
+ logger = logging.getLogger("apcore_cli.cli")
28
+
29
+ BUILTIN_COMMANDS = ["exec", "list", "describe", "completion", "man"]
30
+
31
+ # Module-level audit logger, set during CLI init
32
+ _audit_logger: AuditLogger | None = None
33
+
34
+
35
+ def set_audit_logger(audit_logger: AuditLogger) -> None:
36
+ """Set the global audit logger instance."""
37
+ global _audit_logger
38
+ _audit_logger = audit_logger
39
+
40
+
41
+ class LazyModuleGroup(click.Group):
42
+ """Custom Click Group that lazily loads apcore modules as subcommands."""
43
+
44
+ def __init__(self, registry: Registry, executor: Executor, **kwargs: Any) -> None:
45
+ super().__init__(**kwargs)
46
+ self._registry = registry
47
+ self._executor = executor
48
+ self._module_cache: dict[str, click.Command] = {}
49
+
50
+ def list_commands(self, ctx: click.Context) -> list[str]:
51
+ builtin = list(BUILTIN_COMMANDS)
52
+ try:
53
+ module_ids = self._registry.list()
54
+ except Exception:
55
+ logger.warning("Failed to list modules from registry")
56
+ module_ids = []
57
+ return sorted(set(builtin + module_ids))
58
+
59
+ def get_command(self, ctx: click.Context, cmd_name: str) -> click.Command | None:
60
+ # Check built-in commands first
61
+ if cmd_name in self.commands:
62
+ return self.commands[cmd_name]
63
+
64
+ # Check cache
65
+ if cmd_name in self._module_cache:
66
+ return self._module_cache[cmd_name]
67
+
68
+ # Look up in registry
69
+ module_def = self._registry.get_definition(cmd_name)
70
+ if module_def is None:
71
+ return None
72
+
73
+ cmd = build_module_command(module_def, self._executor)
74
+ self._module_cache[cmd_name] = cmd
75
+ return cmd
76
+
77
+
78
+ # Error code mapping from apcore error codes to CLI exit codes
79
+ _ERROR_CODE_MAP = {
80
+ "MODULE_NOT_FOUND": 44,
81
+ "MODULE_LOAD_ERROR": 44,
82
+ "MODULE_DISABLED": 44,
83
+ "SCHEMA_VALIDATION_ERROR": 45,
84
+ "SCHEMA_CIRCULAR_REF": 48,
85
+ "APPROVAL_DENIED": 46,
86
+ "APPROVAL_TIMEOUT": 46,
87
+ "APPROVAL_PENDING": 46,
88
+ "CONFIG_NOT_FOUND": 47,
89
+ "CONFIG_INVALID": 47,
90
+ "MODULE_EXECUTE_ERROR": 1,
91
+ "MODULE_TIMEOUT": 1,
92
+ "ACL_DENIED": 77,
93
+ }
94
+
95
+
96
+ def _get_module_id(module_def: ModuleDescriptor) -> str:
97
+ """Get the canonical module ID, falling back to module_id."""
98
+ cid = getattr(module_def, "canonical_id", None)
99
+ if isinstance(cid, str):
100
+ return cid
101
+ return module_def.module_id
102
+
103
+
104
+ def build_module_command(module_def: ModuleDescriptor, executor: Executor) -> click.Command:
105
+ """Build a Click command from an apcore module definition.
106
+
107
+ Generates Click options from the module's input_schema, wires up
108
+ STDIN input collection, schema validation, approval gating,
109
+ execution, audit logging, and output formatting.
110
+ """
111
+ # Resolve $refs and generate Click options from input_schema
112
+ raw_schema = getattr(module_def, "input_schema", None)
113
+ module_id = _get_module_id(module_def)
114
+
115
+ # Defensively convert Pydantic model class to dict
116
+ if raw_schema is None:
117
+ input_schema: dict = {}
118
+ elif isinstance(raw_schema, dict):
119
+ input_schema = raw_schema
120
+ elif hasattr(raw_schema, "model_json_schema"):
121
+ # Pydantic v2 BaseModel class
122
+ input_schema = raw_schema.model_json_schema()
123
+ elif hasattr(raw_schema, "schema"):
124
+ # Pydantic v1 BaseModel class
125
+ input_schema = raw_schema.schema()
126
+ else:
127
+ input_schema = {}
128
+
129
+ if input_schema.get("properties"):
130
+ try:
131
+ resolved_schema = resolve_refs(input_schema, max_depth=32, module_id=module_id)
132
+ except SystemExit:
133
+ raise
134
+ except Exception:
135
+ resolved_schema = input_schema
136
+ else:
137
+ resolved_schema = input_schema
138
+
139
+ schema_options = schema_to_click_options(resolved_schema)
140
+
141
+ def callback(**kwargs: Any) -> None:
142
+ # Separate built-in options from schema-generated kwargs
143
+ stdin_input = kwargs.pop("input", None)
144
+ auto_approve = kwargs.pop("yes", False)
145
+ large_input = kwargs.pop("large_input", False)
146
+ output_format = kwargs.pop("format", None)
147
+ sandbox_flag = kwargs.pop("sandbox", False)
148
+
149
+ merged: dict[str, Any] = {}
150
+ try:
151
+ # 1. Collect and merge input (STDIN + CLI flags)
152
+ merged = collect_input(stdin_input, kwargs, large_input)
153
+
154
+ # 2. Reconvert enum values to original types
155
+ merged = reconvert_enum_values(merged, schema_options)
156
+
157
+ # 3. Validate against schema (if schema has properties)
158
+ if resolved_schema.get("properties"):
159
+ try:
160
+ jsonschema.validate(merged, resolved_schema)
161
+ except jsonschema.ValidationError as ve:
162
+ click.echo(
163
+ f"Error: Validation failed for '{ve.path}': {ve.message}.",
164
+ err=True,
165
+ )
166
+ sys.exit(45)
167
+
168
+ # 4. Check approval gate
169
+ check_approval(module_def, auto_approve, click.get_current_context())
170
+
171
+ # 5. Execute with timing (optionally sandboxed)
172
+ audit_start = time.monotonic()
173
+ sandbox = Sandbox(enabled=sandbox_flag)
174
+ result = sandbox.execute(module_id, merged, executor)
175
+ duration_ms = int((time.monotonic() - audit_start) * 1000)
176
+
177
+ # 6. Audit log (success)
178
+ if _audit_logger is not None:
179
+ _audit_logger.log_execution(module_id, merged, "success", 0, duration_ms)
180
+
181
+ # 7. Format and print result
182
+ format_exec_result(result, output_format)
183
+
184
+ except KeyboardInterrupt:
185
+ click.echo("Execution cancelled.", err=True)
186
+ sys.exit(130)
187
+ except SystemExit:
188
+ raise
189
+ except Exception as e:
190
+ error_code = getattr(e, "code", None)
191
+ exit_code = _ERROR_CODE_MAP.get(error_code, 1)
192
+
193
+ # Audit log (error)
194
+ if _audit_logger is not None:
195
+ _audit_logger.log_execution(module_id, merged, "error", exit_code, 0)
196
+
197
+ click.echo(f"Error: {e}", err=True)
198
+ sys.exit(exit_code)
199
+
200
+ # Build the command with schema-generated options + built-in options
201
+ cmd = click.Command(
202
+ name=module_id,
203
+ help=module_def.description,
204
+ callback=callback,
205
+ )
206
+
207
+ # Add built-in options
208
+ cmd.params.append(
209
+ click.Option(
210
+ ["--input"],
211
+ default=None,
212
+ help="Read input from file or STDIN ('-').",
213
+ )
214
+ )
215
+ cmd.params.append(
216
+ click.Option(
217
+ ["--yes", "-y"],
218
+ is_flag=True,
219
+ default=False,
220
+ help="Bypass approval prompts.",
221
+ )
222
+ )
223
+ cmd.params.append(
224
+ click.Option(
225
+ ["--large-input"],
226
+ is_flag=True,
227
+ default=False,
228
+ help="Allow STDIN input larger than 10MB.",
229
+ )
230
+ )
231
+ cmd.params.append(
232
+ click.Option(
233
+ ["--format"],
234
+ type=click.Choice(["json", "table"]),
235
+ default=None,
236
+ help="Output format.",
237
+ )
238
+ )
239
+ cmd.params.append(
240
+ click.Option(
241
+ ["--sandbox"],
242
+ is_flag=True,
243
+ default=False,
244
+ help="Run module in subprocess sandbox.",
245
+ )
246
+ )
247
+
248
+ # Add schema-generated options
249
+ cmd.params.extend(schema_options)
250
+
251
+ return cmd
252
+
253
+
254
+ def validate_module_id(module_id: str) -> None:
255
+ """Validate module ID format and length."""
256
+ if len(module_id) > 128:
257
+ click.echo(
258
+ f"Error: Invalid module ID format: '{module_id}'. Maximum length is 128 characters.",
259
+ err=True,
260
+ )
261
+ sys.exit(2)
262
+ if not re.fullmatch(r"[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*", module_id):
263
+ click.echo(
264
+ f"Error: Invalid module ID format: '{module_id}'.",
265
+ err=True,
266
+ )
267
+ sys.exit(2)
268
+
269
+
270
+ def collect_input(
271
+ stdin_flag: str | None,
272
+ cli_kwargs: dict[str, Any],
273
+ large_input: bool = False,
274
+ ) -> dict[str, Any]:
275
+ """Collect and merge input from STDIN and CLI flags."""
276
+ # Remove None values from CLI kwargs
277
+ cli_kwargs_non_none = {k: v for k, v in cli_kwargs.items() if v is not None}
278
+
279
+ if not stdin_flag:
280
+ return cli_kwargs_non_none
281
+
282
+ if stdin_flag == "-":
283
+ raw = sys.stdin.read()
284
+ raw_size = len(raw.encode("utf-8"))
285
+
286
+ if raw_size > 10_485_760 and not large_input:
287
+ click.echo(
288
+ "Error: STDIN input exceeds 10MB limit. Use --large-input to override.",
289
+ err=True,
290
+ )
291
+ sys.exit(2)
292
+
293
+ if not raw or raw_size == 0:
294
+ stdin_data: dict[str, Any] = {}
295
+ else:
296
+ try:
297
+ stdin_data = json.loads(raw)
298
+ except json.JSONDecodeError as e:
299
+ click.echo(
300
+ f"Error: STDIN does not contain valid JSON: {e.msg}.",
301
+ err=True,
302
+ )
303
+ sys.exit(2)
304
+
305
+ if not isinstance(stdin_data, dict):
306
+ click.echo(
307
+ f"Error: STDIN JSON must be an object, got {type(stdin_data).__name__}.",
308
+ err=True,
309
+ )
310
+ sys.exit(2)
311
+
312
+ # CLI flags override STDIN for duplicate keys
313
+ return {**stdin_data, **cli_kwargs_non_none}
314
+
315
+ return cli_kwargs_non_none