Skip to content

Commit b948ff0

Browse files
committed
Created find-project-root
1 parent 7c9decd commit b948ff0

18 files changed

Lines changed: 627 additions & 0 deletions

File tree

find-project-root/docs/LICENSE.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# 🏛️ MIT License
2+
3+
**Copyright © 2026 [Adam Lui](https://github.com/adamlui)**
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.

find-project-root/docs/README.md

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
<a id="top"></a>
2+
3+
# > find-project-root
4+
5+
<a href="https://github.com/adamlui/python-utils/releases/tag/find-project-root-1.0.0">
6+
<img height=31 src="https://img.shields.io/badge/Latest_Build-1.0.0-32fcee.svg?logo=icinga&logoColor=white&labelColor=464646&style=for-the-badge"></a>
7+
<a href="https://github.com/adamlui/python-utils/blob/main/find-project-root/docs/LICENSE.md">
8+
<img height=31 src="https://img.shields.io/badge/License-MIT-f99b27.svg?logo=internetarchive&logoColor=white&labelColor=464646&style=for-the-badge"></a>
9+
<a href="https://www.codefactor.io/repository/github/adamlui/python-utils">
10+
<img height=31 src="https://img.shields.io/codefactor/grade/github/adamlui/python-utils?label=Code+Quality&logo=codefactor&logoColor=white&labelColor=464646&color=a0fc55&style=for-the-badge"></a>
11+
<a href="https://sonarcloud.io/component_measures?metric=new_vulnerabilities&id=adamlui_python-utils">
12+
<img height=31 src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fsonarcloud.io%2Fapi%2Fmeasures%2Fcomponent%3Fcomponent%3Dadamlui_python-utils%26metricKeys%3Dvulnerabilities&query=%24.component.measures.0.value&style=for-the-badge&logo=sonarcloud&logoColor=white&labelColor=464646&label=Vulnerabilities&color=fafc74"></a>
13+
14+
> ### _Locate project root via custom markers._
15+
16+
## About
17+
18+
**find-project-root** is a lightweight utility that traverses up from a given path until it finds a project marker.
19+
20+
- Minimal dependencies — only uses [project-markers](project-markers-gh) (~6 KB module)
21+
- Path flexibility — accepts strings, `Path` objects, or current working dir
22+
- Customizable markers — provide your own or use defaults
23+
- Multi-Python support — from Python 2.6 thru 3.15+
24+
25+
## Installation
26+
27+
```bash
28+
pip install find-project-root
29+
```
30+
31+
## API usage
32+
33+
```py
34+
import find_project_root
35+
36+
# Find from current dir
37+
root = find_project_root()
38+
print(root) # e.g. /home/user/projects/my-project
39+
```
40+
41+
_Note: Most type checkers will falsely warn_ `find_project_root` _is not a callable module because they cannot analyze runtime behavior (where the module is replaced w/ a function for cleaner, direct access). You can safely suppress such warnings using_ `# type: ignore`.
42+
43+
### Available options:
44+
45+
Name | Type | Description | Default Value
46+
------------|-------------------------|------------------------------------|------------------------------------------------------------
47+
`path` | `str`, `Path` or `None` | Starting directory to search from. | `None` (current working dir)
48+
`max_depth` | `int` | Max levels to traverse up. | `9`
49+
`markers` | `List[str]` or `None` | Custom marker files to look for. | [`project-markers`](project-markers-json) list
50+
51+
### Examples:
52+
53+
54+
Start from specific path:
55+
56+
```py
57+
root = find_project_root(path='assets/images')
58+
```
59+
60+
Limit search depth:
61+
```py
62+
root = find_project_root(max_depth=3)
63+
```
64+
65+
Use custom markers:
66+
```py
67+
root = find_project_root(markers=['.git', 'pyproject.toml', 'requirements.txt'])
68+
```
69+
70+
Combine options:
71+
```py
72+
root = find_project_root(path='src', max_depth=5, markers=['.git'])
73+
```
74+
75+
## MIT License
76+
77+
Copyright © 2026 [Adam Lui](https://github.com/adamlui).
78+
79+
#
80+
81+
<a href="#top">Back to top ↑</a>
82+
83+
[project-markers-gh]: https://github.com/adamlui/python-utils/tree/main/project-markers
84+
[project-markers-json]: https://github.com/adamlui/python-utils/blob/main/project-markers/src/project_markers/project_markers.json

find-project-root/noxfile.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
from pathlib import Path
2+
from types import SimpleNamespace as sn
3+
4+
import nox
5+
6+
pkg = sn(dir=Path(__file__).parent.name)
7+
pkg.name = pkg.dir.replace('-', '_')
8+
paths = sn(utils=sn(bump='utils/bump.py', clean='utils/clean.py', publish='utils/publish.sh'))
9+
10+
def session(func) : return nox.session(venv_backend='none')(func)
11+
12+
# SESSIONS
13+
14+
@session
15+
def dev(session) : session.run('pip', 'install', '-e', '.')
16+
17+
@session
18+
def bump_patch(session, no_push=True):
19+
cmd = ['py', paths.utils.bump, '--patch']
20+
if no_push : cmd.append('--no-push')
21+
session.run(*cmd, *session.posargs)
22+
@session
23+
def bump_minor(session, no_push=True):
24+
cmd = ['py', paths.utils.bump, '--minor']
25+
if no_push : cmd.append('--no-push')
26+
session.run(*cmd, *session.posargs)
27+
@session
28+
def bump_feat(session, no_push=True):
29+
bump_minor(session, no_push)
30+
@session
31+
def bump_major(session, no_push=True):
32+
cmd = ['py', paths.utils.bump, '--major']
33+
if no_push : cmd.append('--no-push')
34+
session.run(*cmd, *session.posargs)
35+
36+
@session
37+
def build(session) : clean(session) ; session.run('py', '-m', 'build') ; print('Build complete!')
38+
@session
39+
def publish(session) : session.run('bash', paths.utils.publish, *session.posargs)
40+
41+
@session
42+
def deploy_patch(session) : bump_patch(session, no_push=False) ; build(session) ; publish(session)
43+
@session
44+
def deploy_minor(session) : bump_minor(session, no_push=False) ; build(session) ; publish(session)
45+
@session
46+
def deploy_feat(session) : deploy_minor(session)
47+
@session
48+
def deploy_major(session) : bump_major(session, no_push=False) ; build(session) ; publish(session)
49+
50+
@session
51+
def clean(session) : session.run('py', paths.utils.clean)

find-project-root/pyproject.toml

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
[build-system]
2+
requires = [
3+
"setuptools>=61.0",
4+
"wheel"
5+
]
6+
build-backend = "setuptools.build_meta"
7+
8+
[project]
9+
name = "find-project-root"
10+
version = "1.0.0"
11+
description = "Locate project root via custom markers."
12+
authors = [
13+
{ name = "Adam Lui", email = "adam@kudoai.com" }
14+
]
15+
readme = "docs/README.md"
16+
license = "MIT"
17+
license-files = [
18+
"docs/LICENSE.md"
19+
]
20+
dependencies = [
21+
"project-markers>=1.0.0,<2.0.0"
22+
]
23+
requires-python = ">=2.6,<4.0"
24+
keywords = [
25+
"auto-detect",
26+
"dev-tool",
27+
"directory",
28+
"filesystem",
29+
"git-root",
30+
"locate-root",
31+
"marker-files",
32+
"path-finder",
33+
"project-root",
34+
"root-detection"
35+
]
36+
classifiers = [
37+
"Development Status :: 5 - Production/Stable",
38+
"Intended Audience :: Developers",
39+
"Intended Audience :: Information Technology",
40+
"Natural Language :: English",
41+
"Operating System :: OS Independent",
42+
"Programming Language :: Python",
43+
"Programming Language :: Python :: 2",
44+
"Programming Language :: Python :: 2.6",
45+
"Programming Language :: Python :: 2.7",
46+
"Programming Language :: Python :: 3",
47+
"Programming Language :: Python :: 3.8",
48+
"Programming Language :: Python :: 3.9",
49+
"Programming Language :: Python :: 3.10",
50+
"Programming Language :: Python :: 3.11",
51+
"Programming Language :: Python :: 3.12",
52+
"Programming Language :: Python :: 3.13",
53+
"Programming Language :: Python :: 3.14",
54+
"Programming Language :: Python :: 3.15",
55+
"Topic :: Software Development",
56+
"Topic :: Software Development :: Version Control",
57+
"Topic :: System :: Filesystems",
58+
"Topic :: System :: Systems Administration",
59+
"Topic :: Utilities"
60+
]
61+
62+
[project.urls]
63+
Changelog = "https://github.com/adamlui/python-utils/releases/tag/find-project-root-1.0.0"
64+
Documentation = "https://github.com/adamlui/python-utils/tree/main/find-project-root/docs"
65+
Funding = "https://github.com/sponsors/adamlui"
66+
Homepage = "https://github.com/adamlui/python-utils/tree/main/find-project-root/#readme"
67+
Issues = "https://github.com/adamlui/python-utils/issues"
68+
"PyPI Stats" = "https://pepy.tech/projects/find-project-root"
69+
Releases = "https://github.com/adamlui/python-utils/releases"
70+
Repository = "https://github.com/adamlui/python-utils"
71+
72+
[project.optional-dependencies]
73+
dev = [
74+
"nox>=2026.2.9",
75+
"tomli>=2.0.0,<3.0.0",
76+
"tomli-w>=0.1.0,<2.0.0"
77+
]
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import sys
2+
3+
from .api import find_project_root
4+
5+
sys.modules[__name__] = find_project_root # type: ignore[assignment]
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import os, sys
2+
if sys.version_info >= (3, 4) : from pathlib import Path
3+
else : Path = str
4+
from typing import Optional, Union, List
5+
6+
import project_markers
7+
8+
def find_project_root(
9+
path: Optional[Union[str, Path]] = None,
10+
max_depth: int = 9,
11+
markers: Optional[List[str]] = None
12+
) -> Optional[str]:
13+
current_dir = os.getcwd() if path is None else str(path)
14+
if not os.path.exists(current_dir):
15+
raise ValueError(f'Path does not exist: {os.path.abspath(current_dir)}')
16+
markers = project_markers if markers is None else markers # type: ignore
17+
for _ in range(max_depth):
18+
try : dir_files = os.listdir(current_dir)
19+
except (OSError, IOError, PermissionError):
20+
return None
21+
if any(marker in dir_files for marker in markers): # type: ignore
22+
return str(current_dir)
23+
parent = os.path.dirname(current_dir)
24+
if parent == current_dir : break # at fs root
25+
current_dir = parent
26+
return None

find-project-root/src/find_project_root/cli/__init__.py

Whitespace-only changes.

find-project-root/src/find_project_root/cli/__main__.py

Whitespace-only changes.

find-project-root/utils/bump.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import argparse, re, sys
2+
from pathlib import Path
3+
from types import SimpleNamespace as sn
4+
5+
from lib import data, git, log, toml
6+
7+
paths = sn(root=Path(__file__).parent.parent)
8+
paths.pyproject = paths.root / 'pyproject.toml'
9+
paths.readme = paths.root / 'docs/README.md'
10+
paths.util_msgs = paths.root / 'utils/data/messages.json'
11+
12+
msgs = sn(**{ key:val['message'] for key,val in data.json.read(paths.util_msgs)['bump'].items() })
13+
14+
def parse_args():
15+
argp = argparse.ArgumentParser(description=msgs.app_DESC, add_help=False)
16+
argp.add_argument('-M', '--major', action='store_true', help=msgs.help_MAJOR)
17+
argp.add_argument('-m', '--minor', action='store_true', help=msgs.help_MINOR)
18+
argp.add_argument('-p', '--patch', action='store_true', help=msgs.help_PATCH)
19+
argp.add_argument('-n', '--no-commit', '--skip-commit', action='store_true', help=msgs.help_NO_COMMIT)
20+
argp.add_argument('-N', '--no-push', '--skip-push', action='store_true', help=msgs.help_NO_PUSH)
21+
argp.add_argument('-h', '--help', action='help', help=msgs.help_HELP)
22+
return argp.parse_args()
23+
24+
def init_vers(project, bump_type):
25+
prev_ver = project.version
26+
major, minor, patch = map(int, prev_ver.split('.'))
27+
if bump_type == 'major' : patch = 0 ; minor = 0 ; major += 1
28+
elif bump_type == 'minor' : patch = 0 ; minor += 1
29+
elif bump_type == 'patch' : patch += 1
30+
new_ver = f'{major}.{minor}.{patch}'
31+
return prev_ver, new_ver
32+
33+
def bump_pyproject_vers(pyproject, project, new_ver):
34+
35+
# Bump project.version
36+
pyproject['project']['version'] = new_ver
37+
toml.write(paths.pyproject, pyproject)
38+
log.success(msgs.log_BUMPED_PROJECT_VER.format(prev_ver=project.version, **locals()))
39+
40+
# Bump project.urls['Releases']
41+
new_ver_tag = f'{project.name}-{new_ver}'
42+
changelog_url = f"{project.urls['Releases']}/tag/{new_ver_tag}"
43+
log.debug(f'{msgs.log_GENERATED_CLOG_URL}: {changelog_url}')
44+
log.info(f'{msgs.log_UPDATING_CLOG_URL_IN} pyproject.toml...')
45+
pyproject['project']['urls']['Changelog'] = changelog_url
46+
toml.write(paths.pyproject, pyproject)
47+
log.success(msgs.log_BUMPED_CLOG_URL_VER_TAG.format(**locals()))
48+
49+
def update_readme_vers(new_ver):
50+
log.info(f'{msgs.log_UPDATING_VERS_IN} docs/README.md...')
51+
updated_readme_content = re.sub(r'\b(?>\d{1,3}\.\d{1,3}\.\d{1,3})\b', new_ver, data.file.read(paths.readme))
52+
data.file.write(paths.readme, updated_readme_content)
53+
log.success(msgs.log_UPDATED_README_VERS.format(**locals()))
54+
55+
def main():
56+
57+
# Parse args
58+
args = parse_args()
59+
bump_type = 'major' if args.major else 'minor' if args.minor else 'patch' if args.patch else None
60+
if not bump_type:
61+
log.error(msgs.err_MISSING_BUMP_TYPE_ARG)
62+
sys.exit(1)
63+
64+
# Init project data
65+
log.info(f'{msgs.log_LOADING_PYPROJECT.format(pyproject_path=paths.pyproject)}...')
66+
pyproject = toml.read(paths.pyproject)
67+
project = sn(**pyproject['project'])
68+
69+
# Update files
70+
_, new_ver = init_vers(project, bump_type)
71+
bump_pyproject_vers(pyproject, project, new_ver)
72+
update_readme_vers(new_ver)
73+
74+
# Git commit/push
75+
if args.no_commit:
76+
print(f'\n{msgs.log_SKIPPING_GIT_COMMIT}...')
77+
else:
78+
git.init_kudo_sync_bot(msgs)
79+
log.info(f'{msgs.log_COMMITTING_CHANGES}...')
80+
git.commit([str(paths.readme)],
81+
f'Updated {project.name} versions in README URLs to {new_ver}', '-n')
82+
if args.no_push:
83+
print(f'\n{msgs.log_SKIPPING_GIT_PUSH}...')
84+
else:
85+
log.info(f'{msgs.log_PUSHING_CHANGES}...')
86+
git.push()
87+
log.success(f'{msgs.log_PUSHED_ALL_COMMITS}')
88+
git.restore_og_config(msgs)
89+
90+
log.success(f'{msgs.log_SUCCESS}! {project.name} {msgs.log_BUMPED_TO} v{new_ver}!')
91+
92+
if __name__ == '__main__' : main()

find-project-root/utils/clean.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
from pathlib import Path
2+
import shutil
3+
from types import SimpleNamespace as sn
4+
5+
from lib import data, log
6+
7+
def main():
8+
msgs_path = Path(__file__).parent / 'data/messages.json'
9+
msgs = sn(**{ key:val['message'] for key,val in data.json.read(msgs_path)['clean'].items() })
10+
11+
for target in ['dist', 'build', '*_cache', '__pycache__', '*.egg-info']:
12+
for path in Path('.').rglob(target):
13+
if path.is_dir() : shutil.rmtree(path) ; log.info(f'{msgs.log_REMOVED} {path}/')
14+
15+
log.success(f'{msgs.log_CLEAN_COMPLETE}!')
16+
17+
if __name__ == '__main__' : main()

0 commit comments

Comments
 (0)