12

How do I get the current topmost package, i.e., the name defined in setup.py?

Here is my tree:

.
|-- README.md
|-- the_project_name_for_this_pkg
|   |-- __init__.py
|   |-- __main__.py
|   |-- _config
|   |   `-- foo.conf
|   |-- _data
|   |   `-- logging.yml
|   `-- tests
|       |-- __init__.py
|       `-- test_foo.py   <--- # executing from here
|-- requirements.txt
`-- setup.py

4 directories, 9 files

The only solution I've gotten to work so far is this:

import os
import sys


os.path.basename(sys.path[1])

But this is obviously a bad solution. Other solutions like having a __name__ in my uppermost __init__.py file and using ast.parse to read in the relevant section of setup.py also seems cumbersome.

Other solutions I've tried—by calling them within a unittest.TestCase inheriting class in my tests python [sub]package—include checking sys.modules[__name__], inspect.getmodule & inspect.stack, as well as the answers to these questions:

BTW: In case you were wondering why I want the package name… it's so I can run things like:

import pkg_resources


version   = pkg_resources.require('the_project_name_for_this_pkg')[0].version
data_file = path.join(resource_filename('the_project_name_for_this_pkg', '__init__.py'),
                      '_config', 'data_file.txt')
sinoroc
  • 18,409
  • 2
  • 39
  • 70
A T
  • 13,008
  • 21
  • 97
  • 158
  • 3
    Seems like you are mixing up the name of the project and the name of a top-level package. They are often the same, but there are still many cases when they do not match. – sinoroc Feb 22 '20 at 10:41
  • Within `test_foo.py` it should already be defined within the package itself, just use the package name directly there. – metatoaster Feb 22 '20 at 10:50
  • @metatoaster Where is it defined in the package itself? - I couldn't find it in magic variables, `inspect`, or `sys.modules`. – A T Feb 22 '20 at 13:05
  • It would be whatever you defined in `setup.py`; the package system is bolted onto Python after the fact, and given that string for the package name don't typically change, it's a lot less hassle to just hardcode that same `str` value into `test_foo.py` that rather than trying to come up with ways to resolve that in Python. – metatoaster Feb 23 '20 at 00:10
  • Alternatively, you could leverage EntryPoints - create your own at `setup.py` that would have values that reference the module that has the resources you need. From your code (or any other package, fro that matter) simply query for that, and use that result to feed into `resource_filename` to get what is needed. – metatoaster Feb 23 '20 at 00:15

2 Answers2

6

Not entirely sure what the larger goal is, but maybe you could be interested in reading about importlib.resources as well as importlib.metadata.

Something like the following:

import importlib.metadata
import importlib.resources

version = importlib.metadata.version('SomeProject')
data = importlib.resources.files('top_level_package.sub_package').joinpath('file.txt').read_text()

And more generally, it is near impossible (or not worth the amount of work) to 100% reliably detect the name of the project (SomeProject) from within the code. It is easier to just hard-code it.

Nevertheless here are some techniques, and ideas to retrieve the name of the project from one of its modules:


Update:

I believe some function like the following should return the name of the installed distribution containing the current file:

import pathlib
import importlib_metadata

def get_project_name():
    for dist in importlib_metadata.distributions():
        try:
            relative = pathlib.Path(__file__).relative_to(dist.locate_file(''))
        except ValueError:
            pass
        else:
            if relative in dist.files:
                return dist.metadata['Name']
    return None

Update (February 2021):

Looks like this could become easier thanks to the newly added packages_distributions() function in importlib_metadata:

sinoroc
  • 18,409
  • 2
  • 39
  • 70
  • I wrote an `import ast` answer that figures out the `SomeProject` value… it's not super generic but does work for a few common use cases (and could be trivially extended for more). – A T Feb 22 '20 at 13:07
0

A solution I've been working on:

from os import listdir, path
from contextlib import suppress
import ast



def get_first_setup_py(cur_dir):
    if 'setup.py' in listdir(cur_dir):
        return path.join(cur_dir, 'setup.py')
    prev_dir = cur_dir
    cur_dir = path.realpath(path.dirname(cur_dir))
    if prev_dir == cur_dir:
        raise StopIteration()
    return get_first_setup_py(cur_dir)


setup_py_file_name = get_first_setup_py(path.dirname(__file__))

First pass:

def get_from_setup_py(setup_file): # mostly https://stackoverflow.com/a/47463422
    import importlib.util


    spec = importlib.util.spec_from_file_location('setup', setup_file)
    setup = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(setup)
    # And now access it
    print(setup)

That option did work. So I returned to the ast solution I referenced in the question, and got this second pass to work:

def parse_package_name_from_setup_py(setup_py_file_name):
    with open(setup_py_file_name, 'rt') as f:
        parsed_setup_py = ast.parse(f.read(), 'setup.py')

    # Assumes you have an `if __name__ == '__main__'` block:
    main_body = next(sym for sym in parsed_setup_py.body[::-1]
                     if isinstance(sym, ast.If)).body

    setup_call = next(sym.value
                      for sym in main_body[::-1]
                      if isinstance(sym, ast.Expr)
                      and isinstance(sym.value, ast.Call)
                      and sym.value.func.id in frozenset(('setup',
                                                          'distutils.core.setup',
                                                          'setuptools.setup')))

    package_name = next(keyword
                        for keyword in setup_call.keywords
                        if keyword.arg == 'name'
                        and isinstance(keyword.value, ast.Name))

    # Return the raw string if it is one
    if isinstance(package_name.value, ast.Str):
        return package_name.value.s

    # Otherwise it's a variable defined in the `if __name__ == '__main__'` block:
    elif isinstance(package_name.value, ast.Name):
        return next(sym.value.s
                    for sym in main_body
                    if isinstance(sym, ast.Assign)
                    and isinstance(sym.value, ast.Str)
                    and any(target.id == package_name.value.id
                            for target in sym.targets)
                    )

    else:
        raise NotImplemented('Package name extraction only built for raw strings & '
                             'assigment in the same scope that setup() is called')

Third pass (works for both installed and development versions):

# Originally from https://stackoverflow.com/a/56032725;
# but made more concise and added support whence source
class App(object):
    def get_app_name(self) -> str:
        # Iterate through all installed packages and try to find one
        # that has the app's file in it
        app_def_path = inspect.getfile(self.__class__)
        with suppress(FileNotFoundError):
            return next(
                (dist.project_name
                 for dist in pkg_resources.working_set
                 if any(app_def_path == path.normpath(path.join(dist.location, r[0]))
                        for r in csv.reader(dist.get_metadata_lines('RECORD')))),
                None) or parse_package_name_from_setup_py(
                get_first_setup_py(path.dirname(__file__)))
A T
  • 13,008
  • 21
  • 97
  • 158
  • 1
    I don't see any use case for this. The `setup.py` file is never installed, so it simply can't be read. Sure, it looks like it works when it is executed directly from a clone of the source code repository, but once installed, it can't possibly work. Am I missing something? I don't really see the point of this code. If you want to have access to the name of the project, there are better techniques: https://bitbucket.org/pypa/distlib/issues/102/getting-the-distribution-that-a-module - https://stackoverflow.com/a/22845276/11138259 - https://stackoverflow.com/a/56032725/11138259 - But why? – sinoroc Feb 22 '20 at 13:36
  • If really you want to read the name of the project from `setup.py`, then maybe use [this technique](https://docs.python.org/3/distutils/apiref.html#distutils.core.run_setup) to retrieve the metadata by executing (parts of) the setup. Or place the metadata in a [`setup.cfg`](https://setuptools.readthedocs.io/en/latest/setuptools.html#configuring-setup-using-setup-cfg-files) file which is easier to [parse](https://docs.python.org/3/library/configparser.html) than a `setup.py`. – sinoroc Feb 22 '20 at 14:00
  • Good point on the `setup.py` not being available once installed. So I'll really need someway of `if` conditioning and use a different solution once installed. I'll try [this answer you mentioned](https://stackoverflow.com/a/56032725)… though I am worried that it may get confused between two packages. – A T Feb 22 '20 at 22:18
  • 1
    You should only ever read from the installed distribution's metadata (_develop_ and/or _editable_ modes, also have this metadata available). If the metadata is not readable, then it's your clue that there is a packaging or installation issue, no reason to fallback to reading from `setup.py`. I don't see how it could get confused between two installed distributions. – sinoroc Feb 23 '20 at 11:10
  • @sinoroc - The fallback to read from `setup.py` is if the package isn't installed, or if you have `cd`'d to the directory of the package source and are running `python -m `. Otherwise it won't pick the right file in `pkg_resources.resource_filename`… – A T Feb 24 '20 at 03:59