PFERD 3.6.0__tar.gz

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.
Files changed (49) hide show
  1. pferd-3.6.0/LICENSE +20 -0
  2. pferd-3.6.0/PFERD/__init__.py +0 -0
  3. pferd-3.6.0/PFERD/__main__.py +169 -0
  4. pferd-3.6.0/PFERD/auth/__init__.py +29 -0
  5. pferd-3.6.0/PFERD/auth/authenticator.py +80 -0
  6. pferd-3.6.0/PFERD/auth/credential_file.py +46 -0
  7. pferd-3.6.0/PFERD/auth/keyring.py +65 -0
  8. pferd-3.6.0/PFERD/auth/pass_.py +98 -0
  9. pferd-3.6.0/PFERD/auth/simple.py +62 -0
  10. pferd-3.6.0/PFERD/auth/tfa.py +30 -0
  11. pferd-3.6.0/PFERD/cli/__init__.py +14 -0
  12. pferd-3.6.0/PFERD/cli/command_ilias_web.py +56 -0
  13. pferd-3.6.0/PFERD/cli/command_kit_ilias_web.py +37 -0
  14. pferd-3.6.0/PFERD/cli/command_kit_ipd.py +54 -0
  15. pferd-3.6.0/PFERD/cli/command_local.py +70 -0
  16. pferd-3.6.0/PFERD/cli/common_ilias_args.py +104 -0
  17. pferd-3.6.0/PFERD/cli/parser.py +245 -0
  18. pferd-3.6.0/PFERD/config.py +193 -0
  19. pferd-3.6.0/PFERD/crawl/__init__.py +27 -0
  20. pferd-3.6.0/PFERD/crawl/crawler.py +369 -0
  21. pferd-3.6.0/PFERD/crawl/http_crawler.py +199 -0
  22. pferd-3.6.0/PFERD/crawl/ilias/__init__.py +9 -0
  23. pferd-3.6.0/PFERD/crawl/ilias/async_helper.py +39 -0
  24. pferd-3.6.0/PFERD/crawl/ilias/file_templates.py +201 -0
  25. pferd-3.6.0/PFERD/crawl/ilias/ilias_html_cleaner.py +91 -0
  26. pferd-3.6.0/PFERD/crawl/ilias/ilias_web_crawler.py +1009 -0
  27. pferd-3.6.0/PFERD/crawl/ilias/kit_ilias_html.py +1297 -0
  28. pferd-3.6.0/PFERD/crawl/ilias/kit_ilias_web_crawler.py +229 -0
  29. pferd-3.6.0/PFERD/crawl/kit_ipd_crawler.py +170 -0
  30. pferd-3.6.0/PFERD/crawl/local_crawler.py +117 -0
  31. pferd-3.6.0/PFERD/deduplicator.py +85 -0
  32. pferd-3.6.0/PFERD/limiter.py +97 -0
  33. pferd-3.6.0/PFERD/logging.py +291 -0
  34. pferd-3.6.0/PFERD/output_dir.py +518 -0
  35. pferd-3.6.0/PFERD/pferd.py +194 -0
  36. pferd-3.6.0/PFERD/report.py +238 -0
  37. pferd-3.6.0/PFERD/transformer.py +443 -0
  38. pferd-3.6.0/PFERD/utils.py +144 -0
  39. pferd-3.6.0/PFERD/version.py +2 -0
  40. pferd-3.6.0/PFERD.egg-info/PKG-INFO +10 -0
  41. pferd-3.6.0/PFERD.egg-info/SOURCES.txt +47 -0
  42. pferd-3.6.0/PFERD.egg-info/dependency_links.txt +1 -0
  43. pferd-3.6.0/PFERD.egg-info/entry_points.txt +2 -0
  44. pferd-3.6.0/PFERD.egg-info/requires.txt +5 -0
  45. pferd-3.6.0/PFERD.egg-info/top_level.txt +1 -0
  46. pferd-3.6.0/PKG-INFO +10 -0
  47. pferd-3.6.0/README.md +158 -0
  48. pferd-3.6.0/pyproject.toml +42 -0
  49. pferd-3.6.0/setup.cfg +4 -0
pferd-3.6.0/LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright 2019-2024 Garmelon, I-Al-Istannen, danstooamerican, pavelzw,
2
+ TheChristophe, Scriptim, thelukasprobst, Toorero,
3
+ Mr-Pine, p-fruck
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of
6
+ this software and associated documentation files (the "Software"), to deal in
7
+ the Software without restriction, including without limitation the rights to
8
+ use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
9
+ the Software, and to permit persons to whom the Software is furnished to do so,
10
+ subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
17
+ FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
18
+ COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
19
+ IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
20
+ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
File without changes
@@ -0,0 +1,169 @@
1
+ import argparse
2
+ import asyncio
3
+ import configparser
4
+ import os
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ from .auth import AuthLoadError
9
+ from .cli import PARSER, ParserLoadError, load_default_section
10
+ from .config import Config, ConfigDumpError, ConfigLoadError, ConfigOptionError
11
+ from .logging import log
12
+ from .pferd import Pferd, PferdLoadError
13
+ from .transformer import RuleParseError
14
+
15
+
16
+ def load_config_parser(args: argparse.Namespace) -> configparser.ConfigParser:
17
+ log.explain_topic("Loading config")
18
+ parser = configparser.ConfigParser(interpolation=None)
19
+
20
+ if args.command is None:
21
+ log.explain("No CLI command specified, loading config from file")
22
+ Config.load_parser(parser, path=args.config)
23
+ else:
24
+ log.explain("CLI command specified, loading config from its arguments")
25
+ if args.command:
26
+ args.command(args, parser)
27
+
28
+ load_default_section(args, parser)
29
+
30
+ return parser
31
+
32
+
33
+ def load_config(args: argparse.Namespace) -> Config:
34
+ try:
35
+ return Config(load_config_parser(args))
36
+ except ConfigLoadError as e:
37
+ log.error(str(e))
38
+ log.error_contd(e.reason)
39
+ sys.exit(1)
40
+ except ParserLoadError as e:
41
+ log.error(str(e))
42
+ sys.exit(1)
43
+
44
+
45
+ def configure_logging_from_args(args: argparse.Namespace) -> None:
46
+ if args.explain is not None:
47
+ log.output_explain = args.explain
48
+ if args.status is not None:
49
+ log.output_status = args.status
50
+ if args.show_not_deleted is not None:
51
+ log.output_not_deleted = args.show_not_deleted
52
+ if args.report is not None:
53
+ log.output_report = args.report
54
+
55
+ # We want to prevent any unnecessary output if we're printing the config to
56
+ # stdout, otherwise it would not be a valid config file.
57
+ if args.dump_config_to == "-":
58
+ log.output_explain = False
59
+ log.output_status = False
60
+ log.output_report = False
61
+
62
+
63
+ def configure_logging_from_config(args: argparse.Namespace, config: Config) -> None:
64
+ # In configure_logging_from_args(), all normal logging is already disabled
65
+ # whenever we dump the config. We don't want to override that decision with
66
+ # values from the config file.
67
+ if args.dump_config_to == "-":
68
+ return
69
+
70
+ try:
71
+ if args.explain is None:
72
+ log.output_explain = config.default_section.explain()
73
+ if args.status is None:
74
+ log.output_status = config.default_section.status()
75
+ if args.report is None:
76
+ log.output_report = config.default_section.report()
77
+ if args.show_not_deleted is None:
78
+ log.output_not_deleted = config.default_section.show_not_deleted()
79
+ except ConfigOptionError as e:
80
+ log.error(str(e))
81
+ sys.exit(1)
82
+
83
+
84
+ def dump_config(args: argparse.Namespace, config: Config) -> None:
85
+ log.explain_topic("Dumping config")
86
+
87
+ if args.dump_config and args.dump_config_to is not None:
88
+ log.error("--dump-config and --dump-config-to can't be specified at the same time")
89
+ sys.exit(1)
90
+
91
+ try:
92
+ if args.dump_config:
93
+ config.dump()
94
+ elif args.dump_config_to == "-":
95
+ config.dump_to_stdout()
96
+ else:
97
+ config.dump(Path(args.dump_config_to))
98
+ except ConfigDumpError as e:
99
+ log.error(str(e))
100
+ log.error_contd(e.reason)
101
+ sys.exit(1)
102
+
103
+
104
+ def main() -> None:
105
+ args = PARSER.parse_args()
106
+
107
+ # Configuring logging happens in two stages because CLI args have
108
+ # precedence over config file options and loading the config already
109
+ # produces some kinds of log messages (usually only explain()-s).
110
+ configure_logging_from_args(args)
111
+
112
+ config = load_config(args)
113
+
114
+ # Now, after loading the config file, we can apply its logging settings in
115
+ # all places that were not already covered by CLI args.
116
+ configure_logging_from_config(args, config)
117
+
118
+ if args.dump_config or args.dump_config_to is not None:
119
+ dump_config(args, config)
120
+ sys.exit()
121
+
122
+ try:
123
+ pferd = Pferd(config, args.crawler, args.skip)
124
+ except PferdLoadError as e:
125
+ log.unlock()
126
+ log.error(str(e))
127
+ sys.exit(1)
128
+
129
+ try:
130
+ if os.name == "nt":
131
+ # A "workaround" for the windows event loop somehow crashing after
132
+ # asyncio.run() completes. See:
133
+ # https://bugs.python.org/issue39232
134
+ # https://github.com/encode/httpx/issues/914#issuecomment-780023632
135
+ # TODO Fix this properly
136
+ loop = asyncio.get_event_loop()
137
+ loop.run_until_complete(pferd.run(args.debug_transforms))
138
+ loop.run_until_complete(asyncio.sleep(1))
139
+ loop.close()
140
+ else:
141
+ asyncio.run(pferd.run(args.debug_transforms))
142
+ except (ConfigOptionError, AuthLoadError) as e:
143
+ log.unlock()
144
+ log.error(str(e))
145
+ sys.exit(1)
146
+ except RuleParseError as e:
147
+ log.unlock()
148
+ e.pretty_print()
149
+ sys.exit(1)
150
+ except KeyboardInterrupt:
151
+ log.unlock()
152
+ log.explain_topic("Interrupted, exiting immediately")
153
+ log.explain("Open files and connections are left for the OS to clean up")
154
+ pferd.print_report()
155
+ # TODO Clean up tmp files
156
+ # And when those files *do* actually get cleaned up properly,
157
+ # reconsider if this should really exit with 1
158
+ sys.exit(1)
159
+ except Exception:
160
+ log.unlock()
161
+ log.unexpected_exception()
162
+ pferd.print_report()
163
+ sys.exit(1)
164
+ else:
165
+ pferd.print_report()
166
+
167
+
168
+ if __name__ == "__main__":
169
+ main()
@@ -0,0 +1,29 @@
1
+ from configparser import SectionProxy
2
+ from typing import Callable, Dict
3
+
4
+ from ..config import Config
5
+ from .authenticator import Authenticator, AuthError, AuthLoadError, AuthSection # noqa: F401
6
+ from .credential_file import CredentialFileAuthenticator, CredentialFileAuthSection
7
+ from .keyring import KeyringAuthenticator, KeyringAuthSection
8
+ from .pass_ import PassAuthenticator, PassAuthSection
9
+ from .simple import SimpleAuthenticator, SimpleAuthSection
10
+ from .tfa import TfaAuthenticator
11
+
12
+ AuthConstructor = Callable[[
13
+ str, # Name (without the "auth:" prefix)
14
+ SectionProxy, # Authenticator's section of global config
15
+ Config, # Global config
16
+ ], Authenticator]
17
+
18
+ AUTHENTICATORS: Dict[str, AuthConstructor] = {
19
+ "credential-file": lambda n, s, c:
20
+ CredentialFileAuthenticator(n, CredentialFileAuthSection(s), c),
21
+ "keyring": lambda n, s, c:
22
+ KeyringAuthenticator(n, KeyringAuthSection(s)),
23
+ "pass": lambda n, s, c:
24
+ PassAuthenticator(n, PassAuthSection(s)),
25
+ "simple": lambda n, s, c:
26
+ SimpleAuthenticator(n, SimpleAuthSection(s)),
27
+ "tfa": lambda n, s, c:
28
+ TfaAuthenticator(n),
29
+ }
@@ -0,0 +1,80 @@
1
+ from abc import ABC, abstractmethod
2
+ from typing import Tuple
3
+
4
+ from ..config import Section
5
+
6
+
7
+ class AuthLoadError(Exception):
8
+ pass
9
+
10
+
11
+ class AuthError(Exception):
12
+ pass
13
+
14
+
15
+ class AuthSection(Section):
16
+ def type(self) -> str:
17
+ value = self.s.get("type")
18
+ if value is None:
19
+ self.missing_value("type")
20
+ return value
21
+
22
+
23
+ class Authenticator(ABC):
24
+ def __init__(self, name: str) -> None:
25
+ """
26
+ Initialize an authenticator from its name and its section in the config
27
+ file.
28
+
29
+ If you are writing your own constructor for your own authenticator,
30
+ make sure to call this constructor first (via super().__init__).
31
+
32
+ May throw an AuthLoadError.
33
+ """
34
+
35
+ self.name = name
36
+
37
+ @abstractmethod
38
+ async def credentials(self) -> Tuple[str, str]:
39
+ pass
40
+
41
+ async def username(self) -> str:
42
+ username, _ = await self.credentials()
43
+ return username
44
+
45
+ async def password(self) -> str:
46
+ _, password = await self.credentials()
47
+ return password
48
+
49
+ def invalidate_credentials(self) -> None:
50
+ """
51
+ Tell the authenticator that some or all of its credentials are invalid.
52
+
53
+ Authenticators should overwrite this function if they have a way to
54
+ deal with this issue that is likely to result in valid credentials
55
+ (e. g. prompting the user).
56
+ """
57
+
58
+ raise AuthError("Invalid credentials")
59
+
60
+ def invalidate_username(self) -> None:
61
+ """
62
+ Tell the authenticator that specifically its username is invalid.
63
+
64
+ Authenticators should overwrite this function if they have a way to
65
+ deal with this issue that is likely to result in valid credentials
66
+ (e. g. prompting the user).
67
+ """
68
+
69
+ raise AuthError("Invalid username")
70
+
71
+ def invalidate_password(self) -> None:
72
+ """
73
+ Tell the authenticator that specifically its password is invalid.
74
+
75
+ Authenticators should overwrite this function if they have a way to
76
+ deal with this issue that is likely to result in valid credentials
77
+ (e. g. prompting the user).
78
+ """
79
+
80
+ raise AuthError("Invalid password")
@@ -0,0 +1,46 @@
1
+ from pathlib import Path
2
+ from typing import Tuple
3
+
4
+ from ..config import Config
5
+ from ..utils import fmt_real_path
6
+ from .authenticator import Authenticator, AuthLoadError, AuthSection
7
+
8
+
9
+ class CredentialFileAuthSection(AuthSection):
10
+ def path(self) -> Path:
11
+ value = self.s.get("path")
12
+ if value is None:
13
+ self.missing_value("path")
14
+ return Path(value)
15
+
16
+
17
+ class CredentialFileAuthenticator(Authenticator):
18
+ def __init__(self, name: str, section: CredentialFileAuthSection, config: Config) -> None:
19
+ super().__init__(name)
20
+
21
+ path = config.default_section.working_dir() / section.path()
22
+ try:
23
+ with open(path, encoding="utf-8") as f:
24
+ lines = list(f)
25
+ except UnicodeDecodeError:
26
+ raise AuthLoadError(f"Credential file at {fmt_real_path(path)} is not encoded using UTF-8")
27
+ except OSError as e:
28
+ raise AuthLoadError(f"No credential file at {fmt_real_path(path)}") from e
29
+
30
+ if len(lines) != 2:
31
+ raise AuthLoadError("Credential file must be two lines long")
32
+ [uline, pline] = lines
33
+ uline = uline[:-1] # Remove trailing newline
34
+ if pline.endswith("\n"):
35
+ pline = pline[:-1]
36
+
37
+ if not uline.startswith("username="):
38
+ raise AuthLoadError("First line must start with 'username='")
39
+ if not pline.startswith("password="):
40
+ raise AuthLoadError("Second line must start with 'password='")
41
+
42
+ self._username = uline[9:]
43
+ self._password = pline[9:]
44
+
45
+ async def credentials(self) -> Tuple[str, str]:
46
+ return self._username, self._password
@@ -0,0 +1,65 @@
1
+ from typing import Optional, Tuple
2
+
3
+ import keyring
4
+
5
+ from ..logging import log
6
+ from ..utils import agetpass, ainput
7
+ from ..version import NAME
8
+ from .authenticator import Authenticator, AuthError, AuthSection
9
+
10
+
11
+ class KeyringAuthSection(AuthSection):
12
+ def username(self) -> Optional[str]:
13
+ return self.s.get("username")
14
+
15
+ def keyring_name(self) -> str:
16
+ return self.s.get("keyring_name", fallback=NAME)
17
+
18
+
19
+ class KeyringAuthenticator(Authenticator):
20
+
21
+ def __init__(self, name: str, section: KeyringAuthSection) -> None:
22
+ super().__init__(name)
23
+
24
+ self._username = section.username()
25
+ self._password: Optional[str] = None
26
+ self._keyring_name = section.keyring_name()
27
+
28
+ self._password_invalidated = False
29
+ self._username_fixed = section.username() is not None
30
+
31
+ async def credentials(self) -> Tuple[str, str]:
32
+ # Request the username
33
+ if self._username is None:
34
+ async with log.exclusive_output():
35
+ self._username = await ainput("Username: ")
36
+
37
+ # First try looking it up in the keyring.
38
+ # Do not look it up if it was invalidated - we want to re-prompt in this case
39
+ if self._password is None and not self._password_invalidated:
40
+ self._password = keyring.get_password(self._keyring_name, self._username)
41
+
42
+ # If that fails it wasn't saved in the keyring - we need to
43
+ # read it from the user and store it
44
+ if self._password is None:
45
+ async with log.exclusive_output():
46
+ self._password = await agetpass("Password: ")
47
+ keyring.set_password(self._keyring_name, self._username, self._password)
48
+
49
+ self._password_invalidated = False
50
+ return self._username, self._password
51
+
52
+ def invalidate_credentials(self) -> None:
53
+ if not self._username_fixed:
54
+ self.invalidate_username()
55
+ self.invalidate_password()
56
+
57
+ def invalidate_username(self) -> None:
58
+ if self._username_fixed:
59
+ raise AuthError("Configured username is invalid")
60
+ else:
61
+ self._username = None
62
+
63
+ def invalidate_password(self) -> None:
64
+ self._password = None
65
+ self._password_invalidated = True
@@ -0,0 +1,98 @@
1
+ import re
2
+ import subprocess
3
+ from typing import List, Tuple
4
+
5
+ from ..logging import log
6
+ from .authenticator import Authenticator, AuthError, AuthSection
7
+
8
+
9
+ class PassAuthSection(AuthSection):
10
+ def passname(self) -> str:
11
+ if (value := self.s.get("passname")) is None:
12
+ self.missing_value("passname")
13
+ return value
14
+
15
+ def username_prefixes(self) -> List[str]:
16
+ value = self.s.get("username_prefixes", "login,username,user")
17
+ return [prefix.lower() for prefix in value.split(",")]
18
+
19
+ def password_prefixes(self) -> List[str]:
20
+ value = self.s.get("password_prefixes", "password,pass,secret")
21
+ return [prefix.lower() for prefix in value.split(",")]
22
+
23
+
24
+ class PassAuthenticator(Authenticator):
25
+ PREFIXED_LINE_RE = r"([a-zA-Z]+):\s?(.*)" # to be used with fullmatch
26
+
27
+ def __init__(self, name: str, section: PassAuthSection) -> None:
28
+ super().__init__(name)
29
+
30
+ self._passname = section.passname()
31
+ self._username_prefixes = section.username_prefixes()
32
+ self._password_prefixes = section.password_prefixes()
33
+
34
+ async def credentials(self) -> Tuple[str, str]:
35
+ log.explain_topic("Obtaining credentials from pass")
36
+
37
+ try:
38
+ log.explain(f"Calling 'pass show {self._passname}'")
39
+ result = subprocess.check_output(["pass", "show", self._passname], text=True)
40
+ except subprocess.CalledProcessError as e:
41
+ raise AuthError(f"Failed to get password info from {self._passname}: {e}")
42
+
43
+ prefixed = {}
44
+ unprefixed = []
45
+ for line in result.strip().splitlines():
46
+ if match := re.fullmatch(self.PREFIXED_LINE_RE, line):
47
+ prefix = match.group(1).lower()
48
+ value = match.group(2)
49
+ log.explain(f"Found prefixed line {line!r} with prefix {prefix!r}, value {value!r}")
50
+ if prefix in prefixed:
51
+ raise AuthError(f"Prefix {prefix} specified multiple times")
52
+ prefixed[prefix] = value
53
+ else:
54
+ log.explain(f"Found unprefixed line {line!r}")
55
+ unprefixed.append(line)
56
+
57
+ username = None
58
+ for prefix in self._username_prefixes:
59
+ log.explain(f"Looking for username at prefix {prefix!r}")
60
+ if prefix in prefixed:
61
+ username = prefixed[prefix]
62
+ log.explain(f"Found username {username!r}")
63
+ break
64
+
65
+ password = None
66
+ for prefix in self._password_prefixes:
67
+ log.explain(f"Looking for password at prefix {prefix!r}")
68
+ if prefix in prefixed:
69
+ password = prefixed[prefix]
70
+ log.explain(f"Found password {password!r}")
71
+ break
72
+
73
+ if password is None and username is None:
74
+ log.explain("No username and password found so far")
75
+ log.explain("Using first unprefixed line as password")
76
+ log.explain("Using second unprefixed line as username")
77
+ elif password is None:
78
+ log.explain("No password found so far")
79
+ log.explain("Using first unprefixed line as password")
80
+ elif username is None:
81
+ log.explain("No username found so far")
82
+ log.explain("Using first unprefixed line as username")
83
+
84
+ if password is None:
85
+ if not unprefixed:
86
+ log.explain("Not enough unprefixed lines left")
87
+ raise AuthError("Password could not be determined")
88
+ password = unprefixed.pop(0)
89
+ log.explain(f"Found password {password!r}")
90
+
91
+ if username is None:
92
+ if not unprefixed:
93
+ log.explain("Not enough unprefixed lines left")
94
+ raise AuthError("Username could not be determined")
95
+ username = unprefixed.pop(0)
96
+ log.explain(f"Found username {username!r}")
97
+
98
+ return username, password
@@ -0,0 +1,62 @@
1
+ from typing import Optional, Tuple
2
+
3
+ from ..logging import log
4
+ from ..utils import agetpass, ainput
5
+ from .authenticator import Authenticator, AuthError, AuthSection
6
+
7
+
8
+ class SimpleAuthSection(AuthSection):
9
+ def username(self) -> Optional[str]:
10
+ return self.s.get("username")
11
+
12
+ def password(self) -> Optional[str]:
13
+ return self.s.get("password")
14
+
15
+
16
+ class SimpleAuthenticator(Authenticator):
17
+ def __init__(self, name: str, section: SimpleAuthSection) -> None:
18
+ super().__init__(name)
19
+
20
+ self._username = section.username()
21
+ self._password = section.password()
22
+
23
+ self._username_fixed = self.username is not None
24
+ self._password_fixed = self.password is not None
25
+
26
+ async def credentials(self) -> Tuple[str, str]:
27
+ if self._username is not None and self._password is not None:
28
+ return self._username, self._password
29
+
30
+ async with log.exclusive_output():
31
+ if self._username is None:
32
+ self._username = await ainput("Username: ")
33
+ else:
34
+ print(f"Username: {self._username}")
35
+
36
+ if self._password is None:
37
+ self._password = await agetpass("Password: ")
38
+
39
+ # Intentionally returned inside the context manager so we know
40
+ # they're both not None
41
+ return self._username, self._password
42
+
43
+ def invalidate_credentials(self) -> None:
44
+ if self._username_fixed and self._password_fixed:
45
+ raise AuthError("Configured credentials are invalid")
46
+
47
+ if not self._username_fixed:
48
+ self._username = None
49
+ if not self._password_fixed:
50
+ self._password = None
51
+
52
+ def invalidate_username(self) -> None:
53
+ if self._username_fixed:
54
+ raise AuthError("Configured username is invalid")
55
+ else:
56
+ self._username = None
57
+
58
+ def invalidate_password(self) -> None:
59
+ if self._password_fixed:
60
+ raise AuthError("Configured password is invalid")
61
+ else:
62
+ self._password = None
@@ -0,0 +1,30 @@
1
+ from typing import Tuple
2
+
3
+ from ..logging import log
4
+ from ..utils import ainput
5
+ from .authenticator import Authenticator, AuthError
6
+
7
+
8
+ class TfaAuthenticator(Authenticator):
9
+ def __init__(self, name: str) -> None:
10
+ super().__init__(name)
11
+
12
+ async def username(self) -> str:
13
+ raise AuthError("TFA authenticator does not support usernames")
14
+
15
+ async def password(self) -> str:
16
+ async with log.exclusive_output():
17
+ code = await ainput("TFA code: ")
18
+ return code
19
+
20
+ async def credentials(self) -> Tuple[str, str]:
21
+ raise AuthError("TFA authenticator does not support usernames")
22
+
23
+ def invalidate_username(self) -> None:
24
+ raise AuthError("TFA authenticator does not support usernames")
25
+
26
+ def invalidate_password(self) -> None:
27
+ pass
28
+
29
+ def invalidate_credentials(self) -> None:
30
+ pass
@@ -0,0 +1,14 @@
1
+ # isort: skip_file
2
+
3
+ # The order of imports matters because each command module registers itself
4
+ # with the parser from ".parser" and the import order affects the order in
5
+ # which they appear in the help. Because of this, isort is disabled for this
6
+ # file. Also, since we're reexporting or just using the side effect of
7
+ # importing itself, we get a few linting warnings, which we're disabling as
8
+ # well.
9
+
10
+ from . import command_local # noqa: F401 imported but unused
11
+ from . import command_ilias_web # noqa: F401 imported but unused
12
+ from . import command_kit_ilias_web # noqa: F401 imported but unused
13
+ from . import command_kit_ipd # noqa: F401 imported but unused
14
+ from .parser import PARSER, ParserLoadError, load_default_section # noqa: F401 imported but unused
@@ -0,0 +1,56 @@
1
+ import argparse
2
+ import configparser
3
+
4
+ from ..logging import log
5
+ from .common_ilias_args import configure_common_group_args, load_common
6
+ from .parser import CRAWLER_PARSER, SUBPARSERS, load_crawler
7
+
8
+ COMMAND_NAME = "ilias-web"
9
+
10
+ SUBPARSER = SUBPARSERS.add_parser(
11
+ COMMAND_NAME,
12
+ parents=[CRAWLER_PARSER],
13
+ )
14
+
15
+ GROUP = SUBPARSER.add_argument_group(
16
+ title=f"{COMMAND_NAME} crawler arguments",
17
+ description=f"arguments for the '{COMMAND_NAME}' crawler",
18
+ )
19
+
20
+ GROUP.add_argument(
21
+ "--base-url",
22
+ type=str,
23
+ metavar="BASE_URL",
24
+ help="The base url of the ilias instance"
25
+ )
26
+
27
+ GROUP.add_argument(
28
+ "--client-id",
29
+ type=str,
30
+ metavar="CLIENT_ID",
31
+ help="The client id of the ilias instance"
32
+ )
33
+
34
+ configure_common_group_args(GROUP)
35
+
36
+
37
+ def load(
38
+ args: argparse.Namespace,
39
+ parser: configparser.ConfigParser,
40
+ ) -> None:
41
+ log.explain(f"Creating config for command '{COMMAND_NAME}'")
42
+
43
+ parser["crawl:ilias"] = {}
44
+ section = parser["crawl:ilias"]
45
+ load_crawler(args, section)
46
+
47
+ section["type"] = COMMAND_NAME
48
+ if args.ilias_url is not None:
49
+ section["base_url"] = args.ilias_url
50
+ if args.client_id is not None:
51
+ section["client_id"] = args.client_id
52
+
53
+ load_common(section, args, parser)
54
+
55
+
56
+ SUBPARSER.set_defaults(command=load)