Repository: repobee/repobee
Pull request #1172 (2023-09-03 19:18:18)

[feat] Add environment variable to disable name normalization


Patch:
diff --git a/requirements/requirements.dev.txt b/requirements/requirements.dev.txt
index 181f2d6..d027fd7 100644
--- a/requirements/requirements.dev.txt
+++ b/requirements/requirements.dev.txt
@@ -9,3 +9,4 @@ pytest-cov==4.0.0
 pytest-mock==3.10.0
 pytest==7.2.2
 virtualenv==20.20.0
+sphinx_rtd_theme==1.3.0
diff --git a/src/_repobee/cli/dispatch.py b/src/_repobee/cli/dispatch.py
index cb9a009..b1c1a60 100644
--- a/src/_repobee/cli/dispatch.py
+++ b/src/_repobee/cli/dispatch.py
@@ -15,7 +15,7 @@ from typing import Optional, List, Mapping, NoReturn
 
 import repobee_plug as plug
 
-from _repobee import command, exception, formatters, featflags, fileutil
+from _repobee import command, exception, formatters, fileutil
 
 
 def dispatch_command(
@@ -171,8 +171,8 @@ def _dispatch_reviews_command(
         )
         return None
     elif action == reviews.end:
-        if featflags.is_feature_enabled(
-            featflags.FeatureFlag.REPOBEE_4_REVIEW_COMMANDS
+        if plug._featflags.is_feature_enabled(
+            plug._featflags.FeatureFlag.REPOBEE_4_REVIEW_COMMANDS
         ):
             command.peer.end_reviews_repobee_4(args.allocations_file, api)
         else:
@@ -181,8 +181,8 @@ def _dispatch_reviews_command(
             )
         return None
     elif action == reviews.check:
-        if featflags.is_feature_enabled(
-            featflags.FeatureFlag.REPOBEE_4_REVIEW_COMMANDS
+        if plug._featflags.is_feature_enabled(
+            plug._featflags.FeatureFlag.REPOBEE_4_REVIEW_COMMANDS
         ):
             command.peer.check_reviews_repobee_4(
                 args.allocations_file, args.title_regex, api
diff --git a/src/_repobee/cli/mainparser.py b/src/_repobee/cli/mainparser.py
index 19816e2..cd608e8 100644
--- a/src/_repobee/cli/mainparser.py
+++ b/src/_repobee/cli/mainparser.py
@@ -19,7 +19,7 @@ import repobee_plug as plug
 from repobee_plug.cli.categorization import Action
 
 import _repobee
-from _repobee import plugin, featflags, fileutil
+from _repobee import plugin, fileutil
 from _repobee.cli import argparse_ext, pluginparsers, preparser
 import _repobee.ext.core_commands
 
@@ -109,8 +109,8 @@ _CORE_ACTIONS_IMPLEMENTED_AS_PLUGINS = (
 def _should_skip_adding_action_parser(action: Action) -> bool:
     return (
         action in _CORE_ACTIONS_IMPLEMENTED_AS_PLUGINS
-        and featflags.is_feature_enabled(
-            featflags.FeatureFlag.REPOBEE_CORE_COMMANDS_AS_PLUGINS
+        and plug._featflags.is_feature_enabled(
+            plug._featflags.FeatureFlag.REPOBEE_CORE_COMMANDS_AS_PLUGINS
         )
     )
 
@@ -499,12 +499,12 @@ def _add_peer_review_parsers(base_parsers, add_parser):
         "(DESTRUCTIVE ACTION: read help section before using)"
     )
 
-    if featflags.is_feature_enabled(
-        featflags.FeatureFlag.REPOBEE_4_REVIEW_COMMANDS
+    if plug._featflags.is_feature_enabled(
+        plug._featflags.FeatureFlag.REPOBEE_4_REVIEW_COMMANDS
     ):
         plug.log.warning(
             "Activating preview feature "
-            f"{featflags.FeatureFlag.REPOBEE_4_REVIEW_COMMANDS.value}"
+            f"{plug._featflags.FeatureFlag.REPOBEE_4_REVIEW_COMMANDS.value}"
         )
         allocation_parser = argparse_ext.RepobeeParser(add_help=False)
         allocation_parser.add_argument(
diff --git a/src/_repobee/command/peer.py b/src/_repobee/command/peer.py
index 709f12e..441c142 100644
--- a/src/_repobee/command/peer.py
+++ b/src/_repobee/command/peer.py
@@ -30,7 +30,6 @@ import _repobee.ext.gitea
 import _repobee.hash
 import _repobee.exception
 from _repobee import formatters
-from _repobee import featflags
 
 from _repobee.command import progresswrappers
 
@@ -106,8 +105,8 @@ def assign_peer_reviews(
             )
             assigned_reviews.append(review_team_with_repo)
 
-    if featflags.is_feature_enabled(
-        featflags.FeatureFlag.REPOBEE_4_REVIEW_COMMANDS
+    if plug._featflags.is_feature_enabled(
+        plug._featflags.FeatureFlag.REPOBEE_4_REVIEW_COMMANDS
     ):
         output = dict(
             allocations=[a.to_dict() for a in assigned_reviews],
diff --git a/src/_repobee/featflags.py b/src/_repobee/featflags.py
index a2a212d..4b71492 100644
--- a/src/_repobee/featflags.py
+++ b/src/_repobee/featflags.py
@@ -10,6 +10,7 @@ FEATURE_ENABLED_VALUE = "true"
 class FeatureFlag(enum.Enum):
     REPOBEE_4_REVIEW_COMMANDS = "REPOBEE_4_REVIEW_COMMANDS"
     REPOBEE_CORE_COMMANDS_AS_PLUGINS = "REPOBEE_CORE_COMMANDS_AS_PLUGINS"
+    REPOBEE_DISABLE_NAME_NORMALIZATION = "REPOBEE_DISABLE_NAME_NORMALIZATION"
 
 
 def is_feature_enabled(flag: FeatureFlag) -> bool:
diff --git a/src/_repobee/plugin.py b/src/_repobee/plugin.py
index 6289242..9765e3e 100644
--- a/src/_repobee/plugin.py
+++ b/src/_repobee/plugin.py
@@ -22,12 +22,12 @@ from typing import (
     Iterable,
     Mapping,
     Union,
-    Callable,
     Any,
     DefaultDict,
     Type,
 )
 
+from repobee_plug import _featflags
 
 import _repobee
 import _repobee.ext.defaults
@@ -35,7 +35,6 @@ import _repobee.ext.dist
 import _repobee.ext.core_commands
 import _repobee.distinfo
 from _repobee import exception
-from _repobee import featflags
 
 import repobee_plug as plug
 
@@ -351,8 +350,8 @@ def initialize_default_plugins() -> None:
         _repobee.ext.defaults
     )
 
-    if featflags.is_feature_enabled(
-        featflags.FeatureFlag.REPOBEE_CORE_COMMANDS_AS_PLUGINS
+    if _featflags.is_feature_enabled(
+        _featflags.FeatureFlag.REPOBEE_CORE_COMMANDS_AS_PLUGINS
     ):
         core_command_plugin_names = get_qualified_module_names(
             _repobee.ext.core_commands
@@ -444,9 +443,7 @@ def execute_setup_tasks(
 
 def execute_tasks(
     repos: Iterable[Union[plug.StudentRepo, plug.TemplateRepo]],
-    hook_function: Callable[
-        [pathlib.Path, plug.PlatformAPI], Optional[plug.Result]
-    ],
+    hook_function,
     api: plug.PlatformAPI,
     cwd: Optional[pathlib.Path],
     copy_repos: bool = True,
diff --git a/src/repobee_plug/__init__.py b/src/repobee_plug/__init__.py
index 9a4c4ad..d69d699 100644
--- a/src/repobee_plug/__init__.py
+++ b/src/repobee_plug/__init__.py
@@ -56,7 +56,12 @@ from repobee_plug.exceptions import (
 )
 
 # Local representations
-from repobee_plug.localreps import StudentTeam, StudentRepo, TemplateRepo
+from repobee_plug.localreps import (
+    StudentTeam,
+    StudentRepo,
+    TemplateRepo,
+    normalize_name,
+)
 
 # Hook functions
 from repobee_plug.hookmanager import manager
@@ -112,6 +117,7 @@ __all__ = [
     "deprecate",
     "deprecated_hooks",
     "Config",
+    "normalize_name",
     # Modules/Packages
     "cli",
     "fileutils",
diff --git a/src/repobee_plug/_featflags.py b/src/repobee_plug/_featflags.py
new file mode 100644
index 0000000..4b71492
--- /dev/null
+++ b/src/repobee_plug/_featflags.py
@@ -0,0 +1,22 @@
+"""Module for dealing with feature flags."""
+import os
+import enum
+
+__all__ = ["FEATURE_ENABLED_VALUE", "FeatureFlag", "is_feature_enabled"]
+
+FEATURE_ENABLED_VALUE = "true"
+
+
+class FeatureFlag(enum.Enum):
+    REPOBEE_4_REVIEW_COMMANDS = "REPOBEE_4_REVIEW_COMMANDS"
+    REPOBEE_CORE_COMMANDS_AS_PLUGINS = "REPOBEE_CORE_COMMANDS_AS_PLUGINS"
+    REPOBEE_DISABLE_NAME_NORMALIZATION = "REPOBEE_DISABLE_NAME_NORMALIZATION"
+
+
+def is_feature_enabled(flag: FeatureFlag) -> bool:
+    """Check if a feature is enabled.
+
+    Args:
+        flag: A feature flag.
+    """
+    return os.getenv(flag.value) == FEATURE_ENABLED_VALUE
diff --git a/src/repobee_plug/localreps.py b/src/repobee_plug/localreps.py
index 9d59244..b0aad26 100644
--- a/src/repobee_plug/localreps.py
+++ b/src/repobee_plug/localreps.py
@@ -5,6 +5,7 @@ import pathlib
 from typing import Optional, List, TypeVar
 
 from repobee_plug import exceptions
+from repobee_plug import _featflags
 
 MAX_NAME_LENGTH = 100
 
@@ -38,7 +39,9 @@ class StudentTeam:
         return self.name
 
     def __post_init__(self):
-        object.__setattr__(self, "members", [m.lower() for m in self.members])
+        object.__setattr__(
+            self, "members", [normalize_name(m) for m in self.members]
+        )
         object.__setattr__(
             self, "name", self.name or "-".join(sorted(self.members))
         )
@@ -115,3 +118,23 @@ class TemplateRepo(_RepoPathMixin):
         p = f"file://{self.path}"
         print(p)
         return f"file://{self.path}"
+
+
+def normalize_name(name: str) -> str:
+    """Normalize a name (e.g. repo or username) according to RepoBee's conventions.
+
+    Name normalization can be disabled by setting the
+    REPOBEE_DISABLE_NAME_NORMALIZATION environment variable to true for better
+    interoperability with external tools such as GitHub Classroom.
+
+    Args:
+        name: A name.
+    Returns:
+        A normalized representation of the name.
+    """
+    if _featflags.is_feature_enabled(
+        _featflags.FeatureFlag.REPOBEE_DISABLE_NAME_NORMALIZATION
+    ):
+        return name
+
+    return name.casefold()
diff --git a/src/repobee_plug/platform.py b/src/repobee_plug/platform.py
index 31d6948..80f5a94 100644
--- a/src/repobee_plug/platform.py
+++ b/src/repobee_plug/platform.py
@@ -8,9 +8,10 @@ import dataclasses
 import inspect
 import enum
 import itertools
-from typing import List, Iterable, Optional, Any
+from typing import List, Iterable, NoReturn, Optional, Any
 
 from repobee_plug import exceptions
+from repobee_plug import localreps
 
 
 class APIObject:
@@ -63,7 +64,11 @@ class Team(APIObject):
     implementation: Any = dataclasses.field(compare=False, repr=False)
 
     def __post_init__(self):
-        object.__setattr__(self, "members", [m.lower() for m in self.members])
+        object.__setattr__(
+            self,
+            "members",
+            [localreps.normalize_name(m) for m in self.members],
+        )
 
     def __str__(self):
         return self.name
@@ -93,7 +98,9 @@ class Issue(APIObject):
         object.__setattr__(
             self,
             "author",
-            self.author.lower() if self.author is not None else None,
+            localreps.normalize_name(self.author)
+            if self.author is not None
+            else None,
         )
 
     def to_dict(self):
@@ -464,7 +471,7 @@ class _APISpec:
         _not_implemented()
 
 
-def _not_implemented():
+def _not_implemented() -> NoReturn:
     raise NotImplementedError(
         "The chosen API does not currently support this functionality"
     )


Test patch:
diff --git a/tests/integration_tests/test_reviews.py b/tests/integration_tests/test_reviews.py
index 488d913..dd8f5fe 100644
--- a/tests/integration_tests/test_reviews.py
+++ b/tests/integration_tests/test_reviews.py
@@ -12,7 +12,6 @@ from repobee_testhelpers import funcs
 from repobee_testhelpers import const
 
 import _repobee.constants
-from _repobee import featflags
 
 
 class TestAssign:
@@ -59,6 +58,43 @@ class TestAssign:
         num_repos_after = len(list(api.get_repos()))
         assert num_repos_after == num_repos_before + len(const.STUDENT_TEAMS)
 
+    def test_uppercased_username_without_case_normalization(
+        self, disable_name_normalization, platform_url
+    ):
+        assignment_name = const.TEMPLATE_REPO_NAMES[0]
+        uppercase_user_name = "USER"
+        api = funcs.get_api(platform_url)
+
+        api._add_users([uppercase_user_name])
+        uppercase_team, other_team = [
+            api.create_team(name, members=[name])
+            for name in [uppercase_user_name, const.STUDENT_TEAMS[0].name]
+        ]
+
+        for team in [uppercase_team, other_team]:
+            api.create_repo(
+                name=plug.generate_repo_name(team, assignment_name),
+                description="Test repo",
+                private=True,
+                team=uppercase_team,
+            )
+
+        funcs.run_repobee(
+            f"reviews assign --num-reviews 1 "
+            f"--base-url {platform_url} "
+            f"--assignments {assignment_name} "
+            f"--students {uppercase_team.name} {other_team.name}"
+        )
+        api._restore_platform_state()
+
+        review_team_name = plug.generate_review_team_name(
+            uppercase_user_name, assignment_name
+        )
+        review_team, *_ = api.get_teams([review_team_name])
+
+        assert review_team.name == review_team_name
+        assert len(list(api.get_team_repos(review_team))) == 1
+
 
 class TestEnd:
     """Tests for the ``reviews end`` command."""
@@ -241,7 +277,20 @@ def activate_review_command_preview():
             "Peer review command preview feature should be removed!"
         )
     os.environ[
-        featflags.FeatureFlag.REPOBEE_4_REVIEW_COMMANDS.value
-    ] = featflags.FEATURE_ENABLED_VALUE
+        plug._featflags.FeatureFlag.REPOBEE_4_REVIEW_COMMANDS.value
+    ] = plug._featflags.FEATURE_ENABLED_VALUE
     yield
-    del os.environ[featflags.FeatureFlag.REPOBEE_4_REVIEW_COMMANDS.value]
+    del os.environ[plug._featflags.FeatureFlag.REPOBEE_4_REVIEW_COMMANDS.value]
+
+
+@pytest.fixture
+def disable_name_normalization():
+    try:
+        os.environ[
+            plug._featflags.FeatureFlag.REPOBEE_DISABLE_NAME_NORMALIZATION.value
+        ] = plug._featflags.FEATURE_ENABLED_VALUE
+        yield
+    finally:
+        del os.environ[
+            plug._featflags.FeatureFlag.REPOBEE_DISABLE_NAME_NORMALIZATION.value
+        ]