remotivelabs-cli 0.2.1__py3-none-any.whl → 0.2.2__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.

Potentially problematic release.


This version of remotivelabs-cli might be problematic. Click here for more details.

cli/remotive.py CHANGED
@@ -20,6 +20,7 @@ from cli.settings.migration.migrate_legacy_dirs import migrate_legacy_settings_d
20
20
  from cli.tools.tools import app as tools_app
21
21
  from cli.topology.cmd import app as topology_app
22
22
  from cli.typer import typer_utils
23
+ from cli.utils.version_check import check_for_update
23
24
 
24
25
  err_console = Console(stderr=True)
25
26
 
@@ -46,7 +47,6 @@ def version_callback(value: bool) -> None:
46
47
  if value:
47
48
  my_version = version("remotivelabs-cli")
48
49
  typer.echo(my_version)
49
- raise typer.Exit()
50
50
 
51
51
 
52
52
  def test_callback(value: int) -> None:
@@ -55,6 +55,10 @@ def test_callback(value: int) -> None:
55
55
  raise typer.Exit()
56
56
 
57
57
 
58
+ def check_for_newer_version(settings: Settings) -> None:
59
+ check_for_update("remotivelabs-cli", version("remotivelabs-cli"), settings)
60
+
61
+
58
62
  def run_migrations(settings: Settings) -> None:
59
63
  """
60
64
  Run all migration scripts.
@@ -91,11 +95,12 @@ def main(
91
95
  None,
92
96
  "--version",
93
97
  callback=version_callback,
94
- is_eager=False,
98
+ is_eager=True,
95
99
  help="Print current version",
96
100
  ),
97
101
  ) -> None:
98
102
  run_migrations(settings)
103
+ check_for_newer_version(settings)
99
104
  set_default_org_as_env(settings)
100
105
  # Do other global stuff, handle other global options here
101
106
 
cli/settings/core.py CHANGED
@@ -7,20 +7,21 @@ from json import JSONDecodeError
7
7
  from pathlib import Path
8
8
  from typing import Optional, Tuple
9
9
 
10
+ from pydantic import ValidationError
10
11
  from rich.console import Console
11
12
 
12
13
  from cli.errors import ErrorPrinter
13
14
  from cli.settings import config_file, token_file
14
15
  from cli.settings import token_file as tf
15
16
  from cli.settings.config_file import ConfigFile
17
+ from cli.settings.state_file import StateFile
16
18
  from cli.settings.token_file import TokenFile
17
19
 
18
20
  err_console = Console(stderr=True)
19
21
 
20
-
21
22
  CONFIG_DIR_PATH = Path.home() / ".config" / "remotive"
22
23
  CLI_CONFIG_FILE_NAME = "config.json"
23
-
24
+ CLI_INTERNAL_STATE_FILE_NAME = "app-state.json"
24
25
 
25
26
  TokenFileMetadata = Tuple[TokenFile, Path]
26
27
 
@@ -46,6 +47,10 @@ class Settings:
46
47
  self.config_file_path = self.config_dir / CLI_CONFIG_FILE_NAME
47
48
  if not self.config_file_path.exists():
48
49
  self._write_config_file(ConfigFile())
50
+ self.state_dir = self.config_dir / "state"
51
+ self.state_file_path = self.state_dir / CLI_INTERNAL_STATE_FILE_NAME
52
+ if not self.state_file_path.exists():
53
+ self.write_state_file(StateFile())
49
54
 
50
55
  def set_default_organisation(self, organisation: str) -> None:
51
56
  cli_config = self.get_cli_config()
@@ -166,6 +171,15 @@ class Settings:
166
171
 
167
172
  return file
168
173
 
174
+ def read_state_file(self) -> StateFile:
175
+ try:
176
+ return StateFile.loads(self.state_file_path.read_text(encoding="utf-8"))
177
+ except Exception:
178
+ return StateFile()
179
+
180
+ def write_state_file(self, state_file: StateFile) -> None:
181
+ self._write_file(self.state_file_path, state_file.dumps())
182
+
169
183
  def list_personal_tokens(self) -> list[TokenFile]:
170
184
  """
171
185
  List all personal tokens
@@ -234,7 +248,7 @@ class Settings:
234
248
  try:
235
249
  self._read_token_file(path)
236
250
  return True
237
- except JSONDecodeError:
251
+ except (JSONDecodeError, ValidationError):
238
252
  # TODO - this should be printed but printing it here causes it to be displayed to many times
239
253
  # err_console.print(f"File is not valid json, skipping. {path}")
240
254
  return False
@@ -0,0 +1,32 @@
1
+ from __future__ import annotations
2
+
3
+ import dataclasses
4
+ import json
5
+ from dataclasses import dataclass
6
+ from datetime import datetime
7
+ from typing import Optional
8
+
9
+ from dacite import from_dict
10
+
11
+ from cli.utils.time import parse_datetime
12
+
13
+
14
+ @dataclass
15
+ class StateFile:
16
+ version: str = "1.0"
17
+ last_update_check_time: Optional[str] = None
18
+
19
+ def dumps(self) -> str:
20
+ return json.dumps(dataclasses.asdict(self), default=str)
21
+
22
+ def should_perform_update_check(self) -> bool:
23
+ if self.last_update_check_time:
24
+ seconds = (datetime.now() - parse_datetime(self.last_update_check_time)).seconds
25
+ return (seconds / 3600) > 2
26
+ # This will solve the issue
27
+ return True
28
+
29
+ @staticmethod
30
+ def loads(data: str) -> StateFile:
31
+ d = json.loads(data)
32
+ return from_dict(StateFile, d)
@@ -1,12 +1,13 @@
1
1
  from __future__ import annotations
2
2
 
3
- import dataclasses
4
- import json
5
3
  import re
6
- from dataclasses import dataclass
7
4
  from datetime import date, datetime
8
5
  from typing import Any, Literal
9
6
 
7
+ from pydantic import BaseModel, EmailStr, Field, field_validator, model_validator
8
+
9
+ from cli.utils.time import parse_date
10
+
10
11
  DEFAULT_EMAIL = "unknown@remotivecloud.com"
11
12
  PERSONAL_TOKEN_FILE_PREFIX = "personal-token-"
12
13
  SERVICE_ACCOUNT_TOKEN_FILE_PREFIX = "service-account-token-"
@@ -14,9 +15,9 @@ SERVICE_ACCOUNT_TOKEN_FILE_PREFIX = "service-account-token-"
14
15
  TokenType = Literal["authorized_user", "service_account"]
15
16
 
16
17
 
17
- def _parse_date(date_str: str) -> date:
18
- normalized = date_str.replace("Z", "+00:00")
19
- return datetime.fromisoformat(normalized).date()
18
+ def _email_to_safe_filename(email: str) -> str:
19
+ """Replace any invalid character with an underscore"""
20
+ return re.sub(r'[<>:"/\\|?*]', "_", email)
20
21
 
21
22
 
22
23
  def _parse_token_type(token: str) -> TokenType:
@@ -27,75 +28,109 @@ def _parse_token_type(token: str) -> TokenType:
27
28
  raise ValueError(f"Unknown token type for token: {token}")
28
29
 
29
30
 
30
- def _from_dict(d: dict[str, Any]) -> TokenFile:
31
- if "version" not in d:
32
- token_type = _parse_token_type(d["token"])
33
- return TokenFile(
34
- version="1.0",
35
- type=token_type,
36
- name=d["name"],
37
- token=d["token"],
38
- created=_parse_date(d["created"]),
39
- expires=_parse_date(d["expires"]),
40
- account=TokenFileAccount(email=DEFAULT_EMAIL),
41
- )
42
-
43
- account_email = d.get("account", {}).get("email", DEFAULT_EMAIL)
44
- return TokenFile(
45
- version=d["version"],
46
- type=d["type"],
47
- name=d["name"],
48
- token=d["token"],
49
- created=_parse_date(d["created"]),
50
- expires=_parse_date(d["expires"]),
51
- account=TokenFileAccount(email=account_email),
52
- )
31
+ class TokenFileAccount(BaseModel):
32
+ email: EmailStr = DEFAULT_EMAIL
53
33
 
54
34
 
55
- def loads(data: str) -> TokenFile:
56
- return _from_dict(json.loads(data))
35
+ class TokenFile(BaseModel):
36
+ version: str = "1.0"
37
+ type: TokenType
38
+ name: str
39
+ token: str
40
+ created: date
41
+ expires: date
42
+ account: TokenFileAccount = Field(default_factory=TokenFileAccount)
57
43
 
44
+ @field_validator("created", "expires", mode="before")
45
+ @classmethod
46
+ def validate_parse_date(cls, value: str | date) -> date:
47
+ if isinstance(value, date):
48
+ return value
49
+ return parse_date(value)
58
50
 
59
- def dumps(token: TokenFile) -> str:
60
- return json.dumps(dataclasses.asdict(token), default=str)
51
+ @model_validator(mode="before")
52
+ @classmethod
53
+ def init_with_defaults(cls, json_data: Any) -> Any:
54
+ """
55
+ Try to migrate old formats and missing fields as best we can.
61
56
 
57
+ NOTE: If we ever need to add a new version (like 2.0), we should add explicit classes for each version (e.g. TokenFileV1,
58
+ TokenFileV2, etc.), each with their own fields. This will allow us to migrate to new versions without breaking
59
+ backwards compatibility.
60
+ """
61
+ if not isinstance(json_data, dict):
62
+ return json_data
62
63
 
63
- @dataclass
64
- class TokenFileAccount:
65
- email: str
64
+ if "version" not in json_data:
65
+ json_data["version"] = "1.0"
66
66
 
67
+ if "type" not in json_data and "token" in json_data:
68
+ json_data["type"] = _parse_token_type(json_data["token"])
67
69
 
68
- @dataclass
69
- class TokenFile:
70
- version: str
71
- type: TokenType
72
- name: str
73
- token: str
74
- created: date
75
- expires: date
76
- account: TokenFileAccount
70
+ if "account" not in json_data:
71
+ json_data["account"] = {"email": DEFAULT_EMAIL}
72
+ elif isinstance(json_data["account"], str):
73
+ json_data["account"] = {"email": json_data["account"]}
77
74
 
78
- def get_token_file_name(self) -> str:
79
- def email_to_safe_filename(email: str) -> str:
80
- # Replace any invalid character with an underscore
81
- return re.sub(r'[<>:"/\\|?*]', "_", email)
75
+ return json_data
82
76
 
83
- # From now, user will never be None when adding a token so in this case token_file.user is never None
84
- email = email_to_safe_filename(self.account.email) if self.account is not None else "unknown"
77
+ def get_token_file_name(self) -> str:
78
+ """
79
+ Returns the name of the token file using the proper file name format.
80
+ """
81
+ email = _email_to_safe_filename(self.account.email) if self.account is not None else "unknown"
85
82
  if self.type == "authorized_user":
86
83
  return f"{PERSONAL_TOKEN_FILE_PREFIX}{self.name}-{email}.json"
87
84
  return f"{SERVICE_ACCOUNT_TOKEN_FILE_PREFIX}{self.name}-{email}.json"
88
85
 
89
86
  def is_expired(self) -> bool:
87
+ """
88
+ Returns True if the token is expired, False otherwise.
89
+ """
90
90
  return datetime.today().date() > self.expires
91
91
 
92
92
  def expires_in_days(self) -> int:
93
+ """
94
+ Returns the number of days until the token expires.
95
+ """
93
96
  return (self.expires - datetime.today().date()).days
94
97
 
95
- @staticmethod
96
- def from_json_str(data: str) -> TokenFile:
97
- return loads(data)
98
+ @classmethod
99
+ def from_json_str(cls, data: str) -> TokenFile:
100
+ """
101
+ Creates a TokenFile from a JSON string.
102
+ """
103
+ return cls.model_validate_json(data)
104
+
105
+ @classmethod
106
+ def from_dict(cls, data: dict[str, Any]) -> TokenFile:
107
+ """
108
+ Creates a TokenFile from a dictionary.
109
+ """
110
+ return cls.model_validate(data)
111
+
112
+ def to_json_str(self) -> str:
113
+ """
114
+ Returns the JSON string representation of the TokenFile.
115
+ """
116
+ return self.model_dump_json()
117
+
118
+ def to_dict(self) -> dict[str, Any]:
119
+ """
120
+ Returns the dictionary representation of the TokenFile.
121
+ """
122
+ return self.model_dump()
123
+
124
+
125
+ def loads(data: str) -> TokenFile:
126
+ """
127
+ Creates a TokenFile from a JSON string.
128
+ """
129
+ return TokenFile.from_json_str(data)
130
+
98
131
 
99
- @staticmethod
100
- def from_dict(data: dict[str, Any]) -> TokenFile:
101
- return _from_dict(data)
132
+ def dumps(token_file: TokenFile) -> str:
133
+ """
134
+ Returns the JSON string representation of the TokenFile.
135
+ """
136
+ return token_file.to_json_str()
cli/typer/typer_utils.py CHANGED
@@ -17,7 +17,7 @@ console = Console()
17
17
  def create_typer(**kwargs: Any) -> typer.Typer:
18
18
  """Create a Typer instance with default settings."""
19
19
  # return typer.Typer(no_args_is_help=True, **kwargs)
20
- return typer.Typer(cls=OrderCommands, no_args_is_help=True, **kwargs)
20
+ return typer.Typer(cls=OrderCommands, no_args_is_help=True, invoke_without_command=True, **kwargs)
21
21
 
22
22
 
23
23
  def print_padded(label: str, right_text: str, length: int = 30) -> None:
cli/utils/time.py ADDED
@@ -0,0 +1,11 @@
1
+ from datetime import date, datetime
2
+
3
+
4
+ def parse_date(date_str: str) -> date:
5
+ return parse_datetime(date_str).date()
6
+
7
+
8
+ def parse_datetime(date_str: str) -> datetime:
9
+ """Required for pre 3.11"""
10
+ normalized = date_str.replace("Z", "+00:00")
11
+ return datetime.fromisoformat(normalized)
@@ -0,0 +1,112 @@
1
+ from __future__ import annotations
2
+
3
+ import datetime
4
+ import json
5
+ import os
6
+ import urllib.request
7
+ from datetime import timedelta
8
+ from importlib import metadata as importlib_metadata
9
+
10
+ from packaging.version import InvalidVersion, Version
11
+
12
+ from cli.errors import ErrorPrinter
13
+ from cli.settings import Settings
14
+
15
+
16
+ def _pypi_latest(
17
+ project: str, *, include_prereleases: bool, timeout: float = 2.5, user_agent: str | None = None
18
+ ) -> tuple[str | None, str | None]:
19
+ """Return (latest_version, project_url) from PyPI, skipping yanked files."""
20
+ url = f"https://pypi.org/pypi/{project}/json"
21
+ headers = {"Accept": "application/json"}
22
+ if user_agent:
23
+ headers["User-Agent"] = user_agent
24
+ req = urllib.request.Request(url, headers=headers)
25
+
26
+ try:
27
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
28
+ data = json.load(resp)
29
+ except Exception:
30
+ return None, None # network/404/etc.
31
+
32
+ releases = data.get("releases") or {}
33
+ candidates: list[Version] = []
34
+ for s, files in releases.items():
35
+ try:
36
+ v = Version(s)
37
+ except InvalidVersion:
38
+ continue
39
+ the_files = files or []
40
+ if any(f.get("yanked", False) for f in the_files):
41
+ continue
42
+ if (v.is_prerelease or v.is_devrelease) and not include_prereleases:
43
+ continue
44
+ candidates.append(v)
45
+
46
+ if not candidates:
47
+ return None, None
48
+
49
+ latest = str(max(candidates))
50
+ info = data.get("info") or {}
51
+ proj_url = info.get("project_url") or info.get("package_url") or f"https://pypi.org/project/{project}/"
52
+ return latest, proj_url
53
+
54
+
55
+ def _installed_version(distribution_name: str, fallback: str | None = None) -> str | None:
56
+ try:
57
+ return importlib_metadata.version(distribution_name)
58
+ except importlib_metadata.PackageNotFoundError:
59
+ return fallback
60
+
61
+
62
+ def check_for_update(project: str, current_version: str, settings: Settings) -> None:
63
+ # Make it possible to disable update check, i.e in CI
64
+ if os.environ.get("PYTHON_DISABLE_UPDATE_CHECK"):
65
+ return
66
+
67
+ # Determine current version
68
+ cur = current_version or _installed_version(project)
69
+ if not cur:
70
+ return # unknown version → skip silently
71
+
72
+ state = settings.read_state_file()
73
+
74
+ if not state.last_update_check_time:
75
+ if os.environ.get("RUNS_IN_DOCKER"):
76
+ # To prevent that we always check update in docker due to ephemeral disks we write an "old" check if the state
77
+ # is missing. If no disk is mounted we will never get the update check but if its mounted properly we will get
78
+ # it on the second attempt. This is good enough
79
+ state.last_update_check_time = (datetime.datetime.now() - timedelta(hours=10)).isoformat()
80
+ settings.write_state_file(state)
81
+ return
82
+
83
+ elif not state.should_perform_update_check():
84
+ return
85
+
86
+ # We end up here if last_update_check_time is None or should_perform_update_check is true
87
+
88
+ include_prereleases = Version(cur).is_prerelease or Version(cur).is_devrelease
89
+
90
+ latest, proj_url = _pypi_latest(
91
+ project, include_prereleases=include_prereleases, user_agent=f"{project}/{cur} (+https://pypi.org/project/{project}/)"
92
+ )
93
+ if latest:
94
+ if Version(latest) > Version(cur):
95
+ _print_update_info(
96
+ cur,
97
+ latest,
98
+ )
99
+ state.last_update_check_time = datetime.datetime.now().isoformat()
100
+ settings.write_state_file(state)
101
+
102
+
103
+ def _print_update_info(cur: str, latest: str) -> None:
104
+ instructions = (
105
+ "upgrade with: docker pull remotivelabs/remotivelabs-cli"
106
+ if os.environ.get("RUNS_IN_DOCKER")
107
+ else "upgrade with: pipx install -U remotivelabs-cli"
108
+ )
109
+
110
+ ErrorPrinter.print_hint(
111
+ f"Update available: remotivelabs-cli {cur} → {latest} , ({instructions}) we always recommend to use latest version"
112
+ )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: remotivelabs-cli
3
- Version: 0.2.1
3
+ Version: 0.2.2
4
4
  Summary: CLI for operating RemotiveCloud and RemotiveBroker
5
5
  Author: Johan Rask
6
6
  Author-email: johan.rask@remotivelabs.com
@@ -13,9 +13,11 @@ Classifier: Programming Language :: Python :: 3.12
13
13
  Classifier: Programming Language :: Python :: 3.13
14
14
  Requires-Dist: click (<8.2.0)
15
15
  Requires-Dist: dacite (>=1.9.2,<2.0.0)
16
+ Requires-Dist: email-validator (>=2.2.0,<3.0.0)
16
17
  Requires-Dist: grpc-stubs (>=1.53.0.5)
17
18
  Requires-Dist: mypy-protobuf (>=3.0.0)
18
19
  Requires-Dist: plotext (>=5.2,<6.0)
20
+ Requires-Dist: pydantic (>=2.11.7,<3.0.0)
19
21
  Requires-Dist: pyjwt (>=2.6,<3.0)
20
22
  Requires-Dist: python-can (>=4.3.1)
21
23
  Requires-Dist: python-socketio (>=4.6.1)
@@ -37,28 +37,31 @@ cli/connect/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
37
37
  cli/connect/connect.py,sha256=SH2DNTTVLu2dNpk6xIah1-KJZAqrK_7Skt8RKp8Mjh8,4231
38
38
  cli/connect/protopie/protopie.py,sha256=ElmrGaV0ivb85wo0gLzCAXZhmSmIDASaCVlF1iQblLI,6532
39
39
  cli/errors.py,sha256=djODw6sdMJXzOsuAUOP3N13nfmm1sIP3Pe6tllGdozM,1657
40
- cli/remotive.py,sha256=FkXJCq0mw_bak75u6aukleIld-jjgn4hjuBQ28Lcz1U,3831
40
+ cli/remotive.py,sha256=xfeekzG6tojXsWZdiGN5ceKCCd8xVNf8v9EUAY2Gnjc,4033
41
41
  cli/settings/__init__.py,sha256=t1qkaGrJ4xx8WMHlmBTbQ1VdJL4YOcz8VFfRkGa2_jQ,711
42
42
  cli/settings/config_file.py,sha256=6sdHUtZSUIgubwpfwEEn7GarTK1M_iQhtRJZzFDdP5o,2784
43
- cli/settings/core.py,sha256=FqME9ghV6bBNDrZnQCm3jxz-7T8M04dSFumYK1-QOhs,10911
43
+ cli/settings/core.py,sha256=IJ62CzPrrvwO46zmvsjKIn6VD4oR9VG5IX29ctd2RO4,11611
44
44
  cli/settings/migration/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
45
45
  cli/settings/migration/migrate_all_token_files.py,sha256=xoVvAqn_tGskEW148uf3xZx1mpJKUnERMTcBo0nkCnI,3010
46
46
  cli/settings/migration/migrate_config_file.py,sha256=hw4EpRwJz1zUNxfCOk0PvMuZjAlaGy4m_rDbMsHZO_w,2047
47
47
  cli/settings/migration/migrate_legacy_dirs.py,sha256=N0t2io3bT_ub8BcVPw1CeQ4eeexRUiu3jXq3DL018OE,1819
48
48
  cli/settings/migration/migrate_token_file.py,sha256=Fp7Z_lNqSdoWY05TYwFW2QH8q9QhmB2TYSok6hV1Mic,1530
49
49
  cli/settings/migration/migration_tools.py,sha256=P72tuw6-aS_Kd0qn-0ZecplsYxMTu0LTXM5sMSNTVEM,1378
50
- cli/settings/token_file.py,sha256=llfP1GohXtLVIDy3UyNB8FneGRuCesnce9uasv-7XKM,2953
50
+ cli/settings/state_file.py,sha256=ujTOJgCts-gpM-66EhRYXwO803HAFcmia2Pf_nYGldc,837
51
+ cli/settings/token_file.py,sha256=Po3Vwu5cdT5ZgLO3_ZLEX13_57coqHz1PPu2SQ-202o,4177
51
52
  cli/tools/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
52
53
  cli/tools/can/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
53
54
  cli/tools/can/can.py,sha256=TtP5w8vb0QG4ObNhkWIDpRMdNelirFffoc_lFZy8ePM,2260
54
55
  cli/tools/tools.py,sha256=jhLfrFDqkmWV3eBAzNwBf6WgDGrz7sOhgVCia36Twn8,232
55
56
  cli/topology/cmd.py,sha256=SQ5wi7KDoh4iR2Ed7gyfGLNj6UE0K6UkksmBMSD2XAk,3981
56
57
  cli/typer/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
57
- cli/typer/typer_utils.py,sha256=8SkvG9aKkfK9fTRsLD9pOBtWn9XSwtOXWg2RAk9FhOI,708
58
+ cli/typer/typer_utils.py,sha256=TaJuK1EtE9Gv3DfmoyHPTNKmhiAimuQCHKxQjnUZ7bs,737
58
59
  cli/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
59
60
  cli/utils/rest_helper.py,sha256=De-1Z75p-zjA4hZrylVxWn2wqdPB2gvyvA-ixm_dRFo,14141
60
- remotivelabs_cli-0.2.1.dist-info/LICENSE,sha256=qDPP_yfuv1fF-u7EfexN-cN3M8aFgGVndGhGLovLKz0,608
61
- remotivelabs_cli-0.2.1.dist-info/METADATA,sha256=gJSJmVNokErSbGtxifX0Z2uMMFdXy77NC4LLD6Owx8A,1428
62
- remotivelabs_cli-0.2.1.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
63
- remotivelabs_cli-0.2.1.dist-info/entry_points.txt,sha256=lvDhPgagLqW_KTnLPCwKSqfYlEp-1uYVosRiPjsVj10,45
64
- remotivelabs_cli-0.2.1.dist-info/RECORD,,
61
+ cli/utils/time.py,sha256=TEKcNZ-pQoJ7cZ6hQmVD0sTRwRm2rBy51-MuDNdO4S4,296
62
+ cli/utils/version_check.py,sha256=sAkTzNRlgGn4Hto_56J0KHLe-3zK7he7gbUTjrtY1lc,3940
63
+ remotivelabs_cli-0.2.2.dist-info/LICENSE,sha256=qDPP_yfuv1fF-u7EfexN-cN3M8aFgGVndGhGLovLKz0,608
64
+ remotivelabs_cli-0.2.2.dist-info/METADATA,sha256=Dyz6ht0Psz5TCHsxJXxsKIWckmwKEPVsO3amT4GkDng,1518
65
+ remotivelabs_cli-0.2.2.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
66
+ remotivelabs_cli-0.2.2.dist-info/entry_points.txt,sha256=lvDhPgagLqW_KTnLPCwKSqfYlEp-1uYVosRiPjsVj10,45
67
+ remotivelabs_cli-0.2.2.dist-info/RECORD,,