diff --git a/CHANGELOG.md b/CHANGELOG.md index 8dff690..d6865ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,11 +9,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - - `transformer.request.Request.name`: This attribute is default set as `Request.url`, - users can change `Request.name` in `OnTask` plugin. If `Request.name` is changed, - the locust request name argument will changed to `Request.name`. (#32) + - `transformer.request.Request.name`: Controls + [Locust's URL grouping][locust-dynamic-parameters]. Its default value is + `Request.url`, which ensures retrocompatibility. (#32) + - `transformer.python.ExpressionView`: An Expression that wraps a non-Expression + (e.g. a Request instance), similarly to how Standalone is a Statement that + wraps an Expression. A ExpressionView has a `target` (the wrapped object), a + `converter` (function capable of transforming the target into an Expression), + and a `name` for inspection purposes. (#33) -## [1.0.2] - 2019-02-21 +### Changed + + - When processing HAR requests with the `application/json` MIME type, + Transformer no longer uses the `params` field as a replacement for a missing + `text` field. This was a mitigation for a bug in a different, internal tool. (#33) + +### Fixed + + - A bug in the conversion between Task and Task2 makes Transformer ignore all + changes made by plugins to `Task2.request` in the generated locustfile. + Thank you [@xinke2411][] for reporting this! (#33) + +### Removed + + - `transformer.task.Task.as_locust_action`: As part of the merge between Task + and Task2 (#11). `as_locust_action` generates locustfile code as a string, + which is made obsolete by the `transformer.python` syntax tree framework. (#33) + +## [1.0.2][] - 2019-02-21 ### Added @@ -42,14 +65,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `transformer.transform.transform` (#14) - `transformer.locust.locustfile` (#14) -## [1.0.1] - 2019-02-12 +## [1.0.1][] - 2019-02-12 ### Fixed - Fix `transformer` command-line crash due to a missing version identifier. (#17) - Publish development releases to PyPI for every merge to the `master` branch. (#17) -## [1.0.0] - 2019-02-11 +## [1.0.0][] - 2019-02-11 ### Added @@ -76,3 +99,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [1.0.2]: https://github.com/zalando-incubator/transformer/compare/v1.0.1...v1.0.2 [1.0.1]: https://github.com/zalando-incubator/transformer/compare/v1.0.0...v1.0.1 [1.0.0]: https://github.com/zalando-incubator/transformer/compare/f842c4163e037dc345eaf1992187f58126b7d909...v1.0.0 + +[@xinke2411]: https://github.com/xinke2411 + +[locust-dynamic-parameters]: https://docs.locust.io/en/stable/writing-a-locustfile.html#grouping-requests-to-urls-with-dynamic-parameters \ No newline at end of file diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 9e3e1e5..e0acb01 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -4,6 +4,7 @@ All external contributors to the project. We are grateful for all their help! ## Contributors sorted alphabetically - - **[xinke2411](https://github.com/xinke2411)** + - [#34](https://github.com/zalando-incubator/Transformer/pull/34): + _Add attribute name for class Request_. diff --git a/poetry.lock b/poetry.lock index b3d0ebe..57a0ae7 100644 --- a/poetry.lock +++ b/poetry.lock @@ -97,6 +97,14 @@ optional = false python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*, <4" version = "4.5.2" +[[package]] +category = "main" +description = "A backport of the dataclasses module for Python 3.6" +name = "dataclasses" +optional = false +python-versions = "*" +version = "0.6" + [[package]] category = "main" description = "Pythonic argument parser, that will make you smile" @@ -488,7 +496,7 @@ python-versions = "*" version = "0.14.1" [metadata] -content-hash = "07875f7d656cdf2a43285874140662ab7ac017bfc395f0619fad99f246fcf71a" +content-hash = "b571ac733fdcaec72508313548443753bb6d648385ef50aaa26af52a8e1a1594" python-versions = "^3.6" [metadata.hashes] @@ -503,6 +511,7 @@ chevron = ["95b0a055ef0ada5eb061d60be64a7f70670b53372ccd221d1b88adf1c41a9094", " click = ["2335065e6395b9e67ca716de5f7526736bfa6ceead690adf616d925bdc622b13", "5b94b49521f6456670fdb30cd82a4eca9412788a93fa6dd6df72c94d5a8ff2d7"] colorama = ["05eed71e2e327246ad6b38c540c4a3117230b19679b875190486ddd2d721422d", "f8ac84de7840f5b9c4e3347b3c1eaa50f7e49c2b07596221daec5edaabbd7c48"] coverage = ["06123b58a1410873e22134ca2d88bd36680479fe354955b3579fb8ff150e4d27", "09e47c529ff77bf042ecfe858fb55c3e3eb97aac2c87f0349ab5a7efd6b3939f", "0a1f9b0eb3aa15c990c328535655847b3420231af299386cfe5efc98f9c250fe", "0cc941b37b8c2ececfed341444a456912e740ecf515d560de58b9a76562d966d", "0d34245f824cc3140150ab7848d08b7e2ba67ada959d77619c986f2062e1f0e8", "10e8af18d1315de936d67775d3a814cc81d0747a1a0312d84e27ae5610e313b0", "1b4276550b86caa60606bd3572b52769860a81a70754a54acc8ba789ce74d607", "1e8a2627c48266c7b813975335cfdea58c706fe36f607c97d9392e61502dc79d", "258b21c5cafb0c3768861a6df3ab0cfb4d8b495eee5ec660e16f928bf7385390", "2b224052bfd801beb7478b03e8a66f3f25ea56ea488922e98903914ac9ac930b", "3ad59c84c502cd134b0088ca9038d100e8fb5081bbd5ccca4863f3804d81f61d", "447c450a093766744ab53bf1e7063ec82866f27bcb4f4c907da25ad293bba7e3", "46101fc20c6f6568561cdd15a54018bb42980954b79aa46da8ae6f008066a30e", "4710dc676bb4b779c4361b54eb308bc84d64a2fa3d78e5f7228921eccce5d815", "510986f9a280cd05189b42eee2b69fecdf5bf9651d4cd315ea21d24a964a3c36", "5535dda5739257effef56e49a1c51c71f1d37a6e5607bb25a5eee507c59580d1", "5a7524042014642b39b1fcae85fb37556c200e64ec90824ae9ecf7b667ccfc14", "5f55028169ef85e1fa8e4b8b1b91c0b3b0fa3297c4fb22990d46ff01d22c2d6c", "6694d5573e7790a0e8d3d177d7a416ca5f5c150742ee703f3c18df76260de794", "6831e1ac20ac52634da606b658b0b2712d26984999c9d93f0c6e59fe62ca741b", "71afc1f5cd72ab97330126b566bbf4e8661aab7449f08895d21a5d08c6b051ff", "7349c27128334f787ae63ab49d90bf6d47c7288c63a0a5dfaa319d4b4541dd2c", "77f0d9fa5e10d03aa4528436e33423bfa3718b86c646615f04616294c935f840", "828ad813c7cdc2e71dcf141912c685bfe4b548c0e6d9540db6418b807c345ddd", "859714036274a75e6e57c7bab0c47a4602d2a8cfaaa33bbdb68c8359b2ed4f5c", "85a06c61598b14b015d4df233d249cd5abfa61084ef5b9f64a48e997fd829a82", "869ef4a19f6e4c6987e18b315721b8b971f7048e6eaea29c066854242b4e98d9", "8cb4febad0f0b26c6f62e1628f2053954ad2c555d67660f28dfb1b0496711952", "977e2d9a646773cc7428cdd9a34b069d6ee254fadfb4d09b3f430e95472f3cf3", "99bd767c49c775b79fdcd2eabff405f1063d9d959039c0bdd720527a7738748a", "a5c58664b23b248b16b96253880b2868fb34358911400a7ba39d7f6399935389", "aaa0f296e503cda4bc07566f592cd7a28779d433f3a23c48082af425d6d5a78f", "ab235d9fe64833f12d1334d29b558aacedfbca2356dfb9691f2d0d38a8a7bfb4", "b3b0c8f660fae65eac74fbf003f3103769b90012ae7a460863010539bb7a80da", "bab8e6d510d2ea0f1d14f12642e3f35cefa47a9b2e4c7cea1852b52bc9c49647", "c45297bbdbc8bb79b02cf41417d63352b70bcb76f1bbb1ee7d47b3e89e42f95d", "d19bca47c8a01b92640c614a9147b081a1974f69168ecd494687c827109e8f42", "d64b4340a0c488a9e79b66ec9f9d77d02b99b772c8b8afd46c1294c1d39ca478", "da969da069a82bbb5300b59161d8d7c8d423bc4ccd3b410a9b4d8932aeefc14b", "ed02c7539705696ecb7dc9d476d861f3904a8d2b7e894bd418994920935d36bb", "ee5b8abc35b549012e03a7b1e86c09491457dba6c94112a2482b18589cc2bdb9"] +dataclasses = ["454a69d788c7fda44efd71e259be79577822f5e3f53f029a22d08004e951dc9f", "6988bd2b895eef432d562370bb707d540f32f7360ab13da45340101bc2307d84"] docopt = ["49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491"] ecological = ["536cca0c6d6f0a85dcf3c825ddc31189a5c78428b6deed0eac00637d450d4f89", "ed0f6e5a6ae5bd0db32591c0ed0cb62816ae5e0f0a0677ce1192ebf00b7d83eb"] entrypoints = ["589f874b313739ad35be6e0cd7efde2a4e9b6fea91edcc34e58ecbb8dbe56d19", "c70dd71abe5a8c85e55e12c19bd91ccfeec11a6e99044204511f9ed547d48451"] diff --git a/pyproject.toml b/pyproject.toml index b33de7d..4c1a675 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,7 @@ pendulum = "^2.0" chevron = "^0.13" docopt = "^0.6.2" ecological = "^1.6" +dataclasses = "^0.6.0" [tool.poetry.dev-dependencies] locustio = "^0.9.0" diff --git a/transformer/plugins/sanitize_headers.py b/transformer/plugins/sanitize_headers.py index 588d895..57e3f07 100644 --- a/transformer/plugins/sanitize_headers.py +++ b/transformer/plugins/sanitize_headers.py @@ -1,5 +1,5 @@ -from transformer.helpers import zip_kv_pairs from transformer.plugins import plugin, Contract +from transformer.request import Header from transformer.task import Task2 @@ -10,17 +10,10 @@ def plugin(task: Task2) -> Task2: Converts header names to lowercase to simplify further overriding. Removes the cookie header as it is handled by Locust's HttpSession. """ - headers = task.request.headers - - if not isinstance(headers, dict): - headers = zip_kv_pairs(headers) - - sanitized_headers = { - k.lower(): v - for (k, v) in headers.items() - if not k.startswith(":") and k.lower() != "cookie" - } - - task.request = task.request._replace(headers=sanitized_headers) + task.request.headers = [ + Header(name=h.name.lower(), value=h.value) + for h in task.request.headers + if not h.name.startswith(":") and h.name.lower() != "cookie" + ] return task diff --git a/transformer/plugins/test_sanitize_headers.py b/transformer/plugins/test_sanitize_headers.py index 00d8550..68045a2 100644 --- a/transformer/plugins/test_sanitize_headers.py +++ b/transformer/plugins/test_sanitize_headers.py @@ -39,7 +39,8 @@ def test_it_removes_headers_beginning_with_a_colon(): def test_it_downcases_header_names(): task = task_with_header("Some Name", "some value") sanitized_headers = plugin(task).request.headers - assert "some name" in sanitized_headers + header_names = {h.name for h in sanitized_headers} + assert "some name" in header_names def test_it_removes_cookies(): diff --git a/transformer/python.py b/transformer/python.py index 5a15f6c..72801d9 100644 --- a/transformer/python.py +++ b/transformer/python.py @@ -11,8 +11,12 @@ Tuple, cast, Iterable, + Callable, + TypeVar, ) +from dataclasses import dataclass + IMMUTABLE_EMPTY_DICT = MappingProxyType({}) @@ -686,3 +690,43 @@ def __repr__(self) -> str: self.alias, self.comments, ) + + +_T = TypeVar("_T") + + +@dataclass +class ExpressionView(Expression): + """ + The promise of an Expression representing an object currently not in + Expression format. + + The ExpressionView allows to mix non-Expression objects in the syntax tree, + along with a function capable of transforming these objects into actual + Expression objects at any time. + This is useful when there is a simpler representation than Expression. + + For instance, any Request object can be converted into an equivalent + Expression, but Request has a simpler API than Expression for all + request-oriented operations like accessing the URL, etc. + Embedding a Request in a ExpressionView allows to pretend that the Request is + already in Expression format (with all associated benefits) but still use + the Request API. + + `target` is a callable returning the non-Expression object. That callable + allows to specify as target some mutable field of an object, rather than a + fixed reference to an object. See for example task.Task2, which contains a + ExpressionView to its own "request" field; if the value of that field is + changed, the ExpressionView will refer to the new value instead of keeping a + reference to the old value. + + `name` is purely descriptive: it can make inspection of data structures + containing ExpressionView objects more comfortable. + """ + + name: str + target: Callable[[], _T] + converter: Callable[[_T], Expression] + + def __str__(self) -> str: + return str(self.converter(self.target())) diff --git a/transformer/request.py b/transformer/request.py index 81eff4f..e49544c 100644 --- a/transformer/request.py +++ b/transformer/request.py @@ -9,6 +9,7 @@ from urllib.parse import urlparse, SplitResult import pendulum +from dataclasses import dataclass from transformer.naming import to_identifier @@ -34,7 +35,8 @@ class Header(NamedTuple): value: str -class QueryPair(NamedTuple): +@dataclass +class QueryPair: """ Query String as recorded in HAR file. """ @@ -43,19 +45,29 @@ class QueryPair(NamedTuple): value: str -class Request(NamedTuple): +@dataclass +class Request: """ An HTTP request as recorded in a HAR file. + + Note that *post_data*, if present, will be a dict of the same format as read + in the HAR file. + Although not consistently followed by HAR generators, its format is + documented here: http://www.softwareishard.com/blog/har-12-spec/#postData. """ timestamp: datetime method: HttpMethod url: SplitResult - headers: List[Header] - post_data: dict - query: List[QueryPair] + headers: List[Header] = () + post_data: Optional[dict] = None + query: List[QueryPair] = () name: Optional[str] = None + def __post_init__(self): + self.headers = list(self.headers) + self.query = list(self.query) + @classmethod def from_har_entry(cls, entry: dict) -> "Request": """ @@ -109,6 +121,6 @@ def __hash__(self) -> int: self.timestamp, self.method, self.url, - tuple(self.post_data) if self.post_data else None, + tuple(self.post_data.items()) if self.post_data else None, ) ) diff --git a/transformer/scenario.py b/transformer/scenario.py index 08da472..abd7cd3 100644 --- a/transformer/scenario.py +++ b/transformer/scenario.py @@ -14,7 +14,6 @@ NamedTuple, ) - import transformer.plugins as plug from transformer.naming import to_identifier from transformer.plugins.contracts import Plugin diff --git a/transformer/task.py b/transformer/task.py index 544f79c..74ef113 100644 --- a/transformer/task.py +++ b/transformer/task.py @@ -2,10 +2,25 @@ """ A representation of a Locust Task. """ - import json +from collections import OrderedDict +from json import JSONDecodeError from types import MappingProxyType -from typing import Iterable, NamedTuple, Iterator, Sequence, Optional, Mapping, List +from typing import ( + Iterable, + NamedTuple, + Iterator, + Sequence, + Optional, + Mapping, + Dict, + List, + Tuple, + cast, +) + +import dataclasses +from dataclasses import dataclass import transformer.python as py from transformer.blacklist import on_blacklist @@ -15,6 +30,7 @@ IMMUTABLE_EMPTY_DICT = MappingProxyType({}) TIMEOUT = 30 ACTION_INDENTATION_LEVEL = 12 +JSON_MIME_TYPE = "application/json" class LocustRequest(NamedTuple): @@ -40,45 +56,21 @@ def from_request(cls, r: Request) -> "LocustRequest": name=repr(r.name or r.url.geturl()), ) - NOOP_HTTP_METHODS = {HttpMethod.GET, HttpMethod.OPTIONS, HttpMethod.DELETE} - - def as_locust_action(self) -> str: - args = { - "url": self.url, - "name": self.name or self.url, - "headers": self.headers, - "timeout": TIMEOUT, - "allow_redirects": False, - } - if self.method is HttpMethod.POST: - post_data = _parse_post_data(self.post_data) - args[post_data["key"]] = post_data["data"] - elif self.method is HttpMethod.PUT: - post_data = _parse_post_data(self.post_data) - args["params"] = zip_kv_pairs(self.query) - args[post_data["key"]] = post_data["data"] - elif self.method not in self.NOOP_HTTP_METHODS: - raise ValueError(f"unsupported HTTP method: {self.method!r}") - - method = self.method.name.lower() - named_args = ", ".join(f"{k}={v}" for k, v in args.items()) - return f"response = self.client.{method}({named_args})" - +@dataclass class Task2: - def __init__( - self, - name: str, - request: Request, - statements: Sequence[py.Statement] = (), - # TODO: Replace me with a plugin framework that accesses the full tree. - # See https://github.com/zalando-incubator/Transformer/issues/11. - global_code_blocks: Mapping[str, Sequence[str]] = IMMUTABLE_EMPTY_DICT, - ) -> None: - self.name = name - self.request = request - self.statements = list(statements) - self.global_code_blocks = {k: list(v) for k, v in global_code_blocks.items()} + name: str + request: Request + statements: Sequence[py.Statement] = () + # TODO: Replace me with a plugin framework that accesses the full tree. + # See https://github.com/zalando-incubator/Transformer/issues/11. + global_code_blocks: Mapping[str, Sequence[str]] = IMMUTABLE_EMPTY_DICT + + def __post_init__(self,) -> None: + self.statements = list(self.statements) + self.global_code_blocks = { + k: list(v) for k, v in self.global_code_blocks.items() + } @classmethod def from_requests(cls, requests: Iterable[Request]) -> Iterator["Task2"]: @@ -90,7 +82,8 @@ def from_requests(cls, requests: Iterable[Request]) -> Iterator["Task2"]: corresponding request. """ # TODO: Update me when merging Task with Task2: "statements" needs to - # contain the equivalent of LocustRequest. + # contain a ExpressionView to Task2.request. + # See what is done in from_task (but without the LocustRequest part). # See https://github.com/zalando-incubator/Transformer/issues/11. for req in sorted(requests, key=lambda r: r.timestamp): if not on_blacklist(req.url.netloc): @@ -101,21 +94,100 @@ def from_task(cls, task: "Task") -> "Task2": # TODO: Remove me as soon as the old Task is no longer used and Task2 is # renamed to Task. # See https://github.com/zalando-incubator/Transformer/issues/11. - locust_request = task.locust_request - if locust_request is None: - locust_request = LocustRequest.from_request(task.request) - return cls( - name=task.name, - request=task.request, - statements=[ - py.OpaqueBlock(block) - for block in [ - *task.locust_preprocessing, - locust_request.as_locust_action(), - *task.locust_postprocessing, - ] - ], + t = cls(name=task.name, request=task.request) + if task.locust_request: + expr_view = py.ExpressionView( + name="this task's request field", + target=lambda: task.locust_request, + converter=lreq_to_expr, + ) + else: + expr_view = py.ExpressionView( + name="this task's request field", + target=lambda: t.request, + converter=req_to_expr, + ) + t.statements = [ + *[py.OpaqueBlock(x) for x in task.locust_preprocessing], + py.Assignment("response", expr_view), + *[py.OpaqueBlock(x) for x in task.locust_postprocessing], + ] + return t + + +NOOP_HTTP_METHODS = {HttpMethod.GET, HttpMethod.OPTIONS, HttpMethod.DELETE} + + +def req_to_expr(r: Request) -> py.FunctionCall: + url = py.Literal(str(r.url.geturl())) + headers = zip_kv_pairs(r.headers) + args: Dict[str, py.Expression] = OrderedDict( + url=url, + name=py.Literal(r.name) if r.name else url, + timeout=py.Literal(TIMEOUT), + allow_redirects=py.Literal(False), + ) + if headers: + args["headers"] = py.Literal(headers) + + if r.method is HttpMethod.POST: + rpd = RequestsPostData.from_har_post_data(r.post_data) + args.update(rpd.as_kwargs()) + elif r.method is HttpMethod.PUT: + rpd = RequestsPostData.from_har_post_data(r.post_data) + args.update(rpd.as_kwargs()) + + args.setdefault("params", py.Literal({})) + cast(py.Literal, args["params"]).value.extend( + _params_from_name_value_dicts([dataclasses.asdict(q) for q in r.query]) ) + elif r.method not in NOOP_HTTP_METHODS: + raise ValueError(f"unsupported HTTP method: {r.method!r}") + + method = r.method.name.lower() + return py.FunctionCall(name=f"self.client.{method}", named_args=args) + + +def lreq_to_expr(lr: LocustRequest) -> py.FunctionCall: + # TODO: Remove me once LocustRequest no longer exists. + # See https://github.com/zalando-incubator/Transformer/issues/11. + url = _peel_off_repr(lr.url) + name = _peel_off_repr(lr.name) if lr.name else url + + args: Dict[str, py.Expression] = OrderedDict( + url=url, + name=name, + timeout=py.Literal(TIMEOUT), + allow_redirects=py.Literal(False), + ) + if lr.headers: + args["headers"] = py.Literal(lr.headers) + + if lr.method is HttpMethod.POST: + rpd = RequestsPostData.from_har_post_data(lr.post_data) + args.update(rpd.as_kwargs()) + elif lr.method is HttpMethod.PUT: + rpd = RequestsPostData.from_har_post_data(lr.post_data) + args.update(rpd.as_kwargs()) + + args.setdefault("params", py.Literal({})) + cast(py.Literal, args["params"]).value.extend( + _params_from_name_value_dicts([dataclasses.asdict(q) for q in lr.query]) + ) + elif lr.method not in NOOP_HTTP_METHODS: + raise ValueError(f"unsupported HTTP method: {lr.method!r}") + + method = lr.method.name.lower() + return py.FunctionCall(name=f"self.client.{method}", named_args=args) + + +def _peel_off_repr(s: str) -> py.Literal: + """ + Reverse the effect of LocustRequest's repr() calls on url and name. + """ + if s.startswith("f"): + return py.FString(eval(s[1:], {}, {})) + return py.Literal(eval(s, {}, {})) class Task(NamedTuple): @@ -143,27 +215,6 @@ def from_requests(cls, requests: Iterable[Request]) -> Iterator["Task"]: else: yield cls(name=req.task_name(), request=req) - def as_locust_action(self, indentation=ACTION_INDENTATION_LEVEL) -> str: - """ - Converts a Task into a Locust Action. - """ - action: List[str] = [] - - for preprocessing in self.locust_preprocessing: - action.append(_indent(preprocessing, indentation)) - - if self.locust_request is None: - locust_request = LocustRequest.from_request(self.request) - else: - locust_request = self.locust_request - - action.append(locust_request.as_locust_action()) - - for postprocessing in self.locust_postprocessing: - action.append(_indent(postprocessing, indentation)) - - return "\n".join(action) - def inject_headers(self, headers: dict): if self.locust_request is None: original_locust_request = LocustRequest.from_request(self.request) @@ -187,53 +238,111 @@ def replace_url(self, url: str): return self._replace(locust_request=new_locust_request) -def _indent(input_string: str, requested_indentation: int) -> str: - output_string = "" - indentation = requested_indentation - initial_leading_spaces = 0 - for i, line in enumerate(input_string.splitlines()): +@dataclass +class RequestsPostData: + """ + Data to be sent via HTTP POST, along with which API of the requests library + to use. + """ - leading_spaces = len(line) - len(line.lstrip()) - if leading_spaces > 0: + data: Optional[py.Literal] = None + params: Optional[py.Literal] = None + json: Optional[py.Literal] = None - # We need to check the indentation of the second line in order to - # account for the case where the existing indentation is greater than - # the requested; it is used for reapplying sub-level-indentation e.g. - # to if statements. - if i == 1: - initial_leading_spaces = leading_spaces - else: - indentation = requested_indentation + ( - leading_spaces - initial_leading_spaces - ) + def as_kwargs(self) -> Dict[str, py.Expression]: + return {k: v for k, v in dataclasses.asdict(self).items() if v is not None} + + @classmethod + def from_har_post_data(cls, post_data: dict) -> "RequestsPostData": + """ + Converts a HAR postData object into a RequestsPostData instance. + + :param post_data: a HAR "postData" object, + see http://www.softwareishard.com/blog/har-12-spec/#postData. + :raise ValueError: if *post_data* is invalid. + """ + try: + return _from_har_post_data(post_data) + except ValueError as err: + raise ValueError(f"invalid HAR postData object: {post_data!r}") from err + + +def _from_har_post_data(post_data: dict) -> RequestsPostData: + mime_k = "mimeType" + try: + mime: str = post_data[mime_k] + except KeyError: + raise ValueError(f"missing {mime_k!r} field") from None + + rpd = RequestsPostData() + + # The "text" and "params" fields are supposed to be mutually + # exclusive (according to the HAR spec) but nobody respects that. + # Often, both text and params are provided for x-www-form-urlencoded. + text_k, params_k = "text", "params" + if text_k not in post_data and params_k not in post_data: + raise ValueError(f"should contain {text_k!r} or {params_k!r}") + + _extract_text(mime, post_data, text_k, rpd) + + try: + params = _params_from_post_data(params_k, post_data) + if params is not None: + rpd.params = py.Literal(params) + except (KeyError, UnicodeEncodeError, TypeError) as err: + raise ValueError("unreadable params field") from err + + return rpd + + +def _extract_text( + mime: str, post_data: dict, text_k: str, rpd: RequestsPostData +) -> None: + text = post_data.get(text_k) + if mime == JSON_MIME_TYPE: + if text is None: + raise ValueError(f"missing {text_k!r} field for {JSON_MIME_TYPE} content") + try: + rpd.json = py.Literal(json.loads(text)) + except JSONDecodeError as err: + raise ValueError(f"unreadable JSON from field {text_k!r}") from err + elif text is not None: # Probably application/x-www-form-urlencoded. + try: + rpd.data = py.Literal(text.encode()) + except UnicodeEncodeError as err: + raise ValueError(f"cannot encode the {text_k!r} field in UTF-8") from err + + +def _params_from_post_data( + key: str, post_data: dict +) -> Optional[List[Tuple[bytes, bytes]]]: + """ + Extracts the *key* list from *post_data* and calls + _params_from_name_value_dicts with that list. - line = line.lstrip() + :raise TypeError: if the object at *key* is built using unexpected data types. + """ + params = post_data.get(key) + if params is None: + return None + if not isinstance(params, list): + raise TypeError(f"the {key!r} field should be a list") + return _params_from_name_value_dicts(params) - output_string += line.rjust(len(line) + indentation, " ") + "\n" - return output_string +def _params_from_name_value_dicts( + dicts: Iterable[Mapping[str, str]] +) -> List[Tuple[bytes, bytes]]: + """ + Converts a HAR "params" element [0] into a list of tuples that can be used + as value for requests' "params" keyword-argument. + [0]: http://www.softwareishard.com/blog/har-12-spec/#params + [1]: http://docs.python-requests.org/en/master/user/quickstart/ + #more-complicated-post-requests -def _parse_post_data(post_data: dict) -> dict: - data = post_data.get("text") - mime: str = post_data.get("mimeType") - if mime == "application/json": - key = "json" - # Workaround for bug in chrome-har: - # https://github.com/sitespeedio/chrome-har/issues/23 - # TODO: Remove once bug fixed. - if data is None: - params = post_data.get("params") - if params is None: - data = "" - else: - data = {} - for param in params: - data[param.get("name")] = param.get("value") - else: - data = json.loads(data) - else: - key = "data" - if data: - data = data.encode() - return {"key": key, "data": data} + :raise KeyError: if one of the elements doesn't contain a "name" or "value" field. + :raise UnicodeEncodeError: if an element's "name" or "value" string cannot + be encoded in UTF-8. + """ + return [(d["name"].encode(), d["value"].encode()) for d in dicts] diff --git a/transformer/test_locust.py b/transformer/test_locust.py index 809fefd..2cdda95 100644 --- a/transformer/test_locust.py +++ b/transformer/test_locust.py @@ -45,7 +45,7 @@ class ScenarioGroup(TaskSet): class SomeScenario(TaskSequence): @seq_task(1) def some_task(self): - response = self.client.get(url='some_url', name='some_url', headers={}, timeout=$TIMEOUT, allow_redirects=False) + response = self.client.get(url='some_url', name='some_url', timeout=$TIMEOUT, allow_redirects=False) class LocustForScenarioGroup(HttpLocust): task_set = ScenarioGroup weight = 2 @@ -58,7 +58,7 @@ class LocustForScenarioGroup(HttpLocust): def test_it_renders_a_locustfile_template_with_plugin_change_task_name(self): @plugin(Contract.OnTask) def plugin_change_task_name(t: Task2) -> Task2: - t.request.name = 'changed_name' + t.request.name = "changed_name" return t a_name = "some_task" @@ -91,7 +91,7 @@ class ScenarioGroup(TaskSet): class SomeScenario(TaskSequence): @seq_task(1) def some_task(self): - response = self.client.get(url='some_url', name='changed_name', headers={}, timeout=$TIMEOUT, allow_redirects=False) + response = self.client.get(url='some_url', name='changed_name', timeout=$TIMEOUT, allow_redirects=False) class LocustForScenarioGroup(HttpLocust): task_set = ScenarioGroup weight = 2 diff --git a/transformer/test_python.py b/transformer/test_python.py index 8719b8d..1063619 100644 --- a/transformer/test_python.py +++ b/transformer/test_python.py @@ -926,3 +926,13 @@ def test_repr(self): repr(stmt) == f"Import(targets=['a', 'b'], source=None, alias=None, comments=['hi'])" ) + + +class TestExpressionView: + def test_wraps_int_into_literal(self): + def f(x: int) -> py.Literal: + return py.Literal(x * 2) + + ev = py.ExpressionView(name="hello", target=lambda: 7, converter=f) + assert ev.converter(ev.target()) == py.Literal(14) + assert str(ev) == "14" diff --git a/transformer/test_task.py b/transformer/test_task.py index b9d6205..a8480c7 100644 --- a/transformer/test_task.py +++ b/transformer/test_task.py @@ -1,257 +1,473 @@ # pylint: skip-file - +import enum import io -import json -from unittest.mock import MagicMock +from unittest.mock import MagicMock, Mock from unittest.mock import patch from urllib.parse import urlparse import pytest +from hypothesis import given +from hypothesis.strategies import composite, sampled_from, booleans -from transformer.request import Header +from transformer import python as py +from transformer.request import Header, QueryPair from transformer.task import ( Task, Request, HttpMethod, - QueryPair, TIMEOUT, LocustRequest, + Task2, + RequestsPostData, + JSON_MIME_TYPE, + req_to_expr, + lreq_to_expr, ) -class TestFromRequests: - def test_it_returns_a_task(self): - request = MagicMock() - request.timestamp = 1 - second_request = MagicMock() - second_request.timestamp = 2 - assert all( - isinstance(t, Task) for t in Task.from_requests([request, second_request]) - ) - - @patch("builtins.open") - def test_it_doesnt_create_a_task_if_the_url_is_on_the_blacklist(self, mock_open): - mock_open.return_value = io.StringIO("amazon") - request = MagicMock() - request.url = MagicMock() - request.url.netloc = "www.amazon.com" - task = Task.from_requests([request]) - assert len(list(task)) == 0 - - @patch("builtins.open") - def test_it_creates_a_task_if_the_path_not_host_is_on_the_blacklist( +class TestTask: + class TestFromRequests: + def test_it_returns_a_task(self): + request = MagicMock() + request.timestamp = 1 + second_request = MagicMock() + second_request.timestamp = 2 + assert all( + isinstance(t, Task) + for t in Task.from_requests([request, second_request]) + ) + + @patch("builtins.open") + def test_it_doesnt_create_a_task_if_the_url_is_on_the_blacklist( self, mock_open - ): - mock_open.return_value = io.StringIO("search\namazon") - request = MagicMock() - request.url = urlparse("https://www.google.com/search?&q=amazon") - task = Task.from_requests([request]) - assert len(list(task)) == 1 - - -class TestAsLocustAction: - def test_it_returns_an_error_given_an_unsupported_http_method(self): - a_request_with_an_unsupported_http_method = MagicMock() - task = Task("some_task", a_request_with_an_unsupported_http_method) - with pytest.raises(ValueError): - task.as_locust_action() - - def test_it_returns_a_string(self): - a_request = MagicMock(spec_set=Request) - a_request.method = HttpMethod.GET - task = Task("some_task", a_request) - assert isinstance(task.as_locust_action(), str) - - def test_it_returns_action_from_locust_request(self): - a_request = MagicMock(spec_set=Request) - a_request.method = HttpMethod.GET - locust_request = LocustRequest( - method=HttpMethod.GET, url=repr("http://locust-task"), name="task name", headers={} - ) - task = Task("some_task", request=a_request, locust_request=locust_request) - action = task.as_locust_action() - assert action.startswith("response = self.client.get(url='http://locust-task'") - - def test_it_returns_task_using_get_given_a_get_http_method(self): - a_request = MagicMock(spec_set=Request) - a_request.method = HttpMethod.GET - task = Task("some_task", a_request) - action = task.as_locust_action() - assert action.startswith("response = self.client.get(") - - def test_it_returns_a_task_using_post_given_a_post_http_method(self): - a_request = MagicMock(spec_set=Request) - a_request.method = HttpMethod.POST - a_request.post_data = {} - task = Task("some_task", a_request) - action = task.as_locust_action() - assert action.startswith("response = self.client.post(") - - def test_it_returns_a_task_using_put_given_a_put_http_method(self): - a_request = MagicMock(spec_set=Request) - a_request.method = HttpMethod.PUT - a_request.post_data = {"text": "{'some key': 'some value'}"} - a_request.query = [QueryPair(name="some name", value="some value")] - task = Task("some_task", a_request) - action = task.as_locust_action() - assert action.startswith("response = self.client.put(") - assert "params={'some name': 'some value'}" in action - assert "data=b\"{'some key': 'some value'}\"" in action - - def test_it_returns_a_task_using_options_given_an_options_http_method(self): - a_request = MagicMock(spec_set=Request) - a_request.method = HttpMethod.OPTIONS - a_request.headers = [Header(name="Access-Control-Request-Method", value="POST")] - task = Task("some_task", a_request) - action = task.as_locust_action() - assert action.startswith("response = self.client.options(") - assert "headers={'Access-Control-Request-Method': 'POST'" in action - - def test_it_returns_a_task_using_delete_given_a_delete_http_method(self): - a_request = MagicMock(spec_set=Request) - a_request.method = HttpMethod.DELETE - a_request.url = urlparse("http://www.some.web.site/?some_name=some_value") - task = Task("some_task", a_request) - action = task.as_locust_action() - assert action.startswith("response = self.client.delete(") - assert "?some_name=some_value" in action - - def test_it_provides_timeout_to_requests(self): - a_request = MagicMock(spec_set=Request) - a_request.method = HttpMethod.GET - task = Task("some_task", a_request) - action = task.as_locust_action() - assert f"timeout={TIMEOUT}" in action - - def test_it_injects_headers(self): - a_request = MagicMock(spec_set=Request) - a_request.method = HttpMethod.GET - a_request.headers = [Header(name="some_header", value="some_value")] - task = Task("some_task", a_request) - action = task.as_locust_action() - assert "some_value" in action - - def test_it_encodes_data_in_task_for_text_mime(self): - decoded_value = '{"formatted": "54,95 €"}' - a_request = MagicMock(spec_set=Request) - a_request.method = HttpMethod.POST - a_request.post_data = {"text": decoded_value} - task = Task("some_task", a_request) - action = task.as_locust_action() - assert str(decoded_value.encode()) in action - - def test_it_encodes_data_in_task_for_json_mime(self): - decoded_value = '{"formatted": "54,95 €"}' - a_request = MagicMock(spec_set=Request) - a_request.method = HttpMethod.POST - a_request.post_data = {"text": decoded_value, "mimeType": "application/json"} - task = Task("some_task", a_request) - action = task.as_locust_action() - assert str(json.loads(decoded_value)) in action - - def test_it_converts_post_params_to_post_text(self): - a_request = MagicMock(spec_set=Request) - a_request.method = HttpMethod.POST - a_request.post_data = { - "mimeType": "application/json", - "params": [ - {"name": "username", "value": "some user"}, - {"name": "password", "value": "some password"}, - ], + ): + mock_open.return_value = io.StringIO("amazon") + request = MagicMock() + request.url = MagicMock() + request.url.netloc = "www.amazon.com" + task = Task.from_requests([request]) + assert len(list(task)) == 0 + + @patch("builtins.open") + def test_it_creates_a_task_if_the_path_not_host_is_on_the_blacklist( + self, mock_open + ): + mock_open.return_value = io.StringIO("search\namazon") + request = MagicMock() + request.url = urlparse("https://www.google.com/search?&q=amazon") + task = Task.from_requests([request]) + assert len(list(task)) == 1 + + class TestReplaceURL: + def test_it_creates_a_locust_request_when_there_is_none(self): + task = Task(name="some name", request=MagicMock()) + + modified_task = Task.replace_url(task, "") + + assert modified_task.locust_request + + def test_it_returns_a_task_with_the_replaced_url(self): + locust_request = LocustRequest( + method=MagicMock(), url=MagicMock(), headers=MagicMock() + ) + task = Task( + name="some name", request=MagicMock(), locust_request=locust_request + ) + expected_url = 'f"http://a.b.c/{some.value}/"' + + modified_task = Task.replace_url(task, expected_url) + + assert modified_task.locust_request.url == expected_url + + +class TestTask2: + class TestFromTask: + def test_without_locust_request_it_proxies_the_request(self): + req = Mock(spec_set=Request) + task = Task(name="T", request=req) + task2 = Task2.from_task(task) + + assert task2.name == "T" + assert task2.request == req + assert len(task2.statements) == 1 + + assign = task2.statements[0] + assert isinstance(assign, py.Assignment) + assert assign.lhs == "response" + + assert isinstance(assign.rhs, py.ExpressionView) + assert assign.rhs.target() == task2.request + assert assign.rhs.converter is req_to_expr + + def test_with_locust_request_it_proxies_it(self): + lr = Mock(spec_set=LocustRequest) + req = Mock(spec_set=Request) + task = Task(name="T", request=req, locust_request=lr) + task2 = Task2.from_task(task) + + assert task2.name == "T" + assert task2.request == req + assert len(task2.statements) == 1 + + assign = task2.statements[0] + assert isinstance(assign, py.Assignment) + assert assign.lhs == "response" + + assert isinstance(assign.rhs, py.ExpressionView) + assert assign.rhs.target() == lr + assert assign.rhs.converter is lreq_to_expr + + +class _KindOfDict(enum.Flag): + Text = enum.auto() + Params = enum.auto() + Both = Text | Params + + +_formats = sampled_from(("json", "www")) +_kinds_of_dicts = sampled_from(_KindOfDict) + + +# From http://www.softwareishard.com/blog/har-12-spec/#postData. +@composite +def har_post_dicts(draw, format=None): + format = format or draw(_formats) + if format == "json": + d = {"mimeType": "application/json", "text": """{"a":"b", "c": "d"}"""} + if draw(booleans()): + d["params"] = [] + if draw(booleans()): + d["comment"] = "" + return d + + d = {"mimeType": "application/x-www-form-urlencoded"} + kind = draw(_kinds_of_dicts) + if kind & _KindOfDict.Text: + d["text"] = "a=b&c=d" + if draw(booleans()): + d.setdefault("params", []) + if kind & _KindOfDict.Params: + d["params"] = [{"name": "a", "value": "b"}, {"name": "c", "value": "d"}] + if draw(booleans()): + d.setdefault("text", "") + return d + + +class TestRequestPostData: + def test_as_kwargs_only_shows_defined(self): + v, w = MagicMock(), MagicMock() + assert RequestsPostData(data=v).as_kwargs() == {"data": v} + assert RequestsPostData(params=v, json=w).as_kwargs() == { + "params": v, + "json": w, } - task = Task("some task", a_request) - action = task.as_locust_action() - assert "'username': 'some user'" in action - assert "'password': 'some password'" in action - - def test_it_creates_a_locust_request_when_there_is_none(self): - task = Task(name="some name", request=MagicMock()) - modified_task = Task.inject_headers(task, {}) + class TestFromHarPostData: + @given(har_post_dicts(format="json")) + def test_it_selects_json_approach_for_json_format(self, d: dict): + rpd = RequestsPostData.from_har_post_data(d) + assert rpd.json == py.Literal({"a": "b", "c": "d"}) + assert rpd.data is None + + @given(har_post_dicts(format="www")) + def test_it_selects_data_approach_for_urlencoded_format(self, d: dict): + rpd = RequestsPostData.from_har_post_data(d) + assert rpd.json is None + assert rpd.data == py.Literal(b"a=b&c=d") or rpd.params == py.Literal( + [(b"a", b"b"), (b"c", b"d")] + ) + + @given(har_post_dicts()) + def test_it_doesnt_raise_error_on_valid_input(self, d: dict): + RequestsPostData.from_har_post_data(d) + + def test_it_raises_on_post_data_without_text_or_params(self): + with pytest.raises(ValueError): + RequestsPostData.from_har_post_data({"mimeType": "nil"}) + + def test_it_raises_on_invalid_json(self): + with pytest.raises(ValueError): + RequestsPostData.from_har_post_data( + {"mimeType": JSON_MIME_TYPE, "text": "not json"} + ) + + @pytest.mark.parametrize( + "mime,kwarg,val", + ( + (JSON_MIME_TYPE, "json", {}), + ("application/x-www-form-urlencoded", "data", b"{}"), + ), + ) + def test_it_accepts_both_params_and_text(self, mime: str, kwarg, val): + expected_fields = { + "params": py.Literal([(b"n", b"v")]), + kwarg: py.Literal(val), + } + assert RequestsPostData.from_har_post_data( + { + "mimeType": mime, + "text": "{}", + "params": [{"name": "n", "value": "v"}], + } + ) == RequestsPostData(**expected_fields) + + +class TestReqToExpr: + def test_it_supports_get_requests(self): + url = "http://abc.de" + r = Request( + timestamp=MagicMock(), + method=HttpMethod.GET, + url=urlparse(url), + headers=[Header("a", "b")], + query=[QueryPair("x", "y")], # query is currently ignored for GET + ) + assert req_to_expr(r) == py.FunctionCall( + name="self.client.get", + named_args={ + "url": py.Literal(url), + "name": py.Literal(url), + "headers": py.Literal({"a": "b"}), + "timeout": py.Literal(TIMEOUT), + "allow_redirects": py.Literal(False), + }, + ) - assert modified_task.locust_request + def test_it_supports_urlencoded_post_requests(self): + url = "http://abc.de" + r = Request( + timestamp=MagicMock(), + method=HttpMethod.POST, + url=urlparse(url), + headers=[Header("a", "b")], + post_data={ + "mimeType": "application/x-www-form-urlencoded", + "params": [{"name": "x", "value": "y"}], + "text": "z=7", + }, + ) + assert req_to_expr(r) == py.FunctionCall( + name="self.client.post", + named_args={ + "url": py.Literal(url), + "name": py.Literal(url), + "headers": py.Literal({"a": "b"}), + "timeout": py.Literal(TIMEOUT), + "allow_redirects": py.Literal(False), + "data": py.Literal(b"z=7"), + "params": py.Literal([(b"x", b"y")]), + }, + ) - def test_it_returns_a_task_with_the_injected_headers(self): - locust_request = LocustRequest( - method=MagicMock(), url=MagicMock(), name=MagicMock(), headers={"x-forwarded-for": ""} + def test_it_supports_json_post_requests(self): + url = "http://abc.de" + r = Request( + timestamp=MagicMock(), + method=HttpMethod.POST, + url=urlparse(url), + headers=[Header("a", "b")], + post_data={ + "mimeType": "application/json", + "params": [{"name": "x", "value": "y"}], + "text": """{"z": 7}""", + }, ) - task = Task( - name="some name", request=MagicMock(), locust_request=locust_request + assert req_to_expr(r) == py.FunctionCall( + name="self.client.post", + named_args={ + "url": py.Literal(url), + "name": py.Literal(url), + "headers": py.Literal({"a": "b"}), + "timeout": py.Literal(TIMEOUT), + "allow_redirects": py.Literal(False), + "json": py.Literal({"z": 7}), + "params": py.Literal([(b"x", b"y")]), + }, ) - expected_headers = {"x-forwarded-for": "1.2.3.4"} - modified_task = Task.inject_headers(task, headers=expected_headers) - - assert isinstance(modified_task, Task) - - headers = modified_task.locust_request.headers - assert len(headers) == 1 - assert headers == expected_headers - - -class TestIndentation: - def test_pre_processing_returns_an_indented_string_given_an_indentation(self): - a_request = MagicMock(spec_set=Request) - a_request.method = HttpMethod.GET - task = Task("some_task", a_request) - new_pre_processings = (*task.locust_preprocessing, "def some_function():") - task = task._replace(locust_preprocessing=new_pre_processings) - action = task.as_locust_action(indentation=2) - assert action.startswith(" def some_function():") - - def test_post_processing_returns_an_indented_string_given_an_indentation(self): - a_request = MagicMock(spec_set=Request) - a_request.method = HttpMethod.GET - task = Task("some_task", a_request) - new_post_processings = (*task.locust_postprocessing, "def some_function():") - task = task._replace(locust_postprocessing=new_post_processings) - action = task.as_locust_action(indentation=2) - assert " def some_function():" in action - - def test_it_applies_indentation_to_all_pre_processings(self): - a_request = MagicMock(spec_set=Request) - a_request.method = HttpMethod.GET - task = Task("some_task", a_request) - new_pre_processings = ( - *task.locust_preprocessing, - "def some_function():", - "def some_other_function():", + + def test_it_supports_put_requests(self): + url = "http://abc.de" + r = Request( + timestamp=MagicMock(), + method=HttpMethod.PUT, + url=urlparse(url), + headers=[Header("a", "b")], + query=[QueryPair("c", "d")], + post_data={ + "mimeType": "application/json", + "params": [{"name": "x", "value": "y"}], + "text": """{"z": 7}""", + }, ) - task = task._replace(locust_preprocessing=new_pre_processings) - action = task.as_locust_action(indentation=2) - assert action.startswith( - " def some_function():\n\n def some_other_function():" + assert req_to_expr(r) == py.FunctionCall( + name="self.client.put", + named_args={ + "url": py.Literal(url), + "name": py.Literal(url), + "headers": py.Literal({"a": "b"}), + "timeout": py.Literal(TIMEOUT), + "allow_redirects": py.Literal(False), + "json": py.Literal({"z": 7}), + "params": py.Literal([(b"x", b"y"), (b"c", b"d")]), + }, ) - def test_it_respects_sub_indentation_levels(self): - a_request = MagicMock(spec_set=Request) - a_request.method = HttpMethod.GET - task = Task("some_task", a_request) - new_pre_processings = ( - *task.locust_preprocessing, - "\n def function():\n if True:\n print(True)", + def test_it_uses_the_custom_name_if_provided(self): + url = "http://abc.de" + name = "my-req" + r = Request( + name=name, timestamp=MagicMock(), method=HttpMethod.GET, url=urlparse(url) + ) + assert req_to_expr(r) == py.FunctionCall( + name="self.client.get", + named_args={ + "url": py.Literal(url), + "name": py.Literal(name), + "timeout": py.Literal(TIMEOUT), + "allow_redirects": py.Literal(False), + }, ) - task = task._replace(locust_preprocessing=new_pre_processings) - action = task.as_locust_action(indentation=1) - assert action.startswith(" \n def function():\n if True:\n print(True)") -class TestReplaceURL: - def test_it_creates_a_locust_request_when_there_is_none(self): - task = Task(name="some name", request=MagicMock()) +class TestLreqToExpr: + def test_it_supports_get_requests(self): + url = "http://abc.de" + r = LocustRequest.from_request( + Request( + timestamp=MagicMock(), + method=HttpMethod.GET, + url=urlparse(url), + headers=[Header("a", "b")], + query=[QueryPair("x", "y")], # query is currently ignored for GET + ) + ) + assert lreq_to_expr(r) == py.FunctionCall( + name="self.client.get", + named_args={ + "url": py.Literal(url), + "name": py.Literal(url), + "headers": py.Literal({"a": "b"}), + "timeout": py.Literal(TIMEOUT), + "allow_redirects": py.Literal(False), + }, + ) - modified_task = Task.replace_url(task, "") + def test_it_supports_fstring_urls(self): + url = "http://abc.{tld}" + r = LocustRequest(method=HttpMethod.GET, url=f"f'{url}'", headers={"a": "b"}) + assert lreq_to_expr(r) == py.FunctionCall( + name="self.client.get", + named_args={ + "url": py.FString(url), + "name": py.FString(url), + "headers": py.Literal({"a": "b"}), + "timeout": py.Literal(TIMEOUT), + "allow_redirects": py.Literal(False), + }, + ) - assert modified_task.locust_request + def test_it_supports_urlencoded_post_requests(self): + url = "http://abc.de" + r = LocustRequest.from_request( + Request( + timestamp=MagicMock(), + method=HttpMethod.POST, + url=urlparse(url), + headers=[Header("a", "b")], + post_data={ + "mimeType": "application/x-www-form-urlencoded", + "params": [{"name": "x", "value": "y"}], + "text": "z=7", + }, + ) + ) + assert lreq_to_expr(r) == py.FunctionCall( + name="self.client.post", + named_args={ + "url": py.Literal(url), + "name": py.Literal(url), + "headers": py.Literal({"a": "b"}), + "timeout": py.Literal(TIMEOUT), + "allow_redirects": py.Literal(False), + "data": py.Literal(b"z=7"), + "params": py.Literal([(b"x", b"y")]), + }, + ) - def test_it_returns_a_task_with_the_replaced_url(self): - locust_request = LocustRequest( - method=MagicMock(), url=MagicMock(), name=MagicMock(), headers=MagicMock() + def test_it_supports_json_post_requests(self): + url = "http://abc.de" + r = LocustRequest.from_request( + Request( + timestamp=MagicMock(), + method=HttpMethod.POST, + url=urlparse(url), + headers=[Header("a", "b")], + post_data={ + "mimeType": "application/json", + "params": [{"name": "x", "value": "y"}], + "text": """{"z": 7}""", + }, + ) ) - task = Task( - name="some name", request=MagicMock(), locust_request=locust_request + assert lreq_to_expr(r) == py.FunctionCall( + name="self.client.post", + named_args={ + "url": py.Literal(url), + "name": py.Literal(url), + "headers": py.Literal({"a": "b"}), + "timeout": py.Literal(TIMEOUT), + "allow_redirects": py.Literal(False), + "json": py.Literal({"z": 7}), + "params": py.Literal([(b"x", b"y")]), + }, ) - expected_url = 'f"http://a.b.c/{some.value}/"' - modified_task = Task.replace_url(task, expected_url) + def test_it_supports_put_requests(self): + url = "http://abc.de" + r = LocustRequest.from_request( + Request( + timestamp=MagicMock(), + method=HttpMethod.PUT, + url=urlparse(url), + headers=[Header("a", "b")], + query=[QueryPair("c", "d")], + post_data={ + "mimeType": "application/json", + "params": [{"name": "x", "value": "y"}], + "text": """{"z": 7}""", + }, + ) + ) + assert lreq_to_expr(r) == py.FunctionCall( + name="self.client.put", + named_args={ + "url": py.Literal(url), + "name": py.Literal(url), + "headers": py.Literal({"a": "b"}), + "timeout": py.Literal(TIMEOUT), + "allow_redirects": py.Literal(False), + "json": py.Literal({"z": 7}), + "params": py.Literal([(b"x", b"y"), (b"c", b"d")]), + }, + ) - assert modified_task.locust_request.url == expected_url + def test_it_uses_the_custom_name_if_provided(self): + url = "http://abc.de" + name = "my-req" + r = LocustRequest.from_request( + Request( + name=name, + timestamp=MagicMock(), + method=HttpMethod.GET, + url=urlparse(url), + ) + ) + assert lreq_to_expr(r) == py.FunctionCall( + name="self.client.get", + named_args={ + "url": py.Literal(url), + "name": py.Literal(name), + "timeout": py.Literal(TIMEOUT), + "allow_redirects": py.Literal(False), + }, + )