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.
- {asana_api_cli-3.1.2/src/asana_api_cli.egg-info → asana_api_cli-3.1.3}/PKG-INFO +1 -1
- {asana_api_cli-3.1.2 → asana_api_cli-3.1.3}/pyproject.toml +1 -1
- {asana_api_cli-3.1.2 → asana_api_cli-3.1.3}/src/asana_api_cli/cli.py +6 -5
- {asana_api_cli-3.1.2 → asana_api_cli-3.1.3}/src/asana_api_cli/formatter.py +14 -4
- asana_api_cli-3.1.3/src/asana_api_cli/multibyte_filename.py +68 -0
- {asana_api_cli-3.1.2 → asana_api_cli-3.1.3}/src/asana_api_cli/session.py +0 -63
- {asana_api_cli-3.1.2 → asana_api_cli-3.1.3/src/asana_api_cli.egg-info}/PKG-INFO +1 -1
- {asana_api_cli-3.1.2 → asana_api_cli-3.1.3}/src/asana_api_cli.egg-info/SOURCES.txt +2 -0
- {asana_api_cli-3.1.2 → asana_api_cli-3.1.3}/tests/test_formatter.py +18 -6
- asana_api_cli-3.1.3/tests/test_multibyte_filename.py +117 -0
- {asana_api_cli-3.1.2 → asana_api_cli-3.1.3}/tests/test_session.py +1 -109
- {asana_api_cli-3.1.2 → asana_api_cli-3.1.3}/LICENSE +0 -0
- {asana_api_cli-3.1.2 → asana_api_cli-3.1.3}/README.md +0 -0
- {asana_api_cli-3.1.2 → asana_api_cli-3.1.3}/setup.cfg +0 -0
- {asana_api_cli-3.1.2 → asana_api_cli-3.1.3}/src/asana_api_cli/__init__.py +0 -0
- {asana_api_cli-3.1.2 → asana_api_cli-3.1.3}/src/asana_api_cli/click_ext.py +0 -0
- {asana_api_cli-3.1.2 → asana_api_cli-3.1.3}/src/asana_api_cli/redactor.py +0 -0
- {asana_api_cli-3.1.2 → asana_api_cli-3.1.3}/src/asana_api_cli/structured_arg.py +0 -0
- {asana_api_cli-3.1.2 → asana_api_cli-3.1.3}/src/asana_api_cli/version.py +0 -0
- {asana_api_cli-3.1.2 → asana_api_cli-3.1.3}/src/asana_api_cli.egg-info/dependency_links.txt +0 -0
- {asana_api_cli-3.1.2 → asana_api_cli-3.1.3}/src/asana_api_cli.egg-info/entry_points.txt +0 -0
- {asana_api_cli-3.1.2 → asana_api_cli-3.1.3}/src/asana_api_cli.egg-info/requires.txt +0 -0
- {asana_api_cli-3.1.2 → asana_api_cli-3.1.3}/src/asana_api_cli.egg-info/top_level.txt +0 -0
- {asana_api_cli-3.1.2 → asana_api_cli-3.1.3}/tests/test_cli.py +0 -0
- {asana_api_cli-3.1.2 → asana_api_cli-3.1.3}/tests/test_cli_invocation.py +0 -0
- {asana_api_cli-3.1.2 → asana_api_cli-3.1.3}/tests/test_cli_surface.py +0 -0
- {asana_api_cli-3.1.2 → asana_api_cli-3.1.3}/tests/test_click_ext.py +0 -0
- {asana_api_cli-3.1.2 → asana_api_cli-3.1.3}/tests/test_py310_compat.py +0 -0
- {asana_api_cli-3.1.2 → asana_api_cli-3.1.3}/tests/test_redactor.py +0 -0
- {asana_api_cli-3.1.2 → asana_api_cli-3.1.3}/tests/test_sdk_boilerplate.py +0 -0
- {asana_api_cli-3.1.2 → asana_api_cli-3.1.3}/tests/test_structured_arg.py +0 -0
- {asana_api_cli-3.1.2 → asana_api_cli-3.1.3}/tests/test_version.py +0 -0
|
@@ -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
|
|
899
|
-
# only affects multipart uploads, so it is
|
|
900
|
-
# (``does_upload``) rather than as a global
|
|
901
|
-
# strict SDK parity (the SDK emits
|
|
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
|
-
#
|
|
390
|
-
|
|
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
|
-
|
|
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.
|
|
@@ -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
|
|
136
|
-
#
|
|
137
|
-
#
|
|
138
|
-
#
|
|
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": "
|
|
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
|
|
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
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|