asana-api-cli 3.1.2__tar.gz → 3.1.3__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 (32) hide show
  1. {asana_api_cli-3.1.2/src/asana_api_cli.egg-info → asana_api_cli-3.1.3}/PKG-INFO +1 -1
  2. {asana_api_cli-3.1.2 → asana_api_cli-3.1.3}/pyproject.toml +1 -1
  3. {asana_api_cli-3.1.2 → asana_api_cli-3.1.3}/src/asana_api_cli/cli.py +6 -5
  4. {asana_api_cli-3.1.2 → asana_api_cli-3.1.3}/src/asana_api_cli/formatter.py +14 -4
  5. asana_api_cli-3.1.3/src/asana_api_cli/multibyte_filename.py +68 -0
  6. {asana_api_cli-3.1.2 → asana_api_cli-3.1.3}/src/asana_api_cli/session.py +0 -63
  7. {asana_api_cli-3.1.2 → asana_api_cli-3.1.3/src/asana_api_cli.egg-info}/PKG-INFO +1 -1
  8. {asana_api_cli-3.1.2 → asana_api_cli-3.1.3}/src/asana_api_cli.egg-info/SOURCES.txt +2 -0
  9. {asana_api_cli-3.1.2 → asana_api_cli-3.1.3}/tests/test_formatter.py +18 -6
  10. asana_api_cli-3.1.3/tests/test_multibyte_filename.py +117 -0
  11. {asana_api_cli-3.1.2 → asana_api_cli-3.1.3}/tests/test_session.py +1 -109
  12. {asana_api_cli-3.1.2 → asana_api_cli-3.1.3}/LICENSE +0 -0
  13. {asana_api_cli-3.1.2 → asana_api_cli-3.1.3}/README.md +0 -0
  14. {asana_api_cli-3.1.2 → asana_api_cli-3.1.3}/setup.cfg +0 -0
  15. {asana_api_cli-3.1.2 → asana_api_cli-3.1.3}/src/asana_api_cli/__init__.py +0 -0
  16. {asana_api_cli-3.1.2 → asana_api_cli-3.1.3}/src/asana_api_cli/click_ext.py +0 -0
  17. {asana_api_cli-3.1.2 → asana_api_cli-3.1.3}/src/asana_api_cli/redactor.py +0 -0
  18. {asana_api_cli-3.1.2 → asana_api_cli-3.1.3}/src/asana_api_cli/structured_arg.py +0 -0
  19. {asana_api_cli-3.1.2 → asana_api_cli-3.1.3}/src/asana_api_cli/version.py +0 -0
  20. {asana_api_cli-3.1.2 → asana_api_cli-3.1.3}/src/asana_api_cli.egg-info/dependency_links.txt +0 -0
  21. {asana_api_cli-3.1.2 → asana_api_cli-3.1.3}/src/asana_api_cli.egg-info/entry_points.txt +0 -0
  22. {asana_api_cli-3.1.2 → asana_api_cli-3.1.3}/src/asana_api_cli.egg-info/requires.txt +0 -0
  23. {asana_api_cli-3.1.2 → asana_api_cli-3.1.3}/src/asana_api_cli.egg-info/top_level.txt +0 -0
  24. {asana_api_cli-3.1.2 → asana_api_cli-3.1.3}/tests/test_cli.py +0 -0
  25. {asana_api_cli-3.1.2 → asana_api_cli-3.1.3}/tests/test_cli_invocation.py +0 -0
  26. {asana_api_cli-3.1.2 → asana_api_cli-3.1.3}/tests/test_cli_surface.py +0 -0
  27. {asana_api_cli-3.1.2 → asana_api_cli-3.1.3}/tests/test_click_ext.py +0 -0
  28. {asana_api_cli-3.1.2 → asana_api_cli-3.1.3}/tests/test_py310_compat.py +0 -0
  29. {asana_api_cli-3.1.2 → asana_api_cli-3.1.3}/tests/test_redactor.py +0 -0
  30. {asana_api_cli-3.1.2 → asana_api_cli-3.1.3}/tests/test_sdk_boilerplate.py +0 -0
  31. {asana_api_cli-3.1.2 → asana_api_cli-3.1.3}/tests/test_structured_arg.py +0 -0
  32. {asana_api_cli-3.1.2 → asana_api_cli-3.1.3}/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.2
3
+ Version: 3.1.3
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.2"
3
+ version = "3.1.3"
4
4
  description = "Command-line wrapper around the official Asana Python SDK"
5
5
  authors = [{name = "Masanao Izumo"}]
6
6
  readme = "README.md"
@@ -59,9 +59,9 @@ from asana_api_cli.click_ext import (
59
59
  LazyGroup,
60
60
  )
61
61
  from asana_api_cli.formatter import formatted, formatter_flag_names, make_formatter_options
62
+ from asana_api_cli.multibyte_filename import MultibyteFilenameSupport
62
63
  from asana_api_cli.session import (
63
64
  AsanaSession,
64
- MultibyteFilenameSupport,
65
65
  )
66
66
  from asana_api_cli.structured_arg import (
67
67
  click_callback,
@@ -895,10 +895,11 @@ def _make_command(api_cls: type, op: _Operation) -> click.Command:
895
895
  options.extend(_make_per_call_kwarg_options())
896
896
 
897
897
  # ``--multibyte-filenames`` is an asana-api extension that toggles the
898
- # multipart filename patch (``MultibyteFilenameSupport`` in session.py). It
899
- # only affects multipart uploads, so it is exposed solely on upload commands
900
- # (``does_upload``) rather than as a global flag. Off by default to preserve
901
- # strict SDK parity (the SDK emits ``filename=`` only); see sdk-deviations.md.
898
+ # multipart filename patch (``MultibyteFilenameSupport`` in
899
+ # ``multibyte_filename.py``). It only affects multipart uploads, so it is
900
+ # exposed solely on upload commands (``does_upload``) rather than as a global
901
+ # flag. Off by default to preserve strict SDK parity (the SDK emits
902
+ # ``filename=`` only); see sdk-deviations.md.
902
903
  # Its callback installs the patch and scopes it to this command via
903
904
  # ``ctx.with_resource``; ``expose_value=False`` keeps it out of
904
905
  # ``inner_callback``'s ``**kwargs``.
@@ -386,9 +386,19 @@ def _print_csv(rows: list[dict[str, Any]], *, with_bom: bool = False) -> None:
386
386
  # ``dict.fromkeys`` preserves insertion order (Python 3.7+), giving a
387
387
  # stable column order based on first appearance.
388
388
  fieldnames = list(dict.fromkeys(key for row in rows for key in row))
389
- # lineterminator="\n" avoids Windows text-mode stdout translating the
390
- # csv module's default "\r\n" into "\r\r\n".
391
- writer = csv.DictWriter(buf, fieldnames=fieldnames, lineterminator="\n")
389
+ # RFC 4180: CRLF between records; newlines inside a field stay verbatim.
390
+ writer = csv.DictWriter(buf, fieldnames=fieldnames, lineterminator="\r\n")
392
391
  writer.writeheader()
393
392
  writer.writerows(rows)
394
- click.echo(buf.getvalue(), nl=False)
393
+ # Write bytes via the binary layer so the stdout text layer can't translate
394
+ # newlines (Windows would turn each "\r\n" into "\r\r\n"); fall back to text
395
+ # for streams without one.
396
+ text = buf.getvalue()
397
+ out = sys.stdout
398
+ raw = getattr(out, "buffer", None)
399
+ if raw is None:
400
+ out.write(text)
401
+ else:
402
+ out.flush()
403
+ raw.write(text.encode(out.encoding or "utf-8"))
404
+ raw.flush()
@@ -0,0 +1,68 @@
1
+ """RFC 5987 multipart filename support for the ``python-asana`` SDK.
2
+
3
+ ``MultibyteFilenameSupport`` patches ``urllib3``'s multipart encoder to add the
4
+ RFC 5987 ``filename*=`` parameter so non-ASCII attachment filenames round-trip.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import urllib.parse
10
+ from collections.abc import Callable
11
+ from typing import Any
12
+
13
+ from urllib3.fields import RequestField
14
+
15
+
16
+ class MultibyteFilenameSupport:
17
+ """Make multipart uploads round-trip filenames with non-ASCII characters.
18
+
19
+ In ``python-asana`` 5.2.4 (the latest version checked, and likely later
20
+ ones too), uploading a file whose name has characters outside ASCII
21
+ stores a garbled (mojibake) name on Asana: the SDK's multipart encoder
22
+ emits only ``filename="..."`` and omits the RFC 5987 ``filename*=``
23
+ parameter the server needs to decode them. This context manager patches
24
+ ``urllib3.fields.RequestField.make_multipart`` to add
25
+ ``filename*=utf-8''<percent-encoded>`` for such names, scoped to the
26
+ ``with`` block::
27
+
28
+ with MultibyteFilenameSupport():
29
+ # urllib3-based uploads in this block emit filename*=
30
+ ...
31
+ """
32
+
33
+ def __init__(self) -> None:
34
+ self._original: Callable[..., None] | None = None
35
+
36
+ def install(self) -> None:
37
+ if self._original is not None:
38
+ return
39
+ self._original = RequestField.make_multipart
40
+ original = self._original
41
+
42
+ def _patched(
43
+ field: RequestField,
44
+ content_disposition: str | None = None,
45
+ content_type: str | None = None,
46
+ content_location: str | None = None,
47
+ ) -> None:
48
+ original(field, content_disposition, content_type, content_location)
49
+ filename = field._filename # pyright: ignore[reportPrivateUsage]
50
+ if filename and any(ord(c) > 127 for c in filename):
51
+ encoded = urllib.parse.quote(filename, safe="")
52
+ existing = field.headers.get("Content-Disposition") or ""
53
+ field.headers["Content-Disposition"] = existing + f"; filename*=utf-8''{encoded}"
54
+
55
+ RequestField.make_multipart = _patched # pyright: ignore[reportAttributeAccessIssue]
56
+
57
+ def uninstall(self) -> None:
58
+ if self._original is None:
59
+ return
60
+ RequestField.make_multipart = self._original # pyright: ignore[reportAttributeAccessIssue]
61
+ self._original = None
62
+
63
+ def __enter__(self) -> MultibyteFilenameSupport:
64
+ self.install()
65
+ return self
66
+
67
+ def __exit__(self, *exc: Any) -> None:
68
+ self.uninstall()
@@ -11,80 +11,17 @@ import http.client
11
11
  import logging
12
12
  import os
13
13
  import sys
14
- import urllib.parse
15
- from collections.abc import Callable
16
14
  from dataclasses import dataclass
17
15
  from typing import Any
18
16
 
19
17
  import asana
20
18
  import click
21
- from urllib3.fields import RequestField
22
19
 
23
20
  from asana_api_cli.redactor import HttpClientAuthRedactor
24
21
 
25
22
  ACCESS_TOKEN_ENV = "ASANA_ACCESS_TOKEN"
26
23
 
27
24
 
28
- class MultibyteFilenameSupport:
29
- """Make multipart uploads round-trip filenames with non-ASCII characters.
30
-
31
- In ``python-asana`` 5.2.4 (the latest version checked, and likely later
32
- ones too), uploading a file whose name has characters outside ASCII
33
- stores a garbled (mojibake) name on Asana: the SDK's multipart encoder
34
- emits only ``filename="..."`` and omits the RFC 5987 ``filename*=``
35
- parameter the server needs to decode them. This context manager patches
36
- ``urllib3.fields.RequestField.make_multipart`` to add
37
- ``filename*=utf-8''<percent-encoded>`` for such names.
38
-
39
- Off by default to preserve strict SDK parity. The CLI enables it when
40
- ``--multibyte-filenames`` is passed to an upload command (e.g.
41
- ``attachments create-attachment-for-object``): that option's callback enters
42
- this context manager via ``ctx.with_resource``, scoping the patch to the
43
- command. The context-manager form scopes the patch to a block::
44
-
45
- with MultibyteFilenameSupport():
46
- # urllib3-based uploads in this block emit filename*=
47
- ...
48
- """
49
-
50
- def __init__(self) -> None:
51
- self._original: Callable[..., None] | None = None
52
-
53
- def install(self) -> None:
54
- if self._original is not None:
55
- return
56
- self._original = RequestField.make_multipart
57
- original = self._original
58
-
59
- def _patched(
60
- field: RequestField,
61
- content_disposition: str | None = None,
62
- content_type: str | None = None,
63
- content_location: str | None = None,
64
- ) -> None:
65
- original(field, content_disposition, content_type, content_location)
66
- filename = field._filename # pyright: ignore[reportPrivateUsage]
67
- if filename and any(ord(c) > 127 for c in filename):
68
- encoded = urllib.parse.quote(filename, safe="")
69
- existing = field.headers.get("Content-Disposition") or ""
70
- field.headers["Content-Disposition"] = existing + f"; filename*=utf-8''{encoded}"
71
-
72
- RequestField.make_multipart = _patched # pyright: ignore[reportAttributeAccessIssue]
73
-
74
- def uninstall(self) -> None:
75
- if self._original is None:
76
- return
77
- RequestField.make_multipart = self._original # pyright: ignore[reportAttributeAccessIssue]
78
- self._original = None
79
-
80
- def __enter__(self) -> MultibyteFilenameSupport:
81
- self.install()
82
- return self
83
-
84
- def __exit__(self, *exc: Any) -> None:
85
- self.uninstall()
86
-
87
-
88
25
  @dataclass
89
26
  class _Runtime:
90
27
  """Configuration shared globally during a CLI invocation.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: asana-api-cli
3
- Version: 3.1.2
3
+ Version: 3.1.3
4
4
  Summary: Command-line wrapper around the official Asana Python SDK
5
5
  Author: Masanao Izumo
6
6
  License-Expression: MIT
@@ -5,6 +5,7 @@ src/asana_api_cli/__init__.py
5
5
  src/asana_api_cli/cli.py
6
6
  src/asana_api_cli/click_ext.py
7
7
  src/asana_api_cli/formatter.py
8
+ src/asana_api_cli/multibyte_filename.py
8
9
  src/asana_api_cli/redactor.py
9
10
  src/asana_api_cli/session.py
10
11
  src/asana_api_cli/structured_arg.py
@@ -20,6 +21,7 @@ tests/test_cli_invocation.py
20
21
  tests/test_cli_surface.py
21
22
  tests/test_click_ext.py
22
23
  tests/test_formatter.py
24
+ tests/test_multibyte_filename.py
23
25
  tests/test_py310_compat.py
24
26
  tests/test_redactor.py
25
27
  tests/test_sdk_boilerplate.py
@@ -2,7 +2,9 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import io
5
6
  import json
7
+ import sys
6
8
  from typing import Any, cast
7
9
 
8
10
  import click
@@ -132,17 +134,27 @@ class TestFormatOutputCsv:
132
134
  _format_output([], output_format="csv", jq_query=None)
133
135
  assert capsys.readouterr().out == ""
134
136
 
135
- def test_no_carriage_returns_in_csv_output(self, capsys: pytest.CaptureFixture[str]) -> None:
136
- # csv module's default lineterminator is "\r\n", which would interact
137
- # with Windows text-mode stdout to produce "\r\r\n". We force "\n"
138
- # so the platform layer handles any translation.
137
+ def test_crlf_rows_with_verbatim_lf_in_field(self, capsys: pytest.CaptureFixture[str]) -> None:
138
+ # RFC 4180: each record is terminated by CRLF, while a newline inside a
139
+ # quoted field is written verbatim (LF). The bytes are identical on every
140
+ # platform no text-layer translation doubling a CR into "\r\r\n".
139
141
  _format_output(
140
- [{"a": "1", "b": "2"}, {"a": "3", "b": "4"}],
142
+ [{"a": "1", "b": "x\ny"}, {"a": "2", "b": "z"}],
141
143
  output_format="csv",
142
144
  jq_query=None,
143
145
  )
144
146
  out = capsys.readouterr().out
145
- assert "\r" not in out
147
+ assert out == 'a,b\r\n1,"x\ny"\r\n2,z\r\n'
148
+ assert out.count("\r\n") == 3 # header + 2 records, each CRLF-terminated
149
+ assert "\r\r\n" not in out
150
+
151
+ def test_crlf_preserved_on_pure_text_stdout(self, monkeypatch: pytest.MonkeyPatch) -> None:
152
+ # A pure text stream (io.StringIO) has no binary buffer, exercising the
153
+ # fallback branch. It still emits RFC 4180 CRLF terminators verbatim.
154
+ buf = io.StringIO()
155
+ monkeypatch.setattr(sys, "stdout", buf)
156
+ _format_output([{"a": "1"}], output_format="csv", jq_query=None)
157
+ assert buf.getvalue() == "a\r\n1\r\n"
146
158
 
147
159
  def test_bom_off_by_default(self, capsys: pytest.CaptureFixture[str]) -> None:
148
160
  _format_output([{"a": "1"}], output_format="csv", jq_query=None)
@@ -0,0 +1,117 @@
1
+ """Tests for asana_api_cli.multibyte_filename — the ``MultibyteFilenameSupport``
2
+ context manager that augments ``urllib3.fields.RequestField.make_multipart`` so
3
+ multipart fields whose filename contains non-ASCII characters also carry the
4
+ RFC 5987 ``filename*=UTF-8''<percent-encoded>`` parameter.
5
+
6
+ The CLI wiring (the per-command ``--multibyte-filenames`` flag) is covered in
7
+ ``test_cli_invocation.py``.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from collections.abc import Iterator
13
+
14
+ import pytest
15
+ from urllib3.fields import RequestField
16
+
17
+ from asana_api_cli.multibyte_filename import MultibyteFilenameSupport
18
+
19
+
20
+ @pytest.fixture
21
+ def _clean_request_field() -> Iterator[None]:
22
+ """Snapshot and restore ``urllib3.fields.RequestField.make_multipart``."""
23
+ saved = RequestField.make_multipart
24
+ try:
25
+ yield
26
+ finally:
27
+ RequestField.make_multipart = saved # pyright: ignore[reportAttributeAccessIssue]
28
+
29
+
30
+ def _content_disposition(filename: str | None) -> str:
31
+ """Build a fresh ``RequestField`` and return its rendered
32
+ Content-Disposition after ``make_multipart``."""
33
+ field = RequestField("file", b"data", filename=filename)
34
+ field.make_multipart()
35
+ result = field.headers["Content-Disposition"]
36
+ assert result is not None # set unconditionally by make_multipart
37
+ return result
38
+
39
+
40
+ class TestMultibyteFilenameSupport:
41
+ """``MultibyteFilenameSupport`` augments ``RequestField.make_multipart``
42
+ so that multipart fields whose filename contains non-ASCII characters
43
+ also carry the RFC 5987 ``filename*=UTF-8''<percent-encoded>``
44
+ parameter."""
45
+
46
+ def test_context_manager_installs_and_uninstalls(self, _clean_request_field: None) -> None:
47
+ original = RequestField.make_multipart
48
+ with MultibyteFilenameSupport():
49
+ assert RequestField.make_multipart is not original
50
+ assert RequestField.make_multipart is original
51
+
52
+ def test_explicit_install_uninstall(self, _clean_request_field: None) -> None:
53
+ original = RequestField.make_multipart
54
+ patcher = MultibyteFilenameSupport()
55
+ patcher.install()
56
+ assert RequestField.make_multipart is not original
57
+ patcher.uninstall()
58
+ assert RequestField.make_multipart is original
59
+
60
+ def test_uninstall_safe_to_call_twice(self, _clean_request_field: None) -> None:
61
+ patcher = MultibyteFilenameSupport()
62
+ patcher.install()
63
+ patcher.uninstall()
64
+ patcher.uninstall() # no-op, must not raise
65
+
66
+ def test_install_idempotent_on_same_instance(self, _clean_request_field: None) -> None:
67
+ """A second ``install()`` on the same instance must not capture
68
+ the already-patched function as the new ``_original`` — that
69
+ would make ``uninstall()`` restore the patch instead of the
70
+ true upstream function."""
71
+ original = RequestField.make_multipart
72
+ patcher = MultibyteFilenameSupport()
73
+ patcher.install()
74
+ first_patched = RequestField.make_multipart
75
+ patcher.install()
76
+ # Still the same patched function — no double-wrapping.
77
+ assert RequestField.make_multipart is first_patched
78
+ patcher.uninstall()
79
+ # And ``_original`` survived intact, so uninstall restored upstream.
80
+ assert RequestField.make_multipart is original
81
+
82
+ def test_default_behavior_unchanged_without_patch(self) -> None:
83
+ """Sanity check on the upstream baseline: a non-ASCII filename
84
+ only produces ``filename="<utf-8 chars>"`` (no ``filename*=``)
85
+ when the patch is NOT installed."""
86
+ disposition = _content_disposition("日本語.txt")
87
+ assert "filename*=" not in disposition
88
+
89
+ def test_ascii_filename_is_noop(self, _clean_request_field: None) -> None:
90
+ """ASCII filenames must pass through unchanged — no extra
91
+ ``filename*=`` parameter."""
92
+ with MultibyteFilenameSupport():
93
+ disposition = _content_disposition("ascii.txt")
94
+ assert 'filename="ascii.txt"' in disposition
95
+ assert "filename*=" not in disposition
96
+
97
+ def test_non_ascii_filename_adds_filename_star(self, _clean_request_field: None) -> None:
98
+ """Non-ASCII filenames get an additional RFC 5987
99
+ ``filename*=utf-8''<percent-encoded>`` parameter."""
100
+ with MultibyteFilenameSupport():
101
+ disposition = _content_disposition("日本語.txt")
102
+ # Original ``filename=`` is kept (servers that don't speak RFC
103
+ # 5987 fall back to it).
104
+ assert 'filename="日本語.txt"' in disposition
105
+ # Plus the encoded ``filename*=`` variant for servers that do.
106
+ assert "filename*=utf-8''%E6%97%A5%E6%9C%AC%E8%AA%9E.txt" in disposition
107
+
108
+ def test_no_filename_field_unaffected(self, _clean_request_field: None) -> None:
109
+ """Multipart fields without a filename (e.g. plain form data
110
+ like ``parent=<gid>``) must pass through unchanged."""
111
+ with MultibyteFilenameSupport():
112
+ field = RequestField("parent", b"12345")
113
+ field.make_multipart()
114
+ disposition = field.headers["Content-Disposition"]
115
+ assert disposition is not None
116
+ assert 'name="parent"' in disposition
117
+ assert "filename*=" not in disposition
@@ -1,5 +1,5 @@
1
1
  """Tests for asana_api_cli.session — the AsanaSession side-effect lifecycle,
2
- MultibyteFilenameSupport, the pagination knobs, the ``_CONFIG_KNOBS``
2
+ the pagination knobs, the ``_CONFIG_KNOBS``
3
3
  Configuration table, and the ApiClient-instance settings (``--user-agent`` /
4
4
  ``--set-default-header``).
5
5
 
@@ -15,11 +15,9 @@ from collections.abc import Iterator
15
15
  from typing import Any
16
16
 
17
17
  import pytest
18
- from urllib3.fields import RequestField
19
18
 
20
19
  from asana_api_cli.session import (
21
20
  AsanaSession,
22
- MultibyteFilenameSupport,
23
21
  runtime,
24
22
  )
25
23
 
@@ -155,112 +153,6 @@ class TestAsanaSessionSideEffectLifecycle:
155
153
  assert http.client.HTTPConnection.debuglevel == 0
156
154
 
157
155
 
158
- # ---------------------------------------------------------------------------
159
- # MultibyteFilenameSupport
160
- # ---------------------------------------------------------------------------
161
-
162
-
163
- @pytest.fixture
164
- def _clean_request_field() -> Iterator[None]:
165
- """Snapshot and restore ``urllib3.fields.RequestField.make_multipart``."""
166
- saved = RequestField.make_multipart
167
- try:
168
- yield
169
- finally:
170
- RequestField.make_multipart = saved # pyright: ignore[reportAttributeAccessIssue]
171
-
172
-
173
- def _content_disposition(filename: str | None) -> str:
174
- """Build a fresh ``RequestField`` and return its rendered
175
- Content-Disposition after ``make_multipart``."""
176
- field = RequestField("file", b"data", filename=filename)
177
- field.make_multipart()
178
- result = field.headers["Content-Disposition"]
179
- assert result is not None # set unconditionally by make_multipart
180
- return result
181
-
182
-
183
- class TestMultibyteFilenameSupport:
184
- """``MultibyteFilenameSupport`` augments ``RequestField.make_multipart``
185
- so that multipart fields whose filename contains non-ASCII characters
186
- also carry the RFC 5987 ``filename*=UTF-8''<percent-encoded>``
187
- parameter. Off by default to preserve strict SDK parity; opt-in via
188
- ``--multibyte-filenames``."""
189
-
190
- def test_context_manager_installs_and_uninstalls(self, _clean_request_field: None) -> None:
191
- original = RequestField.make_multipart
192
- with MultibyteFilenameSupport():
193
- assert RequestField.make_multipart is not original
194
- assert RequestField.make_multipart is original
195
-
196
- def test_explicit_install_uninstall(self, _clean_request_field: None) -> None:
197
- original = RequestField.make_multipart
198
- patcher = MultibyteFilenameSupport()
199
- patcher.install()
200
- assert RequestField.make_multipart is not original
201
- patcher.uninstall()
202
- assert RequestField.make_multipart is original
203
-
204
- def test_uninstall_safe_to_call_twice(self, _clean_request_field: None) -> None:
205
- patcher = MultibyteFilenameSupport()
206
- patcher.install()
207
- patcher.uninstall()
208
- patcher.uninstall() # no-op, must not raise
209
-
210
- def test_install_idempotent_on_same_instance(self, _clean_request_field: None) -> None:
211
- """A second ``install()`` on the same instance must not capture
212
- the already-patched function as the new ``_original`` — that
213
- would make ``uninstall()`` restore the patch instead of the
214
- true upstream function."""
215
- original = RequestField.make_multipart
216
- patcher = MultibyteFilenameSupport()
217
- patcher.install()
218
- first_patched = RequestField.make_multipart
219
- patcher.install()
220
- # Still the same patched function — no double-wrapping.
221
- assert RequestField.make_multipart is first_patched
222
- patcher.uninstall()
223
- # And ``_original`` survived intact, so uninstall restored upstream.
224
- assert RequestField.make_multipart is original
225
-
226
- def test_default_behavior_unchanged_without_patch(self) -> None:
227
- """Sanity check on the upstream baseline: a non-ASCII filename
228
- only produces ``filename="<utf-8 chars>"`` (no ``filename*=``)
229
- when the patch is NOT installed."""
230
- disposition = _content_disposition("日本語.txt")
231
- assert "filename*=" not in disposition
232
-
233
- def test_ascii_filename_is_noop(self, _clean_request_field: None) -> None:
234
- """ASCII filenames must pass through unchanged — no extra
235
- ``filename*=`` parameter."""
236
- with MultibyteFilenameSupport():
237
- disposition = _content_disposition("ascii.txt")
238
- assert 'filename="ascii.txt"' in disposition
239
- assert "filename*=" not in disposition
240
-
241
- def test_non_ascii_filename_adds_filename_star(self, _clean_request_field: None) -> None:
242
- """Non-ASCII filenames get an additional RFC 5987
243
- ``filename*=utf-8''<percent-encoded>`` parameter."""
244
- with MultibyteFilenameSupport():
245
- disposition = _content_disposition("日本語.txt")
246
- # Original ``filename=`` is kept (servers that don't speak RFC
247
- # 5987 fall back to it).
248
- assert 'filename="日本語.txt"' in disposition
249
- # Plus the encoded ``filename*=`` variant for servers that do.
250
- assert "filename*=utf-8''%E6%97%A5%E6%9C%AC%E8%AA%9E.txt" in disposition
251
-
252
- def test_no_filename_field_unaffected(self, _clean_request_field: None) -> None:
253
- """Multipart fields without a filename (e.g. plain form data
254
- like ``parent=<gid>``) must pass through unchanged."""
255
- with MultibyteFilenameSupport():
256
- field = RequestField("parent", b"12345")
257
- field.make_multipart()
258
- disposition = field.headers["Content-Disposition"]
259
- assert disposition is not None
260
- assert 'name="parent"' in disposition
261
- assert "filename*=" not in disposition
262
-
263
-
264
156
  # ---------------------------------------------------------------------------
265
157
  # AsanaSession pagination kwargs
266
158
  # ---------------------------------------------------------------------------
File without changes
File without changes
File without changes