api/group_rename: full api support for group renaming
M test/test_api.py +10 -0
@@ 831,6 831,16 @@ 2021-01-04  5.0  6.0  7.0
 2021-01-05  6.0  7.0  8.0
 """, df)
 
+    tsx.group_rename('first_group_api', 'new_name_api')
+    assert not tsx.group_exists('first_group_api')
+    assert tsx.group_exists('new_name_api')
+    df2 = tsx.group_get(
+        'new_name_api',
+        revision_date=utcdt(2021, 1, 2)
+    )
+
+    assert df2.equals(df)
+
 
 def test_group_errors(tsx):
     df = gengroup(

          
M tshistory/api.py +9 -0
@@ 580,6 580,15 @@ class mainsource:
         with self.engine.begin() as cn:
             return self.tsh.group_type(cn, name)
 
+    def group_rename(self, oldname: str, newname: str) -> NONETYPE:
+        """Rename a group.
+
+        The target name must be available.
+
+        """
+        with self.engine.begin() as cn:
+            self.tsh.group_rename(cn, oldname, newname)
+
     def group_get(self,
                   name: str,
                   revision_date: Optional[pd.Timestamp]=None,

          
M tshistory/http/client.py +11 -0
@@ 638,3 638,14 @@ class Client:
             return
 
         return res
+
+    @unwraperror
+    def group_rename(self, oldname, newname):
+        res = self.session.put(
+            f'{self.uri}/group/state',
+            data={'name': oldname, 'newname': newname}
+        )
+        if res.status_code == 204:
+            return
+
+        return res

          
M tshistory/http/server.py +23 -0
@@ 263,6 263,11 @@ groupupdate.add_argument(
     help='series group in binary format'
 )
 
+grouprename = groupbase.copy()
+grouprename.add_argument(
+    'newname', type=str, required=True,
+    help='new name of the group'
+)
 
 groupget = groupbase.copy()
 groupget.add_argument(

          
@@ 772,6 777,24 @@ class httpapi:
                     200
                 )
 
+            @api.expect(grouprename)
+            @onerror
+            def put(self):
+                args = grouprename.parse_args()
+                if not tsa.group_exists(args.name):
+                    api.abort(404, f'`{args.name}` does not exists')
+                if tsa.group_exists(args.newname):
+                    api.abort(409, f'`{args.newname}` does exists')
+
+                try:
+                    tsa.group_rename(args.name, args.newname)
+                except ValueError as err:
+                    if err.args[0].startswith('not allowed to'):
+                        api.abort(405, err.args[0])
+                    raise
+
+                return no_content()
+
             @api.expect(groupdelete)
             @onerror
             def delete(self):

          
M tshistory/testutil.py +5 -0
@@ 270,6 270,11 @@ def with_tester(uri, resp, wsgitester):
     )
 
     resp.add_callback(
+        responses.PUT, uri + '/group/state',
+        callback=write_request_bridge(wsgitester.put)
+    )
+
+    resp.add_callback(
         responses.DELETE, uri + '/group/state',
         callback=write_request_bridge(wsgitester.delete)
     )