diff --git a/.changes/unreleased/Added-20231028-222932.yaml b/.changes/unreleased/Added-20231028-222932.yaml new file mode 100644 index 00000000..a6cf7dd6 --- /dev/null +++ b/.changes/unreleased/Added-20231028-222932.yaml @@ -0,0 +1,5 @@ +kind: Added +body: Support `DestSurveyID` parameter to RPC method `copy_survey` +time: 2023-10-28T22:29:32.491728-06:00 +custom: + Issue: "1016" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index cc32d8f3..65e7b28e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -26,20 +26,15 @@ repos: - id: check-readthedocs - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.1.1 + rev: v0.1.3 hooks: - id: ruff + name: Ruff lint args: [--fix, --exit-non-zero-on-fix, --show-fixes] - id: ruff name: Ruff format entry: ruff format -- repo: https://github.com/psf/black - rev: 23.10.0 - hooks: - - id: black - language_version: python3.11 - - repo: https://github.com/codespell-project/codespell rev: v2.2.6 hooks: @@ -77,6 +72,6 @@ repos: args: [--all] - repo: https://github.com/tox-dev/pyproject-fmt - rev: "1.2.0" + rev: "1.3.0" hooks: - id: pyproject-fmt diff --git a/README.md b/README.md index fe550e89..32daa290 100644 --- a/README.md +++ b/README.md @@ -61,12 +61,12 @@ Python. | | **PostgreSQL** | **MySQL** | | - |:--: | :-: | +| 6.3.0 | ✅ | ✅ | +| 6.2.11 | ✅ | ✅ | | 6.2.9 | ✅ | ✅ | -| 6.2.8 | ✅ | ✅ | -| 6.2.7 | ✅ | ✅ | +| 5.6.41 | ✅ | ✅ | +| 5.6.40 | ✅ | ✅ | | 5.6.39 | ✅ | ✅ | -| 5.6.38 | ✅ | ✅ | -| 5.6.37 | ✅ | ✅ | ## Installation diff --git a/docs/_ext/limesurvey_future.py b/docs/_ext/limesurvey_future.py index a1966660..89911b8e 100644 --- a/docs/_ext/limesurvey_future.py +++ b/docs/_ext/limesurvey_future.py @@ -21,10 +21,7 @@ class UnreleasedFeature(Directive): """ required_arguments = 1 - message = ( - "This method is only supported in LimeSurvey >= {next_version} " - "(currently in development)." - ) + message = "This method is only supported in LimeSurvey >= {next_version}." admonition_type = nodes.warning def run(self) -> list[nodes.Node]: @@ -33,6 +30,25 @@ def run(self) -> list[nodes.Node]: return [self.admonition_type("", nodes.paragraph(text=text))] +class UnreleasedParameter(Directive): + """A directive for development-only parameters. + + Adds a warning to method parameters that are only available in the next minor + release of LimeSurvey. + """ + + required_arguments = 2 + message = ( + "The parameter {parameter} is only supported in LimeSurvey >= {next_version}." + ) + admonition_type = nodes.warning + + def run(self) -> list[nodes.Node]: + next_version, parameter = self.arguments[:2] + text = self.message.format(next_version=next_version, parameter=parameter) + return [self.admonition_type("", nodes.paragraph(text=text))] + + class ReleasedFeature(UnreleasedFeature): """A directive for released features. @@ -43,9 +59,24 @@ class ReleasedFeature(UnreleasedFeature): admonition_type = nodes.note +class ReleasedParameter(UnreleasedParameter): + """A directive for released parameters. + + Adds a note to method parameters that are only available after some release of + LimeSurvey. + """ + + message = ( + "The parameter {parameter} is only supported in LimeSurvey >= {next_version}." + ) + admonition_type = nodes.note + + def setup(app: Sphinx) -> dict[str, t.Any]: app.add_directive("future", UnreleasedFeature) + app.add_directive("futureparam", UnreleasedParameter) app.add_directive("minlimesurvey", ReleasedFeature) + app.add_directive("minlimesurveyparam", ReleasedParameter) return { "version": "0.1", diff --git a/pyproject.toml b/pyproject.toml index 5aa10033..ce1b2dfe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -74,14 +74,20 @@ docs = [ line-length = 88 [tool.ruff] +include = ["*.py", "*.pyi", "**/pyproject.toml", "*.ipynb"] +line-length = 88 +src = ["src", "tests", "docs"] +target-version = "py38" + +[tool.ruff.lint] explicit-preview-rules = false ignore = [ "ANN101", # missing-type-self "DJ", # flake8-django "FIX002", # line-contains-todo + "COM812", # missing-trailing-comma + "ISC001", # single-line-implicit-string-concatenation ] -include = ["*.py", "*.pyi", "**/pyproject.toml", "*.ipynb"] -line-length = 88 preview = true select = [ "F", # Pyflakes @@ -138,8 +144,6 @@ select = [ "LOG", # flake8-logging "RUF", # Ruff-specific rules ] -src = ["src", "tests", "docs"] -target-version = "py38" unfixable = [ "ERA", # Don't remove commented out code ] @@ -170,44 +174,48 @@ unfixable = [ # Enable preview style formatting. preview = true -[tool.ruff.flake8-quotes] +[tool.ruff.lint.flake8-quotes] docstring-quotes = "double" inline-quotes = "double" multiline-quotes = "double" -[tool.ruff.flake8-annotations] +[tool.ruff.lint.flake8-annotations] allow-star-arg-any = true mypy-init-return = true suppress-dummy-args = true -[tool.ruff.flake8-errmsg] +[tool.ruff.lint.flake8-errmsg] max-string-length = 30 -[tool.ruff.flake8-import-conventions] +[tool.ruff.lint.flake8-import-conventions] banned-from = ["typing"] -[tool.ruff.flake8-import-conventions.extend-aliases] +[tool.ruff.lint.flake8-import-conventions.extend-aliases] typing = "t" -[tool.ruff.flake8-pytest-style] +[tool.ruff.lint.flake8-pytest-style] fixture-parentheses = false mark-parentheses = false -[tool.ruff.isort] +[tool.ruff.lint.isort] known-first-party = ["citric"] required-imports = ["from __future__ import annotations"] -[tool.ruff.mccabe] +[tool.ruff.lint.mccabe] max-complexity = 5 -[tool.ruff.pydocstyle] +[tool.ruff.lint.pydocstyle] convention = "google" -[tool.ruff.pylint] +[tool.ruff.lint.pylint] max-args = 10 [tool.pytest.ini_options] -addopts = ["-vvv", "-W error"] +addopts = [ + "-vvv", + "-W error", + "-W default::citric._compat.FutureVersionWarning", +] markers = [ "integration_test: Integration and end-to-end tests", "xfail_mysql: Mark a test as expected to fail on MySQL", diff --git a/scripts/docker_tags.py b/scripts/docker_tags.py index 055d141b..31bc0e9d 100644 --- a/scripts/docker_tags.py +++ b/scripts/docker_tags.py @@ -9,19 +9,30 @@ import requests import requests_cache +PATTERN_VERSION = re.compile(r"(\d+\.\d+\.\d+)-\d{6}-apache") PATTERN_5x = re.compile(r"5\.\d+.\d+-\d{6}-apache") PATTERN_6x = re.compile(r"6\.\d+.\d+-\d{6}-apache") requests_cache.install_cache("docker_tags") +def _extract_version(tag: dict) -> tuple[int, ...]: + """Extract version from tag.""" + name = tag["name"] + return ( + tuple(int(part) for part in match.group(1).split(".")) + if (match := PATTERN_VERSION.match(name)) + else (999,) + ) + + def get_tags() -> t.Generator[dict, None, None]: """Get all tags from the Docker Hub.""" url = ( "https://hub.docker.com/v2/namespaces/martialblog/repositories/limesurvey/tags" ) while True: - data = requests.get(url, timeout=5).json() + data = requests.get(url, timeout=30).json() yield from data["results"] url = data.get("next") @@ -31,11 +42,7 @@ def get_tags() -> t.Generator[dict, None, None]: def sort_tags(tags: t.Iterable[dict]) -> list[dict]: """Sort tags.""" - return sorted( - tags, - key=lambda tag: tag["name"], - reverse=True, - ) + return sorted(tags, key=_extract_version, reverse=True) def filter_tags(tags: t.Iterable[dict]) -> t.Generator[str, None, None]: diff --git a/src/citric/_compat.py b/src/citric/_compat.py index f423ff93..99212ab4 100644 --- a/src/citric/_compat.py +++ b/src/citric/_compat.py @@ -54,3 +54,33 @@ def wrapper(*args: t.Any, **kwargs: t.Any) -> t.Callable: return wrapper return decorate + + +def future_parameter(version: str, parameter: str) -> t.Callable: + """Mark a function as only available in the current development build of LimeSurvey. + + Args: + version: The earliest version of LimeSurvey that this parameter is + available in. + parameter: The parameter that is only available in the current development + build of LimeSurvey. + + Returns: + The wrapped function. + """ + message = _warning_message(version) + + def decorate(fn: t.Callable) -> t.Callable: + @wraps(fn) + def wrapper(*args: t.Any, **kwargs: t.Any) -> t.Callable: + if parameter in kwargs: + warnings.warn( + f"Parameter {parameter} {''.join(message)}", + FutureVersionWarning, + stacklevel=2, + ) + return fn(*args, **kwargs) + + return wrapper + + return decorate diff --git a/src/citric/client.py b/src/citric/client.py index 6d32cdf3..2fe107e1 100644 --- a/src/citric/client.py +++ b/src/citric/client.py @@ -13,6 +13,7 @@ import requests from citric import enums +from citric._compat import future_parameter from citric.exceptions import LimeSurveyStatusError from citric.session import Session @@ -488,7 +489,14 @@ def update_response(self, survey_id: int, response_data: dict[str, t.Any]) -> bo data = self._map_response_keys(response_data, questions) return self.session.update_response(survey_id, data) - def copy_survey(self, survey_id: int, name: str) -> dict[str, t.Any]: + @future_parameter("6.4.0", "destination_survey_id") + def copy_survey( + self, + survey_id: int, + name: str, + *, + destination_survey_id: int | None = None, + ) -> dict[str, t.Any]: """Copy a survey. Calls :rpc_method:`copy_survey`. @@ -496,13 +504,18 @@ def copy_survey(self, survey_id: int, name: str) -> dict[str, t.Any]: Args: survey_id: ID of the source survey. name: Name of the new survey. + destination_survey_id: ID of the new survey. If already used a, random one + will be generated. Returns: Dictionary of status message and the new survey ID. .. versionadded:: 0.0.10 + .. versionchanged:: NEXT_VERSION + The ``destination_survey_id`` optional parameter was added. + .. futureparam:: 6.4.0 destination_survey_id """ - return self.session.copy_survey(survey_id, name) + return self.session.copy_survey(survey_id, name, destination_survey_id) def import_cpdb_participants( self, diff --git a/tests/test_client.py b/tests/test_client.py index 1a9b956b..5e87f944 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -245,7 +245,13 @@ def test_add_survey(client: MockClient): def test_copy_survey(client: MockClient): """Test copy_survey client method.""" - assert_client_session_call(client, "copy_survey", 1, NEW_SURVEY_NAME) + assert_client_session_call( + client, + "copy_survey", + 1, + NEW_SURVEY_NAME, + destination_survey_id=None, + ) def test_delete_group(client: MockClient): diff --git a/tests/test_compat.py b/tests/test_compat.py index b83c052b..4750f137 100644 --- a/tests/test_compat.py +++ b/tests/test_compat.py @@ -4,7 +4,7 @@ import pytest -from citric._compat import FutureVersionWarning, future +from citric._compat import FutureVersionWarning, future, future_parameter @future("4.0.0") @@ -12,10 +12,27 @@ def my_function() -> None: """A simple function.""" +@future_parameter("4.0.0", "new_param") +def function_new_param(new_param: str | None = None) -> None: + """A simple function.""" + + def test_dev_only(): - """Test that dev_only raises a warning.""" + """Test that calling a dev-only functions raise a warning.""" with pytest.warns( FutureVersionWarning, match="Method my_function is only supported .* 4.0.0", ): my_function() + + +def test_dev_only_param(): + """Test that calling a dev-only function parameters raise a warning.""" + # Calling with the default value should not raise a warning + function_new_param() + + with pytest.warns( + FutureVersionWarning, + match="Parameter new_param is only supported .* 4.0.0", + ): + function_new_param(new_param="test") diff --git a/tests/test_integration.py b/tests/test_integration.py index 322598bc..23a4a883 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -88,9 +88,9 @@ def participants(faker: Faker) -> list[dict[str, t.Any]]: @pytest.fixture -def server_version(client: citric.Client) -> semver.Version: +def server_version(client: citric.Client) -> semver.VersionInfo: """Get the server version.""" - return semver.Version.parse(client.get_server_version()) + return semver.VersionInfo.parse(client.get_server_version()) @pytest.mark.integration_test @@ -186,6 +186,36 @@ def test_survey(client: citric.Client): @pytest.mark.integration_test +def test_copy_survey_destination_id( + request: pytest.FixtureRequest, + client: citric.Client, + survey_id: int, + server_version: semver.VersionInfo, +): + """Test copying a survey with a destination survey ID.""" + request.applymarker( + pytest.mark.xfail( + server_version < semver.VersionInfo.parse("6.4.0-dev"), + reason=( + "The destination_survey_id parameter is only supported in LimeSurvey " + f"{server_version} < 6.4.0" + ), + ), + ) + + # Copy a survey, specifying a new survey ID + copied = client.copy_survey( + survey_id, + NEW_SURVEY_NAME, + destination_survey_id=9797, + ) + + assert copied["status"] == "OK" + assert copied["newsid"] == 9797 + + +@pytest.mark.integration_test +@pytest.mark.xfail_mysql def test_group(client: citric.Client, survey_id: int): """Test group methods.""" # Import a group @@ -259,7 +289,7 @@ def test_question(client: citric.Client, survey_id: int): def test_quota( request: pytest.FixtureRequest, client: citric.Client, - server_version: semver.Version, + server_version: semver.VersionInfo, survey_id: int, ): """Test quota methods.""" @@ -584,7 +614,7 @@ def test_file_upload_invalid_extension( def test_get_available_site_settings( request: pytest.FixtureRequest, client: citric.Client, - server_version: semver.Version, + server_version: semver.VersionInfo, ): """Test getting available site settings.""" request.applymarker(