Repository: DanCardin/cappa

Issue:
Global arguments with `count=True` do not work as expected
Global arguments with `propagate=True` and `count=True` do not work as expected and only seem to pass a value of `0` or `1` regardless of the count of args actually passed.

Minimal working example:

```python
from __future__ import annotations

from dataclasses import dataclass
from typing import Annotated

import cappa

@dataclass
class TopLevelCommand:
    command: cappa.Subcommands[SubCommand]
    verbosity: Annotated[
        int,
        cappa.Arg(
            short=True,
            count=True,
            help="Verbosity level. INFO (default), DEBUG (-v), TRACE (-vv)",
            propagate=True,
        ),
    ] = 0

@cappa.command(name="subcommand", invoke="package.subcommand.subcommand")
@dataclass
class SubCommand:
    arg: str

def main() -> None:
    """Main function."""
    cappa.invoke(TopLevelCommand)
```

When running this with `cli subcommand -vv` the expected value of `TopLevelCommand.verbosity` is `2`  but the actual value passed is `1`

If the `verbosity` argument is moved to `SubCommand` it works as expected.

Hints from issue comments:
@DanCardin: Thanks for the report!

This would have applied to `ArgAction.append` also fwiw. The value was being **saved** to the correct context, but the context being supplied to the action was always the "current" one. So it was failing to accumulate state correctly.

Solution patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c998ed5..6ea3106 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,9 @@
 
 ## 0.26
 
+### 0.26.4
+- fix: Ensure `propagate=True` args supply the correct parent context to the arg's action handler.
+
 ### 0.26.3
 - fix: Reexport Empty/EmptyType types at the root.
 
diff --git a/pyproject.toml b/pyproject.toml
index 197f1e6..6f1604a 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
 [project]
 name = "cappa"
-version = "0.26.3"
+version = "0.26.4"
 description = "Declarative CLI argument parser."
 
 urls = { repository = "https://github.com/dancardin/cappa" }
diff --git a/src/cappa/parser.py b/src/cappa/parser.py
index 808fa81..6a4385f 100644
--- a/src/cappa/parser.py
+++ b/src/cappa/parser.py
@@ -247,6 +247,12 @@ class ParseContext:
     def next_argument(self):
         return self.arguments.popleft()
 
+    def resolve_context(self, field_name: str, option: RawOption | None = None):
+        context = self
+        if option and field_name in self.propagated_options:
+            context = self.propagated_context[field_name]
+        return context
+
     def set_result(
         self,
         field_name: str,
@@ -254,16 +260,11 @@ class ParseContext:
         option: RawOption | None = None,
         has_value: bool = True,
     ):
-        context = self
-        if option:
-            if field_name in self.propagated_options:
-                context = self.propagated_context[field_name]
-
-            if field_name in context.missing_options:
-                context.missing_options.remove(field_name)
+        if option and field_name in self.missing_options:
+            self.missing_options.remove(field_name)
 
         if has_value:
-            context.result[field_name] = value
+            self.result[field_name] = value
 
     def push(self, command: Command, name: str) -> ParseContext:
         nested_context = ParseContext.from_command(command, parent_context=self)
@@ -639,10 +640,13 @@ def consume_arg(
 
     action_handler = determine_action_handler(arg.action)
 
+    # Propagated arguments need to supply the correct parent context to their action handler.
+    resolved_context = context.resolve_context(field_name, option)
+
     fulfilled_deps: dict = {
         Command: parse_state.current_command,
         Output: parse_state.output,
-        ParseContext: context,
+        ParseContext: resolved_context,
         ParseState: parse_state,
         Arg: arg,
         Value: Value(result),
@@ -654,7 +658,9 @@ def consume_arg(
     kwargs = fulfill_deps(action_handler, fulfilled_deps).kwargs
     result = action_handler(**kwargs)
 
-    context.set_result(field_name, result, option, assert_type(arg.has_value, bool))
+    resolved_context.set_result(
+        field_name, result, option, assert_type(arg.has_value, bool)
+    )
 
     check_deprecated(parse_state, arg, option)
 


Test patch:
diff --git a/tests/arg/test_propagate.py b/tests/arg/test_propagate.py
index 8312031..adb9632 100644
--- a/tests/arg/test_propagate.py
+++ b/tests/arg/test_propagate.py
@@ -6,6 +6,7 @@ import pytest
 from typing_extensions import Annotated
 
 import cappa
+from cappa.subcommand import Subcommands
 from tests.utils import CapsysOutput, parse
 
 
@@ -133,3 +134,24 @@ def test_help_contains_propagated_arg(capsys):
     assert "foo" in output.stdout
     assert "Everywhere" in output.stdout
     assert "Nowhere" not in output.stdout
+
+
+def test_count():
+    """Test that actions requiring accumulated prior state (e.g. count) **get** values from the correct context."""
+
+    @dataclass
+    class Command:
+        foo: Annotated[int, cappa.Arg(short=True, propagate=True, count=True)] = 1
+        subcommand: Subcommands[One | None] = None
+
+    result = parse(Command, "-f")
+    assert result == Command(1, None)
+
+    result = parse(Command, "-fff")
+    assert result == Command(3, None)
+
+    result = parse(Command, "one", "-f")
+    assert result == Command(1, One())
+
+    result = parse(Command, "one", "-fff")
+    assert result == Command(3, One())