├── src
├── create_package_setup.py
├── check_inits.py
├── get_installed_packages.py
└── main.py
├── LICENSE
└── README.md
/src/create_package_setup.py:
--------------------------------------------------------------------------------
1 | import os
2 |
3 |
--------------------------------------------------------------------------------
/src/check_inits.py:
--------------------------------------------------------------------------------
1 | import os
2 | import beautifish.icons as bfi
3 | import beautifish.colors as bfc
4 |
5 | def check_all_folders_got_init(root_path:str=os.getcwd()):
6 | # traverse through all subfolders under this root path given
7 | for folder_path, _, _ in os.walk(root_path):
8 | init_file = os.path.join(folder_path,"__init__.py")
9 |
10 | # create __init__.py if it doesnt exist
11 | if not os.path.exists(init_file):
12 | print(f"Creating __init__.py file at: {folder_path}")
13 | with open(init_file,"w") as f:
14 | pass # to create an empty __init__.py in this directory
15 | else:
16 | print(f"{bfc.cyan_text(bfi.DOT)} FOUND __init__.py file at {folder_path}")
17 |
18 | return
19 |
20 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 Kushal
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/src/get_installed_packages.py:
--------------------------------------------------------------------------------
1 | import os
2 | import sys
3 | import ast
4 |
5 | def extract_imports(file_path:str):
6 | if file_path=="":
7 | return
8 |
9 | with open(file_path,'r',encoding='utf-8') as file:
10 | tree = ast.parse(file.read(),file_path)
11 |
12 | user_imports = set()
13 | for node in ast.walk(tree):
14 | if isinstance(node, ast.Import):
15 | for alias in node.names:
16 | user_imports.add(alias.name)
17 | elif isinstance(node, ast.ImportFrom):
18 | user_imports.add(node.module)
19 |
20 | return user_imports
21 |
22 |
23 |
24 | def get_user_installed_packages(root_path:str=os.getcwd()):
25 | packages_used = set()
26 | system_native_packages = set(sys.builtin_module_names)
27 |
28 | for folder_path, _, files in os.walk(root_path):
29 | for file_name in files:
30 | if file_name.endswith('.py'):
31 | file_path = os.path.join(folder_path,file_name)
32 | user_imports = extract_imports(file_path=file_path)
33 | packages_used.update(user_imports)
34 |
35 | imported_packages = packages_used - system_native_packages
36 |
37 | return imported_packages
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Pipme: Package bundler for PyPI
2 |
3 |
4 |
5 |
6 |
7 |
8 | pipme is a tool that simplifies the process of publishing Python projects to PyPI. It automates the process of packaging and uploading projects, making it easy for developers to share their Python packages with the world.
9 |
10 | ```bash
11 | pip install pipme
12 | ```
13 |
14 |
15 |
16 | ## ✨ Features
17 |
18 | - **Easy publishing**: Publish Python projects to PyPI with a single command.
19 | - **Seamless integration**: Secure and reliable package uploads.
20 | - **Automatic packaging**: Automatically creates distribution packages from your project files.
21 | - **Simple usage**: Just provide your PyPI access token, and pipme takes care of the rest.
22 |
23 |
24 |
25 | ## 🌻 Usage
26 |
27 | To use pipme, simply install it via pip:
28 |
29 | ```bash
30 | pip install pipme
31 | ```
32 |
33 | Then, navigate to your project root directory and run the following command to publish your project to PyPI:
34 |
35 | ```bash
36 | pipme
37 | ```
38 |
39 | - creates new package folder with your project code inside it
40 | - adds `__init__.py` files to every directory as a necessary step
41 | - checks if such pip package already exists or not
42 | - creates `dist_wheel` to create the build folder
43 | - uploads to pip after requesting `access_token` from user
44 |
45 |
46 |
47 | ## 🤝 Contributions
48 |
49 | Contributions are welcome!
50 |
51 | If you have ideas for improvements, new features, or bug fixes, please feel free to open an issue or submit a pull request.
52 |
53 | ## ⚖️ License
54 |
55 | This project is licensed under the MIT License - see the LICENSE file for details.
56 |
57 |
58 |
59 |
Kushal Kumar 2025 • All rights reserved
60 |
--------------------------------------------------------------------------------
/src/main.py:
--------------------------------------------------------------------------------
1 | import os
2 | import re
3 | import shutil
4 | import requests
5 | import subprocess
6 |
7 | """
8 | # valid package name = valid_user_package_name
9 | # valid package version = ask_user_version
10 | # parent_dir = parent_dir
11 | # packages installed = packages_installed
12 | # package folder is created with setup.py and markdown.md files in it
13 | """
14 |
15 |
16 | # beautifish imports ++++++++++++++++++++++++++++++++++++++
17 | import beautifish.templates as bft
18 | import beautifish.colors as bfc
19 | import beautifish.decorators as bfd
20 | # file imports ++++++++++++++++++++++++++++++++++++++
21 | from pipme.check_inits import *
22 | from pipme.get_installed_packages import get_user_installed_packages
23 | # +++++++++++++++++++++++++++++++++++++++++++++++++++
24 |
25 | command = {
26 | "bdist_wheel":"python setup.py sdist bdist_wheel",
27 | "twine_upload":"twine upload --repository-url https://upload.pypi.org/legacy/ -u __token__ -p dist/*"
28 | }
29 |
30 | # ————————————————————————————————————————————————————————————————————————————————————————
31 | # UTIL FUNCTIONS —————————————————————————————————————————————————————————————————————————
32 |
33 | def is_valid_version(input_string):
34 | pattern = r'^\d+(\.\d+){2}$'
35 | return re.match(pattern, input_string) is not None
36 |
37 |
38 | def convert_to_valid_package_name(name):
39 | # Replace non-ASCII letters, digits, underscores, and hyphens with underscores
40 | valid_name = re.sub(r'[^a-zA-Z0-9_-]', '_', name)
41 | # Ensure the name starts and ends with an ASCII letter or digit
42 | valid_name = re.sub(r'^[^a-zA-Z0-9]*|[^a-zA-Z0-9]*$', '', valid_name)
43 | # Convert the name to lowercase
44 | valid_name = valid_name.lower()
45 | return valid_name
46 |
47 |
48 | def create_package_and_setup(parent_dir,package_name:str):
49 | SETUPPY_FILE_CODE = """\
50 | from setuptools import setup, find_packages
51 |
52 | setup(
53 | name='',
54 | version='',
55 | packages=find_packages(),
56 | install_requires=[]
57 | )
58 | """
59 | package_folder = os.path.join(parent_dir, package_name)
60 | os.makedirs(package_folder,exist_ok=True)
61 |
62 | # create setup.py file in new folder
63 | setupPy_file_path = os.path.join(package_folder,"setup.py")
64 | with open(setupPy_file_path,"w") as f:
65 | f.write(SETUPPY_FILE_CODE)
66 |
67 |
68 | # create readme.md
69 | readmeMD_file_path = os.path.join(package_folder,"readme.md")
70 | with open(readmeMD_file_path,"w") as f:
71 | pass
72 |
73 |
74 |
75 | def check_package_exists_in_pypi(package_name:str):
76 | PYPI_API_ENDPOINT = f"https://pypi.org/pypi/{package_name}/json"
77 | response = requests.get(PYPI_API_ENDPOINT)
78 | if response.status_code==200:
79 | return False
80 | elif response.status_code==404:
81 | return True
82 | else:
83 | print("Unexpected status code while reaching out to PyPI API:",response.status_code)
84 | return None
85 |
86 |
87 | def migrate_files(source_path,destination_path):
88 | try:
89 | shutil.copytree(source_path, destination_path)
90 | print(f"Files migrated to new folder...")
91 | except FileExistsError:
92 | print(f"Destination directory {destination_path} already exists")
93 |
94 |
95 | def modify_setup_file(setup_file_path,package_name:str,version:str,packages_to_install:list):
96 | # reading setup.py contents
97 | with open(setup_file_path,'r') as file:
98 | setup_contents = file.readlines()
99 |
100 | # updating name and version attributes
101 | modifications = []
102 | for line in setup_contents:
103 | if line.strip().startswith('name='):
104 | modifications.append(f" name='{package_name}',\n")
105 | elif line.strip().startswith('version='):
106 | modifications.append(f" version='{version}',\n")
107 | elif line.strip().startswith('install_requires='):
108 | arr = ""
109 | for package in packages_to_install:
110 | arr += f"'{package}',"
111 | arr = arr[:len(arr)-1]
112 | modifications.append(f" install_requires=[{arr}],\n")
113 | else:
114 | modifications.append(line)
115 |
116 | # write into the setup.py with modifications
117 | with open(setup_file_path,'w') as file:
118 | file.writelines(modifications)
119 |
120 |
121 | def change_relative_imports(root_folder, old_package_name, new_package_name):
122 | for folder_path, _, files in os.walk(root_folder):
123 | for file_name in files:
124 | if file_name.endswith('.py'):
125 | file_path = os.path.join(folder_path, file_name)
126 | with open(file_path, 'r') as f:
127 | content = f.read()
128 | # Use regular expression to find and replace relative imports
129 | new_content = re.sub(
130 | fr'from\s+{old_package_name}(\s+|\.)',
131 | fr'from {new_package_name}\g<1>',
132 | content
133 | )
134 | # Write the modified content back to the file
135 | with open(file_path, 'w') as f:
136 | f.write(new_content)
137 |
138 |
139 | def rename_folder(old_folder_path,new_folder_name):
140 | # Get the parent directory of the folder
141 | parent_directory = os.path.dirname(old_folder_path)
142 | # Create the new folder path
143 | new_folder_path = os.path.join(parent_directory, new_folder_name)
144 | # Rename the folder
145 | os.rename(old_folder_path, new_folder_path)
146 | print(f"Folder '{old_folder_path}' renamed to '{new_folder_name}'")
147 |
148 | # —————————————————————————————————————————————————————————————————————————————————————————
149 | # —————————————————————————————————————————————————————————————————————————————————————————
150 |
151 |
152 | # ——— MAIN FUNCTION ———————————————————————————————————————————————————————————————————————
153 | # —————————————————————————————————————————————————————————————————————————————————————————
154 | def run_cli(shell_path:str=os.getcwd()):
155 | """
156 | this function runs when user runs "pipme" in the shell
157 | """
158 |
159 | bft.banner(msg="pipme",color="blue")
160 |
161 | # ask confirmation if this is the root folder where they want to pip ------------------------------------------
162 | print(f"Using {bfc.blue_text(shell_path)} as root directory.")
163 | ask_user = bft.input(f"Continue? (y/N): ",color="blue")
164 | if ask_user=="" or ask_user.lower().split()=='n':
165 | return
166 |
167 |
168 | # ask if they registered at pypi.org and have an access token ready or not ------------------------------------
169 | ask_user = input(f"Are you registered at {bfc.blue_text("https://pypi.org/")} and have an {bfc.blue_text("access token")} (y/N): ")
170 | if ask_user=='' or ask_user.lower().split()=='n':
171 | return
172 |
173 |
174 | # create __init__.py files to each subfolder ------------------------------------------------------------------
175 | # this creates __init_.py files inside all subfolders if it doesnt already exist
176 | bft.seperator2(header="Scanning for __init__.py files")
177 | check_all_folders_got_init(shell_path)
178 |
179 |
180 | # get all packages imported into the folder -------------------------------------------------------------------
181 | dummy = set()
182 | packages_installed = get_user_installed_packages(shell_path)
183 | for s in packages_installed:
184 | dummy.add(s.split('.')[0] if '.' in s else s)
185 | packages_installed = list(dummy)
186 | bft.success(msg=" ",title="Found packages used in project")
187 | bft.list(packages_installed)
188 | # this will go into the requirements array-------->>
189 |
190 |
191 | # get parent dir path -----------------------------------------------------------------------------------------
192 | parent_dir = os.path.dirname(shell_path)
193 | user_package_name = os.path.basename(shell_path)
194 | valid_user_package_name = convert_to_valid_package_name(user_package_name)
195 |
196 | # check if this name already exists and owned by someone in pypi ----------------------------------------------
197 | check_pip_existence = check_package_exists_in_pypi(valid_user_package_name)
198 | if(check_pip_existence==False or check_pip_existence==None):
199 | flag = True
200 | while flag:
201 | is_owner = input(f"Your package name '{valid_user_package_name}' already is owned by someone. If its owned by you, type 'yes': ")
202 | if(is_owner.lower()=="yes"):
203 | flag = False
204 | break
205 | new_input = input("Enter a different package name you wish to use: ")
206 | new_input = convert_to_valid_package_name(new_input)
207 | print(f"Reformatted to: {new_input}...")
208 | valid_user_package_name = new_input
209 | if(check_package_exists_in_pypi(new_input)==True):
210 | flag=False
211 | valid_user_package_name = new_input
212 |
213 | print(f"{bfi.DOT} Package name used: {bfc.cyan_text(valid_user_package_name)}")
214 |
215 |
216 |
217 | # ask user for the version they wish to make it ----------------------------------------------------------------
218 | ask_user_version = input(f"What's the version you wish to give {bfc.gray_text("(default 1.0.0)")}: ")
219 | # check user input to version's authneticity
220 | if ask_user_version=="":
221 | ask_user_version = "1.0.0"
222 | while not is_valid_version(ask_user_version):
223 | bft.error("Unauthentic version syntax, must be of type num.num.num",mode="spaced")
224 | ask_user_version = input(f"What's the version you wish to give {bfc.gray_text("(default 1.0.0)")}: ")
225 | if ask_user_version=="":
226 | ask_user_version = "1.0.0"
227 |
228 |
229 | # create package folder and setup.py in it ----------------------------------------------------------------------
230 | print(f"Creating package in parent dir and adding setup.py file to it")
231 | create_package_and_setup(parent_dir=parent_dir,package_name=f"{valid_user_package_name}_package")
232 |
233 |
234 | # copy shell_path to new folder ---------------------------------------------------------------------------------
235 | package_path = os.path.join(parent_dir, f"{valid_user_package_name}_package")
236 |
237 |
238 | # migrate files to package_path ---------------------------------------------------------------------------------
239 | print(f"Migrating files from {bfc.gray_text(shell_path)} to {bfc.orange_text(package_path)}")
240 | migrate_files(shell_path,os.path.join(package_path,valid_user_package_name))
241 |
242 |
243 | # modify setup files to contain name and version number ---------------------------------------------------------
244 | print(f"Adding name and version to setup.py...")
245 | modify_setup_file(setup_file_path=os.path.join(package_path,"setup.py"),package_name=valid_user_package_name,version=ask_user_version,packages_to_install=packages_installed)
246 |
247 |
248 | # change relative imports ---------------------------------------------------------------------------------------
249 | if os.path.basename(shell_path)!=valid_user_package_name:
250 | print(f"Changing relative imports from {os.path.basename(shell_path)} to {valid_user_package_name}")
251 | change_relative_imports(os.path.join(package_path,valid_user_package_name),os.path.basename(shell_path),valid_user_package_name)
252 |
253 |
254 | # cross-check __init__.py files exist in new cleaned folder directory or not ------------------------------------
255 | check_all_folders_got_init(os.path.join(package_path,valid_user_package_name))
256 |
257 |
258 | # migrate to package directory in shell -------------------------------------------------------------------------
259 | os.chdir(package_path)
260 | print("Migrated shell path to:",bfc.orange_text(os.getcwd()))
261 |
262 |
263 | # run command to create distwheel -------------------------------------------------------------------------------
264 | """ the idea is when package will be installed, twine would also be installed already
265 | as such, its safe to assume twine isnt required as another installation dependency """
266 | print(bfc.gray_text("Running command to create distwheel..."))
267 | distwheel_status = subprocess.run(command["bdist_wheel"],shell=True,capture_output=True,text=True)
268 | print(distwheel_status.stdout)
269 |
270 |
271 | # run command to upload using twine -----------------------------------------------------------------------------
272 | # get access token
273 | access_token = bft.input("Enter the access token: ",color="blue")
274 | if len(access_token)<150:
275 | bft.error("The access token entered seems too short to be precise")
276 | return
277 | # run pip upload command
278 | print(bfc.cyan_text("Running command to upload to pip using twine and access token provided..."))
279 | upload_command = command["twine_upload"].replace("",access_token)
280 | twine_upload_status = subprocess.run(upload_command,shell=True,capture_output=True,text=True)
281 | print(twine_upload_status.stdout)
282 |
283 | print(bfc.orange_text("[^^] Thanks for using Pipme\n\n - by Kushal Kumar (am.kushal02@gmail.com)\n - https://kushalkumarsaha.com\n\n"))
284 |
285 |
286 |
287 |
288 | if __name__=="__main__":
289 | current_shell_path = os.getcwd()
290 | run_cli(current_shell_path)
291 |
--------------------------------------------------------------------------------