Make required params positional for auto-generated airflowctl commands - #64812

Closed
shivaam wants to merge 1 commit into
apache:mainfrom
shivaam:fix/positional-params-airflowctl-60142
Closed

Make required params positional for auto-generated airflowctl commands#64812
shivaam wants to merge 1 commit into
apache:mainfrom
shivaam:fix/positional-params-airflowctl-60142

Conversation

@shivaam

@shivaamshivaam commented Apr 6, 2026

Copy link
Copy Markdown
Contributor

Required non-boolean parameters in auto-generated CLI commands are now positional instead of flag-style, improving UX consistency with manually defined commands like dags pause.

Before:

airflowctl connections create --connection-id="test" --conn-type="mysql" --password=secret
airflowctl dags get --dag-id=example_bash_operator

After:

airflowctl connections create test mysql --password=secret
airflowctl dags get example_bash_operator

Changes

  • Track which operation parameters have default values during AST parsing
  • For primitive params, use the default info to decide positional vs flag
  • For Pydantic model fields, use is_required() to decide positional vs flag
  • Use _UNSET for arg_dest default to avoid argparse crash on positional args
  • Skip the has_default metadata key at runtime when mapping CLI args to method params
  • Updated unit tests and integration test commands to match new positional syntax
  • Added 2 new tests for positional arg behavior and default detection
  • Added newsfragment for the breaking change

Notes

  • _create_arg stays a dumb pass-through builder — all positional/flag logic is in the callers via one-liner conditionals
  • Booleans are never positional (they need --flag/--no-flag)
  • No special-casing for specific operations
  • Breaking change: existing --flag=value syntax for required params will no longer work. Since airflowctl is new and not yet stable, this should be acceptable.

Open question

Posted a clarifying question on the issue about whether all required params should be positional (e.g., xcom add has 5 positional args), or if we should limit it.

closes: #60142


Was generative AI tooling used to co-author this PR?
  • Yes — Claude Code (Claude Opus 4.6)

Generated-by: Claude Code (Claude Opus 4.6) following the guidelines

@bugraoz93bugraoz93 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the PR! I addressed your question. Could you please amend it to be both optional and positional, live side by side?
This way, we won't break anything while working, but rather add Airflow CLI positional arguments to improve parity and make migrations easier.

@@ -0,0 +1 @@
Required non-boolean parameters in auto-generated airflowctl commands are now positional instead of flag-style (e.g., ``airflowctl dags get example_dag`` instead of ``airflowctl dags get --dag-id=example_dag``).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This news fragment is for Airflow Core and not related to anything within airflowctl flow.
Since we are still in 0.x, significant changes are also expected at a certain level.
We are logging these things in the release notes

https://airflow.apache.org/docs/apache-airflow-ctl/stable/release_notes.html#significant-changes

@kaxil
kaxil requested a review from CopilotApril 10, 2026 19:55

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Note

Copilot was unable to run its full agentic suite in this review.

Updates auto-generated airflowctl commands so required non-boolean parameters are positional (UX aligned with manually-defined commands), and adjusts parsing/tests to support the breaking CLI syntax change.

Changes:

  • Track whether operation parameters have defaults during AST parsing and use it to decide positional vs flag args for primitive params.
  • Use Pydantic model_fields[].is_required() to decide positional vs flag args for datamodel fields (booleans remain flags).
  • Update unit/integration tests and add a newsfragment documenting the breaking change.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.

Show a summary per file
FileDescription
airflow-ctl/tests/airflow_ctl/ctl/test_cli_config.pyUpdates assertions for positional args; adds tests for positional behavior and default detection.
airflow-ctl/src/airflowctl/ctl/cli_config.pyAdds has_default metadata via AST parsing; generates positional args for required params; skips metadata during arg mapping and invocation.
airflow-ctl-tests/tests/airflowctl_tests/test_config_sensitive_masking.pyUpdates integration test commands to new positional syntax.
airflow-ctl-tests/tests/airflowctl_tests/test_airflowctl_commands.pyUpdates end-to-end command list to match new positional syntax across many commands.
airflow-core/newsfragments/64812.significant.rstDocuments the breaking CLI change for auto-generated commands.

if arg_name != "self":
args.append({arg_name: arg_type})
has_default = idx >= first_default_index
args.append({arg_name: arg_type, "has_default": has_default})

CopilotAIApr 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The parameters entries are now shaped as a dict with a dynamic key for the param name plus a reserved has_default key. This forces downstream code to iterate parameter.items() and special-case parameter_key == \"has_default\", which is brittle and harder to extend (and also makes collisions with a real param named has_default difficult to reason about). Consider switching to a stable structure like { \"name\": ..., \"type\": ..., \"has_default\": ... } for each parameter so consumers can access fields directly without item-iteration + skips.

Suggested change
args.append({arg_name: arg_type, "has_default": has_default})
args.append(
{"name": arg_name, "type": arg_type, "has_default": has_default}
)

Copilot uses AI. Check for mistakes.
Comment on lines +579 to +590
args = []
for parameter in operation.get("parameters"):
for parameter_key, parameter_type in parameter.items():
if parameter_key == "has_default":
continue
if self._is_primitive_type(type_name=parameter_type):
is_bool = parameter_type == "bool"
is_positional = not is_bool and not parameter.get("has_default", False)
sanitized_key = self._sanitize_arg_parameter_key(parameter_key)
args.append(
self._create_arg(
arg_flags=("--" + self._sanitize_arg_parameter_key(parameter_key),),
arg_flags=(parameter_key,) if is_positional else ("--" + sanitized_key,),

CopilotAIApr 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Related to the parameter-shape issue: the for parameter_key, parameter_type in parameter.items() loop plus if parameter_key == \"has_default\": continue is an avoidable control-flow hack that spreads across multiple call sites (also in _get_func). If you keep the current shape, a safer alternative is to store metadata under a dedicated nested key (e.g. {\"name\": ..., \"type\": ..., \"meta\": {\"has_default\": ...}}) or move metadata out of the per-parameter dict entirely so this loop can be simplified and cannot accidentally skip real parameters.

Copilot uses AI. Check for mistakes.
Comment on lines +566 to +598
def test_has_default_detection_in_ast_parsing(self):
"""Test that AST parsing correctly detects which params have defaults."""
from textwrap import dedent

temp_file = "test_has_default.py"
with open(temp_file, "w") as f:
f.write(
dedent("""
class TestOperations(BaseOperations):
def get(self, required_id: str) -> str | ServerResponseError:
pass
def search(self, query: str, limit: int = 10, offset: int = 0) -> str | ServerResponseError:
pass
""")
)

try:
command_factory = CommandFactory(file_path=temp_file)
for op in command_factory.operations:
if op["name"] == "get":
param = op["parameters"][0]
assert param.get("has_default") is False
elif op["name"] == "search":
query_param = op["parameters"][0]
assert query_param.get("has_default") is False
limit_param = op["parameters"][1]
assert limit_param.get("has_default") is True
offset_param = op["parameters"][2]
assert offset_param.get("has_default") is True
finally:
import os

os.remove(temp_file)

CopilotAIApr 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test writes a fixed filename (test_has_default.py) into the working directory, which can conflict under parallel test runs or when the CWD is not writable. Use pytest’s tmp_path/tmp_path_factory (or tempfile.NamedTemporaryFile) to create an isolated temporary file path, and open it with an explicit encoding (e.g. encoding=\"utf-8\") to match the production file-read behavior.

Suggested change
deftest_has_default_detection_in_ast_parsing(self):
"""Test that AST parsing correctly detects which params have defaults."""
fromtextwrapimportdedent
temp_file="test_has_default.py"
withopen(temp_file, "w") asf:
f.write(
dedent("""
classTestOperations(BaseOperations):
defget(self, required_id: str) ->str|ServerResponseError:
pass
defsearch(self, query: str, limit: int=10, offset: int=0) ->str|ServerResponseError:
pass
""")
)
try:
command_factory=CommandFactory(file_path=temp_file)
foropincommand_factory.operations:
ifop["name"] =="get":
param=op["parameters"][0]
assertparam.get("has_default") isFalse
elifop["name"] =="search":
query_param=op["parameters"][0]
assertquery_param.get("has_default") isFalse
limit_param=op["parameters"][1]
assertlimit_param.get("has_default") isTrue
offset_param=op["parameters"][2]
assertoffset_param.get("has_default") isTrue
finally:
importos
os.remove(temp_file)
deftest_has_default_detection_in_ast_parsing(self, tmp_path):
"""Test that AST parsing correctly detects which params have defaults."""
fromtextwrapimportdedent
temp_file=tmp_path/"test_has_default.py"
temp_file.write_text(
dedent("""
classTestOperations(BaseOperations):
defget(self, required_id: str) ->str|ServerResponseError:
pass
defsearch(self, query: str, limit: int=10, offset: int=0) ->str|ServerResponseError:
pass
"""),
encoding="utf-8",
)
command_factory=CommandFactory(file_path=str(temp_file))
foropincommand_factory.operations:
ifop["name"] =="get":
param=op["parameters"][0]
assertparam.get("has_default") isFalse
elifop["name"] =="search":
query_param=op["parameters"][0]
assertquery_param.get("has_default") isFalse
limit_param=op["parameters"][1]
assertlimit_param.get("has_default") isTrue
offset_param=op["parameters"][2]
assertoffset_param.get("has_default") isTrue

Copilot uses AI. Check for mistakes.
@bugraoz93

Copy link
Copy Markdown
Contributor

Moving to draft to manage release better, please make it ready again when you think ready for maintainers review

@bugraoz93
bugraoz93 marked this pull request as draft April 12, 2026 15:20
@shivaam

shivaam commented Apr 12, 2026

Copy link
Copy Markdown
ContributorAuthor

Moving to draft to manage release better, please make it ready again when you think ready for maintainers review
@bugraoz93!

Sorry I replied on the issue instead of the PR. argparse doesn't natively support this. Below I have listed some options.


I looked into supporting both positional and --flag styles for required params. Unfortunately argparse doesn't natively support this — a parameter is either positional (dag_id) or a flag (--dag-id), never both.

Why it's not straightforward:

The closest workaround is registering all params as --flags, adding a nargs='*' catch-all for bare values, and using parse_intermixed_args to map positionals into unfilled flag slots after parsing. More about this at the bottom of this comment. But this means we lose argparse's built-in validation, type checking, help formatting, and required=True for those params — we'd essentially reimplement positional argument handling ourselves on top of argparse.

Options:

  1. Keep it positional for required params (current PR) — the simplest, most conventional choice. Required params are positional, optional params are flags. All argparse features work, clean help output.

  2. Two-pass parsing — maintain two parsers per command: one with required params as positional, one with them as --flags required=True. Try positional first, fall back to flags. Users can use either style, just not mixed in the same invocation:

    # both work:
    airflowctl xcom add my_dag my_task my_key
    airflowctl xcom add --dag-id=my_dag --task-id=my_task --key=my_key
    # but not mixed:
    airflowctl xcom add my_dag --task-id=my_task my_key # would fail

    No argparse hacks needed (both parsers are standard), but doubles the parser setup and mixed usage isn't supported.

  3. Cap positional count — first 1-2 required params (like dag_id) are positional, rest stay as --flag required=True. Common CLI pattern (like git checkout <branch> --force). No hacks, all argparse features work.

I'd recommend Option 1 for simplicity. Happy to go with whichever you prefer!


Details on the parse_intermixed_args workaround (and why I didn't recommend it)

The idea is to register all required params as --flags only (no argparse positionals), add a single nargs='*' argument to collect any bare values, then map those bare values into whichever flag slots the user didn't fill:

PARAM_ORDER= ['dag_id', 'task_id', 'key']
parser.add_argument('--dag-id', dest='dag_id', default=None)
parser.add_argument('--task-id', dest='task_id', default=None)
parser.add_argument('--key', default=None)
parser.add_argument('positionals', nargs='*')
args=parser.parse_intermixed_args(argv)
# map bare values into unfilled flag slots, left to rightunfilled= [dfordinPARAM_ORDERifgetattr(args, d) isNone]
fori, valinenumerate(args.positionals):
setattr(args, unfilled[i], val)

parse_intermixed_args (Python 3.7+) allows flags and bare values to be freely interspersed, so all of these would work:

airflowctl xcom add d t k # all positional
airflowctl xcom add --dag-id=d --task-id=t --key=k # all flags
airflowctl xcom add d --task-id=t k # mixed
airflowctl xcom add --task-id=t my_dag my_key # gap-filling

The problem is what we lose:

  • Validation — can't use required=True on flags (argparse would reject positional-only usage), so we validate manually after gap-filling
  • Type checkingtype=int, choices=[...] etc. don't apply to the nargs='*' catch-all, so we type-check manually
  • Help output — shows [positionals ...] instead of named params like DAG_ID TASK_ID, and no indication which flags are required vs optional
  • argparse.REMAINDER and mutually exclusive groups with positionals — unsupported with parse_intermixed_args

This effectively means using argparse for flag parsing only and reimplementing positional handling on top of it.

@bugraoz93

Copy link
Copy Markdown
Contributor

@shivaam, this will make things a bit complicated. The two-pass parsing is still complex. I would say before we finish adding the command to the parser, we can reprocess the args, maybe even in get_parser, which can also be responsible for parsing the command. We may need to update the building part a bit but it should be possible with reprocessing

Even though we are still around 0.x.x, still thinking about how we could implement this in the best position. As it will be a user-facing change, we need to create a devlist discussion and ask to only keep positional for required or maintain both.

My idea is adding both can save us from the entire discussion flow and we could be backwards compatible, even though this is not trivial with argparase in Python.

@bugraoz93

Copy link
Copy Markdown
Contributor

Something like this
#65261

@shivaam

Copy link
Copy Markdown
ContributorAuthor

Closing in favor of #65261 which takes a better approach — supporting both positional and flag styles simultaneously via argv pre-processing, so there's no breaking change for existing users. Thanks @bugraoz93 for the guidance!

@shivaamshivaam closed this Apr 16, 2026
@shivaam

Copy link
Copy Markdown
ContributorAuthor

Something like this #65261

I was just thinking about this. Supporting both positional and flag styles for the same param isn't something most CLI libraries or popular tools do. We're adding maintenance burden, constraining future changes as all CLI libraries might not suppor this feature, and expanding the testing surface forever. Since airflowctl is still pre-1.0, feels like this is the natural window for a clean break rather than committing to maintaining a non-standard approach indefinitely.

Since the layer we are adding is light, either way works. Just wanted to share my thoughts.

@bugraoz93

Copy link
Copy Markdown
Contributor

Thanks @shivaam for your patience and joining the discussion! We have some procedures to follow as a community. :) Reopened yours, feel free to rebase and if ready we can ship

@bugraoz93

Copy link
Copy Markdown
Contributor

@shivaam

Copy link
Copy Markdown
ContributorAuthor

Sounds good. I will rebase the project.

@shivaam
shivaamforce-pushed the fix/positional-params-airflowctl-60142 branch 2 times, most recently from db3384e to c88029dCompareMay 12, 2026 04:37
"assets create-event 1",
# Backfill commands
"backfill list",
"backfill list example_bash_operator",

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Before this PR, airflowctl backfill list (no args) returned [] and exited 0, but that was a silent bug — the CLI passed dag_id=None, which the server coerced to an empty filter that matched no records. The command was never actually returning backfills.

@shivaam
shivaamforce-pushed the fix/positional-params-airflowctl-60142 branch from c88029d to 3d53d29CompareMay 12, 2026 05:17
apache#60142)
Required non-boolean parameters in auto-generated CLI commands are now
positional instead of flag-style, improving UX consistency with manually
defined commands like `dags pause`.
For primitive params parsed via AST, uses default detection to determine
positional vs flag. For Pydantic model fields, uses `is_required()`.
@shivaam
shivaamforce-pushed the fix/positional-params-airflowctl-60142 branch from 498d6a6 to 4270f6eCompareMay 12, 2026 05:44
@shivaam
shivaam marked this pull request as ready for review May 12, 2026 15:14

@bugraoz93bugraoz93 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good! Thanks for your patience and work @shivaam!

@bugraoz93bugraoz93 added the full tests needed We need to run full set of tests for this PR to merge label May 12, 2026
@bugraoz93bugraoz93 reopened this May 12, 2026
@bugraoz93

Copy link
Copy Markdown
Contributor

Added the full test label to see the integration tests green. We can merge after seeing it green :)

@bugraoz93bugraoz93 added the backport-to-airflow-ctl-v0-1-test Backport to airflow-ctl/v0-1-test label May 12, 2026
@bugraoz93

Copy link
Copy Markdown
Contributor

CI looks unrelated, ctl test looks good

@bugraoz93

bugraoz93 commented May 13, 2026

Copy link
Copy Markdown
Contributor

@shivaam Could you please rebase and resolve conflicts, we can merge afterwards

@shivaam

Copy link
Copy Markdown
ContributorAuthor

@bugraoz93 Seems like there is another PR that already solved this problem PR #66768 — "airflowctl: make required CLI params positional, keep optional as --flag" by 1fanwang
I dont think we need this anymore.

@shivaamshivaam closed this May 17, 2026
@bugraoz93

bugraoz93 commented May 23, 2026

Copy link
Copy Markdown
Contributor

Sorry @shivaam! Totally missed that one. I will try to be faster next time 😅 It also has some release notes changes need manually handled while backporting and should be cleaned while generating

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:airflow-ctlbackport-to-airflow-ctl-v0-1-testBackport to airflow-ctl/v0-1-testfull tests neededWe need to run full set of tests for this PR to merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Make positional parameters as a destionation paramater for auto-generated commands

3 participants

@shivaam@bugraoz93
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Make required params positional for auto-generated airflowctl commands - #64812

Closed
shivaam wants to merge 1 commit into
apache:mainfrom
shivaam:fix/positional-params-airflowctl-60142
Closed

Make required params positional for auto-generated airflowctl commands#64812
shivaam wants to merge 1 commit into
apache:mainfrom
shivaam:fix/positional-params-airflowctl-60142

Conversation

@shivaam

@shivaamshivaam commented Apr 6, 2026

Copy link
Copy Markdown
Contributor

Required non-boolean parameters in auto-generated CLI commands are now positional instead of flag-style, improving UX consistency with manually defined commands like dags pause.

Before:

airflowctl connections create --connection-id="test" --conn-type="mysql" --password=secret
airflowctl dags get --dag-id=example_bash_operator

After:

airflowctl connections create test mysql --password=secret
airflowctl dags get example_bash_operator

Changes

  • Track which operation parameters have default values during AST parsing
  • For primitive params, use the default info to decide positional vs flag
  • For Pydantic model fields, use is_required() to decide positional vs flag
  • Use _UNSET for arg_dest default to avoid argparse crash on positional args
  • Skip the has_default metadata key at runtime when mapping CLI args to method params
  • Updated unit tests and integration test commands to match new positional syntax
  • Added 2 new tests for positional arg behavior and default detection
  • Added newsfragment for the breaking change

Notes

  • _create_arg stays a dumb pass-through builder — all positional/flag logic is in the callers via one-liner conditionals
  • Booleans are never positional (they need --flag/--no-flag)
  • No special-casing for specific operations
  • Breaking change: existing --flag=value syntax for required params will no longer work. Since airflowctl is new and not yet stable, this should be acceptable.

Open question

Posted a clarifying question on the issue about whether all required params should be positional (e.g., xcom add has 5 positional args), or if we should limit it.

closes: #60142


Was generative AI tooling used to co-author this PR?
  • Yes — Claude Code (Claude Opus 4.6)

Generated-by: Claude Code (Claude Opus 4.6) following the guidelines

@bugraoz93bugraoz93 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the PR! I addressed your question. Could you please amend it to be both optional and positional, live side by side?
This way, we won't break anything while working, but rather add Airflow CLI positional arguments to improve parity and make migrations easier.

@@ -0,0 +1 @@
Required non-boolean parameters in auto-generated airflowctl commands are now positional instead of flag-style (e.g., ``airflowctl dags get example_dag`` instead of ``airflowctl dags get --dag-id=example_dag``).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This news fragment is for Airflow Core and not related to anything within airflowctl flow.
Since we are still in 0.x, significant changes are also expected at a certain level.
We are logging these things in the release notes

https://airflow.apache.org/docs/apache-airflow-ctl/stable/release_notes.html#significant-changes

@kaxil
kaxil requested a review from CopilotApril 10, 2026 19:55

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Note

Copilot was unable to run its full agentic suite in this review.

Updates auto-generated airflowctl commands so required non-boolean parameters are positional (UX aligned with manually-defined commands), and adjusts parsing/tests to support the breaking CLI syntax change.

Changes:

  • Track whether operation parameters have defaults during AST parsing and use it to decide positional vs flag args for primitive params.
  • Use Pydantic model_fields[].is_required() to decide positional vs flag args for datamodel fields (booleans remain flags).
  • Update unit/integration tests and add a newsfragment documenting the breaking change.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.

Show a summary per file
FileDescription
airflow-ctl/tests/airflow_ctl/ctl/test_cli_config.pyUpdates assertions for positional args; adds tests for positional behavior and default detection.
airflow-ctl/src/airflowctl/ctl/cli_config.pyAdds has_default metadata via AST parsing; generates positional args for required params; skips metadata during arg mapping and invocation.
airflow-ctl-tests/tests/airflowctl_tests/test_config_sensitive_masking.pyUpdates integration test commands to new positional syntax.
airflow-ctl-tests/tests/airflowctl_tests/test_airflowctl_commands.pyUpdates end-to-end command list to match new positional syntax across many commands.
airflow-core/newsfragments/64812.significant.rstDocuments the breaking CLI change for auto-generated commands.

if arg_name != "self":
args.append({arg_name: arg_type})
has_default = idx >= first_default_index
args.append({arg_name: arg_type, "has_default": has_default})

CopilotAIApr 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The parameters entries are now shaped as a dict with a dynamic key for the param name plus a reserved has_default key. This forces downstream code to iterate parameter.items() and special-case parameter_key == \"has_default\", which is brittle and harder to extend (and also makes collisions with a real param named has_default difficult to reason about). Consider switching to a stable structure like { \"name\": ..., \"type\": ..., \"has_default\": ... } for each parameter so consumers can access fields directly without item-iteration + skips.

Suggested change
args.append({arg_name: arg_type, "has_default": has_default})
args.append(
{"name": arg_name, "type": arg_type, "has_default": has_default}
)

Copilot uses AI. Check for mistakes.
Comment on lines +579 to +590
args = []
for parameter in operation.get("parameters"):
for parameter_key, parameter_type in parameter.items():
if parameter_key == "has_default":
continue
if self._is_primitive_type(type_name=parameter_type):
is_bool = parameter_type == "bool"
is_positional = not is_bool and not parameter.get("has_default", False)
sanitized_key = self._sanitize_arg_parameter_key(parameter_key)
args.append(
self._create_arg(
arg_flags=("--" + self._sanitize_arg_parameter_key(parameter_key),),
arg_flags=(parameter_key,) if is_positional else ("--" + sanitized_key,),

CopilotAIApr 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Related to the parameter-shape issue: the for parameter_key, parameter_type in parameter.items() loop plus if parameter_key == \"has_default\": continue is an avoidable control-flow hack that spreads across multiple call sites (also in _get_func). If you keep the current shape, a safer alternative is to store metadata under a dedicated nested key (e.g. {\"name\": ..., \"type\": ..., \"meta\": {\"has_default\": ...}}) or move metadata out of the per-parameter dict entirely so this loop can be simplified and cannot accidentally skip real parameters.

Copilot uses AI. Check for mistakes.
Comment on lines +566 to +598
def test_has_default_detection_in_ast_parsing(self):
"""Test that AST parsing correctly detects which params have defaults."""
from textwrap import dedent

temp_file = "test_has_default.py"
with open(temp_file, "w") as f:
f.write(
dedent("""
class TestOperations(BaseOperations):
def get(self, required_id: str) -> str | ServerResponseError:
pass
def search(self, query: str, limit: int = 10, offset: int = 0) -> str | ServerResponseError:
pass
""")
)

try:
command_factory = CommandFactory(file_path=temp_file)
for op in command_factory.operations:
if op["name"] == "get":
param = op["parameters"][0]
assert param.get("has_default") is False
elif op["name"] == "search":
query_param = op["parameters"][0]
assert query_param.get("has_default") is False
limit_param = op["parameters"][1]
assert limit_param.get("has_default") is True
offset_param = op["parameters"][2]
assert offset_param.get("has_default") is True
finally:
import os

os.remove(temp_file)

CopilotAIApr 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test writes a fixed filename (test_has_default.py) into the working directory, which can conflict under parallel test runs or when the CWD is not writable. Use pytest’s tmp_path/tmp_path_factory (or tempfile.NamedTemporaryFile) to create an isolated temporary file path, and open it with an explicit encoding (e.g. encoding=\"utf-8\") to match the production file-read behavior.

Suggested change
deftest_has_default_detection_in_ast_parsing(self):
"""Test that AST parsing correctly detects which params have defaults."""
fromtextwrapimportdedent
temp_file="test_has_default.py"
withopen(temp_file, "w") asf:
f.write(
dedent("""
classTestOperations(BaseOperations):
defget(self, required_id: str) ->str|ServerResponseError:
pass
defsearch(self, query: str, limit: int=10, offset: int=0) ->str|ServerResponseError:
pass
""")
)
try:
command_factory=CommandFactory(file_path=temp_file)
foropincommand_factory.operations:
ifop["name"] =="get":
param=op["parameters"][0]
assertparam.get("has_default") isFalse
elifop["name"] =="search":
query_param=op["parameters"][0]
assertquery_param.get("has_default") isFalse
limit_param=op["parameters"][1]
assertlimit_param.get("has_default") isTrue
offset_param=op["parameters"][2]
assertoffset_param.get("has_default") isTrue
finally:
importos
os.remove(temp_file)
deftest_has_default_detection_in_ast_parsing(self, tmp_path):
"""Test that AST parsing correctly detects which params have defaults."""
fromtextwrapimportdedent
temp_file=tmp_path/"test_has_default.py"
temp_file.write_text(
dedent("""
classTestOperations(BaseOperations):
defget(self, required_id: str) ->str|ServerResponseError:
pass
defsearch(self, query: str, limit: int=10, offset: int=0) ->str|ServerResponseError:
pass
"""),
encoding="utf-8",
)
command_factory=CommandFactory(file_path=str(temp_file))
foropincommand_factory.operations:
ifop["name"] =="get":
param=op["parameters"][0]
assertparam.get("has_default") isFalse
elifop["name"] =="search":
query_param=op["parameters"][0]
assertquery_param.get("has_default") isFalse
limit_param=op["parameters"][1]
assertlimit_param.get("has_default") isTrue
offset_param=op["parameters"][2]
assertoffset_param.get("has_default") isTrue

Copilot uses AI. Check for mistakes.
@bugraoz93

Copy link
Copy Markdown
Contributor

Moving to draft to manage release better, please make it ready again when you think ready for maintainers review

@bugraoz93
bugraoz93 marked this pull request as draft April 12, 2026 15:20
@shivaam

shivaam commented Apr 12, 2026

Copy link
Copy Markdown
ContributorAuthor

Moving to draft to manage release better, please make it ready again when you think ready for maintainers review
@bugraoz93!

Sorry I replied on the issue instead of the PR. argparse doesn't natively support this. Below I have listed some options.


I looked into supporting both positional and --flag styles for required params. Unfortunately argparse doesn't natively support this — a parameter is either positional (dag_id) or a flag (--dag-id), never both.

Why it's not straightforward:

The closest workaround is registering all params as --flags, adding a nargs='*' catch-all for bare values, and using parse_intermixed_args to map positionals into unfilled flag slots after parsing. More about this at the bottom of this comment. But this means we lose argparse's built-in validation, type checking, help formatting, and required=True for those params — we'd essentially reimplement positional argument handling ourselves on top of argparse.

Options:

  1. Keep it positional for required params (current PR) — the simplest, most conventional choice. Required params are positional, optional params are flags. All argparse features work, clean help output.

  2. Two-pass parsing — maintain two parsers per command: one with required params as positional, one with them as --flags required=True. Try positional first, fall back to flags. Users can use either style, just not mixed in the same invocation:

    # both work:
    airflowctl xcom add my_dag my_task my_key
    airflowctl xcom add --dag-id=my_dag --task-id=my_task --key=my_key
    # but not mixed:
    airflowctl xcom add my_dag --task-id=my_task my_key # would fail

    No argparse hacks needed (both parsers are standard), but doubles the parser setup and mixed usage isn't supported.

  3. Cap positional count — first 1-2 required params (like dag_id) are positional, rest stay as --flag required=True. Common CLI pattern (like git checkout <branch> --force). No hacks, all argparse features work.

I'd recommend Option 1 for simplicity. Happy to go with whichever you prefer!


Details on the parse_intermixed_args workaround (and why I didn't recommend it)

The idea is to register all required params as --flags only (no argparse positionals), add a single nargs='*' argument to collect any bare values, then map those bare values into whichever flag slots the user didn't fill:

PARAM_ORDER= ['dag_id', 'task_id', 'key']
parser.add_argument('--dag-id', dest='dag_id', default=None)
parser.add_argument('--task-id', dest='task_id', default=None)
parser.add_argument('--key', default=None)
parser.add_argument('positionals', nargs='*')
args=parser.parse_intermixed_args(argv)
# map bare values into unfilled flag slots, left to rightunfilled= [dfordinPARAM_ORDERifgetattr(args, d) isNone]
fori, valinenumerate(args.positionals):
setattr(args, unfilled[i], val)

parse_intermixed_args (Python 3.7+) allows flags and bare values to be freely interspersed, so all of these would work:

airflowctl xcom add d t k # all positional
airflowctl xcom add --dag-id=d --task-id=t --key=k # all flags
airflowctl xcom add d --task-id=t k # mixed
airflowctl xcom add --task-id=t my_dag my_key # gap-filling

The problem is what we lose:

  • Validation — can't use required=True on flags (argparse would reject positional-only usage), so we validate manually after gap-filling
  • Type checkingtype=int, choices=[...] etc. don't apply to the nargs='*' catch-all, so we type-check manually
  • Help output — shows [positionals ...] instead of named params like DAG_ID TASK_ID, and no indication which flags are required vs optional
  • argparse.REMAINDER and mutually exclusive groups with positionals — unsupported with parse_intermixed_args

This effectively means using argparse for flag parsing only and reimplementing positional handling on top of it.

@bugraoz93

Copy link
Copy Markdown
Contributor

@shivaam, this will make things a bit complicated. The two-pass parsing is still complex. I would say before we finish adding the command to the parser, we can reprocess the args, maybe even in get_parser, which can also be responsible for parsing the command. We may need to update the building part a bit but it should be possible with reprocessing

Even though we are still around 0.x.x, still thinking about how we could implement this in the best position. As it will be a user-facing change, we need to create a devlist discussion and ask to only keep positional for required or maintain both.

My idea is adding both can save us from the entire discussion flow and we could be backwards compatible, even though this is not trivial with argparase in Python.

@bugraoz93

Copy link
Copy Markdown
Contributor

Something like this
#65261

@shivaam

Copy link
Copy Markdown
ContributorAuthor

Closing in favor of #65261 which takes a better approach — supporting both positional and flag styles simultaneously via argv pre-processing, so there's no breaking change for existing users. Thanks @bugraoz93 for the guidance!

@shivaamshivaam closed this Apr 16, 2026
@shivaam

Copy link
Copy Markdown
ContributorAuthor

Something like this #65261

I was just thinking about this. Supporting both positional and flag styles for the same param isn't something most CLI libraries or popular tools do. We're adding maintenance burden, constraining future changes as all CLI libraries might not suppor this feature, and expanding the testing surface forever. Since airflowctl is still pre-1.0, feels like this is the natural window for a clean break rather than committing to maintaining a non-standard approach indefinitely.

Since the layer we are adding is light, either way works. Just wanted to share my thoughts.

@bugraoz93

Copy link
Copy Markdown
Contributor

Thanks @shivaam for your patience and joining the discussion! We have some procedures to follow as a community. :) Reopened yours, feel free to rebase and if ready we can ship

@bugraoz93

Copy link
Copy Markdown
Contributor

@shivaam

Copy link
Copy Markdown
ContributorAuthor

Sounds good. I will rebase the project.

@shivaam
shivaamforce-pushed the fix/positional-params-airflowctl-60142 branch 2 times, most recently from db3384e to c88029dCompareMay 12, 2026 04:37
"assets create-event 1",
# Backfill commands
"backfill list",
"backfill list example_bash_operator",

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Before this PR, airflowctl backfill list (no args) returned [] and exited 0, but that was a silent bug — the CLI passed dag_id=None, which the server coerced to an empty filter that matched no records. The command was never actually returning backfills.

@shivaam
shivaamforce-pushed the fix/positional-params-airflowctl-60142 branch from c88029d to 3d53d29CompareMay 12, 2026 05:17
apache#60142)
Required non-boolean parameters in auto-generated CLI commands are now
positional instead of flag-style, improving UX consistency with manually
defined commands like `dags pause`.
For primitive params parsed via AST, uses default detection to determine
positional vs flag. For Pydantic model fields, uses `is_required()`.
@shivaam
shivaamforce-pushed the fix/positional-params-airflowctl-60142 branch from 498d6a6 to 4270f6eCompareMay 12, 2026 05:44
@shivaam
shivaam marked this pull request as ready for review May 12, 2026 15:14

@bugraoz93bugraoz93 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good! Thanks for your patience and work @shivaam!

@bugraoz93bugraoz93 added the full tests needed We need to run full set of tests for this PR to merge label May 12, 2026
@bugraoz93bugraoz93 reopened this May 12, 2026
@bugraoz93

Copy link
Copy Markdown
Contributor

Added the full test label to see the integration tests green. We can merge after seeing it green :)

@bugraoz93bugraoz93 added the backport-to-airflow-ctl-v0-1-test Backport to airflow-ctl/v0-1-test label May 12, 2026
@bugraoz93

Copy link
Copy Markdown
Contributor

CI looks unrelated, ctl test looks good

@bugraoz93

bugraoz93 commented May 13, 2026

Copy link
Copy Markdown
Contributor

@shivaam Could you please rebase and resolve conflicts, we can merge afterwards

@shivaam

Copy link
Copy Markdown
ContributorAuthor

@bugraoz93 Seems like there is another PR that already solved this problem PR #66768 — "airflowctl: make required CLI params positional, keep optional as --flag" by 1fanwang
I dont think we need this anymore.

@shivaamshivaam closed this May 17, 2026
@bugraoz93

bugraoz93 commented May 23, 2026

Copy link
Copy Markdown
Contributor

Sorry @shivaam! Totally missed that one. I will try to be faster next time 😅 It also has some release notes changes need manually handled while backporting and should be cleaned while generating

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:airflow-ctlbackport-to-airflow-ctl-v0-1-testBackport to airflow-ctl/v0-1-testfull tests neededWe need to run full set of tests for this PR to merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Make positional parameters as a destionation paramater for auto-generated commands

3 participants

@shivaam@bugraoz93
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Make required params positional for auto-generated airflowctl commands - #64812

Closed
shivaam wants to merge 1 commit into
apache:mainfrom
shivaam:fix/positional-params-airflowctl-60142
Closed

Make required params positional for auto-generated airflowctl commands#64812
shivaam wants to merge 1 commit into
apache:mainfrom
shivaam:fix/positional-params-airflowctl-60142

Conversation

@shivaam

@shivaamshivaam commented Apr 6, 2026

Copy link
Copy Markdown
Contributor

Required non-boolean parameters in auto-generated CLI commands are now positional instead of flag-style, improving UX consistency with manually defined commands like dags pause.

Before:

airflowctl connections create --connection-id="test" --conn-type="mysql" --password=secret
airflowctl dags get --dag-id=example_bash_operator

After:

airflowctl connections create test mysql --password=secret
airflowctl dags get example_bash_operator

Changes

  • Track which operation parameters have default values during AST parsing
  • For primitive params, use the default info to decide positional vs flag
  • For Pydantic model fields, use is_required() to decide positional vs flag
  • Use _UNSET for arg_dest default to avoid argparse crash on positional args
  • Skip the has_default metadata key at runtime when mapping CLI args to method params
  • Updated unit tests and integration test commands to match new positional syntax
  • Added 2 new tests for positional arg behavior and default detection
  • Added newsfragment for the breaking change

Notes

  • _create_arg stays a dumb pass-through builder — all positional/flag logic is in the callers via one-liner conditionals
  • Booleans are never positional (they need --flag/--no-flag)
  • No special-casing for specific operations
  • Breaking change: existing --flag=value syntax for required params will no longer work. Since airflowctl is new and not yet stable, this should be acceptable.

Open question

Posted a clarifying question on the issue about whether all required params should be positional (e.g., xcom add has 5 positional args), or if we should limit it.

closes: #60142


Was generative AI tooling used to co-author this PR?
  • Yes — Claude Code (Claude Opus 4.6)

Generated-by: Claude Code (Claude Opus 4.6) following the guidelines

@bugraoz93bugraoz93 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the PR! I addressed your question. Could you please amend it to be both optional and positional, live side by side?
This way, we won't break anything while working, but rather add Airflow CLI positional arguments to improve parity and make migrations easier.

@@ -0,0 +1 @@
Required non-boolean parameters in auto-generated airflowctl commands are now positional instead of flag-style (e.g., ``airflowctl dags get example_dag`` instead of ``airflowctl dags get --dag-id=example_dag``).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This news fragment is for Airflow Core and not related to anything within airflowctl flow.
Since we are still in 0.x, significant changes are also expected at a certain level.
We are logging these things in the release notes

https://airflow.apache.org/docs/apache-airflow-ctl/stable/release_notes.html#significant-changes

@kaxil
kaxil requested a review from CopilotApril 10, 2026 19:55

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Note

Copilot was unable to run its full agentic suite in this review.

Updates auto-generated airflowctl commands so required non-boolean parameters are positional (UX aligned with manually-defined commands), and adjusts parsing/tests to support the breaking CLI syntax change.

Changes:

  • Track whether operation parameters have defaults during AST parsing and use it to decide positional vs flag args for primitive params.
  • Use Pydantic model_fields[].is_required() to decide positional vs flag args for datamodel fields (booleans remain flags).
  • Update unit/integration tests and add a newsfragment documenting the breaking change.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.

Show a summary per file
FileDescription
airflow-ctl/tests/airflow_ctl/ctl/test_cli_config.pyUpdates assertions for positional args; adds tests for positional behavior and default detection.
airflow-ctl/src/airflowctl/ctl/cli_config.pyAdds has_default metadata via AST parsing; generates positional args for required params; skips metadata during arg mapping and invocation.
airflow-ctl-tests/tests/airflowctl_tests/test_config_sensitive_masking.pyUpdates integration test commands to new positional syntax.
airflow-ctl-tests/tests/airflowctl_tests/test_airflowctl_commands.pyUpdates end-to-end command list to match new positional syntax across many commands.
airflow-core/newsfragments/64812.significant.rstDocuments the breaking CLI change for auto-generated commands.

if arg_name != "self":
args.append({arg_name: arg_type})
has_default = idx >= first_default_index
args.append({arg_name: arg_type, "has_default": has_default})

CopilotAIApr 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The parameters entries are now shaped as a dict with a dynamic key for the param name plus a reserved has_default key. This forces downstream code to iterate parameter.items() and special-case parameter_key == \"has_default\", which is brittle and harder to extend (and also makes collisions with a real param named has_default difficult to reason about). Consider switching to a stable structure like { \"name\": ..., \"type\": ..., \"has_default\": ... } for each parameter so consumers can access fields directly without item-iteration + skips.

Suggested change
args.append({arg_name: arg_type, "has_default": has_default})
args.append(
{"name": arg_name, "type": arg_type, "has_default": has_default}
)

Copilot uses AI. Check for mistakes.
Comment on lines +579 to +590
args = []
for parameter in operation.get("parameters"):
for parameter_key, parameter_type in parameter.items():
if parameter_key == "has_default":
continue
if self._is_primitive_type(type_name=parameter_type):
is_bool = parameter_type == "bool"
is_positional = not is_bool and not parameter.get("has_default", False)
sanitized_key = self._sanitize_arg_parameter_key(parameter_key)
args.append(
self._create_arg(
arg_flags=("--" + self._sanitize_arg_parameter_key(parameter_key),),
arg_flags=(parameter_key,) if is_positional else ("--" + sanitized_key,),

CopilotAIApr 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Related to the parameter-shape issue: the for parameter_key, parameter_type in parameter.items() loop plus if parameter_key == \"has_default\": continue is an avoidable control-flow hack that spreads across multiple call sites (also in _get_func). If you keep the current shape, a safer alternative is to store metadata under a dedicated nested key (e.g. {\"name\": ..., \"type\": ..., \"meta\": {\"has_default\": ...}}) or move metadata out of the per-parameter dict entirely so this loop can be simplified and cannot accidentally skip real parameters.

Copilot uses AI. Check for mistakes.
Comment on lines +566 to +598
def test_has_default_detection_in_ast_parsing(self):
"""Test that AST parsing correctly detects which params have defaults."""
from textwrap import dedent

temp_file = "test_has_default.py"
with open(temp_file, "w") as f:
f.write(
dedent("""
class TestOperations(BaseOperations):
def get(self, required_id: str) -> str | ServerResponseError:
pass
def search(self, query: str, limit: int = 10, offset: int = 0) -> str | ServerResponseError:
pass
""")
)

try:
command_factory = CommandFactory(file_path=temp_file)
for op in command_factory.operations:
if op["name"] == "get":
param = op["parameters"][0]
assert param.get("has_default") is False
elif op["name"] == "search":
query_param = op["parameters"][0]
assert query_param.get("has_default") is False
limit_param = op["parameters"][1]
assert limit_param.get("has_default") is True
offset_param = op["parameters"][2]
assert offset_param.get("has_default") is True
finally:
import os

os.remove(temp_file)

CopilotAIApr 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test writes a fixed filename (test_has_default.py) into the working directory, which can conflict under parallel test runs or when the CWD is not writable. Use pytest’s tmp_path/tmp_path_factory (or tempfile.NamedTemporaryFile) to create an isolated temporary file path, and open it with an explicit encoding (e.g. encoding=\"utf-8\") to match the production file-read behavior.

Suggested change
deftest_has_default_detection_in_ast_parsing(self):
"""Test that AST parsing correctly detects which params have defaults."""
fromtextwrapimportdedent
temp_file="test_has_default.py"
withopen(temp_file, "w") asf:
f.write(
dedent("""
classTestOperations(BaseOperations):
defget(self, required_id: str) ->str|ServerResponseError:
pass
defsearch(self, query: str, limit: int=10, offset: int=0) ->str|ServerResponseError:
pass
""")
)
try:
command_factory=CommandFactory(file_path=temp_file)
foropincommand_factory.operations:
ifop["name"] =="get":
param=op["parameters"][0]
assertparam.get("has_default") isFalse
elifop["name"] =="search":
query_param=op["parameters"][0]
assertquery_param.get("has_default") isFalse
limit_param=op["parameters"][1]
assertlimit_param.get("has_default") isTrue
offset_param=op["parameters"][2]
assertoffset_param.get("has_default") isTrue
finally:
importos
os.remove(temp_file)
deftest_has_default_detection_in_ast_parsing(self, tmp_path):
"""Test that AST parsing correctly detects which params have defaults."""
fromtextwrapimportdedent
temp_file=tmp_path/"test_has_default.py"
temp_file.write_text(
dedent("""
classTestOperations(BaseOperations):
defget(self, required_id: str) ->str|ServerResponseError:
pass
defsearch(self, query: str, limit: int=10, offset: int=0) ->str|ServerResponseError:
pass
"""),
encoding="utf-8",
)
command_factory=CommandFactory(file_path=str(temp_file))
foropincommand_factory.operations:
ifop["name"] =="get":
param=op["parameters"][0]
assertparam.get("has_default") isFalse
elifop["name"] =="search":
query_param=op["parameters"][0]
assertquery_param.get("has_default") isFalse
limit_param=op["parameters"][1]
assertlimit_param.get("has_default") isTrue
offset_param=op["parameters"][2]
assertoffset_param.get("has_default") isTrue

Copilot uses AI. Check for mistakes.
@bugraoz93

Copy link
Copy Markdown
Contributor

Moving to draft to manage release better, please make it ready again when you think ready for maintainers review

@bugraoz93
bugraoz93 marked this pull request as draft April 12, 2026 15:20
@shivaam

shivaam commented Apr 12, 2026

Copy link
Copy Markdown
ContributorAuthor

Moving to draft to manage release better, please make it ready again when you think ready for maintainers review
@bugraoz93!

Sorry I replied on the issue instead of the PR. argparse doesn't natively support this. Below I have listed some options.


I looked into supporting both positional and --flag styles for required params. Unfortunately argparse doesn't natively support this — a parameter is either positional (dag_id) or a flag (--dag-id), never both.

Why it's not straightforward:

The closest workaround is registering all params as --flags, adding a nargs='*' catch-all for bare values, and using parse_intermixed_args to map positionals into unfilled flag slots after parsing. More about this at the bottom of this comment. But this means we lose argparse's built-in validation, type checking, help formatting, and required=True for those params — we'd essentially reimplement positional argument handling ourselves on top of argparse.

Options:

  1. Keep it positional for required params (current PR) — the simplest, most conventional choice. Required params are positional, optional params are flags. All argparse features work, clean help output.

  2. Two-pass parsing — maintain two parsers per command: one with required params as positional, one with them as --flags required=True. Try positional first, fall back to flags. Users can use either style, just not mixed in the same invocation:

    # both work:
    airflowctl xcom add my_dag my_task my_key
    airflowctl xcom add --dag-id=my_dag --task-id=my_task --key=my_key
    # but not mixed:
    airflowctl xcom add my_dag --task-id=my_task my_key # would fail

    No argparse hacks needed (both parsers are standard), but doubles the parser setup and mixed usage isn't supported.

  3. Cap positional count — first 1-2 required params (like dag_id) are positional, rest stay as --flag required=True. Common CLI pattern (like git checkout <branch> --force). No hacks, all argparse features work.

I'd recommend Option 1 for simplicity. Happy to go with whichever you prefer!


Details on the parse_intermixed_args workaround (and why I didn't recommend it)

The idea is to register all required params as --flags only (no argparse positionals), add a single nargs='*' argument to collect any bare values, then map those bare values into whichever flag slots the user didn't fill:

PARAM_ORDER= ['dag_id', 'task_id', 'key']
parser.add_argument('--dag-id', dest='dag_id', default=None)
parser.add_argument('--task-id', dest='task_id', default=None)
parser.add_argument('--key', default=None)
parser.add_argument('positionals', nargs='*')
args=parser.parse_intermixed_args(argv)
# map bare values into unfilled flag slots, left to rightunfilled= [dfordinPARAM_ORDERifgetattr(args, d) isNone]
fori, valinenumerate(args.positionals):
setattr(args, unfilled[i], val)

parse_intermixed_args (Python 3.7+) allows flags and bare values to be freely interspersed, so all of these would work:

airflowctl xcom add d t k # all positional
airflowctl xcom add --dag-id=d --task-id=t --key=k # all flags
airflowctl xcom add d --task-id=t k # mixed
airflowctl xcom add --task-id=t my_dag my_key # gap-filling

The problem is what we lose:

  • Validation — can't use required=True on flags (argparse would reject positional-only usage), so we validate manually after gap-filling
  • Type checkingtype=int, choices=[...] etc. don't apply to the nargs='*' catch-all, so we type-check manually
  • Help output — shows [positionals ...] instead of named params like DAG_ID TASK_ID, and no indication which flags are required vs optional
  • argparse.REMAINDER and mutually exclusive groups with positionals — unsupported with parse_intermixed_args

This effectively means using argparse for flag parsing only and reimplementing positional handling on top of it.

@bugraoz93

Copy link
Copy Markdown
Contributor

@shivaam, this will make things a bit complicated. The two-pass parsing is still complex. I would say before we finish adding the command to the parser, we can reprocess the args, maybe even in get_parser, which can also be responsible for parsing the command. We may need to update the building part a bit but it should be possible with reprocessing

Even though we are still around 0.x.x, still thinking about how we could implement this in the best position. As it will be a user-facing change, we need to create a devlist discussion and ask to only keep positional for required or maintain both.

My idea is adding both can save us from the entire discussion flow and we could be backwards compatible, even though this is not trivial with argparase in Python.

@bugraoz93

Copy link
Copy Markdown
Contributor

Something like this
#65261

@shivaam

Copy link
Copy Markdown
ContributorAuthor

Closing in favor of #65261 which takes a better approach — supporting both positional and flag styles simultaneously via argv pre-processing, so there's no breaking change for existing users. Thanks @bugraoz93 for the guidance!

@shivaamshivaam closed this Apr 16, 2026
@shivaam

Copy link
Copy Markdown
ContributorAuthor

Something like this #65261

I was just thinking about this. Supporting both positional and flag styles for the same param isn't something most CLI libraries or popular tools do. We're adding maintenance burden, constraining future changes as all CLI libraries might not suppor this feature, and expanding the testing surface forever. Since airflowctl is still pre-1.0, feels like this is the natural window for a clean break rather than committing to maintaining a non-standard approach indefinitely.

Since the layer we are adding is light, either way works. Just wanted to share my thoughts.

@bugraoz93

Copy link
Copy Markdown
Contributor

Thanks @shivaam for your patience and joining the discussion! We have some procedures to follow as a community. :) Reopened yours, feel free to rebase and if ready we can ship

@bugraoz93

Copy link
Copy Markdown
Contributor

@shivaam

Copy link
Copy Markdown
ContributorAuthor

Sounds good. I will rebase the project.

@shivaam
shivaamforce-pushed the fix/positional-params-airflowctl-60142 branch 2 times, most recently from db3384e to c88029dCompareMay 12, 2026 04:37
"assets create-event 1",
# Backfill commands
"backfill list",
"backfill list example_bash_operator",

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Before this PR, airflowctl backfill list (no args) returned [] and exited 0, but that was a silent bug — the CLI passed dag_id=None, which the server coerced to an empty filter that matched no records. The command was never actually returning backfills.

@shivaam
shivaamforce-pushed the fix/positional-params-airflowctl-60142 branch from c88029d to 3d53d29CompareMay 12, 2026 05:17
apache#60142)
Required non-boolean parameters in auto-generated CLI commands are now
positional instead of flag-style, improving UX consistency with manually
defined commands like `dags pause`.
For primitive params parsed via AST, uses default detection to determine
positional vs flag. For Pydantic model fields, uses `is_required()`.
@shivaam
shivaamforce-pushed the fix/positional-params-airflowctl-60142 branch from 498d6a6 to 4270f6eCompareMay 12, 2026 05:44
@shivaam
shivaam marked this pull request as ready for review May 12, 2026 15:14

@bugraoz93bugraoz93 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good! Thanks for your patience and work @shivaam!

@bugraoz93bugraoz93 added the full tests needed We need to run full set of tests for this PR to merge label May 12, 2026
@bugraoz93bugraoz93 reopened this May 12, 2026
@bugraoz93

Copy link
Copy Markdown
Contributor

Added the full test label to see the integration tests green. We can merge after seeing it green :)

@bugraoz93bugraoz93 added the backport-to-airflow-ctl-v0-1-test Backport to airflow-ctl/v0-1-test label May 12, 2026
@bugraoz93

Copy link
Copy Markdown
Contributor

CI looks unrelated, ctl test looks good

@bugraoz93

bugraoz93 commented May 13, 2026

Copy link
Copy Markdown
Contributor

@shivaam Could you please rebase and resolve conflicts, we can merge afterwards

@shivaam

Copy link
Copy Markdown
ContributorAuthor

@bugraoz93 Seems like there is another PR that already solved this problem PR #66768 — "airflowctl: make required CLI params positional, keep optional as --flag" by 1fanwang
I dont think we need this anymore.

@shivaamshivaam closed this May 17, 2026
@bugraoz93

bugraoz93 commented May 23, 2026

Copy link
Copy Markdown
Contributor

Sorry @shivaam! Totally missed that one. I will try to be faster next time 😅 It also has some release notes changes need manually handled while backporting and should be cleaned while generating

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:airflow-ctlbackport-to-airflow-ctl-v0-1-testBackport to airflow-ctl/v0-1-testfull tests neededWe need to run full set of tests for this PR to merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Make positional parameters as a destionation paramater for auto-generated commands

3 participants

@shivaam@bugraoz93
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Make required params positional for auto-generated airflowctl commands - #64812

Closed
shivaam wants to merge 1 commit into
apache:mainfrom
shivaam:fix/positional-params-airflowctl-60142
Closed

Make required params positional for auto-generated airflowctl commands#64812
shivaam wants to merge 1 commit into
apache:mainfrom
shivaam:fix/positional-params-airflowctl-60142

Conversation

@shivaam

@shivaamshivaam commented Apr 6, 2026

Copy link
Copy Markdown
Contributor

Required non-boolean parameters in auto-generated CLI commands are now positional instead of flag-style, improving UX consistency with manually defined commands like dags pause.

Before:

airflowctl connections create --connection-id="test" --conn-type="mysql" --password=secret
airflowctl dags get --dag-id=example_bash_operator

After:

airflowctl connections create test mysql --password=secret
airflowctl dags get example_bash_operator

Changes

  • Track which operation parameters have default values during AST parsing
  • For primitive params, use the default info to decide positional vs flag
  • For Pydantic model fields, use is_required() to decide positional vs flag
  • Use _UNSET for arg_dest default to avoid argparse crash on positional args
  • Skip the has_default metadata key at runtime when mapping CLI args to method params
  • Updated unit tests and integration test commands to match new positional syntax
  • Added 2 new tests for positional arg behavior and default detection
  • Added newsfragment for the breaking change

Notes

  • _create_arg stays a dumb pass-through builder — all positional/flag logic is in the callers via one-liner conditionals
  • Booleans are never positional (they need --flag/--no-flag)
  • No special-casing for specific operations
  • Breaking change: existing --flag=value syntax for required params will no longer work. Since airflowctl is new and not yet stable, this should be acceptable.

Open question

Posted a clarifying question on the issue about whether all required params should be positional (e.g., xcom add has 5 positional args), or if we should limit it.

closes: #60142


Was generative AI tooling used to co-author this PR?
  • Yes — Claude Code (Claude Opus 4.6)

Generated-by: Claude Code (Claude Opus 4.6) following the guidelines

@bugraoz93bugraoz93 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the PR! I addressed your question. Could you please amend it to be both optional and positional, live side by side?
This way, we won't break anything while working, but rather add Airflow CLI positional arguments to improve parity and make migrations easier.

@@ -0,0 +1 @@
Required non-boolean parameters in auto-generated airflowctl commands are now positional instead of flag-style (e.g., ``airflowctl dags get example_dag`` instead of ``airflowctl dags get --dag-id=example_dag``).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This news fragment is for Airflow Core and not related to anything within airflowctl flow.
Since we are still in 0.x, significant changes are also expected at a certain level.
We are logging these things in the release notes

https://airflow.apache.org/docs/apache-airflow-ctl/stable/release_notes.html#significant-changes

@kaxil
kaxil requested a review from CopilotApril 10, 2026 19:55

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Note

Copilot was unable to run its full agentic suite in this review.

Updates auto-generated airflowctl commands so required non-boolean parameters are positional (UX aligned with manually-defined commands), and adjusts parsing/tests to support the breaking CLI syntax change.

Changes:

  • Track whether operation parameters have defaults during AST parsing and use it to decide positional vs flag args for primitive params.
  • Use Pydantic model_fields[].is_required() to decide positional vs flag args for datamodel fields (booleans remain flags).
  • Update unit/integration tests and add a newsfragment documenting the breaking change.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.

Show a summary per file
FileDescription
airflow-ctl/tests/airflow_ctl/ctl/test_cli_config.pyUpdates assertions for positional args; adds tests for positional behavior and default detection.
airflow-ctl/src/airflowctl/ctl/cli_config.pyAdds has_default metadata via AST parsing; generates positional args for required params; skips metadata during arg mapping and invocation.
airflow-ctl-tests/tests/airflowctl_tests/test_config_sensitive_masking.pyUpdates integration test commands to new positional syntax.
airflow-ctl-tests/tests/airflowctl_tests/test_airflowctl_commands.pyUpdates end-to-end command list to match new positional syntax across many commands.
airflow-core/newsfragments/64812.significant.rstDocuments the breaking CLI change for auto-generated commands.

if arg_name != "self":
args.append({arg_name: arg_type})
has_default = idx >= first_default_index
args.append({arg_name: arg_type, "has_default": has_default})

CopilotAIApr 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The parameters entries are now shaped as a dict with a dynamic key for the param name plus a reserved has_default key. This forces downstream code to iterate parameter.items() and special-case parameter_key == \"has_default\", which is brittle and harder to extend (and also makes collisions with a real param named has_default difficult to reason about). Consider switching to a stable structure like { \"name\": ..., \"type\": ..., \"has_default\": ... } for each parameter so consumers can access fields directly without item-iteration + skips.

Suggested change
args.append({arg_name: arg_type, "has_default": has_default})
args.append(
{"name": arg_name, "type": arg_type, "has_default": has_default}
)

Copilot uses AI. Check for mistakes.
Comment on lines +579 to +590
args = []
for parameter in operation.get("parameters"):
for parameter_key, parameter_type in parameter.items():
if parameter_key == "has_default":
continue
if self._is_primitive_type(type_name=parameter_type):
is_bool = parameter_type == "bool"
is_positional = not is_bool and not parameter.get("has_default", False)
sanitized_key = self._sanitize_arg_parameter_key(parameter_key)
args.append(
self._create_arg(
arg_flags=("--" + self._sanitize_arg_parameter_key(parameter_key),),
arg_flags=(parameter_key,) if is_positional else ("--" + sanitized_key,),

CopilotAIApr 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Related to the parameter-shape issue: the for parameter_key, parameter_type in parameter.items() loop plus if parameter_key == \"has_default\": continue is an avoidable control-flow hack that spreads across multiple call sites (also in _get_func). If you keep the current shape, a safer alternative is to store metadata under a dedicated nested key (e.g. {\"name\": ..., \"type\": ..., \"meta\": {\"has_default\": ...}}) or move metadata out of the per-parameter dict entirely so this loop can be simplified and cannot accidentally skip real parameters.

Copilot uses AI. Check for mistakes.
Comment on lines +566 to +598
def test_has_default_detection_in_ast_parsing(self):
"""Test that AST parsing correctly detects which params have defaults."""
from textwrap import dedent

temp_file = "test_has_default.py"
with open(temp_file, "w") as f:
f.write(
dedent("""
class TestOperations(BaseOperations):
def get(self, required_id: str) -> str | ServerResponseError:
pass
def search(self, query: str, limit: int = 10, offset: int = 0) -> str | ServerResponseError:
pass
""")
)

try:
command_factory = CommandFactory(file_path=temp_file)
for op in command_factory.operations:
if op["name"] == "get":
param = op["parameters"][0]
assert param.get("has_default") is False
elif op["name"] == "search":
query_param = op["parameters"][0]
assert query_param.get("has_default") is False
limit_param = op["parameters"][1]
assert limit_param.get("has_default") is True
offset_param = op["parameters"][2]
assert offset_param.get("has_default") is True
finally:
import os

os.remove(temp_file)

CopilotAIApr 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test writes a fixed filename (test_has_default.py) into the working directory, which can conflict under parallel test runs or when the CWD is not writable. Use pytest’s tmp_path/tmp_path_factory (or tempfile.NamedTemporaryFile) to create an isolated temporary file path, and open it with an explicit encoding (e.g. encoding=\"utf-8\") to match the production file-read behavior.

Suggested change
deftest_has_default_detection_in_ast_parsing(self):
"""Test that AST parsing correctly detects which params have defaults."""
fromtextwrapimportdedent
temp_file="test_has_default.py"
withopen(temp_file, "w") asf:
f.write(
dedent("""
classTestOperations(BaseOperations):
defget(self, required_id: str) ->str|ServerResponseError:
pass
defsearch(self, query: str, limit: int=10, offset: int=0) ->str|ServerResponseError:
pass
""")
)
try:
command_factory=CommandFactory(file_path=temp_file)
foropincommand_factory.operations:
ifop["name"] =="get":
param=op["parameters"][0]
assertparam.get("has_default") isFalse
elifop["name"] =="search":
query_param=op["parameters"][0]
assertquery_param.get("has_default") isFalse
limit_param=op["parameters"][1]
assertlimit_param.get("has_default") isTrue
offset_param=op["parameters"][2]
assertoffset_param.get("has_default") isTrue
finally:
importos
os.remove(temp_file)
deftest_has_default_detection_in_ast_parsing(self, tmp_path):
"""Test that AST parsing correctly detects which params have defaults."""
fromtextwrapimportdedent
temp_file=tmp_path/"test_has_default.py"
temp_file.write_text(
dedent("""
classTestOperations(BaseOperations):
defget(self, required_id: str) ->str|ServerResponseError:
pass
defsearch(self, query: str, limit: int=10, offset: int=0) ->str|ServerResponseError:
pass
"""),
encoding="utf-8",
)
command_factory=CommandFactory(file_path=str(temp_file))
foropincommand_factory.operations:
ifop["name"] =="get":
param=op["parameters"][0]
assertparam.get("has_default") isFalse
elifop["name"] =="search":
query_param=op["parameters"][0]
assertquery_param.get("has_default") isFalse
limit_param=op["parameters"][1]
assertlimit_param.get("has_default") isTrue
offset_param=op["parameters"][2]
assertoffset_param.get("has_default") isTrue

Copilot uses AI. Check for mistakes.
@bugraoz93

Copy link
Copy Markdown
Contributor

Moving to draft to manage release better, please make it ready again when you think ready for maintainers review

@bugraoz93
bugraoz93 marked this pull request as draft April 12, 2026 15:20
@shivaam

shivaam commented Apr 12, 2026

Copy link
Copy Markdown
ContributorAuthor

Moving to draft to manage release better, please make it ready again when you think ready for maintainers review
@bugraoz93!

Sorry I replied on the issue instead of the PR. argparse doesn't natively support this. Below I have listed some options.


I looked into supporting both positional and --flag styles for required params. Unfortunately argparse doesn't natively support this — a parameter is either positional (dag_id) or a flag (--dag-id), never both.

Why it's not straightforward:

The closest workaround is registering all params as --flags, adding a nargs='*' catch-all for bare values, and using parse_intermixed_args to map positionals into unfilled flag slots after parsing. More about this at the bottom of this comment. But this means we lose argparse's built-in validation, type checking, help formatting, and required=True for those params — we'd essentially reimplement positional argument handling ourselves on top of argparse.

Options:

  1. Keep it positional for required params (current PR) — the simplest, most conventional choice. Required params are positional, optional params are flags. All argparse features work, clean help output.

  2. Two-pass parsing — maintain two parsers per command: one with required params as positional, one with them as --flags required=True. Try positional first, fall back to flags. Users can use either style, just not mixed in the same invocation:

    # both work:
    airflowctl xcom add my_dag my_task my_key
    airflowctl xcom add --dag-id=my_dag --task-id=my_task --key=my_key
    # but not mixed:
    airflowctl xcom add my_dag --task-id=my_task my_key # would fail

    No argparse hacks needed (both parsers are standard), but doubles the parser setup and mixed usage isn't supported.

  3. Cap positional count — first 1-2 required params (like dag_id) are positional, rest stay as --flag required=True. Common CLI pattern (like git checkout <branch> --force). No hacks, all argparse features work.

I'd recommend Option 1 for simplicity. Happy to go with whichever you prefer!


Details on the parse_intermixed_args workaround (and why I didn't recommend it)

The idea is to register all required params as --flags only (no argparse positionals), add a single nargs='*' argument to collect any bare values, then map those bare values into whichever flag slots the user didn't fill:

PARAM_ORDER= ['dag_id', 'task_id', 'key']
parser.add_argument('--dag-id', dest='dag_id', default=None)
parser.add_argument('--task-id', dest='task_id', default=None)
parser.add_argument('--key', default=None)
parser.add_argument('positionals', nargs='*')
args=parser.parse_intermixed_args(argv)
# map bare values into unfilled flag slots, left to rightunfilled= [dfordinPARAM_ORDERifgetattr(args, d) isNone]
fori, valinenumerate(args.positionals):
setattr(args, unfilled[i], val)

parse_intermixed_args (Python 3.7+) allows flags and bare values to be freely interspersed, so all of these would work:

airflowctl xcom add d t k # all positional
airflowctl xcom add --dag-id=d --task-id=t --key=k # all flags
airflowctl xcom add d --task-id=t k # mixed
airflowctl xcom add --task-id=t my_dag my_key # gap-filling

The problem is what we lose:

  • Validation — can't use required=True on flags (argparse would reject positional-only usage), so we validate manually after gap-filling
  • Type checkingtype=int, choices=[...] etc. don't apply to the nargs='*' catch-all, so we type-check manually
  • Help output — shows [positionals ...] instead of named params like DAG_ID TASK_ID, and no indication which flags are required vs optional
  • argparse.REMAINDER and mutually exclusive groups with positionals — unsupported with parse_intermixed_args

This effectively means using argparse for flag parsing only and reimplementing positional handling on top of it.

@bugraoz93

Copy link
Copy Markdown
Contributor

@shivaam, this will make things a bit complicated. The two-pass parsing is still complex. I would say before we finish adding the command to the parser, we can reprocess the args, maybe even in get_parser, which can also be responsible for parsing the command. We may need to update the building part a bit but it should be possible with reprocessing

Even though we are still around 0.x.x, still thinking about how we could implement this in the best position. As it will be a user-facing change, we need to create a devlist discussion and ask to only keep positional for required or maintain both.

My idea is adding both can save us from the entire discussion flow and we could be backwards compatible, even though this is not trivial with argparase in Python.

@bugraoz93

Copy link
Copy Markdown
Contributor

Something like this
#65261

@shivaam

Copy link
Copy Markdown
ContributorAuthor

Closing in favor of #65261 which takes a better approach — supporting both positional and flag styles simultaneously via argv pre-processing, so there's no breaking change for existing users. Thanks @bugraoz93 for the guidance!

@shivaamshivaam closed this Apr 16, 2026
@shivaam

Copy link
Copy Markdown
ContributorAuthor

Something like this #65261

I was just thinking about this. Supporting both positional and flag styles for the same param isn't something most CLI libraries or popular tools do. We're adding maintenance burden, constraining future changes as all CLI libraries might not suppor this feature, and expanding the testing surface forever. Since airflowctl is still pre-1.0, feels like this is the natural window for a clean break rather than committing to maintaining a non-standard approach indefinitely.

Since the layer we are adding is light, either way works. Just wanted to share my thoughts.

@bugraoz93

Copy link
Copy Markdown
Contributor

Thanks @shivaam for your patience and joining the discussion! We have some procedures to follow as a community. :) Reopened yours, feel free to rebase and if ready we can ship

@bugraoz93

Copy link
Copy Markdown
Contributor

@shivaam

Copy link
Copy Markdown
ContributorAuthor

Sounds good. I will rebase the project.

@shivaam
shivaamforce-pushed the fix/positional-params-airflowctl-60142 branch 2 times, most recently from db3384e to c88029dCompareMay 12, 2026 04:37
"assets create-event 1",
# Backfill commands
"backfill list",
"backfill list example_bash_operator",

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Before this PR, airflowctl backfill list (no args) returned [] and exited 0, but that was a silent bug — the CLI passed dag_id=None, which the server coerced to an empty filter that matched no records. The command was never actually returning backfills.

@shivaam
shivaamforce-pushed the fix/positional-params-airflowctl-60142 branch from c88029d to 3d53d29CompareMay 12, 2026 05:17
apache#60142)
Required non-boolean parameters in auto-generated CLI commands are now
positional instead of flag-style, improving UX consistency with manually
defined commands like `dags pause`.
For primitive params parsed via AST, uses default detection to determine
positional vs flag. For Pydantic model fields, uses `is_required()`.
@shivaam
shivaamforce-pushed the fix/positional-params-airflowctl-60142 branch from 498d6a6 to 4270f6eCompareMay 12, 2026 05:44
@shivaam
shivaam marked this pull request as ready for review May 12, 2026 15:14

@bugraoz93bugraoz93 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good! Thanks for your patience and work @shivaam!

@bugraoz93bugraoz93 added the full tests needed We need to run full set of tests for this PR to merge label May 12, 2026
@bugraoz93bugraoz93 reopened this May 12, 2026
@bugraoz93

Copy link
Copy Markdown
Contributor

Added the full test label to see the integration tests green. We can merge after seeing it green :)

@bugraoz93bugraoz93 added the backport-to-airflow-ctl-v0-1-test Backport to airflow-ctl/v0-1-test label May 12, 2026
@bugraoz93

Copy link
Copy Markdown
Contributor

CI looks unrelated, ctl test looks good

@bugraoz93

bugraoz93 commented May 13, 2026

Copy link
Copy Markdown
Contributor

@shivaam Could you please rebase and resolve conflicts, we can merge afterwards

@shivaam

Copy link
Copy Markdown
ContributorAuthor

@bugraoz93 Seems like there is another PR that already solved this problem PR #66768 — "airflowctl: make required CLI params positional, keep optional as --flag" by 1fanwang
I dont think we need this anymore.

@shivaamshivaam closed this May 17, 2026
@bugraoz93

bugraoz93 commented May 23, 2026

Copy link
Copy Markdown
Contributor

Sorry @shivaam! Totally missed that one. I will try to be faster next time 😅 It also has some release notes changes need manually handled while backporting and should be cleaned while generating

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:airflow-ctlbackport-to-airflow-ctl-v0-1-testBackport to airflow-ctl/v0-1-testfull tests neededWe need to run full set of tests for this PR to merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Make positional parameters as a destionation paramater for auto-generated commands

3 participants

@shivaam@bugraoz93
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Make required params positional for auto-generated airflowctl commands - #64812

Closed
shivaam wants to merge 1 commit into
apache:mainfrom
shivaam:fix/positional-params-airflowctl-60142
Closed

Make required params positional for auto-generated airflowctl commands#64812
shivaam wants to merge 1 commit into
apache:mainfrom
shivaam:fix/positional-params-airflowctl-60142

Conversation

@shivaam

@shivaamshivaam commented Apr 6, 2026

Copy link
Copy Markdown
Contributor

Required non-boolean parameters in auto-generated CLI commands are now positional instead of flag-style, improving UX consistency with manually defined commands like dags pause.

Before:

airflowctl connections create --connection-id="test" --conn-type="mysql" --password=secret
airflowctl dags get --dag-id=example_bash_operator

After:

airflowctl connections create test mysql --password=secret
airflowctl dags get example_bash_operator

Changes

  • Track which operation parameters have default values during AST parsing
  • For primitive params, use the default info to decide positional vs flag
  • For Pydantic model fields, use is_required() to decide positional vs flag
  • Use _UNSET for arg_dest default to avoid argparse crash on positional args
  • Skip the has_default metadata key at runtime when mapping CLI args to method params
  • Updated unit tests and integration test commands to match new positional syntax
  • Added 2 new tests for positional arg behavior and default detection
  • Added newsfragment for the breaking change

Notes

  • _create_arg stays a dumb pass-through builder — all positional/flag logic is in the callers via one-liner conditionals
  • Booleans are never positional (they need --flag/--no-flag)
  • No special-casing for specific operations
  • Breaking change: existing --flag=value syntax for required params will no longer work. Since airflowctl is new and not yet stable, this should be acceptable.

Open question

Posted a clarifying question on the issue about whether all required params should be positional (e.g., xcom add has 5 positional args), or if we should limit it.

closes: #60142


Was generative AI tooling used to co-author this PR?
  • Yes — Claude Code (Claude Opus 4.6)

Generated-by: Claude Code (Claude Opus 4.6) following the guidelines

@bugraoz93bugraoz93 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the PR! I addressed your question. Could you please amend it to be both optional and positional, live side by side?
This way, we won't break anything while working, but rather add Airflow CLI positional arguments to improve parity and make migrations easier.

@@ -0,0 +1 @@
Required non-boolean parameters in auto-generated airflowctl commands are now positional instead of flag-style (e.g., ``airflowctl dags get example_dag`` instead of ``airflowctl dags get --dag-id=example_dag``).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This news fragment is for Airflow Core and not related to anything within airflowctl flow.
Since we are still in 0.x, significant changes are also expected at a certain level.
We are logging these things in the release notes

https://airflow.apache.org/docs/apache-airflow-ctl/stable/release_notes.html#significant-changes

@kaxil
kaxil requested a review from CopilotApril 10, 2026 19:55

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Note

Copilot was unable to run its full agentic suite in this review.

Updates auto-generated airflowctl commands so required non-boolean parameters are positional (UX aligned with manually-defined commands), and adjusts parsing/tests to support the breaking CLI syntax change.

Changes:

  • Track whether operation parameters have defaults during AST parsing and use it to decide positional vs flag args for primitive params.
  • Use Pydantic model_fields[].is_required() to decide positional vs flag args for datamodel fields (booleans remain flags).
  • Update unit/integration tests and add a newsfragment documenting the breaking change.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.

Show a summary per file
FileDescription
airflow-ctl/tests/airflow_ctl/ctl/test_cli_config.pyUpdates assertions for positional args; adds tests for positional behavior and default detection.
airflow-ctl/src/airflowctl/ctl/cli_config.pyAdds has_default metadata via AST parsing; generates positional args for required params; skips metadata during arg mapping and invocation.
airflow-ctl-tests/tests/airflowctl_tests/test_config_sensitive_masking.pyUpdates integration test commands to new positional syntax.
airflow-ctl-tests/tests/airflowctl_tests/test_airflowctl_commands.pyUpdates end-to-end command list to match new positional syntax across many commands.
airflow-core/newsfragments/64812.significant.rstDocuments the breaking CLI change for auto-generated commands.

if arg_name != "self":
args.append({arg_name: arg_type})
has_default = idx >= first_default_index
args.append({arg_name: arg_type, "has_default": has_default})

CopilotAIApr 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The parameters entries are now shaped as a dict with a dynamic key for the param name plus a reserved has_default key. This forces downstream code to iterate parameter.items() and special-case parameter_key == \"has_default\", which is brittle and harder to extend (and also makes collisions with a real param named has_default difficult to reason about). Consider switching to a stable structure like { \"name\": ..., \"type\": ..., \"has_default\": ... } for each parameter so consumers can access fields directly without item-iteration + skips.

Suggested change
args.append({arg_name: arg_type, "has_default": has_default})
args.append(
{"name": arg_name, "type": arg_type, "has_default": has_default}
)

Copilot uses AI. Check for mistakes.
Comment on lines +579 to +590
args = []
for parameter in operation.get("parameters"):
for parameter_key, parameter_type in parameter.items():
if parameter_key == "has_default":
continue
if self._is_primitive_type(type_name=parameter_type):
is_bool = parameter_type == "bool"
is_positional = not is_bool and not parameter.get("has_default", False)
sanitized_key = self._sanitize_arg_parameter_key(parameter_key)
args.append(
self._create_arg(
arg_flags=("--" + self._sanitize_arg_parameter_key(parameter_key),),
arg_flags=(parameter_key,) if is_positional else ("--" + sanitized_key,),

CopilotAIApr 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Related to the parameter-shape issue: the for parameter_key, parameter_type in parameter.items() loop plus if parameter_key == \"has_default\": continue is an avoidable control-flow hack that spreads across multiple call sites (also in _get_func). If you keep the current shape, a safer alternative is to store metadata under a dedicated nested key (e.g. {\"name\": ..., \"type\": ..., \"meta\": {\"has_default\": ...}}) or move metadata out of the per-parameter dict entirely so this loop can be simplified and cannot accidentally skip real parameters.

Copilot uses AI. Check for mistakes.
Comment on lines +566 to +598
def test_has_default_detection_in_ast_parsing(self):
"""Test that AST parsing correctly detects which params have defaults."""
from textwrap import dedent

temp_file = "test_has_default.py"
with open(temp_file, "w") as f:
f.write(
dedent("""
class TestOperations(BaseOperations):
def get(self, required_id: str) -> str | ServerResponseError:
pass
def search(self, query: str, limit: int = 10, offset: int = 0) -> str | ServerResponseError:
pass
""")
)

try:
command_factory = CommandFactory(file_path=temp_file)
for op in command_factory.operations:
if op["name"] == "get":
param = op["parameters"][0]
assert param.get("has_default") is False
elif op["name"] == "search":
query_param = op["parameters"][0]
assert query_param.get("has_default") is False
limit_param = op["parameters"][1]
assert limit_param.get("has_default") is True
offset_param = op["parameters"][2]
assert offset_param.get("has_default") is True
finally:
import os

os.remove(temp_file)

CopilotAIApr 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test writes a fixed filename (test_has_default.py) into the working directory, which can conflict under parallel test runs or when the CWD is not writable. Use pytest’s tmp_path/tmp_path_factory (or tempfile.NamedTemporaryFile) to create an isolated temporary file path, and open it with an explicit encoding (e.g. encoding=\"utf-8\") to match the production file-read behavior.

Suggested change
deftest_has_default_detection_in_ast_parsing(self):
"""Test that AST parsing correctly detects which params have defaults."""
fromtextwrapimportdedent
temp_file="test_has_default.py"
withopen(temp_file, "w") asf:
f.write(
dedent("""
classTestOperations(BaseOperations):
defget(self, required_id: str) ->str|ServerResponseError:
pass
defsearch(self, query: str, limit: int=10, offset: int=0) ->str|ServerResponseError:
pass
""")
)
try:
command_factory=CommandFactory(file_path=temp_file)
foropincommand_factory.operations:
ifop["name"] =="get":
param=op["parameters"][0]
assertparam.get("has_default") isFalse
elifop["name"] =="search":
query_param=op["parameters"][0]
assertquery_param.get("has_default") isFalse
limit_param=op["parameters"][1]
assertlimit_param.get("has_default") isTrue
offset_param=op["parameters"][2]
assertoffset_param.get("has_default") isTrue
finally:
importos
os.remove(temp_file)
deftest_has_default_detection_in_ast_parsing(self, tmp_path):
"""Test that AST parsing correctly detects which params have defaults."""
fromtextwrapimportdedent
temp_file=tmp_path/"test_has_default.py"
temp_file.write_text(
dedent("""
classTestOperations(BaseOperations):
defget(self, required_id: str) ->str|ServerResponseError:
pass
defsearch(self, query: str, limit: int=10, offset: int=0) ->str|ServerResponseError:
pass
"""),
encoding="utf-8",
)
command_factory=CommandFactory(file_path=str(temp_file))
foropincommand_factory.operations:
ifop["name"] =="get":
param=op["parameters"][0]
assertparam.get("has_default") isFalse
elifop["name"] =="search":
query_param=op["parameters"][0]
assertquery_param.get("has_default") isFalse
limit_param=op["parameters"][1]
assertlimit_param.get("has_default") isTrue
offset_param=op["parameters"][2]
assertoffset_param.get("has_default") isTrue

Copilot uses AI. Check for mistakes.
@bugraoz93

Copy link
Copy Markdown
Contributor

Moving to draft to manage release better, please make it ready again when you think ready for maintainers review

@bugraoz93
bugraoz93 marked this pull request as draft April 12, 2026 15:20
@shivaam

shivaam commented Apr 12, 2026

Copy link
Copy Markdown
ContributorAuthor

Moving to draft to manage release better, please make it ready again when you think ready for maintainers review
@bugraoz93!

Sorry I replied on the issue instead of the PR. argparse doesn't natively support this. Below I have listed some options.


I looked into supporting both positional and --flag styles for required params. Unfortunately argparse doesn't natively support this — a parameter is either positional (dag_id) or a flag (--dag-id), never both.

Why it's not straightforward:

The closest workaround is registering all params as --flags, adding a nargs='*' catch-all for bare values, and using parse_intermixed_args to map positionals into unfilled flag slots after parsing. More about this at the bottom of this comment. But this means we lose argparse's built-in validation, type checking, help formatting, and required=True for those params — we'd essentially reimplement positional argument handling ourselves on top of argparse.

Options:

  1. Keep it positional for required params (current PR) — the simplest, most conventional choice. Required params are positional, optional params are flags. All argparse features work, clean help output.

  2. Two-pass parsing — maintain two parsers per command: one with required params as positional, one with them as --flags required=True. Try positional first, fall back to flags. Users can use either style, just not mixed in the same invocation:

    # both work:
    airflowctl xcom add my_dag my_task my_key
    airflowctl xcom add --dag-id=my_dag --task-id=my_task --key=my_key
    # but not mixed:
    airflowctl xcom add my_dag --task-id=my_task my_key # would fail

    No argparse hacks needed (both parsers are standard), but doubles the parser setup and mixed usage isn't supported.

  3. Cap positional count — first 1-2 required params (like dag_id) are positional, rest stay as --flag required=True. Common CLI pattern (like git checkout <branch> --force). No hacks, all argparse features work.

I'd recommend Option 1 for simplicity. Happy to go with whichever you prefer!


Details on the parse_intermixed_args workaround (and why I didn't recommend it)

The idea is to register all required params as --flags only (no argparse positionals), add a single nargs='*' argument to collect any bare values, then map those bare values into whichever flag slots the user didn't fill:

PARAM_ORDER= ['dag_id', 'task_id', 'key']
parser.add_argument('--dag-id', dest='dag_id', default=None)
parser.add_argument('--task-id', dest='task_id', default=None)
parser.add_argument('--key', default=None)
parser.add_argument('positionals', nargs='*')
args=parser.parse_intermixed_args(argv)
# map bare values into unfilled flag slots, left to rightunfilled= [dfordinPARAM_ORDERifgetattr(args, d) isNone]
fori, valinenumerate(args.positionals):
setattr(args, unfilled[i], val)

parse_intermixed_args (Python 3.7+) allows flags and bare values to be freely interspersed, so all of these would work:

airflowctl xcom add d t k # all positional
airflowctl xcom add --dag-id=d --task-id=t --key=k # all flags
airflowctl xcom add d --task-id=t k # mixed
airflowctl xcom add --task-id=t my_dag my_key # gap-filling

The problem is what we lose:

  • Validation — can't use required=True on flags (argparse would reject positional-only usage), so we validate manually after gap-filling
  • Type checkingtype=int, choices=[...] etc. don't apply to the nargs='*' catch-all, so we type-check manually
  • Help output — shows [positionals ...] instead of named params like DAG_ID TASK_ID, and no indication which flags are required vs optional
  • argparse.REMAINDER and mutually exclusive groups with positionals — unsupported with parse_intermixed_args

This effectively means using argparse for flag parsing only and reimplementing positional handling on top of it.

@bugraoz93

Copy link
Copy Markdown
Contributor

@shivaam, this will make things a bit complicated. The two-pass parsing is still complex. I would say before we finish adding the command to the parser, we can reprocess the args, maybe even in get_parser, which can also be responsible for parsing the command. We may need to update the building part a bit but it should be possible with reprocessing

Even though we are still around 0.x.x, still thinking about how we could implement this in the best position. As it will be a user-facing change, we need to create a devlist discussion and ask to only keep positional for required or maintain both.

My idea is adding both can save us from the entire discussion flow and we could be backwards compatible, even though this is not trivial with argparase in Python.

@bugraoz93

Copy link
Copy Markdown
Contributor

Something like this
#65261

@shivaam

Copy link
Copy Markdown
ContributorAuthor

Closing in favor of #65261 which takes a better approach — supporting both positional and flag styles simultaneously via argv pre-processing, so there's no breaking change for existing users. Thanks @bugraoz93 for the guidance!

@shivaamshivaam closed this Apr 16, 2026
@shivaam

Copy link
Copy Markdown
ContributorAuthor

Something like this #65261

I was just thinking about this. Supporting both positional and flag styles for the same param isn't something most CLI libraries or popular tools do. We're adding maintenance burden, constraining future changes as all CLI libraries might not suppor this feature, and expanding the testing surface forever. Since airflowctl is still pre-1.0, feels like this is the natural window for a clean break rather than committing to maintaining a non-standard approach indefinitely.

Since the layer we are adding is light, either way works. Just wanted to share my thoughts.

@bugraoz93

Copy link
Copy Markdown
Contributor

Thanks @shivaam for your patience and joining the discussion! We have some procedures to follow as a community. :) Reopened yours, feel free to rebase and if ready we can ship

@bugraoz93

Copy link
Copy Markdown
Contributor

@shivaam

Copy link
Copy Markdown
ContributorAuthor

Sounds good. I will rebase the project.

@shivaam
shivaamforce-pushed the fix/positional-params-airflowctl-60142 branch 2 times, most recently from db3384e to c88029dCompareMay 12, 2026 04:37
"assets create-event 1",
# Backfill commands
"backfill list",
"backfill list example_bash_operator",

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Before this PR, airflowctl backfill list (no args) returned [] and exited 0, but that was a silent bug — the CLI passed dag_id=None, which the server coerced to an empty filter that matched no records. The command was never actually returning backfills.

@shivaam
shivaamforce-pushed the fix/positional-params-airflowctl-60142 branch from c88029d to 3d53d29CompareMay 12, 2026 05:17
apache#60142)
Required non-boolean parameters in auto-generated CLI commands are now
positional instead of flag-style, improving UX consistency with manually
defined commands like `dags pause`.
For primitive params parsed via AST, uses default detection to determine
positional vs flag. For Pydantic model fields, uses `is_required()`.
@shivaam
shivaamforce-pushed the fix/positional-params-airflowctl-60142 branch from 498d6a6 to 4270f6eCompareMay 12, 2026 05:44
@shivaam
shivaam marked this pull request as ready for review May 12, 2026 15:14

@bugraoz93bugraoz93 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good! Thanks for your patience and work @shivaam!

@bugraoz93bugraoz93 added the full tests needed We need to run full set of tests for this PR to merge label May 12, 2026
@bugraoz93bugraoz93 reopened this May 12, 2026
@bugraoz93

Copy link
Copy Markdown
Contributor

Added the full test label to see the integration tests green. We can merge after seeing it green :)

@bugraoz93bugraoz93 added the backport-to-airflow-ctl-v0-1-test Backport to airflow-ctl/v0-1-test label May 12, 2026
@bugraoz93

Copy link
Copy Markdown
Contributor

CI looks unrelated, ctl test looks good

@bugraoz93

bugraoz93 commented May 13, 2026

Copy link
Copy Markdown
Contributor

@shivaam Could you please rebase and resolve conflicts, we can merge afterwards

@shivaam

Copy link
Copy Markdown
ContributorAuthor

@bugraoz93 Seems like there is another PR that already solved this problem PR #66768 — "airflowctl: make required CLI params positional, keep optional as --flag" by 1fanwang
I dont think we need this anymore.

@shivaamshivaam closed this May 17, 2026
@bugraoz93

bugraoz93 commented May 23, 2026

Copy link
Copy Markdown
Contributor

Sorry @shivaam! Totally missed that one. I will try to be faster next time 😅 It also has some release notes changes need manually handled while backporting and should be cleaned while generating

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:airflow-ctlbackport-to-airflow-ctl-v0-1-testBackport to airflow-ctl/v0-1-testfull tests neededWe need to run full set of tests for this PR to merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Make positional parameters as a destionation paramater for auto-generated commands

3 participants

@shivaam@bugraoz93
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Make required params positional for auto-generated airflowctl commands - #64812

Closed
shivaam wants to merge 1 commit into
apache:mainfrom
shivaam:fix/positional-params-airflowctl-60142
Closed

Make required params positional for auto-generated airflowctl commands#64812
shivaam wants to merge 1 commit into
apache:mainfrom
shivaam:fix/positional-params-airflowctl-60142

Conversation

@shivaam

@shivaamshivaam commented Apr 6, 2026

Copy link
Copy Markdown
Contributor

Required non-boolean parameters in auto-generated CLI commands are now positional instead of flag-style, improving UX consistency with manually defined commands like dags pause.

Before:

airflowctl connections create --connection-id="test" --conn-type="mysql" --password=secret
airflowctl dags get --dag-id=example_bash_operator

After:

airflowctl connections create test mysql --password=secret
airflowctl dags get example_bash_operator

Changes

  • Track which operation parameters have default values during AST parsing
  • For primitive params, use the default info to decide positional vs flag
  • For Pydantic model fields, use is_required() to decide positional vs flag
  • Use _UNSET for arg_dest default to avoid argparse crash on positional args
  • Skip the has_default metadata key at runtime when mapping CLI args to method params
  • Updated unit tests and integration test commands to match new positional syntax
  • Added 2 new tests for positional arg behavior and default detection
  • Added newsfragment for the breaking change

Notes

  • _create_arg stays a dumb pass-through builder — all positional/flag logic is in the callers via one-liner conditionals
  • Booleans are never positional (they need --flag/--no-flag)
  • No special-casing for specific operations
  • Breaking change: existing --flag=value syntax for required params will no longer work. Since airflowctl is new and not yet stable, this should be acceptable.

Open question

Posted a clarifying question on the issue about whether all required params should be positional (e.g., xcom add has 5 positional args), or if we should limit it.

closes: #60142


Was generative AI tooling used to co-author this PR?
  • Yes — Claude Code (Claude Opus 4.6)

Generated-by: Claude Code (Claude Opus 4.6) following the guidelines

@bugraoz93bugraoz93 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the PR! I addressed your question. Could you please amend it to be both optional and positional, live side by side?
This way, we won't break anything while working, but rather add Airflow CLI positional arguments to improve parity and make migrations easier.

@@ -0,0 +1 @@
Required non-boolean parameters in auto-generated airflowctl commands are now positional instead of flag-style (e.g., ``airflowctl dags get example_dag`` instead of ``airflowctl dags get --dag-id=example_dag``).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This news fragment is for Airflow Core and not related to anything within airflowctl flow.
Since we are still in 0.x, significant changes are also expected at a certain level.
We are logging these things in the release notes

https://airflow.apache.org/docs/apache-airflow-ctl/stable/release_notes.html#significant-changes

@kaxil
kaxil requested a review from CopilotApril 10, 2026 19:55

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Note

Copilot was unable to run its full agentic suite in this review.

Updates auto-generated airflowctl commands so required non-boolean parameters are positional (UX aligned with manually-defined commands), and adjusts parsing/tests to support the breaking CLI syntax change.

Changes:

  • Track whether operation parameters have defaults during AST parsing and use it to decide positional vs flag args for primitive params.
  • Use Pydantic model_fields[].is_required() to decide positional vs flag args for datamodel fields (booleans remain flags).
  • Update unit/integration tests and add a newsfragment documenting the breaking change.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.

Show a summary per file
FileDescription
airflow-ctl/tests/airflow_ctl/ctl/test_cli_config.pyUpdates assertions for positional args; adds tests for positional behavior and default detection.
airflow-ctl/src/airflowctl/ctl/cli_config.pyAdds has_default metadata via AST parsing; generates positional args for required params; skips metadata during arg mapping and invocation.
airflow-ctl-tests/tests/airflowctl_tests/test_config_sensitive_masking.pyUpdates integration test commands to new positional syntax.
airflow-ctl-tests/tests/airflowctl_tests/test_airflowctl_commands.pyUpdates end-to-end command list to match new positional syntax across many commands.
airflow-core/newsfragments/64812.significant.rstDocuments the breaking CLI change for auto-generated commands.

if arg_name != "self":
args.append({arg_name: arg_type})
has_default = idx >= first_default_index
args.append({arg_name: arg_type, "has_default": has_default})

CopilotAIApr 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The parameters entries are now shaped as a dict with a dynamic key for the param name plus a reserved has_default key. This forces downstream code to iterate parameter.items() and special-case parameter_key == \"has_default\", which is brittle and harder to extend (and also makes collisions with a real param named has_default difficult to reason about). Consider switching to a stable structure like { \"name\": ..., \"type\": ..., \"has_default\": ... } for each parameter so consumers can access fields directly without item-iteration + skips.

Suggested change
args.append({arg_name: arg_type, "has_default": has_default})
args.append(
{"name": arg_name, "type": arg_type, "has_default": has_default}
)

Copilot uses AI. Check for mistakes.
Comment on lines +579 to +590
args = []
for parameter in operation.get("parameters"):
for parameter_key, parameter_type in parameter.items():
if parameter_key == "has_default":
continue
if self._is_primitive_type(type_name=parameter_type):
is_bool = parameter_type == "bool"
is_positional = not is_bool and not parameter.get("has_default", False)
sanitized_key = self._sanitize_arg_parameter_key(parameter_key)
args.append(
self._create_arg(
arg_flags=("--" + self._sanitize_arg_parameter_key(parameter_key),),
arg_flags=(parameter_key,) if is_positional else ("--" + sanitized_key,),

CopilotAIApr 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Related to the parameter-shape issue: the for parameter_key, parameter_type in parameter.items() loop plus if parameter_key == \"has_default\": continue is an avoidable control-flow hack that spreads across multiple call sites (also in _get_func). If you keep the current shape, a safer alternative is to store metadata under a dedicated nested key (e.g. {\"name\": ..., \"type\": ..., \"meta\": {\"has_default\": ...}}) or move metadata out of the per-parameter dict entirely so this loop can be simplified and cannot accidentally skip real parameters.

Copilot uses AI. Check for mistakes.
Comment on lines +566 to +598
def test_has_default_detection_in_ast_parsing(self):
"""Test that AST parsing correctly detects which params have defaults."""
from textwrap import dedent

temp_file = "test_has_default.py"
with open(temp_file, "w") as f:
f.write(
dedent("""
class TestOperations(BaseOperations):
def get(self, required_id: str) -> str | ServerResponseError:
pass
def search(self, query: str, limit: int = 10, offset: int = 0) -> str | ServerResponseError:
pass
""")
)

try:
command_factory = CommandFactory(file_path=temp_file)
for op in command_factory.operations:
if op["name"] == "get":
param = op["parameters"][0]
assert param.get("has_default") is False
elif op["name"] == "search":
query_param = op["parameters"][0]
assert query_param.get("has_default") is False
limit_param = op["parameters"][1]
assert limit_param.get("has_default") is True
offset_param = op["parameters"][2]
assert offset_param.get("has_default") is True
finally:
import os

os.remove(temp_file)

CopilotAIApr 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test writes a fixed filename (test_has_default.py) into the working directory, which can conflict under parallel test runs or when the CWD is not writable. Use pytest’s tmp_path/tmp_path_factory (or tempfile.NamedTemporaryFile) to create an isolated temporary file path, and open it with an explicit encoding (e.g. encoding=\"utf-8\") to match the production file-read behavior.

Suggested change
deftest_has_default_detection_in_ast_parsing(self):
"""Test that AST parsing correctly detects which params have defaults."""
fromtextwrapimportdedent
temp_file="test_has_default.py"
withopen(temp_file, "w") asf:
f.write(
dedent("""
classTestOperations(BaseOperations):
defget(self, required_id: str) ->str|ServerResponseError:
pass
defsearch(self, query: str, limit: int=10, offset: int=0) ->str|ServerResponseError:
pass
""")
)
try:
command_factory=CommandFactory(file_path=temp_file)
foropincommand_factory.operations:
ifop["name"] =="get":
param=op["parameters"][0]
assertparam.get("has_default") isFalse
elifop["name"] =="search":
query_param=op["parameters"][0]
assertquery_param.get("has_default") isFalse
limit_param=op["parameters"][1]
assertlimit_param.get("has_default") isTrue
offset_param=op["parameters"][2]
assertoffset_param.get("has_default") isTrue
finally:
importos
os.remove(temp_file)
deftest_has_default_detection_in_ast_parsing(self, tmp_path):
"""Test that AST parsing correctly detects which params have defaults."""
fromtextwrapimportdedent
temp_file=tmp_path/"test_has_default.py"
temp_file.write_text(
dedent("""
classTestOperations(BaseOperations):
defget(self, required_id: str) ->str|ServerResponseError:
pass
defsearch(self, query: str, limit: int=10, offset: int=0) ->str|ServerResponseError:
pass
"""),
encoding="utf-8",
)
command_factory=CommandFactory(file_path=str(temp_file))
foropincommand_factory.operations:
ifop["name"] =="get":
param=op["parameters"][0]
assertparam.get("has_default") isFalse
elifop["name"] =="search":
query_param=op["parameters"][0]
assertquery_param.get("has_default") isFalse
limit_param=op["parameters"][1]
assertlimit_param.get("has_default") isTrue
offset_param=op["parameters"][2]
assertoffset_param.get("has_default") isTrue

Copilot uses AI. Check for mistakes.
@bugraoz93

Copy link
Copy Markdown
Contributor

Moving to draft to manage release better, please make it ready again when you think ready for maintainers review

@bugraoz93
bugraoz93 marked this pull request as draft April 12, 2026 15:20
@shivaam

shivaam commented Apr 12, 2026

Copy link
Copy Markdown
ContributorAuthor

Moving to draft to manage release better, please make it ready again when you think ready for maintainers review
@bugraoz93!

Sorry I replied on the issue instead of the PR. argparse doesn't natively support this. Below I have listed some options.


I looked into supporting both positional and --flag styles for required params. Unfortunately argparse doesn't natively support this — a parameter is either positional (dag_id) or a flag (--dag-id), never both.

Why it's not straightforward:

The closest workaround is registering all params as --flags, adding a nargs='*' catch-all for bare values, and using parse_intermixed_args to map positionals into unfilled flag slots after parsing. More about this at the bottom of this comment. But this means we lose argparse's built-in validation, type checking, help formatting, and required=True for those params — we'd essentially reimplement positional argument handling ourselves on top of argparse.

Options:

  1. Keep it positional for required params (current PR) — the simplest, most conventional choice. Required params are positional, optional params are flags. All argparse features work, clean help output.

  2. Two-pass parsing — maintain two parsers per command: one with required params as positional, one with them as --flags required=True. Try positional first, fall back to flags. Users can use either style, just not mixed in the same invocation:

    # both work:
    airflowctl xcom add my_dag my_task my_key
    airflowctl xcom add --dag-id=my_dag --task-id=my_task --key=my_key
    # but not mixed:
    airflowctl xcom add my_dag --task-id=my_task my_key # would fail

    No argparse hacks needed (both parsers are standard), but doubles the parser setup and mixed usage isn't supported.

  3. Cap positional count — first 1-2 required params (like dag_id) are positional, rest stay as --flag required=True. Common CLI pattern (like git checkout <branch> --force). No hacks, all argparse features work.

I'd recommend Option 1 for simplicity. Happy to go with whichever you prefer!


Details on the parse_intermixed_args workaround (and why I didn't recommend it)

The idea is to register all required params as --flags only (no argparse positionals), add a single nargs='*' argument to collect any bare values, then map those bare values into whichever flag slots the user didn't fill:

PARAM_ORDER= ['dag_id', 'task_id', 'key']
parser.add_argument('--dag-id', dest='dag_id', default=None)
parser.add_argument('--task-id', dest='task_id', default=None)
parser.add_argument('--key', default=None)
parser.add_argument('positionals', nargs='*')
args=parser.parse_intermixed_args(argv)
# map bare values into unfilled flag slots, left to rightunfilled= [dfordinPARAM_ORDERifgetattr(args, d) isNone]
fori, valinenumerate(args.positionals):
setattr(args, unfilled[i], val)

parse_intermixed_args (Python 3.7+) allows flags and bare values to be freely interspersed, so all of these would work:

airflowctl xcom add d t k # all positional
airflowctl xcom add --dag-id=d --task-id=t --key=k # all flags
airflowctl xcom add d --task-id=t k # mixed
airflowctl xcom add --task-id=t my_dag my_key # gap-filling

The problem is what we lose:

  • Validation — can't use required=True on flags (argparse would reject positional-only usage), so we validate manually after gap-filling
  • Type checkingtype=int, choices=[...] etc. don't apply to the nargs='*' catch-all, so we type-check manually
  • Help output — shows [positionals ...] instead of named params like DAG_ID TASK_ID, and no indication which flags are required vs optional
  • argparse.REMAINDER and mutually exclusive groups with positionals — unsupported with parse_intermixed_args

This effectively means using argparse for flag parsing only and reimplementing positional handling on top of it.

@bugraoz93

Copy link
Copy Markdown
Contributor

@shivaam, this will make things a bit complicated. The two-pass parsing is still complex. I would say before we finish adding the command to the parser, we can reprocess the args, maybe even in get_parser, which can also be responsible for parsing the command. We may need to update the building part a bit but it should be possible with reprocessing

Even though we are still around 0.x.x, still thinking about how we could implement this in the best position. As it will be a user-facing change, we need to create a devlist discussion and ask to only keep positional for required or maintain both.

My idea is adding both can save us from the entire discussion flow and we could be backwards compatible, even though this is not trivial with argparase in Python.

@bugraoz93

Copy link
Copy Markdown
Contributor

Something like this
#65261

@shivaam

Copy link
Copy Markdown
ContributorAuthor

Closing in favor of #65261 which takes a better approach — supporting both positional and flag styles simultaneously via argv pre-processing, so there's no breaking change for existing users. Thanks @bugraoz93 for the guidance!

@shivaamshivaam closed this Apr 16, 2026
@shivaam

Copy link
Copy Markdown
ContributorAuthor

Something like this #65261

I was just thinking about this. Supporting both positional and flag styles for the same param isn't something most CLI libraries or popular tools do. We're adding maintenance burden, constraining future changes as all CLI libraries might not suppor this feature, and expanding the testing surface forever. Since airflowctl is still pre-1.0, feels like this is the natural window for a clean break rather than committing to maintaining a non-standard approach indefinitely.

Since the layer we are adding is light, either way works. Just wanted to share my thoughts.

@bugraoz93

Copy link
Copy Markdown
Contributor

Thanks @shivaam for your patience and joining the discussion! We have some procedures to follow as a community. :) Reopened yours, feel free to rebase and if ready we can ship

@bugraoz93

Copy link
Copy Markdown
Contributor

@shivaam

Copy link
Copy Markdown
ContributorAuthor

Sounds good. I will rebase the project.

@shivaam
shivaamforce-pushed the fix/positional-params-airflowctl-60142 branch 2 times, most recently from db3384e to c88029dCompareMay 12, 2026 04:37
"assets create-event 1",
# Backfill commands
"backfill list",
"backfill list example_bash_operator",

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Before this PR, airflowctl backfill list (no args) returned [] and exited 0, but that was a silent bug — the CLI passed dag_id=None, which the server coerced to an empty filter that matched no records. The command was never actually returning backfills.

@shivaam
shivaamforce-pushed the fix/positional-params-airflowctl-60142 branch from c88029d to 3d53d29CompareMay 12, 2026 05:17
apache#60142)
Required non-boolean parameters in auto-generated CLI commands are now
positional instead of flag-style, improving UX consistency with manually
defined commands like `dags pause`.
For primitive params parsed via AST, uses default detection to determine
positional vs flag. For Pydantic model fields, uses `is_required()`.
@shivaam
shivaamforce-pushed the fix/positional-params-airflowctl-60142 branch from 498d6a6 to 4270f6eCompareMay 12, 2026 05:44
@shivaam
shivaam marked this pull request as ready for review May 12, 2026 15:14

@bugraoz93bugraoz93 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good! Thanks for your patience and work @shivaam!

@bugraoz93bugraoz93 added the full tests needed We need to run full set of tests for this PR to merge label May 12, 2026
@bugraoz93bugraoz93 reopened this May 12, 2026
@bugraoz93

Copy link
Copy Markdown
Contributor

Added the full test label to see the integration tests green. We can merge after seeing it green :)

@bugraoz93bugraoz93 added the backport-to-airflow-ctl-v0-1-test Backport to airflow-ctl/v0-1-test label May 12, 2026
@bugraoz93

Copy link
Copy Markdown
Contributor

CI looks unrelated, ctl test looks good

@bugraoz93

bugraoz93 commented May 13, 2026

Copy link
Copy Markdown
Contributor

@shivaam Could you please rebase and resolve conflicts, we can merge afterwards

@shivaam

Copy link
Copy Markdown
ContributorAuthor

@bugraoz93 Seems like there is another PR that already solved this problem PR #66768 — "airflowctl: make required CLI params positional, keep optional as --flag" by 1fanwang
I dont think we need this anymore.

@shivaamshivaam closed this May 17, 2026
@bugraoz93

bugraoz93 commented May 23, 2026

Copy link
Copy Markdown
Contributor

Sorry @shivaam! Totally missed that one. I will try to be faster next time 😅 It also has some release notes changes need manually handled while backporting and should be cleaned while generating

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:airflow-ctlbackport-to-airflow-ctl-v0-1-testBackport to airflow-ctl/v0-1-testfull tests neededWe need to run full set of tests for this PR to merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Make positional parameters as a destionation paramater for auto-generated commands

3 participants

@shivaam@bugraoz93
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Make required params positional for auto-generated airflowctl commands - #64812

Closed
shivaam wants to merge 1 commit into
apache:mainfrom
shivaam:fix/positional-params-airflowctl-60142
Closed

Make required params positional for auto-generated airflowctl commands#64812
shivaam wants to merge 1 commit into
apache:mainfrom
shivaam:fix/positional-params-airflowctl-60142

Conversation

@shivaam

@shivaamshivaam commented Apr 6, 2026

Copy link
Copy Markdown
Contributor

Required non-boolean parameters in auto-generated CLI commands are now positional instead of flag-style, improving UX consistency with manually defined commands like dags pause.

Before:

airflowctl connections create --connection-id="test" --conn-type="mysql" --password=secret
airflowctl dags get --dag-id=example_bash_operator

After:

airflowctl connections create test mysql --password=secret
airflowctl dags get example_bash_operator

Changes

  • Track which operation parameters have default values during AST parsing
  • For primitive params, use the default info to decide positional vs flag
  • For Pydantic model fields, use is_required() to decide positional vs flag
  • Use _UNSET for arg_dest default to avoid argparse crash on positional args
  • Skip the has_default metadata key at runtime when mapping CLI args to method params
  • Updated unit tests and integration test commands to match new positional syntax
  • Added 2 new tests for positional arg behavior and default detection
  • Added newsfragment for the breaking change

Notes

  • _create_arg stays a dumb pass-through builder — all positional/flag logic is in the callers via one-liner conditionals
  • Booleans are never positional (they need --flag/--no-flag)
  • No special-casing for specific operations
  • Breaking change: existing --flag=value syntax for required params will no longer work. Since airflowctl is new and not yet stable, this should be acceptable.

Open question

Posted a clarifying question on the issue about whether all required params should be positional (e.g., xcom add has 5 positional args), or if we should limit it.

closes: #60142


Was generative AI tooling used to co-author this PR?
  • Yes — Claude Code (Claude Opus 4.6)

Generated-by: Claude Code (Claude Opus 4.6) following the guidelines

@bugraoz93bugraoz93 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the PR! I addressed your question. Could you please amend it to be both optional and positional, live side by side?
This way, we won't break anything while working, but rather add Airflow CLI positional arguments to improve parity and make migrations easier.

@@ -0,0 +1 @@
Required non-boolean parameters in auto-generated airflowctl commands are now positional instead of flag-style (e.g., ``airflowctl dags get example_dag`` instead of ``airflowctl dags get --dag-id=example_dag``).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This news fragment is for Airflow Core and not related to anything within airflowctl flow.
Since we are still in 0.x, significant changes are also expected at a certain level.
We are logging these things in the release notes

https://airflow.apache.org/docs/apache-airflow-ctl/stable/release_notes.html#significant-changes

@kaxil
kaxil requested a review from CopilotApril 10, 2026 19:55

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Note

Copilot was unable to run its full agentic suite in this review.

Updates auto-generated airflowctl commands so required non-boolean parameters are positional (UX aligned with manually-defined commands), and adjusts parsing/tests to support the breaking CLI syntax change.

Changes:

  • Track whether operation parameters have defaults during AST parsing and use it to decide positional vs flag args for primitive params.
  • Use Pydantic model_fields[].is_required() to decide positional vs flag args for datamodel fields (booleans remain flags).
  • Update unit/integration tests and add a newsfragment documenting the breaking change.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.

Show a summary per file
FileDescription
airflow-ctl/tests/airflow_ctl/ctl/test_cli_config.pyUpdates assertions for positional args; adds tests for positional behavior and default detection.
airflow-ctl/src/airflowctl/ctl/cli_config.pyAdds has_default metadata via AST parsing; generates positional args for required params; skips metadata during arg mapping and invocation.
airflow-ctl-tests/tests/airflowctl_tests/test_config_sensitive_masking.pyUpdates integration test commands to new positional syntax.
airflow-ctl-tests/tests/airflowctl_tests/test_airflowctl_commands.pyUpdates end-to-end command list to match new positional syntax across many commands.
airflow-core/newsfragments/64812.significant.rstDocuments the breaking CLI change for auto-generated commands.

if arg_name != "self":
args.append({arg_name: arg_type})
has_default = idx >= first_default_index
args.append({arg_name: arg_type, "has_default": has_default})

CopilotAIApr 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The parameters entries are now shaped as a dict with a dynamic key for the param name plus a reserved has_default key. This forces downstream code to iterate parameter.items() and special-case parameter_key == \"has_default\", which is brittle and harder to extend (and also makes collisions with a real param named has_default difficult to reason about). Consider switching to a stable structure like { \"name\": ..., \"type\": ..., \"has_default\": ... } for each parameter so consumers can access fields directly without item-iteration + skips.

Suggested change
args.append({arg_name: arg_type, "has_default": has_default})
args.append(
{"name": arg_name, "type": arg_type, "has_default": has_default}
)

Copilot uses AI. Check for mistakes.
Comment on lines +579 to +590
args = []
for parameter in operation.get("parameters"):
for parameter_key, parameter_type in parameter.items():
if parameter_key == "has_default":
continue
if self._is_primitive_type(type_name=parameter_type):
is_bool = parameter_type == "bool"
is_positional = not is_bool and not parameter.get("has_default", False)
sanitized_key = self._sanitize_arg_parameter_key(parameter_key)
args.append(
self._create_arg(
arg_flags=("--" + self._sanitize_arg_parameter_key(parameter_key),),
arg_flags=(parameter_key,) if is_positional else ("--" + sanitized_key,),

CopilotAIApr 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Related to the parameter-shape issue: the for parameter_key, parameter_type in parameter.items() loop plus if parameter_key == \"has_default\": continue is an avoidable control-flow hack that spreads across multiple call sites (also in _get_func). If you keep the current shape, a safer alternative is to store metadata under a dedicated nested key (e.g. {\"name\": ..., \"type\": ..., \"meta\": {\"has_default\": ...}}) or move metadata out of the per-parameter dict entirely so this loop can be simplified and cannot accidentally skip real parameters.

Copilot uses AI. Check for mistakes.
Comment on lines +566 to +598
def test_has_default_detection_in_ast_parsing(self):
"""Test that AST parsing correctly detects which params have defaults."""
from textwrap import dedent

temp_file = "test_has_default.py"
with open(temp_file, "w") as f:
f.write(
dedent("""
class TestOperations(BaseOperations):
def get(self, required_id: str) -> str | ServerResponseError:
pass
def search(self, query: str, limit: int = 10, offset: int = 0) -> str | ServerResponseError:
pass
""")
)

try:
command_factory = CommandFactory(file_path=temp_file)
for op in command_factory.operations:
if op["name"] == "get":
param = op["parameters"][0]
assert param.get("has_default") is False
elif op["name"] == "search":
query_param = op["parameters"][0]
assert query_param.get("has_default") is False
limit_param = op["parameters"][1]
assert limit_param.get("has_default") is True
offset_param = op["parameters"][2]
assert offset_param.get("has_default") is True
finally:
import os

os.remove(temp_file)

CopilotAIApr 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test writes a fixed filename (test_has_default.py) into the working directory, which can conflict under parallel test runs or when the CWD is not writable. Use pytest’s tmp_path/tmp_path_factory (or tempfile.NamedTemporaryFile) to create an isolated temporary file path, and open it with an explicit encoding (e.g. encoding=\"utf-8\") to match the production file-read behavior.

Suggested change
deftest_has_default_detection_in_ast_parsing(self):
"""Test that AST parsing correctly detects which params have defaults."""
fromtextwrapimportdedent
temp_file="test_has_default.py"
withopen(temp_file, "w") asf:
f.write(
dedent("""
classTestOperations(BaseOperations):
defget(self, required_id: str) ->str|ServerResponseError:
pass
defsearch(self, query: str, limit: int=10, offset: int=0) ->str|ServerResponseError:
pass
""")
)
try:
command_factory=CommandFactory(file_path=temp_file)
foropincommand_factory.operations:
ifop["name"] =="get":
param=op["parameters"][0]
assertparam.get("has_default") isFalse
elifop["name"] =="search":
query_param=op["parameters"][0]
assertquery_param.get("has_default") isFalse
limit_param=op["parameters"][1]
assertlimit_param.get("has_default") isTrue
offset_param=op["parameters"][2]
assertoffset_param.get("has_default") isTrue
finally:
importos
os.remove(temp_file)
deftest_has_default_detection_in_ast_parsing(self, tmp_path):
"""Test that AST parsing correctly detects which params have defaults."""
fromtextwrapimportdedent
temp_file=tmp_path/"test_has_default.py"
temp_file.write_text(
dedent("""
classTestOperations(BaseOperations):
defget(self, required_id: str) ->str|ServerResponseError:
pass
defsearch(self, query: str, limit: int=10, offset: int=0) ->str|ServerResponseError:
pass
"""),
encoding="utf-8",
)
command_factory=CommandFactory(file_path=str(temp_file))
foropincommand_factory.operations:
ifop["name"] =="get":
param=op["parameters"][0]
assertparam.get("has_default") isFalse
elifop["name"] =="search":
query_param=op["parameters"][0]
assertquery_param.get("has_default") isFalse
limit_param=op["parameters"][1]
assertlimit_param.get("has_default") isTrue
offset_param=op["parameters"][2]
assertoffset_param.get("has_default") isTrue

Copilot uses AI. Check for mistakes.
@bugraoz93

Copy link
Copy Markdown
Contributor

Moving to draft to manage release better, please make it ready again when you think ready for maintainers review

@bugraoz93
bugraoz93 marked this pull request as draft April 12, 2026 15:20
@shivaam

shivaam commented Apr 12, 2026

Copy link
Copy Markdown
ContributorAuthor

Moving to draft to manage release better, please make it ready again when you think ready for maintainers review
@bugraoz93!

Sorry I replied on the issue instead of the PR. argparse doesn't natively support this. Below I have listed some options.


I looked into supporting both positional and --flag styles for required params. Unfortunately argparse doesn't natively support this — a parameter is either positional (dag_id) or a flag (--dag-id), never both.

Why it's not straightforward:

The closest workaround is registering all params as --flags, adding a nargs='*' catch-all for bare values, and using parse_intermixed_args to map positionals into unfilled flag slots after parsing. More about this at the bottom of this comment. But this means we lose argparse's built-in validation, type checking, help formatting, and required=True for those params — we'd essentially reimplement positional argument handling ourselves on top of argparse.

Options:

  1. Keep it positional for required params (current PR) — the simplest, most conventional choice. Required params are positional, optional params are flags. All argparse features work, clean help output.

  2. Two-pass parsing — maintain two parsers per command: one with required params as positional, one with them as --flags required=True. Try positional first, fall back to flags. Users can use either style, just not mixed in the same invocation:

    # both work:
    airflowctl xcom add my_dag my_task my_key
    airflowctl xcom add --dag-id=my_dag --task-id=my_task --key=my_key
    # but not mixed:
    airflowctl xcom add my_dag --task-id=my_task my_key # would fail

    No argparse hacks needed (both parsers are standard), but doubles the parser setup and mixed usage isn't supported.

  3. Cap positional count — first 1-2 required params (like dag_id) are positional, rest stay as --flag required=True. Common CLI pattern (like git checkout <branch> --force). No hacks, all argparse features work.

I'd recommend Option 1 for simplicity. Happy to go with whichever you prefer!


Details on the parse_intermixed_args workaround (and why I didn't recommend it)

The idea is to register all required params as --flags only (no argparse positionals), add a single nargs='*' argument to collect any bare values, then map those bare values into whichever flag slots the user didn't fill:

PARAM_ORDER= ['dag_id', 'task_id', 'key']
parser.add_argument('--dag-id', dest='dag_id', default=None)
parser.add_argument('--task-id', dest='task_id', default=None)
parser.add_argument('--key', default=None)
parser.add_argument('positionals', nargs='*')
args=parser.parse_intermixed_args(argv)
# map bare values into unfilled flag slots, left to rightunfilled= [dfordinPARAM_ORDERifgetattr(args, d) isNone]
fori, valinenumerate(args.positionals):
setattr(args, unfilled[i], val)

parse_intermixed_args (Python 3.7+) allows flags and bare values to be freely interspersed, so all of these would work:

airflowctl xcom add d t k # all positional
airflowctl xcom add --dag-id=d --task-id=t --key=k # all flags
airflowctl xcom add d --task-id=t k # mixed
airflowctl xcom add --task-id=t my_dag my_key # gap-filling

The problem is what we lose:

  • Validation — can't use required=True on flags (argparse would reject positional-only usage), so we validate manually after gap-filling
  • Type checkingtype=int, choices=[...] etc. don't apply to the nargs='*' catch-all, so we type-check manually
  • Help output — shows [positionals ...] instead of named params like DAG_ID TASK_ID, and no indication which flags are required vs optional
  • argparse.REMAINDER and mutually exclusive groups with positionals — unsupported with parse_intermixed_args

This effectively means using argparse for flag parsing only and reimplementing positional handling on top of it.

@bugraoz93

Copy link
Copy Markdown
Contributor

@shivaam, this will make things a bit complicated. The two-pass parsing is still complex. I would say before we finish adding the command to the parser, we can reprocess the args, maybe even in get_parser, which can also be responsible for parsing the command. We may need to update the building part a bit but it should be possible with reprocessing

Even though we are still around 0.x.x, still thinking about how we could implement this in the best position. As it will be a user-facing change, we need to create a devlist discussion and ask to only keep positional for required or maintain both.

My idea is adding both can save us from the entire discussion flow and we could be backwards compatible, even though this is not trivial with argparase in Python.

@bugraoz93

Copy link
Copy Markdown
Contributor

Something like this
#65261

@shivaam

Copy link
Copy Markdown
ContributorAuthor

Closing in favor of #65261 which takes a better approach — supporting both positional and flag styles simultaneously via argv pre-processing, so there's no breaking change for existing users. Thanks @bugraoz93 for the guidance!

@shivaamshivaam closed this Apr 16, 2026
@shivaam

Copy link
Copy Markdown
ContributorAuthor

Something like this #65261

I was just thinking about this. Supporting both positional and flag styles for the same param isn't something most CLI libraries or popular tools do. We're adding maintenance burden, constraining future changes as all CLI libraries might not suppor this feature, and expanding the testing surface forever. Since airflowctl is still pre-1.0, feels like this is the natural window for a clean break rather than committing to maintaining a non-standard approach indefinitely.

Since the layer we are adding is light, either way works. Just wanted to share my thoughts.

@bugraoz93

Copy link
Copy Markdown
Contributor

Thanks @shivaam for your patience and joining the discussion! We have some procedures to follow as a community. :) Reopened yours, feel free to rebase and if ready we can ship

@bugraoz93

Copy link
Copy Markdown
Contributor

@shivaam

Copy link
Copy Markdown
ContributorAuthor

Sounds good. I will rebase the project.

@shivaam
shivaamforce-pushed the fix/positional-params-airflowctl-60142 branch 2 times, most recently from db3384e to c88029dCompareMay 12, 2026 04:37
"assets create-event 1",
# Backfill commands
"backfill list",
"backfill list example_bash_operator",

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Before this PR, airflowctl backfill list (no args) returned [] and exited 0, but that was a silent bug — the CLI passed dag_id=None, which the server coerced to an empty filter that matched no records. The command was never actually returning backfills.

@shivaam
shivaamforce-pushed the fix/positional-params-airflowctl-60142 branch from c88029d to 3d53d29CompareMay 12, 2026 05:17
apache#60142)
Required non-boolean parameters in auto-generated CLI commands are now
positional instead of flag-style, improving UX consistency with manually
defined commands like `dags pause`.
For primitive params parsed via AST, uses default detection to determine
positional vs flag. For Pydantic model fields, uses `is_required()`.
@shivaam
shivaamforce-pushed the fix/positional-params-airflowctl-60142 branch from 498d6a6 to 4270f6eCompareMay 12, 2026 05:44
@shivaam
shivaam marked this pull request as ready for review May 12, 2026 15:14

@bugraoz93bugraoz93 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good! Thanks for your patience and work @shivaam!

@bugraoz93bugraoz93 added the full tests needed We need to run full set of tests for this PR to merge label May 12, 2026
@bugraoz93bugraoz93 reopened this May 12, 2026
@bugraoz93

Copy link
Copy Markdown
Contributor

Added the full test label to see the integration tests green. We can merge after seeing it green :)

@bugraoz93bugraoz93 added the backport-to-airflow-ctl-v0-1-test Backport to airflow-ctl/v0-1-test label May 12, 2026
@bugraoz93

Copy link
Copy Markdown
Contributor

CI looks unrelated, ctl test looks good

@bugraoz93

bugraoz93 commented May 13, 2026

Copy link
Copy Markdown
Contributor

@shivaam Could you please rebase and resolve conflicts, we can merge afterwards

@shivaam

Copy link
Copy Markdown
ContributorAuthor

@bugraoz93 Seems like there is another PR that already solved this problem PR #66768 — "airflowctl: make required CLI params positional, keep optional as --flag" by 1fanwang
I dont think we need this anymore.

@shivaamshivaam closed this May 17, 2026
@bugraoz93

bugraoz93 commented May 23, 2026

Copy link
Copy Markdown
Contributor

Sorry @shivaam! Totally missed that one. I will try to be faster next time 😅 It also has some release notes changes need manually handled while backporting and should be cleaned while generating

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:airflow-ctlbackport-to-airflow-ctl-v0-1-testBackport to airflow-ctl/v0-1-testfull tests neededWe need to run full set of tests for this PR to merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Make positional parameters as a destionation paramater for auto-generated commands

3 participants

@shivaam@bugraoz93
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Make required params positional for auto-generated airflowctl commands - #64812

Closed
shivaam wants to merge 1 commit into
apache:mainfrom
shivaam:fix/positional-params-airflowctl-60142
Closed

Make required params positional for auto-generated airflowctl commands#64812
shivaam wants to merge 1 commit into
apache:mainfrom
shivaam:fix/positional-params-airflowctl-60142

Conversation

@shivaam

@shivaamshivaam commented Apr 6, 2026

Copy link
Copy Markdown
Contributor

Required non-boolean parameters in auto-generated CLI commands are now positional instead of flag-style, improving UX consistency with manually defined commands like dags pause.

Before:

airflowctl connections create --connection-id="test" --conn-type="mysql" --password=secret
airflowctl dags get --dag-id=example_bash_operator

After:

airflowctl connections create test mysql --password=secret
airflowctl dags get example_bash_operator

Changes

  • Track which operation parameters have default values during AST parsing
  • For primitive params, use the default info to decide positional vs flag
  • For Pydantic model fields, use is_required() to decide positional vs flag
  • Use _UNSET for arg_dest default to avoid argparse crash on positional args
  • Skip the has_default metadata key at runtime when mapping CLI args to method params
  • Updated unit tests and integration test commands to match new positional syntax
  • Added 2 new tests for positional arg behavior and default detection
  • Added newsfragment for the breaking change

Notes

  • _create_arg stays a dumb pass-through builder — all positional/flag logic is in the callers via one-liner conditionals
  • Booleans are never positional (they need --flag/--no-flag)
  • No special-casing for specific operations
  • Breaking change: existing --flag=value syntax for required params will no longer work. Since airflowctl is new and not yet stable, this should be acceptable.

Open question

Posted a clarifying question on the issue about whether all required params should be positional (e.g., xcom add has 5 positional args), or if we should limit it.

closes: #60142


Was generative AI tooling used to co-author this PR?
  • Yes — Claude Code (Claude Opus 4.6)

Generated-by: Claude Code (Claude Opus 4.6) following the guidelines

@bugraoz93bugraoz93 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the PR! I addressed your question. Could you please amend it to be both optional and positional, live side by side?
This way, we won't break anything while working, but rather add Airflow CLI positional arguments to improve parity and make migrations easier.

@@ -0,0 +1 @@
Required non-boolean parameters in auto-generated airflowctl commands are now positional instead of flag-style (e.g., ``airflowctl dags get example_dag`` instead of ``airflowctl dags get --dag-id=example_dag``).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This news fragment is for Airflow Core and not related to anything within airflowctl flow.
Since we are still in 0.x, significant changes are also expected at a certain level.
We are logging these things in the release notes

https://airflow.apache.org/docs/apache-airflow-ctl/stable/release_notes.html#significant-changes

@kaxil
kaxil requested a review from CopilotApril 10, 2026 19:55

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Note

Copilot was unable to run its full agentic suite in this review.

Updates auto-generated airflowctl commands so required non-boolean parameters are positional (UX aligned with manually-defined commands), and adjusts parsing/tests to support the breaking CLI syntax change.

Changes:

  • Track whether operation parameters have defaults during AST parsing and use it to decide positional vs flag args for primitive params.
  • Use Pydantic model_fields[].is_required() to decide positional vs flag args for datamodel fields (booleans remain flags).
  • Update unit/integration tests and add a newsfragment documenting the breaking change.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.

Show a summary per file
FileDescription
airflow-ctl/tests/airflow_ctl/ctl/test_cli_config.pyUpdates assertions for positional args; adds tests for positional behavior and default detection.
airflow-ctl/src/airflowctl/ctl/cli_config.pyAdds has_default metadata via AST parsing; generates positional args for required params; skips metadata during arg mapping and invocation.
airflow-ctl-tests/tests/airflowctl_tests/test_config_sensitive_masking.pyUpdates integration test commands to new positional syntax.
airflow-ctl-tests/tests/airflowctl_tests/test_airflowctl_commands.pyUpdates end-to-end command list to match new positional syntax across many commands.
airflow-core/newsfragments/64812.significant.rstDocuments the breaking CLI change for auto-generated commands.

if arg_name != "self":
args.append({arg_name: arg_type})
has_default = idx >= first_default_index
args.append({arg_name: arg_type, "has_default": has_default})

CopilotAIApr 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The parameters entries are now shaped as a dict with a dynamic key for the param name plus a reserved has_default key. This forces downstream code to iterate parameter.items() and special-case parameter_key == \"has_default\", which is brittle and harder to extend (and also makes collisions with a real param named has_default difficult to reason about). Consider switching to a stable structure like { \"name\": ..., \"type\": ..., \"has_default\": ... } for each parameter so consumers can access fields directly without item-iteration + skips.

Suggested change
args.append({arg_name: arg_type, "has_default": has_default})
args.append(
{"name": arg_name, "type": arg_type, "has_default": has_default}
)

Copilot uses AI. Check for mistakes.
Comment on lines +579 to +590
args = []
for parameter in operation.get("parameters"):
for parameter_key, parameter_type in parameter.items():
if parameter_key == "has_default":
continue
if self._is_primitive_type(type_name=parameter_type):
is_bool = parameter_type == "bool"
is_positional = not is_bool and not parameter.get("has_default", False)
sanitized_key = self._sanitize_arg_parameter_key(parameter_key)
args.append(
self._create_arg(
arg_flags=("--" + self._sanitize_arg_parameter_key(parameter_key),),
arg_flags=(parameter_key,) if is_positional else ("--" + sanitized_key,),

CopilotAIApr 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Related to the parameter-shape issue: the for parameter_key, parameter_type in parameter.items() loop plus if parameter_key == \"has_default\": continue is an avoidable control-flow hack that spreads across multiple call sites (also in _get_func). If you keep the current shape, a safer alternative is to store metadata under a dedicated nested key (e.g. {\"name\": ..., \"type\": ..., \"meta\": {\"has_default\": ...}}) or move metadata out of the per-parameter dict entirely so this loop can be simplified and cannot accidentally skip real parameters.

Copilot uses AI. Check for mistakes.
Comment on lines +566 to +598
def test_has_default_detection_in_ast_parsing(self):
"""Test that AST parsing correctly detects which params have defaults."""
from textwrap import dedent

temp_file = "test_has_default.py"
with open(temp_file, "w") as f:
f.write(
dedent("""
class TestOperations(BaseOperations):
def get(self, required_id: str) -> str | ServerResponseError:
pass
def search(self, query: str, limit: int = 10, offset: int = 0) -> str | ServerResponseError:
pass
""")
)

try:
command_factory = CommandFactory(file_path=temp_file)
for op in command_factory.operations:
if op["name"] == "get":
param = op["parameters"][0]
assert param.get("has_default") is False
elif op["name"] == "search":
query_param = op["parameters"][0]
assert query_param.get("has_default") is False
limit_param = op["parameters"][1]
assert limit_param.get("has_default") is True
offset_param = op["parameters"][2]
assert offset_param.get("has_default") is True
finally:
import os

os.remove(temp_file)

CopilotAIApr 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test writes a fixed filename (test_has_default.py) into the working directory, which can conflict under parallel test runs or when the CWD is not writable. Use pytest’s tmp_path/tmp_path_factory (or tempfile.NamedTemporaryFile) to create an isolated temporary file path, and open it with an explicit encoding (e.g. encoding=\"utf-8\") to match the production file-read behavior.

Suggested change
deftest_has_default_detection_in_ast_parsing(self):
"""Test that AST parsing correctly detects which params have defaults."""
fromtextwrapimportdedent
temp_file="test_has_default.py"
withopen(temp_file, "w") asf:
f.write(
dedent("""
classTestOperations(BaseOperations):
defget(self, required_id: str) ->str|ServerResponseError:
pass
defsearch(self, query: str, limit: int=10, offset: int=0) ->str|ServerResponseError:
pass
""")
)
try:
command_factory=CommandFactory(file_path=temp_file)
foropincommand_factory.operations:
ifop["name"] =="get":
param=op["parameters"][0]
assertparam.get("has_default") isFalse
elifop["name"] =="search":
query_param=op["parameters"][0]
assertquery_param.get("has_default") isFalse
limit_param=op["parameters"][1]
assertlimit_param.get("has_default") isTrue
offset_param=op["parameters"][2]
assertoffset_param.get("has_default") isTrue
finally:
importos
os.remove(temp_file)
deftest_has_default_detection_in_ast_parsing(self, tmp_path):
"""Test that AST parsing correctly detects which params have defaults."""
fromtextwrapimportdedent
temp_file=tmp_path/"test_has_default.py"
temp_file.write_text(
dedent("""
classTestOperations(BaseOperations):
defget(self, required_id: str) ->str|ServerResponseError:
pass
defsearch(self, query: str, limit: int=10, offset: int=0) ->str|ServerResponseError:
pass
"""),
encoding="utf-8",
)
command_factory=CommandFactory(file_path=str(temp_file))
foropincommand_factory.operations:
ifop["name"] =="get":
param=op["parameters"][0]
assertparam.get("has_default") isFalse
elifop["name"] =="search":
query_param=op["parameters"][0]
assertquery_param.get("has_default") isFalse
limit_param=op["parameters"][1]
assertlimit_param.get("has_default") isTrue
offset_param=op["parameters"][2]
assertoffset_param.get("has_default") isTrue

Copilot uses AI. Check for mistakes.
@bugraoz93

Copy link
Copy Markdown
Contributor

Moving to draft to manage release better, please make it ready again when you think ready for maintainers review

@bugraoz93
bugraoz93 marked this pull request as draft April 12, 2026 15:20
@shivaam

shivaam commented Apr 12, 2026

Copy link
Copy Markdown
ContributorAuthor

Moving to draft to manage release better, please make it ready again when you think ready for maintainers review
@bugraoz93!

Sorry I replied on the issue instead of the PR. argparse doesn't natively support this. Below I have listed some options.


I looked into supporting both positional and --flag styles for required params. Unfortunately argparse doesn't natively support this — a parameter is either positional (dag_id) or a flag (--dag-id), never both.

Why it's not straightforward:

The closest workaround is registering all params as --flags, adding a nargs='*' catch-all for bare values, and using parse_intermixed_args to map positionals into unfilled flag slots after parsing. More about this at the bottom of this comment. But this means we lose argparse's built-in validation, type checking, help formatting, and required=True for those params — we'd essentially reimplement positional argument handling ourselves on top of argparse.

Options:

  1. Keep it positional for required params (current PR) — the simplest, most conventional choice. Required params are positional, optional params are flags. All argparse features work, clean help output.

  2. Two-pass parsing — maintain two parsers per command: one with required params as positional, one with them as --flags required=True. Try positional first, fall back to flags. Users can use either style, just not mixed in the same invocation:

    # both work:
    airflowctl xcom add my_dag my_task my_key
    airflowctl xcom add --dag-id=my_dag --task-id=my_task --key=my_key
    # but not mixed:
    airflowctl xcom add my_dag --task-id=my_task my_key # would fail

    No argparse hacks needed (both parsers are standard), but doubles the parser setup and mixed usage isn't supported.

  3. Cap positional count — first 1-2 required params (like dag_id) are positional, rest stay as --flag required=True. Common CLI pattern (like git checkout <branch> --force). No hacks, all argparse features work.

I'd recommend Option 1 for simplicity. Happy to go with whichever you prefer!


Details on the parse_intermixed_args workaround (and why I didn't recommend it)

The idea is to register all required params as --flags only (no argparse positionals), add a single nargs='*' argument to collect any bare values, then map those bare values into whichever flag slots the user didn't fill:

PARAM_ORDER= ['dag_id', 'task_id', 'key']
parser.add_argument('--dag-id', dest='dag_id', default=None)
parser.add_argument('--task-id', dest='task_id', default=None)
parser.add_argument('--key', default=None)
parser.add_argument('positionals', nargs='*')
args=parser.parse_intermixed_args(argv)
# map bare values into unfilled flag slots, left to rightunfilled= [dfordinPARAM_ORDERifgetattr(args, d) isNone]
fori, valinenumerate(args.positionals):
setattr(args, unfilled[i], val)

parse_intermixed_args (Python 3.7+) allows flags and bare values to be freely interspersed, so all of these would work:

airflowctl xcom add d t k # all positional
airflowctl xcom add --dag-id=d --task-id=t --key=k # all flags
airflowctl xcom add d --task-id=t k # mixed
airflowctl xcom add --task-id=t my_dag my_key # gap-filling

The problem is what we lose:

  • Validation — can't use required=True on flags (argparse would reject positional-only usage), so we validate manually after gap-filling
  • Type checkingtype=int, choices=[...] etc. don't apply to the nargs='*' catch-all, so we type-check manually
  • Help output — shows [positionals ...] instead of named params like DAG_ID TASK_ID, and no indication which flags are required vs optional
  • argparse.REMAINDER and mutually exclusive groups with positionals — unsupported with parse_intermixed_args

This effectively means using argparse for flag parsing only and reimplementing positional handling on top of it.

@bugraoz93

Copy link
Copy Markdown
Contributor

@shivaam, this will make things a bit complicated. The two-pass parsing is still complex. I would say before we finish adding the command to the parser, we can reprocess the args, maybe even in get_parser, which can also be responsible for parsing the command. We may need to update the building part a bit but it should be possible with reprocessing

Even though we are still around 0.x.x, still thinking about how we could implement this in the best position. As it will be a user-facing change, we need to create a devlist discussion and ask to only keep positional for required or maintain both.

My idea is adding both can save us from the entire discussion flow and we could be backwards compatible, even though this is not trivial with argparase in Python.

@bugraoz93

Copy link
Copy Markdown
Contributor

Something like this
#65261

@shivaam

Copy link
Copy Markdown
ContributorAuthor

Closing in favor of #65261 which takes a better approach — supporting both positional and flag styles simultaneously via argv pre-processing, so there's no breaking change for existing users. Thanks @bugraoz93 for the guidance!

@shivaamshivaam closed this Apr 16, 2026
@shivaam

Copy link
Copy Markdown
ContributorAuthor

Something like this #65261

I was just thinking about this. Supporting both positional and flag styles for the same param isn't something most CLI libraries or popular tools do. We're adding maintenance burden, constraining future changes as all CLI libraries might not suppor this feature, and expanding the testing surface forever. Since airflowctl is still pre-1.0, feels like this is the natural window for a clean break rather than committing to maintaining a non-standard approach indefinitely.

Since the layer we are adding is light, either way works. Just wanted to share my thoughts.

@bugraoz93

Copy link
Copy Markdown
Contributor

Thanks @shivaam for your patience and joining the discussion! We have some procedures to follow as a community. :) Reopened yours, feel free to rebase and if ready we can ship

@bugraoz93

Copy link
Copy Markdown
Contributor

@shivaam

Copy link
Copy Markdown
ContributorAuthor

Sounds good. I will rebase the project.

@shivaam
shivaamforce-pushed the fix/positional-params-airflowctl-60142 branch 2 times, most recently from db3384e to c88029dCompareMay 12, 2026 04:37
"assets create-event 1",
# Backfill commands
"backfill list",
"backfill list example_bash_operator",

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Before this PR, airflowctl backfill list (no args) returned [] and exited 0, but that was a silent bug — the CLI passed dag_id=None, which the server coerced to an empty filter that matched no records. The command was never actually returning backfills.

@shivaam
shivaamforce-pushed the fix/positional-params-airflowctl-60142 branch from c88029d to 3d53d29CompareMay 12, 2026 05:17
apache#60142)
Required non-boolean parameters in auto-generated CLI commands are now
positional instead of flag-style, improving UX consistency with manually
defined commands like `dags pause`.
For primitive params parsed via AST, uses default detection to determine
positional vs flag. For Pydantic model fields, uses `is_required()`.
@shivaam
shivaamforce-pushed the fix/positional-params-airflowctl-60142 branch from 498d6a6 to 4270f6eCompareMay 12, 2026 05:44
@shivaam
shivaam marked this pull request as ready for review May 12, 2026 15:14

@bugraoz93bugraoz93 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good! Thanks for your patience and work @shivaam!

@bugraoz93bugraoz93 added the full tests needed We need to run full set of tests for this PR to merge label May 12, 2026
@bugraoz93bugraoz93 reopened this May 12, 2026
@bugraoz93

Copy link
Copy Markdown
Contributor

Added the full test label to see the integration tests green. We can merge after seeing it green :)

@bugraoz93bugraoz93 added the backport-to-airflow-ctl-v0-1-test Backport to airflow-ctl/v0-1-test label May 12, 2026
@bugraoz93

Copy link
Copy Markdown
Contributor

CI looks unrelated, ctl test looks good

@bugraoz93

bugraoz93 commented May 13, 2026

Copy link
Copy Markdown
Contributor

@shivaam Could you please rebase and resolve conflicts, we can merge afterwards

@shivaam

Copy link
Copy Markdown
ContributorAuthor

@bugraoz93 Seems like there is another PR that already solved this problem PR #66768 — "airflowctl: make required CLI params positional, keep optional as --flag" by 1fanwang
I dont think we need this anymore.

@shivaamshivaam closed this May 17, 2026
@bugraoz93

bugraoz93 commented May 23, 2026

Copy link
Copy Markdown
Contributor

Sorry @shivaam! Totally missed that one. I will try to be faster next time 😅 It also has some release notes changes need manually handled while backporting and should be cleaned while generating

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:airflow-ctlbackport-to-airflow-ctl-v0-1-testBackport to airflow-ctl/v0-1-testfull tests neededWe need to run full set of tests for this PR to merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Make positional parameters as a destionation paramater for auto-generated commands

3 participants

@shivaam@bugraoz93