github-org-manager 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
gh_org_mgr/__init__.py ADDED
@@ -0,0 +1,26 @@
1
+ # SPDX-FileCopyrightText: 2024 DB Systel GmbH
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ """Global init file"""
6
+
7
+ import logging
8
+ from importlib.metadata import version
9
+
10
+ __version__ = version("github-org-manager")
11
+
12
+
13
+ def configure_logger(debug: bool = False) -> logging.Logger:
14
+ """Set logging options"""
15
+ log = logging.getLogger()
16
+ logging.basicConfig(
17
+ encoding="utf-8",
18
+ format="[%(asctime)s] %(levelname)s: %(message)s",
19
+ datefmt="%Y-%m-%d %H:%M:%S",
20
+ )
21
+ if debug:
22
+ log.setLevel(logging.DEBUG)
23
+ else:
24
+ log.setLevel(logging.INFO)
25
+
26
+ return log
gh_org_mgr/_config.py ADDED
@@ -0,0 +1,117 @@
1
+ # SPDX-FileCopyrightText: 2024 DB Systel GmbH
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ """Handling the private and organisation configuration"""
6
+
7
+ import logging
8
+ import os
9
+ import re
10
+ import sys
11
+ from typing import Any
12
+
13
+ import yaml
14
+
15
+ # Global files with settings for the app and org, e.g. GitHub token and org name
16
+ ORG_CONFIG_FILE = r"org\.ya?ml"
17
+ APP_CONFIG_FILE = r"app\.ya?ml"
18
+ TEAM_CONFIG_DIR = "teams"
19
+ TEAM_CONFIG_FILES = r".+\.ya?ml"
20
+
21
+
22
+ def _find_matching_files(directory: str, pattern: str, only_one: bool = False) -> list[str]:
23
+ """
24
+ Get all files in a directory matching a regex pattern.
25
+
26
+ Args:
27
+ - directory: Path to the directory
28
+ - pattern: Regular expression pattern to match filenames
29
+ - only_one: Whether only the first match shall be returned.
30
+
31
+ Returns:
32
+ - List of filenames matching the pattern
33
+ """
34
+ matching_files: list[str] = []
35
+
36
+ # Validate directory existence
37
+ if not os.path.isdir(directory):
38
+ logging.error("'%s' is not a valid directory", directory)
39
+
40
+ else:
41
+ # Compile the regex pattern
42
+ regex_pattern = re.compile(pattern + "$")
43
+
44
+ # Traverse the directory and find matching files
45
+ for file_name in os.listdir(directory):
46
+ if regex_pattern.match(file_name):
47
+ file_path = os.path.join(directory, file_name)
48
+ if os.path.isfile(file_path):
49
+ matching_files.append(file_path)
50
+ else:
51
+ logging.warning(
52
+ "'%s' looks like a file we searched for, but it's not. "
53
+ "Will not consider its contents",
54
+ file_path,
55
+ )
56
+
57
+ if only_one and len(matching_files) > 1:
58
+ matching_files = [matching_files[0]]
59
+ logging.warning(
60
+ "More than one configuration file for the pattern '%s' found. "
61
+ "Reducing to the first match as wished: %s",
62
+ pattern,
63
+ matching_files[0],
64
+ )
65
+
66
+ if not matching_files:
67
+ logging.error(
68
+ "No configuration file found for '%s' in '%s'. The program might not work as expected!",
69
+ pattern,
70
+ directory
71
+ )
72
+
73
+ return matching_files
74
+
75
+
76
+ def _read_config_file(file: str) -> dict:
77
+ """Return dict of a YAML file"""
78
+ logging.debug("Attempting to parse YAML file %s", file)
79
+ with open(file, encoding="UTF-8") as yamlfile:
80
+ config: dict = yaml.safe_load(yamlfile)
81
+
82
+ if not config:
83
+ config = {}
84
+
85
+ return config
86
+
87
+
88
+ def parse_config_files(path: str) -> tuple[dict, dict, dict]:
89
+ """Parse all relevant files in the configuration directory"""
90
+ # Find the relevant config files for app, org, and teams
91
+ cfg_app_files = _find_matching_files(path, APP_CONFIG_FILE, only_one=True)
92
+ cfg_org_files = _find_matching_files(path, ORG_CONFIG_FILE, only_one=True)
93
+ cfg_teams_files = _find_matching_files(os.path.join(path, TEAM_CONFIG_DIR), TEAM_CONFIG_FILES)
94
+
95
+ # Read and parse config files for app and org
96
+ cfg_app = _read_config_file(cfg_app_files[0])
97
+ cfg_org = _read_config_file(cfg_org_files[0])
98
+
99
+ # For the teams config files, we parse and combine them as there may be multiple
100
+ cfg_teams: dict[str, Any] = {}
101
+ # For this, merge the resulting dicts of the previously read files, and the current file
102
+ # Compare their keys (team names). They must not be defined multiple times!
103
+ for cfg_team_file in cfg_teams_files:
104
+ cfg = _read_config_file(cfg_team_file)
105
+ if overlap := set(cfg_teams.keys()) & set(cfg.keys()):
106
+ logging.critical(
107
+ "The config file '%s' contains keys that are also defined in "
108
+ "other config files. This is disallowed. Affected keys: %s",
109
+ cfg_team_file,
110
+ ", ".join(overlap),
111
+ )
112
+ sys.exit(1)
113
+ else:
114
+ # Merge the dicts into one
115
+ cfg_teams = cfg_teams | cfg
116
+
117
+ return cfg_org, cfg_app, cfg_teams
gh_org_mgr/_gh_api.py ADDED
@@ -0,0 +1,27 @@
1
+ # SPDX-FileCopyrightText: 2024 DB Systel GmbH
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ """
6
+ Functions for interacting with the GitHub API
7
+ """
8
+
9
+ import logging
10
+ import os
11
+ import sys
12
+
13
+
14
+ def get_github_token(token: str = "") -> str:
15
+ """Get the GitHub token from config or environment, while environment overrides"""
16
+ if "GITHUB_TOKEN" in os.environ and os.environ["GITHUB_TOKEN"]:
17
+ logging.debug("GitHub Token taken from environment variable GITHUB_TOKEN")
18
+ token = os.environ["GITHUB_TOKEN"]
19
+ elif token:
20
+ logging.debug("GitHub Token taken from app configuration file")
21
+ else:
22
+ sys.exit(
23
+ "No token set for GitHub authentication! Set it in config/app_config.yaml "
24
+ "or via environment variable GITHUB_TOKEN"
25
+ )
26
+
27
+ return token
gh_org_mgr/_gh_org.py ADDED
@@ -0,0 +1,405 @@
1
+ # SPDX-FileCopyrightText: 2024 DB Systel GmbH
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ """Class for the GitHub organization which contains most of the logic"""
6
+
7
+ import logging
8
+ from dataclasses import asdict, dataclass, field
9
+
10
+ from github import (
11
+ Github,
12
+ NamedUser,
13
+ Organization,
14
+ Repository,
15
+ Team,
16
+ UnknownObjectException,
17
+ )
18
+
19
+ from ._gh_api import get_github_token
20
+
21
+
22
+ @dataclass
23
+ class GHorg: # pylint: disable=too-many-instance-attributes
24
+ """Dataclass holding GH organization data and functions"""
25
+
26
+ gh: Github = None # type: ignore
27
+ org: Organization.Organization = None # type: ignore
28
+ org_owners: list[NamedUser.NamedUser] = field(default_factory=list)
29
+ org_members: list[NamedUser.NamedUser] = field(default_factory=list)
30
+ current_teams: dict[Team.Team, dict] = field(default_factory=dict)
31
+ configured_teams: dict[str, dict | None] = field(default_factory=dict)
32
+ current_repos: dict[Repository.Repository, dict[Team.Team, str]] = field(default_factory=dict)
33
+
34
+ # --------------------------------------------------------------------------
35
+ # Helper functions
36
+ # --------------------------------------------------------------------------
37
+ def _sluggify_teamname(self, team: str) -> str:
38
+ """Slugify a GitHub team name"""
39
+ # TODO: this is very naive, no other special chars are
40
+ # supported, or multiple spaces etc.
41
+ return team.replace(" ", "-")
42
+
43
+ def login(self, orgname: str, token: str):
44
+ """Login to GH, gather org data"""
45
+ self.gh = Github(get_github_token(token))
46
+ self.org = self.gh.get_organization(orgname)
47
+
48
+ def ratelimit(self):
49
+ """Get current rate limit"""
50
+ core = self.gh.get_rate_limit().core
51
+ logging.debug(
52
+ "Current rate limit: %s/%s (reset: %s)", core.remaining, core.limit, core.reset
53
+ )
54
+
55
+ def df2json(self) -> str:
56
+ """Convert the dataclass to a JSON string"""
57
+ d = asdict(self)
58
+
59
+ def pretty(d, indent=0):
60
+ string = ""
61
+ for key, value in d.items():
62
+ string += " " * indent + str(key) + ":\n"
63
+ if isinstance(value, dict):
64
+ string += pretty(value, indent + 1)
65
+ else:
66
+ string += " " * (indent + 1) + str(value) + "\n"
67
+
68
+ return string
69
+
70
+ return pretty(d)
71
+
72
+ # --------------------------------------------------------------------------
73
+ # Teams
74
+ # --------------------------------------------------------------------------
75
+ def get_current_teams(self):
76
+ """Get teams of the existing organisation"""
77
+
78
+ for team in list(self.org.get_teams()):
79
+ self.current_teams[team] = {"members": {}, "repos": {}}
80
+
81
+ def create_missing_teams(self, dry: bool = False):
82
+ """Find out which teams are configured but not part of the org yet"""
83
+
84
+ # Get list of current teams
85
+ self.get_current_teams()
86
+
87
+ # Get the names of the existing teams
88
+ existent_team_names = [team.name for team in self.current_teams]
89
+
90
+ for team, attributes in self.configured_teams.items():
91
+ if team not in existent_team_names:
92
+ if parent := attributes.get("parent"): # type: ignore
93
+ parent_id = self.org.get_team_by_slug(self._sluggify_teamname(parent)).id
94
+
95
+ logging.info("Creating team '%s' with parent ID '%s'", team, parent_id)
96
+ if not dry:
97
+ self.org.create_team(team, parent_team_id=parent_id)
98
+
99
+ else:
100
+ logging.info("Creating team '%s' without parent", team)
101
+ if not dry:
102
+ self.org.create_team(team, privacy="closed")
103
+
104
+ else:
105
+ logging.debug("Team '%s' already exists", team)
106
+
107
+ # Re-scan current teams as new ones may have been created
108
+ self.get_current_teams()
109
+
110
+ # --------------------------------------------------------------------------
111
+ # Members
112
+ # --------------------------------------------------------------------------
113
+ def _get_org_members(self):
114
+ """Get all owners of the org"""
115
+ for member in self.org.get_members(role="admin"):
116
+ self.org_owners.append(member)
117
+ for member in self.org.get_members(role="member"):
118
+ self.org_members.append(member)
119
+
120
+ def _get_configured_team_members(
121
+ self, team_config: dict, team_name: str, role: str
122
+ ) -> list[str]:
123
+ """Read configured members/maintainers from the configuration"""
124
+
125
+ if isinstance(team_config, dict) and team_config.get(role):
126
+ configured_team_members = []
127
+ for user in team_config.get(role, []):
128
+ configured_team_members.append(user)
129
+
130
+ return configured_team_members
131
+
132
+ logging.debug("Team '%s' has no configured %ss", team_name, role)
133
+ return []
134
+
135
+ def _get_current_team_members(self, team: Team.Team) -> dict[NamedUser.NamedUser, str]:
136
+ """Return dict of current users with their respective roles. Also
137
+ contains members of child teams"""
138
+ current_users: dict[NamedUser.NamedUser, str] = {}
139
+ for role in ("member", "maintainer"):
140
+ # Make a two-step check whether person is actually in team, as
141
+ # get_members() also return child-team members
142
+ for user in list(team.get_members(role=role)):
143
+ current_users.update({user: role})
144
+
145
+ return current_users
146
+
147
+ def _resolve_gh_username(self, username: str, teamname: str) -> NamedUser.NamedUser | None:
148
+ """Turn a username into a proper GitHub user object"""
149
+ try:
150
+ gh_user: NamedUser.NamedUser = self.gh.get_user(username) # type: ignore
151
+ except UnknownObjectException:
152
+ logging.error(
153
+ "The user '%s' configured as member of team '%s' does not "
154
+ "exist on GitHub. Spelling error or did they rename themselves?",
155
+ username,
156
+ teamname,
157
+ )
158
+ return None
159
+
160
+ return gh_user
161
+
162
+ def sync_teams_members(self, dry: bool = False) -> None: # pylint: disable=too-many-branches
163
+ """Check the configured members of each team, add missing ones and delete unconfigured"""
164
+ logging.debug("Starting to sync team members")
165
+
166
+ # Gather all members and owners of the organisation
167
+ self._get_org_members()
168
+
169
+ # Get open invitations
170
+ open_invitations = [user.login for user in self.org.invitations()]
171
+
172
+ for team, team_attrs in self.current_teams.items():
173
+ # Update current team members with dict[NamedUser, str (role)]
174
+ team_attrs["members"] = self._get_current_team_members(team)
175
+
176
+ # For the rest of the function however, we use just the login name
177
+ # for each current user
178
+ current_team_members = {
179
+ user.login: role for user, role in team_attrs["members"].items()
180
+ }
181
+
182
+ # Handle the team not being configured locally
183
+ if team.name not in self.configured_teams:
184
+ logging.warning(
185
+ "Team '%s' does not seem to be configured locally. "
186
+ "Taking no action about this team at all",
187
+ team.name,
188
+ )
189
+ continue
190
+
191
+ # Get configuration from current team
192
+ if team_configuration := self.configured_teams.get(team.name):
193
+ pass
194
+ else:
195
+ team_configuration = {}
196
+
197
+ # Analog to team_attrs["members"], add members and maintainers to shared
198
+ # dict with respective role, while maintainer role dominates
199
+ configured_users: dict[str, str] = {}
200
+ for config_role in ("member", "maintainer"):
201
+ team_members = self._get_configured_team_members(
202
+ team_configuration, team.name, config_role
203
+ )
204
+ for team_member in team_members:
205
+ # Add user with role to dict
206
+ configured_users.update({team_member: config_role})
207
+
208
+ # Consider all GitHub organisation team maintainers if they are member of the team
209
+ # This is because GitHub API returns them as maintainers even if they are just members
210
+ for user in self.org_owners:
211
+ if user.login in configured_users:
212
+ logging.debug(
213
+ "Overriding role of organisation owner '%s' to maintainer", user.login
214
+ )
215
+ configured_users[user.login] = "maintainer"
216
+
217
+ # Only make edits to the team membership if the current state differs from config
218
+ if configured_users == current_team_members:
219
+ logging.info("Team '%s' configuration is in sync, no changes", team.name)
220
+ continue
221
+
222
+ # Loop through the configured users, add / update them if necessary
223
+ for config_user, config_role in configured_users.items():
224
+ # Add user if they haven't been in the team yet
225
+ if config_user not in current_team_members:
226
+ # Turn user to GitHub object, trying to find them
227
+ if not (gh_user := self._resolve_gh_username(config_user, team.name)):
228
+ continue
229
+
230
+ # Do not reinvite user if their invitation is already pending
231
+ if config_user in open_invitations:
232
+ logging.info(
233
+ "User '%s' shall be added to team '%s' as %s, invitation is pending",
234
+ gh_user.login,
235
+ team.name,
236
+ config_role,
237
+ )
238
+ continue
239
+
240
+ logging.info(
241
+ "Adding user '%s' to team '%s' as %s",
242
+ gh_user.login,
243
+ team.name,
244
+ config_role,
245
+ )
246
+ if not dry:
247
+ team.add_membership(member=gh_user, role=config_role)
248
+
249
+ # Update roles if they differ from old role
250
+ elif config_role != current_team_members.get(config_user, ""):
251
+ # Turn user to GitHub object, trying to find them
252
+ if not (gh_user := self._resolve_gh_username(config_user, team.name)):
253
+ continue
254
+ logging.info(
255
+ "Updating role of '%s' in team '%s' to %s",
256
+ config_user,
257
+ team.name,
258
+ config_role,
259
+ )
260
+ if not dry:
261
+ team.add_membership(member=gh_user, role=config_role)
262
+
263
+ # Loop through all current members. Remove them if they are not configured
264
+ for current_user in current_team_members:
265
+ if current_user not in configured_users:
266
+ # Turn user to GitHub object, trying to find them
267
+ if not (gh_user := self._resolve_gh_username(current_user, team.name)):
268
+ continue
269
+ if team.has_in_members(gh_user):
270
+ logging.info(
271
+ "Removing '%s' from team '%s' as they are not configured",
272
+ gh_user.login,
273
+ team.name,
274
+ )
275
+ if not dry:
276
+ team.remove_membership(gh_user)
277
+ else:
278
+ logging.debug(
279
+ "User '%s' does not need to be removed from team '%s' "
280
+ "as they are just member of a child-team",
281
+ gh_user.login,
282
+ team.name,
283
+ )
284
+
285
+ def get_members_without_team(self) -> None:
286
+ """Get all organisation members without any team membership"""
287
+ # Combine org owners and org members
288
+ all_org_members = set(self.org_members + self.org_owners)
289
+
290
+ # Get all members of all teams
291
+ all_team_members_lst = []
292
+ for _, team_attrs in self.current_teams.items():
293
+ for member in team_attrs.get("members", {}):
294
+ all_team_members_lst.append(member)
295
+ all_team_members = set(all_team_members_lst)
296
+
297
+ # Find members that are in org_members but not team_members
298
+ members_without_team = all_org_members.difference(all_team_members)
299
+
300
+ if members_without_team:
301
+ members_without_team_str = [user.login for user in members_without_team]
302
+ logging.warning(
303
+ "The following members of your GitHub organisation are not member of any team: %s",
304
+ ", ".join(members_without_team_str),
305
+ )
306
+
307
+ # --------------------------------------------------------------------------
308
+ # Repos
309
+ # --------------------------------------------------------------------------
310
+ def _get_current_repos_and_perms(self) -> None:
311
+ """Get all repos, their current teams and their permissions"""
312
+ for repo in list(self.org.get_repos()):
313
+ self.current_repos[repo] = {}
314
+ for team in list(repo.get_teams()):
315
+ self.current_repos[repo][team] = team.permission
316
+
317
+ def _create_perms_changelist_for_teams(
318
+ self,
319
+ ) -> dict[Team.Team, dict[Repository.Repository, str]]:
320
+ """Create a permission/repo changelist from the perspective of configured teams"""
321
+ team_changelist: dict[Team.Team, dict[Repository.Repository, str]] = {}
322
+ for team_name, team_attrs in self.configured_teams.items():
323
+ # Handle unset configured attributes
324
+ if team_attrs is None:
325
+ continue
326
+
327
+ # Convert team name to Team object
328
+ team = self.org.get_team_by_slug(self._sluggify_teamname(team_name))
329
+
330
+ # Get configured repo permissions
331
+ for repo, perm in team_attrs.get("repos", {}).items():
332
+ # Convert repo to Repo object
333
+ try:
334
+ repo = self.org.get_repo(repo)
335
+ except UnknownObjectException:
336
+ logging.warning(
337
+ "Configured repository '%s' for team '%s' has not been "
338
+ "found in the organisation",
339
+ repo,
340
+ team.name,
341
+ )
342
+ continue
343
+
344
+ if perm != self.current_repos[repo].get(team):
345
+ # Add the changeset to the changelist
346
+ if team not in team_changelist:
347
+ team_changelist[team] = {}
348
+ team_changelist[team][repo] = perm
349
+
350
+ return team_changelist
351
+
352
+ def sync_repo_permissions(self, dry: bool = False) -> None:
353
+ """Synchronise the repository permissions of all teams"""
354
+ logging.debug("Starting to sync repo/team permissions")
355
+
356
+ # Get all repos and their current permissions from GitHub
357
+ self._get_current_repos_and_perms()
358
+
359
+ # Find differences between configured permissions for a team's repo and the current state
360
+ for team, repos in self._create_perms_changelist_for_teams().items():
361
+ for repo, perm in repos.items():
362
+ logging.info(
363
+ "Changing permission of repository '%s' for team '%s' to '%s'",
364
+ repo.name,
365
+ team.name,
366
+ perm,
367
+ )
368
+ if not dry:
369
+ # Update permissions or newly add a team to a repo
370
+ team.update_team_repository(repo, perm)
371
+
372
+ # Find out whether repos' permissions contain *configured* teams that
373
+ # should not have permissions
374
+ for repo, teams in self.current_repos.items():
375
+ for team in teams:
376
+ # Get configured repos for this team, finding out whether repo
377
+ # is configured for this team
378
+ remove = False
379
+ # Handle: Team is not configured at all
380
+ if team.name not in self.configured_teams:
381
+ logging.warning(
382
+ "Team '%s' has permissions on repository '%s', but this team "
383
+ "is not configured locally",
384
+ team.name,
385
+ repo.name,
386
+ )
387
+ continue
388
+ # Handle: Team is configured, but contains no config
389
+ if self.configured_teams[team.name] is None:
390
+ remove = True
391
+ # Handle: Team is configured, contains config
392
+ elif repos := self.configured_teams[team.name].get("repos", []): # type: ignore
393
+ # If this repo has not been found in the configured repos
394
+ # for the team, remove all permissions
395
+ if repo.name not in repos:
396
+ remove = True
397
+ # Handle: Team is configured, contains config, but no "repos" key
398
+ else:
399
+ remove = True
400
+
401
+ # Remove if any mismatch has been found
402
+ if remove:
403
+ logging.info("Removing team '%s' from repository '%s'", team.name, repo.name)
404
+ if not dry:
405
+ team.remove_from_repos(repo)
gh_org_mgr/manage.py ADDED
@@ -0,0 +1,66 @@
1
+ # SPDX-FileCopyrightText: 2024 DB Systel GmbH
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ """Manage a GitHub Organization, its teams, repository permissions, and more"""
6
+
7
+ import argparse
8
+ import logging
9
+ import sys
10
+
11
+ from . import __version__, configure_logger
12
+ from ._config import parse_config_files
13
+ from ._gh_org import GHorg
14
+
15
+ parser = argparse.ArgumentParser(
16
+ description=__doc__, formatter_class=argparse.ArgumentDefaultsHelpFormatter
17
+ )
18
+ parser.add_argument(
19
+ "-c",
20
+ "--config",
21
+ required=True,
22
+ help="Path to the directory in which the configuration of an GitHub organisation is located",
23
+ )
24
+ parser.add_argument("--debug", action="store_true", help="Get verbose logging output")
25
+ parser.add_argument("--dry", action="store_true", help="Do not make any changes at GitHub")
26
+ parser.add_argument("--version", action="version", version="GitHub Team Manager " + __version__)
27
+
28
+
29
+ def main():
30
+ """Main function"""
31
+
32
+ # Process arguments
33
+ args = parser.parse_args()
34
+
35
+ configure_logger(args.debug)
36
+
37
+ if args.dry:
38
+ logging.info("Dry-run mode activated, will not make any changes at GitHub")
39
+
40
+ org = GHorg()
41
+
42
+ # Parse configuration folder, and do sanity check
43
+ cfg_org, cfg_app, org.configured_teams = parse_config_files(args.config)
44
+ if not cfg_org.get("org_name"):
45
+ logging.critical(
46
+ "No GitHub organisation name configured in organisation settings. Cannot continue"
47
+ )
48
+ sys.exit(1)
49
+
50
+ # Login to GitHub with token, get GitHub organisation
51
+ org.login(cfg_org.get("org_name", ""), cfg_app.get("github_token", ""))
52
+ # Get current rate limit
53
+ org.ratelimit()
54
+
55
+ # Create teams that aren't present at Github yet
56
+ org.create_missing_teams(dry=args.dry)
57
+ # Synchronise the team memberships
58
+ org.sync_teams_members(dry=args.dry)
59
+ # Report about organisation members that do not belong to any team
60
+ org.get_members_without_team()
61
+ # Synchronise the permissions of teams for all repositories
62
+ org.sync_repo_permissions(dry=args.dry)
63
+
64
+ # Debug output
65
+ logging.debug("Final dataclass:\n%s", org.df2json())
66
+ org.ratelimit()