asana-api-cli 3.1.1__tar.gz → 3.1.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. {asana_api_cli-3.1.1/src/asana_api_cli.egg-info → asana_api_cli-3.1.2}/PKG-INFO +1 -1
  2. {asana_api_cli-3.1.1 → asana_api_cli-3.1.2}/pyproject.toml +1 -1
  3. {asana_api_cli-3.1.1 → asana_api_cli-3.1.2}/src/asana_api_cli/cli.py +133 -346
  4. asana_api_cli-3.1.2/src/asana_api_cli/click_ext.py +570 -0
  5. {asana_api_cli-3.1.1 → asana_api_cli-3.1.2}/src/asana_api_cli/formatter.py +78 -72
  6. {asana_api_cli-3.1.1 → asana_api_cli-3.1.2}/src/asana_api_cli/redactor.py +2 -2
  7. {asana_api_cli-3.1.1 → asana_api_cli-3.1.2}/src/asana_api_cli/session.py +71 -76
  8. {asana_api_cli-3.1.1 → asana_api_cli-3.1.2/src/asana_api_cli.egg-info}/PKG-INFO +1 -1
  9. {asana_api_cli-3.1.1 → asana_api_cli-3.1.2}/tests/test_cli.py +54 -29
  10. {asana_api_cli-3.1.1 → asana_api_cli-3.1.2}/tests/test_cli_invocation.py +63 -29
  11. {asana_api_cli-3.1.1 → asana_api_cli-3.1.2}/tests/test_click_ext.py +62 -78
  12. {asana_api_cli-3.1.1 → asana_api_cli-3.1.2}/tests/test_formatter.py +26 -23
  13. {asana_api_cli-3.1.1 → asana_api_cli-3.1.2}/tests/test_redactor.py +2 -1
  14. {asana_api_cli-3.1.1 → asana_api_cli-3.1.2}/tests/test_session.py +96 -56
  15. {asana_api_cli-3.1.1 → asana_api_cli-3.1.2}/tests/test_structured_arg.py +15 -3
  16. asana_api_cli-3.1.1/src/asana_api_cli/click_ext.py +0 -540
  17. {asana_api_cli-3.1.1 → asana_api_cli-3.1.2}/LICENSE +0 -0
  18. {asana_api_cli-3.1.1 → asana_api_cli-3.1.2}/README.md +0 -0
  19. {asana_api_cli-3.1.1 → asana_api_cli-3.1.2}/setup.cfg +0 -0
  20. {asana_api_cli-3.1.1 → asana_api_cli-3.1.2}/src/asana_api_cli/__init__.py +0 -0
  21. {asana_api_cli-3.1.1 → asana_api_cli-3.1.2}/src/asana_api_cli/structured_arg.py +0 -0
  22. {asana_api_cli-3.1.1 → asana_api_cli-3.1.2}/src/asana_api_cli/version.py +0 -0
  23. {asana_api_cli-3.1.1 → asana_api_cli-3.1.2}/src/asana_api_cli.egg-info/SOURCES.txt +0 -0
  24. {asana_api_cli-3.1.1 → asana_api_cli-3.1.2}/src/asana_api_cli.egg-info/dependency_links.txt +0 -0
  25. {asana_api_cli-3.1.1 → asana_api_cli-3.1.2}/src/asana_api_cli.egg-info/entry_points.txt +0 -0
  26. {asana_api_cli-3.1.1 → asana_api_cli-3.1.2}/src/asana_api_cli.egg-info/requires.txt +0 -0
  27. {asana_api_cli-3.1.1 → asana_api_cli-3.1.2}/src/asana_api_cli.egg-info/top_level.txt +0 -0
  28. {asana_api_cli-3.1.1 → asana_api_cli-3.1.2}/tests/test_cli_surface.py +0 -0
  29. {asana_api_cli-3.1.1 → asana_api_cli-3.1.2}/tests/test_py310_compat.py +0 -0
  30. {asana_api_cli-3.1.1 → asana_api_cli-3.1.2}/tests/test_sdk_boilerplate.py +0 -0
  31. {asana_api_cli-3.1.1 → asana_api_cli-3.1.2}/tests/test_version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: asana-api-cli
3
- Version: 3.1.1
3
+ Version: 3.1.2
4
4
  Summary: Command-line wrapper around the official Asana Python SDK
5
5
  Author: Masanao Izumo
6
6
  License-Expression: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "asana-api-cli"
3
- version = "3.1.1"
3
+ version = "3.1.2"
4
4
  description = "Command-line wrapper around the official Asana Python SDK"
5
5
  authors = [{name = "Masanao Izumo"}]
6
6
  readme = "README.md"
@@ -54,31 +54,27 @@ import asana
54
54
  import click
55
55
 
56
56
  from asana_api_cli.click_ext import (
57
- _SDK_HAS_RETRY_STRATEGY,
58
57
  CommandWithGlobalOptions,
59
58
  GroupWithGlobalOptions,
60
59
  LazyGroup,
61
- _make_global_option_params,
62
60
  )
63
- from asana_api_cli.formatter import formatted, formatter_flag_names
61
+ from asana_api_cli.formatter import formatted, formatter_flag_names, make_formatter_options
64
62
  from asana_api_cli.session import (
65
63
  AsanaSession,
66
- runtime,
64
+ MultibyteFilenameSupport,
67
65
  )
68
66
  from asana_api_cli.structured_arg import (
69
- RETRY_FIELD_SCHEMA,
70
67
  click_callback,
71
- default_header_callback,
72
68
  )
73
69
  from asana_api_cli.version import version_string
74
70
 
75
71
  # ---------------------------------------------------------------------------
76
72
  # Input resolution
77
73
  #
78
- # Turn a raw CLI option value into the argument the SDK call receives, exiting
79
- # with code 2 (user-input error) on bad input. These are pure invocation-layer
80
- # helpers — no SDK client / session involved — called only from the command
81
- # callback below.
74
+ # Turn a raw CLI option value into the argument the SDK call receives, raising
75
+ # ``click.BadParameter`` (exit code 2, user-input error) on bad input. These are
76
+ # pure invocation-layer helpers — no SDK client / session involved — called only
77
+ # from the command callback below.
82
78
  # ---------------------------------------------------------------------------
83
79
 
84
80
  DEFAULT_WORKSPACE_ENV = "ASANA_DEFAULT_WORKSPACE"
@@ -101,32 +97,30 @@ def resolve_body(value: str) -> JsonValue:
101
97
  # stdin is reconfigured to UTF-8 at startup (see ``main``), so
102
98
  # non-UTF-8 input from a pipe surfaces here instead of being
103
99
  # silently misdecoded with the locale code page.
104
- click.echo(f"Body from stdin is not valid UTF-8: {exc}", err=True)
105
- sys.exit(2)
100
+ raise click.BadParameter(
101
+ f"stdin is not valid UTF-8: {exc}", param_hint="--body"
102
+ ) from exc
106
103
  elif value.startswith("@"):
107
104
  path = Path(value[1:])
108
105
  try:
109
106
  raw = path.read_text(encoding="utf-8")
110
- except FileNotFoundError:
111
- click.echo(f"Body file not found: {path}", err=True)
112
- sys.exit(2)
107
+ except FileNotFoundError as exc:
108
+ raise click.BadParameter(f"file not found: {path}", param_hint="--body") from exc
113
109
  except UnicodeDecodeError as exc:
114
- click.echo(
115
- f"Body file {path} is not valid UTF-8: {exc}",
116
- err=True,
117
- )
118
- sys.exit(2)
110
+ raise click.BadParameter(
111
+ f"file {path} is not valid UTF-8: {exc}", param_hint="--body"
112
+ ) from exc
119
113
  except OSError as exc:
120
- click.echo(f"Cannot read body file {path}: {exc}", err=True)
121
- sys.exit(2)
114
+ raise click.BadParameter(
115
+ f"cannot read file {path}: {exc}", param_hint="--body"
116
+ ) from exc
122
117
  else:
123
118
  raw = value
124
119
 
125
120
  try:
126
121
  return json.loads(raw)
127
122
  except json.JSONDecodeError as exc:
128
- click.echo(f"Invalid JSON in body: {exc}", err=True)
129
- sys.exit(2)
123
+ raise click.BadParameter(f"invalid JSON: {exc}", param_hint="--body") from exc
130
124
 
131
125
 
132
126
  def resolve_workspace(
@@ -144,7 +138,7 @@ def resolve_workspace(
144
138
  alongside other scope parameters (e.g. ``--project`` on ``get-tasks``)
145
139
  that the Asana API accepts in place of workspace.
146
140
 
147
- If *required* is True and no value is found, exits with an error.
141
+ If *required* is True and no value is found, raises ``click.BadParameter``.
148
142
  """
149
143
  if explicit is not None:
150
144
  return explicit
@@ -152,11 +146,9 @@ def resolve_workspace(
152
146
  ws = os.environ.get(DEFAULT_WORKSPACE_ENV)
153
147
  if ws:
154
148
  return ws
155
- click.echo(
156
- f"Workspace is required. Specify --workspace or set {DEFAULT_WORKSPACE_ENV}.",
157
- err=True,
149
+ raise click.BadParameter(
150
+ f"required (or set {DEFAULT_WORKSPACE_ENV})", param_hint="--workspace"
158
151
  )
159
- sys.exit(2)
160
152
  return None
161
153
 
162
154
 
@@ -285,9 +277,11 @@ def _group_short_help(class_name: str) -> str:
285
277
 
286
278
 
287
279
  # Hand-written help text for endpoint-local options whose SDK ``:param:``
288
- # docstring is empty. The triple ``(class_name, method_name, param_name)``
289
- # is the key because bare param names (``file``, ``parent``, ``name``)
290
- # would collide across endpoints. Sourced from Asana's developer
280
+ # docstring is empty. The key is ``(group_class, method_name, param_name)``
281
+ # where ``group_class`` is the ``*Api`` class name with the ``Api`` suffix
282
+ # stripped (e.g. ``"Attachments"``, not ``"AttachmentsApi"`` the lookup uses
283
+ # ``api_cls.__name__[:-3]``); bare param names (``file``, ``parent``, ``name``)
284
+ # would otherwise collide across endpoints. Sourced from Asana's developer
291
285
  # reference. Lookup is conditional — only used when the SDK provides no
292
286
  # description — so an SDK that later fills in a description silently wins
293
287
  # over the override (which is fine, the SDK text is authoritative).
@@ -357,14 +351,14 @@ def _escape_help(text: str) -> str:
357
351
  # (asana-api: extension) no SDK counterpart (CLI-only)
358
352
  #
359
353
  # Configuration globals and the two ApiClient-instance globals (--user-agent /
360
- # --set-default-header) carry the literal by hand in both ``main`` and
361
- # ``_make_global_option_params`` (kept byte-identical between cli.py and
362
- # click_ext.py by ``test_click_ext.TestHelpTextSync``); the CLI-only formatter
363
- # flags (``--output`` / ``--query`` / ``--csv-bom`` and the error-path twins
364
- # ``--exception-output`` / ``--exception-query``) live only in ``formatted``. This
365
- # helper builds every label
366
- # ``_make_command`` derives at runtime: ``args`` / ``opts`` for path / body /
367
- # docstring params, ``kwargs`` for the common per-call kwargs, and the
354
+ # --set-default-header) carry the literal in their single declaration in
355
+ # ``click_ext.py:_global_option_sections`` (the one source every command's
356
+ # globals are built from, so the label is identical at the root and at any
357
+ # subcommand); the CLI-only formatter flags (``--output`` / ``--query`` /
358
+ # ``--csv-bom`` and the error-path twins ``--exception-output`` /
359
+ # ``--exception-query``) live in ``formatter.py:make_formatter_options``. This helper builds every
360
+ # label ``_make_command`` derives at runtime: ``args`` / ``opts`` for path /
361
+ # body / docstring params, ``kwargs`` for the common per-call kwargs, and the
368
362
  # extension marker on the deprecated aliases.
369
363
  def _sdk_dest(category: str, name: str = "") -> str:
370
364
  if category == "args":
@@ -440,7 +434,7 @@ def _parse_params(doc: str) -> dict[str, _DocParam]:
440
434
  p.description = p.description.replace("(required)", "").strip()
441
435
 
442
436
  # `_PARAM_RE` already drops the SDK's `:param async_req bool` line (no
443
- # colon after the type, so the regex never matches). Kept as a guard in
437
+ # colon after the name, so the regex never matches). Kept as a guard in
444
438
  # case the SDK docstring format changes to the colon form.
445
439
  params.pop("async_req", None)
446
440
  return params
@@ -543,11 +537,13 @@ class _Operation:
543
537
 
544
538
  @property
545
539
  def workspace_required(self) -> bool:
546
- """Workspace is required exactly when it is a path positional.
540
+ """Workspace is required when it is a path positional.
547
541
 
548
542
  An ``opts`` workspace is always optional — no python-asana method marks
549
- a query param ``(required)``. Drives the ``ASANA_DEFAULT_WORKSPACE``
550
- env-var fallback: auto-fill only when required.
543
+ a query param ``(required)`` so the return's second branch
544
+ (``wo.required``) is a defensive guard that does not fire on today's SDK.
545
+ Drives the ``ASANA_DEFAULT_WORKSPACE`` env-var fallback: auto-fill only
546
+ when required.
551
547
  """
552
548
  wo = self.workspace_opt
553
549
  return self.workspace_positional is not None or (wo is not None and wo.required)
@@ -665,36 +661,16 @@ def _make_per_call_kwarg_options() -> list[click.Option]:
665
661
 
666
662
  @functools.cache
667
663
  def _static_reserved_flags() -> frozenset[str]:
668
- """Built-in CLI flag strings present on (essentially) every command.
669
-
670
- An SDK arg/opt whose derived flag lands in this set is exposed with a
671
- ``sdk-`` prefix (see :func:`_decls`) so the built-in keeps its bare name.
672
- Derived from the actual option builders (not hand-kept) so it tracks
673
- renames / additions automatically. Per-command conditional flags
674
- (deprecated aliases, ``--multibyte-filenames``) are added in
675
- :func:`_reserved_flags`.
676
-
677
- ``--help`` is added by click at parse time (never in ``cmd.params``) and
678
- ``--version`` is root-only, so neither is discoverable by scanning a leaf's
679
- params — they are listed explicitly so a future SDK ``help`` / ``version``
680
- param is still pushed to ``--sdk-help`` / ``--sdk-version``.
664
+ """All asana-api own flags (no SDK counterpart). An SDK arg/opt whose
665
+ derived flag collides with one is exposed as ``--sdk-<name>``
666
+ (:func:`_decls`) so the built-in keeps its bare name. ``--help`` /
667
+ ``--version`` are listed explicitly because neither appears in a leaf's
668
+ params.
681
669
  """
682
670
  flags: set[str] = {"--help", "--version"}
683
671
  flags |= formatter_flag_names()
684
- for params in (_make_per_call_kwarg_options(), _make_global_option_params()):
685
- for p in params:
686
- flags.update(p.opts)
687
- flags.update(getattr(p, "secondary_opts", []))
688
- return frozenset(flags)
689
-
690
-
691
- def _reserved_flags(op: _Operation) -> frozenset[str]:
692
- """Built-in flags this command occupies (static set + per-command extras)."""
693
- flags = set(_static_reserved_flags())
694
- if op.paginatable:
695
- flags |= {"--all-items", "--page-size", "--max-items"}
696
- if op.does_upload:
697
- flags.add("--multibyte-filenames")
672
+ # pagination aliases + upload toggle; keep in sync with _make_command
673
+ flags |= {"--all-items", "--page-size", "--max-items", "--multibyte-filenames"}
698
674
  return frozenset(flags)
699
675
 
700
676
 
@@ -704,7 +680,7 @@ def _decls(flag: str, dest: str, reserved: frozenset[str]) -> list[str]:
704
680
  If ``flag`` collides with a built-in CLI flag (in ``reserved``), the SDK
705
681
  param yields: it is exposed as ``--sdk-<name>`` with an *explicit* ``dest``
706
682
  equal to the SDK param name, so the call path (which pops by param name) is
707
- unchanged and the ``(opts/arg: <name>)`` help label still shows the real
683
+ unchanged and the ``(opts/args: <name>)`` help label still shows the real
708
684
  name. Otherwise the bare ``[flag]`` is used (dest auto-derives to ``dest``).
709
685
  """
710
686
  if flag in reserved:
@@ -809,6 +785,54 @@ def _plain_opt_option(
809
785
  return click.Option(_decls(flag, p.name, reserved), **kw)
810
786
 
811
787
 
788
+ def _apply_deprecated_aliases(kwargs: dict[str, Any], item_limit: int | None) -> int | None:
789
+ """Resolve the deprecated pagination aliases and return the effective item
790
+ limit. Pops ``all_items`` / ``page_size`` / ``max_items`` from ``kwargs``
791
+ (absent on non-paginatable commands), warns on stderr, and folds
792
+ ``page_size`` into ``kwargs["limit"]`` / ``max_items`` into the item limit,
793
+ with the canonical ``--limit`` / ``--item-limit`` winning when both are given.
794
+ """
795
+ all_items = kwargs.pop("all_items", False)
796
+ page_size = kwargs.pop("page_size", None)
797
+ max_items = kwargs.pop("max_items", None)
798
+
799
+ if all_items:
800
+ click.echo(
801
+ "warning: --all-items is deprecated; walking every page is "
802
+ "now the default (will be removed in a future release)",
803
+ err=True,
804
+ )
805
+ if page_size is not None:
806
+ click.echo(
807
+ "warning: --page-size is deprecated; use --limit instead "
808
+ "(will be removed in a future release)",
809
+ err=True,
810
+ )
811
+ # Canonical --limit wins when both are given.
812
+ if kwargs.get("limit") is None:
813
+ kwargs["limit"] = page_size
814
+ if max_items is not None:
815
+ click.echo(
816
+ "warning: --max-items is deprecated; use --item-limit "
817
+ "instead (will be removed in a future release)",
818
+ err=True,
819
+ )
820
+ # Canonical --item-limit wins when both are given.
821
+ if item_limit is None:
822
+ item_limit = max_items
823
+ return item_limit
824
+
825
+
826
+ def _multibyte_filenames_callback(ctx: click.Context, param: click.Parameter, value: bool) -> None:
827
+ """``--multibyte-filenames`` callback: install the RFC 5987 multipart patch
828
+ for this command and uninstall it at context teardown. ``with_resource``
829
+ enters the context manager now (install) and exits it when the command
830
+ finishes (uninstall), so the patch is scoped to this one invocation.
831
+ """
832
+ if value:
833
+ ctx.with_resource(MultibyteFilenameSupport())
834
+
835
+
812
836
  def _make_command(api_cls: type, op: _Operation) -> click.Command:
813
837
  """Build a :class:`CommandWithGlobalOptions` for a single SDK method.
814
838
 
@@ -822,6 +846,9 @@ def _make_command(api_cls: type, op: _Operation) -> click.Command:
822
846
  so there is no required/optional split to order by.
823
847
  3. **Boilerplate per-call kwargs** (the SDK ``all_params``), plus the
824
848
  upload / deprecation extensions where they apply.
849
+ 4. **Output-formatting options** (``--output`` / ``--query`` / ``--csv-bom``
850
+ / ``--exception-output`` / ``--exception-query``) from
851
+ ``make_formatter_options``, appended last.
825
852
  """
826
853
  # If the SDK method has no ``opts`` parameter, docstring-derived named
827
854
  # arguments cannot be forwarded — they would be silently dropped at call
@@ -832,10 +859,11 @@ def _make_command(api_cls: type, op: _Operation) -> click.Command:
832
859
  )
833
860
  paginatable = op.paginatable
834
861
  does_upload = op.does_upload
835
- # Built-in flags this command occupies. An SDK arg/opt whose flag collides
836
- # with one of these is exposed as ``--sdk-<name>`` (see ``_decls``) so the
837
- # built-in keeps its bare name; the SDK param stays reachable + labelled.
838
- reserved = _reserved_flags(op)
862
+ # asana-api's own flags (reserved uniformly across commands). An SDK arg/opt
863
+ # whose flag collides with one is exposed as ``--sdk-<name>`` (see
864
+ # ``_decls``) so the own flag keeps its bare name; the SDK param stays
865
+ # reachable + labelled.
866
+ reserved = _static_reserved_flags()
839
867
 
840
868
  options: list[click.Option] = []
841
869
 
@@ -871,12 +899,17 @@ def _make_command(api_cls: type, op: _Operation) -> click.Command:
871
899
  # only affects multipart uploads, so it is exposed solely on upload commands
872
900
  # (``does_upload``) rather than as a global flag. Off by default to preserve
873
901
  # strict SDK parity (the SDK emits ``filename=`` only); see sdk-deviations.md.
902
+ # Its callback installs the patch and scopes it to this command via
903
+ # ``ctx.with_resource``; ``expose_value=False`` keeps it out of
904
+ # ``inner_callback``'s ``**kwargs``.
874
905
  if does_upload:
875
906
  options.append(
876
907
  click.Option(
877
908
  ["--multibyte-filenames", "multibyte_filenames"],
878
909
  is_flag=True,
879
910
  default=False,
911
+ callback=_multibyte_filenames_callback,
912
+ expose_value=False,
880
913
  help=(
881
914
  "Emit RFC 5987 filename*=UTF-8'' on this multipart upload. "
882
915
  "Required when the --file name contains non-ASCII characters; "
@@ -925,46 +958,10 @@ def _make_command(api_cls: type, op: _Operation) -> click.Command:
925
958
  header_params = kwargs.pop("header_params", None)
926
959
  request_timeout = kwargs.pop("request_timeout", None)
927
960
 
928
- # Per-command extension on upload commands only: pop the toggle (so it
929
- # does not leak into the opts dict) and set the runtime flag the session
930
- # reads when deciding whether to install MultibyteFilenameSupport. Other
931
- # commands never expose it, so their runtime value stays the default.
932
- if does_upload:
933
- runtime.multibyte_filenames = kwargs.pop("multibyte_filenames", False)
934
-
935
- # Deprecated aliases: pop from kwargs (per-command) and warn. Effective
936
- # values fold into local vars without mutating ``runtime``, so the
937
- # dispatch state stays scoped to this invocation.
938
- all_items = kwargs.pop("all_items", False) if paginatable else False
939
- page_size = kwargs.pop("page_size", None) if paginatable else None
940
- max_items = kwargs.pop("max_items", None) if paginatable else None
941
-
942
- effective_item_limit = item_limit
943
-
944
- if all_items:
945
- click.echo(
946
- "warning: --all-items is deprecated; walking every page is "
947
- "now the default (will be removed in a future release)",
948
- err=True,
949
- )
950
- if page_size is not None:
951
- click.echo(
952
- "warning: --page-size is deprecated; use --limit instead "
953
- "(will be removed in a future release)",
954
- err=True,
955
- )
956
- # Canonical --limit wins when both are given.
957
- if kwargs.get("limit") is None:
958
- kwargs["limit"] = page_size
959
- if max_items is not None:
960
- click.echo(
961
- "warning: --max-items is deprecated; use --item-limit "
962
- "instead (will be removed in a future release)",
963
- err=True,
964
- )
965
- # Canonical --item-limit wins when both are given.
966
- if effective_item_limit is None:
967
- effective_item_limit = max_items
961
+ # Deprecated aliases (--all-items / --page-size / --max-items): warn and
962
+ # fold into their canonical replacements. Isolated in a helper so their
963
+ # eventual removal does not touch this hot-path closure.
964
+ effective_item_limit = _apply_deprecated_aliases(kwargs, item_limit)
968
965
 
969
966
  if op.has_body:
970
967
  body_value = kwargs.pop("body") # click marks --body as required
@@ -1037,7 +1034,8 @@ def _make_command(api_cls: type, op: _Operation) -> click.Command:
1037
1034
  # Two independent layers:
1038
1035
  # - Layer A (session lifecycle, above): every SDK call runs
1039
1036
  # inside ``with AsanaSession.from_env() as session:``, which
1040
- # keeps the ``HttpClientAuthRedactor`` installed.
1037
+ # keeps the ``HttpClientAuthRedactor`` installed when ``--debug``
1038
+ # is active.
1041
1039
  # - Layer B (this block): when the SDK returns a lazy iterator
1042
1040
  # (PageIterator / EventIterator), iterating it issues one
1043
1041
  # HTTP request per page. We must consume the iterator *before*
@@ -1053,12 +1051,11 @@ def _make_command(api_cls: type, op: _Operation) -> click.Command:
1053
1051
 
1054
1052
  callback = formatted(inner_callback)
1055
1053
 
1056
- # ``formatted`` adds the output-formatting options (--output / --query /
1057
- # --csv-bom and their error-path twins --exception-output / --exception-query)
1058
- # via click.option decorators; pull those Option instances out of the
1059
- # wrapped callback (in natural order) and append them to our options list.
1060
- fmt_params = list(reversed(getattr(callback, "__click_params__", [])))
1061
- options.extend(fmt_params)
1054
+ # The output-formatting options (--output / --query / --csv-bom and their
1055
+ # error-path twins --exception-output / --exception-query) are declared by
1056
+ # ``make_formatter_options``; ``callback`` (wrapped by ``formatted``) consumes
1057
+ # their parsed values as kwargs.
1058
+ options.extend(make_formatter_options())
1062
1059
 
1063
1060
  summary = _escape_help(op.summary or f"Call {api_cls.__name__}.{op.method_name}")
1064
1061
  return CommandWithGlobalOptions(
@@ -1127,202 +1124,16 @@ _ROOT_EPILOG = (
1127
1124
  )
1128
1125
 
1129
1126
 
1130
- def _retry_strategy_option(f: Any) -> Any:
1131
- """Apply the ``--retry-strategy`` decorator only when the installed
1132
- python-asana exposes ``Configuration.retry_strategy`` (added in 5.1).
1133
-
1134
- On older SDKs the flag would crash at apply time, so we hide it
1135
- entirely both from ``--help`` and from the parser, so users on
1136
- 5.0.x get a clean ``no such option`` rather than a traceback.
1137
- """
1138
- if not _SDK_HAS_RETRY_STRATEGY:
1139
- return f
1140
- return click.option(
1141
- "--retry-strategy",
1142
- "retry_strategy_overrides",
1143
- default=None,
1144
- callback=click_callback(schema=RETRY_FIELD_SCHEMA),
1145
- help=(
1146
- "Override urllib3 Retry fields. VALUE: 'k1=v1,k2=v2,...', JSON "
1147
- "object, or @path. See urllib3 Retry docs. List-typed fields "
1148
- "(allowed_methods, status_forcelist, remove_headers_on_redirect) "
1149
- "require JSON. (Configuration: retry_strategy)"
1150
- ),
1151
- )(f)
1152
-
1153
-
1154
- # Root uses LazyGroup so that the manually declared @click.option globals are
1155
- # not duplicated by GroupWithGlobalOptions' auto-append behavior. Subgroups
1156
- # (_ApiGroup) and leaf commands (CommandWithGlobalOptions) still auto-append
1157
- # so global options work at any level of the tree.
1127
+ # Root group. ``LazyGroup`` is a ``GroupWithGlobalOptions``, so the root
1128
+ # appends and consumes the global Configuration / ApiClient flags from the
1129
+ # single ``_global_option_sections`` source in ``click_ext.py`` — exactly the
1130
+ # way every subgroup (``_ApiGroup``) and leaf command (``CommandWithGlobalOptions``)
1131
+ # does. There is no separate root-level declaration; the flags work at any level
1132
+ # of the tree, and ``--retry-strategy`` is gated on the SDK version in that one
1133
+ # source.
1158
1134
  @click.group(name="asana-api", cls=LazyGroup, epilog=_ROOT_EPILOG)
1159
1135
  @click.version_option(version_string(), prog_name="asana-api")
1160
- @click.option(
1161
- "--host",
1162
- default=None,
1163
- help="Override API base URL (default: https://app.asana.com/api/1.0). (Configuration: host)",
1164
- )
1165
- @click.option("--proxy", default=None, help="HTTP/HTTPS proxy URL. (Configuration: proxy)")
1166
- @click.option(
1167
- "--verify-ssl/--no-verify-ssl",
1168
- "verify_ssl",
1169
- default=None,
1170
- help=(
1171
- "Verify TLS certificates (default: True). Pass --no-verify-ssl "
1172
- "to disable (insecure). (Configuration: verify_ssl)"
1173
- ),
1174
- )
1175
- @click.option(
1176
- "--ssl-ca-cert",
1177
- "ssl_ca_cert",
1178
- default=None,
1179
- type=click.Path(exists=True, dir_okay=False),
1180
- help="Path to a PEM bundle of trusted CA certs. (Configuration: ssl_ca_cert)",
1181
- )
1182
- @click.option(
1183
- "--cert-file",
1184
- "cert_file",
1185
- default=None,
1186
- type=click.Path(exists=True, dir_okay=False),
1187
- help="Client TLS certificate for mTLS. (Configuration: cert_file)",
1188
- )
1189
- @click.option(
1190
- "--key-file",
1191
- "key_file",
1192
- default=None,
1193
- type=click.Path(exists=True, dir_okay=False),
1194
- help="Client TLS private key for mTLS. (Configuration: key_file)",
1195
- )
1196
- @click.option(
1197
- "--assert-hostname/--no-assert-hostname",
1198
- "assert_hostname",
1199
- default=None,
1200
- help=(
1201
- "Verify the server certificate's hostname matches the request URL "
1202
- "host. Tri-state: unspecified → urllib3 default. "
1203
- "(Configuration: assert_hostname)"
1204
- ),
1205
- )
1206
- @click.option(
1207
- "--user-agent",
1208
- "user_agent",
1209
- default=None,
1210
- help=("Override the User-Agent header the SDK sends on every request. (ApiClient: user_agent)"),
1211
- )
1212
- @click.option(
1213
- "--set-default-header",
1214
- "default_headers",
1215
- multiple=True,
1216
- callback=default_header_callback,
1217
- help=(
1218
- "Add an HTTP header sent on every request, given as NAME=VALUE; "
1219
- "repeatable. Unlike per-call --header-params it applies to all "
1220
- "calls. Not redacted in --debug output — see SECURITY.md. "
1221
- "(ApiClient: set_default_header)"
1222
- ),
1223
- )
1224
- @_retry_strategy_option
1225
- @click.option(
1226
- "--connection-pool-maxsize",
1227
- "connection_pool_maxsize",
1228
- type=click.IntRange(min=1),
1229
- default=None,
1230
- help=(
1231
- "Max urllib3 connections cached per host (default: cpu_count "
1232
- "* 5). (Configuration: connection_pool_maxsize)"
1233
- ),
1234
- )
1235
- @click.option(
1236
- "--access-token",
1237
- "access_token",
1238
- default=None,
1239
- help=(
1240
- "Asana personal access token (default: $ASANA_ACCESS_TOKEN). (Configuration: access_token)"
1241
- ),
1242
- )
1243
- @click.option(
1244
- "--temp-folder-path",
1245
- "temp_folder_path",
1246
- default=None,
1247
- type=click.Path(file_okay=False),
1248
- help="Directory for temporary downloads. (Configuration: temp_folder_path)",
1249
- )
1250
- @click.option(
1251
- "--safe-chars-for-path-param",
1252
- "safe_chars_for_path_param",
1253
- default=None,
1254
- help=(
1255
- "Extra chars treated as safe when percent-encoding path "
1256
- "parameters. (Configuration: safe_chars_for_path_param)"
1257
- ),
1258
- )
1259
- @click.option(
1260
- "--logger-format",
1261
- "logger_format",
1262
- default=None,
1263
- help="Python logging format string. (Configuration: logger_format)",
1264
- )
1265
- @click.option(
1266
- "--logger-file",
1267
- "logger_file",
1268
- default=None,
1269
- type=click.Path(dir_okay=False),
1270
- help="Path SDK loggers write to. (Configuration: logger_file)",
1271
- )
1272
- @click.option(
1273
- "--debug",
1274
- is_flag=True,
1275
- default=False,
1276
- help="Print HTTP request/response to stderr for troubleshooting. (Configuration: debug)",
1277
- )
1278
- @click.option(
1279
- "--return-page-iterator/--no-return-page-iterator",
1280
- "return_page_iterator",
1281
- default=None,
1282
- help=(
1283
- "Toggle the SDK page iterator (default: enabled). With "
1284
- "--no-return-page-iterator, paginatable endpoints return a "
1285
- "single {data, next_page} dict from one HTTP call instead of "
1286
- "auto-walking every page. (Configuration: return_page_iterator)"
1287
- ),
1288
- )
1289
- @click.option(
1290
- "--page-limit",
1291
- "page_limit",
1292
- type=int,
1293
- default=None,
1294
- help=(
1295
- "Per-page size when the iterator falls back to Configuration "
1296
- "(default: 100). Equivalent to --limit on paginatable endpoints; "
1297
- '--limit (per-call opts["limit"]) takes precedence when both '
1298
- "are set. (Configuration: page_limit)"
1299
- ),
1300
- )
1301
- def main(
1302
- host: str | None,
1303
- proxy: str | None,
1304
- verify_ssl: bool | None,
1305
- ssl_ca_cert: str | None,
1306
- cert_file: str | None,
1307
- key_file: str | None,
1308
- assert_hostname: bool | None,
1309
- user_agent: str | None,
1310
- default_headers: dict[str, str] | None,
1311
- # ``retry_strategy_overrides`` and everything after it have ``= None``
1312
- # defaults so the ``--retry-strategy`` decorator can be skipped on
1313
- # python-asana <5.1 without click then trying to call this function
1314
- # without a value for that name.
1315
- retry_strategy_overrides: dict[str, Any] | None = None,
1316
- connection_pool_maxsize: int | None = None,
1317
- access_token: str | None = None,
1318
- temp_folder_path: str | None = None,
1319
- safe_chars_for_path_param: str | None = None,
1320
- logger_format: str | None = None,
1321
- logger_file: str | None = None,
1322
- debug: bool = False,
1323
- return_page_iterator: bool | None = None,
1324
- page_limit: int | None = None,
1325
- ) -> None:
1136
+ def main() -> None:
1326
1137
  """Asana API CLI — runtime-introspected wrapper around the python-asana SDK."""
1327
1138
  # JSON I/O is required to be UTF-8 by RFC 8259, but on Windows the default
1328
1139
  # stream encodings are the locale code page (e.g. cp932 on Japanese
@@ -1331,39 +1142,15 @@ def main(
1331
1142
  # Reconfigure all three to UTF-8 so the same input/output works on every
1332
1143
  # platform. The hasattr guard keeps CliRunner's in-memory streams (used
1333
1144
  # by tests) from blowing up, since StringIO has no reconfigure().
1145
+ #
1146
+ # The global flags are parsed into the root context and written to
1147
+ # ``runtime`` by ``GroupWithGlobalOptions.invoke`` → ``_consume_global_options``
1148
+ # (inherited by ``LazyGroup``) before this callback runs, so there is nothing
1149
+ # to apply here.
1334
1150
  for stream in (sys.stdin, sys.stdout, sys.stderr):
1335
1151
  if hasattr(stream, "reconfigure"):
1336
1152
  stream.reconfigure(encoding="utf-8") # pyright: ignore[reportAttributeAccessIssue]
1337
1153
 
1338
- runtime.host = host
1339
- runtime.proxy = proxy
1340
- # Both verify_ssl and assert_hostname are tri-state toggles (positive /
1341
- # negative / unset). Guard so the unset case does not clobber a value
1342
- # set by an earlier code path; symmetry with how the leaf-level
1343
- # propagation in ``click_ext._consume_global_options`` skips the
1344
- # default ``None``.
1345
- if verify_ssl is not None:
1346
- runtime.verify_ssl = verify_ssl
1347
- runtime.ssl_ca_cert = ssl_ca_cert
1348
- runtime.cert_file = cert_file
1349
- runtime.key_file = key_file
1350
- if assert_hostname is not None:
1351
- runtime.assert_hostname = assert_hostname
1352
- runtime.user_agent = user_agent
1353
- runtime.default_headers = default_headers
1354
- runtime.retry_strategy_overrides = retry_strategy_overrides
1355
- runtime.connection_pool_maxsize = connection_pool_maxsize
1356
- if access_token:
1357
- runtime.access_token = access_token
1358
- runtime.temp_folder_path = temp_folder_path
1359
- runtime.safe_chars_for_path_param = safe_chars_for_path_param
1360
- runtime.logger_format = logger_format
1361
- runtime.logger_file = logger_file
1362
- runtime.debug = debug
1363
- if return_page_iterator is not None:
1364
- runtime.return_page_iterator = return_page_iterator
1365
- runtime.page_limit = page_limit
1366
-
1367
1154
 
1368
1155
  def _register_groups(root: click.Group) -> None:
1369
1156
  for cls in _enumerate_api_classes():