├── .gitignore ├── .python-version ├── LICENSE ├── README.md ├── README_JA.md ├── demo.jpg ├── haipera_logo.jpg ├── pyproject.toml ├── requirements-dev.lock ├── requirements.lock └── src └── paramit ├── __init__.py ├── cli ├── __init__.py └── __main__.py ├── constants.py ├── cuda.py └── nb.py /.gitignore: -------------------------------------------------------------------------------- 1 | # python generated files 2 | __pycache__/ 3 | *.py[oc] 4 | build/ 5 | dist/ 6 | wheels/ 7 | *.egg-info 8 | 9 | # venv 10 | .venv 11 | 12 | # OSX 13 | .DS_Store 14 | 15 | # haipera 16 | reports 17 | 18 | .vscode 19 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.9 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [2024-] [Haipera, Towaki Takikawa & Allen Wang] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Paramit: Parameterize Python scripts/notebooks all from the command line 2 | 3 | [![License](https://img.shields.io/github/license/haipera/paramit)](https://github.com/haipera/paramit/blob/main/LICENSE) 4 | [![GitHub stars](https://img.shields.io/github/stars/haipera/paramit)](https://github.com/haipera/paramit/stargazers) 5 | [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/drive/12jY7Kr1Rupj-aJFjlIRgZf1x-nySQdoJ?usp=sharing) 6 | [![Twitter](https://img.shields.io/twitter/follow/haipera_ai?style=social)](https://twitter.com/haipera_ai) 7 | 8 | [日本語のREADMEはこちら!](README_JA.md) 9 | 10 | **Paramit was recently renamed from Haipera to Paramit. Make sure you `pip uninstall haipera` first.** 11 | 12 | Automatically track hyperparameters for your ML models without the boilerplate, and run 100s of experiments all at once, with 1 command. 13 | 14 | Built by Haipera. 15 | 16 | [Sign up on our waitlist for updates!](https://docs.google.com/forms/d/e/1FAIpQLSer1jjQKapYnNbyBCnpMBB4Nv2kmm7MnFp7t25ISYA7mlH6WA/viewform) 17 | 18 | [Join our Discord server!](https://discord.gg/UtHcwJzW) 19 | 20 |

21 | Demo image for Haipera 22 |

23 | 24 | ## What is Paramit? 25 | 26 | Paramit is an open-source framework to take scripts _and_ notebooks and make them **production ready**. 27 | 28 | - 🦥 **Config files without any code.** Automatically probes the source code to generate reproducible config files. 29 | - 🤖 **Grid search from CLI.** Use the command line to directly iterate through hyperparameters. 30 | - 🪵 **Automatic experiment logging.** Automatically generates per-experiment output folders with reproducible configs. 31 | - ☁️ **Scale to the Cloud (coming soon!).** Run everything locally, or send your model to Haipera Cloud or your own Cloud for parallel experimentation. 32 | 33 | Other general features: 34 | 35 | - supports running `.ipynb` notebook files as scripts 36 | - supports running a notebook server (with configs) 37 | - debug as usual with `pdb` 38 | - supports Windows, Linux, OSX 39 | - saves console logs along with configs 40 | - artifacts (images, models, etc) are also saved to separate experiment folders 41 | 42 | #### What's next for Paramit? 43 | 44 | - bring-your-own-cloud GPU training infrastructure 45 | - automatic logging 46 | - automatic GPU profiling instrumentation 47 | - dashboard for GPU profile analytics w/ LLMs 48 | - experiment management web dashboard 49 | 50 | Let us know at info@haipera.com if you have opinions - or if you have dying problems or needs that you want us to hear! We're all ears. 51 | 52 | ## Getting Started 53 | 54 | 55 | 56 | Install Paramit: 57 | 58 | ``` 59 | pip install paramit 60 | ``` 61 | 62 | If you want to use the notebook hosting, you can do 63 | 64 | ``` 65 | pip install "paramit[notebook]" 66 | ``` 67 | 68 | On Linux, you'll have to install a `venv` package, like: 69 | 70 | ``` 71 | apt install python3.10-venv 72 | ``` 73 | 74 | Make sure you have a `requirements.txt` file where `script.py` or any Python script you want to run is (or alternatively, somewhere in the Git repo for the script). 75 | 76 | ## Example of using paramit 77 | 78 | In a typical project, you may set up a script like: 79 | 80 | ```python3 81 | import numpy 82 | 83 | num_apples = 100 84 | apple_price = 3.0 85 | print("# apples: ", num_apples) 86 | print("price of an apple: ", apple_price) 87 | price = num_apples * apple_price 88 | print("total: ", price) 89 | ``` 90 | 91 | And in the same folder, you may have a `requirements.txt` that lists the dependencies: 92 | 93 | ``` 94 | numpy 95 | ``` 96 | 97 | Say you want to start experimenting with code like this. You'll probably adjust `num_apples` and `apple_price` manually at first, but eventually you'll lose track of what changes caused the differences in the results. 98 | 99 | To properly keep track of things, you may write code to load these variables from command line interfaces, set up a notebook, write dense JSON or YAML files, log the outputs in a logging service, save the outputs / configs in a separate experiment folder, etc. There's a lot of grunt work involved in making experimentation reproducible. 100 | 101 | Paramit is designed to solve this. With paramit you can edit variables on the fly, which you can view with: 102 | 103 | ``` 104 | paramit run script.py --help 105 | ``` 106 | 107 | By default, paramit will try to use the default `python3` interpreter to run your code. If you want to specify a speciifc Python interpreter to use, set the environment variable: 108 | 109 | ``` 110 | PARAMIT_PYTHON_PATH=/path/to/your/python/interpreter 111 | ``` 112 | 113 | When you run paramit, you can pass in arguments without ever setting up `argparse`: 114 | ``` 115 | paramit run script.py --num-apples 30 116 | ``` 117 | 118 | This will also generate a `script.toml` configuration file. 119 | 120 | You can run these generated config files directly: 121 | 122 | ``` 123 | paramit run script.toml 124 | ``` 125 | 126 | You can also set up grid searches over parameters by: 127 | 128 | ``` 129 | paramit run script.py --num-apples 30,60 --apple-price 1.0,2.0 130 | ``` 131 | 132 | Running `paramit` will also generate a `reports` folder where you run `paramit` from, with isolated experiment outputs in that folder. 133 | 134 | You can then re-run existing configs reproducibly with: 135 | 136 | ``` 137 | paramit run reports/experiment/script.toml 138 | ``` 139 | 140 | ## Using paramit with Jupyter Notebooks 141 | 142 | You can even run paramit with Jupyter notebooks! Using `paramit run` on a notebook file will run the notebook as a script. This is convenient when you want to develop your script inside a notebook environment, but then scale out your runs across a bunch of parameters. 143 | 144 | ``` 145 | paramit run script.ipynb --num-apples 30,40,50 146 | ``` 147 | 148 | If you instead want to spin up a notebook with your chosen config, and have it run in an isolated environment (inside the generated `reports` folder), you can simply run the notebook with `paramit notebook`: 149 | 150 | ``` 151 | paramit notebook script.ipynb --num-apples 30 152 | ``` 153 | 154 | This will start a notebook server as usual with the provided configs, inside a dedicated folder inside `reports`. 155 | 156 | This turns out to be a convenient way to do _versioning_ for notebooks- if you have a notebook that you want to use for different data or different examples, instead of cloning 8 versions of the same notebook, you can just have a single notebook and 8 different config files for those notebooks! 157 | 158 | You can also run a Python script as a notebook, although usually there are probably not great reasons to do this. 159 | 160 | ## Demo on Google Colab 161 | You can also try our Google Colab version which allows you to run Paramit in the cloud. Check out our Colab demo using the following notebook: [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/drive/12jY7Kr1Rupj-aJFjlIRgZf1x-nySQdoJ?usp=sharing) 162 | 163 | 164 | ## More examples 165 | 166 | See https://github.com/haipera/haipera-samples for more complex examples that you can try running paramit on. 167 | 168 | 169 | ## Have issues? 170 | 171 | Haipera is still in its early stages, so it'll likely to have bugs. We're actively developing haipera, so if you file a GitHub issue or comment in the Discord server or drop us a line at support@haipera.com we will try to resolve them ASAP! 172 | -------------------------------------------------------------------------------- /README_JA.md: -------------------------------------------------------------------------------- 1 | ## paramit:コードを書かなくても始められるPythonスクリプトやNotebookの設定管理! 2 | 3 | [![License](https://img.shields.io/github/license/haipera/paramit)](https://github.com/haipera/paramit/blob/main/LICENSE) 4 | [![GitHub stars](https://img.shields.io/github/stars/haipera/paramit)](https://github.com/haipera/paramit/stargazers) 5 | [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/drive/12jY7Kr1Rupj-aJFjlIRgZf1x-nySQdoJ?usp=sharing) 6 | [![Twitter](https://img.shields.io/twitter/follow/haipera_ai?style=social)](https://twitter.com/haipera_ai) 7 | 8 | えっボイラープレートを書かなくても設定管理ができる?再現性のあるスクリプトが書ける?NotebookをベースにCLIでハイパラサーチできる?勝手にフォルダ分けしてくれる?スクリプトから自動でGPUぶんまわそう! 9 | 10 | [ディスコード!](https://discord.gg/UtHcwJzW) 11 | 12 |

13 | Demo image for paramit 14 |

15 | 16 | ## paramitってなに? 17 | 18 | paramitは、スクリプトとノートブックからカスタムなコードを書かなくても「実験管理」を可能にするオープンソースフレームワークです。 19 | 20 | - 🦥 **コード不要のコンフィグファイル。** ソースコードを自動解析して再現可能な設定ファイルを生成。 21 | - 🐳 **仮想環境でデプロイして実験の再現性を確保。** 実験の再現性を最大化するため、仮想環境をすべて管理。 22 | - 🤖 **CLIからハイパラチューニング。** コマンドラインから直接ハイパーパラメータを調整したり、グリッドサーチすることが可能。 23 | - 🪵 **自動実験ログ記録。** 再現可能な設定を含む実験ごとの出力フォルダを自動生成。 24 | - ☁️ **クラウドホスティング(近日公開予定!)。** ローカルで全て実行するか、Haiperaクラウドやあなたのクラウドアカウントにモデルを送信して並列実験が可能。 25 | 26 | 27 | その他機能: 28 | 29 | - `pip install paramit` だけでインストール! 30 | - .ipynbノートブックファイルをスクリプトとして実行可能 31 | - ノートブックサーバーの実行をサポート(設定管理付き!) 32 | - 仮想環境のキャッシュ機能 33 | - 通常通りpdbでデバッグ可能 34 | - Windows、Linux、OSXをサポート 35 | - 設定と共にコンソールログを保存 36 | - 成果物(画像、モデルなど)も別の実験フォルダに保存 37 | 38 | #### 実装予定の機能 39 | 40 | - Bring-you-ownクラウドでのGPUトレーニングインフラ 41 | - 自動ログ設定(wandb的な何か) 42 | - GPUプロファイリングの自動計測 43 | - LLMを活用したGPUプロファイル分析ダッシュボード 44 | - 実験管理用Webダッシュボード 45 | 46 | ご意見などありましたら、info@haipera.comまでお知らせください。また、解決したい切実な問題やニーズがあれば、いつでもお聞かせください!Twitterなどでも大丈夫です[@yongyuanxi](https://x.com/yongyuanxi)。 47 | 48 | ## paramitのはじめかた 49 | 50 | インストール: 51 | ``` 52 | pip install paramit 53 | ``` 54 | 55 | ノートブック機能を使うには: 56 | ``` 57 | pip install "paramit[notebook]" 58 | ``` 59 | 60 | Linux環境だとVenv用のパッケージをインストールする必要があります。 61 | ``` 62 | apt install python3.10-venv 63 | ``` 64 | 65 | `script.py`や実行したいPythonスクリプトがある場所(または代替として、スクリプトのGitリポジトリ内のどこか)に`requirements.txt`ファイルがあることを確認してください。 66 | 67 | ## paramitの使い方 68 | 69 | Pythonで色々と実験してる時、以下のようなスクリプトを書くことがあります: 70 | 71 | ```python3 72 | import numpy 73 | 74 | num_apples = 100 75 | apple_price = 3.0 76 | print("# apples: ", num_apples) 77 | print("price of an apple: ", apple_price) 78 | price = num_apples * apple_price 79 | print("total: ", price) 80 | ``` 81 | 82 | 同じフォルダに、Dependenciesをリストアップしたrequirements.txtがあるかもしれません: 83 | 84 | ``` 85 | numpy 86 | ``` 87 | 88 | あまり現実的ではない例ですが、このコードで実験を始めるとします。 89 | 90 | まず最初は`num_apples`と`apple_price`を手動で調整したりして、どう結果が変わるかをみたりします。ただ、スクリプトが複雑化して変数が増えてきたり、メトリックスなどが増えてくるとだんだんとどのパラメータがどの結果とCorrespondするのかがわけわからなくなってきます。 91 | 92 | これらの変数を追跡するには、これらの変数をコマンドラインインターフェースから編集できるようにしたり、ノートブックをセットアップしたり、これを追跡するためのJSONやYAMLファイルを設定したり、ログサービスに出力をログしたり、別々の実験フォルダに出力や設定を保存するなどが必要です。 93 | 94 | 実験を再現可能にするには**多くの作業**が必要で複雑なプロジェクトになると大変な量のボイラープレートが発生したりします。 95 | 96 | paramitはこれを解決するために設計されています。paramitを使用すると、変数を設定管理のフレームワークを使用しなくても編集できます。 97 | 98 | ``` 99 | paramit run script.py --help 100 | ``` 101 | 102 | paramitを実行すると、`argparse`を設定しなくても引数を渡すことができます: 103 | 104 | ``` 105 | paramit run script.py --num-apples 30 106 | ``` 107 | 108 | paramitを走らせると、コードを実行するための仮想環境のビルドが呼び出され、`script.toml`設定ファイルが生成されます。 109 | 110 | 生成された設定ファイルを直接実行することもできます: 111 | 112 | ``` 113 | paramit run script.toml 114 | ``` 115 | 116 | パラメータのグリッドサーチを設定することもできます(例えばこの例だと4つの実験がスケジュールされます): 117 | 118 | ``` 119 | paramit run script.py --num-apples 30,60 --apple-price 1.0,2.0 120 | ``` 121 | 122 | paramitを実行すると、paramitを実行した場所にreportsフォルダが生成され、そのフォルダ内に独立した実験出力が保存されます。 123 | 124 | 既存の設定を再現可能に再実行するには: 125 | 126 | ``` 127 | paramit run reports/experiment/script.toml 128 | ``` 129 | 130 | ## Using paramit with Jupyter Notebooks 131 | 132 | Jupyterノートブックでもparamitを実行できます! 133 | 134 | ノートブックファイルで`paramit run`を使用すると、ノートブックをスクリプトとして実行します。 135 | 136 | これは、ノートブック環境でスクリプトを開発し、その後多くのパラメータにわたって実行をスケールアウトしたい場合に便利です。 137 | 138 | ``` 139 | paramit run script.ipynb --num-apples 30,40,50 140 | ``` 141 | 142 | CLIからの設定でノートブックを起動し、分離された環境(生成されたreportsフォルダ内)で実行したい場合は、`paramit notebook`を使うことでノートブックサーバーを実行できます: 143 | 144 | ``` 145 | paramit notebook script.ipynb --num-apples 30 146 | ``` 147 | 148 | これにより、提供された設定で通常通りノートブックサーバーが起動し、reports内の専用フォルダ内で実行されます。 149 | 150 | Reports内で生成された設定(Config)ファイルはノートブックのバージョン管理としても使えたりします。異なるデータや異なる例に使用したいノートブックがある場合、同じノートブックの8つのクローンを作成する代わりに、単一のノートブックと8つの異なる設定ファイルを用意するだけで済みます! 151 | 152 | ## Demo on Google Colab 153 | 154 | クラウドでparamitを実行できるGoogle Colabバージョンも試すことができます: [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/drive/12jY7Kr1Rupj-aJFjlIRgZf1x-nySQdoJ?usp=sharing) 155 | 156 | ## More examples 157 | 158 | paramitで実行できるより複雑な例については、https://github.com/haipera/haipera-samples をご覧ください。 159 | 160 | ## Have issues? 161 | 162 | paramitはまだ初期段階にあるため、バグがある可能性が高いです。GitHubでイシューを立てるか、Discordサーバーでコメントするか、support@haipera.comまでメールを送っていただければ、できるだけ早く解決するよう努めます! 163 | -------------------------------------------------------------------------------- /demo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/outerport/paramit/3520957f96fe415c83d1790be4f44ecc36efbcb9/demo.jpg -------------------------------------------------------------------------------- /haipera_logo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/outerport/paramit/3520957f96fe415c83d1790be4f44ecc36efbcb9/haipera_logo.jpg -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "paramit" 3 | version = "0.2.4" 4 | description = "Parameterize Python scripts/notebooks all from the command line." 5 | authors = [ 6 | { name = "Towaki Takikawa", email = "tovacinni@gmail.com" }, 7 | { name = "Allen Wang", email = "allen.houze.wang@gmail.com" } 8 | ] 9 | dependencies = [ 10 | "pydantic>=2.7.4", 11 | "tomli>=2.0.1", 12 | "tomli-w>=1.0.0", 13 | "gitpython>=3.1.43", 14 | "platformdirs>=4.2.2", 15 | "subprocess-tee>=0.4.2", 16 | "nbconvert>=7.16.4", 17 | "nbformat>=5.10.4", 18 | "ipython>=8.18.0", 19 | "libcst>=1.4.0", 20 | "jupytext>=1.16.3", 21 | ] 22 | readme = "README.md" 23 | requires-python = ">= 3.9" 24 | 25 | [project.scripts] 26 | haipera = "paramit.cli:main" 27 | paramit = "paramit.cli:main" 28 | 29 | [project.optional-dependencies] 30 | notebook = [ 31 | "notebook>=7.2.1", 32 | ] 33 | 34 | [build-system] 35 | requires = ["hatchling"] 36 | build-backend = "hatchling.build" 37 | 38 | [tool.rye] 39 | managed = true 40 | dev-dependencies = [ 41 | "pytest>=8.2.2", 42 | ] 43 | 44 | [tool.hatch.metadata] 45 | allow-direct-references = true 46 | 47 | [tool.hatch.build.targets.wheel] 48 | packages = ["src/paramit"] 49 | -------------------------------------------------------------------------------- /requirements-dev.lock: -------------------------------------------------------------------------------- 1 | # generated by rye 2 | # use `rye lock` or `rye sync` to update this lockfile 3 | # 4 | # last locked with the following flags: 5 | # pre: false 6 | # features: [] 7 | # all-features: false 8 | # with-sources: false 9 | # generate-hashes: false 10 | 11 | -e file:. 12 | annotated-types==0.7.0 13 | # via pydantic 14 | asttokens==2.4.1 15 | # via stack-data 16 | attrs==23.2.0 17 | # via jsonschema 18 | # via referencing 19 | beautifulsoup4==4.12.3 20 | # via nbconvert 21 | bleach==6.1.0 22 | # via nbconvert 23 | decorator==5.1.1 24 | # via ipython 25 | defusedxml==0.7.1 26 | # via nbconvert 27 | exceptiongroup==1.2.2 28 | # via ipython 29 | # via pytest 30 | executing==2.0.1 31 | # via stack-data 32 | fastjsonschema==2.20.0 33 | # via nbformat 34 | gitdb==4.0.11 35 | # via gitpython 36 | gitpython==3.1.43 37 | # via paramit 38 | importlib-metadata==8.2.0 39 | # via jupyter-client 40 | # via nbconvert 41 | iniconfig==2.0.0 42 | # via pytest 43 | ipython==8.18.1 44 | # via paramit 45 | jedi==0.19.1 46 | # via ipython 47 | jinja2==3.1.4 48 | # via nbconvert 49 | jsonschema==4.23.0 50 | # via nbformat 51 | jsonschema-specifications==2023.12.1 52 | # via jsonschema 53 | jupyter-client==8.6.2 54 | # via nbclient 55 | jupyter-core==5.7.2 56 | # via jupyter-client 57 | # via nbclient 58 | # via nbconvert 59 | # via nbformat 60 | jupyterlab-pygments==0.3.0 61 | # via nbconvert 62 | jupytext==1.16.3 63 | # via paramit 64 | libcst==1.4.0 65 | # via paramit 66 | markdown-it-py==3.0.0 67 | # via jupytext 68 | # via mdit-py-plugins 69 | markupsafe==2.1.5 70 | # via jinja2 71 | # via nbconvert 72 | matplotlib-inline==0.1.7 73 | # via ipython 74 | mdit-py-plugins==0.4.1 75 | # via jupytext 76 | mdurl==0.1.2 77 | # via markdown-it-py 78 | mistune==3.0.2 79 | # via nbconvert 80 | nbclient==0.10.0 81 | # via nbconvert 82 | nbconvert==7.16.4 83 | # via paramit 84 | nbformat==5.10.4 85 | # via jupytext 86 | # via nbclient 87 | # via nbconvert 88 | # via paramit 89 | packaging==24.1 90 | # via jupytext 91 | # via nbconvert 92 | # via pytest 93 | pandocfilters==1.5.1 94 | # via nbconvert 95 | parso==0.8.4 96 | # via jedi 97 | pexpect==4.9.0 98 | # via ipython 99 | platformdirs==4.2.2 100 | # via jupyter-core 101 | # via paramit 102 | pluggy==1.5.0 103 | # via pytest 104 | prompt-toolkit==3.0.47 105 | # via ipython 106 | ptyprocess==0.7.0 107 | # via pexpect 108 | pure-eval==0.2.3 109 | # via stack-data 110 | pydantic==2.8.2 111 | # via paramit 112 | pydantic-core==2.20.1 113 | # via pydantic 114 | pygments==2.18.0 115 | # via ipython 116 | # via nbconvert 117 | pytest==8.3.1 118 | python-dateutil==2.9.0.post0 119 | # via jupyter-client 120 | pyyaml==6.0.1 121 | # via jupytext 122 | # via libcst 123 | pyzmq==26.0.3 124 | # via jupyter-client 125 | referencing==0.35.1 126 | # via jsonschema 127 | # via jsonschema-specifications 128 | rpds-py==0.19.0 129 | # via jsonschema 130 | # via referencing 131 | six==1.16.0 132 | # via asttokens 133 | # via bleach 134 | # via python-dateutil 135 | smmap==5.0.1 136 | # via gitdb 137 | soupsieve==2.5 138 | # via beautifulsoup4 139 | stack-data==0.6.3 140 | # via ipython 141 | subprocess-tee==0.4.2 142 | # via paramit 143 | tinycss2==1.3.0 144 | # via nbconvert 145 | tomli==2.0.1 146 | # via jupytext 147 | # via paramit 148 | # via pytest 149 | tomli-w==1.0.0 150 | # via paramit 151 | tornado==6.4.1 152 | # via jupyter-client 153 | traitlets==5.14.3 154 | # via ipython 155 | # via jupyter-client 156 | # via jupyter-core 157 | # via matplotlib-inline 158 | # via nbclient 159 | # via nbconvert 160 | # via nbformat 161 | typing-extensions==4.12.2 162 | # via ipython 163 | # via pydantic 164 | # via pydantic-core 165 | wcwidth==0.2.13 166 | # via prompt-toolkit 167 | webencodings==0.5.1 168 | # via bleach 169 | # via tinycss2 170 | zipp==3.19.2 171 | # via importlib-metadata 172 | -------------------------------------------------------------------------------- /requirements.lock: -------------------------------------------------------------------------------- 1 | # generated by rye 2 | # use `rye lock` or `rye sync` to update this lockfile 3 | # 4 | # last locked with the following flags: 5 | # pre: false 6 | # features: [] 7 | # all-features: false 8 | # with-sources: false 9 | # generate-hashes: false 10 | 11 | -e file:. 12 | annotated-types==0.7.0 13 | # via pydantic 14 | asttokens==2.4.1 15 | # via stack-data 16 | attrs==23.2.0 17 | # via jsonschema 18 | # via referencing 19 | beautifulsoup4==4.12.3 20 | # via nbconvert 21 | bleach==6.1.0 22 | # via nbconvert 23 | decorator==5.1.1 24 | # via ipython 25 | defusedxml==0.7.1 26 | # via nbconvert 27 | exceptiongroup==1.2.2 28 | # via ipython 29 | executing==2.0.1 30 | # via stack-data 31 | fastjsonschema==2.20.0 32 | # via nbformat 33 | gitdb==4.0.11 34 | # via gitpython 35 | gitpython==3.1.43 36 | # via paramit 37 | importlib-metadata==8.2.0 38 | # via jupyter-client 39 | # via nbconvert 40 | ipython==8.18.1 41 | # via paramit 42 | jedi==0.19.1 43 | # via ipython 44 | jinja2==3.1.4 45 | # via nbconvert 46 | jsonschema==4.23.0 47 | # via nbformat 48 | jsonschema-specifications==2023.12.1 49 | # via jsonschema 50 | jupyter-client==8.6.2 51 | # via nbclient 52 | jupyter-core==5.7.2 53 | # via jupyter-client 54 | # via nbclient 55 | # via nbconvert 56 | # via nbformat 57 | jupyterlab-pygments==0.3.0 58 | # via nbconvert 59 | jupytext==1.16.3 60 | # via paramit 61 | libcst==1.4.0 62 | # via paramit 63 | markdown-it-py==3.0.0 64 | # via jupytext 65 | # via mdit-py-plugins 66 | markupsafe==2.1.5 67 | # via jinja2 68 | # via nbconvert 69 | matplotlib-inline==0.1.7 70 | # via ipython 71 | mdit-py-plugins==0.4.1 72 | # via jupytext 73 | mdurl==0.1.2 74 | # via markdown-it-py 75 | mistune==3.0.2 76 | # via nbconvert 77 | nbclient==0.10.0 78 | # via nbconvert 79 | nbconvert==7.16.4 80 | # via paramit 81 | nbformat==5.10.4 82 | # via jupytext 83 | # via nbclient 84 | # via nbconvert 85 | # via paramit 86 | packaging==24.1 87 | # via jupytext 88 | # via nbconvert 89 | pandocfilters==1.5.1 90 | # via nbconvert 91 | parso==0.8.4 92 | # via jedi 93 | pexpect==4.9.0 94 | # via ipython 95 | platformdirs==4.2.2 96 | # via jupyter-core 97 | # via paramit 98 | prompt-toolkit==3.0.47 99 | # via ipython 100 | ptyprocess==0.7.0 101 | # via pexpect 102 | pure-eval==0.2.3 103 | # via stack-data 104 | pydantic==2.8.2 105 | # via paramit 106 | pydantic-core==2.20.1 107 | # via pydantic 108 | pygments==2.18.0 109 | # via ipython 110 | # via nbconvert 111 | python-dateutil==2.9.0.post0 112 | # via jupyter-client 113 | pyyaml==6.0.1 114 | # via jupytext 115 | # via libcst 116 | pyzmq==26.0.3 117 | # via jupyter-client 118 | referencing==0.35.1 119 | # via jsonschema 120 | # via jsonschema-specifications 121 | rpds-py==0.19.0 122 | # via jsonschema 123 | # via referencing 124 | six==1.16.0 125 | # via asttokens 126 | # via bleach 127 | # via python-dateutil 128 | smmap==5.0.1 129 | # via gitdb 130 | soupsieve==2.5 131 | # via beautifulsoup4 132 | stack-data==0.6.3 133 | # via ipython 134 | subprocess-tee==0.4.2 135 | # via paramit 136 | tinycss2==1.3.0 137 | # via nbconvert 138 | tomli==2.0.1 139 | # via jupytext 140 | # via paramit 141 | tomli-w==1.0.0 142 | # via paramit 143 | tornado==6.4.1 144 | # via jupyter-client 145 | traitlets==5.14.3 146 | # via ipython 147 | # via jupyter-client 148 | # via jupyter-core 149 | # via matplotlib-inline 150 | # via nbclient 151 | # via nbconvert 152 | # via nbformat 153 | typing-extensions==4.12.2 154 | # via ipython 155 | # via pydantic 156 | # via pydantic-core 157 | wcwidth==0.2.13 158 | # via prompt-toolkit 159 | webencodings==0.5.1 160 | # via bleach 161 | # via tinycss2 162 | zipp==3.19.2 163 | # via importlib-metadata 164 | -------------------------------------------------------------------------------- /src/paramit/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/outerport/paramit/3520957f96fe415c83d1790be4f44ecc36efbcb9/src/paramit/__init__.py -------------------------------------------------------------------------------- /src/paramit/cli/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import ast 4 | import libcst as cst 5 | from libcst.metadata import PositionProvider 6 | import datetime 7 | from typing import List, Any, Dict, Union 8 | from pydantic import BaseModel 9 | import tomli 10 | import tomli_w 11 | import uuid 12 | import enum 13 | import subprocess 14 | from copy import deepcopy 15 | import subprocess_tee 16 | import tempfile 17 | import shutil 18 | from paramit.nb import ( 19 | convert_ipynb_to_py, 20 | convert_source_code_to_ipynb, 21 | is_jupyter_kernel_installed, 22 | ) 23 | from paramit.constants import YELLOW, MAGENTA, GREEN, RED, RESET 24 | 25 | sys.stdout.reconfigure(line_buffering=True) 26 | 27 | 28 | class ParamitMode(enum.Enum): 29 | RUN = "run" 30 | CLOUD = "cloud" 31 | NOTEBOOK = "notebook" 32 | 33 | 34 | class ParamitVariable(BaseModel): 35 | name: str 36 | value: Any 37 | type: str 38 | file_name: str 39 | line: int 40 | column: int 41 | 42 | def __str__(self): 43 | return ( 44 | f"{self.name} = {self.value} ({self.type}) [{self.file_name}:{self.line}]" 45 | ) 46 | 47 | 48 | class ParamitMetadata(BaseModel): 49 | version: str 50 | created_on: str 51 | script_path: str 52 | python_path: str 53 | 54 | 55 | class ParamitParameter(BaseModel): 56 | name: str 57 | type: str 58 | values: List[Any] 59 | 60 | 61 | class VariableVisitor(cst.CSTVisitor): 62 | METADATA_DEPENDENCIES = (PositionProvider,) 63 | 64 | def __init__(self, file_path: str): 65 | self.file_path = file_path 66 | self.variables = [] 67 | self.current_context = [] 68 | 69 | def visit_Assign(self, node: cst.Assign): 70 | for target in node.targets: 71 | if isinstance(target.target, cst.Name) and isinstance( 72 | node.value, cst.Integer 73 | ): 74 | pos = self.get_metadata(PositionProvider, node).start 75 | self.add_variable( 76 | target.target.value, node.value.value, pos.line, pos.column 77 | ) 78 | elif isinstance(target.target, cst.Name) and isinstance( 79 | node.value, cst.Float 80 | ): 81 | pos = self.get_metadata(PositionProvider, node).start 82 | self.add_variable( 83 | target.target.value, node.value.value, pos.line, pos.column 84 | ) 85 | elif isinstance(target.target, cst.Name) and isinstance( 86 | node.value, cst.SimpleString 87 | ): 88 | pos = self.get_metadata(PositionProvider, node).start 89 | self.add_variable( 90 | target.target.value, 91 | node.value.value.strip("'\""), 92 | pos.line, 93 | pos.column, 94 | ) 95 | elif isinstance(target.target, cst.Name) and isinstance( 96 | node.value, cst.Name 97 | ): 98 | if node.value.value == "True" or node.value.value == "False": 99 | pos = self.get_metadata(PositionProvider, node).start 100 | value = True if node.value.value == "True" else False 101 | self.add_variable(target.target.value, value, pos.line, pos.column) 102 | elif ( 103 | isinstance(target.target, cst.Attribute) 104 | and isinstance(node.value, cst.SimpleString) 105 | and isinstance(target.target.value, cst.Name) 106 | and target.target.value.value == "self" 107 | ): 108 | pass # Disabled for now 109 | 110 | def visit_Call(self, node: cst.Call): 111 | if isinstance(node.func, cst.Name): 112 | self.current_context.append(node.func.value) 113 | elif isinstance(node.func, cst.Attribute): 114 | self.current_context.append(node.func.attr.value) 115 | 116 | # Disabled for now 117 | """ 118 | for arg in node.args: 119 | if isinstance(arg, cst.Arg) and isinstance(arg.value, cst.SimpleString): 120 | self.add_variable(arg.keyword.value if arg.keyword else None, arg.value) 121 | """ 122 | 123 | def leave_Call(self, original_node: cst.Call): 124 | if self.current_context: 125 | self.current_context.pop() 126 | 127 | def visit_FunctionDef(self, node: cst.FunctionDef): 128 | pass 129 | """ 130 | if node.name.value == "__init__": 131 | params = node.params 132 | if params.default_params: 133 | for param in params.default_params: 134 | if isinstance(param.default, cst.SimpleString): 135 | pass # Disabled for now 136 | # self.add_variable(param.name.value, param.default) 137 | """ 138 | 139 | def visit_ClassDef(self, node: cst.ClassDef): 140 | self.current_context.append(node.name.value) 141 | for base in node.bases: 142 | if isinstance(base.value, cst.Call): 143 | self.visit_Call(base.value) 144 | 145 | def leave_ClassDef(self, original_node: cst.ClassDef): 146 | self.current_context.pop() 147 | 148 | def add_variable(self, name: str, value: Any, line: int, column: int): 149 | full_name = ".".join(self.current_context + [name]) if name else "" 150 | self.variables.append( 151 | ParamitVariable( 152 | name=full_name, 153 | value=value, 154 | type=type(value).__name__, 155 | file_name=os.path.basename(self.file_path), 156 | line=line, 157 | column=column, 158 | ) 159 | ) 160 | 161 | 162 | class VariableTransformer(cst.CSTTransformer): 163 | METADATA_DEPENDENCIES = (PositionProvider,) 164 | 165 | ## TODO: Clean all the transformation code 166 | ## Instead of taking in a config file, this should take in the same 167 | ## ParamitVariable objects that the VariableVisitor generates 168 | ## and check against line and column numbers 169 | def __init__(self, config: Dict[str, Union[str, int, float, bool]]): 170 | self.config = config 171 | 172 | def leave_Assign( 173 | self, original_node: cst.Assign, updated_node: cst.Assign 174 | ) -> cst.Assign: 175 | if len(original_node.targets) == 1: 176 | target = original_node.targets[0].target 177 | if isinstance(target, cst.Name): 178 | name = target.value 179 | # pos = self.get_metadata(PositionProvider, original_node).start 180 | if name in self.config: 181 | value = self.config[name] 182 | if isinstance(original_node.value, cst.SimpleString): 183 | value_node = cst.SimpleString(value=f"'{value}'") 184 | elif isinstance( 185 | original_node.value, cst.Name 186 | ) and original_node.value.value in ["True", "False"]: 187 | value_node = cst.Name(value="True" if value else "False") 188 | elif isinstance(original_node.value, cst.Integer): 189 | value_node = cst.Integer(str(value)) 190 | elif isinstance(original_node.value, cst.Float): 191 | value_node = cst.Float(str(value)) 192 | else: 193 | raise ValueError(f"Unsupported type {type(value)}") 194 | return updated_node.with_changes(value=value_node) 195 | return updated_node 196 | 197 | 198 | def find_variables(tree: cst.Module, path: str) -> List[ParamitVariable]: 199 | """Find all variables, their values, and types in the given CST tree.""" 200 | visitor = VariableVisitor(file_path=path) 201 | wrapper = cst.MetadataWrapper(tree) 202 | wrapper.visit(visitor) 203 | return visitor.variables 204 | 205 | 206 | def expand_paths_in_global_variables( 207 | global_vars: List[ParamitVariable], script_path: str 208 | ) -> List[ParamitVariable]: 209 | """Expand the path in the given global variables using the script path.""" 210 | expanded_vars = [] 211 | for var in global_vars: 212 | if isinstance(var.value, str) and var.value != "": 213 | expanded_path = os.path.abspath( 214 | os.path.join(os.path.dirname(script_path), var.value) 215 | ) 216 | if os.path.exists(expanded_path): 217 | expanded_vars.append( 218 | ParamitVariable( 219 | name=var.name, 220 | value=expanded_path, 221 | type=var.type, 222 | file_name=var.file_name, 223 | line=var.line, 224 | column=var.column, 225 | ) 226 | ) 227 | else: 228 | expanded_vars.append(var) 229 | else: 230 | expanded_vars.append(var) 231 | return expanded_vars 232 | 233 | 234 | def generate_config_file( 235 | tree: cst.Module, 236 | path: str, 237 | ) -> Dict[str, Any]: 238 | """Generate a TOML configuration file with the given global variables.""" 239 | global_vars = find_variables(tree, path) 240 | global_vars = expand_paths_in_global_variables(global_vars, path) 241 | script_path = path.replace(".toml", ".py") 242 | 243 | config = {"global": {}, "meta": {}} 244 | for var in global_vars: 245 | parts = var.name.split(".") 246 | if len(parts) == 1: 247 | config["global"][parts[0]] = var.value 248 | else: 249 | current_dict = config["global"] 250 | for part in parts[:-1]: 251 | if part not in current_dict: 252 | current_dict[part] = {} 253 | current_dict = current_dict[part] 254 | current_dict[parts[-1]] = var.value 255 | 256 | python_path = get_python_path() 257 | 258 | metadata = ParamitMetadata( 259 | version="0.2.4", 260 | created_on=str(datetime.datetime.now()), 261 | script_path=os.path.abspath(script_path), 262 | python_path=python_path if python_path else "", 263 | ) 264 | 265 | config["meta"] = metadata.model_dump() 266 | 267 | return config 268 | 269 | 270 | def load_config_file(path: str) -> dict: 271 | """Load a TOML configuration file from the given path 272 | and return it as a dictionary.""" 273 | 274 | with open(path, "rb") as f: 275 | return tomli.load(f) 276 | 277 | 278 | def set_global_variables_from_config( 279 | tree: cst.Module, config: Dict[str, Dict[str, Union[str, int, float, bool]]] 280 | ) -> cst.Module: 281 | """Set global variables in the given CST tree using the values from the config dictionary.""" 282 | transformer = VariableTransformer(config["global"]) 283 | wrapper = cst.MetadataWrapper(tree) 284 | modified_tree = wrapper.visit(transformer) 285 | return modified_tree 286 | 287 | 288 | def help_in_args(args: List[str]) -> bool: 289 | """Check if the help flag is in the given list of arguments.""" 290 | return any(arg in args for arg in ["-h", "--help"]) 291 | 292 | 293 | def parse_args(args: List[str]) -> Dict[str, Any]: 294 | """Parse the given list of arguments into a dictionary.""" 295 | args_dict = {} 296 | for arg_index, arg in enumerate(args): 297 | if arg in ["--help", "--h"]: 298 | continue 299 | if arg.startswith("--"): 300 | if "=" in arg: 301 | key, value = arg.split("=") 302 | else: 303 | key = arg 304 | if arg_index + 1 >= len(args): 305 | print(f"{RED}Error: Argument {arg} is missing a value{RESET}") 306 | sys.exit(1) 307 | 308 | value = "" 309 | while arg_index + 1 < len(args) and not args[arg_index + 1].startswith( 310 | "--" 311 | ): 312 | if value: 313 | value += "," 314 | value += args[arg_index + 1] 315 | arg_index += 1 316 | 317 | key = key[2:].replace("-", "_") 318 | args_dict[key] = value 319 | return args_dict 320 | 321 | 322 | def expand_args_dict(args_dict: Dict[str, str]) -> Dict[str, ParamitParameter]: 323 | """Parse the value in the args according to the special paramit syntax. 324 | 325 | The syntax is as follows: 326 | "123,126,128" -> [123, 126, 128] 327 | "blue, red" -> ["blue", "red"] 328 | "blue red" -> ["blue", "red"] 329 | """ 330 | 331 | hyperparameters = {} 332 | for arg, value in args_dict.items(): 333 | if "," in value: 334 | values = value.split(",") 335 | else: 336 | values = [value] 337 | 338 | if not values: 339 | print(f"{RED}Error: Argument {arg} must have at least one value{RESET}") 340 | sys.exit(1) 341 | 342 | value_type = None 343 | if all(v.isdigit() for v in values): 344 | value_type = int 345 | values = [value_type(v) for v in values] 346 | elif all(v.replace(".", "").replace("e", "").isdigit() for v in values): 347 | value_type = float 348 | values = [value_type(v) for v in values] 349 | elif all(v.lower() in ["true", "false"] for v in values): 350 | value_type = bool 351 | 352 | def str_to_bool(value): 353 | if value.lower() == "true": 354 | return True 355 | elif value.lower() == "false": 356 | return False 357 | else: 358 | print(f"{RED}Error: Bool argument must be True or False{RESET}") 359 | sys.exit(1) 360 | 361 | values = [str_to_bool(v) for v in values] 362 | 363 | else: 364 | value_type = str 365 | values = [value_type(v) for v in values] 366 | # Make paths absolute if the argument exists as a path 367 | for i, v in enumerate(values): 368 | if os.path.exists(v): 369 | values[i] = os.path.abspath(v) 370 | 371 | 372 | hyperparameters[arg] = ParamitParameter( 373 | name=arg, type=type(values[0]).__name__, values=values 374 | ) 375 | 376 | return hyperparameters 377 | 378 | 379 | def pretty_print_config(config: Dict[str, Any]) -> None: 380 | """Pretty print the config as parameters that can be passed to the CLI.""" 381 | print("Arguments:") 382 | for key, value in config["global"].items(): 383 | print(f" --{key.replace('_', '-')}={value}") 384 | print("\nMetadata:") 385 | for key, value in config["meta"].items(): 386 | print(f" {key}: {value}") 387 | 388 | 389 | def generate_configs_from_hyperparameters( 390 | base_config: Dict[str, Any], hyperparameters: Dict[str, ParamitParameter] 391 | ) -> List[Dict[str, Any]]: 392 | """Generate a list of configurations from the base config and hyperparameters.""" 393 | 394 | hyperparameters_range: List[ParamitParameter] = [] 395 | hyperparameters_single: List[ParamitParameter] = [] 396 | for hyperparameter in hyperparameters: 397 | if len(hyperparameters[hyperparameter].values) > 1: 398 | hyperparameters_range.append(hyperparameters[hyperparameter]) 399 | else: 400 | hyperparameters_single.append(hyperparameters[hyperparameter]) 401 | 402 | for hyperparameter in hyperparameters_single: 403 | if hyperparameter.name in base_config["global"]: 404 | try: 405 | base_config["global"][hyperparameter.name] = type( 406 | base_config["global"][hyperparameter.name] 407 | )(hyperparameter.values[0]) 408 | except ValueError: 409 | print( 410 | f"{RED}Error: Argument {hyperparameter.name} must be of type {type(base_config['global'][hyperparameter.name])}{RESET}" 411 | ) 412 | sys.exit(1) 413 | else: 414 | print( 415 | f"{RED}Error: Argument {hyperparameter.name} not found in the code or config{RESET}" 416 | ) 417 | # Print the available arguments 418 | pretty_print_config(base_config) 419 | sys.exit(1) 420 | 421 | if not hyperparameters_range: 422 | return [base_config] 423 | 424 | # Generate all possible combinations of hyperparameters 425 | hyperparameters_combinations = [] 426 | for i in range(len(hyperparameters_range)): 427 | hyperparameter = hyperparameters_range[i] 428 | if not hyperparameters_combinations: 429 | for value in hyperparameter.values: 430 | hyperparameters_combinations.append({hyperparameter.name: value}) 431 | else: 432 | new_combinations = [] 433 | for combination in hyperparameters_combinations: 434 | for value in hyperparameter.values: 435 | new_combination = combination.copy() 436 | new_combination[hyperparameter.name] = value 437 | new_combinations.append(new_combination) 438 | hyperparameters_combinations = new_combinations 439 | 440 | configs: List[Dict[str, Any]] = [] 441 | 442 | for combination in hyperparameters_combinations: 443 | config = deepcopy(base_config) 444 | for key, value in combination.items(): 445 | if key in config["global"]: 446 | try: 447 | config["global"][key] = type(config["global"][key])(value) 448 | except ValueError: 449 | print( 450 | f"{RED}Error: Argument {key} must be of type {type(config['global'][key])}{RESET}" 451 | ) 452 | sys.exit(1) 453 | else: 454 | print( 455 | f"{RED}Error: Argument {key} not found in the code or config{RESET}" 456 | ) 457 | pretty_print_config(base_config) 458 | sys.exit(1) 459 | 460 | configs.append(config) 461 | 462 | return configs 463 | 464 | 465 | def print_usage(): 466 | print( 467 | f"{MAGENTA}Usage: paramit [run | cloud | notebook] {RESET}" 468 | ) 469 | print() 470 | print("commands") 471 | print(" run - Run the Python script or notebook") 472 | print(" cloud - Run the Python script or notebook on the cloud") 473 | print(" notebook - Start a Jupyter notebook server with the script or notebook") 474 | 475 | 476 | def get_python_path() -> str: 477 | # Check a paramit specific environment variable 478 | if "HAIPERA_PYTHON_PATH" in os.environ: 479 | return os.environ["HAIPERA_PYTHON_PATH"] 480 | 481 | # Check for VIRTUAL_ENV first (covers venv and conda environments) 482 | if "VIRTUAL_ENV" in os.environ: 483 | return os.path.join(os.environ["VIRTUAL_ENV"], "bin", "python") 484 | 485 | # Check for CONDA_PREFIX (specific to conda environments) 486 | if "CONDA_PREFIX" in os.environ: 487 | return os.path.join(os.environ["CONDA_PREFIX"], "bin", "python") 488 | 489 | # Look for python3 or python in PATH 490 | for cmd in ["python3", "python"]: 491 | python_path = shutil.which(cmd) 492 | if python_path: 493 | return python_path 494 | 495 | # If still not found, try common locations 496 | common_locations = [ 497 | "/usr/bin/python3", 498 | "/usr/local/bin/python3", 499 | "/usr/bin/python", 500 | "/usr/local/bin/python", 501 | "C:\\Python\\python.exe", 502 | "C:\\Program Files\\Python\\python.exe", 503 | ] 504 | for location in common_locations: 505 | if os.path.exists(location): 506 | return location 507 | raise FileNotFoundError( 508 | "Could not find a Python interpreter. Please set the HAIPERA_PYTHON_PATH environment variable." 509 | ) 510 | 511 | 512 | def is_package_installed(package_name: str) -> bool: 513 | python_path = get_python_path() 514 | try: 515 | result = subprocess.run( 516 | [python_path, "-m", "pip", "show", package_name], 517 | capture_output=True, 518 | text=True, 519 | ) 520 | return result.returncode == 0 521 | except subprocess.CalledProcessError: 522 | return False 523 | 524 | 525 | def run_code(source_code: str, python_path: str, cwd: str, script_path: str) -> None: 526 | with tempfile.NamedTemporaryFile("w", delete=False) as temp_file: 527 | # Write the __file__ variable at the top of the file to the original script path 528 | temp_file.write(f"__file__ = {repr(os.path.abspath(script_path))}\n") 529 | 530 | # Write the code to set the original directory as the working directory 531 | temp_file.write(f"import os\nos.chdir({repr(os.path.dirname(os.path.abspath(script_path)))})\n") 532 | 533 | temp_file.write(source_code) 534 | temp_file_path = temp_file.name 535 | 536 | log_file_path = os.path.join(cwd, "console.log") 537 | try: 538 | output = subprocess_tee.run( 539 | f"{python_path} -u {temp_file_path}", 540 | cwd=cwd, 541 | shell=True, 542 | ) 543 | 544 | # Save 545 | with open(log_file_path, "w") as f: 546 | f.write(output.stdout) 547 | 548 | except subprocess.CalledProcessError as e: 549 | return e.stderr 550 | finally: 551 | os.unlink(temp_file_path) 552 | 553 | 554 | def main(): 555 | if len(sys.argv) < 3: 556 | print_usage() 557 | sys.exit(1) 558 | 559 | try: 560 | mode = ParamitMode(sys.argv[1]) 561 | except ValueError: 562 | print_usage() 563 | sys.exit(1) 564 | 565 | mode = ParamitMode(sys.argv[1]) 566 | 567 | if mode == ParamitMode.CLOUD: 568 | print( 569 | f"{MAGENTA}Cloud runs are in development. Please sign up on the waitlist for updates at https://www.haipera.com{RESET}" 570 | ) 571 | sys.exit(1) 572 | 573 | path = sys.argv[2] 574 | if not os.path.exists(path): 575 | print(f"{RED}Error: File {path} does not exist{RESET}") 576 | sys.exit(1) 577 | 578 | cli_args = parse_args(sys.argv[3:]) 579 | hyperparameters = expand_args_dict(cli_args) 580 | 581 | if ( 582 | not path.endswith(".py") 583 | and not path.endswith(".toml") 584 | and not path.endswith(".ipynb") 585 | ): 586 | print( 587 | f"{RED}Error: File {path} is not a Python or TOML or Notebook file{RESET}" 588 | ) 589 | sys.exit(1) 590 | 591 | if path.endswith(".toml"): 592 | config = load_config_file(path) 593 | 594 | try: 595 | ParamitMetadata(**config["meta"]) 596 | except Exception: 597 | print( 598 | f"{RED}Error: The config file is not a valid paramit config file{RESET}" 599 | ) 600 | 601 | if not os.path.exists(config["meta"]["script_path"]): 602 | print( 603 | f"{RED}Error: Python file {config['meta']['script_path']} does not exist{RESET}" 604 | ) 605 | sys.exit(1) 606 | 607 | with open(config["meta"]["script_path"], "r") as f: 608 | if config["meta"]["script_path"].endswith(".ipynb"): 609 | code = convert_ipynb_to_py(config["meta"]["script_path"]) 610 | elif config["meta"]["script_path"].endswith(".py"): 611 | code = f.read() 612 | else: 613 | print( 614 | f"{RED}Error: Python file {config['meta']['script_path']} is not a Python or Notebook file{RESET}" 615 | ) 616 | sys.exit(1) 617 | 618 | elif path.endswith(".py"): 619 | with open(path, "r") as f: 620 | code = f.read() 621 | elif path.endswith(".ipynb"): 622 | code = convert_ipynb_to_py(path) 623 | 624 | try: 625 | # We do an extra check here to catch syntax errors w/ helpful messages 626 | ast.parse(code) 627 | except SyntaxError as e: 628 | e.filename = path 629 | print(f"{RED}SyntaxError: {e}{RESET}") 630 | sys.exit(1) 631 | 632 | tree = cst.parse_module(code) 633 | 634 | config_path = path.replace(".py", ".toml").replace(".ipynb", ".toml") 635 | 636 | if help_in_args(sys.argv[3:]): 637 | generated_config_file = generate_config_file(tree, path) 638 | print(f"{MAGENTA}Usage: paramit run [args]{RESET}") 639 | pretty_print_config(generated_config_file) 640 | sys.exit(0) 641 | 642 | elif not os.path.exists(config_path): 643 | generated_config = generate_config_file(tree, path) 644 | with open(config_path, "wb") as f: 645 | tomli_w.dump(generated_config, f) 646 | 647 | elif not path.endswith(".toml"): 648 | print( 649 | f"{YELLOW}Warning: Configuration file {config_path} already exists{RESET}" 650 | ) 651 | overwrite = input("Do you want to overwrite it? (y/n): ") 652 | if overwrite.lower() == "y": 653 | generated_config = generate_config_file(tree, path) 654 | 655 | with open(config_path, "wb") as f: 656 | tomli_w.dump(generated_config, f) 657 | 658 | config = load_config_file(config_path) 659 | python_path = config["meta"]["python_path"] 660 | orig_script_path = config["meta"]["script_path"] 661 | 662 | experiment_configs = generate_configs_from_hyperparameters(config, hyperparameters) 663 | 664 | if len(experiment_configs) > 100: 665 | print(f"{YELLOW}Warning: Running {len(experiment_configs)} experiments{RESET}") 666 | confirm = input("Do you want to continue? (y/n): ") 667 | if confirm.lower() != "y": 668 | sys.exit(0) 669 | elif len(experiment_configs) == 0: 670 | print(f"{YELLOW}Warning: No experiments to run{RESET}") 671 | sys.exit(0) 672 | elif len(experiment_configs) == 1: 673 | pass 674 | else: 675 | print(f"{GREEN}Running {len(experiment_configs)} experiments{RESET}") 676 | 677 | if mode == ParamitMode.NOTEBOOK and len(experiment_configs) > 1: 678 | print("Notebook mode only supports running a single experiment") 679 | sys.exit(1) 680 | 681 | for experiment_config in experiment_configs: 682 | experiment_id = ( 683 | datetime.datetime.now().strftime("%Y-%m-%d-%H-%M-%S") 684 | + "-" 685 | + str(uuid.uuid4())[0:8] 686 | ) 687 | experiment_dir = os.path.join("reports", experiment_id) 688 | os.makedirs(experiment_dir, exist_ok=True) 689 | base_name = os.path.splitext(os.path.basename(path))[0] 690 | 691 | # Save the config file in the experiment directory 692 | with open(os.path.join(experiment_dir, base_name + ".toml"), "wb") as f: 693 | tomli_w.dump(experiment_config, f) 694 | 695 | modified_tree = set_global_variables_from_config(tree, experiment_config) 696 | 697 | source_code = modified_tree.code 698 | 699 | if mode == ParamitMode.RUN: 700 | with open(os.path.join(experiment_dir, base_name + ".py"), "w") as f: 701 | f.write(source_code) 702 | 703 | if path.endswith(".ipynb"): 704 | notebook_path = os.path.join(experiment_dir, base_name + ".ipynb") 705 | with open(notebook_path, "w") as f: 706 | f.write(convert_source_code_to_ipynb(source_code)) 707 | 708 | print(f"Running with the Python interpreter at {python_path}") 709 | run_code(source_code, python_path, experiment_dir, orig_script_path) 710 | 711 | elif mode == ParamitMode.NOTEBOOK: 712 | ipykernel_is_installed = is_package_installed("ipykernel") 713 | if not ipykernel_is_installed: 714 | print( 715 | "ipykernel is not installed. Please install it to use notebook mode" 716 | ) 717 | sys.exit(1) 718 | 719 | notebook_path = os.path.join(experiment_dir, base_name + ".ipynb") 720 | with open(notebook_path, "w") as f: 721 | f.write(convert_source_code_to_ipynb(source_code)) 722 | print("Starting Jupyter notebook server!\n") 723 | kernel_name = os.path.basename(os.path.dirname(path)) 724 | if not is_jupyter_kernel_installed(kernel_name): 725 | subprocess.run( 726 | [ 727 | python_path, 728 | "-m", 729 | "ipykernel", 730 | "install", 731 | "--name", 732 | kernel_name, 733 | "--user", 734 | ], 735 | check=True, 736 | ) 737 | subprocess.run( 738 | [ 739 | "jupyter", 740 | "notebook", 741 | notebook_path, 742 | "--MultiKernelManager.default_kernel_name", 743 | kernel_name, 744 | "--notebook-dir", 745 | experiment_dir, 746 | ], 747 | check=True, 748 | ) 749 | -------------------------------------------------------------------------------- /src/paramit/cli/__main__.py: -------------------------------------------------------------------------------- 1 | from . import main 2 | 3 | if __name__ == "__main__": 4 | main() -------------------------------------------------------------------------------- /src/paramit/constants.py: -------------------------------------------------------------------------------- 1 | MAGENTA = "\033[95m" 2 | YELLOW = "\033[33m" 3 | GREEN = "\033[32m" 4 | RED = "\033[91m" 5 | RESET = "\033[0m" 6 | -------------------------------------------------------------------------------- /src/paramit/cuda.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | import re 3 | from typing import Optional, Tuple 4 | 5 | __all__ = ["get_cuda_version"] 6 | 7 | 8 | def get_cuda_version() -> Optional[Tuple[int, int]]: 9 | try: 10 | output = subprocess.check_output(["nvcc", "--version"]).decode("utf-8") 11 | version = re.search(r"release (\S+),", output) 12 | if version: 13 | version = version.group(1) 14 | major, minor = version.split(".") 15 | return int(major), int(minor) 16 | except Exception: 17 | return None 18 | -------------------------------------------------------------------------------- /src/paramit/nb.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import jupytext 4 | from jupyter_core.paths import jupyter_path 5 | 6 | __all__ = [ 7 | "convert_ipynb_to_py", 8 | "convert_py_to_ipynb", 9 | "convert_source_code_to_ipynb", 10 | "is_jupyter_kernel_installed", 11 | ] 12 | 13 | 14 | def convert_ipynb_to_py(ipynb_file: str) -> str: 15 | notebook = jupytext.read(ipynb_file) 16 | py_contents = jupytext.writes(notebook, fmt="py:percent") 17 | return py_contents 18 | 19 | 20 | def convert_py_to_ipynb(py_file: str) -> str: 21 | notebook = jupytext.read(py_file) 22 | ipynb_contents = jupytext.writes(notebook, fmt="ipynb") 23 | return ipynb_contents 24 | 25 | 26 | def convert_source_code_to_ipynb(source_code: str) -> str: 27 | notebook = jupytext.reads(source_code, fmt="py:percent") 28 | ipynb_contents = jupytext.writes(notebook, fmt="ipynb") 29 | return ipynb_contents 30 | 31 | 32 | def is_jupyter_kernel_installed(kernel_name): 33 | for kernel_dir in jupyter_path("kernels"): 34 | spec_file = os.path.join(kernel_dir, kernel_name, "kernel.json") 35 | if os.path.exists(spec_file): 36 | return True 37 | return False 38 | --------------------------------------------------------------------------------