if __name__ == ‘__main__’ vs Click: Package Entry Points

Disclosure: As an Amazon Associate, I earn from qualifying purchases. Some links in this post are affiliate links — they cost you nothing extra.
⚡ Key Takeaways
  • Entry points change how __name__, __file__, and sys.path behave — relative imports that worked in scripts will break without proper package structure.
  • Click's CliRunner makes testing 10x easier than subprocess-based script testing, and subcommands scale better than separate entry points.
  • Migrating requires fixing data file paths with importlib.resources and understanding the 3ms startup overhead from setuptools wrappers.

The Script That Stopped Working

You write a working Python script with if __name__ == "__main__" at the bottom. It runs fine from the command line. Then you turn it into a package, install it with pip, and suddenly your entry point doesn’t work the way you expected. Import errors appear. Relative imports break. Configuration paths fail.

This happened to me when migrating a collection of data processing scripts into a proper package structure. The if __name__ == "__main__" pattern that worked perfectly for standalone scripts became a maintenance headache once I needed proper CLI arguments, subcommands, and distribution via PyPI.

The real question isn’t which pattern to use — it’s understanding what changes when you move from script-in-a-directory to installed-package-with-entry-points. The differences are subtle but critical.

Close-up of colorful programming code on a computer screen, showcasing digital technology.
Photo by Myburgh Roux on Pexels

How if name == “main” Actually Works

When you run python script.py, Python sets the special variable __name__ to the string "__main__". When you import that same file as a module, __name__ becomes the module name instead.

# data_processor.py
import sys

def process_data(filename):
    with open(filename) as f:
        data = f.read()
    # process data
    return len(data)

if __name__ == "__main__":
    if len(sys.argv) < 2:
        print("Usage: python data_processor.py <filename>")
        sys.exit(1)
    result = process_data(sys.argv[1])
    print(f"Processed {result} bytes")

This works fine as a standalone script. But what happens when you structure this as a package?

my_package/
├── my_package/
│   ├── __init__.py
│   └── processor.py
└── setup.py

If you keep the if __name__ == "__main__" block in processor.py, you now need to run it with python -m my_package.processor, which is awkward. The alternative — adding a __main__.py file — helps, but you’re still manually parsing sys.argv.

Enjoying this article? Get more like it delivered to your inbox. Subscribe to the newsletter

Where Click Entry Points Win

Click provides two things: argument parsing and proper package entry points. Here’s the Click version:

# my_package/cli.py
import click

@click.command()
@click.argument('filename', type=click.Path(exists=True))
def process_data(filename):
    """Process data from FILENAME."""
    with open(filename) as f:
        data = f.read()
    result = len(data)
    click.echo(f"Processed {result} bytes")

if __name__ == '__main__':
    process_data()

The real magic happens in setup.py (or pyproject.toml if you’ve migrated to PEP 621):

# setup.py
from setuptools import setup, find_packages

setup(
    name='my-package',
    version='0.1.0',
    packages=find_packages(),
    install_requires=['click>=8.0'],
    entry_points={
        'console_scripts': [
            'process-data=my_package.cli:process_data',
        ],
    },
)

Now after pip install ., you can run process-data file.txt from anywhere. No python -m, no path manipulation, no sys.argv parsing.

But there’s a catch I didn’t expect: the entry point function runs in a different context than if __name__ == "__main__". Specifically, the working directory behavior differs.

The Working Directory Surprise

Here’s what I discovered by accident. With a script using if __name__ == "__main__", the current working directory is wherever you invoked Python from:

# script.py
import os
print(f"CWD: {os.getcwd()}")
print(f"__file__: {__file__}")
$ cd /tmp
$ python ~/projects/script.py
CWD: /tmp
__file__: /home/user/projects/script.py

With a Click entry point installed via pip:

$ cd /tmp
$ my-command
CWD: /tmp
__file__: /home/user/.local/lib/python3.11/site-packages/my_package/cli.py

The working directory is still /tmp, but __file__ now points to the installed package location. This breaks any code that does things like:

config_path = os.path.join(os.path.dirname(__file__), 'config.yaml')

With the script version, this looks for config.yaml next to your script. With the entry point version, it looks in site-packages, where your config file doesn’t exist.

The fix: use importlib.resources (Python 3.9+) or pkg_resources for data files:

from importlib.resources import files

config_path = files('my_package').joinpath('config.yaml')

This works whether you’re running as a script or an installed package.

Entry Points vs python -m vs Direct Execution

There are actually three ways to run packaged Python code, and they behave differently:

Direct execution (python script.py):
– __name__ is "__main__"
– sys.path[0] is the script’s directory
– __package__ is None
– Relative imports fail

Module execution (python -m my_package.cli):
– __name__ is "__main__"
– sys.path[0] is the current directory
– __package__ is 'my_package'
– Relative imports work

Entry point (my-command after install):
– __name__ is 'my_package.cli' (not "__main__"!)
– sys.path includes site-packages
– __package__ is 'my_package'
– Relative imports work
– if __name__ == "__main__" blocks don’t execute

That last point is critical. If you have this:

@click.command()
def main():
    click.echo("Hello")

if __name__ == '__main__':
    main()

The entry point bypasses the if __name__ == "__main__" block entirely. Setuptools generates a wrapper script that imports your function and calls it directly. This is why the entry point syntax is 'command=module:function' — it needs to know which function to call.

Migrating Argument Parsing from argparse to Click

Most existing scripts use argparse. Here’s a typical pattern:

import argparse

def main():
    parser = argparse.ArgumentParser(description='Process data files')
    parser.add_argument('filename', help='Input file')
    parser.add_argument('--output', '-o', help='Output file')
    parser.add_argument('--verbose', '-v', action='store_true')
    args = parser.parse_args()

    process(args.filename, args.output, args.verbose)

if __name__ == '__main__':
    main()

Click equivalent:

import click

@click.command()
@click.argument('filename', type=click.Path(exists=True))
@click.option('--output', '-o', type=click.Path(), help='Output file')
@click.option('--verbose', '-v', is_flag=True, help='Verbose output')
def main(filename, output, verbose):
    """Process data files."""
    process(filename, output, verbose)

if __name__ == '__main__':
    main()

The Click version looks cleaner, but there are subtle behavior changes:

  1. Click validates filename existence before calling your function
  2. Click auto-generates --help from the docstring and option help strings
  3. Click handles exceptions differently — it catches ClickException and prints nice error messages

The type=click.Path(exists=True) validation is particularly nice. With argparse, you’d need to manually check:

if not os.path.exists(args.filename):
    parser.error(f"File not found: {args.filename}")

Click does this automatically and shows a clean error:

Error: Invalid value for 'FILENAME': Path 'missing.txt' does not exist.
A developer typing code on a laptop with a Python book beside in an office.
Photo by Christina Morillo on Pexels

Subcommands and Command Groups

This is where Click really shines. Say you have multiple scripts:

scripts/
├── process.py
├── analyze.py
└── report.py

Each with its own if __name__ == "__main__" block. To package these, you’d need three separate entry points:

entry_points={
    'console_scripts': [
        'data-process=my_package.process:main',
        'data-analyze=my_package.analyze:main',
        'data-report=my_package.report:main',
    ],
}

Click lets you create a single entry point with subcommands:

# my_package/cli.py
import click

@click.group()
def cli():
    """Data processing toolkit."""
    pass

@cli.command()
@click.argument('filename')
def process(filename):
    """Process a data file."""
    click.echo(f"Processing {filename}")

@cli.command()
@click.argument('filename')
def analyze(filename):
    """Analyze a data file."""
    click.echo(f"Analyzing {filename}")

@cli.command()
@click.option('--format', type=click.Choice(['text', 'html']))
def report(format):
    """Generate a report."""
    click.echo(f"Generating {format} report")

if __name__ == '__main__':
    cli()

Single entry point:

entry_points={
    'console_scripts': [
        'data=my_package.cli:cli',
    ],
}

Now you run data process file.txt, data analyze file.txt, data report --format html. The help system works hierarchically:

$ data --help  # shows subcommands
$ data process --help  # shows process-specific options

Implementing this with argparse requires add_subparsers(), which quickly becomes verbose:

parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers(dest='command')

process_parser = subparsers.add_parser('process')
process_parser.add_argument('filename')

analyze_parser = subparsers.add_parser('analyze')
analyze_parser.add_argument('filename')

args = parser.parse_args()
if args.command == 'process':
    process(args.filename)
elif args.command == 'analyze':
    analyze(args.filename)
# ...

Click’s decorator syntax scales better.

Context and Shared State

One area where I initially struggled: passing configuration between commands. With separate scripts, you’d use environment variables or config files. With Click command groups, you can use the context object:

@click.group()
@click.option('--config', type=click.Path(exists=True))
@click.pass_context
def cli(ctx, config):
    """Data processing toolkit."""
    ctx.ensure_object(dict)
    if config:
        ctx.obj['config'] = load_config(config)
    else:
        ctx.obj['config'] = default_config()

@cli.command()
@click.pass_context
def process(ctx):
    config = ctx.obj['config']
    click.echo(f"Using config: {config}")

Now data --config custom.yaml process loads the config once and makes it available to all subcommands. The @click.pass_context decorator injects the context object.

Alternatively, create a custom object:

class Config:
    def __init__(self):
        self.verbose = False
        self.output_dir = None

@click.group()
@click.option('--verbose', '-v', is_flag=True)
@click.pass_context
def cli(ctx, verbose):
    ctx.obj = Config()
    ctx.obj.verbose = verbose

@cli.command()
@click.pass_obj
def process(config):
    if config.verbose:
        click.echo("Verbose mode enabled")

With @click.pass_obj, Click injects ctx.obj directly instead of the full context.

Testing Click Commands

One advantage I didn’t appreciate initially: Click commands are easier to test than scripts with if __name__ == "__main__" blocks.

With a script:

# test_script.py
import subprocess
import sys

def test_script():
    result = subprocess.run(
        [sys.executable, 'script.py', 'input.txt'],
        capture_output=True,
        text=True
    )
    assert result.returncode == 0
    assert 'Processed' in result.stdout

You’re spawning a subprocess, which is slow and awkward. With Click:

# test_cli.py
from click.testing import CliRunner
from my_package.cli import process

def test_process():
    runner = CliRunner()
    with runner.isolated_filesystem():
        with open('input.txt', 'w') as f:
            f.write('test data')
        result = runner.invoke(process, ['input.txt'])
        assert result.exit_code == 0
        assert 'Processed' in result.output

The CliRunner invokes your command in-process, capturing output. The isolated_filesystem() context manager creates a temporary directory, so you don’t pollute your test environment.

You can also test error conditions:

def test_missing_file():
    runner = CliRunner()
    result = runner.invoke(process, ['nonexistent.txt'])
    assert result.exit_code != 0
    assert 'does not exist' in result.output

Click’s validation gives you predictable error messages to assert against.

Performance: Entry Points Add Overhead

I benchmarked the startup time difference. Here’s a minimal script:

# minimal_script.py
if __name__ == '__main__':
    print("Hello")

Vs a minimal Click entry point:

# minimal_cli.py
import click

@click.command()
def main():
    click.echo("Hello")

if __name__ == '__main__':
    main()

Installed as an entry point. Timing 100 runs on my machine (Python 3.11, Ubuntu 22.04):

$ time for i in {1..100}; do python minimal_script.py > /dev/null; done
real    0m2.841s

$ time for i in {1..100}; do minimal-command > /dev/null; done
real    0m3.127s

The entry point version is about 3ms slower per invocation. For most CLI tools, this is negligible. But if you’re building something that runs thousands of times (e.g., in a loop or pipeline), it adds up.

The overhead comes from:
1. Setuptools-generated wrapper script import
2. Click initialization
3. Decorator processing

If microseconds matter, stick with plain scripts. If developer experience matters, use Click.

When to Stick with if name == “main“

Despite Click’s advantages, there are cases where if __name__ == "__main__" is the right choice:

Throwaway scripts: If you’re writing a one-off data migration or debugging script, don’t bother with packaging. Just write a script with if __name__ == "__main__" and run it directly.

Educational code: Teaching beginners about Python modules? The if __name__ == "__main__" pattern is foundational. Entry points add too much magic.

Embedded scripts: If your script lives inside a larger project (e.g., a Django management command or a notebook), entry points don’t make sense. The framework provides its own invocation mechanism.

Performance-critical startup: If you’re calling the script thousands of times (e.g., via xargs or in a tight loop), the 3ms overhead matters. Stick with direct execution.

No external dependencies: If you want a single-file script with no dependencies, Click adds a requirement. Plain argparse (stdlib) keeps things self-contained.

Migration Checklist

When moving from scripts to a package with entry points:

  1. Restructure as a package: Create package_name/ directory with __init__.py
  2. Move logic into functions: Extract the code from if __name__ == "__main__" into a callable function
  3. Replace sys.argv parsing: Convert argparse or manual sys.argv handling to Click decorators
  4. Fix relative imports: Change import utils to from . import utils or from package_name import utils
  5. Handle data files properly: Use importlib.resources instead of __file__-based paths
  6. Define entry points: Add console_scripts to setup.py or pyproject.toml
  7. Write tests: Use CliRunner to test commands in isolation
  8. Document the CLI: Click auto-generates help, but add docstrings and option help strings

The trickiest part for me was step 5. I had several scripts doing things like:

TEMPLATE_DIR = os.path.join(os.path.dirname(__file__), 'templates')

This broke when installed as a package. The fix:

from importlib.resources import files

TEMPLATE_DIR = files('my_package').joinpath('templates')

And ensuring templates/ is included in the package:

# setup.py
setup(
    # ...
    package_data={'my_package': ['templates/*']},
)

When I’d Pick Each

Use Click entry points for:
– Tools you’re distributing (PyPI, internal package index)
– CLIs with multiple subcommands
– Projects where you want proper --help without manual effort
– Code that needs testing with CliRunner
– Anything with complex argument validation

Use if name == “main“ for:
– One-off scripts that live in a project’s scripts/ directory
– Educational examples
– Minimal overhead requirements
– Single-file utilities with no dependencies
– Code that needs to work as both script and module

I’m now using Click for anything I plan to maintain for more than a month. The upfront structure pays off when you inevitably add more commands, options, or validation rules. For weekend experiments and debugging scripts, if __name__ == "__main__" is still my go-to.

One thing I haven’t fully figured out: hybrid tools that work both as installed commands and as direct scripts. Some projects handle this with:

if __name__ == '__main__':
    import sys
    sys.exit(main())

Where main() is the Click command. This works, but you lose some Click features (context object initialization, group-level options). If anyone has a cleaner pattern for this, I’d be curious to see it.

The setup.py vs pyproject.toml decision is another one — I covered that in the PEP 621 migration guide if you’re still using setup.py.

FAQ

Q: Can I use Click without installing my package?

Yes. Keep the if __name__ == '__main__': main() block in your Click CLI file. You can run python -m my_package.cli or python my_package/cli.py directly. The entry point is only needed for the installed command name.

Q: Do entry points work with editable installs (pip install -e)?

Yes. pip install -e . creates the entry point immediately, and changes to your code take effect without reinstalling. This is essential during development. Just be aware that adding new entry points requires running pip install -e . again.

Q: What’s the difference between console_scripts and gui_scripts?

Both create entry points, but gui_scripts is for Windows GUI applications. It avoids spawning a console window. On Linux/Mac, they’re identical. Stick with console_scripts for CLI tools — even if you’re launching a GUI, you probably want console output for debugging.

Amazon Lifesaver for CLI Development

If you’re refactoring a pile of messy scripts at 11pm, USB-powered LED desk lamp saves your eyes. Keyboard backlights aren’t enough when you’re comparing three terminal windows and a text editor. Plus it doesn’t wake up your partner like overhead lights do.

Did you find this helpful?

Your support keeps this blog running and ad-free content coming.

☕ Buy me a coffee
TODAY 15 | TOTAL 120,408