Repository: openSUSE/osc

Issue:
Provide an easy way to create an OBS branched project for a git-based package
Since the introduction of the new git workflow, basically the handling of the source and building the package became separated. Although `osc fork` is a way to achieve a la previous `osc branch` for git, it'd be also handy if we have a simple way to create a new OBS project tied with the existing (possibly already forked) git repo.

Suppose you have a package `foo` in OBS devel project `abc:bar`, corresponding gitea repo is `abc-bar/foo`.
And now you forked the git repo to `user/foo`, and modified something. Now you want to test builds -- that is, create a new OBS project `home:user:branches:abc:bar` that refers to `abc:bar`, and put the package `foo` from the gitea repo `user/foo` with scmsync meta.

Or, use an OBS project name corresponding to gitea owner/repo name, such as `home:user:git:user-foo`, referring to the devel proj `abc:bar`. And the package will be `home:user:git:user-foo/foo` .

Maybe there can be better ways, but hopefully you get an idea.
I'd just like to avoid cumbersome copy&paste of obs meta at each time.

Discussion:
@andrii-suse: I've looked into what can be done and wonder if adding logic for handling `--scmsync` parameter to `osc mkpac` will cover at least some scenarios? E.g.

```
cd ~/oscroot/home:user:git:user-foo
mkpac --scmsync https://src.opensuse.org/user-foo/foo.git foo
```

Do you think it will be useful or do you have better ideas how the commands could look like?
@tiwai: Yes, the option to `osc mkpac` is a nice idea and a straightforward implementation.

For making the situation clearer: currently, `osc fork` does three things:
1.  Fork the Gitea repo
2. Branch the OBS project
3. Create a new package on the branched OBS project with scmsync to the forked Gitea repo

Any my proposal was to provide command(s) for 2 and 3 while the user already has a Gitea repo (e.g. newly created or already forked on Gitea UI).

The `osc mkpac` option would implement 3.
Or, maybe an alternative would be a way to create a package on the given OBS project with the scmsync setup (a la automatism of `osc meta pkg`)?

What about 2? Do we have an osc command just to branch a project without a package?@andrii-suse: Yeah 2. is the trickiest part (because we want the command to work with single parameter - gitea url, is my understanding correct?), I was hoping to at least quickly solve 3. But apparently mkpac is probably wrong direction, because it needs `commit` for `osc meta` to work (and adding `autocommit` is also weird here). I will try to find better idea@andrii-suse: So I've tried ~git fork~ `osc fork`  when the git repository already exists and it just proceeds to 2. and 3. from the list.
Do you think using existing ~git fork~ `osc fork`  functionality will do the job? (considering that it skips gitea step if the gitea repo already exists). Or is anything missing?@tiwai: What about creating a new package from the scratch?@andrii-suse: > What about creating a new package from the scratch?

I don't know if automation is needed here. I believe it is more intuitive to just use set of commands once in a while, than remember another abstraction (e.g. I use the same set of simple commands git commands over and over and don't really feel that I need a wrapper for them). 

If I am not mistaken, the whole sequence should be like:
```
obs-git-init MyPackage
cd MyPackage
git add ...
git commit ...
git push ...
cd ~/oscroot:myuser:myproject
osc mkpac MyPackage
osc add MyPackage
osc commit
cd MyPackage
osc meta ...
```

Or do you e.g. prefer new `rmkpac` command, instead of the last steps, like:
```
osc rmkpac --scmsync https://src.opensuse.org/myuser/MyPackage MyPackage
```

(rmkpac will just commit the new package and edit meta if scmsync is provided)@tiwai: Do I understand correctly that, in your example above, the linkage from git (MyPackage) to OBS MyPackage repo is done in the last step (`osc meta ...`)? 
Then yes, an easier way to achieve this would be helpful, instead of forcing user to edit the line manually.
@andrii-suse: Yeah it looks `osc meta` cannot easily tweak xml (i.e. add the xml tag) from command line at the moment. But on the other hand: is it really needed for this use case if we implement `osc rmkpac --force --scmsync <url> <PackageName>` ?@tiwai: The osc rmkpac with --scmsync would work, too. Then how will your steps above change?@andrii-suse: fyi `rmkpac` proposal is in #1794 , feel free to add code review there

Solution patch:
diff --git a/osc/commands/rmkpac.py b/osc/commands/rmkpac.py
new file mode 100644
index 00000000..f1942e93
--- /dev/null
+++ b/osc/commands/rmkpac.py
@@ -0,0 +1,84 @@
+import sys
+from urllib.error import HTTPError
+
+import osc.commandline
+
+
+class RmkpacCommand(osc.commandline.OscCommand):
+    """
+    Make a package on OBS side.
+    The primary goal is to provide a convenient way for creating new package with scmsync enabled
+    """
+
+    name = "rmkpac"
+
+    def init_arguments(self):
+
+        self.add_argument(
+            "project",
+            help="Name of the project or . for current directory",
+        )
+
+        self.add_argument(
+            "package",
+            help="Name of the new package",
+        )
+
+        self.add_argument(
+            "--scmsync",
+            help="URL to put to the scmsync tag in _meta file",
+        )
+
+        self.add_argument(
+            "--force",
+            action="store_true",
+            default=False,
+            help="Overwrite existing package, if any",
+        )
+
+    def run(self, args):
+        from osc import obs_api
+        from osc.output import tty
+        from osc.store import get_store
+
+        package = args.package
+
+        if not package:
+            print(f"{tty.colorize('ERROR', 'red,bold')}: Package is empty")
+            sys.exit(1)
+
+        if args.project == ".":
+            store = get_store(".")
+            store.assert_is_project()
+            project = store.project
+        else:
+            project = args.project
+
+        if not project:
+            print(f"{tty.colorize('ERROR', 'red,bold')}: Project is empty")
+            sys.exit(1)
+
+        # reject if the project meta has scmsync configured
+        prj = obs_api.Project.from_api(args.apiurl, project)
+
+        if prj.scmsync:
+            raise RuntimeError(
+                "rmkpac cannot create package for projects managed in Git (the <scmsync> element is set in the project meta)"
+            )
+
+        # reject if the package exists unless --force is set
+        if not args.force:
+            pkg = None
+            try:
+                pkg = obs_api.Package.from_api(args.apiurl, project, package)
+            except HTTPError as e:
+                if e.code != 404:
+                    raise
+
+            if pkg:
+                print(f"{tty.colorize('ERROR', 'red,bold')}: Package already exists")
+                sys.exit(1)
+
+        pkg = obs_api.Package(name=package, project=project)
+        pkg.scmsync = args.scmsync
+        pkg.to_api(args.apiurl)
diff --git a/osc/git_scm/store.py b/osc/git_scm/store.py
index eedce91e..b2b74635 100644
--- a/osc/git_scm/store.py
+++ b/osc/git_scm/store.py
@@ -74,10 +74,7 @@ class GitStore:
         with tempfile.TemporaryDirectory(prefix="osc_devel_project_git") as tmp_dir:
             try:
                 gitea_api.Repo.clone(gitea_conn, gitea_owner, gitea_repo, branch=gitea_branch, quiet=True, directory=tmp_dir)
-                project_build_path = os.path.join(tmp_dir, "project.build")
-                with open(project_build_path, "r", encoding="utf-8") as f:
-                    project_build = f.readline().strip()
-                    return project_build
+                return GitStore(tmp_dir, check=False).project
             except gitea_api.GiteaException:
                 # "_ObsPrj" repo doesn't exist
                 return None
@@ -88,63 +85,85 @@ class GitStore:
                 # "project.build" file doesn't exist
                 return None
 
-    @property
-    def git_project_dir(self):
-        if not hasattr(self, "_git_project_dir"):
-            self._git_project_dir = None
-            path = self.abspath
-            while path and path != "/":
-                path, _ = os.path.split(path)
-
-                osc_path = os.path.join(path, ".osc")
-                git_path = os.path.join(path, ".git")
-                config_path = os.path.join(path, "_config")
-                pbuild_path = os.path.join(path, "_pbuild")
-                subdirs_path = os.path.join(path, "_subdirs")
-
-                if (os.path.isdir(osc_path) or os.path.isdir(git_path)) and (os.path.isfile(config_path) or os.path.isfile(pbuild_path)):
-                    if os.path.isfile(subdirs_path):
-                        # the _subdirs file contains a list of project subdirs that contain packages
-                        yaml = ruamel.yaml.YAML()
-                        with open(subdirs_path, "r") as f:
-                            data = yaml.load(f)
-
-                            # ``subdirs`` is a list of directories, which have subdirectories which are packages
-                            subdirs = data.get("subdirs", [])
-
-                            # if set to "include", then all top-level directories are packages in addition to ``subdirs``
-                            toplevel = data.get("toplevel", "")
-
-                        if toplevel == "include":
-                            subdirs.append(".")
-
-                        subdirs_abspath = [os.path.abspath(os.path.join(path, subdir)) for subdir in subdirs]
-
-                        # paths listed in ``subdirs`` are never packages, their subdirs are
-                        if self.abspath in subdirs_abspath:
-                            break
-
-                        # we're outside paths specified in subdirs -> not a package
-                        if os.path.abspath(os.path.join(self.abspath, "..")) not in subdirs_abspath:
-                            break
-                    else:
-                        # no _subdirs file and self.abspath is not directly under the project dir -> not a valid package
-                        if path != os.path.abspath(os.path.join(self.abspath, "..")):
-                            break
-
-                    self._git_project_dir = path
-                    break
-        return self._git_project_dir
+    def get_project_obs_scm_store(self):
+        from ..obs_scm import Store
+
+        if not self.is_package:
+            return None
+
+        try:
+            store = Store(os.path.join(self.abspath, ".."))
+            store.assert_is_project()
+            return store
+        except oscerr.NoWorkingCopy:
+            return None
+
+    def get_project_git_scm_store(self):
+        if not self.is_package:
+            return None
+
+        path = self.abspath
+        while path:
+            if path == "/":
+                # no git repo found
+                return None
+
+            path, _ = os.path.split(path)
+
+            if os.path.isdir(os.path.join(path, ".git")):
+                break
+
+        config_path = os.path.join(path, "_config")
+        pbuild_path = os.path.join(path, "_pbuild")
+        subdirs_path = os.path.join(path, "_subdirs")
+
+        # we always stop at the top-most directory that contains .git subdir
+        if not os.path.isfile(config_path) or os.path.isfile(pbuild_path):
+            # it's not a project, stop traversing and return
+            return None
+
+        if os.path.isfile(subdirs_path):
+            # the _subdirs file contains a list of project subdirs that contain packages
+            yaml = ruamel.yaml.YAML()
+            with open(subdirs_path, "r") as f:
+                data = yaml.load(f)
+
+                # ``subdirs`` is a list of directories, which have subdirectories which are packages
+                subdirs = data.get("subdirs", [])
+
+                # if set to "include", then all top-level directories are packages in addition to ``subdirs``
+                toplevel = data.get("toplevel", "")
+
+            if toplevel == "include":
+                subdirs.append(".")
+
+            subdirs_abspath = [os.path.abspath(os.path.join(path, subdir)) for subdir in subdirs]
+
+            # paths listed in ``subdirs`` are never packages, their subdirs are
+            if self.abspath in subdirs_abspath:
+                return None
+
+            # we're outside paths specified in subdirs -> not a package
+            if os.path.abspath(os.path.join(self.abspath, "..")) not in subdirs_abspath:
+                return None
+        else:
+            # no _subdirs file and self.abspath is not directly under the project dir -> not a valid package
+            if path != os.path.abspath(os.path.join(self.abspath, "..")):
+                return None
+
+        return GitStore(path)
 
     def __init__(self, path, check=True):
         self.path = path
         self.abspath = os.path.abspath(self.path)
 
+        self._apiurl = None
+        self._package = None
+        self._project = None
+
         self.is_project = False
         self.is_package = False
 
-        self.project_obs_scm_store = None
-
         if os.path.isdir(os.path.join(self.abspath, ".git")):
             # NOTE: we have only one store in project-git for all packages
             config_path = os.path.join(self.abspath, "_config")
@@ -155,16 +174,17 @@ class GitStore:
             else:
                 # there's .git and no _config/_pbuild in the working directory -> it's a package
                 self.is_package = True
-        elif self.git_project_dir:
-            from ..obs_scm import Store
 
-            # there's no .git in the working directory and there's .osc, .git and _config/_pbuild in the parent directory tree -> it's a package
-            self.is_package = True
-            self.project_obs_scm_store = Store(self.git_project_dir, check=False)
+        self.project_store = None
 
-        self._apiurl = None
-        self._package = None
-        self._project = None
+        if self.project_store is None:
+            self.project_store = self.get_project_obs_scm_store()
+
+        if self.project_store is None:
+            self.project_store = self.get_project_git_scm_store()
+
+        if self.project_store:
+            self.is_package = True
 
         if check and not any([self.is_project, self.is_package]):
             msg = f"Directory '{self.path}' is not a Git SCM working copy"
@@ -193,9 +213,9 @@ class GitStore:
     @property
     def apiurl(self):
         if not self._apiurl:
-            if self.is_package and self.project_obs_scm_store:
+            if self.is_package and self.project_store:
                 # read apiurl from parent directory that contains a project with .osc metadata
-                self._apiurl = self.project_obs_scm_store.apiurl
+                self._apiurl = self.project_store.apiurl
             if not self._apiurl:
                 # HACK: use the currently configured apiurl
                 self._apiurl = osc_conf.config["apiurl"]
@@ -208,16 +228,32 @@ class GitStore:
     @property
     def project(self):
         if not self._project:
-            if self.is_package and self.project_obs_scm_store:
-                # read project from parent directory that contains a project with .osc metadata
-                self._project = self.project_obs_scm_store.project
-            if not self._project:
-                # read project from Gitea (identical owner, repo: _ObsPrj, file: project.build)
-                origin = self._run_git(["remote", "get-url", "origin"])
-                self._project = self.get_build_project(origin)
+            if self.is_package:
+                # handle _project in a package
+
+                if self.project_store:
+                    # read project from detected project store
+                    self._project = self.project_store.project
+
+                if not self._project:
+                    # read project from Gitea (identical owner, repo: _ObsPrj, file: project.build)
+                    origin = self._run_git(["remote", "get-url", "origin"])
+                    self._project = self.get_build_project(origin)
+
+            else:
+                # handle _project in a project
+
+                if not self._project:
+                    # read project from "project.build" file
+                    path = os.path.join(self.abspath, "project.build")
+                    if os.path.exists(path):
+                        with open(path, "r", encoding="utf-8") as f:
+                            self._project = f.readline().strip()
+
             if not self._project:
                 # HACK: assume openSUSE:Factory project if project metadata is missing
                 self._project = "openSUSE:Factory"
+
         return self._project
 
     @project.setter
diff --git a/osc/gitea_api/exceptions.py b/osc/gitea_api/exceptions.py
index 8cb8b4a8..2fdaf62e 100644
--- a/osc/gitea_api/exceptions.py
+++ b/osc/gitea_api/exceptions.py
@@ -1,4 +1,5 @@
 import inspect
+import json
 import re
 from typing import Optional
 
@@ -17,8 +18,11 @@ def response_to_exception(response: GiteaHTTPResponse, *, context: Optional[dict
     - in some cases, it's required to provide additional context to the request, that is passed to the raised exception,
       for example: ``conn.request("GET", url, context={"owner": owner, "repo": repo})``
     """
-    data = response.json()
-    messages = [data["message"]] + (data.get("errors", None) or [])
+    try:
+        data = response.json()
+        messages = [data["message"]] + (data.get("errors", None) or [])
+    except json.JSONDecodeError:
+        messages = [response.data.decode("utf-8")]
 
     for cls in EXCEPTION_CLASSES:
         if cls.RESPONSE_STATUS is not None and cls.RESPONSE_STATUS != response.status:
@@ -57,6 +61,25 @@ class GiteaException(oscerr.OscBaseError):
         return result
 
 
+class MovedPermanently(GiteaException):
+    RESPONSE_STATUS = 301
+    RESPONSE_MESSAGE_RE = [
+        re.compile(r"(?P<message>.*)"),
+    ]
+
+    def __init__(self, response: GiteaHTTPResponse, message: str):
+        super().__init__(response)
+        self.message = message
+
+    def __str__(self):
+        result = (
+            f"{self.RESPONSE_STATUS} Moved Permanently: {self.message}\n"
+            " * Change Gitea URL to the actual location of the service\n"
+            " * Check Gitea URL for errors and typos such as https:// vs http://"
+        )
+        return result
+
+
 class BranchDoesNotExist(GiteaException):
     RESPONSE_STATUS = 404
     # modules/git/error.go:   return fmt.Sprintf("branch does not exist [name: %s]", err.Name)


Test patch:
diff --git a/tests/test_git_scm_store.py b/tests/test_git_scm_store.py
index e6bdd3b7..8a9d8155 100644
--- a/tests/test_git_scm_store.py
+++ b/tests/test_git_scm_store.py
@@ -4,19 +4,34 @@ import subprocess
 import tempfile
 import unittest
 
+import osc.conf
 from osc.git_scm.store import GitStore
 
+from .common import patch
 
+
+@unittest.skipIf(not shutil.which("git"), "The 'git' executable is not available")
 class TestGitStore(unittest.TestCase):
     def setUp(self):
-        self.tmpdir = tempfile.mkdtemp(prefix="osc_test")
-        os.chdir(self.tmpdir)
+        environ = {
+            "OSC_CONFIG": "/dev/null",
+            "OSC_APIURL": "",
+            "OSC_USERNAME": "user",
+            "OSC_PASSWORD": "pass",
+        }
+        with patch.dict(os.environ, environ, clear=True):
+            # reload osc configuration to use the ENV variables
+            osc.conf.get_config()
+
+        self.tmpdir = tempfile.mkdtemp(prefix="osc_test_")
+
         # 'git init -b <initial-branch>' is not supported on older distros
-        subprocess.check_output(["git", "init", "-q"])
-        subprocess.check_output(["git", "checkout", "-b", "factory", "-q"])
-        subprocess.check_output(["git", "remote", "add", "origin", "https://example.com/packages/my-package.git"])
+        subprocess.check_output(["git", "init", "-q"], cwd=self.tmpdir)
+        subprocess.check_output(["git", "checkout", "-b", "factory", "-q"], cwd=self.tmpdir)
+        subprocess.check_output(["git", "remote", "add", "origin", "https://example.com/packages/my-package.git"], cwd=self.tmpdir)
 
     def tearDown(self):
+        osc.conf.config = None
         try:
             shutil.rmtree(self.tmpdir)
         except OSError:
@@ -51,8 +66,149 @@ class TestGitStore(unittest.TestCase):
         self.assertEqual(store.scmurl, "https://example.com/packages/my-package.git")
 
 
-if not shutil.which("git"):
-    TestGitStore = unittest.skip("The 'git' executable is not available")(TestGitStore)
+@unittest.skipIf(not shutil.which("git"), "The 'git' executable is not available")
+class TestGitStoreProject(unittest.TestCase):
+    """
+    We're guessting the project so much that it requires testing.
+    """
+
+    def setUp(self):
+        environ = {
+            "OSC_CONFIG": "/dev/null",
+            "OSC_APIURL": "",
+            "OSC_USERNAME": "user",
+            "OSC_PASSWORD": "pass",
+        }
+        with patch.dict(os.environ, environ, clear=True):
+            # reload osc configuration to use the ENV variables
+            osc.conf.get_config()
+
+        self.tmpdir = tempfile.mkdtemp(prefix="osc_test_")
+
+    def tearDown(self):
+        osc.conf.config = None
+        try:
+            shutil.rmtree(self.tmpdir)
+        except OSError:
+            pass
+
+    def _git_init(self, path):
+        os.makedirs(path, exist_ok=True)
+        subprocess.check_output(["git", "init", "-q"], cwd=path)
+        subprocess.check_output(["git", "checkout", "-b", "factory", "-q"], cwd=path)
+        subprocess.check_output(["git", "remote", "add", "origin", "https://example.com/packages/my-package.git"], cwd=path)
+
+    def _osc_init(self, path, project, package=None):
+        from osc.store import Store
+
+        os.makedirs(path, exist_ok=True)
+        store = Store(path, check=False)
+        store.project = project
+        store.package = package
+
+    def _write(self, path: str, data: str = ""):
+        with open(path, "w", encoding="utf-8") as f:
+            f.write(data)
+
+    def test_pkg_no_project(self):
+        pkg_path = os.path.join(self.tmpdir, "pkg")
+        self._git_init(pkg_path)
+
+        store = GitStore(pkg_path)
+        # fallback to openSUSE:Factory
+        self.assertEqual(store.project, "openSUSE:Factory")
+        self.assertEqual(store.package, "my-package")
+
+    def test_pkg_osc_project(self):
+        prj_path = os.path.join(self.tmpdir, "project")
+        self._osc_init(prj_path, project="PROJ")
+
+        pkg_path = os.path.join(prj_path, "package")
+        self._git_init(pkg_path)
+
+        store = GitStore(pkg_path)
+        self.assertEqual(store.project, "PROJ")
+        self.assertEqual(store.package, "my-package")
+
+    def test_pkg_git_project(self):
+        prj_path = os.path.join(self.tmpdir, "project")
+        self._git_init(prj_path)
+        self._write(os.path.join(prj_path, "_config"))
+        self._write(os.path.join(prj_path, "project.build"), "PROJ")
+
+        pkg_path = os.path.join(prj_path, "package")
+        self._git_init(pkg_path)
+
+        store = GitStore(pkg_path)
+        self.assertEqual(store.project, "PROJ")
+        self.assertEqual(store.package, "my-package")
+
+    def test_pkg_git_with_no_project(self):
+        prj_path = os.path.join(self.tmpdir, "project")
+        self._git_init(prj_path)
+        self._write(os.path.join(prj_path, "_config"))
+        self._write(os.path.join(prj_path, "project.build"), "PROJ")
+
+        git_subdir_path = os.path.join(prj_path, "git")
+        self._git_init(git_subdir_path)
+
+        pkg_path = os.path.join(git_subdir_path, "package")
+        self._git_init(pkg_path)
+
+        store = GitStore(pkg_path)
+        # the parent directory contains arbitrary git repo -> fallback to openSUSE:Factory
+        self.assertEqual(store.project, "openSUSE:Factory")
+        self.assertEqual(store.package, "my-package")
+
+    def test_pkg_git_project_ObsPrj(self):
+        pkg_path = os.path.join(self.tmpdir, "package")
+        self._git_init(pkg_path)
+
+        class Repo:
+            def clone(self, *args, **kwargs):
+                path = kwargs["directory"]
+                with open(os.path.join(path, "_config"), "w", encoding="utf-8") as f:
+                    f.write("")
+                with open(os.path.join(path, "project.build"), "w", encoding="utf-8") as f:
+                    f.write("PROJ")
+
+        with patch("osc.gitea_api.Config"), patch("osc.gitea_api.Connection"):
+            with patch("osc.gitea_api.Repo", new_callable=Repo) as mock_clone:
+                store = GitStore(pkg_path)
+                self.assertEqual(store.project, "PROJ")
+                self.assertEqual(store.package, "my-package")
+
+    def test_pkg_git_project_ObsPrj_no_config(self):
+        pkg_path = os.path.join(self.tmpdir, "package")
+        self._git_init(pkg_path)
+
+        class Repo:
+            def clone(self, *args, **kwargs):
+                path = kwargs["directory"]
+                with open(os.path.join(path, "project.build"), "w", encoding="utf-8") as f:
+                    f.write("PROJ")
+
+        with patch("osc.gitea_api.Config"), patch("osc.gitea_api.Connection"):
+            with patch("osc.gitea_api.Repo", new_callable=Repo) as mock_clone:
+                store = GitStore(pkg_path)
+                self.assertEqual(store.project, "PROJ")
+                self.assertEqual(store.package, "my-package")
+
+    def test_pkg_git_project_ObsPrj_no_project_build(self):
+        pkg_path = os.path.join(self.tmpdir, "package")
+        self._git_init(pkg_path)
+
+        class Repo:
+            def clone(self, *args, **kwargs):
+                path = kwargs["directory"]
+                with open(os.path.join(path, "_config"), "w", encoding="utf-8") as f:
+                    f.write("")
+
+        with patch("osc.gitea_api.Config"), patch("osc.gitea_api.Connection"):
+            with patch("osc.gitea_api.Repo", new_callable=Repo) as mock_clone:
+                store = GitStore(pkg_path)
+                self.assertEqual(store.project, "openSUSE:Factory")
+                self.assertEqual(store.package, "my-package")
 
 
 if __name__ == "__main__":
diff --git a/tests/test_vc.py b/tests/test_vc.py
index 9da14b55..03f59c25 100644
--- a/tests/test_vc.py
+++ b/tests/test_vc.py
@@ -20,62 +20,62 @@ class TestVC(unittest.TestCase):
         config["apiurl"] = host_options["apiurl"]
         self.host_options = host_options
 
-
-    @patch.dict(os.environ, {}, clear=True)
     def test_vc_export_env_conf(self):
         self.host_options.realname = "<REALNAME>"
         self.host_options.email = "<EMAIL>"
-        vc_export_env("http://localhost")
-        expected = {
-            "VC_REALNAME": "<REALNAME>",
-            "VC_MAILADDR": "<EMAIL>",
-            "mailaddr": "<EMAIL>",
-        }
-        self.assertEqual(os.environ, expected)
 
-    @patch.dict(os.environ, {}, clear=True)
+        with patch.dict(os.environ, {}, clear=True):
+            vc_export_env("http://localhost")
+            expected = {
+                "VC_REALNAME": "<REALNAME>",
+                "VC_MAILADDR": "<EMAIL>",
+                "mailaddr": "<EMAIL>",
+            }
+            self.assertEqual(os.environ, expected)
+
     @GET(
         "http://localhost/person/Admin",
         text="<person><login>Admin</login><email>root@localhost</email><realname>OBS Instance Superuser</realname></person>",
     )
     def test_vc_export_env_conf_realname(self):
         self.host_options.realname = "<REALNAME>"
-        vc_export_env("http://localhost")
-        expected = {
-            "VC_REALNAME": "<REALNAME>",
-            "VC_MAILADDR": "root@localhost",
-            "mailaddr": "root@localhost",
-        }
-        self.assertEqual(os.environ, expected)
+        with patch.dict(os.environ, {}, clear=True):
+            vc_export_env("http://localhost")
+            expected = {
+                "VC_REALNAME": "<REALNAME>",
+                "VC_MAILADDR": "root@localhost",
+                "mailaddr": "root@localhost",
+            }
+            self.assertEqual(os.environ, expected)
 
-    @patch.dict(os.environ, {}, clear=True)
     @GET(
         "http://localhost/person/Admin",
         text="<person><login>Admin</login><email>root@localhost</email><realname>OBS Instance Superuser</realname></person>",
     )
     def test_vc_export_env_conf_email(self):
         self.host_options.email = "<EMAIL>"
-        vc_export_env("http://localhost")
-        expected = {
-            "VC_REALNAME": "OBS Instance Superuser",
-            "VC_MAILADDR": "<EMAIL>",
-            "mailaddr": "<EMAIL>",
-        }
-        self.assertEqual(os.environ, expected)
+        with patch.dict(os.environ, {}, clear=True):
+            vc_export_env("http://localhost")
+            expected = {
+                "VC_REALNAME": "OBS Instance Superuser",
+                "VC_MAILADDR": "<EMAIL>",
+                "mailaddr": "<EMAIL>",
+            }
+            self.assertEqual(os.environ, expected)
 
-    @patch.dict(os.environ, {}, clear=True)
     @GET(
         "http://localhost/person/Admin",
         text="<person><login>Admin</login><email>root@localhost</email><realname>OBS Instance Superuser</realname></person>",
     )
     def test_vc_export_env_api_call(self):
-        vc_export_env("http://localhost")
-        expected = {
-            "VC_REALNAME": "OBS Instance Superuser",
-            "VC_MAILADDR": "root@localhost",
-            "mailaddr": "root@localhost",
-        }
-        self.assertEqual(os.environ, expected)
+        with patch.dict(os.environ, {}, clear=True):
+            vc_export_env("http://localhost")
+            expected = {
+                "VC_REALNAME": "OBS Instance Superuser",
+                "VC_MAILADDR": "root@localhost",
+                "mailaddr": "root@localhost",
+            }
+            self.assertEqual(os.environ, expected)
 
 
 if __name__ == "__main__":