diff --git a/index.md b/index.md index 242ba40..4940674 100644 --- a/index.md +++ b/index.md @@ -41,6 +41,16 @@ TODO: let's merge this with the conditional lesson ::: :::: +::::{grid-item} +:::{card} [✿ Package Code ✿](python-packaging/intro) +:class-card: left-aligned + +* [Execute a Python Package](execute-package) +* [Execute a Python Script](execute-script) + +::: +:::: + ::::{grid-item} :::{card} [✿ Share Code ✿](publish-share-code/intro) :class-card: left-aligned @@ -86,6 +96,15 @@ Clean Code Optimize Code ::: +:::{toctree} +:hidden: +:caption: Package Code +:maxdepth: 2 + +Package Code +::: + + :::{toctree} :hidden: :caption: Share Code diff --git a/python-packaging/execute-package.md b/python-packaging/execute-package.md new file mode 100644 index 0000000..b1b298e --- /dev/null +++ b/python-packaging/execute-package.md @@ -0,0 +1,111 @@ +--- +jupytext: + text_representation: + extension: .md + format_name: myst + format_version: 0.13 + jupytext_version: 1.16.4 +kernelspec: + display_name: Python 3 (ipykernel) + language: python + name: python3 +--- + +# Execute a python package + +In [Execute a Python script][#Execute_a_Python_script] you learned of the two primary ways to execute a stand-alone Python script. +There are two other ways to execute Python as commands, both of which work for code that has been formatted as a package. + +## Executable modules + +We have seen how The `python` command can be passed a file for execution, but it can alternatively be passed +the name of a module, exactly as would be used after an `import`. In this case, Python will look up the module +referenced in its installed packages, and when it finds the module, will execute it as a script. + +This execution mode is performed with the `-m` flag, as in `python -m site`. It can be used in place of a file +path, but cannot be used in combination with a path, as there can only be one executing module. + +:::{tip} +These commands both do the same thing, but one is much more portable, and easier to remember + +```bash +python ./.venv/lib/python3.12/site-packages/pip/__main__.py +``` + +```bash +python -m pip +``` +::: + +On your own or in small groups: + +Install the `my_program.py` module from the last lesson, and then try to get the same greeting as before using `-m`. + + +### Executable packages + +The `-m` flag as described above only works for Python modules (files), but does not work for Python (sub-)packages (directories). This means that we cannot execute a command using only the name of our package when it is structured to use directories + +Once our package grows, the top-level name `my_program` turns into a directory +``` +project/ +└── src/ + └── my_program/ + ├── __init__.py + └── greeting.py +``` + +Which can't be executed +```bash +python -m my_program +python: No module named my_program.__main__; 'my_program' is a package and cannot be directly executed +``` + +Initially Python seems to be telling us that names of directories, including out top-level package name, +cannot be directly executes. But actually there is another lead in the error message that gives us the hint to make it work. + +Earlier you learned that the `if __name__ == "__main__":` can protect parts of your script from executing +when it is imported, making that conditional only change the file's behavior as a script. There is a very +similar concept that can be used on whole packages. + +Any package that contains a [`__main__.py` module](https://docs.python.org/3/library/__main__.html#module-__main__) +can be executed directly from the `python` command, without reference to any specific module files. + +:::{note} +The `__main__.py` file typically doesn't have an `if __name__ == "__main__":` conditional in it, as its execution +is already separated out from the rest of the package. +::: + +Try to create a `__main__.py` module in your package that will execute with the `python -m my_program`. (don't forget to +(re)install your package after creating this file!) + +## Entrypoints + +The final way to make Python code executable directly from the command line is to include a special entrypoint +into the package metadata. Entrypoints are a general purpose plug-in system for Python packages, but the +[`console_scripts`](https://packaging.python.org/en/latest/specifications/entry-points/#use-for-scripts) +entry is specifically targeted at creating executable commands on systems that install the package. + +In `pyproject.toml` this specific entrypoint is configured as such + +```toml +[project.scripts] +shiny = "my_program.greetings:shiny_hello" +``` + +In the above example `shiny` is the name of the command that will be made available after installation, `my_program` is the name of +your top-level package import, `greetings` is the name of the sub-package (optional, or may be +repeated as necessary to access the correct sub-package), and `shiny_hello` is the function that will be called. + +The target of each `scripts` definition should always be one function within your package, which will be directly executed (without parameters) +when the command is invoked in the shell. The target function can live anywhere; it does not have to be in a `__main__.py` or under a `if __name__ == "__main__":`. + +## Further exploration + +On your own or in small groups: + +- What might be the advantages of making a package executable over providing a script entrypoint? +- What are some disadvantages? +- Review the Pros section from [Executable _comparisons][Executable_comparisons] + - Any similarities between executable packages and executable scripts? + - Any similarities between scripts and executable scripts? diff --git a/python-packaging/execute-script.md b/python-packaging/execute-script.md new file mode 100644 index 0000000..68ad47f --- /dev/null +++ b/python-packaging/execute-script.md @@ -0,0 +1,190 @@ +--- +jupytext: + text_representation: + extension: .md + format_name: myst + format_version: 0.13 + jupytext_version: 1.16.4 +kernelspec: + display_name: Python 3 (ipykernel) + language: python + name: python3 +--- + +# Execute a Python script + +There are two primary ways to execute a Python script. + +You may already be familiar with the `python` command, and that it can take the name of a Python file and execute it + +```bash +python my_program.py +``` + +When Python reads a file in this way, it executes all of the "top-level" commands that are not indented. +This is similar, but not identical, to the behavior of copying this file and pasting it line-by-line into an interactive +Python shell (or notebook cell). + +```python +def report_error(): + print("An error has occured") + +print("\N{Sparkles} Hello from Python \N{Sparkles}") +``` + +Note that only one line is printed when this script is run + +```bash +my_program.py +# ✨ Hello from Python ✨ +``` + +The other way a Python script may be executed is to associate the file with a launch command. The way in which +this association is done depends on what operating system you are running. + +## Non-Windows executables + +On Linux or Mac systems, the Python file can itself be turned into a command. By adding a [shebang](https://en.wikipedia.org/wiki/Shebang_(Unix)) +as the first line in any Python file, and by giving the file [executable permissions](https://docs.python.org/3/using/unix.html#miscellaneous) the +file can be directly invoked without a `python` command. + +```python +#!/usr/bin/env python +# The above line is a shebang, and can take the place of typing python on the command line +# This comment is below, because shebangs must be the first line! + +def shiny_hello(): + print("\N{Sparkles} Hello from Python \N{Sparkles}") + +shiny_hello() +``` + +```bash +my_program.py +# ✨ Hello from Python ✨ +``` + +:::{tip} +Shebangs are a feature of [POSIX](https://en.wikipedia.org/wiki/POSIX). POSIX represents some level of compatibility between systems. +Linux, macOS, all BSDs, and many other operating systems are fully- or mostly-POSIX compliant. + +Windows is not natively POSIX compliant. However, some "modes" inside of Windows are, such as [WSL](https://learn.microsoft.com/en-us/windows/wsl/about) +(Windows Subsystem for Linux), gitbash, or some VSCode terminals. +::: + +## Windows executables + +If your Windows machine has Python registered as the default application associated with `.py` files, then any Python +scripts can be run as commands. However, only one Python can be registered at a time, so all Python scripts run this +way will use the same Python environment. + +Additionally, most Windows Python installs come with the [Python Launcher](https://docs.python.org/3/using/windows.html#python-launcher-for-windows) +which, in addition to allowing specifying the version of Python to run, can also read shebang lines and emulate some of that behavior. +This allows for shebang lines to be re-used between Linux, macOS, and Windows systems. However, on Windows the command must still +be prefaced with another command (`py`). + +```bash +py my_program.py +# ✨ Hello from Python ✨ +``` + +:::{tip} +While all Python files should end in a `.py`, this naming is necessary for Windows to associate a script with Python, as opposed +to Linux where `.py` is a convention and the shebang associates the file with Python. + +Also, although there is no in-source format that can tell Windows what to do with a Python file, executing a +Python file with a shebang on Windows also does not cause any issues. Python just sees the whole line as +a comment and ignores it! + +Because of these differences it is best practice to use both a shebang and `.py` for all Python scripts. +::: + +## Executable comparisons + +### Pros of passing a file to `python`: +- don't need execute permissions +- works on every system +- explicit about what you expect to happen + +### Pros of inserting a shebang to the file: +- file is associated with specific python + - don't have to remember which +- don't have to use the `python` command + - don't have to even remember it is a Python script + + +## Separating script from import behavior + +Sometimes a Python file that is useful to execute is also useful to import. You may want to use `shiny_hello` +in another Python file. But right now, the `my_program.py` does all its script behavior even when it is imported. Consider + +```python +import my_program + +def guess_my_number(): + my_program.shiny_hello() + print("Was your number 42?") + +guess_my_number() +# ✨ Hello from Python ✨ +# ✨ Hello from Python ✨ +# Was your number 42? +``` + +You may not have expected it to print the hello twice, but it did. This is because `my_program` is set to +_always_ call `shiny_hello`, and now `guess_my_number` also calls it. That's two times. How can we make +`my_program` only call `shiny_hello` when it is used as a script? + +You may have already seen the answer, without realizing what it was doing. `my_program` needs a conditional that checks if is is in "script mode" or "import mode" and that conditional is `if __name__ == "__main__":`. + +This conditional is often used at the bottom of modules, especially modules that are expected to be executed +directly, to separate code that is intended to execute as part of a command from code that is intended to +execute as part of an import. + +```python +#!/usr/bin/env python +# The above line is a shebang, and can take the place of typing python on the command line +# This comment is below, because shebangs must be the first line! + +def shiny_hello(): + print("\N{Sparkles} Hello from Python \N{Sparkles}") + + +if __name__ == "__main__": + shiny_hello() +``` + +```bash +my_program.py +# ✨ Hello from Python ✨ +``` + +```python +import my_program + +def guess_my_number(): + my_program.shiny_hello() + print("Was your number 42?") + +guess_my_number() +# ✨ Hello from Python ✨ +# Was your number 42? +``` + +:::{note}Why did that work? + +All Python modules (individual files) have a `__name__` attribute, which is usually the same as the name used to import the module. + +```python +import os +print(os.__name__) +# 'os' +``` + +This attribute is available within a module by using a global `__name__`. So in the `os.py` module, `__name__` +also gives the value `'os'`. + +Importantly, this name is changed for the *first user-module* executed by Python. When you pass a file to +`python`, that is the first user-module executed. For this module, and only when it is the first, the `__name__` +is changed to the string `'__main__'`. This answers the question for every module used in a Python program, "am I the main module?". +::: diff --git a/python-packaging/intro.md b/python-packaging/intro.md new file mode 100644 index 0000000..aeeb7d1 --- /dev/null +++ b/python-packaging/intro.md @@ -0,0 +1,14 @@ +--- +jupytext: + text_representation: + extension: .md + format_name: myst + format_version: 0.13 + jupytext_version: 1.16.4 +kernelspec: + display_name: Python 3 (ipykernel) + language: python + name: python3 +--- + +# Intro