dotflow 1.0.0.dev2__py3-none-any.whl → 1.0.0.dev4__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.
dotflow/__init__.py CHANGED
@@ -1,6 +1,6 @@
1
1
  """Dotflow __init__ module."""
2
2
 
3
- __version__ = "1.0.0.dev2"
3
+ __version__ = "1.0.0.dev4"
4
4
  __description__ = "🎲 Dotflow turns an idea into flow!"
5
5
 
6
6
  from .core.action import Action as action
@@ -4,6 +4,8 @@ from dotflow.cli.commands.cloud import CloudGenerateCommand, CloudListCommand
4
4
  from dotflow.cli.commands.deploy import DeployCommand
5
5
  from dotflow.cli.commands.init import InitCommand
6
6
  from dotflow.cli.commands.log import LogCommand
7
+ from dotflow.cli.commands.login import LoginCommand
8
+ from dotflow.cli.commands.logout import LogoutCommand
7
9
  from dotflow.cli.commands.schedule import ScheduleCommand
8
10
  from dotflow.cli.commands.start import StartCommand
9
11
 
@@ -13,6 +15,8 @@ __all__ = [
13
15
  "DeployCommand",
14
16
  "InitCommand",
15
17
  "LogCommand",
18
+ "LoginCommand",
19
+ "LogoutCommand",
16
20
  "ScheduleCommand",
17
21
  "StartCommand",
18
22
  ]
@@ -0,0 +1,123 @@
1
+ """Command login module."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import time
7
+ import webbrowser
8
+
9
+ from requests import RequestException, post
10
+ from rich import print # type: ignore
11
+
12
+ from dotflow.cli.command import Command
13
+ from dotflow.core.config_file import save_cloud_config
14
+ from dotflow.settings import Settings as settings
15
+
16
+ DEFAULT_BASE_URL = "https://www.host.dotflow.io/api/v1"
17
+ DEVICE_ENDPOINT = "/cli/device"
18
+ TOKEN_ENDPOINT = "/cli/token"
19
+ TIMEOUT = 15
20
+ DEFAULT_INTERVAL = 5
21
+ MAX_POLL_INTERVAL = 60
22
+
23
+
24
+ class LoginCommand(Command):
25
+ def setup(self):
26
+ token = getattr(self.params, "token", None)
27
+ base_url = self._resolve_base_url()
28
+
29
+ if token:
30
+ save_cloud_config(token=token, base_url=base_url)
31
+ print(settings.INFO_ALERT, "Token saved.")
32
+ return
33
+
34
+ handshake = self._start_device(base_url)
35
+ if handshake is None:
36
+ return
37
+
38
+ print(settings.INFO_ALERT, f"Opening {handshake['verification_uri']}")
39
+ print(settings.INFO_ALERT, f"Code: {handshake['user_code']}")
40
+
41
+ webbrowser.open(handshake["verification_uri"])
42
+
43
+ token = self._poll_token(base_url, handshake)
44
+
45
+ if not token:
46
+ return
47
+
48
+ save_cloud_config(token=token, base_url=base_url)
49
+ print(settings.INFO_ALERT, "Authenticated.")
50
+
51
+ def _resolve_base_url(self) -> str:
52
+ """Flag or env var wins over the default."""
53
+ explicit = getattr(self.params, "base_url", None) or os.environ.get(
54
+ "SERVER_BASE_URL"
55
+ )
56
+ return explicit.rstrip("/") if explicit else DEFAULT_BASE_URL
57
+
58
+ def _start_device(self, base_url: str) -> dict | None:
59
+ try:
60
+ response = post(
61
+ f"{base_url}{DEVICE_ENDPOINT}",
62
+ timeout=TIMEOUT,
63
+ )
64
+ response.raise_for_status()
65
+ return response.json()
66
+ except RequestException as error:
67
+ print(
68
+ settings.ERROR_ALERT,
69
+ f"Could not reach Dotflow Cloud: {error}",
70
+ )
71
+ return None
72
+
73
+ def _poll_token(self, base_url: str, handshake: dict) -> str | None:
74
+ interval = handshake.get("interval") or DEFAULT_INTERVAL
75
+ deadline = time.monotonic() + (handshake.get("expires_in") or 300)
76
+
77
+ while time.monotonic() < deadline:
78
+ try:
79
+ response = post(
80
+ f"{base_url}{TOKEN_ENDPOINT}",
81
+ json={"device_code": handshake["device_code"]},
82
+ timeout=TIMEOUT,
83
+ )
84
+ except RequestException as error:
85
+ print(settings.ERROR_ALERT, f"Network error: {error}")
86
+ return None
87
+
88
+ if response.status_code == 200:
89
+ return response.json().get("api_token")
90
+
91
+ detail = self._detail(response)
92
+
93
+ if (
94
+ response.status_code == 400
95
+ and detail == "authorization_pending"
96
+ ):
97
+ time.sleep(interval)
98
+ continue
99
+
100
+ if response.status_code == 400 and detail in (
101
+ "slow_down",
102
+ "slow down",
103
+ ):
104
+ interval = min(interval + 5, MAX_POLL_INTERVAL)
105
+ time.sleep(interval)
106
+ continue
107
+
108
+ if response.status_code == 410:
109
+ print(settings.ERROR_ALERT, "Authorization expired.")
110
+ return None
111
+
112
+ print(settings.ERROR_ALERT, f"{response.status_code}: {detail}")
113
+ return None
114
+
115
+ print(settings.ERROR_ALERT, "Timed out waiting for authorization.")
116
+ return None
117
+
118
+ @staticmethod
119
+ def _detail(response) -> str:
120
+ try:
121
+ return str(response.json().get("detail", response.text))
122
+ except ValueError:
123
+ return response.text
@@ -0,0 +1,15 @@
1
+ """Command logout module."""
2
+
3
+ from rich import print # type: ignore
4
+
5
+ from dotflow.cli.command import Command
6
+ from dotflow.core.config_file import clear_cloud_config
7
+ from dotflow.settings import Settings as settings
8
+
9
+
10
+ class LogoutCommand(Command):
11
+ def setup(self):
12
+ if clear_cloud_config():
13
+ print(settings.INFO_ALERT, "Signed out.")
14
+ else:
15
+ print(settings.WARNING_ALERT, "No saved credentials.")
@@ -35,7 +35,7 @@ class StartCommand(Command):
35
35
  initial_context=self.params.initial_context,
36
36
  )
37
37
 
38
- workflow.start(mode=self.params.mode)
38
+ workflow.start(mode=self.params.mode, resume=self.params.resume)
39
39
 
40
40
  def _start_from_factory(self):
41
41
  step_only_flags = {
@@ -56,7 +56,7 @@ class StartCommand(Command):
56
56
  if not isinstance(result, DotFlow):
57
57
  raise InvalidWorkflowFactory(factory=self.params.workflow)
58
58
 
59
- result.start(mode=self.params.mode)
59
+ result.start(mode=self.params.mode, resume=self.params.resume)
60
60
 
61
61
  def _new_workflow(self):
62
62
  storage = self._build_storage()
dotflow/cli/setup.py CHANGED
@@ -9,6 +9,8 @@ from dotflow.cli.commands import (
9
9
  DeployCommand,
10
10
  InitCommand,
11
11
  LogCommand,
12
+ LoginCommand,
13
+ LogoutCommand,
12
14
  ScheduleCommand,
13
15
  StartCommand,
14
16
  )
@@ -42,12 +44,37 @@ class Command:
42
44
 
43
45
  self.setup_init()
44
46
  self.setup_logs()
47
+ self.setup_login()
48
+ self.setup_logout()
45
49
  self.setup_start()
46
50
  self.setup_schedule()
47
51
  self.setup_cloud()
48
52
  self.setup_deploy()
49
53
  self.command()
50
54
 
55
+ def setup_login(self):
56
+ cmd = self.subparsers.add_parser(
57
+ "login", help="Authenticate the CLI via browser"
58
+ )
59
+ cmd_group = cmd.add_argument_group("Usage: dotflow login [OPTIONS]")
60
+ cmd_group.add_argument(
61
+ "--base-url",
62
+ default=None,
63
+ help="Override the Dotflow Cloud API base URL",
64
+ )
65
+ cmd_group.add_argument(
66
+ "--token",
67
+ default=None,
68
+ help="Skip the browser flow and save a pre-obtained API token",
69
+ )
70
+ cmd.set_defaults(exec=LoginCommand)
71
+
72
+ def setup_logout(self):
73
+ cmd = self.subparsers.add_parser(
74
+ "logout", help="Remove saved CLI credentials"
75
+ )
76
+ cmd.set_defaults(exec=LogoutCommand)
77
+
51
78
  def setup_init(self):
52
79
  self.cmd_init = self.subparsers.add_parser(
53
80
  "init", help="Scaffold a new dotflow project"
@@ -93,6 +120,12 @@ class Command:
93
120
  TypeExecution.PARALLEL,
94
121
  ],
95
122
  )
123
+ self.cmd_start.add_argument(
124
+ "--resume",
125
+ action="store_true",
126
+ default=False,
127
+ help="Enable checkpoint-based resume",
128
+ )
96
129
 
97
130
  cmd_start_parser.set_defaults(exec=StartCommand)
98
131
 
@@ -0,0 +1,95 @@
1
+ """Persistent CLI config stored at ``~/.dotflow/config.json``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import json
7
+ import os
8
+ from pathlib import Path
9
+
10
+ CONFIG_DIR_NAME = ".dotflow"
11
+ CONFIG_FILE_NAME = "config.json"
12
+ CLOUD_SECTION = "cloud"
13
+
14
+
15
+ def config_path() -> Path:
16
+ """Return the absolute path of the CLI config file."""
17
+ return Path.home() / CONFIG_DIR_NAME / CONFIG_FILE_NAME
18
+
19
+
20
+ def load_cloud_config() -> dict[str, str]:
21
+ """Read the ``cloud`` section from the config file. ``{}`` when absent."""
22
+ path = config_path()
23
+
24
+ if not path.exists():
25
+ return {}
26
+
27
+ try:
28
+ raw = path.read_text(encoding="utf-8")
29
+ except OSError:
30
+ return {}
31
+
32
+ try:
33
+ data = json.loads(raw)
34
+ except json.JSONDecodeError:
35
+ return {}
36
+
37
+ section = data.get(CLOUD_SECTION) if isinstance(data, dict) else None
38
+ if not isinstance(section, dict):
39
+ return {}
40
+
41
+ return {
42
+ key: str(value)
43
+ for key, value in section.items()
44
+ if isinstance(value, str)
45
+ }
46
+
47
+
48
+ def save_cloud_config(token: str, base_url: str) -> Path:
49
+ """Persist ``cloud`` with the given token/base_url. Returns the file path."""
50
+ path = config_path()
51
+ path.parent.mkdir(parents=True, exist_ok=True)
52
+
53
+ payload = {
54
+ CLOUD_SECTION: {
55
+ "base_url": base_url,
56
+ "token": token,
57
+ }
58
+ }
59
+ content = json.dumps(payload, indent=2) + "\n"
60
+
61
+ fd = os.open(
62
+ str(path),
63
+ os.O_WRONLY | os.O_CREAT | os.O_TRUNC,
64
+ 0o600,
65
+ )
66
+ with contextlib.suppress(OSError):
67
+ os.fchmod(fd, 0o600)
68
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
69
+ fh.write(content)
70
+
71
+ return path
72
+
73
+
74
+ def clear_cloud_config() -> bool:
75
+ """Remove the config file. Returns True when something was deleted."""
76
+ path = config_path()
77
+
78
+ if not path.exists():
79
+ return False
80
+
81
+ try:
82
+ path.unlink()
83
+ return True
84
+ except OSError:
85
+ return False
86
+
87
+
88
+ def resolve(key: str, env_var: str) -> str | None:
89
+ """Resolve a setting with env > file precedence. ``None`` when absent."""
90
+ env_value = os.environ.get(env_var)
91
+
92
+ if env_value:
93
+ return env_value
94
+
95
+ return load_cloud_config().get(key) or None
@@ -1,4 +1,4 @@
1
- """ServerDefault — no-op or managed HTTP server provider."""
1
+ """ServerDefault - managed HTTP server provider."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
@@ -24,24 +24,19 @@ def managed(method):
24
24
 
25
25
 
26
26
  class ServerDefault(Server):
27
- """Default Server provider with auto-detected managed mode.
28
-
29
- When ``SERVER_BASE_URL`` and ``SERVER_USER_TOKEN`` env vars are set,
30
- sends workflow and task events to the remote API. Otherwise, all
31
- methods are no-ops.
32
- """
27
+ """Default Server provider with auto-detected managed mode."""
33
28
 
34
29
  MAX_RESULT_SIZE = 5_000_000
35
30
  TIMEOUT = 15.0
36
31
 
37
- ENDPOINT_WORKFLOWS = "/workflows"
38
- ENDPOINT_WORKFLOW = "/workflows/{workflow_id}"
39
- ENDPOINT_TASKS = "/workflows/{workflow_id}/tasks"
40
- ENDPOINT_TASK = "/workflows/{workflow_id}/tasks/{task_id}"
32
+ ENDPOINT_WORKFLOWS = "/cli/workflows"
33
+ ENDPOINT_WORKFLOW = "/cli/workflows/{workflow_id}"
34
+ ENDPOINT_TASKS = "/cli/workflows/{workflow_id}/tasks"
35
+ ENDPOINT_TASK = "/cli/workflows/{workflow_id}/tasks/{task_id}"
41
36
 
42
37
  def __init__(self) -> None:
43
- base_url = os.getenv("SERVER_BASE_URL")
44
- user_token = os.getenv("SERVER_USER_TOKEN")
38
+ base_url = os.environ.get("SERVER_BASE_URL") or None
39
+ user_token = os.environ.get("SERVER_USER_TOKEN") or None
45
40
  self._managed = bool(base_url and user_token)
46
41
 
47
42
  self._base_url = base_url.rstrip("/") if base_url else None
@@ -56,25 +51,23 @@ class ServerDefault(Server):
56
51
 
57
52
  def _post(self, url: str, json: dict) -> None:
58
53
  try:
59
- response = post(
54
+ post(
60
55
  url,
61
56
  json=json,
62
57
  headers=self._headers,
63
58
  timeout=self.TIMEOUT,
64
59
  )
65
- logger.info("POST %s [%s]", url, response.status_code)
66
60
  except RequestException as error:
67
61
  logger.error("POST %s failed: %s", url, error)
68
62
 
69
63
  def _patch(self, url: str, json: dict) -> None:
70
64
  try:
71
- response = patch(
65
+ patch(
72
66
  url,
73
67
  json=json,
74
68
  headers=self._headers,
75
69
  timeout=self.TIMEOUT,
76
70
  )
77
- logger.info("PATCH %s [%s]", url, response.status_code)
78
71
  except RequestException as error:
79
72
  logger.error("PATCH %s failed: %s", url, error)
80
73
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dotflow
3
- Version: 1.0.0.dev2
3
+ Version: 1.0.0.dev4
4
4
  Summary: 🎲 Dotflow turns an idea into flow! Lightweight Python library for execution pipelines with retry, parallel, cron and async support.
5
5
  License: Apache License
6
6
  Version 2.0, January 2004
@@ -1,4 +1,4 @@
1
- dotflow/__init__.py,sha256=ZrN0peVdiBsWAm9h8w9HQbs5XFGOU_fwP-OtU-_MLKs,350
1
+ dotflow/__init__.py,sha256=uorc8dzhoW2e0vy6-4AgHvGOzHXG1Y0tGipyWFod15Y,350
2
2
  dotflow/abc/__init__.py,sha256=UIk84jCJYoHAk4In_eVXAaFAnNE63lI3dVP5qunyBJs,32
3
3
  dotflow/abc/file.py,sha256=QEDIS-AXaW3GK5q5ckhNdoC5A3p8oOFE7VyNGSvx540,296
4
4
  dotflow/abc/flow.py,sha256=j6m9u13UXgBCIhgrUwzvBBYHTDul_DfERD4DWA1BQBw,1436
@@ -13,15 +13,17 @@ dotflow/abc/tcp.py,sha256=v4VzXugCpvtCP8jmHjjBv1sFko7je6c_uh0TCJ4dOXU,577
13
13
  dotflow/abc/tracer.py,sha256=S0fKzVydW6m39Wmam6MHcMKXTtzI01WctDjvwEIS0SE,633
14
14
  dotflow/cli/__init__.py,sha256=1NhA5RRAaAfhj3LdBgtC2eZ7WPZlmN0f-Kz-biUkUXE,27
15
15
  dotflow/cli/command.py,sha256=PRvZSB3VcXROsOHWasBANq3Q6-3qLY7LqJ9ecOY0C0g,236
16
- dotflow/cli/commands/__init__.py,sha256=5Y7l1GLjID3oMg_0vCSY56Vzhr6nkj0pw94KqEQ1vmw,541
16
+ dotflow/cli/commands/__init__.py,sha256=7GjVzmVDSK1KGixZru0DQo-_aCHfDL59k17TgNfuA4M,688
17
17
  dotflow/cli/commands/cloud.py,sha256=Y4zDw6u6op3AY7z9yMG7sCF34qT6Gj3B_RXXuHtYoFg,5793
18
18
  dotflow/cli/commands/deploy.py,sha256=qUeZx-C00OfOEMURNZ4LMRI4qYOwwDS_ajOGtLa_WbU,5849
19
19
  dotflow/cli/commands/init.py,sha256=VySDg1gNx_8jH0vfhc15BhBJR3Yckw91v26ijyGz5Pw,387
20
20
  dotflow/cli/commands/log.py,sha256=AJmDQM1ulbQg7_wZRsx3xac_7_6WlP8eA4pBWhpNBTo,590
21
+ dotflow/cli/commands/login.py,sha256=z9nmjLLjtmdX2fiwp9O3N8OuBNEeh8M1mRQs0ku1jOw,3839
22
+ dotflow/cli/commands/logout.py,sha256=d8F7v6KKiqXsTpsK4CspaH5qeDLyIi7sgRXhJNfN2kY,437
21
23
  dotflow/cli/commands/schedule.py,sha256=C9wvgSowV4xgQqrx6pjNZk5-709uNDfsA7xGBjjbZYA,1442
22
- dotflow/cli/commands/start.py,sha256=FBtuSY5KKuNWbqxppKVG-QrSc5fLbqtwrRELgGk7c9g,2425
24
+ dotflow/cli/commands/start.py,sha256=ruF_FEMMgB4TGkOjbCpWy6koA_8HtqLIwU-d6PuQ_bA,2479
23
25
  dotflow/cli/commands/storage.py,sha256=b3CMBSFGEqYxaI9gwiO90MIirnW82CcM3GUf4XJGVYs,1549
24
- dotflow/cli/setup.py,sha256=MDt_yNOdyDIhQHQfrWjXCvvnO5i0Ul-fwa7Dag2F6h0,8094
26
+ dotflow/cli/setup.py,sha256=zECGX3QBQbQAxx8NsYAHVhz0YjQtc0GlKxDSDULxyjw,9135
25
27
  dotflow/cli/validators/__init__.py,sha256=JKD-C4igDghgLjU2eBbpHkThUapoJ2mJRtC_YkeEvHg,121
26
28
  dotflow/cli/validators/start.py,sha256=ziQQWpLds6K_XnkiZt3m8bSs3KPdDSQzIuEiea5slks,466
27
29
  dotflow/cloud/__init__.py,sha256=N8XuL0M9P2vbP3WssHI85J8TSofVHo5llOI8EXp9RU0,21
@@ -64,6 +66,7 @@ dotflow/cloud/github/deployers/actions.py,sha256=0P752J6R0aMuhCI8cKQnoJjff61iewY
64
66
  dotflow/core/__init__.py,sha256=baoenc4kd9OQjCrnmOKRm9JRinEjd5xb7luCeyQ-npY,28
65
67
  dotflow/core/action.py,sha256=TpNHX0euI1cRHwzUCElgE_JqiBUx5BFKC3Tid1AnDys,5715
66
68
  dotflow/core/config.py,sha256=GBJKGrYejrkBr5GJAk2JvVAF1QRow1hBUAUy3FJ1Opk,3252
69
+ dotflow/core/config_file.py,sha256=Y2_RZ2IxW8RjnsU_aOCferljV2X7DgW91RqPKinNsKM,2235
67
70
  dotflow/core/context.py,sha256=X72lG-ISBHIhS03X_FalG0THdgcLpiWrJEsyH2u-g3Q,2893
68
71
  dotflow/core/decorators/__init__.py,sha256=xPQMuZWV8TnIAH8uTF-Be9NoG46K7OmfJx_7GSWO_bc,101
69
72
  dotflow/core/decorators/time.py,sha256=46mI6Q8EZYkxU5DRnPJ5B8KgDBdR1bKivEm0u4pqwAg,300
@@ -96,7 +99,7 @@ dotflow/providers/notify_discord.py,sha256=9cOt3YwJzuZzpNQlMLdc3sfJ5hfFRBBAmpNoU
96
99
  dotflow/providers/notify_telegram.py,sha256=xiyeqVSo_xQg3FR3_Q6Dnx2Qk8Sih2J7tkIVxkFSefk,3230
97
100
  dotflow/providers/scheduler_cron.py,sha256=wRXI5g0fDrYkBgD5zAMa5T8xxjU9v1SjDNpmsQD8nwA,8245
98
101
  dotflow/providers/scheduler_default.py,sha256=mkgMz5D6mROTDDY5juGXV4IgEDRPM0yW8_NJWDVfRTQ,258
99
- dotflow/providers/server_default.py,sha256=T-Ex3BHkXWiF5pWFu65fKtaoJUdjOLnxaRA40H44qjk,3418
102
+ dotflow/providers/server_default.py,sha256=KniaNtNH8kiQ4coD3HC4P5bxSIiloMT9YtlenNnd4Ik,3120
100
103
  dotflow/providers/storage_default.py,sha256=ijE6cN0TlmTn8J5TiNvq_xakoK3JmciqI7qJWxesEBM,568
101
104
  dotflow/providers/storage_file.py,sha256=N4BPiDWWDOUbh5suzWvYzX6ZUtjhrfdZgSqwBPDW__I,2154
102
105
  dotflow/providers/storage_gcs.py,sha256=LgkS0zjlPElbvPhk7-uVKbNTnVn2OTtk8h5XltWIOH0,2484
@@ -110,8 +113,8 @@ dotflow/utils/__init__.py,sha256=AXiUm5p-FVG9SBWPxdG6jV0ft49EK6c40ME4aznPWQc,366
110
113
  dotflow/utils/basic_functions.py,sha256=S-maghYauG5QkMtHdm7qNcxBkKkSoE4akHSiI178Bwg,131
111
114
  dotflow/utils/error_handler.py,sha256=I8DY2AKR-WxKl8k7aUJPExS6wlzKVPy_u42ZJ9gYyts,272
112
115
  dotflow/utils/tools.py,sha256=RtC0D1SnaNCB02cT-l0Dw8Wl6uDSQSABoZH2kdD1PJQ,787
113
- dotflow-1.0.0.dev2.dist-info/METADATA,sha256=o4qcR74cIGtpnaqHVR6xQ7ygY2AaCDPIylXUr967ZhQ,40730
114
- dotflow-1.0.0.dev2.dist-info/WHEEL,sha256=Vz2fHgx6HFtSwhs8KvkHLqH5Ea4w1_rner5uNVGCeIE,88
115
- dotflow-1.0.0.dev2.dist-info/entry_points.txt,sha256=3AW0pYKZpaMW6WZmN3t71X0FuwxQdivmQwe47_6yBp4,45
116
- dotflow-1.0.0.dev2.dist-info/licenses/LICENSE,sha256=0h5VY_8dcAiTIGt0weGPV6cjxdPGB37Pjusxm_WAiPU,10766
117
- dotflow-1.0.0.dev2.dist-info/RECORD,,
116
+ dotflow-1.0.0.dev4.dist-info/METADATA,sha256=5-HwzHn6e-WQTasE6ce9dwwQ_9C9QGnE8lQqSyhmXTI,40730
117
+ dotflow-1.0.0.dev4.dist-info/WHEEL,sha256=Vz2fHgx6HFtSwhs8KvkHLqH5Ea4w1_rner5uNVGCeIE,88
118
+ dotflow-1.0.0.dev4.dist-info/entry_points.txt,sha256=3AW0pYKZpaMW6WZmN3t71X0FuwxQdivmQwe47_6yBp4,45
119
+ dotflow-1.0.0.dev4.dist-info/licenses/LICENSE,sha256=0h5VY_8dcAiTIGt0weGPV6cjxdPGB37Pjusxm_WAiPU,10766
120
+ dotflow-1.0.0.dev4.dist-info/RECORD,,