diff --git a/mergin/cli.py b/mergin/cli.py index ff1d027a..7d5b5f95 100755 --- a/mergin/cli.py +++ b/mergin/cli.py @@ -200,23 +200,12 @@ def create(ctx, project, public, from_dir): mc = ctx.obj["client"] if mc is None: return - if "/" in project: - try: - namespace, project = project.split("/") - assert namespace, "No namespace given" - assert project, "No project name given" - except (ValueError, AssertionError) as e: - click.secho(f"Incorrect namespace/project format: {e}", fg="red") - return - else: - # namespace not specified, use current user namespace - namespace = mc.username() try: if from_dir is None: - mc.create_project(project, is_public=public, namespace=namespace) + mc.create_project(project, is_public=public) click.echo("Created project " + project) else: - mc.create_project_and_push(project, from_dir, is_public=public, namespace=namespace) + mc.create_project_and_push(project, from_dir, is_public=public) click.echo("Created project " + project + " and pushed content from directory " + from_dir) except ClientError as e: click.secho("Error: " + str(e), fg="red") @@ -555,7 +544,21 @@ def clone(ctx, source_project_path, cloned_project_name, cloned_project_namespac if mc is None: return try: - mc.clone_project(source_project_path, cloned_project_name, cloned_project_namespace) + if cloned_project_namespace: + click.secho( + "The usage of `cloned_project_namespace` parameter in `mergin clone` is deprecated." + "Specify `cloned_project_name` as full name (/) instead.", + fg="yellow", + ) + if cloned_project_namespace is None and "/" not in cloned_project_name: + click.secho( + "The use of only project name as `cloned_project_name` in `clone_project()` is deprecated." + "The `cloned_project_name` should be full name (/).", + fg="yellow", + ) + if cloned_project_namespace and "/" not in cloned_project_name: + cloned_project_name = f"{cloned_project_namespace}/{cloned_project_name}" + mc.clone_project(source_project_path, cloned_project_name) click.echo("Done") except ClientError as e: click.secho("Error: " + str(e), fg="red") diff --git a/mergin/client.py b/mergin/client.py index a00f8f07..f5062aca 100644 --- a/mergin/client.py +++ b/mergin/client.py @@ -14,6 +14,7 @@ import ssl from enum import Enum, auto import re +import warnings from .common import ClientError, LoginError, InvalidProject from .merginproject import MerginProject @@ -434,23 +435,46 @@ def create_project(self, project_name, is_public=False, namespace=None): Create new project repository in user namespace on Mergin Maps server. Optionally initialized from given local directory. - :param project_name: Project name + :param project_name: Project's full name (/) :type project_name: String :param is_public: Flag for public/private project, defaults to False :type is_public: Boolean - :param namespace: Optional namespace for a new project. If empty username is used. + :param namespace: Deprecated. project_name should be full project name. Optional namespace for a new project. If empty username is used. :type namespace: String """ if not self._user_info: raise Exception("Authentication required") + if namespace and "/" not in project_name: + warnings.warn( + "The usage of `namespace` parameter in `create_project()` is deprecated." + "Specify `project_name` as full name (/) instead.", + category=DeprecationWarning, + ) + + if "/" in project_name: + if namespace: + warnings.warn( + "Parameter `namespace` specified with full project name (/)." + "The parameter will be ignored." + ) + + namespace, project_name = project_name.split("/") + + elif namespace is None: + warnings.warn( + "The use of only project name in `create_project()` is deprecated." + "The `project_name` should be full name (/).", + category=DeprecationWarning, + ) + params = {"name": project_name, "public": is_public} if namespace is None: namespace = self.username() try: - self.post("/v1/project/%s" % namespace, params, {"Content-Type": "application/json"}) + self.post(f"/v1/project/{namespace}", params, {"Content-Type": "application/json"}) except Exception as e: detail = f"Namespace: {namespace}, project name: {project_name}" raise ClientError(str(e), detail) @@ -458,20 +482,47 @@ def create_project(self, project_name, is_public=False, namespace=None): def create_project_and_push(self, project_name, directory, is_public=False, namespace=None): """ Convenience method to create project and push the initial version right after that. + + :param project_name: Project's full name (/) + :type project_name: String + + :param namespace: Deprecated. project_name should be full project name. Optional namespace for a new project. If empty username is used. + :type namespace: String """ if os.path.exists(os.path.join(directory, ".mergin")): raise ClientError("Directory is already assigned to a Mergin Maps project (contains .mergin sub-dir)") - if namespace is None: + if namespace and "/" not in project_name: + warnings.warn( + "The usage of `namespace` parameter in `create_project_and_push()` is deprecated." + "Specify `project_name` as full name (/) instead.", + category=DeprecationWarning, + ) + project_name = f"{namespace}/{project_name}" + + if "/" in project_name: + if namespace: + warnings.warn( + "Parameter `namespace` specified with full project name (/)." + "The parameter will be ignored." + ) + + elif namespace is None: + warnings.warn( + "The use of only project name in `create_project()` is deprecated." + "The `project_name` should be full name (/).", + category=DeprecationWarning, + ) namespace = self.username() - self.create_project(project_name, is_public, namespace) + project_name = f"{namespace}/{project_name}" + + self.create_project(project_name, is_public) if directory: - full_project_name = "{}/{}".format(namespace, project_name) - project_info = self.project_info(full_project_name) + project_info = self.project_info(project_name) MerginProject.write_metadata( directory, { - "name": full_project_name, + "name": project_name, "version": "v0", "files": [], "project_id": project_info["id"], @@ -816,20 +867,43 @@ def clone_project(self, source_project_path, cloned_project_name, cloned_project Clone project on server. :param source_project_path: Project's full name (/) :type source_project_path: String - :param cloned_project_name: Cloned project's name + :param cloned_project_name: Cloned project's full name (/) :type cloned_project_name: String - :param cloned_project_namespace: Cloned project's namespace, username is used if not defined + :param cloned_project_namespace: Deprecated. cloned_project_name should be full project name. Cloned project's namespace, username is used if not defined :type cloned_project_namespace: String """ - path = "/v1/project/clone/%s" % source_project_path + + if cloned_project_namespace and "/" not in cloned_project_name: + warnings.warn( + "The usage of `cloned_project_namespace` parameter in `clone_project()` is deprecated." + "Specify `cloned_project_name` as full name (/) instead.", + category=DeprecationWarning, + ) + + if "/" in cloned_project_name: + if cloned_project_namespace: + warnings.warn( + "Parameter `cloned_project_namespace` specified with full cloned project name (/)." + "The parameter will be ignored." + ) + + cloned_project_namespace, cloned_project_name = cloned_project_name.split("/") + + elif cloned_project_namespace is None: + warnings.warn( + "The use of only project name as `cloned_project_name` in `clone_project()` is deprecated." + "The `cloned_project_name` should be full name (/).", + category=DeprecationWarning, + ) + + path = f"/v1/project/clone/{source_project_path}" url = urllib.parse.urljoin(self.url, urllib.parse.quote(path)) json_headers = {"Content-Type": "application/json", "Accept": "application/json"} data = { "namespace": cloned_project_namespace if cloned_project_namespace else self.username(), "project": cloned_project_name, } - request = urllib.request.Request(url, data=json.dumps(data).encode(), headers=json_headers, method="POST") self._do_request(request) diff --git a/mergin/test/test_client.py b/mergin/test/test_client.py index 891ab178..febededf 100644 --- a/mergin/test/test_client.py +++ b/mergin/test/test_client.py @@ -97,7 +97,7 @@ def test_login(mc): mc.login("foo", "bar") -def test_create_delete_project(mc): +def test_create_delete_project(mc: MerginClient): test_project = "test_create_delete" project = API_USER + "/" + test_project project_dir = os.path.join(TMP_DIR, test_project) @@ -122,6 +122,33 @@ def test_create_delete_project(mc): with pytest.raises(ClientError): mc.delete_project_now(API_USER + "/" + test_project) + # test that using namespace triggers deprecate warning, but creates project correctly + with pytest.deprecated_call(match=r"The usage of `namespace` parameter in `create_project\(\)` is deprecated."): + mc.create_project(test_project, namespace=API_USER) + projects = mc.projects_list(flag="created") + assert any(p for p in projects if p["name"] == test_project and p["namespace"] == API_USER) + mc.delete_project_now(project) + + # test that using only project name triggers deprecate warning, but creates project correctly + with pytest.deprecated_call(match=r"The use of only project name in `create_project\(\)` is deprecated"): + mc.create_project(test_project) + projects = mc.projects_list(flag="created") + assert any(p for p in projects if p["name"] == test_project and p["namespace"] == API_USER) + mc.delete_project_now(project) + + # test that even if project is specified with full name and namespace is specified a warning is raised, but still create project correctly + with pytest.warns(UserWarning, match="Parameter `namespace` specified with full project name"): + mc.create_project(project, namespace=API_USER) + projects = mc.projects_list(flag="created") + assert any(p for p in projects if p["name"] == test_project and p["namespace"] == API_USER) + mc.delete_project_now(project) + + # test that create project with full name works + mc.create_project(project) + projects = mc.projects_list(flag="created") + assert any(p for p in projects if p["name"] == test_project and p["namespace"] == API_USER) + mc.delete_project_now(project) + def test_create_remote_project_from_local(mc): test_project = "test_project" @@ -134,7 +161,7 @@ def test_create_remote_project_from_local(mc): shutil.copytree(TEST_DATA_DIR, project_dir) # create remote project - mc.create_project_and_push(test_project, directory=project_dir) + mc.create_project_and_push(project, directory=project_dir) # verify we have correct metadata source_mp = MerginProject(project_dir) @@ -185,7 +212,7 @@ def test_push_pull_changes(mc): cleanup(mc, project, [project_dir, project_dir_2]) # create remote project shutil.copytree(TEST_DATA_DIR, project_dir) - mc.create_project_and_push(test_project, project_dir) + mc.create_project_and_push(project, project_dir) # make sure we have v1 also in concurrent project dir mc.download_project(project, project_dir_2) @@ -282,7 +309,7 @@ def test_cancel_push(mc): cleanup(mc, project, [project_dir, project_dir_2]) # create remote project shutil.copytree(TEST_DATA_DIR, project_dir) - mc.create_project_and_push(test_project, project_dir) + mc.create_project_and_push(project, project_dir) # modify the project (add, update) f_added = "new.txt" @@ -324,7 +351,7 @@ def test_ignore_files(mc): # create remote project shutil.copytree(TEST_DATA_DIR, project_dir) shutil.copy(os.path.join(project_dir, "test.qgs"), os.path.join(project_dir, "test.qgs~")) - mc.create_project_and_push(test_project, project_dir) + mc.create_project_and_push(project, project_dir) project_info = mc.project_info(project) assert not next((f for f in project_info["files"] if f["path"] == "test.qgs~"), None) @@ -344,7 +371,7 @@ def test_sync_diff(mc): cleanup(mc, project, [project_dir, project_dir_2, project_dir_3]) # create remote project shutil.copytree(TEST_DATA_DIR, project_dir) - mc.create_project_and_push(test_project, project_dir) + mc.create_project_and_push(project, project_dir) # make sure we have v1 also in concurrent project dirs mc.download_project(project, project_dir_2) @@ -411,7 +438,7 @@ def test_list_of_push_changes(mc): cleanup(mc, project, [project_dir]) shutil.copytree(TEST_DATA_DIR, project_dir) - mc.create_project_and_push(test_project, project_dir) + mc.create_project_and_push(project, project_dir) f_updated = "base.gpkg" mp = MerginProject(project_dir) @@ -430,7 +457,7 @@ def test_token_renewal(mc): cleanup(mc, project, [project_dir]) shutil.copytree(TEST_DATA_DIR, project_dir) - mc.create_project_and_push(test_project, project_dir) + mc.create_project_and_push(project, project_dir) mc._auth_session["expire"] = datetime.now().replace(tzinfo=pytz.utc) - timedelta(days=1) pull_changes, push_changes, _ = mc.project_status(project_dir) @@ -446,7 +473,7 @@ def test_force_gpkg_update(mc): cleanup(mc, project, [project_dir]) # create remote project shutil.copytree(TEST_DATA_DIR, project_dir) - mc.create_project_and_push(test_project, project_dir) + mc.create_project_and_push(project, project_dir) # test push changes with force gpkg file upload: mp = MerginProject(project_dir) @@ -512,7 +539,7 @@ def test_missing_basefile_pull(mc): cleanup(mc, project, [project_dir, project_dir_2]) # create remote project shutil.copytree(test_data_dir, project_dir) - mc.create_project_and_push(test_project, project_dir) + mc.create_project_and_push(project, project_dir) # update our gpkg in a different directory mc.download_project(project, project_dir_2) @@ -543,7 +570,7 @@ def test_empty_file_in_subdir(mc): cleanup(mc, project, [project_dir, project_dir_2]) # create remote project shutil.copytree(test_data_dir, project_dir) - mc.create_project_and_push(test_project, project_dir) + mc.create_project_and_push(project, project_dir) # try to check out the project mc.download_project(project, project_dir_2) @@ -559,7 +586,7 @@ def test_empty_file_in_subdir(mc): assert os.path.exists(os.path.join(project_dir_2, "subdir2", "empty2.txt")) -def test_clone_project(mc): +def test_clone_project(mc: MerginClient): test_project = "test_clone_project" test_project_fullname = API_USER + "/" + test_project @@ -573,14 +600,38 @@ def test_clone_project(mc): assert any(p for p in projects if p["name"] == test_project and p["namespace"] == API_USER) cloned_project_name = test_project + "_cloned" + test_cloned_project_fullname = API_USER + "/" + cloned_project_name + # cleanup cloned project cloned_project_dir = os.path.join(TMP_DIR, cloned_project_name) cleanup(mc, API_USER + "/" + cloned_project_name, [cloned_project_dir]) - # clone project - mc.clone_project(test_project_fullname, cloned_project_name, API_USER) + # clone specifying cloned_project_namespace, does clone but raises deprecation warning + with pytest.deprecated_call(match=r"The usage of `cloned_project_namespace` parameter in `clone_project\(\)`"): + mc.clone_project(test_project_fullname, cloned_project_name, API_USER) projects = mc.projects_list(flag="created") assert any(p for p in projects if p["name"] == cloned_project_name and p["namespace"] == API_USER) + cleanup(mc, API_USER + "/" + cloned_project_name, [cloned_project_dir]) + + # clone without specifying cloned_project_namespace relies on workspace with user name, does clone but raises deprecation warning + with pytest.deprecated_call(match=r"The use of only project name as `cloned_project_name` in `clone_project\(\)`"): + mc.clone_project(test_project_fullname, cloned_project_name) + projects = mc.projects_list(flag="created") + assert any(p for p in projects if p["name"] == cloned_project_name and p["namespace"] == API_USER) + cleanup(mc, API_USER + "/" + cloned_project_name, [cloned_project_dir]) + + # clone project with full cloned project name with specification of `cloned_project_namespace` raises warning + with pytest.warns(match=r"Parameter `cloned_project_namespace` specified with full cloned project name"): + mc.clone_project(test_project_fullname, test_cloned_project_fullname, API_USER) + projects = mc.projects_list(flag="created") + assert any(p for p in projects if p["name"] == cloned_project_name and p["namespace"] == API_USER) + cleanup(mc, API_USER + "/" + cloned_project_name, [cloned_project_dir]) + + # clone project using project full name + mc.clone_project(test_project_fullname, test_cloned_project_fullname) + projects = mc.projects_list(flag="created") + assert any(p for p in projects if p["name"] == cloned_project_name and p["namespace"] == API_USER) + cleanup(mc, API_USER + "/" + cloned_project_name, [cloned_project_dir]) def test_set_read_write_access(mc): @@ -772,7 +823,7 @@ def test_download_versions(mc): cleanup(mc, project, [project_dir, project_dir_v1, project_dir_v2, project_dir_v3]) # create remote project shutil.copytree(TEST_DATA_DIR, project_dir) - mc.create_project_and_push(test_project, project_dir) + mc.create_project_and_push(project, project_dir) # create new version - v2 f_added = "new.txt" @@ -841,7 +892,7 @@ def test_missing_local_file_pull(mc): cleanup(mc, project, [project_dir, project_dir_2]) # create remote project shutil.copytree(test_data_dir, project_dir) - mc.create_project_and_push(test_project, project_dir) + mc.create_project_and_push(project, project_dir) # remove a file in a different directory mc.download_project(project, project_dir_2) @@ -884,7 +935,7 @@ def create_versioned_project(mc, project_name, project_dir, updated_file, remove # create remote project shutil.copytree(TEST_DATA_DIR, project_dir) - mc.create_project_and_push(project_name, project_dir) + mc.create_project_and_push(project, project_dir) mp = MerginProject(project_dir) @@ -1032,7 +1083,7 @@ def test_modify_project_permissions(mc): shutil.copytree(TEST_DATA_DIR, project_dir) # create remote project - mc.create_project_and_push(test_project, directory=project_dir) + mc.create_project_and_push(project, directory=project_dir) # check basic metadata about created project project_info = mc.project_info(project) @@ -1168,7 +1219,7 @@ def test_push_gpkg_schema_change(mc): os.makedirs(project_dir) shutil.copy(os.path.join(TEST_DATA_DIR, "base.gpkg"), test_gpkg) # shutil.copytree(TEST_DATA_DIR, project_dir) - mc.create_project_and_push(test_project, project_dir) + mc.create_project_and_push(project, project_dir) mp = MerginProject(project_dir) @@ -1247,7 +1298,7 @@ def test_rebase_local_schema_change(mc, extra_connection): os.makedirs(project_dir) shutil.copy(os.path.join(TEST_DATA_DIR, "base.gpkg"), test_gpkg) _use_wal(test_gpkg) # make sure we use WAL, that's the more common and more difficult scenario - mc.create_project_and_push(test_project, project_dir) + mc.create_project_and_push(project, project_dir) if extra_connection: # open a connection and keep it open (qgis does this with a pool of connections too) @@ -1313,7 +1364,7 @@ def test_rebase_remote_schema_change(mc, extra_connection): os.makedirs(project_dir) shutil.copy(os.path.join(TEST_DATA_DIR, "base.gpkg"), test_gpkg) _use_wal(test_gpkg) # make sure we use WAL, that's the more common and more difficult scenario - mc.create_project_and_push(test_project, project_dir) + mc.create_project_and_push(project, project_dir) # Download project to the concurrent dir + change DB schema + push a new version mc.download_project(project, project_dir_2) @@ -1378,7 +1429,7 @@ def test_rebase_success(mc, extra_connection): os.makedirs(project_dir) shutil.copy(os.path.join(TEST_DATA_DIR, "base.gpkg"), test_gpkg) _use_wal(test_gpkg) # make sure we use WAL, that's the more common and more difficult scenario - mc.create_project_and_push(test_project, project_dir) + mc.create_project_and_push(project, project_dir) # Download project to the concurrent dir + add a row + push a new version mc.download_project(project, project_dir_2) @@ -1539,7 +1590,7 @@ def test_unfinished_pull(mc): os.makedirs(project_dir) shutil.copy(os.path.join(TEST_DATA_DIR, "base.gpkg"), test_gpkg) _use_wal(test_gpkg) # make sure we use WAL, that's the more common and more difficult scenario - mc.create_project_and_push(test_project, project_dir) + mc.create_project_and_push(project, project_dir) # Download project to the concurrent dir + change DB schema + push a new version mc.download_project(project, project_dir_2) @@ -1627,7 +1678,7 @@ def test_unfinished_pull_push(mc): os.makedirs(project_dir) shutil.copy(os.path.join(TEST_DATA_DIR, "base.gpkg"), test_gpkg) _use_wal(test_gpkg) # make sure we use WAL, that's the more common and more difficult scenario - mc.create_project_and_push(test_project, project_dir) + mc.create_project_and_push(project, project_dir) # Download project to the concurrent dir + change DB schema + push a new version mc.download_project(project, project_dir_2) @@ -1831,7 +1882,7 @@ def test_report_failure(mc): os.makedirs(project_dir) shutil.copy(os.path.join(TEST_DATA_DIR, "base.gpkg"), test_gpkg) - mc.create_project_and_push(test_project, project_dir) + mc.create_project_and_push(project, project_dir) shutil.copy(os.path.join(TEST_DATA_DIR, "inserted_1_A.gpkg"), test_gpkg) mc.push_project(project_dir) @@ -1868,7 +1919,7 @@ def test_changesets_download(mc): os.makedirs(project_dir, exist_ok=True) shutil.copy(os.path.join(TEST_DATA_DIR, "base.gpkg"), file_path) - mc.create_project_and_push(test_project, project_dir) + mc.create_project_and_push(project, project_dir) shutil.copy(os.path.join(TEST_DATA_DIR, "inserted_1_A.gpkg"), file_path) mc.push_project(project_dir) @@ -1912,7 +1963,7 @@ def test_version_info(mc): os.makedirs(project_dir, exist_ok=True) shutil.copy(os.path.join(TEST_DATA_DIR, "base.gpkg"), file_path) - mc.create_project_and_push(test_project, project_dir) + mc.create_project_and_push(project, project_dir) shutil.copy(os.path.join(TEST_DATA_DIR, "inserted_1_A.gpkg"), file_path) mc.push_project(project_dir) @@ -1939,7 +1990,7 @@ def test_clean_diff_files(mc): cleanup(mc, project, [project_dir, project_dir_2]) # create remote project shutil.copytree(TEST_DATA_DIR, project_dir) - mc.create_project_and_push(test_project, project_dir) + mc.create_project_and_push(project, project_dir) # test push changes with diffs: mp = MerginProject(project_dir)