pullup 0.0.6__tar.gz → 0.0.8__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.
pullup-0.0.8/PKG-INFO ADDED
@@ -0,0 +1,168 @@
1
+ Metadata-Version: 2.5
2
+ Name: pullup
3
+ Version: 0.0.8
4
+ Summary: build, release, and deploy projects
5
+ Project-URL: Repository, https://github.com/vedicreader/pullup
6
+ Project-URL: Documentation, https://vedicreader.github.io/pullup
7
+ Author-email: Karthik Rajgopal <karthik.rajgopal@hotmail.com>
8
+ License: Apache-2.0
9
+ License-File: LICENSE
10
+ Keywords: jupyter,nbdev,notebook,python
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Natural Language :: English
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3 :: Only
16
+ Requires-Python: >=3.11
17
+ Requires-Dist: cfeasy>=0.0.4
18
+ Requires-Dist: dockeasy>=0.0.5
19
+ Requires-Dist: fastcore>=1.8.17
20
+ Requires-Dist: gheasy>=0.0.10
21
+ Requires-Dist: keyring>=25.0.0
22
+ Requires-Dist: ptymini>=0.0.5
23
+ Requires-Dist: python-dotenv>=1.0.0
24
+ Requires-Dist: pyyaml>=6.0
25
+ Requires-Dist: vpseasy>=0.0.18
26
+ Description-Content-Type: text/markdown
27
+
28
+ # pullup
29
+
30
+
31
+ <!-- WARNING: THIS FILE WAS AUTOGENERATED! DO NOT EDIT! -->
32
+
33
+ pullup is the engine behind a release and deploy panel. Point it at a project folder and it tells you what kind of project it is (an nbdev library, a plain Python package, a Rust crate and so on) and which commands release it. It then runs those commands in a terminal you can watch, and stops at the first one that fails. The same machinery runs the deploy of a web app to a virtual private server (VPS). pullup also reads the GitHub Actions workflows in the repository, writes new ones, and lists the Hetzner servers and Cloudflare tunnels the project uses.
34
+
35
+ Every public call returns plain dictionaries and lists, so a user interface can draw them and a test can compare them. The work that talks to GitHub, Docker, Hetzner and Cloudflare is done by four sibling libraries: gheasy, dockeasy, vpseasy and cfeasy.
36
+
37
+ ## Install
38
+
39
+ ``` sh
40
+ pip install pullup
41
+ ```
42
+
43
+ This also installs gheasy, dockeasy, vpseasy and cfeasy. None of them is imported until a call needs it, so reading a project or running a release does not load them.
44
+
45
+ ## What kind of project is this
46
+
47
+ A release flow is the ordered list of commands that publishes one kind of project. An nbdev library is released with `nbdev-prepare`, `nbdev-bump-version`, `nbdev-release-gh` and `nbdev-pypi`. A plain Python package and a Rust crate each have a different list. [`Project`](https://vedicreader.github.io/pullup/project.html#project) looks at the files in a folder, picks the flow that fits, and returns its steps. The folder here holds only a `pyproject.toml` with a `[tool.nbdev]` table, so it is an nbdev project:
48
+
49
+ ``` python
50
+ project = Project(root)
51
+ project.kind, [step.id for step in project.steps()]
52
+ ```
53
+
54
+ ('nbdev', ['prepare', 'bump', 'gh', 'pypi'])
55
+
56
+ ## Running the steps
57
+
58
+ A [`Pipeline`](https://vedicreader.github.io/pullup/pipeline.html#pipeline) runs a list of steps for one project. Each step runs in a pseudo-terminal (PTY), so a program that asks for a password behaves as it would in your own shell, and every step’s output goes into one shared transcript. When a step passes, the next one starts by itself. When one fails, the pipeline stops there.
59
+
60
+ `save` stores the plan and `start` launches the first step that has not yet passed. `start` returns as soon as the step is running, not when it finishes, which is why the transcript below shows the command but not yet its output. Call `state` or `tail` whenever you want to see how far it has got.
61
+
62
+ ``` python
63
+ pipeline = Pipeline(root, dir='.demo')
64
+ pipeline.save([{'id': 'hello', 'cmd': 'say hello',
65
+ 'argv': [sys.executable, '-c', 'print("ready")']}])
66
+ await pipeline.start()
67
+ pipeline.state()['done'], pipeline.tail().splitlines()[-2:]
68
+ ```
69
+
70
+ (False, ['', '❯ say hello'])
71
+
72
+ ## Where plans are kept
73
+
74
+ A saved plan is a JSON file inside the project folder, in the directory you pass as `dir` (`.pullup` unless you say otherwise). pullup never writes a plan outside the project:
75
+
76
+ ``` python
77
+ custom = Pipeline(root, dir='.myapp')
78
+ custom.save([{'id': 'show', 'cmd': 'show', 'argv': [sys.executable, '-c', 'print("saved")']}])
79
+ custom.path.relative_to(root)
80
+ ```
81
+
82
+ Path('.myapp/pipeline.json')
83
+
84
+ ## Deploying a web app
85
+
86
+ [`Deploy`](https://vedicreader.github.io/pullup/deploy.html#deploy) is a pipeline that puts a web app on a server. Its steps run the subcommands of a `deploy.py` script that lives in the project, and [`Deploy.scaffold`](https://vedicreader.github.io/pullup/deploy.html#deploy.scaffold) can write that script for you. [`Drive`](https://vedicreader.github.io/pullup/drive.html#drive) covers the same ground one library call at a time (write the Dockerfile, open the tunnel, provision the server), so you can run and check each part on its own. Both are pipelines, so they start, stop and report like the one above.
87
+
88
+ ``` python
89
+ Deploy(root).state()['kind'], Drive(root).state()['kind']
90
+ ```
91
+
92
+ ('deploy', 'drive')
93
+
94
+ ## Workflows
95
+
96
+ [`Workflows`](https://vedicreader.github.io/pullup/workflows.html#workflows) reads the GitHub Actions files in `.github/workflows` and turns each one into a row of triggers and jobs. Reading local files needs no GitHub token. Listing runs, starting them and pushing secrets do.
97
+
98
+ ``` python
99
+ workflow_dir = root/'.github'/'workflows'
100
+ workflow_dir.mkdir(parents=True)
101
+ (workflow_dir/'ci.yml').write_text('''name: CI
102
+ on: push
103
+ jobs:
104
+ test:
105
+ runs-on: ubuntu-latest
106
+ steps:
107
+ - run: pytest
108
+ ''')
109
+ workflows = Workflows(root)
110
+ [(row['name'], [job['id'] for job in row['jobs']]) for row in workflows.parsed()]
111
+ ```
112
+
113
+ [('CI', ['test'])]
114
+
115
+ ## Environment values
116
+
117
+ Many steps need a token or a setting, such as `GITHUB_TOKEN` for a GitHub release. A step lists the names it needs under `needs`, and the pipeline looks them up in an environment store when it starts the step. The values are given to the child process only, and your own process environment is left alone. The default store is an [`EnvStore`](https://vedicreader.github.io/pullup/env.html#envstore), which keeps secrets in the system keychain, but any object with `get` and `values` methods will do, like the small one below:
118
+
119
+ ``` python
120
+ class Values:
121
+ def get(self, key, secret=True): return 'demo-token' if key == 'DEMO_TOKEN' else ''
122
+ def values(self, keys): return {key: self.get(key) for key in keys if self.get(key)}
123
+
124
+ with_values = Pipeline(root, env=Values(), dir='.env-demo')
125
+ with_values.save([{'id': 'env', 'cmd': 'read environment', 'needs': ['DEMO_TOKEN'],
126
+ 'argv': [sys.executable, '-c', 'import os; print(os.environ["DEMO_TOKEN"])']}])
127
+ await with_values.start()
128
+ with_values.tail().splitlines()[-2:]
129
+ ```
130
+
131
+ ['', '❯ read environment']
132
+
133
+ ## Which package broke
134
+
135
+ When a step fails with a Python traceback, the useful question is which of your own packages raised it. [`blame`](https://vedicreader.github.io/pullup/stack.html#blame) reads the traceback text, finds the deepest frame that belongs to one of the packages named in `family`, and returns that package with the file, the line, the function and the error message:
136
+
137
+ ``` python
138
+ trace = '''Traceback (most recent call last):
139
+ File "/tmp/pullup/demo.py", line 7, in release
140
+ raise RuntimeError("stopped")
141
+ RuntimeError: stopped
142
+ '''
143
+ blame(trace, family=['pullup'])
144
+ ```
145
+
146
+ {'package': 'pullup',
147
+ 'file': '/tmp/pullup/demo.py',
148
+ 'line': 7,
149
+ 'fn': 'release',
150
+ 'error': 'RuntimeError: stopped',
151
+ 'open': '/tmp/pullup/demo.py',
152
+ 'checkout': '',
153
+ 'version': '0.0.4'}
154
+
155
+ ## Servers and tunnels
156
+
157
+ [`Infra`](https://vedicreader.github.io/pullup/infra.html#infra) lists and deletes Hetzner servers and Cloudflare tunnels, and lists Cloudflare zones and DNS records, using the tokens in the project’s environment store. `status` says which of the two libraries are installed and which tokens are set, without sending a request. An empty `env` falls back to the default [`EnvStore`](https://vedicreader.github.io/pullup/env.html#envstore), so the answer below reflects the keychain of the machine that ran it:
158
+
159
+ ``` python
160
+ infra = Infra(env={})
161
+ infra.status()
162
+ ```
163
+
164
+ {'vpseasy': True,
165
+ 'cfeasy': True,
166
+ 'hcloud_token': True,
167
+ 'cf_token': False,
168
+ 'keys': ['HCLOUD_TOKEN', 'CLOUDFLARE_API_TOKEN']}
pullup-0.0.8/README.md ADDED
@@ -0,0 +1,141 @@
1
+ # pullup
2
+
3
+
4
+ <!-- WARNING: THIS FILE WAS AUTOGENERATED! DO NOT EDIT! -->
5
+
6
+ pullup is the engine behind a release and deploy panel. Point it at a project folder and it tells you what kind of project it is (an nbdev library, a plain Python package, a Rust crate and so on) and which commands release it. It then runs those commands in a terminal you can watch, and stops at the first one that fails. The same machinery runs the deploy of a web app to a virtual private server (VPS). pullup also reads the GitHub Actions workflows in the repository, writes new ones, and lists the Hetzner servers and Cloudflare tunnels the project uses.
7
+
8
+ Every public call returns plain dictionaries and lists, so a user interface can draw them and a test can compare them. The work that talks to GitHub, Docker, Hetzner and Cloudflare is done by four sibling libraries: gheasy, dockeasy, vpseasy and cfeasy.
9
+
10
+ ## Install
11
+
12
+ ``` sh
13
+ pip install pullup
14
+ ```
15
+
16
+ This also installs gheasy, dockeasy, vpseasy and cfeasy. None of them is imported until a call needs it, so reading a project or running a release does not load them.
17
+
18
+ ## What kind of project is this
19
+
20
+ A release flow is the ordered list of commands that publishes one kind of project. An nbdev library is released with `nbdev-prepare`, `nbdev-bump-version`, `nbdev-release-gh` and `nbdev-pypi`. A plain Python package and a Rust crate each have a different list. [`Project`](https://vedicreader.github.io/pullup/project.html#project) looks at the files in a folder, picks the flow that fits, and returns its steps. The folder here holds only a `pyproject.toml` with a `[tool.nbdev]` table, so it is an nbdev project:
21
+
22
+ ``` python
23
+ project = Project(root)
24
+ project.kind, [step.id for step in project.steps()]
25
+ ```
26
+
27
+ ('nbdev', ['prepare', 'bump', 'gh', 'pypi'])
28
+
29
+ ## Running the steps
30
+
31
+ A [`Pipeline`](https://vedicreader.github.io/pullup/pipeline.html#pipeline) runs a list of steps for one project. Each step runs in a pseudo-terminal (PTY), so a program that asks for a password behaves as it would in your own shell, and every step’s output goes into one shared transcript. When a step passes, the next one starts by itself. When one fails, the pipeline stops there.
32
+
33
+ `save` stores the plan and `start` launches the first step that has not yet passed. `start` returns as soon as the step is running, not when it finishes, which is why the transcript below shows the command but not yet its output. Call `state` or `tail` whenever you want to see how far it has got.
34
+
35
+ ``` python
36
+ pipeline = Pipeline(root, dir='.demo')
37
+ pipeline.save([{'id': 'hello', 'cmd': 'say hello',
38
+ 'argv': [sys.executable, '-c', 'print("ready")']}])
39
+ await pipeline.start()
40
+ pipeline.state()['done'], pipeline.tail().splitlines()[-2:]
41
+ ```
42
+
43
+ (False, ['', '❯ say hello'])
44
+
45
+ ## Where plans are kept
46
+
47
+ A saved plan is a JSON file inside the project folder, in the directory you pass as `dir` (`.pullup` unless you say otherwise). pullup never writes a plan outside the project:
48
+
49
+ ``` python
50
+ custom = Pipeline(root, dir='.myapp')
51
+ custom.save([{'id': 'show', 'cmd': 'show', 'argv': [sys.executable, '-c', 'print("saved")']}])
52
+ custom.path.relative_to(root)
53
+ ```
54
+
55
+ Path('.myapp/pipeline.json')
56
+
57
+ ## Deploying a web app
58
+
59
+ [`Deploy`](https://vedicreader.github.io/pullup/deploy.html#deploy) is a pipeline that puts a web app on a server. Its steps run the subcommands of a `deploy.py` script that lives in the project, and [`Deploy.scaffold`](https://vedicreader.github.io/pullup/deploy.html#deploy.scaffold) can write that script for you. [`Drive`](https://vedicreader.github.io/pullup/drive.html#drive) covers the same ground one library call at a time (write the Dockerfile, open the tunnel, provision the server), so you can run and check each part on its own. Both are pipelines, so they start, stop and report like the one above.
60
+
61
+ ``` python
62
+ Deploy(root).state()['kind'], Drive(root).state()['kind']
63
+ ```
64
+
65
+ ('deploy', 'drive')
66
+
67
+ ## Workflows
68
+
69
+ [`Workflows`](https://vedicreader.github.io/pullup/workflows.html#workflows) reads the GitHub Actions files in `.github/workflows` and turns each one into a row of triggers and jobs. Reading local files needs no GitHub token. Listing runs, starting them and pushing secrets do.
70
+
71
+ ``` python
72
+ workflow_dir = root/'.github'/'workflows'
73
+ workflow_dir.mkdir(parents=True)
74
+ (workflow_dir/'ci.yml').write_text('''name: CI
75
+ on: push
76
+ jobs:
77
+ test:
78
+ runs-on: ubuntu-latest
79
+ steps:
80
+ - run: pytest
81
+ ''')
82
+ workflows = Workflows(root)
83
+ [(row['name'], [job['id'] for job in row['jobs']]) for row in workflows.parsed()]
84
+ ```
85
+
86
+ [('CI', ['test'])]
87
+
88
+ ## Environment values
89
+
90
+ Many steps need a token or a setting, such as `GITHUB_TOKEN` for a GitHub release. A step lists the names it needs under `needs`, and the pipeline looks them up in an environment store when it starts the step. The values are given to the child process only, and your own process environment is left alone. The default store is an [`EnvStore`](https://vedicreader.github.io/pullup/env.html#envstore), which keeps secrets in the system keychain, but any object with `get` and `values` methods will do, like the small one below:
91
+
92
+ ``` python
93
+ class Values:
94
+ def get(self, key, secret=True): return 'demo-token' if key == 'DEMO_TOKEN' else ''
95
+ def values(self, keys): return {key: self.get(key) for key in keys if self.get(key)}
96
+
97
+ with_values = Pipeline(root, env=Values(), dir='.env-demo')
98
+ with_values.save([{'id': 'env', 'cmd': 'read environment', 'needs': ['DEMO_TOKEN'],
99
+ 'argv': [sys.executable, '-c', 'import os; print(os.environ["DEMO_TOKEN"])']}])
100
+ await with_values.start()
101
+ with_values.tail().splitlines()[-2:]
102
+ ```
103
+
104
+ ['', '❯ read environment']
105
+
106
+ ## Which package broke
107
+
108
+ When a step fails with a Python traceback, the useful question is which of your own packages raised it. [`blame`](https://vedicreader.github.io/pullup/stack.html#blame) reads the traceback text, finds the deepest frame that belongs to one of the packages named in `family`, and returns that package with the file, the line, the function and the error message:
109
+
110
+ ``` python
111
+ trace = '''Traceback (most recent call last):
112
+ File "/tmp/pullup/demo.py", line 7, in release
113
+ raise RuntimeError("stopped")
114
+ RuntimeError: stopped
115
+ '''
116
+ blame(trace, family=['pullup'])
117
+ ```
118
+
119
+ {'package': 'pullup',
120
+ 'file': '/tmp/pullup/demo.py',
121
+ 'line': 7,
122
+ 'fn': 'release',
123
+ 'error': 'RuntimeError: stopped',
124
+ 'open': '/tmp/pullup/demo.py',
125
+ 'checkout': '',
126
+ 'version': '0.0.4'}
127
+
128
+ ## Servers and tunnels
129
+
130
+ [`Infra`](https://vedicreader.github.io/pullup/infra.html#infra) lists and deletes Hetzner servers and Cloudflare tunnels, and lists Cloudflare zones and DNS records, using the tokens in the project’s environment store. `status` says which of the two libraries are installed and which tokens are set, without sending a request. An empty `env` falls back to the default [`EnvStore`](https://vedicreader.github.io/pullup/env.html#envstore), so the answer below reflects the keychain of the machine that ran it:
131
+
132
+ ``` python
133
+ infra = Infra(env={})
134
+ infra.status()
135
+ ```
136
+
137
+ {'vpseasy': True,
138
+ 'cfeasy': True,
139
+ 'hcloud_token': True,
140
+ 'cf_token': False,
141
+ 'keys': ['HCLOUD_TOKEN', 'CLOUDFLARE_API_TOKEN']}
@@ -1,4 +1,4 @@
1
- __version__ = "0.0.6"
1
+ __version__ = "0.0.8"
2
2
 
3
3
  # The names a caller reaches for. The modules stay importable for everything else.
4
4
  from .env import EnvError, EnvStore, venv_env
@@ -29,11 +29,13 @@ DeployError = PipelineError
29
29
  _generator = 'Leela'
30
30
 
31
31
  def use_generator(name):
32
- "Name the tool the generated `setup.py` and `deploy.py` say wrote them."
32
+ "Set the tool name that generated `setup.py` and `deploy.py` files say wrote them."
33
33
  global _generator
34
34
  _generator = str(name)
35
35
 
36
- def generator(): return _generator
36
+ def generator():
37
+ "The tool name that generated scripts say wrote them."
38
+ return _generator
37
39
 
38
40
  # %% ../nbs/03_deploy.ipynb #dbcb05a3
39
41
  DEPLOY_KEYS = {
@@ -55,7 +57,7 @@ DEPLOY_STEPS = [
55
57
 
56
58
  # %% ../nbs/03_deploy.ipynb #d6728ee1
57
59
  def setup_template(app, domain, subdomain='', port=5001, lfs=True):
58
- "lego's `setup.py`: the one-shot half of the pattern, with `ENV_KEYS` as its single source."
60
+ "The text of a one-time `setup.py` that prepares a project for deploying the lego way."
59
61
  host = f'{subdomain}.{domain}' if subdomain else domain
60
62
  patterns = "['*.png', '*.jpg', '*.jpeg', '*.webp', '*.ico', '*.mp3', '*.wav']"
61
63
  return f'''"""One-shot setup for {app}: gheasy schema, LFS, .env, the deploy workflow, secrets.
@@ -86,8 +88,8 @@ def _load_env():
86
88
  return dict(os.environ) | (parse_env(fn=str(envf)) if envf.exists() else {{}})
87
89
 
88
90
  def env2push():
89
- 'Schema defaults overlaid with whatever is actually set locally.'
90
- return ENV_KEYS | filter_keys(_load_env(), in_(ENV_KEYS))
91
+ 'Schema defaults overlaid with the local values, leaving out keys with no value so GitHub never stores "None".'
92
+ return {{k: v for k, v in (ENV_KEYS | filter_keys(_load_env(), in_(ENV_KEYS))).items() if v not in (None, 'None', '')}}
91
93
 
92
94
  def init_gheasy():
93
95
  cfg = GheasyConfig.load(ROOT)
@@ -164,7 +166,7 @@ if __name__ == '__main__':
164
166
 
165
167
  # %% ../nbs/03_deploy.ipynb #695a8160
166
168
  def deploy_template(app, domain, subdomain='', port=5001, second_host=''):
167
- "lego's `deploy.py` in full, with this project's names in it. `second_host` is lego's apex."
169
+ "The text of a `deploy.py` that builds, tunnels and deploys the app; `second_host` adds another hostname."
168
170
  host = f'{subdomain}.{domain}' if subdomain else domain
169
171
  tunnel = f'{subdomain or app}_{domain}'
170
172
  second = f'''
@@ -179,13 +181,14 @@ runs whatever this file says.
179
181
  python deploy.py compose
180
182
  python deploy.py deploy
181
183
  python deploy.py status
182
- python deploy.py nuke
184
+ python deploy.py rollback [tag]
185
+ python deploy.py nuke [--tunnel]
183
186
  """
184
187
  import os, sys, secrets
185
188
  from fastcore.all import Path
186
189
  from dockeasy import detect_app, env_set, env_get
187
190
  from cfeasy import CF
188
- from vpseasy import caddy_stack, hetzner_deploy, Hetzner
191
+ from vpseasy import caddy_stack, hetzner_deploy, Hetzner, wait_http, rollback as vps_rollback
189
192
 
190
193
  root = Path(__file__).resolve().parent
191
194
  app, domain, subdomain = {app!r}, {domain!r}, {subdomain!r}
@@ -267,6 +270,22 @@ def deploy(force=None):
267
270
  env_set('HETZNER_IP', r.ip, path=root/'.env')
268
271
  if r.key: env_set('HETZNER_KEY', r.key, path=root/'.env')
269
272
  print(f'deployed: https://{{host}} at {{r.ip}}')
273
+ after_deploy(cf)
274
+
275
+ def after_deploy(cf):
276
+ \'\'\'Wait until the site answers, then purge Cloudflare's cache for the host so visitors get the new pages.
277
+ vpseasy's deploy already keeps the replaced image for `rollback` and prunes dangling images.
278
+ Empty this function to skip both steps.\'\'\'
279
+ if not wait_http(f'https://{{host}}/', ua=f'{{app}}-deploy', tries=20): return print('site not answering yet; cache not purged')
280
+ try: cf.purge(cf.zone_id(domain), prefixes=[f'{{host}}/'], fallback_all=False); print('cache purged:', host)
281
+ except Exception as e: print(f'cache not purged: {{e}}')
282
+
283
+ def rollback(to=None):
284
+ "Run the image before the last deploy, or the kept image tagged `to`."
285
+ ip = env_get('HETZNER_IP', path=root/'.env')
286
+ if not ip: return print('no server yet')
287
+ vps_rollback(ip, to, path=srv, user=env_get('SERVER_USER', path=root/'.env', default='deploy'),
288
+ key=env_get('HETZNER_KEY', path=root/'.env', default=None))
270
289
 
271
290
  def status():
272
291
  "Which servers exist on this token, and which tunnels."
@@ -276,14 +295,17 @@ def status():
276
295
  for t in cf.tunnels(): print(f'tunnel {{t.get("name","")!r:22}} {{t.get("id","")}}')
277
296
  print(f'this project deploys as {{name}} -> {{host}}')
278
297
 
279
- def nuke():
280
- "Delete the server and the tunnel. Irreversible, so it asks for a token you have to read."
298
+ def nuke(tunnel=False):
299
+ \'\'\'Delete the server, and with `tunnel` the tunnel too. Irreversible, so it asks for a token you have to read.
300
+ The tunnel serves the site from whichever box runs its connector, so after a move to a new server keep it.\'\'\'
281
301
  typ = secrets.token_urlsafe(8)
282
- if input(f'This deletes the server and the tunnel. Type {{typ}} to proceed: ') != typ:
302
+ what = 'the server and the tunnel' if tunnel else 'the server'
303
+ if input(f'This deletes {{what}}. Type {{typ}} to proceed: ') != typ:
283
304
  return print('aborted')
284
305
  name = env_get('SERVER_NAME', path=root/'.env', default=app)
285
306
  Hetzner().delete(name)
286
307
  print(f'server {{name}} deleted')
308
+ if not tunnel: return print('tunnel kept (python deploy.py nuke --tunnel deletes it)')
287
309
  try:
288
310
  cf = CF(token=env_get('CLOUDFLARE_API_TOKEN', path=root/'.env'))
289
311
  cf.delete_tunnel(cf.tunnel_id(tunnel_name))
@@ -297,23 +319,24 @@ if __name__ == '__main__':
297
319
  elif cmd == 'env': mk_env()
298
320
  elif cmd == 'deploy': deploy(force=args[1] if len(args) > 1 else None)
299
321
  elif cmd == 'status': status()
300
- elif cmd == 'nuke': nuke()
301
- else: print('usage: deploy.py env | compose | deploy | status | nuke')
322
+ elif cmd == 'rollback': rollback(args[1] if len(args) > 1 else None)
323
+ elif cmd == 'nuke': nuke(tunnel='--tunnel' in args)
324
+ else: print('usage: deploy.py env | compose | deploy | status | rollback [tag] | nuke [--tunnel]')
302
325
  '''
303
326
 
304
327
  # %% ../nbs/03_deploy.ipynb #d11ca8b9
305
328
  class Deploy(Pipeline):
306
- "The deploy pipeline: the project's own `deploy.py`, one subcommand per step."
329
+ "The deploy pipeline, one step per subcommand of the project's `deploy.py`."
307
330
  FILE = DEPLOY_FILE
308
331
  KIND = 'deploy'
309
332
  @staticmethod
310
333
  def defaults(root): return list(DEPLOY_STEPS)
311
334
  def target(self):
312
- "Where this deploy goes and what it is still missing, from the store the steps will read."
335
+ "Where this deploy goes, which secrets are still unset, and which generated files exist."
313
336
  store = self.env if isinstance(self.env, EnvStore) else EnvStore()
314
- def value(key, default=''): return env_value(store, key, default)
337
+ def value(key, default=''): return env_value(store, key) or env_value(store, key.lower(), default)
315
338
  missing = [k for k, v in DEPLOY_KEYS.items()
316
- if v is None and not env_value(store, k, secret=True)]
339
+ if v is None and not (env_value(store, k, secret=True) or env_value(store, k.lower(), secret=True))]
317
340
  return {'app': value('APP') or self.root.name, 'domain': value('DOMAIN'),
318
341
  'server': value('SERVER_NAME'), 'user': value('SERVER_USER', 'deploy'),
319
342
  'port': value('PORT', '5001'), 'missing': missing,
@@ -325,7 +348,7 @@ class Deploy(Pipeline):
325
348
  'keys': {k: (v is None) for k, v in DEPLOY_KEYS.items()}}
326
349
  def state(self): return super().state() | {'target': self.target()}
327
350
  def scaffold(self, domain='', subdomain='', app='', force=False, second_host=''):
328
- "Write lego's pattern out: the one-shot `setup.py` and the `deploy.py` CI runs on every push."
351
+ "Write `deploy.py` and `setup.py`, record the deploy keys, add the deploy workflow, and return the paths written."
329
352
  domain = str(domain or '').strip()
330
353
  if not domain: raise DeployError('a domain is required — the host this app answers on')
331
354
  app = str(app or '').strip() or self.root.name
@@ -36,15 +36,15 @@ DriveError = PipelineError
36
36
  # %% ../nbs/04_drive.ipynb #3ab3ed04
37
37
  @dataclass
38
38
  class Part:
39
- "One library call, as a step you can run on its own."
39
+ "One deploy operation as a code template that can run as a step on its own."
40
40
  id: str
41
- package: str
41
+ package: str # the library the code calls
42
42
  label: str
43
43
  doc: str
44
- code: str
45
- needs: list = field(default_factory=list)
46
- writes: list = field(default_factory=list)
47
- produces: list = field(default_factory=list)
44
+ code: str # Python source whose `{placeholders}` are filled from `context`
45
+ needs: list = field(default_factory=list) # keys the code reads
46
+ writes: list = field(default_factory=list) # keys the code stores
47
+ produces: list = field(default_factory=list) # files the code creates
48
48
 
49
49
  # %% ../nbs/04_drive.ipynb #b80f6bff
50
50
  PARTS = [
@@ -111,7 +111,7 @@ PARTS = [
111
111
 
112
112
  # %% ../nbs/04_drive.ipynb #0cd71f13
113
113
  def context(root, env=None):
114
- "The values every snippet is rendered with, resolved once."
114
+ "The project values every part's template is filled with: names, host, tunnel, port and file lists."
115
115
  root = Path(root).expanduser().resolve()
116
116
  store = env or EnvStore()
117
117
  def value(key, default=''): return env_value(store, key, default)
@@ -151,13 +151,13 @@ def _step(part, exe, ctx):
151
151
 
152
152
  # %% ../nbs/04_drive.ipynb #b532d107
153
153
  def parts(root, env=None, python=''):
154
- "Every part as a runnable step, with this project's values already in the snippet."
154
+ "Every part as a runnable `Step`, its template filled with this project's values."
155
155
  ctx = context(root, env)
156
156
  return [_step(p, str(python or sys.executable), ctx) for p in PARTS]
157
157
 
158
158
  # %% ../nbs/04_drive.ipynb #bc95dca4
159
159
  class Drive(Pipeline):
160
- "The deploy, driven a library at a time."
160
+ "The deploy as six separate library calls, each a step you can run on its own."
161
161
  FILE = DRIVE_FILE
162
162
  KIND = 'drive'
163
163
  def __init__(self, root, python=None, env=None, dir=DIR):
@@ -166,7 +166,7 @@ class Drive(Pipeline):
166
166
  @staticmethod
167
167
  def defaults(root): return parts(root)
168
168
  def refresh(self):
169
- "Re-render the snippets against the current values."
169
+ "Render the parts again if the store's values changed, unless a plan has been saved."
170
170
  if self.path.exists(): return self.steps
171
171
  key = json.dumps(context(self.root, self.env), sort_keys=True)
172
172
  if key != self._rendered:
@@ -16,19 +16,19 @@ class EnvError(RuntimeError): pass
16
16
 
17
17
  # %% ../nbs/00_env.ipynb #9d27ac2f
18
18
  def strip_bundle(env, frozen=None):
19
- "Return `env` without frozen-host interpreter redirection."
19
+ "Remove the frozen-app interpreter variables from `env` when running frozen, and return it."
20
20
  if not (getattr(sys, 'frozen', False) if frozen is None else frozen): return env
21
21
  for name in BUNDLE_ONLY: env.pop(name, None)
22
22
  env['PYTHONUTF8'] = '1'
23
23
  return env
24
24
 
25
25
  def clean_env():
26
- "Return this process's environment without bundle variables."
26
+ "A copy of this process's environment with the frozen-app interpreter variables removed."
27
27
  return strip_bundle(os.environ.copy(), frozen=True)
28
28
 
29
29
  # %% ../nbs/00_env.ipynb #4b56c811
30
30
  def venv_env(python=None, env=None):
31
- "Return `env` with `python`'s virtual environment prepended to `PATH`; None leaves the environment unchanged."
31
+ "A copy of `env` that runs commands in the virtual environment of `python`, or unchanged when `python` is None."
32
32
  env = strip_bundle(dict(os.environ if env is None else env))
33
33
  if not python: return env
34
34
  bindir = str(Path(python).parent)
@@ -42,13 +42,17 @@ def venv_env(python=None, env=None):
42
42
  _extra = 'pullup'
43
43
 
44
44
  def use_extra(name):
45
- "Name the package every message about the cloud half tells the reader to install."
45
+ "Set the package name that missing-library errors tell the reader to install."
46
46
  global _extra
47
47
  _extra = str(name)
48
48
 
49
- def extra(): return _extra
49
+ def extra():
50
+ "The package name that missing-library errors tell the reader to install."
51
+ return _extra
50
52
 
51
- def needs_extra(what): return f'{what}: pip install "{_extra}"'
53
+ def needs_extra(what):
54
+ "An error message for `what` that ends with the command that installs the missing library."
55
+ return f'{what}: pip install "{_extra}"'
52
56
 
53
57
  # %% ../nbs/00_env.ipynb #e34073db
54
58
  def _dockeasy():
@@ -76,16 +80,16 @@ def _call(fn, *args, **kwargs):
76
80
 
77
81
  # %% ../nbs/00_env.ipynb #9937a32a
78
82
  class EnvStore:
79
- "The environment keys one project cares about, read and written through dockeasy."
83
+ "Read and write one project's tokens and settings through dockeasy's keychain and env file."
80
84
  def __init__(self, service='fastops', path=None): self.service, self.path = service, path
81
85
  def get(self, key, secret=True):
82
- "One value, from the keychain or the env file, falling back to this process's environment."
86
+ "One value from the keychain, the environment or the env file, or `''` when none has it."
83
87
  env_get, _set, secret_get, _sset = _dockeasy()
84
88
  stored = (_call(secret_get, key, service=self.service, path=self.path) if secret
85
89
  else _call(env_get, key, path=self.path))
86
90
  return stored or os.environ.get(key) or ''
87
91
  def set(self, key, value, secret=True):
88
- "Store one value. A secret goes to the keychain as well as the file; a variable does not."
92
+ "Store one value: a secret in the keychain and the env file, anything else in the env file only."
89
93
  key = str(key or '').strip()
90
94
  if not key: raise EnvError('a key is required')
91
95
  if not str(value): raise EnvError(f'{key} needs a value')
@@ -94,7 +98,7 @@ class EnvStore:
94
98
  else: _call(env_set, key, str(value), path=self.path)
95
99
  return {'key': key, 'secret': bool(secret)}
96
100
  def unset(self, key):
97
- "Remove a value from every place `set` put it. Says which places actually held one."
101
+ "Remove a value from the keychain, the env file and `os.environ`, and say which of the first two held it."
98
102
  from dockeasy.core import _FASTOPS_ENV
99
103
  from dotenv import unset_key
100
104
  key, gone = str(key), []
@@ -112,7 +116,7 @@ class EnvStore:
112
116
  os.environ.pop(key, None)
113
117
  return {'key': key, 'removed': gone}
114
118
  def values(self, keys, secret=True):
115
- "Every key that has a value, as a dict. A key with none is absent rather than empty."
119
+ "The keys that have a value, as a dict; keys with no value are left out."
116
120
  out = {}
117
121
  for k in keys:
118
122
  try: v = self.get(k, secret=secret)
@@ -122,6 +126,6 @@ class EnvStore:
122
126
 
123
127
  # %% ../nbs/00_env.ipynb #55636ab5
124
128
  def env_value(store, key, default='', secret=False):
125
- "One value out of an environment store, or `default` when it is unset or unreadable."
129
+ "One value from an environment store, or `default` when it is unset or the store raises."
126
130
  try: return store.get(key, secret=secret) or default
127
131
  except Exception: return default