mercuto-client 0.3.4a1__tar.gz → 0.3.5__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 (58) hide show
  1. {mercuto_client-0.3.4a1 → mercuto_client-0.3.5}/PKG-INFO +2 -1
  2. mercuto_client-0.3.5/mercuto_client/_tests/test_ingester/test_backup.py +98 -0
  3. {mercuto_client-0.3.4a1 → mercuto_client-0.3.5}/mercuto_client/client.py +1 -1
  4. mercuto_client-0.3.5/mercuto_client/ingester/__main__.py +275 -0
  5. mercuto_client-0.3.5/mercuto_client/ingester/backup.py +340 -0
  6. {mercuto_client-0.3.4a1 → mercuto_client-0.3.5}/mercuto_client/ingester/mercuto.py +18 -3
  7. mercuto_client-0.3.5/mercuto_client/ingester/pid_file.py +66 -0
  8. {mercuto_client-0.3.4a1 → mercuto_client-0.3.5}/mercuto_client/mocks/mock_notifications.py +2 -2
  9. {mercuto_client-0.3.4a1 → mercuto_client-0.3.5}/mercuto_client/modules/core.py +32 -0
  10. {mercuto_client-0.3.4a1 → mercuto_client-0.3.5}/mercuto_client/modules/notifications.py +3 -3
  11. mercuto_client-0.3.5/mercuto_client/modules/reports.py +222 -0
  12. {mercuto_client-0.3.4a1 → mercuto_client-0.3.5}/mercuto_client.egg-info/PKG-INFO +2 -1
  13. {mercuto_client-0.3.4a1 → mercuto_client-0.3.5}/mercuto_client.egg-info/SOURCES.txt +4 -0
  14. mercuto_client-0.3.5/mercuto_client.egg-info/entry_points.txt +3 -0
  15. {mercuto_client-0.3.4a1 → mercuto_client-0.3.5}/mercuto_client.egg-info/requires.txt +1 -0
  16. {mercuto_client-0.3.4a1 → mercuto_client-0.3.5}/pyproject.toml +9 -1
  17. mercuto_client-0.3.4a1/mercuto_client/ingester/__main__.py +0 -166
  18. mercuto_client-0.3.4a1/mercuto_client/modules/reports.py +0 -185
  19. {mercuto_client-0.3.4a1 → mercuto_client-0.3.5}/LICENSE +0 -0
  20. {mercuto_client-0.3.4a1 → mercuto_client-0.3.5}/README.md +0 -0
  21. {mercuto_client-0.3.4a1 → mercuto_client-0.3.5}/mercuto_client/__init__.py +0 -0
  22. {mercuto_client-0.3.4a1 → mercuto_client-0.3.5}/mercuto_client/_authentication.py +0 -0
  23. {mercuto_client-0.3.4a1 → mercuto_client-0.3.5}/mercuto_client/_tests/__init__.py +0 -0
  24. {mercuto_client-0.3.4a1 → mercuto_client-0.3.5}/mercuto_client/_tests/conftest.py +0 -0
  25. {mercuto_client-0.3.4a1 → mercuto_client-0.3.5}/mercuto_client/_tests/test_ingester/__init__.py +0 -0
  26. {mercuto_client-0.3.4a1 → mercuto_client-0.3.5}/mercuto_client/_tests/test_ingester/test_file_processor.py +0 -0
  27. {mercuto_client-0.3.4a1 → mercuto_client-0.3.5}/mercuto_client/_tests/test_ingester/test_ftp.py +0 -0
  28. {mercuto_client-0.3.4a1 → mercuto_client-0.3.5}/mercuto_client/_tests/test_ingester/test_parsers.py +0 -0
  29. {mercuto_client-0.3.4a1 → mercuto_client-0.3.5}/mercuto_client/_tests/test_mocking/__init__.py +0 -0
  30. {mercuto_client-0.3.4a1 → mercuto_client-0.3.5}/mercuto_client/_tests/test_mocking/conftest.py +0 -0
  31. {mercuto_client-0.3.4a1 → mercuto_client-0.3.5}/mercuto_client/_tests/test_mocking/test_mock_identity.py +0 -0
  32. {mercuto_client-0.3.4a1 → mercuto_client-0.3.5}/mercuto_client/acl.py +0 -0
  33. {mercuto_client-0.3.4a1 → mercuto_client-0.3.5}/mercuto_client/exceptions.py +0 -0
  34. {mercuto_client-0.3.4a1 → mercuto_client-0.3.5}/mercuto_client/ingester/__init__.py +0 -0
  35. {mercuto_client-0.3.4a1 → mercuto_client-0.3.5}/mercuto_client/ingester/ftp.py +0 -0
  36. {mercuto_client-0.3.4a1 → mercuto_client-0.3.5}/mercuto_client/ingester/parsers/__init__.py +0 -0
  37. {mercuto_client-0.3.4a1 → mercuto_client-0.3.5}/mercuto_client/ingester/parsers/campbell.py +0 -0
  38. {mercuto_client-0.3.4a1 → mercuto_client-0.3.5}/mercuto_client/ingester/parsers/generic_csv.py +0 -0
  39. {mercuto_client-0.3.4a1 → mercuto_client-0.3.5}/mercuto_client/ingester/parsers/worldsensing.py +0 -0
  40. {mercuto_client-0.3.4a1 → mercuto_client-0.3.5}/mercuto_client/ingester/processor.py +0 -0
  41. {mercuto_client-0.3.4a1 → mercuto_client-0.3.5}/mercuto_client/mocks/__init__.py +0 -0
  42. {mercuto_client-0.3.4a1 → mercuto_client-0.3.5}/mercuto_client/mocks/_utility.py +0 -0
  43. {mercuto_client-0.3.4a1 → mercuto_client-0.3.5}/mercuto_client/mocks/mock_core.py +0 -0
  44. {mercuto_client-0.3.4a1 → mercuto_client-0.3.5}/mercuto_client/mocks/mock_data.py +0 -0
  45. {mercuto_client-0.3.4a1 → mercuto_client-0.3.5}/mercuto_client/mocks/mock_fatigue.py +0 -0
  46. {mercuto_client-0.3.4a1 → mercuto_client-0.3.5}/mercuto_client/mocks/mock_identity.py +0 -0
  47. {mercuto_client-0.3.4a1 → mercuto_client-0.3.5}/mercuto_client/mocks/mock_media.py +0 -0
  48. {mercuto_client-0.3.4a1 → mercuto_client-0.3.5}/mercuto_client/modules/__init__.py +0 -0
  49. {mercuto_client-0.3.4a1 → mercuto_client-0.3.5}/mercuto_client/modules/_util.py +0 -0
  50. {mercuto_client-0.3.4a1 → mercuto_client-0.3.5}/mercuto_client/modules/data.py +0 -0
  51. {mercuto_client-0.3.4a1 → mercuto_client-0.3.5}/mercuto_client/modules/fatigue.py +0 -0
  52. {mercuto_client-0.3.4a1 → mercuto_client-0.3.5}/mercuto_client/modules/identity.py +0 -0
  53. {mercuto_client-0.3.4a1 → mercuto_client-0.3.5}/mercuto_client/modules/media.py +0 -0
  54. {mercuto_client-0.3.4a1 → mercuto_client-0.3.5}/mercuto_client/py.typed +0 -0
  55. {mercuto_client-0.3.4a1 → mercuto_client-0.3.5}/mercuto_client/util.py +0 -0
  56. {mercuto_client-0.3.4a1 → mercuto_client-0.3.5}/mercuto_client.egg-info/dependency_links.txt +0 -0
  57. {mercuto_client-0.3.4a1 → mercuto_client-0.3.5}/mercuto_client.egg-info/top_level.txt +0 -0
  58. {mercuto_client-0.3.4a1 → mercuto_client-0.3.5}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mercuto-client
3
- Version: 0.3.4a1
3
+ Version: 0.3.5
4
4
  Summary: Library for interfacing with Rockfield's Mercuto API
5
5
  Author-email: Daniel Whipp <daniel.whipp@rocktech.com.au>
6
6
  License-Expression: AGPL-3.0-only
@@ -22,6 +22,7 @@ Requires-Dist: pyftpdlib>=2.0.1
22
22
  Requires-Dist: python-dateutil>=2.9.0.post0
23
23
  Requires-Dist: pytz>=2025.2
24
24
  Requires-Dist: schedule>=1.2.2
25
+ Requires-Dist: zc.lockfile>=4.0
25
26
  Requires-Dist: pydantic>=2.0
26
27
  Dynamic: license-file
27
28
 
@@ -0,0 +1,98 @@
1
+ import os
2
+ import tempfile
3
+ from pathlib import Path
4
+ from threading import Thread
5
+ from typing import Iterator, TypedDict
6
+ from urllib.parse import urlparse
7
+
8
+ import pyftpdlib.authorizers # type: ignore[import-untyped]
9
+ import pyftpdlib.handlers # type: ignore[import-untyped]
10
+ import pyftpdlib.servers # type: ignore[import-untyped]
11
+ import pytest
12
+
13
+ from ...ingester.backup import FileBackup, FTPBackup
14
+
15
+
16
+ def test_file_backup():
17
+ with tempfile.TemporaryDirectory() as temp_dir:
18
+ uri = Path(temp_dir).as_uri()
19
+ bak = FileBackup(urlparse(uri))
20
+ assert bak.process_file(__file__)
21
+
22
+
23
+ def test_file_backup_does_not_exist_create_it():
24
+ with tempfile.TemporaryDirectory() as temp_dir:
25
+ test_dir = Path(temp_dir) / "test_dir"
26
+ uri = test_dir.as_uri() + "?create=true"
27
+ assert not test_dir.exists()
28
+ bak = FileBackup(urlparse(uri))
29
+ assert bak.process_file(__file__)
30
+ dest = test_dir / Path(__file__).name
31
+ assert test_dir.exists()
32
+ assert dest.exists()
33
+
34
+
35
+ def test_file_backup_does_not_exist():
36
+ uri = (Path(__file__).parent / "I_DO_NOT_EXIST").as_uri()
37
+ with pytest.raises(ValueError, match="backup path does not exist"):
38
+ FileBackup(urlparse(uri))
39
+
40
+
41
+ class TestFTPServerConfig(TypedDict):
42
+ host: str
43
+ port: int
44
+ user: str
45
+ passwd: str
46
+ ftp_root: str
47
+
48
+
49
+ @pytest.fixture(scope="function")
50
+ def mock_ftp_server() -> Iterator[TestFTPServerConfig]:
51
+ # Create a temporary directory to act as the FTP root
52
+ temp_dir = tempfile.TemporaryDirectory()
53
+ ftp_root = temp_dir.name
54
+
55
+ # Set up user authentication
56
+ authorizer = pyftpdlib.authorizers.DummyAuthorizer()
57
+ authorizer.add_user("user", "12345", ftp_root, perm="elradfmwMT")
58
+
59
+ # Set up FTP handler
60
+ handler = pyftpdlib.handlers.FTPHandler
61
+ handler.authorizer = authorizer
62
+
63
+ # Start the FTP server in a separate thread
64
+ server = pyftpdlib.servers.FTPServer(("127.0.0.1", 0), handler)
65
+ ip, port = server.socket.getsockname()
66
+
67
+ server_thread = Thread(target=server.serve_forever)
68
+ server_thread.daemon = True
69
+ server_thread.start()
70
+
71
+ yield {
72
+ "host": ip,
73
+ "port": port,
74
+ "user": "user",
75
+ "passwd": "12345",
76
+ "ftp_root": ftp_root
77
+ }
78
+
79
+ server.close_all()
80
+ temp_dir.cleanup()
81
+
82
+
83
+ def test_ftp_backup(mock_ftp_server: TestFTPServerConfig):
84
+ url = f"ftp://{mock_ftp_server['user']}:{mock_ftp_server['passwd']}@{mock_ftp_server['host']}:{mock_ftp_server['port']}/my_dir?create=true"
85
+ os.makedirs(Path(mock_ftp_server['ftp_root']) / "my_dir", exist_ok=True)
86
+ bak = FTPBackup(urlparse(url))
87
+ bak.process_file(__file__)
88
+ assert bak.process_file(__file__)
89
+ dest = Path(mock_ftp_server['ftp_root']) / "my_dir" / Path(__file__).name
90
+ assert dest.exists()
91
+
92
+
93
+ def test_ftp_backup_fails_if_dir_not_exists(mock_ftp_server: TestFTPServerConfig):
94
+ url = f"ftp://{mock_ftp_server['user']}:{mock_ftp_server['passwd']}@{mock_ftp_server['host']}:{mock_ftp_server['port']}/my_dir"
95
+ bak = FTPBackup(urlparse(url))
96
+ assert not bak.process_file(__file__)
97
+ dest = Path(mock_ftp_server['ftp_root']) / "my_dir" / Path(__file__).name
98
+ assert not dest.exists()
@@ -40,7 +40,7 @@ class MercutoClient:
40
40
  if url.endswith('/'):
41
41
  url = url[:-1]
42
42
 
43
- if not url.startswith('https://'):
43
+ if verify_ssl and not url.startswith('https://'):
44
44
  raise ValueError(f'Url must be https, is {url}')
45
45
 
46
46
  self._url = url
@@ -0,0 +1,275 @@
1
+ import argparse
2
+ import logging
3
+ import logging.handlers
4
+ import os
5
+ import signal
6
+ import sys
7
+ import time
8
+ from pathlib import Path
9
+ from typing import Any, Callable, Optional, TypeVar
10
+ from urllib.parse import ParseResult, urlparse
11
+
12
+ import schedule
13
+
14
+ from ..util import get_free_space_excluding_files
15
+ from .backup import get_backup_handler
16
+ from .ftp import simple_ftp_server
17
+ from .mercuto import MercutoIngester
18
+ from .pid_file import PidFile
19
+ from .processor import FileProcessor
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ T = TypeVar('T')
25
+
26
+
27
+ def call_and_log_error(func: Callable[[], T]) -> T | None:
28
+ """
29
+ Call a function and log any exceptions that occur.
30
+ """
31
+ try:
32
+ return func()
33
+ except Exception:
34
+ logging.exception(f"Error in {func.__name__}")
35
+ return None
36
+
37
+
38
+ class Status:
39
+ """
40
+ Status class to handle running state of the ingester.
41
+ """
42
+
43
+ def __init__(self):
44
+ self.running = True
45
+
46
+ def stop(self, code: Any, frame: Any):
47
+ self.running = False
48
+ print("Stopping")
49
+
50
+ def is_running(self):
51
+ return self.running
52
+
53
+
54
+ def launch_mercuto_ingester(
55
+ project: str,
56
+ api_key: str,
57
+ hostname: str = 'https://api.rockfieldcloud.com.au',
58
+ verify_ssl: bool = True,
59
+ pid_file: Optional[Path] = None,
60
+ workdir: Optional[str] = '~/.mercuto-ingester',
61
+ verbose: bool = False,
62
+ logfile: Optional[str] = None,
63
+ directory: Optional[str] = None,
64
+ target_free_space_mb: Optional[float] = None,
65
+ max_files: Optional[int] = None,
66
+ mapping: Optional[str] = None,
67
+ clean: bool = False,
68
+ ftp_server_username: str = 'logger',
69
+ ftp_server_password: str = 'password',
70
+ ftp_server_port: int = 2121,
71
+ ftp_server_rename: bool = True,
72
+ max_attempts: int = 1000,
73
+ backup_location: Optional[list[ParseResult]] = None,
74
+ timezone: Optional[str] = None,
75
+ ):
76
+
77
+ if backup_location is None:
78
+ backup_location = []
79
+
80
+ with PidFile(pid_file):
81
+ if workdir is None:
82
+ workdir = os.path.join(os.path.expanduser('~'), ".mercuto-ingester")
83
+ elif workdir.startswith("~"):
84
+ workdir = os.path.expanduser(workdir)
85
+ else:
86
+ workdir = workdir
87
+ if not os.path.exists(workdir):
88
+ raise ValueError(f"Work directory {workdir} does not exist")
89
+
90
+ os.makedirs(workdir, exist_ok=True)
91
+
92
+ if verbose:
93
+ level = logging.DEBUG
94
+ else:
95
+ level = logging.INFO
96
+
97
+ handlers: list[logging.Handler] = []
98
+ handlers.append(logging.StreamHandler(sys.stderr))
99
+
100
+ if logfile is not None:
101
+ logfile = logfile
102
+ else:
103
+ logfile = os.path.join(workdir, 'log.txt')
104
+ handlers.append(logging.handlers.RotatingFileHandler(
105
+ logfile, maxBytes=1000000, backupCount=3))
106
+
107
+ logging.basicConfig(format='[PID %(process)d] %(asctime)s,%(msecs)d %(name)s %(levelname)s %(message)s',
108
+ datefmt='%d/%m/%Y %H:%M:%S',
109
+ level=level,
110
+ handlers=handlers)
111
+
112
+ if directory is None:
113
+ buffer_directory = os.path.join(workdir, "buffered-files")
114
+ else:
115
+ buffer_directory = directory
116
+ os.makedirs(buffer_directory, exist_ok=True)
117
+
118
+ ftp_dir = os.path.join(workdir, 'temp-ftp-data')
119
+ os.makedirs(ftp_dir, exist_ok=True)
120
+
121
+ if target_free_space_mb is None and max_files is None:
122
+ target_free_space_mb = get_free_space_excluding_files(buffer_directory) * 0.25 // (1024 * 1024) # Convert to MB
123
+ logging.info(f"Target remaining free space set to {target_free_space_mb} MB based on available disk space.")
124
+
125
+ logger.info(f"Using work directory: {workdir}")
126
+
127
+ database_path = os.path.join(workdir, "buffer.db")
128
+ if clean and os.path.exists(database_path):
129
+ logging.info(f"Dropping existing database at {database_path}")
130
+ os.remove(database_path)
131
+
132
+ ingester = MercutoIngester(
133
+ project_code=project,
134
+ api_key=api_key,
135
+ hostname=hostname,
136
+ verify_ssl=verify_ssl,
137
+ timezone=timezone
138
+ )
139
+
140
+ if mapping is not None:
141
+ import json
142
+ with open(mapping, 'r') as f:
143
+ mapping = json.load(f)
144
+ if not isinstance(mapping, dict):
145
+ raise ValueError(f"Mapping file {mapping} must contain a JSON object")
146
+ ingester.update_mapping(mapping)
147
+
148
+ pre_processing_handlers: list[Callable[[str], bool]] = [get_backup_handler(loc) for loc in backup_location]
149
+ processor_callbacks: list[Callable[[str], bool]] = [ingester.process_file]
150
+ post_processing_handlers: list[Callable[[str], bool]] = []
151
+
152
+ all_handlers = pre_processing_handlers + processor_callbacks + post_processing_handlers
153
+
154
+ processor = FileProcessor(
155
+ buffer_dir=buffer_directory,
156
+ db_path=database_path,
157
+ process_callback=lambda filename: all(handler(filename) for handler in all_handlers),
158
+ max_attempts=max_attempts,
159
+ target_free_space_mb=target_free_space_mb,
160
+ max_files=max_files)
161
+
162
+ processor.scan_existing_files()
163
+
164
+ with simple_ftp_server(directory=buffer_directory,
165
+ username=ftp_server_username, password=ftp_server_password, port=ftp_server_port,
166
+ callback=processor.add_file_to_db, rename=ftp_server_rename,
167
+ workdir=ftp_dir):
168
+ call_and_log_error(ingester.ping)
169
+ schedule.every(60).seconds.do(call_and_log_error, ingester.ping) # type: ignore[attr-defined]
170
+ schedule.every(5).seconds.do(call_and_log_error, processor.process_next_file) # type: ignore[attr-defined]
171
+ schedule.every(2).minutes.do(call_and_log_error, processor.cleanup_old_files) # type: ignore[attr-defined]
172
+
173
+ status = Status()
174
+ signal.signal(signal.SIGTERM, status.stop)
175
+
176
+ while status.is_running():
177
+ schedule.run_pending()
178
+ sleep_period = schedule.idle_seconds()
179
+ if sleep_period is None or sleep_period < 0:
180
+ sleep_period = 0
181
+ # We need to wake up to handle ctrl-c etc
182
+ if sleep_period > 1:
183
+ sleep_period = 1
184
+ time.sleep(sleep_period)
185
+
186
+ logger.warning("Shutting Down...")
187
+
188
+
189
+ def main():
190
+ parser = argparse.ArgumentParser(description='Mercuto Ingester CLI')
191
+ parser.add_argument('-p', '--project', type=str,
192
+ required=True, help='Mercuto project code')
193
+ parser.add_argument('-k', '--api-key', type=str,
194
+ required=True, help='API key for Mercuto')
195
+ parser.add_argument('-v', '--verbose', action='store_true',
196
+ help='Enable verbose output')
197
+ parser.add_argument('-d', '--directory', type=str,
198
+ help='Directory to store ingested files. Default is a directory called `buffered-files` in the workdir.')
199
+ parser.add_argument('-s', '--target-free-space-mb', type=int,
200
+ help='Size in MB for total amount of remaining free space to keep available. \
201
+ Default is 25% of the available disk space on the buffer partition excluding the directory itself', default=None)
202
+ parser.add_argument('--max-files', type=int,
203
+ help='Maximum number of files to keep in the buffer. Default is to use the size param.', default=None)
204
+ parser.add_argument('--max-attempts', type=int,
205
+ help='Maximum number of attempts to process a file before giving up. Default is 1000.',
206
+ default=1000)
207
+ parser.add_argument('--workdir', type=str,
208
+ help='Working directory for the ingester. Default is ~/.mercuto-ingester',)
209
+ parser.add_argument('--logfile', type=str,
210
+ help='Log file path. No logs written if not provided. Maximum of 4 log files of 1MB each will be kept.\
211
+ Default is log.txt in the workdir.')
212
+ parser.add_argument('--mapping', type=str,
213
+ help='Path to a JSON file with channel label to channel code mapping.\
214
+ If not provided, the ingester will try to detect the channels from the project.',
215
+ default=None)
216
+ parser.add_argument('--hostname', type=str,
217
+ help='Hostname to use for the Mercuto server. Default is "https://api.rockfieldcloud.com.au".',
218
+ default='https://api.rockfieldcloud.com.au')
219
+ parser.add_argument('--clean',
220
+ help='Drop the database before starting. This will not remove any buffer files and will rescan them on startup.',
221
+ action='store_true')
222
+ parser.add_argument('--username', type=str,
223
+ help='Username for the FTP server. Default is "logger".',
224
+ default='logger')
225
+ parser.add_argument('--password', type=str,
226
+ help='Password for the FTP server. Default is "password".',
227
+ default='password')
228
+ parser.add_argument('--port', type=int,
229
+ help='Port for the FTP server. Default is 2121.',
230
+ default=2121)
231
+ parser.add_argument('--no-rename', action='store_true',
232
+ help='Add the current timestamp to the end of the files received via FTP. \
233
+ This is useful to avoid overwriting files with the same name.')
234
+ parser.add_argument('-i', '--insecure', action="store_true",
235
+ help='Disable SSL verification',
236
+ default=False)
237
+ parser.add_argument('-b', '--backup-location', action="append",
238
+ help='Backup location to store ingested files. Must be a valid URL, e.g. scp://user@host/path. '
239
+ 'Can be specified multiple times.',
240
+ type=urlparse)
241
+ parser.add_argument('-e', '--pid-file', help='Ths location to create the PID file', type=Path, default=None)
242
+ parser.add_argument('--timezone', type=str,
243
+ help='Timezone to use for data uploads (e.g. "Australia/Melbourne"). \
244
+ If not provided, no timezone will be sent on uploads. \
245
+ Only needed if data files do not contain timezone information (E.g. Campbell Scientific loggers).',
246
+ default=None)
247
+
248
+ args = parser.parse_args()
249
+
250
+ launch_mercuto_ingester(
251
+ project=args.project,
252
+ api_key=args.api_key,
253
+ verify_ssl=not args.insecure,
254
+ pid_file=args.pid_file,
255
+ workdir=args.workdir,
256
+ verbose=args.verbose,
257
+ logfile=args.logfile,
258
+ directory=args.directory,
259
+ target_free_space_mb=args.target_free_space_mb,
260
+ max_files=args.max_files,
261
+ mapping=args.mapping,
262
+ clean=args.clean,
263
+ ftp_server_username=args.username,
264
+ ftp_server_password=args.password,
265
+ ftp_server_port=args.port,
266
+ ftp_server_rename=not args.no_rename,
267
+ max_attempts=args.max_attempts,
268
+ backup_location=args.backup_location,
269
+ hostname=args.hostname,
270
+ timezone=args.timezone
271
+ )
272
+
273
+
274
+ if __name__ == '__main__':
275
+ main()