From e2c8b4635005112699c9d7cb468f516dc9e3a1df Mon Sep 17 00:00:00 2001 From: Rich Saupe Date: Fri, 1 Mar 2024 23:09:43 -0600 Subject: [PATCH 01/33] fixed the header levels in the sprite doc changed doc subsection header renamed old subsection --- .python-version | 1 + doc/programming_guide/sprites/spritelists.rst | 15 +++++++++------ 2 files changed, 10 insertions(+), 6 deletions(-) create mode 100644 .python-version diff --git a/.python-version b/.python-version new file mode 100644 index 000000000..62b104567 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +arcade-dev diff --git a/doc/programming_guide/sprites/spritelists.rst b/doc/programming_guide/sprites/spritelists.rst index 72e8adfd2..df0fc4353 100644 --- a/doc/programming_guide/sprites/spritelists.rst +++ b/doc/programming_guide/sprites/spritelists.rst @@ -1,7 +1,10 @@ .. _pg_spritelists: +Drawing with Sprites and SpriteLists +------------------------------------ + What's a Sprite? ----------------- +^^^^^^^^^^^^^^^^ Each sprite describes where a game object is & how to draw it. This includes: @@ -16,12 +19,12 @@ sprites to the screen. .. _pg_spritelists_why: Why SpriteLists? ----------------- +^^^^^^^^^^^^^^^^ .. _pg_spritelists_why_hardware: They're How Hardware Works -^^^^^^^^^^^^^^^^^^^^^^^^^^ +"""""""""""""""""""""""""" Graphics hardware is designed to draw groups of objects at the same time. These groups are called **batches**. @@ -38,7 +41,7 @@ should avoid trying to draw sprites one at a time. .. _pg_spritelists_why_faster_dev: They Help Develop Games Faster -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +"""""""""""""""""""""""""""""" Sprite lists do more than just draw. They also have built-in features which save you time & effort, including: @@ -50,8 +53,8 @@ you time & effort, including: .. _pg_spritelists_minimal_sprite_drawing: -Drawing with Sprites and SpriteLists ------------------------------------- +Using Sprites and SpriteLists +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Let's get to the example code. From c2dcc0f39544e0c8528469f40eddd64f74557806 Mon Sep 17 00:00:00 2001 From: Rich Saupe Date: Fri, 1 Mar 2024 23:17:09 -0600 Subject: [PATCH 02/33] Revert "fixed the header levels in the sprite doc" This reverts commit e2c8b4635005112699c9d7cb468f516dc9e3a1df. --- .python-version | 1 - doc/programming_guide/sprites/spritelists.rst | 15 ++++++--------- 2 files changed, 6 insertions(+), 10 deletions(-) delete mode 100644 .python-version diff --git a/.python-version b/.python-version deleted file mode 100644 index 62b104567..000000000 --- a/.python-version +++ /dev/null @@ -1 +0,0 @@ -arcade-dev diff --git a/doc/programming_guide/sprites/spritelists.rst b/doc/programming_guide/sprites/spritelists.rst index df0fc4353..72e8adfd2 100644 --- a/doc/programming_guide/sprites/spritelists.rst +++ b/doc/programming_guide/sprites/spritelists.rst @@ -1,10 +1,7 @@ .. _pg_spritelists: -Drawing with Sprites and SpriteLists ------------------------------------- - What's a Sprite? -^^^^^^^^^^^^^^^^ +---------------- Each sprite describes where a game object is & how to draw it. This includes: @@ -19,12 +16,12 @@ sprites to the screen. .. _pg_spritelists_why: Why SpriteLists? -^^^^^^^^^^^^^^^^ +---------------- .. _pg_spritelists_why_hardware: They're How Hardware Works -"""""""""""""""""""""""""" +^^^^^^^^^^^^^^^^^^^^^^^^^^ Graphics hardware is designed to draw groups of objects at the same time. These groups are called **batches**. @@ -41,7 +38,7 @@ should avoid trying to draw sprites one at a time. .. _pg_spritelists_why_faster_dev: They Help Develop Games Faster -"""""""""""""""""""""""""""""" +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Sprite lists do more than just draw. They also have built-in features which save you time & effort, including: @@ -53,8 +50,8 @@ you time & effort, including: .. _pg_spritelists_minimal_sprite_drawing: -Using Sprites and SpriteLists -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +Drawing with Sprites and SpriteLists +------------------------------------ Let's get to the example code. From 7523e8968b9b7410ab84528f45db0a60ab218bc3 Mon Sep 17 00:00:00 2001 From: Rich Saupe Date: Thu, 28 Mar 2024 22:55:37 -0500 Subject: [PATCH 03/33] added screenshot functions --- arcade/__init__.py | 2 + arcade/application.py | 16 +- arcade/window_commands.py | 10 +- tash apply | 243 +++++++++++++++++++++++++++ tests/unit/window/test_screenshot.py | 28 +++ 5 files changed, 296 insertions(+), 3 deletions(-) create mode 100644 tash apply create mode 100644 tests/unit/window/test_screenshot.py diff --git a/arcade/__init__.py b/arcade/__init__.py index b51a44b0f..3e8e1afd8 100644 --- a/arcade/__init__.py +++ b/arcade/__init__.py @@ -84,6 +84,7 @@ def configure_logging(level: Optional[int] = None): from .window_commands import start_render from .window_commands import unschedule from .window_commands import schedule_once +from .window_commands import save_screenshot from .camera import SimpleCamera, Camera from .sections import Section, SectionManager @@ -356,6 +357,7 @@ def configure_logging(level: Optional[int] = None): 'read_tmx', 'load_tilemap', 'run', + 'save_screenshot', 'schedule', 'set_background_color', 'set_viewport', diff --git a/arcade/application.py b/arcade/application.py index 8ff1282b5..bf83e69fa 100644 --- a/arcade/application.py +++ b/arcade/application.py @@ -8,8 +8,9 @@ import os import time from typing import List, Tuple, Optional - +from pathlib import Path import pyglet +from PIL import Image import pyglet.gl as gl import pyglet.window.mouse @@ -930,6 +931,19 @@ def on_mouse_leave(self, x: int, y: int): """ pass + def save_screenshot(self, location: Optional[str] = None) -> Path: + + img = self.ctx.get_framebuffer_image(self.ctx.screen) + if not location: + output_dir = Path().parent.absolute() + else: + output_dir = Path(location) + + filename = f"{self.caption.lower().replace(' ', '_')}_{time.monotonic_ns()}.png" + full_file_path = output_dir / filename + img.save(full_file_path, 'PNG') + return full_file_path + def open_window( width: int, diff --git a/arcade/window_commands.py b/arcade/window_commands.py index caa62d5f7..fe9371eab 100644 --- a/arcade/window_commands.py +++ b/arcade/window_commands.py @@ -9,7 +9,7 @@ import os import pyglet - +from pathlib import Path from typing import ( Callable, Optional, @@ -37,7 +37,8 @@ "set_background_color", "schedule", "unschedule", - "schedule_once" + "schedule_once", + "save_screenshot" ] @@ -362,3 +363,8 @@ def some_action(delta_time): :param delay: Delay in seconds """ pyglet.clock.schedule_once(function_pointer, delay) + + +def save_screenshot(location: Optional[str] = None) -> Path: + window = get_window() + return window.save_screenshot(location) diff --git a/tash apply b/tash apply new file mode 100644 index 000000000..623412173 --- /dev/null +++ b/tash apply @@ -0,0 +1,243 @@ +GIT-STASH(1) Git Manual GIT-STASH(1) + +NNAAMMEE + git-stash - Stash the changes in a dirty working directory away + +SSYYNNOOPPSSIISS + _g_i_t _s_t_a_s_h list [] + _g_i_t _s_t_a_s_h show [-u|--include-untracked|--only-untracked] [] [] + _g_i_t _s_t_a_s_h drop [-q|--quiet] [] + _g_i_t _s_t_a_s_h ( pop | apply ) [--index] [-q|--quiet] [] + _g_i_t _s_t_a_s_h branch [] + _g_i_t _s_t_a_s_h [push [-p|--patch] [-k|--[no-]keep-index] [-q|--quiet] + [-u|--include-untracked] [-a|--all] [-m|--message ] + [--pathspec-from-file= [--pathspec-file-nul]] + [--] [...]] + _g_i_t _s_t_a_s_h clear + _g_i_t _s_t_a_s_h create [] + _g_i_t _s_t_a_s_h store [-m|--message ] [-q|--quiet] + +DDEESSCCRRIIPPTTIIOONN + Use ggiitt ssttaasshh when you want to record the current state of the working directory and the index, but want to go back to a clean working directory. The command saves + your local modifications away and reverts the working directory to match the HHEEAADD commit. + + The modifications stashed away by this command can be listed with ggiitt ssttaasshh lliisstt, inspected with ggiitt ssttaasshh sshhooww, and restored (potentially on top of a different + commit) with ggiitt ssttaasshh aappppllyy. Calling ggiitt ssttaasshh without any arguments is equivalent to ggiitt ssttaasshh ppuusshh. A stash is by default listed as "WIP on _b_r_a_n_c_h_n_a_m_e ...", but + you can give a more descriptive message on the command line when you create one. + + The latest stash you created is stored in rreeffss//ssttaasshh; older stashes are found in the reflog of this reference and can be named using the usual reflog syntax (e.g. + ssttaasshh@@{{00}} is the most recently created stash, ssttaasshh@@{{11}} is the one before it, ssttaasshh@@{{22..hhoouurrss..aaggoo}} is also possible). Stashes may also be referenced by specifying + just the stash index (e.g. the integer nn is equivalent to ssttaasshh@@{{nn}}). + +CCOOMMMMAANNDDSS + push [-p|--patch] [-k|--[no-]keep-index] [-u|--include-untracked] [-a|--all] [-q|--quiet] [-m|--message ] [--pathspec-from-file= + [--pathspec-file-nul]] [--] [...] + Save your local modifications to a new _s_t_a_s_h _e_n_t_r_y and roll them back to HEAD (in the working tree and in the index). The part is optional and gives + the description along with the stashed state. + + For quickly making a snapshot, you can omit "push". In this mode, non-option arguments are not allowed to prevent a misspelled subcommand from making an + unwanted stash entry. The two exceptions to this are ssttaasshh --pp which acts as alias for ssttaasshh ppuusshh --pp and pathspec elements, which are allowed after a double + hyphen ---- for disambiguation. + + save [-p|--patch] [-k|--[no-]keep-index] [-u|--include-untracked] [-a|--all] [-q|--quiet] [] + This option is deprecated in favour of _g_i_t _s_t_a_s_h _p_u_s_h. It differs from "stash push" in that it cannot take pathspec. Instead, all non-option arguments are + concatenated to form the stash message. + + list [] + List the stash entries that you currently have. Each _s_t_a_s_h _e_n_t_r_y is listed with its name (e.g. ssttaasshh@@{{00}} is the latest entry, ssttaasshh@@{{11}} is the one before, + etc.), the name of the branch that was current when the entry was made, and a short description of the commit the entry was based on. + + stash@{0}: WIP on submit: 6ebd0e2... Update git-stash documentation + stash@{1}: On master: 9cc0589... Add git-stash + + The command takes options applicable to the _g_i_t _l_o_g command to control what is shown and how. See ggiitt--lloogg(1). + + show [-u|--include-untracked|--only-untracked] [] [] + Show the changes recorded in the stash entry as a diff between the stashed contents and the commit back when the stash entry was first created. By default, the + command shows the diffstat, but it will accept any format known to _g_i_t _d_i_f_f (e.g., ggiitt ssttaasshh sshhooww --pp ssttaasshh@@{{11}} to view the second most recent entry in patch + form). If no <> is provided, the default behavior will be given by the ssttaasshh..sshhoowwSSttaatt, and ssttaasshh..sshhoowwPPaattcchh config variables. You can also use + ssttaasshh..sshhoowwIInncclluuddeeUUnnttrraacckkeedd to set whether ----iinncclluuddee--uunnttrraacckkeedd is enabled by default. + + pop [--index] [-q|--quiet] [] + Remove a single stashed state from the stash list and apply it on top of the current working tree state, i.e., do the inverse operation of ggiitt ssttaasshh ppuusshh. The + working directory must match the index. + + Applying the state can fail with conflicts; in this case, it is not removed from the stash list. You need to resolve the conflicts by hand and call ggiitt ssttaasshh + ddrroopp manually afterwards. + + apply [--index] [-q|--quiet] [] + Like ppoopp, but do not remove the state from the stash list. Unlike ppoopp, <> may be any commit that looks like a commit created by ssttaasshh ppuusshh or ssttaasshh + ccrreeaattee. + + branch [] + Creates and checks out a new branch named <> starting from the commit at which the <> was originally created, applies the changes recorded in + <> to the new working tree and index. If that succeeds, and <> is a reference of the form ssttaasshh@@{{<>}}, it then drops the <>. + + This is useful if the branch on which you ran ggiitt ssttaasshh ppuusshh has changed enough that ggiitt ssttaasshh aappppllyy fails due to conflicts. Since the stash entry is applied + on top of the commit that was HEAD at the time ggiitt ssttaasshh was run, it restores the originally stashed state with no conflicts. + + clear + Remove all the stash entries. Note that those entries will then be subject to pruning, and may be impossible to recover (see _E_x_a_m_p_l_e_s below for a possible + strategy). + + drop [-q|--quiet] [] + Remove a single stash entry from the list of stash entries. + + create + Create a stash entry (which is a regular commit object) and return its object name, without storing it anywhere in the ref namespace. This is intended to be + useful for scripts. It is probably not the command you want to use; see "push" above. + + store + Store a given stash created via _g_i_t _s_t_a_s_h _c_r_e_a_t_e (which is a dangling merge commit) in the stash ref, updating the stash reflog. This is intended to be useful + for scripts. It is probably not the command you want to use; see "push" above. + +OOPPTTIIOONNSS + -a, --all + This option is only valid for ppuusshh and ssaavvee commands. + + All ignored and untracked files are also stashed and then cleaned up with ggiitt cclleeaann. + + -u, --include-untracked, --no-include-untracked + When used with the ppuusshh and ssaavvee commands, all untracked files are also stashed and then cleaned up with ggiitt cclleeaann. + + When used with the sshhooww command, show the untracked files in the stash entry as part of the diff. + + --only-untracked + This option is only valid for the sshhooww command. + + Show only the untracked files in the stash entry as part of the diff. + + --index + This option is only valid for ppoopp and aappppllyy commands. + + Tries to reinstate not only the working tree’s changes, but also the index’s ones. However, this can fail, when you have conflicts (which are stored in the + index, where you therefore can no longer apply the changes as they were originally). + + -k, --keep-index, --no-keep-index + This option is only valid for ppuusshh and ssaavvee commands. + + All changes already added to the index are left intact. + + -p, --patch + This option is only valid for ppuusshh and ssaavvee commands. + + Interactively select hunks from the diff between HEAD and the working tree to be stashed. The stash entry is constructed such that its index state is the same + as the index state of your repository, and its worktree contains only the changes you selected interactively. The selected changes are then rolled back from + your worktree. See the “Interactive Mode” section of ggiitt--aadddd(1) to learn how to operate the ----ppaattcchh mode. + + The ----ppaattcchh option implies ----kkeeeepp--iinnddeexx. You can use ----nnoo--kkeeeepp--iinnddeexx to override this. + + --pathspec-from-file= + This option is only valid for ppuusshh command. + + Pathspec is passed in <> instead of commandline args. If <> is exactly -- then standard input is used. Pathspec elements are separated by LF or CR/LF. + Pathspec elements can be quoted as explained for the configuration variable ccoorree..qquuootteePPaatthh (see ggiitt--ccoonnffiigg(1)). See also ----ppaatthhssppeecc--ffiillee--nnuull and global + ----lliitteerraall--ppaatthhssppeeccss. + + --pathspec-file-nul + This option is only valid for ppuusshh command. + + Only meaningful with ----ppaatthhssppeecc--ffrroomm--ffiillee. Pathspec elements are separated with NUL character and all other characters are taken literally (including newlines + and quotes). + + -q, --quiet + This option is only valid for aappppllyy, ddrroopp, ppoopp, ppuusshh, ssaavvee, ssttoorree commands. + + Quiet, suppress feedback messages. + + -- + This option is only valid for ppuusshh command. + + Separates pathspec from options for disambiguation purposes. + + ... + This option is only valid for ppuusshh command. + + The new stash entry records the modified states only for the files that match the pathspec. The index entries and working tree files are then rolled back to + the state in HEAD only for these files, too, leaving files that do not match the pathspec intact. + + For more details, see the _p_a_t_h_s_p_e_c entry in ggiittgglloossssaarryy(7). + + + This option is only valid for aappppllyy, bbrraanncchh, ddrroopp, ppoopp, sshhooww commands. + + A reference of the form ssttaasshh@@{{<>}}. When no <> is given, the latest stash is assumed (that is, ssttaasshh@@{{00}}). + +DDIISSCCUUSSSSIIOONN + A stash entry is represented as a commit whose tree records the state of the working directory, and its first parent is the commit at HHEEAADD when the entry was + created. The tree of the second parent records the state of the index when the entry is made, and it is made a child of the HHEEAADD commit. The ancestry graph looks + like this: + + .----W + / / + -----H----I + + where HH is the HHEEAADD commit, II is a commit that records the state of the index, and WW is a commit that records the state of the working tree. + +EEXXAAMMPPLLEESS + Pulling into a dirty tree + When you are in the middle of something, you learn that there are upstream changes that are possibly relevant to what you are doing. When your local changes do + not conflict with the changes in the upstream, a simple ggiitt ppuullll will let you move forward. + + However, there are cases in which your local changes do conflict with the upstream changes, and ggiitt ppuullll refuses to overwrite your changes. In such a case, you + can stash your changes away, perform a pull, and then unstash, like this: + + $ git pull + ... + file foobar not up to date, cannot merge. + $ git stash + $ git pull + $ git stash pop + + Interrupted workflow + When you are in the middle of something, your boss comes in and demands that you fix something immediately. Traditionally, you would make a commit to a + temporary branch to store your changes away, and return to your original branch to make the emergency fix, like this: + + # ... hack hack hack ... + $ git switch -c my_wip + $ git commit -a -m "WIP" + $ git switch master + $ edit emergency fix + $ git commit -a -m "Fix in a hurry" + $ git switch my_wip + $ git reset --soft HEAD^ + # ... continue hacking ... + + You can use _g_i_t _s_t_a_s_h to simplify the above, like this: + + # ... hack hack hack ... + $ git stash + $ edit emergency fix + $ git commit -a -m "Fix in a hurry" + $ git stash pop + # ... continue hacking ... + + Testing partial commits + You can use ggiitt ssttaasshh ppuusshh ----kkeeeepp--iinnddeexx when you want to make two or more commits out of the changes in the work tree, and you want to test each change before + committing: + + # ... hack hack hack ... + $ git add --patch foo # add just first part to the index + $ git stash push --keep-index # save all other changes to the stash + $ edit/build/test first part + $ git commit -m 'First part' # commit fully tested change + $ git stash pop # prepare to work on all other changes + # ... repeat above five steps until one commit remains ... + $ edit/build/test remaining parts + $ git commit foo -m 'Remaining parts' + + Recovering stash entries that were cleared/dropped erroneously + If you mistakenly drop or clear stash entries, they cannot be recovered through the normal safety mechanisms. However, you can try the following incantation to + get a list of stash entries that are still in your repository, but not reachable any more: + + git fsck --unreachable | + grep commit | cut -d\ -f3 | + xargs git log --merges --no-walk --grep=WIP + +SSEEEE AALLSSOO + ggiitt--cchheecckkoouutt(1), ggiitt--ccoommmmiitt(1), ggiitt--rreefflloogg(1), ggiitt--rreesseett(1), ggiitt--sswwiittcchh(1) + +GGIITT + Part of the ggiitt(1) suite + +Git 2.34.1 07/07/2023 GIT-STASH(1) diff --git a/tests/unit/window/test_screenshot.py b/tests/unit/window/test_screenshot.py new file mode 100644 index 000000000..f0424cc73 --- /dev/null +++ b/tests/unit/window/test_screenshot.py @@ -0,0 +1,28 @@ +import arcade +import glob +import os + +def test_no_location(window: arcade.Window): + window.save_screenshot() + file_list = glob.glob('testing_*.png') + assert file_list + os.remove(file_list[0]) + + +def test_location(window: arcade.Window): + window.save_screenshot('doc/') + file_list = glob.glob('doc/testing_*.png') + assert file_list + os.remove(file_list[0]) + +def test_command(window: arcade.Window): + arcade.save_screenshot() + file_list = glob.glob('testing_*.png') + assert file_list + os.remove(file_list[0]) + +def test_command_with_location(window: arcade.Window): + arcade.save_screenshot('doc') + file_list = glob.glob('doc/testing_*.png') + assert file_list + os.remove(file_list[0]) From cc598c884d3b7179d95beb182a4c8c5e2de6b27a Mon Sep 17 00:00:00 2001 From: Rich Saupe Date: Thu, 28 Mar 2024 23:20:16 -0500 Subject: [PATCH 04/33] fix tests --- arcade/application.py | 7 +++---- tests/unit/window/test_screenshot.py | 31 ++++++++++++++-------------- 2 files changed, 18 insertions(+), 20 deletions(-) diff --git a/arcade/application.py b/arcade/application.py index bf83e69fa..901c67ee0 100644 --- a/arcade/application.py +++ b/arcade/application.py @@ -10,7 +10,6 @@ from typing import List, Tuple, Optional from pathlib import Path import pyglet -from PIL import Image import pyglet.gl as gl import pyglet.window.mouse @@ -932,13 +931,13 @@ def on_mouse_leave(self, x: int, y: int): pass def save_screenshot(self, location: Optional[str] = None) -> Path: - + img = self.ctx.get_framebuffer_image(self.ctx.screen) if not location: output_dir = Path().parent.absolute() else: - output_dir = Path(location) - + output_dir = Path(location).absolute() + filename = f"{self.caption.lower().replace(' ', '_')}_{time.monotonic_ns()}.png" full_file_path = output_dir / filename img.save(full_file_path, 'PNG') diff --git a/tests/unit/window/test_screenshot.py b/tests/unit/window/test_screenshot.py index f0424cc73..c4bb35266 100644 --- a/tests/unit/window/test_screenshot.py +++ b/tests/unit/window/test_screenshot.py @@ -2,27 +2,26 @@ import glob import os + def test_no_location(window: arcade.Window): - window.save_screenshot() - file_list = glob.glob('testing_*.png') - assert file_list - os.remove(file_list[0]) + filepath = window.save_screenshot() + assert filepath + os.remove(filepath) def test_location(window: arcade.Window): - window.save_screenshot('doc/') - file_list = glob.glob('doc/testing_*.png') - assert file_list - os.remove(file_list[0]) + filepath = window.save_screenshot('doc/') + assert filepath + os.remove(filepath) + def test_command(window: arcade.Window): - arcade.save_screenshot() - file_list = glob.glob('testing_*.png') - assert file_list - os.remove(file_list[0]) + filepath = arcade.save_screenshot() + assert filepath + os.remove(filepath) + def test_command_with_location(window: arcade.Window): - arcade.save_screenshot('doc') - file_list = glob.glob('doc/testing_*.png') - assert file_list - os.remove(file_list[0]) + filepath = arcade.save_screenshot('doc') + assert filepath + os.remove(filepath) From 3d47991ea60b7680230c7a86d69f805b4b9927fd Mon Sep 17 00:00:00 2001 From: Rich Saupe Date: Thu, 28 Mar 2024 23:41:23 -0500 Subject: [PATCH 05/33] fix locations --- arcade/application.py | 2 ++ tests/unit/window/test_screenshot.py | 7 +++++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/arcade/application.py b/arcade/application.py index 901c67ee0..3d1e0bfb4 100644 --- a/arcade/application.py +++ b/arcade/application.py @@ -937,6 +937,8 @@ def save_screenshot(self, location: Optional[str] = None) -> Path: output_dir = Path().parent.absolute() else: output_dir = Path(location).absolute() + if not os.path.exists(output_dir): + raise FileNotFoundError(f'{output_dir} does not exist') filename = f"{self.caption.lower().replace(' ', '_')}_{time.monotonic_ns()}.png" full_file_path = output_dir / filename diff --git a/tests/unit/window/test_screenshot.py b/tests/unit/window/test_screenshot.py index c4bb35266..743bdf4b1 100644 --- a/tests/unit/window/test_screenshot.py +++ b/tests/unit/window/test_screenshot.py @@ -1,6 +1,7 @@ import arcade import glob import os +import pathlib def test_no_location(window: arcade.Window): @@ -10,7 +11,8 @@ def test_no_location(window: arcade.Window): def test_location(window: arcade.Window): - filepath = window.save_screenshot('doc/') + path = pathlib.Path().parent.parent.absolute() + filepath = window.save_screenshot(str(path)) assert filepath os.remove(filepath) @@ -22,6 +24,7 @@ def test_command(window: arcade.Window): def test_command_with_location(window: arcade.Window): - filepath = arcade.save_screenshot('doc') + path = pathlib.Path().parent.parent.absolute() + filepath = arcade.save_screenshot(str(path)) assert filepath os.remove(filepath) From e061b0c2f735514c63378b28fb6f5413a14567b7 Mon Sep 17 00:00:00 2001 From: Rich Saupe Date: Fri, 29 Mar 2024 00:30:54 -0500 Subject: [PATCH 06/33] removed stash file and updated screenshot oath --- arcade/application.py | 20 ++-- arcade/window_commands.py | 7 +- tash apply | 243 -------------------------------------- 3 files changed, 11 insertions(+), 259 deletions(-) delete mode 100644 tash apply diff --git a/arcade/application.py b/arcade/application.py index 3d1e0bfb4..451523e6a 100644 --- a/arcade/application.py +++ b/arcade/application.py @@ -7,7 +7,7 @@ import logging import os import time -from typing import List, Tuple, Optional +from typing import List, Tuple, Optional, Union from pathlib import Path import pyglet @@ -930,20 +930,14 @@ def on_mouse_leave(self, x: int, y: int): """ pass - def save_screenshot(self, location: Optional[str] = None) -> Path: + def save_screenshot(self, path: Union[Path, str]): + """ + Save a screenshot to a png image + :param path: The full path and the png image filename to save. + """ img = self.ctx.get_framebuffer_image(self.ctx.screen) - if not location: - output_dir = Path().parent.absolute() - else: - output_dir = Path(location).absolute() - if not os.path.exists(output_dir): - raise FileNotFoundError(f'{output_dir} does not exist') - - filename = f"{self.caption.lower().replace(' ', '_')}_{time.monotonic_ns()}.png" - full_file_path = output_dir / filename - img.save(full_file_path, 'PNG') - return full_file_path + img.save(path, 'PNG') def open_window( diff --git a/arcade/window_commands.py b/arcade/window_commands.py index fe9371eab..60e20edf9 100644 --- a/arcade/window_commands.py +++ b/arcade/window_commands.py @@ -14,7 +14,8 @@ Callable, Optional, Tuple, - TYPE_CHECKING + TYPE_CHECKING, + Union ) from arcade.types import RGBA255, Color @@ -365,6 +366,6 @@ def some_action(delta_time): pyglet.clock.schedule_once(function_pointer, delay) -def save_screenshot(location: Optional[str] = None) -> Path: +def save_screenshot(path: Union[ Path, str]): window = get_window() - return window.save_screenshot(location) + window.save_screenshot(path) diff --git a/tash apply b/tash apply deleted file mode 100644 index 623412173..000000000 --- a/tash apply +++ /dev/null @@ -1,243 +0,0 @@ -GIT-STASH(1) Git Manual GIT-STASH(1) - -NNAAMMEE - git-stash - Stash the changes in a dirty working directory away - -SSYYNNOOPPSSIISS - _g_i_t _s_t_a_s_h list [] - _g_i_t _s_t_a_s_h show [-u|--include-untracked|--only-untracked] [] [] - _g_i_t _s_t_a_s_h drop [-q|--quiet] [] - _g_i_t _s_t_a_s_h ( pop | apply ) [--index] [-q|--quiet] [] - _g_i_t _s_t_a_s_h branch [] - _g_i_t _s_t_a_s_h [push [-p|--patch] [-k|--[no-]keep-index] [-q|--quiet] - [-u|--include-untracked] [-a|--all] [-m|--message ] - [--pathspec-from-file= [--pathspec-file-nul]] - [--] [...]] - _g_i_t _s_t_a_s_h clear - _g_i_t _s_t_a_s_h create [] - _g_i_t _s_t_a_s_h store [-m|--message ] [-q|--quiet] - -DDEESSCCRRIIPPTTIIOONN - Use ggiitt ssttaasshh when you want to record the current state of the working directory and the index, but want to go back to a clean working directory. The command saves - your local modifications away and reverts the working directory to match the HHEEAADD commit. - - The modifications stashed away by this command can be listed with ggiitt ssttaasshh lliisstt, inspected with ggiitt ssttaasshh sshhooww, and restored (potentially on top of a different - commit) with ggiitt ssttaasshh aappppllyy. Calling ggiitt ssttaasshh without any arguments is equivalent to ggiitt ssttaasshh ppuusshh. A stash is by default listed as "WIP on _b_r_a_n_c_h_n_a_m_e ...", but - you can give a more descriptive message on the command line when you create one. - - The latest stash you created is stored in rreeffss//ssttaasshh; older stashes are found in the reflog of this reference and can be named using the usual reflog syntax (e.g. - ssttaasshh@@{{00}} is the most recently created stash, ssttaasshh@@{{11}} is the one before it, ssttaasshh@@{{22..hhoouurrss..aaggoo}} is also possible). Stashes may also be referenced by specifying - just the stash index (e.g. the integer nn is equivalent to ssttaasshh@@{{nn}}). - -CCOOMMMMAANNDDSS - push [-p|--patch] [-k|--[no-]keep-index] [-u|--include-untracked] [-a|--all] [-q|--quiet] [-m|--message ] [--pathspec-from-file= - [--pathspec-file-nul]] [--] [...] - Save your local modifications to a new _s_t_a_s_h _e_n_t_r_y and roll them back to HEAD (in the working tree and in the index). The part is optional and gives - the description along with the stashed state. - - For quickly making a snapshot, you can omit "push". In this mode, non-option arguments are not allowed to prevent a misspelled subcommand from making an - unwanted stash entry. The two exceptions to this are ssttaasshh --pp which acts as alias for ssttaasshh ppuusshh --pp and pathspec elements, which are allowed after a double - hyphen ---- for disambiguation. - - save [-p|--patch] [-k|--[no-]keep-index] [-u|--include-untracked] [-a|--all] [-q|--quiet] [] - This option is deprecated in favour of _g_i_t _s_t_a_s_h _p_u_s_h. It differs from "stash push" in that it cannot take pathspec. Instead, all non-option arguments are - concatenated to form the stash message. - - list [] - List the stash entries that you currently have. Each _s_t_a_s_h _e_n_t_r_y is listed with its name (e.g. ssttaasshh@@{{00}} is the latest entry, ssttaasshh@@{{11}} is the one before, - etc.), the name of the branch that was current when the entry was made, and a short description of the commit the entry was based on. - - stash@{0}: WIP on submit: 6ebd0e2... Update git-stash documentation - stash@{1}: On master: 9cc0589... Add git-stash - - The command takes options applicable to the _g_i_t _l_o_g command to control what is shown and how. See ggiitt--lloogg(1). - - show [-u|--include-untracked|--only-untracked] [] [] - Show the changes recorded in the stash entry as a diff between the stashed contents and the commit back when the stash entry was first created. By default, the - command shows the diffstat, but it will accept any format known to _g_i_t _d_i_f_f (e.g., ggiitt ssttaasshh sshhooww --pp ssttaasshh@@{{11}} to view the second most recent entry in patch - form). If no <> is provided, the default behavior will be given by the ssttaasshh..sshhoowwSSttaatt, and ssttaasshh..sshhoowwPPaattcchh config variables. You can also use - ssttaasshh..sshhoowwIInncclluuddeeUUnnttrraacckkeedd to set whether ----iinncclluuddee--uunnttrraacckkeedd is enabled by default. - - pop [--index] [-q|--quiet] [] - Remove a single stashed state from the stash list and apply it on top of the current working tree state, i.e., do the inverse operation of ggiitt ssttaasshh ppuusshh. The - working directory must match the index. - - Applying the state can fail with conflicts; in this case, it is not removed from the stash list. You need to resolve the conflicts by hand and call ggiitt ssttaasshh - ddrroopp manually afterwards. - - apply [--index] [-q|--quiet] [] - Like ppoopp, but do not remove the state from the stash list. Unlike ppoopp, <> may be any commit that looks like a commit created by ssttaasshh ppuusshh or ssttaasshh - ccrreeaattee. - - branch [] - Creates and checks out a new branch named <> starting from the commit at which the <> was originally created, applies the changes recorded in - <> to the new working tree and index. If that succeeds, and <> is a reference of the form ssttaasshh@@{{<>}}, it then drops the <>. - - This is useful if the branch on which you ran ggiitt ssttaasshh ppuusshh has changed enough that ggiitt ssttaasshh aappppllyy fails due to conflicts. Since the stash entry is applied - on top of the commit that was HEAD at the time ggiitt ssttaasshh was run, it restores the originally stashed state with no conflicts. - - clear - Remove all the stash entries. Note that those entries will then be subject to pruning, and may be impossible to recover (see _E_x_a_m_p_l_e_s below for a possible - strategy). - - drop [-q|--quiet] [] - Remove a single stash entry from the list of stash entries. - - create - Create a stash entry (which is a regular commit object) and return its object name, without storing it anywhere in the ref namespace. This is intended to be - useful for scripts. It is probably not the command you want to use; see "push" above. - - store - Store a given stash created via _g_i_t _s_t_a_s_h _c_r_e_a_t_e (which is a dangling merge commit) in the stash ref, updating the stash reflog. This is intended to be useful - for scripts. It is probably not the command you want to use; see "push" above. - -OOPPTTIIOONNSS - -a, --all - This option is only valid for ppuusshh and ssaavvee commands. - - All ignored and untracked files are also stashed and then cleaned up with ggiitt cclleeaann. - - -u, --include-untracked, --no-include-untracked - When used with the ppuusshh and ssaavvee commands, all untracked files are also stashed and then cleaned up with ggiitt cclleeaann. - - When used with the sshhooww command, show the untracked files in the stash entry as part of the diff. - - --only-untracked - This option is only valid for the sshhooww command. - - Show only the untracked files in the stash entry as part of the diff. - - --index - This option is only valid for ppoopp and aappppllyy commands. - - Tries to reinstate not only the working tree’s changes, but also the index’s ones. However, this can fail, when you have conflicts (which are stored in the - index, where you therefore can no longer apply the changes as they were originally). - - -k, --keep-index, --no-keep-index - This option is only valid for ppuusshh and ssaavvee commands. - - All changes already added to the index are left intact. - - -p, --patch - This option is only valid for ppuusshh and ssaavvee commands. - - Interactively select hunks from the diff between HEAD and the working tree to be stashed. The stash entry is constructed such that its index state is the same - as the index state of your repository, and its worktree contains only the changes you selected interactively. The selected changes are then rolled back from - your worktree. See the “Interactive Mode” section of ggiitt--aadddd(1) to learn how to operate the ----ppaattcchh mode. - - The ----ppaattcchh option implies ----kkeeeepp--iinnddeexx. You can use ----nnoo--kkeeeepp--iinnddeexx to override this. - - --pathspec-from-file= - This option is only valid for ppuusshh command. - - Pathspec is passed in <> instead of commandline args. If <> is exactly -- then standard input is used. Pathspec elements are separated by LF or CR/LF. - Pathspec elements can be quoted as explained for the configuration variable ccoorree..qquuootteePPaatthh (see ggiitt--ccoonnffiigg(1)). See also ----ppaatthhssppeecc--ffiillee--nnuull and global - ----lliitteerraall--ppaatthhssppeeccss. - - --pathspec-file-nul - This option is only valid for ppuusshh command. - - Only meaningful with ----ppaatthhssppeecc--ffrroomm--ffiillee. Pathspec elements are separated with NUL character and all other characters are taken literally (including newlines - and quotes). - - -q, --quiet - This option is only valid for aappppllyy, ddrroopp, ppoopp, ppuusshh, ssaavvee, ssttoorree commands. - - Quiet, suppress feedback messages. - - -- - This option is only valid for ppuusshh command. - - Separates pathspec from options for disambiguation purposes. - - ... - This option is only valid for ppuusshh command. - - The new stash entry records the modified states only for the files that match the pathspec. The index entries and working tree files are then rolled back to - the state in HEAD only for these files, too, leaving files that do not match the pathspec intact. - - For more details, see the _p_a_t_h_s_p_e_c entry in ggiittgglloossssaarryy(7). - - - This option is only valid for aappppllyy, bbrraanncchh, ddrroopp, ppoopp, sshhooww commands. - - A reference of the form ssttaasshh@@{{<>}}. When no <> is given, the latest stash is assumed (that is, ssttaasshh@@{{00}}). - -DDIISSCCUUSSSSIIOONN - A stash entry is represented as a commit whose tree records the state of the working directory, and its first parent is the commit at HHEEAADD when the entry was - created. The tree of the second parent records the state of the index when the entry is made, and it is made a child of the HHEEAADD commit. The ancestry graph looks - like this: - - .----W - / / - -----H----I - - where HH is the HHEEAADD commit, II is a commit that records the state of the index, and WW is a commit that records the state of the working tree. - -EEXXAAMMPPLLEESS - Pulling into a dirty tree - When you are in the middle of something, you learn that there are upstream changes that are possibly relevant to what you are doing. When your local changes do - not conflict with the changes in the upstream, a simple ggiitt ppuullll will let you move forward. - - However, there are cases in which your local changes do conflict with the upstream changes, and ggiitt ppuullll refuses to overwrite your changes. In such a case, you - can stash your changes away, perform a pull, and then unstash, like this: - - $ git pull - ... - file foobar not up to date, cannot merge. - $ git stash - $ git pull - $ git stash pop - - Interrupted workflow - When you are in the middle of something, your boss comes in and demands that you fix something immediately. Traditionally, you would make a commit to a - temporary branch to store your changes away, and return to your original branch to make the emergency fix, like this: - - # ... hack hack hack ... - $ git switch -c my_wip - $ git commit -a -m "WIP" - $ git switch master - $ edit emergency fix - $ git commit -a -m "Fix in a hurry" - $ git switch my_wip - $ git reset --soft HEAD^ - # ... continue hacking ... - - You can use _g_i_t _s_t_a_s_h to simplify the above, like this: - - # ... hack hack hack ... - $ git stash - $ edit emergency fix - $ git commit -a -m "Fix in a hurry" - $ git stash pop - # ... continue hacking ... - - Testing partial commits - You can use ggiitt ssttaasshh ppuusshh ----kkeeeepp--iinnddeexx when you want to make two or more commits out of the changes in the work tree, and you want to test each change before - committing: - - # ... hack hack hack ... - $ git add --patch foo # add just first part to the index - $ git stash push --keep-index # save all other changes to the stash - $ edit/build/test first part - $ git commit -m 'First part' # commit fully tested change - $ git stash pop # prepare to work on all other changes - # ... repeat above five steps until one commit remains ... - $ edit/build/test remaining parts - $ git commit foo -m 'Remaining parts' - - Recovering stash entries that were cleared/dropped erroneously - If you mistakenly drop or clear stash entries, they cannot be recovered through the normal safety mechanisms. However, you can try the following incantation to - get a list of stash entries that are still in your repository, but not reachable any more: - - git fsck --unreachable | - grep commit | cut -d\ -f3 | - xargs git log --merges --no-walk --grep=WIP - -SSEEEE AALLSSOO - ggiitt--cchheecckkoouutt(1), ggiitt--ccoommmmiitt(1), ggiitt--rreefflloogg(1), ggiitt--rreesseett(1), ggiitt--sswwiittcchh(1) - -GGIITT - Part of the ggiitt(1) suite - -Git 2.34.1 07/07/2023 GIT-STASH(1) From ce14121f39d81e65de3abe126987272fd7d89ba5 Mon Sep 17 00:00:00 2001 From: Rich Saupe Date: Fri, 29 Mar 2024 00:46:25 -0500 Subject: [PATCH 07/33] fix tests --- tests/unit/window/test_screenshot.py | 42 +++++++++++++--------------- 1 file changed, 19 insertions(+), 23 deletions(-) diff --git a/tests/unit/window/test_screenshot.py b/tests/unit/window/test_screenshot.py index 743bdf4b1..e4a3eabe6 100644 --- a/tests/unit/window/test_screenshot.py +++ b/tests/unit/window/test_screenshot.py @@ -1,30 +1,26 @@ import arcade -import glob +import tempfile import os -import pathlib +from pathlib import Path -def test_no_location(window: arcade.Window): - filepath = window.save_screenshot() - assert filepath - os.remove(filepath) - - -def test_location(window: arcade.Window): - path = pathlib.Path().parent.parent.absolute() - filepath = window.save_screenshot(str(path)) - assert filepath - os.remove(filepath) - - -def test_command(window: arcade.Window): - filepath = arcade.save_screenshot() - assert filepath - os.remove(filepath) +def test_save_screenshot_window(window: arcade.Window): + with tempfile.TemporaryDirectory() as temp_dir: + file_1 = f'{temp_dir}/screen.png' + file_2 = Path(temp_dir, 'screen2.png') + window.save_screenshot(file_1) + assert os.path.exists(file_1) + window.save_screenshot(file_2) + assert os.path.exists(file_2) + def test_command_with_location(window: arcade.Window): - path = pathlib.Path().parent.parent.absolute() - filepath = arcade.save_screenshot(str(path)) - assert filepath - os.remove(filepath) + with tempfile.TemporaryDirectory() as temp_dir: + file_1 = f'{temp_dir}/screen.png' + file_2 = Path(temp_dir, 'screen2.png') + arcade.save_screenshot(file_1) + assert os.path.exists(file_1) + + window.save_screenshot(file_2) + assert os.path.exists(file_2) From 3472eaba71d2a31c13d436a7f2e32765c6f93e1a Mon Sep 17 00:00:00 2001 From: pushfoo <36696816+pushfoo@users.noreply.github.com> Date: Fri, 29 Mar 2024 04:04:59 -0400 Subject: [PATCH 08/33] Use cleaner pytest screenshot fixtures --- tests/unit/window/test_screenshot.py | 52 ++++++++++++++++++---------- 1 file changed, 33 insertions(+), 19 deletions(-) diff --git a/tests/unit/window/test_screenshot.py b/tests/unit/window/test_screenshot.py index e4a3eabe6..ea8f97a92 100644 --- a/tests/unit/window/test_screenshot.py +++ b/tests/unit/window/test_screenshot.py @@ -1,26 +1,40 @@ +"""Make sure window screenshots work. + +We use pytests's ``tmp_path`` instead of :py:mod:`tempdir` or manually +creating a temp dir through :py:mod:`os` or other modules. The +``tmp_path`` fixture passes a :py:class:`~pathlib.Path` of a temp dir +unique to the test invocation to all tests with a ``tmp_path`` argument. + +See https://docs.pytest.org/en/8.0.x/tmpdir.html#the-tmp-path-fixture + +""" import arcade -import tempfile -import os from pathlib import Path -def test_save_screenshot_window(window: arcade.Window): - with tempfile.TemporaryDirectory() as temp_dir: - file_1 = f'{temp_dir}/screen.png' - file_2 = Path(temp_dir, 'screen2.png') - window.save_screenshot(file_1) - assert os.path.exists(file_1) +def test_save_screenshot_window(window: arcade.Window, tmp_path: Path): + # Test Path support + file_1 = tmp_path / "screen.png" + assert not file_1.exists() + window.save_screenshot(file_1) + assert file_1.is_file() + + # Test str support + file_2 = tmp_path / "screen2.png" + assert not file_2.exists() + window.save_screenshot(str(file_2)) + assert file_2.is_file() - window.save_screenshot(file_2) - assert os.path.exists(file_2) - -def test_command_with_location(window: arcade.Window): - with tempfile.TemporaryDirectory() as temp_dir: - file_1 = f'{temp_dir}/screen.png' - file_2 = Path(temp_dir, 'screen2.png') - arcade.save_screenshot(file_1) - assert os.path.exists(file_1) +def test_command_with_location(window: arcade.Window, tmp_path): + # Test Path support + file_1 = tmp_path / "screen.png" + assert not file_1.exists() + window.save_screenshot(file_1) + assert file_1.is_file() - window.save_screenshot(file_2) - assert os.path.exists(file_2) + # Test str support + file_2 = tmp_path / "screen2.png" + assert not file_2.exists() + window.save_screenshot(str(file_2)) + assert file_2.is_file() From d38d28e6e27d2a39303837539777bc750441caec Mon Sep 17 00:00:00 2001 From: pushfoo <36696816+pushfoo@users.noreply.github.com> Date: Sat, 30 Mar 2024 22:22:55 -0400 Subject: [PATCH 09/33] Try to make Window.save_screenshot friendlier + better documented --- arcade/application.py | 29 +++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/arcade/application.py b/arcade/application.py index 451523e6a..6e4c93782 100644 --- a/arcade/application.py +++ b/arcade/application.py @@ -930,14 +930,35 @@ def on_mouse_leave(self, x: int, y: int): """ pass - def save_screenshot(self, path: Union[Path, str]): - """ - Save a screenshot to a png image + def save_screenshot( + self, + path: Union[Path, str], + format: Optional[str] = None, + **kwargs + ) -> None: + """Save a screenshot to a specified file name. + + .. warning:: This may override existing files! + + .. code-block:: python + + # By default, the image format is detected from the + # file extension on the path you pass. + window_instance.save_screenshot("screenshot.png") + + You can also use the same arguments as :py:meth:`PIL.Image.save`: + + * You can pass a ``format`` to stop Pillow from guessing the + format from the file name + * The pillow documentation provides a list of + `supported image formats `_ :param path: The full path and the png image filename to save. + :param format: A :py:mod:`PIL` format name. + :param kwargs: Specific to PIL formats. """ img = self.ctx.get_framebuffer_image(self.ctx.screen) - img.save(path, 'PNG') + img.save(path, format=format, **kwargs) def open_window( From b104c0036c3bfe07bf8b74311779b9ff20067edf Mon Sep 17 00:00:00 2001 From: pushfoo <36696816+pushfoo@users.noreply.github.com> Date: Sat, 30 Mar 2024 22:25:19 -0400 Subject: [PATCH 10/33] Use shorter intersphinx mapping instead of full URL --- arcade/application.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/arcade/application.py b/arcade/application.py index 6e4c93782..9e5363b4a 100644 --- a/arcade/application.py +++ b/arcade/application.py @@ -950,8 +950,8 @@ def save_screenshot( * You can pass a ``format`` to stop Pillow from guessing the format from the file name - * The pillow documentation provides a list of - `supported image formats `_ + * The pillow documentation provides a list of supported + :external+PIL:ref:`image-file-formats` :param path: The full path and the png image filename to save. :param format: A :py:mod:`PIL` format name. From 931075e0fec1cbabbb808954f7d0a8679f58b8b8 Mon Sep 17 00:00:00 2001 From: pushfoo <36696816+pushfoo@users.noreply.github.com> Date: Sat, 30 Mar 2024 22:36:41 -0400 Subject: [PATCH 11/33] Phrasing tweak for Window.save_screenshot docstring --- arcade/application.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/arcade/application.py b/arcade/application.py index 9e5363b4a..d28a64a45 100644 --- a/arcade/application.py +++ b/arcade/application.py @@ -938,7 +938,7 @@ def save_screenshot( ) -> None: """Save a screenshot to a specified file name. - .. warning:: This may override existing files! + .. warning:: This may overwrite existing files! .. code-block:: python From 6811519648db4e75c58fecb6997eae67b2d02f21 Mon Sep 17 00:00:00 2001 From: pushfoo <36696816+pushfoo@users.noreply.github.com> Date: Sat, 30 Mar 2024 22:37:07 -0400 Subject: [PATCH 12/33] Sync implementation + docstring for window_command.save_screenshot --- arcade/window_commands.py | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/arcade/window_commands.py b/arcade/window_commands.py index 60e20edf9..a273bab71 100644 --- a/arcade/window_commands.py +++ b/arcade/window_commands.py @@ -366,6 +366,27 @@ def some_action(delta_time): pyglet.clock.schedule_once(function_pointer, delay) -def save_screenshot(path: Union[ Path, str]): +def save_screenshot( + path: Union[ Path, str], + format: Optional[str] = None, + **kwargs +) -> None: + """Save a screenshot to a specified file name. + + .. warning:: This may overwrite existing files! + + .. code-block:: python + + # By default, the image format is detected from the + # file extension on the path you pass. + window_instance.save_screenshot("screenshot.png") + + This works identically to + :py:meth:`Window.save_screenshot ` + + :param path: The full path and the png image filename to save. + :param format: A :py:mod:`PIL` format name. + :param kwargs: Specific to PIL formats. + """ window = get_window() - window.save_screenshot(path) + window.save_screenshot(path, format=format, **kwargs) From b377f8822d912a9df802e31b6f32bb76b6948ea6 Mon Sep 17 00:00:00 2001 From: pushfoo <36696816+pushfoo@users.noreply.github.com> Date: Sat, 30 Mar 2024 22:39:58 -0400 Subject: [PATCH 13/33] Use intersphinx link in screenshot kwargs field --- arcade/application.py | 2 +- arcade/window_commands.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/arcade/application.py b/arcade/application.py index d28a64a45..c48a153da 100644 --- a/arcade/application.py +++ b/arcade/application.py @@ -955,7 +955,7 @@ def save_screenshot( :param path: The full path and the png image filename to save. :param format: A :py:mod:`PIL` format name. - :param kwargs: Specific to PIL formats. + :param kwargs: Varies with :external+PIL:ref:`selected format ` """ img = self.ctx.get_framebuffer_image(self.ctx.screen) img.save(path, format=format, **kwargs) diff --git a/arcade/window_commands.py b/arcade/window_commands.py index a273bab71..85e853aee 100644 --- a/arcade/window_commands.py +++ b/arcade/window_commands.py @@ -386,7 +386,7 @@ def save_screenshot( :param path: The full path and the png image filename to save. :param format: A :py:mod:`PIL` format name. - :param kwargs: Specific to PIL formats. + :param kwargs: Varies with :external+PIL:ref:`selected format ` """ window = get_window() window.save_screenshot(path, format=format, **kwargs) From 0dcdcf168a456784ef68252efbfd9a0be4afb2e1 Mon Sep 17 00:00:00 2001 From: pushfoo <36696816+pushfoo@users.noreply.github.com> Date: Sun, 31 Mar 2024 00:07:12 -0400 Subject: [PATCH 14/33] Add Window.save_timestamped_screenshot method + doc --- arcade/application.py | 87 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/arcade/application.py b/arcade/application.py index c48a153da..b9e2a8f70 100644 --- a/arcade/application.py +++ b/arcade/application.py @@ -7,6 +7,7 @@ import logging import os import time +from datetime import datetime from typing import List, Tuple, Optional, Union from pathlib import Path import pyglet @@ -97,6 +98,9 @@ class Window(pyglet.window.Window): Usually this is 2, 4, 8 or 16. :param enable_polling: Enabled input polling capability. This makes the ``keyboard`` and ``mouse`` \ attributes available for use. + :param timestamp_format: The default screenshot timestamp format. + :param timestamp_extension: The default extension to use when calling + :py:meth:`.save_timestamped_screenshot`. """ def __init__( @@ -119,6 +123,8 @@ def __init__( enable_polling: bool = True, gl_api: str = "gl", draw_rate: float = 1 / 60, + timestamp_format: str = '%Y%m%d_%H%M_%s_%f', + timestamp_extension: str = 'png' ): # In certain environments we can't have antialiasing/MSAA enabled. # Detect replit environment @@ -194,6 +200,7 @@ def __init__( except pyglet.gl.GLException: LOG.warning("Warning: Anti-aliasing not supported on this computer.") + # We don't call the set_draw_rate function here because unlike the updates, the draw scheduling # is initially set in the call to pyglet.app.run() that is done by the run() function. # run() will pull this draw rate from the Window and use it. Calls to set_draw_rate only need @@ -211,6 +218,8 @@ def __init__( # self.invalid = False set_window(self) + + self._current_view: Optional[View] = None self.current_camera: Optional[arcade.SimpleCamera] = None self.textbox_time = 0.0 @@ -222,6 +231,12 @@ def __init__( set_viewport(0, self.width, 0, self.height) self._background_color: Color = TRANSPARENT_BLACK + # Timestamped screenshot support + self._timestamp_format: str = timestamp_format + #: The default file extension used when calling + #: :py:meth:`.save_timestamped_screenshot`. + self.timestamp_extension: str = timestamp_extension + # See if we should center the window if center_window: self.center_window() @@ -960,6 +975,78 @@ def save_screenshot( img = self.ctx.get_framebuffer_image(self.ctx.screen) img.save(path, format=format, **kwargs) + @property + def timestamp_format(self) -> str: + """The default timestamp format when saving timestamped screenshots. + + Setting an invalid code will raise a :py:class:`ValueError`. To + learn more, see the following: + + * Python's guide to :external:ref:`strftime-strptime-behavior`. + * calling :py:meth:`.save_timestamped_screenshot`. + + """ + return self._timestamp_format + + @timestamp_format.setter + def timestamp_format(self, timestamp_format: str) -> None: + # Inlined since we might be saving a lot of + # screenshots in cases when FPS is uncapped. + datetime.now().strftime(timestamp_format) + self._timestamp_format = timestamp_format + + def save_timestamped_screenshot( + self, + directory: Optional[Union[Path, str]] = None, + timestamp_format: Optional[str] = None, + timestamp_extension: Optional[str] = None + ) -> None: + """Save a timestamped screenshot. + + .. warning:: This method may overwrite existing files! + + If no arguments are provided, the screenshot will be saved to + the current directory. For example: + + .. code-block:: python + + # The code below will save to 20240402_1500_00_000000.png + # in the current directory if: + # 1. It is exactly 3PM on April 3rd, 2024 + # 1. No screenshot defaults were changed + # 2. No overriding values were passed + >>> window.save_timestamped_screenshot() + + This works as follows: + + #. Get the current time with :py:meth:`datetime.datetime.now` + #. Use a passed format string or default to + :py:attr:`self.timestamp_format `. + #. Prepend the current directory if any + #. Format the current time with :py:meth:`datetime.datetime.strftime` + #. Append a period and :py:attr:`self.timestamp_extension` + #. Try to save to the resulting filename in the current directory. + + :param directory: The directory to save to. + :param timestamp_format: Override the window's default + :py:attr:`.timestamp_format`. + :param timestamp_extension: One of the :external+PIL:ref:`image-file-formats` + Pillow can detect. + """ + now = datetime.now() + + # Check for overrides for defaults. + # Image.save seems inefficient as of PIL 10.2.0 so there doesn't + # seem to be any point in attempting clever speed tricks. Use + # real platform screen recording tools to get speed / video. + directory = Path(directory) if directory else Path.cwd() + format = timestamp_format or self._timestamp_format + extension = timestamp_extension or self.timestamp_extension + + # Attempt to save + path = directory / f"{now.strftime(format)}.{extension}" + self.save_screenshot(path) + def open_window( width: int, From 161566e74a0f86930d12cd0ff8659aa3220957bf Mon Sep 17 00:00:00 2001 From: pushfoo <36696816+pushfoo@users.noreply.github.com> Date: Sun, 31 Mar 2024 10:36:38 -0400 Subject: [PATCH 15/33] Add creenshot / helper doc, helpers, and examples * Add screenshot overview + example * Add HasStrftime Protocol type * Add arcade.get_timestamp * Add some cross-referencing between logging and other doc * Add doc on datetime replacements which are nicer for filename templating --- arcade/__init__.py | 38 +++ arcade/types.py | 24 +- doc/index.rst | 1 + doc/programming_guide/logging.rst | 3 + .../screenshots_timestamps.rst | 256 ++++++++++++++++++ 5 files changed, 321 insertions(+), 1 deletion(-) create mode 100644 doc/programming_guide/screenshots_timestamps.rst diff --git a/arcade/__init__.py b/arcade/__init__.py index 3e8e1afd8..ad89829b2 100644 --- a/arcade/__init__.py +++ b/arcade/__init__.py @@ -9,6 +9,7 @@ # Error out if we import Arcade with an incompatible version of Python. import sys import os +from datetime import datetime from typing import Optional from pathlib import Path @@ -33,6 +34,42 @@ def configure_logging(level: Optional[int] = None): ch.setFormatter(logging.Formatter('%(relativeCreated)s %(name)s %(levelname)s - %(message)s')) LOG.addHandler(ch) +def get_timestamp( + when: Optional[types.HasStrftime] = None, + how: str = "%Y%m%d_%H%M_%s_%f" +) -> str: + """Return a timestamp as a formatted string. + + .. tip:: To print text to the console, see :ref:`logging`! + + This function :ref:`helps people who can't ` + use a :ref:`better alternative `. + + Calling this function without any arguments returns a string + with the current system time down to microseconds: + + .. code-block:: python + + # This code assumes the function is called at exactly 3PM + # on April 3rd, 2024 in the computer's local time zone. + >>> arcade.get_timestamp() + `20240403_1500_00_000000' + + See the following to learn more: + + * :ref:`debug-timestamps` + * The Python documentation's guide on + :ref:`datetime-like behavior ` + + :param when: ``None`` or a :ref:`a datetime-like object ` + :param how: A :ref:`valid datetime format string ` + :return: A formatted string for either a passed ``when`` or + :py:meth:`datetime.now ` + + """ + when = when or datetime.now() + return when.strftime(how) + # The following is used to load ffmpeg libraries. # Currently Arcade is only shipping binaries for Mac OS @@ -334,6 +371,7 @@ def configure_logging(level: Optional[int] = None): 'get_screens', 'get_sprites_at_exact_point', 'get_sprites_at_point', + 'get_timestamp', 'SpatialHash', 'get_timings', 'create_text_sprite', diff --git a/arcade/types.py b/arcade/types.py index 5d9de3da0..3ef9cfa46 100644 --- a/arcade/types.py +++ b/arcade/types.py @@ -19,7 +19,8 @@ Tuple, Union, TYPE_CHECKING, - TypeVar + TypeVar, + Protocol ) from typing_extensions import Self @@ -496,6 +497,27 @@ class TiledObject(NamedTuple): type: Optional[str] = None +class HasStrftime(Protocol): + """Marks :ref:`datetime-like ` behavior. + + Ideally, this will be one of the + :ref:`improved replacements ` for :py:mod:`datetime`. + """ + + def strftime(self, format: str) -> str: + """Uses a C89 format string to format datetime-like data. + + To learn more, see: + + * :ref:`debug-better-datetime` + * The Python documentation's guide to :external:ref:`strftime-strptime-behavior` + + :param format: A valid format string. + :return: The object's data as a formatter string. + """ + ... + + if sys.version_info >= (3, 12): from collections.abc import Buffer as BufferProtocol else: diff --git a/doc/index.rst b/doc/index.rst index 9cac29faf..217505ca0 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -112,6 +112,7 @@ The Python Arcade Library programming_guide/texture_atlas programming_guide/edge_artifacts/index programming_guide/logging + programming_guide/screenshots_timestamps programming_guide/opengl_notes programming_guide/performance_tips programming_guide/headless diff --git a/doc/programming_guide/logging.rst b/doc/programming_guide/logging.rst index a1efc570b..2f4aabee7 100644 --- a/doc/programming_guide/logging.rst +++ b/doc/programming_guide/logging.rst @@ -7,6 +7,9 @@ Arcade has a few options to log additional information around timings and how things are working internally. The two major ways to do this by turning on logging, and by querying the OpenGL context. +To export data as part of debugging rather than logging, you may want to see +the :ref:`debug-helpers`. + Turn on logging --------------- diff --git a/doc/programming_guide/screenshots_timestamps.rst b/doc/programming_guide/screenshots_timestamps.rst new file mode 100644 index 000000000..8ddc6c647 --- /dev/null +++ b/doc/programming_guide/screenshots_timestamps.rst @@ -0,0 +1,256 @@ +.. _debug-helpers: + +Screenshots & Timestamps +======================== + +Sometimes, you need to export data to separate files +instead of :ref:`logging with lines of text.`. + +Arcade has a limited set of convience functions to help +you saving screenshots and other files, with or without +timestamps. + +Keep the following in mind: + +* These convenience functions are mostly for debugging +* They are built for flexibility and ease of use +* They are not optimized performance, especially video + +Please see :ref:`debug-screenshot-i-need-video` to learn +about tools better suited for screen recording. + + +.. _debug-screenshots: + +Screenshots +----------- + +Arcade's screenshot helpers do one of three things: + +* Save to a file path +* Return a :py:class:`PIL.Image` +* Query pixels at coordinates + +All of them have simliar :ref:`limitations ` +due to how they copy data. + +Saving Screenshots to Files +^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +The most convenient ones are often the ones which save to disk +immediately: + +* :py:meth:`Window.save_screenshot ` +* :py:func:`arcade.save_screenshot` + +All of them have the same API and behavior. Since Arcade assumes +you will only have one window, :py:func:`arcade.save_screenshot` +is an easy-access wrapper around the other. + +You can also get a raw :py:class:`PIL.Image` object by using the +following functions: + + +.. _debug-screenshots-howto: + +Screenshot Saving Example +""""""""""""""""""""""""" + +The following code saves a screenshot to a file: + +.. literalinclude:: ../../arcade/examples/debug_screenshot.py + :caption: How to take a debug screenshot. + +Since it randomizes the circles, the result will be similar yet not +identical to the output below. + +.. image:: ../example_code/how_to_examples/debug_screenshot.png + :width: 800px + :align: center + :alt: Screen shot produced by the screenshot method. + +.. _debug-screenhots-pil-image: + +Getting PIL Images +^^^^^^^^^^^^^^^^^^ + +You can also get a :py:class:`PIL.Image` object back if you need +finer control. + +.. _dbug-screenshots-pixels: + +Pixel Queries +^^^^^^^^^^^^^ + +It's possible to query pixels directly. + +However, this is best reserved for tests since it may have performance +costs. + + +.. _debug-screenshot-limitations: + +Screenshot Limitations +^^^^^^^^^^^^^^^^^^^^^^ + +Arcade's screenshot helpers are not intended for rendering real-time +video. They will probably be too slow for many reasons, including: + +* Copying data from the GPU back to a computer's main memory +* Using the CPU to run :py:meth:`Image.save ` instead + of the GPU +* Systems with slow drives or heavy disk usage may get stuck waiting + to save individual files + + +.. _debug-screenshot-i-need-video: + +I Need to Save Video! +^^^^^^^^^^^^^^^^^^^^^ + +Sometimess, it's easier to :ref:`get help ` if you +record a bug on video. + +The cheapest and most reliable tools have tradeoffs: + +* Pre-installed video records are often easy to use but limmited +* `OBS `_ is powerful but complicated + +For :ref:`getting help `, the first set of tools +is often best. + +There are ways of recording video with Python, but they have variable +quality. Very advanced users may be able to get `ffmpeg `_ +or other libraries to work. However, these can come with risks or costs: + +* Your project must be able to use (L)GPL-licensed code +* It may be difficult to implement or get adequate performance +* The binaries can be very large + +Like OBS, ffmpeg is powerful and complicated. To learn more, see +:external+pyglet:ref:`pyglet's ffmpeg overview ` +in their programming guide. Note that pyglet might only support reading media +files. Wrting may require additional dependencies or non-trivial work. + +.. _debug-timestamps: + +Filename Timestamps +------------------- + +In addition to Arcade's :ref:`logging` features, +:py:func:`arcade.get_timestamp` is a minimal helper for +generating timestamped filenames. + +Calling it without arguments returns a string which specifies the +current time down six places of microseconds. + +.. code-block:: python + + # This output happens at exactly 3PM on April 3rd, 2024 in local time + >>> arcade.get_timestamp() + '20240402_1500_00_000000' + +.. _debug-timestamps-who: + +Who Is This For? +^^^^^^^^^^^^^^^^ + +Everyone who can't use :ref:`a better alternative `: + +* Students required to use only Arcade and Python builtins +* Anyone who needs to minimize install size +* Game jam participants with a deadline + +In each of these cases, :py:func:`~arcade.get_timestamp` can help +write cleaner data export code a little faster. + +.. code-block:: python + + # This example dumps character data to a file with a timestamped name. + import json + from pathlib import Path + from typing import Dict, Any + from arcade import get_timestamp + + BASE_DIR = Path("debug") + + def log_game_character(char: Dict[str, Any]) -> None: + + # Decide on a filename + filename = f"{char['name']}{get_timestamp()}.json" + character_path = BASE_DIR / filename + + # Write the data + with character_path.open("w") as out: + json.dump(char, out) + + + # Set up our data + parrot = dict(name='parrot', funny=True, alive=False) + + # Write it to a timestamped file + log_game_character(parrot) + +.. _debug-timestamps-custom: + +Customizing Output +^^^^^^^^^^^^^^^^^^ + +The ``when`` and ``how`` keyword arguments allow using +:ref:`compatible ` objects and format +strings: + +* ``when`` accepts anything with a + :ref:`datetime-like ` + ``strftime`` method +* ``how`` takes a C89-style format string and defaults to + ``"%Y%m%d_%H%M_%s_%f"`` + +If you can't :ref:`use anything better ` for some +reason, this helps build readable time stamping behavior a little faster: + +.. code-block::python + + >>> from datetime import date + >>> DAY_MONTH_YEAR = '%d-%m-%Y' + >>> today = date.today() + >>> today + datetime.date(2024, 4, 3) + >>> arcade.get_timestamp(when=today, how=DAY_MONTH_YEAR) + '03-04-2024' + + +.. _debug-better-datetime: + +Better Date & Time Handling +^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +There are plenty of xcelllent replacements for both +:py:func:`~arcade.get_timestamp`and Python's :py:mod:`datetime`. + +In addition to beautiful syntax, the best of them are +:ref:`backward compatible ` with +:py:mod:`datetime` types. + +.. list-table:: + :header-rows: 1 + + * - Library + - Pros + - Cons + + * - `Arrow `_ + - Very popular and mature + - Fewer features + + * - `Pendulum `_ + - Popular and mature + - Included by other libraries which build on it + + * - `Moment `_ + - Well-liked and clean + - "Beta-quality" according to creator + + * - `Maya `_ + - Clean syntax + - `Currently unmaintained `_ From 29ccec7e9261e82b11bd3a33bf778d0f62483233 Mon Sep 17 00:00:00 2001 From: pushfoo <36696816+pushfoo@users.noreply.github.com> Date: Sun, 31 Mar 2024 10:41:37 -0400 Subject: [PATCH 16/33] Revert get_timestamped_screenshot --- arcade/application.py | 86 ------------------------------------------- 1 file changed, 86 deletions(-) diff --git a/arcade/application.py b/arcade/application.py index b9e2a8f70..1c97bf8c9 100644 --- a/arcade/application.py +++ b/arcade/application.py @@ -7,7 +7,6 @@ import logging import os import time -from datetime import datetime from typing import List, Tuple, Optional, Union from pathlib import Path import pyglet @@ -98,9 +97,6 @@ class Window(pyglet.window.Window): Usually this is 2, 4, 8 or 16. :param enable_polling: Enabled input polling capability. This makes the ``keyboard`` and ``mouse`` \ attributes available for use. - :param timestamp_format: The default screenshot timestamp format. - :param timestamp_extension: The default extension to use when calling - :py:meth:`.save_timestamped_screenshot`. """ def __init__( @@ -123,8 +119,6 @@ def __init__( enable_polling: bool = True, gl_api: str = "gl", draw_rate: float = 1 / 60, - timestamp_format: str = '%Y%m%d_%H%M_%s_%f', - timestamp_extension: str = 'png' ): # In certain environments we can't have antialiasing/MSAA enabled. # Detect replit environment @@ -218,8 +212,6 @@ def __init__( # self.invalid = False set_window(self) - - self._current_view: Optional[View] = None self.current_camera: Optional[arcade.SimpleCamera] = None self.textbox_time = 0.0 @@ -231,12 +223,6 @@ def __init__( set_viewport(0, self.width, 0, self.height) self._background_color: Color = TRANSPARENT_BLACK - # Timestamped screenshot support - self._timestamp_format: str = timestamp_format - #: The default file extension used when calling - #: :py:meth:`.save_timestamped_screenshot`. - self.timestamp_extension: str = timestamp_extension - # See if we should center the window if center_window: self.center_window() @@ -975,78 +961,6 @@ def save_screenshot( img = self.ctx.get_framebuffer_image(self.ctx.screen) img.save(path, format=format, **kwargs) - @property - def timestamp_format(self) -> str: - """The default timestamp format when saving timestamped screenshots. - - Setting an invalid code will raise a :py:class:`ValueError`. To - learn more, see the following: - - * Python's guide to :external:ref:`strftime-strptime-behavior`. - * calling :py:meth:`.save_timestamped_screenshot`. - - """ - return self._timestamp_format - - @timestamp_format.setter - def timestamp_format(self, timestamp_format: str) -> None: - # Inlined since we might be saving a lot of - # screenshots in cases when FPS is uncapped. - datetime.now().strftime(timestamp_format) - self._timestamp_format = timestamp_format - - def save_timestamped_screenshot( - self, - directory: Optional[Union[Path, str]] = None, - timestamp_format: Optional[str] = None, - timestamp_extension: Optional[str] = None - ) -> None: - """Save a timestamped screenshot. - - .. warning:: This method may overwrite existing files! - - If no arguments are provided, the screenshot will be saved to - the current directory. For example: - - .. code-block:: python - - # The code below will save to 20240402_1500_00_000000.png - # in the current directory if: - # 1. It is exactly 3PM on April 3rd, 2024 - # 1. No screenshot defaults were changed - # 2. No overriding values were passed - >>> window.save_timestamped_screenshot() - - This works as follows: - - #. Get the current time with :py:meth:`datetime.datetime.now` - #. Use a passed format string or default to - :py:attr:`self.timestamp_format `. - #. Prepend the current directory if any - #. Format the current time with :py:meth:`datetime.datetime.strftime` - #. Append a period and :py:attr:`self.timestamp_extension` - #. Try to save to the resulting filename in the current directory. - - :param directory: The directory to save to. - :param timestamp_format: Override the window's default - :py:attr:`.timestamp_format`. - :param timestamp_extension: One of the :external+PIL:ref:`image-file-formats` - Pillow can detect. - """ - now = datetime.now() - - # Check for overrides for defaults. - # Image.save seems inefficient as of PIL 10.2.0 so there doesn't - # seem to be any point in attempting clever speed tricks. Use - # real platform screen recording tools to get speed / video. - directory = Path(directory) if directory else Path.cwd() - format = timestamp_format or self._timestamp_format - extension = timestamp_extension or self.timestamp_extension - - # Attempt to save - path = directory / f"{now.strftime(format)}.{extension}" - self.save_screenshot(path) - def open_window( width: int, From 2b74056407649ceec73ba09bcfc7ce1f803914c6 Mon Sep 17 00:00:00 2001 From: pushfoo <36696816+pushfoo@users.noreply.github.com> Date: Sun, 31 Mar 2024 10:42:59 -0400 Subject: [PATCH 17/33] Spacing in Window.__init__ --- arcade/application.py | 1 + 1 file changed, 1 insertion(+) diff --git a/arcade/application.py b/arcade/application.py index 1c97bf8c9..f045e465b 100644 --- a/arcade/application.py +++ b/arcade/application.py @@ -159,6 +159,7 @@ def __init__( LOG.warning("Skipping antialiasing due missing hardware/driver support") config = None antialiasing = False + # If we still don't have a config if not config: config = pyglet.gl.Config( From f70685a5b3af01f2fd0b3ab1b243b6272e61bbf1 Mon Sep 17 00:00:00 2001 From: pushfoo <36696816+pushfoo@users.noreply.github.com> Date: Sun, 31 Mar 2024 10:45:12 -0400 Subject: [PATCH 18/33] Revert whitespace noise in application.py --- arcade/application.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/arcade/application.py b/arcade/application.py index f045e465b..c48a153da 100644 --- a/arcade/application.py +++ b/arcade/application.py @@ -159,7 +159,6 @@ def __init__( LOG.warning("Skipping antialiasing due missing hardware/driver support") config = None antialiasing = False - # If we still don't have a config if not config: config = pyglet.gl.Config( @@ -195,7 +194,6 @@ def __init__( except pyglet.gl.GLException: LOG.warning("Warning: Anti-aliasing not supported on this computer.") - # We don't call the set_draw_rate function here because unlike the updates, the draw scheduling # is initially set in the call to pyglet.app.run() that is done by the run() function. # run() will pull this draw rate from the Window and use it. Calls to set_draw_rate only need From 6741ea9d3ae9f0cfd8272ed07fd68a4f62166843 Mon Sep 17 00:00:00 2001 From: pushfoo <36696816+pushfoo@users.noreply.github.com> Date: Sun, 31 Mar 2024 10:46:51 -0400 Subject: [PATCH 19/33] Commit overlooked example py file + screenshot for doc --- arcade/examples/debug_screenshot.py | 65 ++++++++++++++++++ .../how_to_examples/debug_screenshot.png | Bin 0 -> 53779 bytes 2 files changed, 65 insertions(+) create mode 100644 arcade/examples/debug_screenshot.py create mode 100644 doc/example_code/how_to_examples/debug_screenshot.png diff --git a/arcade/examples/debug_screenshot.py b/arcade/examples/debug_screenshot.py new file mode 100644 index 000000000..8c2cb2c8b --- /dev/null +++ b/arcade/examples/debug_screenshot.py @@ -0,0 +1,65 @@ +"""Take screenshots for debugging. + +This example shows you how to take debug screenshots by: + +1. Setting the window's background to a non-transparent color +2. Randomly arranging sprites to display a pattern over it +3. Using arcade.save_screenshot + +After installing arcade version 3.0.0 or higher, this example can be run +from the command line with: +python -m arcade.examples.debug_screenshot +""" +import random +import arcade +from arcade.types import Color + + +SCREENSHOT_FILE_NAME = "debug_screenshot_image.png" + +# How many sprites to draw and how big they'll be +NUM_SPRITES = 100 +MIN_RADIUS_PX = 5 +MAX_RADIUS_PX = 50 + +# Window size +WIDTH_PX = 800 +HEIGHT_PX = 600 + + +class ScreenshotWindow(arcade.Window): + + def __init__(self): + super().__init__(WIDTH_PX, HEIGHT_PX, "Press space to save a screenshot") + + # Important: we have to set a non-transparent background color, + # or else the screenshot will have a transparent background. + self.background_color = arcade.color.AMAZON + + # Randomize circle sprite positions, sizes, and colors + self.sprites = arcade.SpriteList() + for i in range(NUM_SPRITES): + sprite = arcade.SpriteCircle( + random.randint(MIN_RADIUS_PX, MAX_RADIUS_PX), + Color.random(a=255) + ) + sprite.position = ( + random.uniform(0, self.width), + random.uniform(0, self.height) + ) + self.sprites.append(sprite) + + def on_draw(self): + self.clear() + self.sprites.draw() + + def on_key_press(self, key, modifiers): + if key == arcade.key.SPACE: + arcade.save_screenshot(SCREENSHOT_FILE_NAME) + # You can also use the format below instead. + # self.save_screenshot(SCREENSHOT_FILE_NAME) + + +if __name__ == "__main__": + window = ScreenshotWindow() + arcade.run() diff --git a/doc/example_code/how_to_examples/debug_screenshot.png b/doc/example_code/how_to_examples/debug_screenshot.png new file mode 100644 index 0000000000000000000000000000000000000000..a5962fbd3b2cd68a89c9f125e77ea3f4e67c724d GIT binary patch literal 53779 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�_R!TNR2 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 literal 0 HcmV?d00001 From eb900a63432b5c105b738e6f92ff575af94fcd32 Mon Sep 17 00:00:00 2001 From: pushfoo <36696816+pushfoo@users.noreply.github.com> Date: Sun, 31 Mar 2024 10:51:21 -0400 Subject: [PATCH 20/33] Fix code-block directive spacing --- doc/programming_guide/screenshots_timestamps.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/programming_guide/screenshots_timestamps.rst b/doc/programming_guide/screenshots_timestamps.rst index 8ddc6c647..5b83066d1 100644 --- a/doc/programming_guide/screenshots_timestamps.rst +++ b/doc/programming_guide/screenshots_timestamps.rst @@ -209,7 +209,7 @@ strings: If you can't :ref:`use anything better ` for some reason, this helps build readable time stamping behavior a little faster: -.. code-block::python +.. code-block:: python >>> from datetime import date >>> DAY_MONTH_YEAR = '%d-%m-%Y' From b6b53418a04097ed66040bfd4893edb150f12aec Mon Sep 17 00:00:00 2001 From: pushfoo <36696816+pushfoo@users.noreply.github.com> Date: Sun, 31 Mar 2024 11:08:29 -0400 Subject: [PATCH 21/33] Better date and time section tweaks * Fix typo in first line * Remove compaction of arcade.get_timestamp link --- doc/programming_guide/screenshots_timestamps.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/programming_guide/screenshots_timestamps.rst b/doc/programming_guide/screenshots_timestamps.rst index 5b83066d1..ee7fc9b25 100644 --- a/doc/programming_guide/screenshots_timestamps.rst +++ b/doc/programming_guide/screenshots_timestamps.rst @@ -225,8 +225,8 @@ reason, this helps build readable time stamping behavior a little faster: Better Date & Time Handling ^^^^^^^^^^^^^^^^^^^^^^^^^^^ -There are plenty of xcelllent replacements for both -:py:func:`~arcade.get_timestamp`and Python's :py:mod:`datetime`. +There are plenty of excellent replacements for both +:py:func:`arcade.get_timestamp`and Python's :py:mod:`datetime`. In addition to beautiful syntax, the best of them are :ref:`backward compatible ` with From b53b41361ee9ac6a29b99374b2effb238e44fabe Mon Sep 17 00:00:00 2001 From: pushfoo <36696816+pushfoo@users.noreply.github.com> Date: Sun, 31 Mar 2024 11:10:45 -0400 Subject: [PATCH 22/33] Intro block revision --- doc/programming_guide/screenshots_timestamps.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/programming_guide/screenshots_timestamps.rst b/doc/programming_guide/screenshots_timestamps.rst index ee7fc9b25..92d497ebe 100644 --- a/doc/programming_guide/screenshots_timestamps.rst +++ b/doc/programming_guide/screenshots_timestamps.rst @@ -7,8 +7,8 @@ Sometimes, you need to export data to separate files instead of :ref:`logging with lines of text.`. Arcade has a limited set of convience functions to help -you saving screenshots and other files, with or without -timestamps. +you with saving screenshots and other data to files, with +or without timestamps. Keep the following in mind: From c42872ad87db9b063826f78930c4ba679621fadb Mon Sep 17 00:00:00 2001 From: pushfoo <36696816+pushfoo@users.noreply.github.com> Date: Sun, 31 Mar 2024 11:18:30 -0400 Subject: [PATCH 23/33] Second pair of intro tweaks * Remove extra sentence * Add list item linking the better datetime overview --- doc/programming_guide/screenshots_timestamps.rst | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/doc/programming_guide/screenshots_timestamps.rst b/doc/programming_guide/screenshots_timestamps.rst index 92d497ebe..584945570 100644 --- a/doc/programming_guide/screenshots_timestamps.rst +++ b/doc/programming_guide/screenshots_timestamps.rst @@ -6,15 +6,16 @@ Screenshots & Timestamps Sometimes, you need to export data to separate files instead of :ref:`logging with lines of text.`. -Arcade has a limited set of convience functions to help -you with saving screenshots and other data to files, with -or without timestamps. +Arcade has a limited set of convience functions to help you save +screenshots and other data this way. Keep the following in mind: * These convenience functions are mostly for debugging * They are built for flexibility and ease of use * They are not optimized performance, especially video +* Arcade's timestamp helper is a fallback for when you can't use + :ref:`much better third-party options ` Please see :ref:`debug-screenshot-i-need-video` to learn about tools better suited for screen recording. From ae00a90ca5ab9e138bf3db079314f789b0b3fad5 Mon Sep 17 00:00:00 2001 From: pushfoo <36696816+pushfoo@users.noreply.github.com> Date: Tue, 2 Apr 2024 01:46:58 -0400 Subject: [PATCH 24/33] Add module-level spacing above get_timestamp --- arcade/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/arcade/__init__.py b/arcade/__init__.py index ad89829b2..89c6cd02b 100644 --- a/arcade/__init__.py +++ b/arcade/__init__.py @@ -34,6 +34,7 @@ def configure_logging(level: Optional[int] = None): ch.setFormatter(logging.Formatter('%(relativeCreated)s %(name)s %(levelname)s - %(message)s')) LOG.addHandler(ch) + def get_timestamp( when: Optional[types.HasStrftime] = None, how: str = "%Y%m%d_%H%M_%s_%f" From 2dfd5b5c556a29a3a5dc5f6434930d15b1ab62e6 Mon Sep 17 00:00:00 2001 From: pushfoo <36696816+pushfoo@users.noreply.github.com> Date: Tue, 2 Apr 2024 04:01:55 -0400 Subject: [PATCH 25/33] Put format string first in get_timestamp --- arcade/__init__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/arcade/__init__.py b/arcade/__init__.py index 89c6cd02b..f4e7a5be7 100644 --- a/arcade/__init__.py +++ b/arcade/__init__.py @@ -36,8 +36,8 @@ def configure_logging(level: Optional[int] = None): def get_timestamp( - when: Optional[types.HasStrftime] = None, - how: str = "%Y%m%d_%H%M_%s_%f" + how: str = "%Y%m%d_%H%M_%S_%f", + when: Optional[types.HasStrftime] = None ) -> str: """Return a timestamp as a formatted string. @@ -62,8 +62,8 @@ def get_timestamp( * The Python documentation's guide on :ref:`datetime-like behavior ` - :param when: ``None`` or a :ref:`a datetime-like object ` :param how: A :ref:`valid datetime format string ` + :param when: ``None`` or a :ref:`a datetime-like object ` :return: A formatted string for either a passed ``when`` or :py:meth:`datetime.now ` From 09a363e8985a0e34c25d3ad7e482d97fa2a2ae8a Mon Sep 17 00:00:00 2001 From: pushfoo <36696816+pushfoo@users.noreply.github.com> Date: Tue, 2 Apr 2024 04:17:24 -0400 Subject: [PATCH 26/33] Add tzinfo to get_timestamp --- arcade/__init__.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/arcade/__init__.py b/arcade/__init__.py index f4e7a5be7..c1822fc48 100644 --- a/arcade/__init__.py +++ b/arcade/__init__.py @@ -37,7 +37,8 @@ def configure_logging(level: Optional[int] = None): def get_timestamp( how: str = "%Y%m%d_%H%M_%S_%f", - when: Optional[types.HasStrftime] = None + when: Optional[types.HasStrftime] = None, + tzinfo: Optional[datetime.tzinfo] = None ) -> str: """Return a timestamp as a formatted string. @@ -63,12 +64,13 @@ def get_timestamp( :ref:`datetime-like behavior ` :param how: A :ref:`valid datetime format string ` + :param tzinfo: A :py:class:`datetime.tzinfo` instance. :param when: ``None`` or a :ref:`a datetime-like object ` :return: A formatted string for either a passed ``when`` or :py:meth:`datetime.now ` """ - when = when or datetime.now() + when = when or datetime.now(tzinfo) return when.strftime(how) From 50d38ee232569c8fc3b59e6f87c167c0ef9909d4 Mon Sep 17 00:00:00 2001 From: pushfoo <36696816+pushfoo@users.noreply.github.com> Date: Tue, 2 Apr 2024 04:38:03 -0400 Subject: [PATCH 27/33] Account for DX + Sabadam32's suggestions * Re-order arguments for get_timestamp * Improve default timestamp format string * Add time-machine test helper to pyproject.toml * Add tests for get_timestamp * Update doc to cover changes --- arcade/__init__.py | 4 +- .../screenshots_timestamps.rst | 4 +- pyproject.toml | 4 + tests/unit/test_get_timestamp.py | 82 +++++++++++++++++++ 4 files changed, 90 insertions(+), 4 deletions(-) create mode 100644 tests/unit/test_get_timestamp.py diff --git a/arcade/__init__.py b/arcade/__init__.py index c1822fc48..2a566d3dd 100644 --- a/arcade/__init__.py +++ b/arcade/__init__.py @@ -36,7 +36,7 @@ def configure_logging(level: Optional[int] = None): def get_timestamp( - how: str = "%Y%m%d_%H%M_%S_%f", + how: str = "%Y_%m_%d_%H%M_%S_%f", when: Optional[types.HasStrftime] = None, tzinfo: Optional[datetime.tzinfo] = None ) -> str: @@ -55,7 +55,7 @@ def get_timestamp( # This code assumes the function is called at exactly 3PM # on April 3rd, 2024 in the computer's local time zone. >>> arcade.get_timestamp() - `20240403_1500_00_000000' + `2024_04_03_1500_00_000000' See the following to learn more: diff --git a/doc/programming_guide/screenshots_timestamps.rst b/doc/programming_guide/screenshots_timestamps.rst index 584945570..5c9bb1873 100644 --- a/doc/programming_guide/screenshots_timestamps.rst +++ b/doc/programming_guide/screenshots_timestamps.rst @@ -149,7 +149,7 @@ current time down six places of microseconds. # This output happens at exactly 3PM on April 3rd, 2024 in local time >>> arcade.get_timestamp() - '20240402_1500_00_000000' + '2024_04_02_1500_00_000000' .. _debug-timestamps-who: @@ -205,7 +205,7 @@ strings: :ref:`datetime-like ` ``strftime`` method * ``how`` takes a C89-style format string and defaults to - ``"%Y%m%d_%H%M_%s_%f"`` + ``"%Y_%m_%d_%H%M_%S_%f"`` If you can't :ref:`use anything better ` for some reason, this helps build readable time stamping behavior a little faster: diff --git a/pyproject.toml b/pyproject.toml index 0b6abf181..12977ec2e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,6 +48,8 @@ dev = [ "coveralls", "pytest-mock", "pytest-cov", + # Can't monkeypatch.setitem datetime.now + freezegun has tz problems + "time-machine==2.14.1", "pygments==2.17.2", "docutils==0.20.1", "furo", @@ -66,6 +68,8 @@ testing_libraries = [ "pytest-mock", "pytest-cov", "pyyaml==6.0.1", + # Can't monkeypatch.setitem datetime.now + freezegun has tz problems + "time-machine==2.14.1" ] [project.scripts] diff --git a/tests/unit/test_get_timestamp.py b/tests/unit/test_get_timestamp.py new file mode 100644 index 000000000..f7942b316 --- /dev/null +++ b/tests/unit/test_get_timestamp.py @@ -0,0 +1,82 @@ +"""Minimal tests for arcade.get_timestamp. + +Since the function is a minimal fallback for people who can't use any of +the alternatives to :py:mod:`datetime`, we'll only check the following: + +* Local system time on the test machine +* A made-up time zone guaranteed not to clash with it +* Two built-ins which implement strftime-like behavior: + + * :py:class:`datetime.date` + * :py:class:`datetime.time` + +It's not our responsibility to check 3rd party date & time types for +compatibility. Instead, we cover it in doc and advise users to choose +one of the popular backward-compatible date & time replacements. + +""" +import datetime + +from arcade import get_timestamp + +import time_machine +from dateutil.tz import tzlocal, tzoffset + + +# System time zone + a zone guaranteed not to clash with it +LOCAL_TZ = tzlocal() +CUSTOM_TZ = tzoffset( + 'arcade', + datetime.timedelta(hours=-5, minutes=4, seconds=3)) + +# Set up date and time constants to use with strfime checks +DATE = datetime.date( + year=2024, + month=12, + day=11) + +TIME = datetime.time( + hour=10, + minute=9, + second=8, + microsecond=7) + +DATETIME_NOW = datetime.datetime.combine(DATE, TIME, tzinfo=LOCAL_TZ) +DATETIME_NOW_CUSTOM = datetime.datetime.combine(DATE, TIME, tzinfo=CUSTOM_TZ) + + +def test_get_timestamp() -> None: + + # Check default usage and all flags individually w/o combinations + with time_machine.travel (DATETIME_NOW, tick=False): + assert get_timestamp() == '2024_12_11_1009_08_000007' + assert get_timestamp(how="%Y") == '2024' + assert get_timestamp(how="%m") == '12' + assert get_timestamp(how="%d") == '11' + assert get_timestamp(how="%H") == "10" + assert get_timestamp(how="%M") == "09" + assert get_timestamp(how="%S") == "08" + assert get_timestamp(how="%f") == "000007" + + # Make sure passing time zones works the same way as above + with time_machine.travel(DATETIME_NOW_CUSTOM, tick=False): + assert get_timestamp(tzinfo=CUSTOM_TZ) == '2024_12_11_1009_08_000007' + assert get_timestamp(how="%Y", tzinfo=CUSTOM_TZ) == '2024' + assert get_timestamp(how="%m", tzinfo=CUSTOM_TZ) == '12' + assert get_timestamp(how="%d", tzinfo=CUSTOM_TZ) == '11' + assert get_timestamp(how="%H", tzinfo=CUSTOM_TZ) == "10" + assert get_timestamp(how="%M", tzinfo=CUSTOM_TZ) == "09" + assert get_timestamp(how="%S", tzinfo=CUSTOM_TZ) == "08" + assert get_timestamp(how="%f", tzinfo=CUSTOM_TZ) == "000007" + + # Spot-check two other built-in strftime-providing objects + assert get_timestamp(how="%Y-%m-%d", when=DATE) == "2024-12-11" + assert get_timestamp(how="%Y", when=DATE) == '2024' + assert get_timestamp(how="%m", when=DATE) == '12' + assert get_timestamp(how="%d", when=DATE) == '11' + + assert get_timestamp(how="%H:%M:%S.%f", when=TIME) == "10:09:08.000007" + assert get_timestamp(how="%H", when=TIME) == "10" + assert get_timestamp(how="%M", when=TIME) == "09" + assert get_timestamp(how="%S", when=TIME) == "08" + assert get_timestamp(how="%f", when=TIME) == "000007" From e6a708ac04768280e8f650e26e5d539847cc4b5f Mon Sep 17 00:00:00 2001 From: pushfoo <36696816+pushfoo@users.noreply.github.com> Date: Wed, 3 Apr 2024 19:27:15 -0400 Subject: [PATCH 28/33] Add timezone to format string + clean up tests * Add %Z to get_timestamp * Add doc on it + cross-refs * Clean datetime test imports --- arcade/__init__.py | 11 +++- .../screenshots_timestamps.rst | 60 ++++++++++++++++--- tests/unit/test_get_timestamp.py | 41 +++++++++---- 3 files changed, 91 insertions(+), 21 deletions(-) diff --git a/arcade/__init__.py b/arcade/__init__.py index 2a566d3dd..4129aab10 100644 --- a/arcade/__init__.py +++ b/arcade/__init__.py @@ -36,7 +36,7 @@ def configure_logging(level: Optional[int] = None): def get_timestamp( - how: str = "%Y_%m_%d_%H%M_%S_%f", + how: str = "%Y_%m_%d_%H%M_%S_%f%Z", when: Optional[types.HasStrftime] = None, tzinfo: Optional[datetime.tzinfo] = None ) -> str: @@ -57,12 +57,17 @@ def get_timestamp( >>> arcade.get_timestamp() `2024_04_03_1500_00_000000' + See the following to learn more: - * :ref:`debug-timestamps` - * The Python documentation's guide on + * For general information, see :ref:`debug-timestamps` + * For custom formatting & times, see :ref:`debug-timestamps-example-when-how` + * To use time zones such as UTC, see :ref:`debug-timestamps-example-timezone` + * The general :py:mod:`datetime` documentation + * Python's guide to :ref:`datetime-like behavior ` + :param how: A :ref:`valid datetime format string ` :param tzinfo: A :py:class:`datetime.tzinfo` instance. :param when: ``None`` or a :ref:`a datetime-like object ` diff --git a/doc/programming_guide/screenshots_timestamps.rst b/doc/programming_guide/screenshots_timestamps.rst index 5c9bb1873..ac04f06c3 100644 --- a/doc/programming_guide/screenshots_timestamps.rst +++ b/doc/programming_guide/screenshots_timestamps.rst @@ -197,18 +197,39 @@ write cleaner data export code a little faster. Customizing Output ^^^^^^^^^^^^^^^^^^ -The ``when`` and ``how`` keyword arguments allow using +Argument Overview +""""""""""""""""" + +The ``when``, ``how``, and ``tzinfo`` keyword arguments allow using :ref:`compatible ` objects and format strings: -* ``when`` accepts anything with a - :ref:`datetime-like ` - ``strftime`` method -* ``how`` takes a C89-style format string and defaults to - ``"%Y_%m_%d_%H%M_%S_%f"`` +.. list-table:: + :header-rows: 1 + + * - Keyword Argument + - What it Takes + - Default + + * - ``when`` + - ``None`` or anything with a :ref:`datetime-like ` + ``strftime`` method + - Calling + :py:meth:`datetime.now(tzinfo) ` + + * - ``how`` + - A :ref:`C89-stlye date format string ` + - ``"%Y_%m_%d_%H%M_%S_%f%Z"`` + + * - ``tzinfo`` + - ``None`` or a valid :py:class:`datetime.tzinfo` instance + - ``None`` + + +.. _debug-timestamps-example-when-how: -If you can't :ref:`use anything better ` for some -reason, this helps build readable time stamping behavior a little faster: +Example of Custom When and How +"""""""""""""""""""""""""""""" .. code-block:: python @@ -221,6 +242,29 @@ reason, this helps build readable time stamping behavior a little faster: '03-04-2024' +.. _debug-timestamps-example-timezone: + +Example of Custom Time Zones +"""""""""""""""""""""""""""" + +.. _UTC_Wiki: https://en.wikipedia.org/wiki/Coordinated_Universal_Time + +Using `UTC `_ is a common way to reduce confusion about when +something happened. On Python 3.8, you can use +:py:class:`datetime.timezone`'s ``utc`` constant with this function: + +.. code-block:: python + + >>> from datetime import timezone + >>> arcade.get_timestamp(tzinfo=timezone.utc) + '2024_12_11_1009_08_000007UTC` + +Starting with Python 3.11, you can use :py:attr:`datetime.UTC` as a +more readable shortcut. However, the built-in date & time tools can +still be confusing and incomplete. To learn about the most popular +alternatives, see the heading below. + + .. _debug-better-datetime: Better Date & Time Handling diff --git a/tests/unit/test_get_timestamp.py b/tests/unit/test_get_timestamp.py index f7942b316..d739a6caa 100644 --- a/tests/unit/test_get_timestamp.py +++ b/tests/unit/test_get_timestamp.py @@ -15,8 +15,7 @@ one of the popular backward-compatible date & time replacements. """ -import datetime - +from datetime import date, datetime, time, timezone, timedelta from arcade import get_timestamp import time_machine @@ -25,30 +24,35 @@ # System time zone + a zone guaranteed not to clash with it LOCAL_TZ = tzlocal() +CUSTOM_TZ_NAME = 'ARC' CUSTOM_TZ = tzoffset( - 'arcade', - datetime.timedelta(hours=-5, minutes=4, seconds=3)) + CUSTOM_TZ_NAME, + timedelta(hours=-5, minutes=4, seconds=3)) # Set up date and time constants to use with strfime checks -DATE = datetime.date( +DATE = date( year=2024, month=12, day=11) -TIME = datetime.time( +TIME = time( hour=10, minute=9, second=8, microsecond=7) -DATETIME_NOW = datetime.datetime.combine(DATE, TIME, tzinfo=LOCAL_TZ) -DATETIME_NOW_CUSTOM = datetime.datetime.combine(DATE, TIME, tzinfo=CUSTOM_TZ) +DATETIME_NOW = datetime.combine(DATE, TIME, tzinfo=LOCAL_TZ) +DATETIME_NOW_UTC = datetime.combine(DATE, TIME, tzinfo=timezone.utc) +DATETIME_NOW_CUSTOM = datetime.combine(DATE, TIME, tzinfo=CUSTOM_TZ) def test_get_timestamp() -> None: # Check default usage and all flags individually w/o combinations - with time_machine.travel (DATETIME_NOW, tick=False): + with time_machine.travel(DATETIME_NOW, tick=False): + # Since we don't pass in a tz here, the following happens: + # 1. datetime.now() returns a datetime with a None time zone + # 2. The default format string's %Z treats lack of tz as '' assert get_timestamp() == '2024_12_11_1009_08_000007' assert get_timestamp(how="%Y") == '2024' assert get_timestamp(how="%m") == '12' @@ -57,10 +61,14 @@ def test_get_timestamp() -> None: assert get_timestamp(how="%M") == "09" assert get_timestamp(how="%S") == "08" assert get_timestamp(how="%f") == "000007" + assert get_timestamp(how="%Z") == '' # Make sure passing time zones works the same way as above with time_machine.travel(DATETIME_NOW_CUSTOM, tick=False): - assert get_timestamp(tzinfo=CUSTOM_TZ) == '2024_12_11_1009_08_000007' + assert get_timestamp(tzinfo=CUSTOM_TZ) == ( + f'2024_12_11_1009_08_000007' + f'{CUSTOM_TZ_NAME}' + ) assert get_timestamp(how="%Y", tzinfo=CUSTOM_TZ) == '2024' assert get_timestamp(how="%m", tzinfo=CUSTOM_TZ) == '12' assert get_timestamp(how="%d", tzinfo=CUSTOM_TZ) == '11' @@ -68,6 +76,19 @@ def test_get_timestamp() -> None: assert get_timestamp(how="%M", tzinfo=CUSTOM_TZ) == "09" assert get_timestamp(how="%S", tzinfo=CUSTOM_TZ) == "08" assert get_timestamp(how="%f", tzinfo=CUSTOM_TZ) == "000007" + assert get_timestamp(how="%Z", tzinfo=CUSTOM_TZ) == CUSTOM_TZ_NAME + + # Test the 3.8-compatible UTC example exactly as in the docstring + with time_machine.travel(DATETIME_NOW_UTC, tick=False): + assert get_timestamp(tzinfo=timezone.utc) == '2024_12_11_1009_08_000007UTC' + assert get_timestamp(how="%Y", tzinfo=timezone.utc) == '2024' + assert get_timestamp(how="%m", tzinfo=timezone.utc) == '12' + assert get_timestamp(how="%d", tzinfo=timezone.utc) == '11' + assert get_timestamp(how="%H", tzinfo=timezone.utc) == "10" + assert get_timestamp(how="%M", tzinfo=timezone.utc) == "09" + assert get_timestamp(how="%S", tzinfo=timezone.utc) == "08" + assert get_timestamp(how="%f", tzinfo=timezone.utc) == "000007" + assert get_timestamp(how="%Z", tzinfo=timezone.utc) == 'UTC' # Spot-check two other built-in strftime-providing objects assert get_timestamp(how="%Y-%m-%d", when=DATE) == "2024-12-11" From d7eeecbca10f601e49f61d3aefbd3dea1bb41767 Mon Sep 17 00:00:00 2001 From: pushfoo <36696816+pushfoo@users.noreply.github.com> Date: Wed, 3 Apr 2024 19:33:52 -0400 Subject: [PATCH 29/33] Phrasing tweak to intro --- doc/programming_guide/screenshots_timestamps.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/doc/programming_guide/screenshots_timestamps.rst b/doc/programming_guide/screenshots_timestamps.rst index ac04f06c3..2ace8eb7d 100644 --- a/doc/programming_guide/screenshots_timestamps.rst +++ b/doc/programming_guide/screenshots_timestamps.rst @@ -6,7 +6,7 @@ Screenshots & Timestamps Sometimes, you need to export data to separate files instead of :ref:`logging with lines of text.`. -Arcade has a limited set of convience functions to help you save +Arcade has a limited set of convenience functions to help you save screenshots and other data this way. Keep the following in mind: @@ -17,8 +17,8 @@ Keep the following in mind: * Arcade's timestamp helper is a fallback for when you can't use :ref:`much better third-party options ` -Please see :ref:`debug-screenshot-i-need-video` to learn -about tools better suited for screen recording. +To learn about better tools for screen recording, please see +:ref:`debug-screenshot-i-need-video`. .. _debug-screenshots: From f585d834cad8f27d1c581838783a7aaae21711ca Mon Sep 17 00:00:00 2001 From: Rich Saupe Date: Wed, 3 Apr 2024 22:43:09 -0500 Subject: [PATCH 30/33] add in get_image --- arcade/application.py | 25 +++++++++++++++++++++++++ arcade/context.py | 15 +++++++++++++-- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/arcade/application.py b/arcade/application.py index c48a153da..f7850236a 100644 --- a/arcade/application.py +++ b/arcade/application.py @@ -24,6 +24,9 @@ from arcade.types import Color, RGBOrA255, RGBANormalized from arcade import SectionManager from arcade.utils import is_raspberry_pi +from arcade.types import Rect +from PIL import Image + LOG = logging.getLogger(__name__) @@ -960,6 +963,28 @@ def save_screenshot( img = self.ctx.get_framebuffer_image(self.ctx.screen) img.save(path, format=format, **kwargs) + def get_image( + self, + viewport: Rect | None + ) -> Image.Image: + """Get an image from the window. + + .. code-block:: python + + # Get an image from a portion of the window by specfying the viewport. + viewport = Rect(10, 16, 20, 20) + image = window_instance.get_image(viewport) + + # Get an image of the whole Window + image = window_instance.get_image() + + :param viewport: The area of the screen to get defined by the x, y, width, height values + """ + return self.ctx.get_framebuffer_image(self.ctx.screen, viewport=viewport) + + def get_pixel(self): + pass + def open_window( width: int, diff --git a/arcade/context.py b/arcade/context.py index cbe908fd4..1f532f564 100644 --- a/arcade/context.py +++ b/arcade/context.py @@ -22,6 +22,8 @@ from arcade.gl.framebuffer import Framebuffer from pyglet.math import Mat4 from arcade.texture_atlas import TextureAtlas +from arcade.types import Rect + __all__ = ["ArcadeContext"] @@ -530,6 +532,7 @@ def get_framebuffer_image( fbo: Framebuffer, components: int = 4, flip: bool = True, + viewport: Optional[Rect] = None ) -> Image.Image: """ Shortcut method for reading data from a framebuffer and converting it to a PIL image. @@ -537,12 +540,20 @@ def get_framebuffer_image( :param fbo: Framebuffer to get image from :param components: Number of components to read :param flip: Flip the image upside down + :param viewport: x, y, width, height to read """ mode = "RGBA"[:components] + if viewport: + width = viewport[2] - viewport[0] + height = viewport[3] - viewport[1] + else: + width = fbo.width + height = fbo.height + image = Image.frombuffer( mode, - (fbo.width, fbo.height), - fbo.read(components=components), + (width, height), + fbo.read(viewport=viewport, components=components), ) if flip: image = image.transpose(Image.Transpose.FLIP_TOP_BOTTOM) From 5aafe39296f62b33b0f3477e4fd6d34d6ddf0afe Mon Sep 17 00:00:00 2001 From: Rich Saupe Date: Thu, 4 Apr 2024 20:24:46 -0500 Subject: [PATCH 31/33] fix typing errors --- arcade/__init__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/arcade/__init__.py b/arcade/__init__.py index 8060c7ed5..f9ca6088b 100644 --- a/arcade/__init__.py +++ b/arcade/__init__.py @@ -9,7 +9,7 @@ # Error out if we import Arcade with an incompatible version of Python. import sys import os -from datetime import datetime +from datetime import datetime, _TzInfo from typing import Optional from pathlib import Path @@ -37,8 +37,8 @@ def configure_logging(level: Optional[int] = None): def get_timestamp( how: str = "%Y_%m_%d_%H%M_%S_%f%Z", - when: Optional[types.HasStrftime] = None, - tzinfo: Optional[datetime.tzinfo] = None + when: Optional[types.HasStrftime | datetime] = None, + tzinfo: Optional[_TzInfo] = None ) -> str: """Return a timestamp as a formatted string. From 90164e87bb3766361180dd24dd6365c5a5701ffd Mon Sep 17 00:00:00 2001 From: Rich Saupe Date: Thu, 4 Apr 2024 20:28:55 -0500 Subject: [PATCH 32/33] fix type error --- arcade/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/arcade/__init__.py b/arcade/__init__.py index f9ca6088b..a8c679e75 100644 --- a/arcade/__init__.py +++ b/arcade/__init__.py @@ -9,7 +9,7 @@ # Error out if we import Arcade with an incompatible version of Python. import sys import os -from datetime import datetime, _TzInfo +from datetime import datetime, tzinfo from typing import Optional from pathlib import Path @@ -38,7 +38,7 @@ def configure_logging(level: Optional[int] = None): def get_timestamp( how: str = "%Y_%m_%d_%H%M_%S_%f%Z", when: Optional[types.HasStrftime | datetime] = None, - tzinfo: Optional[_TzInfo] = None + tzinfo: Optional[tzinfo] = None ) -> str: """Return a timestamp as a formatted string. From 58f2d70d269df59109f257c54c1ff802c889ca31 Mon Sep 17 00:00:00 2001 From: Rich Saupe Date: Fri, 5 Apr 2024 21:43:48 -0500 Subject: [PATCH 33/33] removed datetime type --- arcade/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/arcade/__init__.py b/arcade/__init__.py index a8c679e75..41d6648a8 100644 --- a/arcade/__init__.py +++ b/arcade/__init__.py @@ -37,7 +37,7 @@ def configure_logging(level: Optional[int] = None): def get_timestamp( how: str = "%Y_%m_%d_%H%M_%S_%f%Z", - when: Optional[types.HasStrftime | datetime] = None, + when: Optional[types.HasStrftime] = None, tzinfo: Optional[tzinfo] = None ) -> str: """Return a timestamp as a formatted string.