toolero 0.6.0
Breaking changes
-
arborize()now writes its provenance file with a.ymlextension (figures/np-tree.yml) instead of.yaml, following the family-wide rule inCONVENTIONS.mdthat every YAML file a toolero-family package names for itself ends in.yml. The file’s contents are unchanged, andyaml::read_yaml()reads either spelling, so the only code affected is code that builds the provenance path by hand. Whenarborize()re-renders a tree that already has a.yamlfile next to it from an earlier version, it leaves that file in place and says so, naming both files, so it is clear which one describes the new PNG.
New features
Added
generate_profile(), which writes a YAML skeleton – pre-filled with placeholders and explanatory comments – covering the author information and formatting preferences that tend to be identical across every document created withcreate_qmd(). The written file has two sections: personal information (name,affiliation,orcid,email,url), which becomes the document’sauthor:block, and document settings (date/date-modified,categories,lang,executeoptions for quiet and reproducible rendering, and aformat: html:block of layout preferences). A phone number and mailing address are deliberately not among the placeholders, since documents built from this file tend to get rendered to HTML and published. The file also deliberately does not touchcss,include-before-body, orinclude-after-body– those remaincreate_qmd()’suse_styleargument’s job, reading from a project’s ownassets/folder, and a profile that also tried to set them would collide with a specific project’s branding rather than complementing it.filenamehas no default and must be supplied explicitly, so keeping more than one profile – a personal one and a work one, say – under different filenames is a normal thing to do, not a workaround.pathdefaults to the user’s home directory (fs::path_home()) rather than".", since unlikegenerate_project_config()this file’s whole purpose is being reusable across every project rather than tied to one.Added
generate_citation(), which writes aCITATION.cffskeleton, optionally pre-filled with author information from a profile written bygenerate_profile(), so a project’s citation metadata doesn’t mean retyping the same name, affiliation, and ORCID a third time.title,version,repository-code,url, andlicenseare project facts a personal profile has no way to know, so they are left as placeholders (some commented out) regardless of whetherprofileis supplied;date-releasedis filled in with today’s date. The given-names/ family-names split the Citation File Format requires is done by splitting a profile’s singlenamefield on its last space, which is right for the ordinary case and wrong for some real names – multi-word family names, single-word names, and family-name-first orderings all defeat it. Review the generated file’sgiven-names/family-namesfields before relying on them.Added
generate_license(), which writes a plain-textLICENSEfile at a project’s root from one of three common templates ("MIT","CC0","GPL-3"), with the copyright holder and year filled in."GPL-3"writes the Free Software Foundation’s recommended short notice plus a link to the canonical full text, rather than reproducing the several-hundred-line license itself. It follows the same standalone design and validation and overwrite conventions asgenerate_project_config().Added
generate_data_doc(), which writes a Markdown documentation stub for a single dataset (source, date obtained, license and usage terms, collection method, a variables table, and known issues), with the dataset’s file name and today’s date pre-filled and the rest left as placeholders – the same skeleton-you-complete-by-hand approachgenerate_citation()uses forCITATION.cff.save_output(),generate_manifest(), andwrite_by_group()gain aconfigargument: a path to a project config, typically a project’s own_toolero.ymlas written byinit_project(). When supplied, andoutput_diris not,output_diris resolved from the config’soutput_dirconvention (split_dir, forwrite_by_group()). An explicitoutput_diralways wins overconfig, which only fills in what was not supplied directly.configis entirely opt-in: a project never scaffolded byinit_project()behaves exactly as before, and whenconfigis supplied but cannot be read, each function aborts with the same messageinit_project()gives for a badconfig, rather than silently falling back to a built-in default.generate_manifest()gains agit_rootargument (default".") and now recordscommitin the output record (project-manifest.json): the git commit checked out ingit_rootat the moment the record was written. This is deliberately the one piece of “which version of the code produced this” that package versions cannot supply –renv.lockalready answers which package versions were in play, but nothing else records which revision of the analysis script itself ran.commitis recorded once at the top level alongsideexecution_contextandgenerated_at, not repeated per artifact, and isnullwhen the project is not a git repository, has no commits yet, orgitis not installed. Detection shells out togit rev-parse HEADrather than adding a git R package as a dependency, since this is the only place in toolero that needs git at all.generate_manifest()now writesschema_version: 1as the first key of the output record, and the format is specified in full:?generate_manifestgains a Format section, and the family’sCONVENTIONS.mdgains a section covering every key, its type and allowed values, and the rules for reading records across versions. A record withoutschema_version, as written by toolero 0.5.x, has the version 1 shape and is read as version 1. The version only changes when an existing key is removed, renamed, or changes meaning or type. Reference examples of the format live intests/testthat/fixtures/output-records/.Added
read_output_records()(experimental), the reader that goes withgenerate_manifest(). It reads one or more output folders (or record files) and returns a tibble with one row per artifact: the seven artifact fields as recorded, thenread_from,schema_version, and the run-levelexecution_context,generated_at, andcommit, with asourcecolumn labeling each folder’s rows (by name whenpathis named). It follows the reading rules inCONVENTIONS.md: a record withoutschema_versionreads as version 1, a record with an unknown version is read as far as possible with a warning, and unrecognized keys are ignored. A folder with no usable record falls back to itsaccumulator.csv, deduplicated asgenerate_manifest()would, with a warning; a folder with neither contributes no rows, also with a warning, so one incomplete job never stops the rest from being read. Paired withsubmitr::htc_collect(), it turns a multi-job run into one table in two calls.-
Paths in analysis code now start at the project root, the rule
here::here()follows, so a document can live inreports/(or any folder below the root) and run unchanged there, at the root, and on a cluster. toolero gainshereandrprojrootas dependencies.-
init_project()writes an empty.herefile at the project root, a markerhere::here()finds even in a project created outside RStudio and without git. Commit it. -
create_qmd()accepts afilenamein a subfolder ("reports/analysis.qmd") and creates the folder. The sample data, logo,R/purl.R, and_quarto.ymlstill go to the project root. Header paths fromuse_styleare written relative to the document (css: ../assets/styles.css), since Quarto resolves them from there, and a document below the root that includesheader.htmlorfooter.htmlalso lists the root on itsresource-path, so the logo those files refer to is found. The example template writes its results tohere::here("output"). -
resolve_input_path()reads a relative path from the project root in theinteractiveandquartocontexts, soinput_file: data-raw/sample.csvworks from any folder. A path that exists only relative to the working directory is still used as it is, so documents written with../paths keep working. Therscriptcontext is left alone. -
save_output()andgenerate_manifest()defaultoutput_dirtooutput/under the project root rather than under the working directory, and a relativeoutput_dirconvention inconfigis taken from the folder holding the config. An explicitoutput_diris still used exactly as given.save_output()recordsfile_pathrelative to the project root when the file is inside the project, so the output record never carries a home directory.
On an HTCondor execute node no marker is uploaded, so the job’s scratch directory stands in for the project root and
output/means the job’s own results folder. -
check_project()gains a stale purled scripts check. Every.qmdunder the project whose header declarespurl: true(seecreate_qmd()’suse_purlargument) gets its own row, comparing it against the.RscriptR/purl.Ris expected to have derived from it. Missing entirely, or older than the.qmdit was purled from, is reported as"warn": the.qmdis the source of truth, so anR/script older than the document it came from means an edit was made and not yet re-rendered, and a container or cluster job that runs the.Rfile would run the old analysis without any error to say so. Documents never opted into purl produce no row. The scan covers the project root and the project’s declared folders, the same shape the existingrenv.lockchecks use, sorenv/libraryis neither walked nor mistaken for the project’s own documents.init_project()gains ause_rprofileargument (defaultFALSE). R reads exactly one.Rprofileper session – the project’s own if the working directory has one,~/.Rprofileonly if it does not – so onceuse_renv = TRUEwrites a project.Rprofileviarenv::scaffold(), a user’s personal aliases, options, and helper functions in~/.Rprofilesilently stop loading for that project.use_rprofile = TRUEappends a guarded block to the project’s.Rprofile, after renv’s own activation line, that sources~/.Rprofileif it exists, checked at every session start rather than once at creation time. Opt-in, and defaults toFALSE, since it cuts against renv’s own isolation goal: a project that automatically re-sources the user’s personal environment is no longer fully isolated from it.
Minor improvements
-
init_project()gains ascaffold_fnargument, defaulting torenv::scaffold, mirroring theinteractive_fnargument ofdetect_execution_context(). It exists so the test suite can supply a stand-in function rather than mocking a binding inside renv’s own namespace, which had been loading renv mid-suite and letting it repoint.libPaths()undercovr::package_coverage(), so that packages went missing several test files away from the cause. Most users never need to pass it.
Bug fixes
create_qmd(include_examples = TRUE)no longer copies the placeholder logo intoassets/when the project’s own_toolero.ymldeclares afolders:list that does not includeassets– the arrangementinit_project(branding = "none")produces. Previously every call withinclude_examples = TRUEcopiedlogo.pngregardless, leaving a project that declared no branding with an undeclaredassets/folder holding a file nothing else in the project asked for. A.qmdcreated outside any toolero-scaffolded project (no_toolero.ymlatpath) is unaffected and still gets the logo, since there is no project-level branding decision to defer to. An existingassets/logo.pngcontinues to be left in place either way.create_qmd(header_defaults = )no longer lets a profile’sformat:block silently delete sibling keysuse_stylehad just injected into the same block..substitute_yaml()built one.set_yaml_key()entry per top-level key in the supplied file, and.set_yaml_key()replaces whatever nested structure exists at a path wholesale, so a file settingformat: html: toc: falsewould overwrite the entireformat: html:mapping, discardingcss,include-before-body, orinclude-after-bodyifuse_stylehad set any of them – silently, since substitution runs after style injection so it can override it..substitute_yaml()now flattens a mapping (a named block likeformat: html: ...) into leaf-level path/value pairs before substituting, so a sibling key the file doesn’t mention survives at any depth; a sequence (author:,categories:) is still replaced as a whole, since merging a list element by element against the template’s own list is not a meaningful operation.
Deprecated features
-
create_qmd(): theyaml_dataargument is renamed toheader_defaults, which better reflects what it does now thatgenerate_profile()gives it a natural counterpart to write from.yaml_datais deprecated rather than removed: the old name still works, and its value is used whenheader_defaultsis not also supplied, but alifecycle::deprecate_warn()fires when it is. Removal is planned for v0.7.0, alongsideinit_project(uw_branding = )andcheck_project(error = ).
Documentation
- Documentation, vignettes, and user-facing messages now use the family’s shared vocabulary for the files toolero writes (see
CONVENTIONS.md)._toolero.ymlis the project config,manifest.csvfromwrite_by_group()is the job manifest, andproject-manifest.jsonfromgenerate_manifest()is the output record. File names, function names, and arguments are unchanged; only the prose around them moved. Error and warning messages that previously said “manifest” now name the specific file they mean (for example, “Job manifest … does not exist” fromrun_by_group()and “An output record already exists” fromgenerate_manifest()).
Testing
tests/testthat/setup.Rnow callstestthat::set_state_inspector()to compare.libPaths()before and after every test. Any test that changes it without restoring it now fails immediately, naming the offending test, instead of surfacing later as a misleading “package not installed” error somewhere else in the suite.The
RENV_CONFIG_SANDBOX_ENABLED = "FALSE"workaround is retired fromtests/testthat/setup.R, now thatinit_project()’s tests no longer load renv’s namespace (seescaffold_fnabove).
Internal changes
-
write_by_group()’s internalsanitize_filename()helper is renamed to.sanitize_filename(), confirmed against the package’s ownNAMESPACEas internal-only before the rename.
toolero 0.5.0
Breaking changes
create_qmd(): both bundled templates now setformat: html: embed-resources: truerather than stating the Quarto default offalse. A non-self-contained HTML depends on the_files/sidecar directory rendered beside it, and nothing that moves these documents around knows about sidecars:submitr::htc_gen_executable()archives a folder, an emailed report is one file, and a rendered document committed next to its analysis quietly depends on a directory nobody thinks to copy. The cost oftrueis a larger file; the cost offalseis an artifact that works only on the machine that made it. Set it back in your own header if you would rather have the sidecar.-
init_project(): usesrenv::scaffold()instead ofrenv::init()whenuse_renv = TRUE, no longer writes a.renvignorecontaining*.qmd, and no longer takes a snapshot at creation time.renv::init()loads the newly created project into the calling R session, repointing.libPaths()at a library that is empty apart fromrenvitself. Every package the caller had available disappears until they restart R, which surfaces later as confusingthere is no package called ...errors having nothing to do with the project just created. Therestartargument suppresses the restart, not the activation, andbare = TRUEskips dependency discovery but still loads.renv::scaffold()creates the same infrastructure –renv/library,renv/activate.R,renv/.gitignore, an.Rprofilethat activates the project in future sessions, and an initialrenv.lock– and leaves the caller’s session untouched.The
.renvignoreexcluded Quarto documents fromrenv’s dependency discovery, so a project whoselibrary()calls live in its.qmdsource – the arrangement this package recommends – could snapshot a lockfile with none of the analysis packages in it, andcontainr::generate_dockerfile()would then build an image that could not run the analysis. The file was written at a point ininit_project()where the project contained no.qmdfiles at all, so it never affected the snapshot taken at creation time; its only effect was on every snapshot the user took afterwards. Take a snapshot yourself once the project has code in it, and before containerizing. Projects created by earlier versions still carry the file and should have it removed by hand. write_by_group(): the job manifest now has one schema regardless of how many grouping columns were supplied. One column per grouping variable holding the raw value, thengroup_value,n_rows,file_path. Grouping on a single column previously produced only the last three; it now also carries the grouping column, whose value repeatsgroup_valueexactly. That redundancy is deliberate – one schema with a varying column count is easier to read, validate and rely on than two schemas selected by how many columns you happened to group on.run_by_group()andsubmitr::htc_gen_submit()readgroup_valueandfile_pathand are unaffected.write_by_group(): groups are now written, and manifest rows recorded, in order of first appearance in the data rather than in sort order of the sanitized key. Numeric groups no longer come out10, 11, 9. This is more than cosmetic:submitrwritessubdatasets.csvin manifest order, HTCondor assignsProcIdin that order, and log filenames are reconstructed from position, so manifest row order is the mapping from a job number back to a group. The caveat about iteration order has been removed from the documentation, since there is no longer anything to warn about.write_by_group(): withdrop_na = FALSE, a grouping column holding both missing values and the literal string"NA"is now an error naming the column, rather than silently merging two different groups into one file. Missing values are coerced to"NA"so they form their own group, so a column containing North America, Not Applicable, or a country code would otherwise have collapsed the two.drop_na = TRUEis unaffected: the missing rows are gone before the coercion, so no collision is possible.check_project(): the standard folder set now comes from.default_folders()and therefore includesR/. A project without anR/folder gains a"warn"row it did not have before.init_project():R/is now part of the standard folder set. The derived.Rscript belongs there, whether it comes fromqmd_to_r()or from the post-render hookcreate_qmd(use_purl = TRUE)scaffolds, andR/purl.Rwas already being created there bycreate_qmd()without the folder ever being declared.scripts/remains in the set and is now documented as the home for hand-written scripts.init_project(): every precondition is now checked before anything is created. A call that would previously create the project directory, its folders, and its branding assets before aborting on an existing README now aborts first and leaves nothing behind.init_project(): README detection at the destination is now case-insensitive and extension-agnostic, matchingcheck_project(). A project already containingreadme.txt,README, orReadme.pdfnow aborts rather than quietly acquiring a secondREADME.mdbeside it. Both functions now share one.find_readme()helper (issue #11).init_project(): when the folder set comes fromconfig, acustom_foldersremoval is honored literally and the parent folder is no longer added back.custom_folders = "-output/figures"against the built-in default set still leavesoutput/behind, since that set is a convention; against a config it does not, since a config is an explicit and complete statement of the intended structure.init_project(): aborts rather than overwriting an existing_toolero.ymlor existing files inassets/.generate_project_config(): the generated file now carriesschema_versionand aconventions:block in addition tofolders:, and is written from the same template and writer as the_toolero.ymlthatinit_project()records. Files produced by earlier versions, which carried onlyfolders:, continue to be read without change.init_project(): theuw_brandingargument is deprecated in favor of the newbrandingargument.uw_branding = TRUEmaps tobranding = "uw-madison"(notbranding = TRUE, which now means generic placeholder assets);uw_branding = FALSEmaps tobranding = "none". Alifecycle::deprecate_warn()fires whenuw_brandingis supplied. Removal planned for v0.7.0 alongsidecheck_project(error).create_qmd(): theuse_styleargument now detects branding files by standardized name (styles.css,header.html,footer.html) rather than scanning for any.cssor.htmlfile and erroring on ambiguity. Custom directories are the user’s responsibility to populate under these exact names. The old “error when multiple.cssor.htmlfiles found” behavior is removed.create_qmd():footer.htmlis now separately wired asinclude-after-body:in the generated YAML, a new Quarto YAML key not present in previous versions. Projects usinguse_style = TRUEwill now have a footer included ifassets/footer.htmlexists.init_project(): now creates aREADME.mdfile at the project root by default. The newuse_readmeargument defaults toTRUE; calls that previously created no README – which was all of them, since the argument did not exist – will now produce one. Passuse_readme = FALSEto opt out and preserve the old behavior.
New features
write_by_group(): newprefixargument, a namespace prepended to every output filename.prefix = "data"turnsa.csvintodata-a.csv, anda--female.csvintodata-a--female.csv. It is sanitized the same way group values are and joined with a single-, not the--that separates grouping columns:--is there to keep the group tuple and the filename in one-to-one correspondence, and a prefix is constant across every file in a call, so it cannot create a collision. Without a prefix, splitting on a short column produces short filenames such asa.csv, and sincesubmitr::htc_gen_submit()reduces the manifest tobasename()those land in one flat directory on the access point where two datasets split on the same column would overwrite each other.prefixis last in the signature, so adding it shifts no existing positional argument. Defaults toNULL, which leaves filenames exactly as before.check_project(): reads the project’s own_toolero.ymlwhen noconfigargument is supplied, so a customized project no longer has to be handed the same config on every call. Precedence is explicitconfig, then the project’s manifest, then the built-in standard set. A folder declared in either of the first two and missing from disk is reported as"fail"rather than"warn": a declaration that is not met is a conformance failure, whereas the standard set is a suggestion nobody signed up for. A_toolero.ymlthat exists but cannot be parsed is reported as a failing check and the audit continues against the standard set, since aborting the audit is less useful than reporting the problem (issue #12).check_project(): new_toolero.ymlcheck, reporting whether the project carries a manifest. Absence is a"warn"– a project predating v0.5.0 legitimately has none – and the message says how to create one.check_project(): reports naming conventions only when they differ from the defaults, as a single"info"row naming what changed. Silence when they match, so a conventions row always means something in this project resolves differently from every other one.check_project(): newrenv.lock packagescheck, reporting a lockfile that records no packages other thanrenvitself when the project also has.Ror.qmdsource files. The pairing is what makes it worth printing: a freshly scaffolded project legitimately has an empty lockfile, sincerenv::scaffold()does no dependency discovery, but a project with code in it and nothing in its lockfile is the state that produces a container image which builds cleanly and then cannot run. The source scan covers the project root and the declared folders rather than recursing through everything, sorenv/libraryis neither walked nor mistaken for the project’s own code.check_project(): new.renvignorecheck, reporting an entry that excludes.qmdfiles. The advice is to remove it. Purling to.Rlater is not a substitute, since the snapshot you containerize from may be taken before the purl and the.qmdis the file being maintained either way. Versions ofinit_project()before v0.5.0 wrote such a file, so projects created by those versions still carry one.init_project(): writes a project manifest,_toolero.yml, to the project root, recording the folder set it resolved and the naming conventions in force. The file records the resolved structure, never the inputs that produced it, so a project built from aconfig, one built withcustom_folders, and one built from the defaults all produce the same shape of file. It exists because the structure is configurable:check_project()can audit a customized project without being handed the same config again, andcontainrandsubmitrcan resolve where code, data, and outputs live rather than assuming. Commit the file – it describes the project, not the machine it was created on. The format is experimental and may gain keys before it settles;schema_versionexists so a reader can tell whether it understands what it is holding (issue #12).init_project()andgenerate_project_config()now share one schema, one template (inst/templates/_toolero.yml), and one writer. A config authored by hand and a manifest a project carries are the same kind of document; the only difference is who wrote it.Project configuration files may now declare a
conventions:block alongsidefolders:. Three keys are recognized:output_dir(where the analysis writes artifacts, read bysave_output()andgenerate_manifest()),script_dir(where the derived.Rscript lives), andsplit_dir(wherewrite_by_group()writes per-group subsets). Any key a file does not supply falls back to the package default, and unrecognized keys are ignored with a warning. These are the names the toolero family resolves rather than hardcodes.init_project(): writes a zero-byte.gitkeepinto each folder it creates that is still empty when the call finishes. git tracks files rather than directories, so without this a scaffolded structure survives nothing – the opening commit contains the files at the project root and none of the layout, and a collaborator cloning the repository gets a project with no folders in it. It is also the most common waycheck_project()would report a folder as failing on a project where nothing is actually wrong. The placeholders are written whether or notuse_git = TRUE, since a project can be git-initialized at any point afterwards. Folders that already have content are left alone, soassets/gets none.init_project(): whenbrandingis enabled,assets/now joins the project’s folder set and is recorded in the manifest alongside every other folder, so downstream packages can find the branding files without being told about them separately.init_project(): newbrandingargument replacinguw_branding. AcceptsTRUE(generic placeholder assets),"uw-madison"(UW-Madison RCI branding), or"none"/FALSE(no assets folder). All modes produce the same five standardized filenames inassets/:logo.png,favicon.png,header.html,footer.html,styles.css– so downstream consumers (create_qmd(),containr::generate_dockerfile()) can reference those names regardless of which branding mode was used.init_project(): newuse_readmeargument controlling whether a README is created at the project root.TRUE(default) createsREADME.md;"plain"createsREADME.txtwith identical content – only the extension differs;FALSEcreates no README. Both formats copy the same file,inst/templates/readme-template.md: a generalist guide that explains what a README is and why it matters, lays out a recommended section structure covering material shared by all research artifacts as well as software-specific and data-specific sections, and defers detailed guidance to the Cornell Data Services README guides rather than reproducing them. If a README already exists at the destination,init_project()aborts with an informative message instead of overwriting it.create_qmd():use_style = TRUEnow wires all three styling files present inassets/–styles.cssascss:,header.htmlasinclude-before-body:, andfooter.htmlasinclude-after-body:– rather than onlycss:and one HTML include. Any subset may be present; only files that exist are injected.create_qmd():assets/logo.pngis now exempt fromoverwrite. An existing logo (e.g. placed byinit_project(branding = )) is always left in place even whenoverwrite = TRUE, since a generic placeholder silently replacing institutional branding would be surprising. All other files (sample.csv,_quarto.yml,purl.R, the.qmditself) continue to respectoverwrite.-
Added
resolve_input_path(), which resolves an input data path for the current execution context and explains what went wrong when it cannot. It replaces theswitch()ondetect_execution_context()that this family of packages has been recommending in four different places, which had already drifted: the roxygen example resolved the interactive branch todata/sample.csvwhile the bundled template resolved it todata-raw/sample.csv.Each of the three branch arguments is an ordinary R argument and therefore a promise, so only the branch matching the context is ever evaluated, exactly as in the hand-written
switch(). The difference is where the evaluation happens. A document that declares noparams:block raisesobject 'params' not found, and because the promise is now forced insideresolve_input_path()rather than in the document’s own frame, that error can be caught and turned into a message about the YAML header. No amount of documentation could have reached it.Three failures it reports that previously surfaced one call later as something unhelpful:
commandArgs(trailingOnly = TRUE)[1]returningNA_character_when no argument was passed, which is whatsubmitr’s single mode with nodata_filesproduces; aparamsblock that exists without aninput_filekey; and a resolved path that is simply not there, which is most often a working directory that is not what the author assumed.Omitting an argument is meaningful. The
rscriptbranch defaults to the first command line argument. Theinteractiveandquartobranches both fall back to the document’s ownparams$input_file, so a document whose header declares one can callresolve_input_path()with no arguments and the path is written once, in the header, rather than there and again in a chunk that has to be kept in step with it.
Bug fixes
create_qmd(): the YAML header was matched with a regular expression whose match included both---fences, and that whole string, trailing fence and all, was handed toyaml::yaml.load(), where the closing fence reads as the start of a second, empty YAML document. The parser tolerated it, so nothing visibly broke; the fences are now stripped before anything parses the header.create_qmd(use_purl = TRUE): the warning issued when automatic post-render wiring is skipped for awebsite,book, ormanuscriptproject asserted thatR/purl.R“was still created”. It may not have been: the script is scaffolded subject tooverwrite, so an existing copy is left in place. The warning now reports which of the two happened, since whoever reads it is about to point a post-render hook at that script by hand and needs to know whether it is the copy this version ships. The manual instructions in the same warning are now phrased inline rather than as a two-line YAML snippet, whichclicollapsed onto one line anyway.init_project(use_git = TRUE)initialized the git repository, staged files and made the opening commit in the caller’s project rather than the project it had just created.usethis::create_project()sets the activeusethisproject only for its own duration – it usesusethis::local_project()internally and restores the caller’s project when it returns withopen = FALSE– andinit_project()then calledusethis::use_git()without setting the project again. Runninginit_project()from inside another package or project therefore added entries to that project’s.gitignoreand offered to commit its uncommitted files under the message"initial commit", which is easy to accept because the prompt looks entirely plausible.init_project()now callsusethis::local_project(path, force = TRUE, setwd = FALSE)after creating the project, so every later step resolves against the new project, and the caller’s project is restored when the function returns. Every test in the suite passeduse_git = FALSE, which is why this went unnoticed; the git path is now covered.init_project(): documented thatR/cannot be suppressed, byconfigor bycustom_folders.usethis::create_project()callsuse_directory("R")unconditionally, so the directory is present in every project regardless of the resolved folder set. A structure that leaves it out is honored everywhere else:R/is absent from_toolero.yml, gets no.gitkeep, and is not audited bycheck_project(). Only the directory itself is unavoidable. No code change; the test that asserted otherwise was the thing that was wrong, and it only began failing onceR/joined the default folder set in this release.save_output()andgenerate_manifest(): the guard that reports an accumulator whose columns do not match the expected schema could not format its own message. The expected columns reachedclias{.val {.accumulator_columns()}}, andcli3.4.0 and later read a{}expression beginning with a dot as an inline style name rather than as R code, so formatting failed and the explanation was replaced by acliparse error. The failure only ever surfaced in the one branch whose purpose is to explain what went wrong. Both sites now bind the schema to a local first. The existing tests asserted only that something was thrown, which is why this went unnoticed; they now check that the message names a column.run_by_group(workers = NULL):NULLskipped the validation block entirely and then reachedif (workers > 1L)about a hundred and sixty lines later, whereNULL > 1Lislogical(0)andifraises “argument is of length zero”.NULLis now documented and accepted as a way of saying “do not parallelize” and is coerced to1L. The block also now rejects aworkersof any length other than one, so that everything after it can rely onworkersbeing a single integer of at least one rather than on a reader noticing the gap.qmd_to_r(): creates the parent directory ofoutputif it does not already exist.knitr::purl()writes through a connection and does not, so an explicit output path into a folder that is not there failed with a connection error naming the file rather than the missing directory.R/is the documented home for derived scripts, which makes this the ordinary case for any project not created byinit_project().-
run_by_group(): when running sequentially, arguments in...are now forwarded to.funevaluated rather than captured withlist(...)first. Most arguments are unaffected either way: a number, a string, a logical, a file path are ordinary values, and they reached.fcorrectly before and still do. The case that was broken is the one argument that is not an ordinary value, a bare column name. The old behaviour forced every argument inrun_by_group()’s own frame, where a symbol likeflipper_length_mmmeans nothing, so a function written to capture it with{{ }}failed withobject 'flipper_length_mm' not foundbefore it was ever entered. Such functions now work:plot_group <- function(data, x, y) { ggplot2::ggplot(data, ggplot2::aes(x = {{ x }}, y = {{ y }})) + ggplot2::geom_point() } run_by_group(groups = subsets, .f = plot_group, x = flipper_length_mm, y = body_mass_g)To be clear about what this does not ask of you:
run_by_group()places no requirement on how.fis written.{{ }}is what any function accepting a bare column name needs, called directly or not; a function taking only ordinary values needs nothing. If you would rather avoid tidy evaluation altogether, pass the column name as a string and index with.data[[x]]inside.f, which works in both modes.Two smaller consequences follow, both matching what a direct call to
.fdoes: an argument with a side effect is evaluated at most once for the whole call rather than once per group, as before, and an argument.fnever touches is now never evaluated at all, where previously it was.Bare column names do not survive
workers > 1. Parallel execution sends the work to separate R sessions, so every argument has to be materialized and serialized first, and an argument whose value exists only inside the data mask.fbuilds has nothing to serialize.run_by_group()now reports that directly, naming the two ways forward, rather than letting it surface from insidefuture’s globals inspection as an unattributedobject 'x' not found. Ordinary values, strings included, are unaffected. The portable form for a bare column name moves it inside.f, and works in both modes:run_by_group( groups = subsets, .f = \(d) plot_group(d, x = flipper_length_mm, y = body_mass_g), workers = 4 )Closes #16.
Internal changes
New
R/utils-yaml.Rholds the line-oriented header helpers:.split_yaml_header(),.join_yaml_header(),.set_yaml_key()and.set_yaml_keys(), with.yaml_indent(),.yaml_line_key(),.find_yaml_key(),.yaml_entry_extent(),.first_child_indent(),.splice()and.render_yaml_entry()beneath them. Only the value being written passes throughyaml::as.yaml()..inject_style_yaml(),.inject_purl_yaml()and.substitute_yaml()were three near-identical copies of the same parse-mutate-serialize block, differing only in the mutation between the two. They are now three thin callers of.set_yaml_keys(), and.stamp_params_yaml()joins them as a fourth.create_qmd()resolvessample.csv, the.qmdtemplates,purl.Rand_quarto.ymlthrough.package_template()rather than callingsystem.file(mustWork = TRUE)directly, so a missing or misnamed template reports which file it wanted instead ofpkgload’s bareCan't find package file..standard_folder_message()and.cli_escape()moved fromR/check-project.RtoR/utils-project.R. The folder set and the advice for each member of it are one fact, and keeping them in separate files is how they drift;.standard_folder_message()gained entries forR/andassets/in the move.check_project()’s inline config parsing and README detection were replaced by calls to the shared.read_config_file()and.find_readme().write_by_group(): documentation now uses the term job manifest consistently for themanifest.csvit writes, distinguishing it from the project manifestgenerate_manifest()produces. The first lists inputs to a computation that has not happened; the second records outputs from one that has.sanitize_filename()gained roxygen explaining the invariant the filename scheme depends on: because a run of non-alphanumeric characters collapses to exactly one dash, a sanitized value can contain a single-but never two consecutive ones, which is what leaves--free to mark a column boundary.Added
.renv_lock_is_bare(),.project_has_sources()and.renvignore_excludes_qmd()toR/check-project.R, backing the two new renv checks.Added
R/utils-project.R, holding the facts about a toolero project that more than one function needs:.default_folders(),.default_conventions(),.project_yml_name(),.project_yml_schema_version(),.write_project_yml(),.read_project_yml(),.read_config_file(),.substitute_block(), and.find_readme(). The standard folder set was previously spelled out in four places –init_project(),generate_project_config(),check_project(), and.standard_folder_message()– with nothing keeping them in step. Adding a folder to the standard set is now a one-line change in.default_folders().Added
inst/templates/_toolero.yml, the annotated template bothinit_project()andgenerate_project_config()render. The folder list and conventions block are placeholders filled at write time from.default_folders()and.default_conventions()rather than literal text, so the template cannot drift from the package defaults. Substitution is line-based rather than a YAML round trip, so the template’s explanatory comments survive into the written file..resolve_custom_folders()gains apreserve_parentsargument.init_project()passesFALSEwhen the base folder set came from aconfig.Added
.branding_asset_names(), replacing the inline vector of five standardized asset filenames.init_project(): the new project is now made the activeusethisproject explicitly, viausethis::local_project(), for the duration of the call. The surroundingwithr::with_dir(getwd(), ...)block, which set the working directory to the working directory and therefore did nothing, has been removed. See the bug fix below for why the explicit call is needed.inst/assets/now contains ten files under auw-*/generic-*prefix convention:uw-logo.png,uw-favicon.png,uw-header.html,uw-footer.html,uw-styles.css, and fivegeneric-*counterparts. The copy step ininit_project()strips the prefix and writes standardized names into the project’sassets/directory. The old three-file UW set (rci-banner.png,header.html,styles.css) ininst/extdata/has been removed;inst/extdata/now contains onlydata-provenance.md.inst/templates/logo.pngremoved. The generic placeholder logo is nowinst/assets/generic-logo.png, whichcreate_qmd(include_examples = TRUE)copies intoassets/logo.pngwhen no logo already exists..inject_style_yaml()gainsheader_fileandfooter_filearguments (replacing the old singlehtml_fileargument), maps them toinclude-before-body:andinclude-after-body:respectively, and drops thefavicon_fileargument (favicon wiring belongs in_quarto.ymlas a website-project option, not in the per-document YAML).style_diris absolutized viafs::path_abs()increate_qmd()before style detection, ensuringfs::path_rel()comparisons are valid when a relativeuse_stylepath is combined with an absolutepathargument.Added
inst/templates/readme-template.md, the generalist README template copied byinit_project()’s newuse_readmeargument..ensure_directory()moved fromR/save-output.RtoR/utils-project.R, sinceqmd_to_r()now uses it too and it is no longer specific to the accumulator. Its tests moved with it into the newtests/testthat/test-utils-project.R.
New features (continued)
- Added
save_output()for writing an object to disk via a user-supplied function and recording the write in a project-level accumulator atoutput_dir/accumulator.csv. The accumulator is append-only and carries one row per call, recordingfile_path,r_class,timestamp,function_used,status,error_message, andnote. The call to the write function is wrapped in a narrowly-scopedtryCatch()– only that call, not the rest ofsave_output()’s body – so a failed write is recorded withstatus = "failure"and the caught error message before the original condition is rethrown unmodified, preserving condition class and traceback. Missing destination directories are created automatically and reported. - Added
generate_manifest()for reading the project accumulator, collapsing it to one row per output file (keeping the latest write per path, since the accumulator may contain superseded rows from reruns within a session), and writingproject-manifest.json. The manifest recordsexecution_contextandgenerated_atonce at the top level, followed by anartifactsarray with one entry per deduplicated output, ordered chronologically. Field names are toolero-native rather than RO-Crate vocabulary – that translation belongs inencapsulr::describe()as a thin mapping layer. Checksums are deliberately excluded:rocrateR::bag_rocrate()computesmanifest-sha512.txtat bagging time, and duplicating that here would create a second record to keep in sync. A missing accumulator is an error; an accumulator with no rows produces an empty manifest with a warning.
Improvements
create_qmd(): every edit to a document’s YAML header is now made line by line rather than by parsing the header and writing it back out. Keys the edit does not touch keep the template’s own quoting, indentation, comments and ordering, so the document a reader opens is the template we shipped plus the keys they asked for. Previously a header was parsed and re-serialized once per edit – up to three times in a single call withuse_style,use_purlandyaml_datatogether – and each pass stripped quoting from scalars, moved sequence indentation, and would have deleted any comment the header carried. Both templates now carry a YAML comment explainingembed-resources, which the old implementation could not have preserved.create_qmd(yaml_data = ): a key the supplied file does not mention is now left exactly as the template wrote it. Top-level keys the file does mention are replaced wholesale, as before.check_project(): README detection is now case-insensitive and extension-agnostic. Any file whose stem matchesreadme(in any capitalization) is recognized, regardless of extension or the absence of one. Previously onlyREADME.md,README.Rmd, andREADME.qmdwere checked, all case-sensitively, missing common variants likereadme.mdor a plainREADMEon Linux (issue #11).check_project(): the standard folder set now matchesinit_project()–data-raw/,data/,scripts/,output/figures/,output/tables/, andreports/. The previous hardcoded set (data-raw/,data/,docs/) was stale relative to the v0.4.0 breaking change toinit_project().check_project(): newconfigargument accepts a path to a YAML file produced bygenerate_project_config(). When supplied, thefolders:list in the file replaces the standard toolero folder set for the folder checks. Non-folder hygiene checks (.Rproj,renv.lock, git,.gitignore, README, and hidden files) always run regardless of the config. Folders declared in the config but missing from the project are reported as"fail"rather than"warn"– the user declared them explicitly, so their absence is a conformance failure rather than an advisory (issue #12).detect_execution_context(): its@examplesno longer show the input resolutionswitch(), which now lives inresolve_input_path(). The example shows a use that is genuinely about the context itself, and a@seealsopoints at the new function.detect_execution_context(): the documented priority order now records that it is unobservable.QUARTO_DOCUMENT_PATHis set only by Quarto rendering a document, and every path that renders one runs the R code in a process that is not interactive; running chunks inline in RStudio is the reverse, interactive with the variable unset. The two tests never fire together, so the priority never arbitrates anything. The order is unchanged.-
create_qmd(): the credit for the bundledsample.csvnow says what the file is and where to read about it. It is a subset of the Palmer Archipelago penguin data taken from an earlier version ofpalmerpenguinsthan the one on CRAN today, and since R 4.5.0 the same data ships with base R asdatasets::penguins. The note records that base R shortened four column names, sobill_length_mm,bill_depth_mm,flipper_length_mmandbody_mass_gin the CSV arebill_len,bill_dep,flipper_lenandbody_massthere. The template keeps the longer names, which carry their units. The Gorman, Williams and Fraser- data paper is now cited alongside the R package.
create_qmd(): the bundled example template resolves its input throughresolve_input_path()rather than a hand-writtenswitch(), and points out that the zero-argument form works once the header declaresinput_file.The README was brought up to date with the 0.5.0 changes. The opening workflow now runs end to end against the bundled sample data, with the analysis function defined inline: it previously read an
input.csvthat nothing created, called an undefinedmy_analysis, and wrote the derived script intoscripts/rather thanR/. Two claims that had gone stale are corrected:R/purl.Ris scaffolded subject tooverwriterather than “unconditionally”, and the job manifest has one schema rather than a separate three-column shape for single-column splits. New material covers_toolero.ymland what downstream packages read from it,R/in the standard folder set and why it cannot be suppressed,.gitkeep,renv::scaffold()and the absent creation-time snapshot, the tworenvchecks incheck_project(),prefixand first-appearance ordering inwrite_by_group(),resolve_input_path(),embed-resources: true, the accumulator schema as the thing that holds still, and what runningtooleroinside a container commits you to. The dependency list now distinguishes required packages from suggested ones and names which function needs each,knitrforqmd_to_r()above all, which was absent from the list while being required by step 4 of the first workflow.
Deprecated features
-
check_project(error): theerrorargument is deprecated and will be removed in v0.7.0. The cli report now always prints and the tibble is always returned invisibly. To access results programmatically, assign the output directly:out <- check_project(). Passingerror = FALSEcontinues to work but triggers a deprecation warning.
toolero 0.4.0
CRAN release: 2026-07-16
New features
- Added
run_by_group(), the apply half of the split-apply workflow. Accepts either a manifest CSV produced bywrite_by_group(manifest = TRUE)or a named list of data frames. Applies a user-supplied function to each group subset and collects the results into a flat tibble (when the function returns a data frame) or a nested tibble with a list-column (when it returns anything else). Supports parallel execution viafurrrandfuturethrough theworkersargument, with a ceiling atmax(1L, parallelly::availableCores() - 1L)to reserve one core for the main session. Aseedargument enables reproducible parallel execution for analyses involving randomness. - Added
read_clean_csv()for reading CSV files into a tibble withjanitor::clean_names()applied automatically. Supports explicit missing-value codes viana, selective row dropping viadrop_na(acceptsTRUEor a character vector of column names), an optional ingest summary viasummary, and pass-through arguments toreadr::read_csv()via.... - Added
write_clean_csv()for writing data frames to CSV with clean column names. Appliesjanitor::clean_names()if column names are not already clean and reports affected columns via cli feedback. - Added
check_project()for auditing a project directory against toolero conventions. Checks for expected folders, an.Rprojfile,renv.lock, a git repository, a README, a.gitignore, and hidden files such as.RDataor.Rhistory. Operates in two modes: a cli report (default) or a tibble return for programmatic use (error = FALSE). - Added
qmd_to_r()for extracting R code chunks from any.qmdfile into a standalone.Rscript viaknitr::purl(). The output path defaults to the same directory as the input with the extension replaced. Thedocumentationargument controls how much context is preserved in the extracted script. - Added
generate_project_config()for writing a skeleton YAML project configuration file pre-filled with the standard toolero folder structure. Intended to be edited by the user and passed toinit_project()via the newconfigargument.filenameis required and explicit;pathdefaults to".". Anoverwriteargument (defaultFALSE) guards against accidental replacement of an existing config. The file extension is normalized to.ymlregardless of what is supplied. - Added Palmer Penguins attribution (Horst, Hill & Gorman, 2020) to the template
.qmd, thecreate_qmd()roxygen@detailssection, and a provenance note ininst/extdata/.
Breaking changes
-
init_project(): the standard folder structure has been revised to better reflect research workflow conventions established by The Carpentries and UW-Madison Libraries. The new standard set isdata-raw/,data/,scripts/,output/figures/,output/tables/, andreports/. The previous set (data/,data-raw/,images/,plots/,results/,scripts/,docs/,R/) is no longer created by default. -
init_project():extra_foldershas been renamed tocustom_folders. The argument now supports a dplyr-select-like syntax: bare names add folders (e.g."models"), names prefixed with"-"suppress creation of that folder from the resolved set (e.g."-output/figures"). Suppression removes only the named leaf – parent directories are preserved. Duplicate additions emit an informational message and are skipped; references to non-existent folders via"-"emit a warning. -
create_qmd(): no longer copiesstyles.cssandheader.htmlfrom the package into the project. Custom styling is now controlled exclusively by the newuse_styleargument. Projects that relied oncreate_qmd()copying UW-branded assets should useinit_project(branding = "uw-madison")to scaffold those files, then passuse_style = TRUEtocreate_qmd()to wire them into the YAML. -
create_qmd(): sample data is now copied intodata-raw/instead ofdata/, consistent withinit_project()’s folder structure.
New features (continued from above)
-
init_project(): addedconfigargument. When supplied, the folder list in the YAML file replaces the built-in standard structure entirely.custom_foldersis still applied on top of the config-derived set. Configs are produced bygenerate_project_config()and can be stored in the user home directory for reuse across project types. -
create_qmd(): addedinclude_examplesargument (defaultTRUE). WhenTRUE, copies a sample dataset (sample.csv) intodata-raw/, a placeholder logo (logo.png) intoassets/, and uses a worked example template with aparamsblock referencing the sample data. WhenFALSE, creates a blank skeleton.qmdwith only the YAML header and a setup chunk – no sample data, no logo, no example analysis block. -
create_qmd(): addeduse_styleargument (defaultFALSE). AcceptsFALSE(no custom styling),TRUE(scansassets/for standardized branding files by name), or a directory path (scans that directory instead).styles.cssis added ascss:,header.htmlasinclude-before-body:, andfooter.htmlasinclude-after-body:. Only files that exist are wired into the YAML. - Added
inst/templates/skeleton.qmd– a minimal Quarto template used wheninclude_examples = FALSE. Contains the YAML header, a setup chunk withlibrary(toolero), and a single placeholder heading. -
write_by_group():group_colnow accepts a character vector of column names, enabling grouping by more than one column at once. Sanitized filenames join multiple columns with--(e.g.group_col = c("species", "sex")on an Adelie male producesadelie--male.csv); only combinations actually present in the data produce files, not the full cross-product of possible values. Whenmanifest = TRUE, the manifest gains one column per grouping variable (holding the raw, unsanitized value) in addition to a compositegroup_valuecolumn joining the raw values with" | ". Single-column calls are unaffected – filenames, manifest schema, and behavior are unchanged from previous versions. -
write_by_group(): addeddrop_naargument (defaultTRUE). Rows with a missing value in any grouping column are dropped before splitting, with a cli message reporting how many rows were dropped and from which column(s) – this was previously silent, undocumented behavior inherited fromsplit(). Setdrop_na = FALSEto instead treat missing values as their own group rather than dropping them.
Bug fixes
-
create_qmd():use_style = TRUEnow correctly copiesrci-banner.pngfrominst/assets/into the projectassets/directory. Previously the banner was only copied inside theinclude_examplesblock and was silently omitted whenuse_style = TRUEwas combined withinclude_examples = FALSE. -
create_qmd():filenameargument now normalizes the file extension to.qmdviafs::path_ext_set(). Passing"my-document"and"my-document.qmd"both producemy-document.qmd; a double extension is never added. -
init_project(): path construction now usesfs::path()throughout rather thanglue::glue("{path}/{folder}"), ensuring correct behavior on all platforms. -
init_project(): branding files are now copied frominst/assets/rather thaninst/extdata/, consistent with the rest of the package.
toolero 0.3.0
CRAN release: 2026-04-27
Breaking changes
-
create_qmd():filenameis now the first argument and has no default – it must be supplied explicitly.pathis now the second argument and defaults to".", allowing natural calls likecreate_qmd("analysis.qmd"). -
write_by_group(): sanitized output filenames now use-(dash) as the separator instead of_(underscore), consistent with the package convention that file names use dashes. Existing workflows that reference output paths by name will need to update accordingly. -
init_project(): thefile_pathargument has been renamed topathfor consistency withcreate_qmd()and the broader package API. Calls usingfile_path =by name will error; positional calls are unaffected.
New features
Added
generate_kb_xml()to produce UW-Madison KB-importable XML files from rendered Quarto documents. Extracts metadata from the.qmdYAML header and re-renders with embedded resources for self-contained import.create_qmd(): addeduse_purlargument (defaultTRUE) that scaffolds a_quarto.ymlpost-render hook and apurl.Rscript for extracting R code from rendered documents intoR/.
Bug fixes
-
init_project(): now runsrenv::snapshot()and creates.renvignoreafterrenv::init(), ensuring the lockfile is populated and.qmdfiles are excluded from dependency scanning at project creation time. -
create_qmd():_quarto.ymlis now copied frominst/templates/rather than written from a hardcoded string, so changes to the template are reflected automatically. -
create_qmd():purl.Ris now correctly placed inR/instead of the project root, consistent with_quarto.ymlcallingRscript R/purl.R. -
create_qmd(): fixed YAML boolean serialization whenyaml_datais supplied.yaml::as.yaml()was convertingtrue/falsetoyes/no, which Quarto does not recognize. A custom handler now forces unquotedtrue/falseoutput. -
inst/templates/purl.R: replacedQUARTO_DOCUMENT_PATHenvironment variable approach withfs::dir_ls()glob scan, which works reliably regardless of how Quarto invokes the post-render script.
toolero 0.2.0
CRAN release: 2026-04-24
Breaking changes
-
create_qmd():pathis now a required argument with no default. PassingNULLor omitting it raises an error. Usetempdir()for temporary output. -
write_by_group():output_diris now a required argument with no default. PassingNULLor omitting it raises an error. Usetempdir()for temporary output. -
init_project():opennow defaults toFALSEinstead ofTRUEto avoid disrupting the current RStudio session in non-interactive contexts.
New features
- Added
detect_execution_context()to identify whether code is running in an interactive R session, aquarto rendercall, or a plainRscriptinvocation. Returns one of"interactive","quarto", or"rscript". - Added
create_qmd()to scaffold a new Quarto document from a reproducible template, including a sample dataset, UW-Madison branded assets, and three-context input resolution viadetect_execution_context(). Optionally pre-populates the YAML header from a user-supplied YAML config file. - Added
write_by_group()to split a data frame by a single grouping column and write each group to a separate CSV file. Filenames are derived from sanitized group values. Optionally writes amanifest.csvlisting output files, group values, and row counts.
toolero 0.1.1
New features
- Added
uw_brandingargument toinit_project(). WhenTRUE, creates anassets/folder in the new project and populates it with UW-Madison RCI branding files (styles.css,header.html,rci-banner.png).
